Expose protected operability metrics
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-07-29 23:52:06 +02:00
parent a336f594e7
commit 2bcda7f26f
3 changed files with 56 additions and 0 deletions

View file

@ -14,6 +14,15 @@ Use `structured_log_context(correlation_id=..., tenant=..., actor=...)` as the
base log envelope. Adapters should add transport details around that envelope
without dropping correlation id or tenant.
## Metrics
`GET /metrics` exposes Prometheus text containing the binary
`user_engine_ready` dependency gauge and bounded aggregate
`user_engine_records{kind=...}` counters. It does not expose identity values,
emails, tenant names, correlation identifiers, credential material, or raw
outbox payloads. Keep this route cluster-internal and authorize ingress only
from the selected monitoring workload.
## Outbox Drain
`outbox_diagnostics()` reports pending event count, event type counts, and the

View file

@ -91,6 +91,11 @@ class PortalApplication:
if path == "/readyz":
report = self.service.readiness()
return self._json(start_response, "200 OK" if report.ready else "503 Service Unavailable", _jsonable(report), correlation_id)
if path == "/metrics":
supplied = str(environ.get("HTTP_X_USER_ENGINE_PROXY_SECRET", ""))
if not secrets.compare_digest(supplied, self.trusted_proxy_secret):
raise AuthorizationDenied("metrics require the trusted workload marker")
return self._metrics(start_response, correlation_id)
if path in {"/login", "/oidc/start"}:
location = self.oidc_client.begin() if self.oidc_client else self.login_url
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
@ -536,6 +541,35 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o
start_response(status, [("Content-Type", "application/json"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
return [data]
def _metrics(
self, start_response: StartResponse, correlation_id: str
) -> list[bytes]:
report = self.service.readiness()
counts = self.service.operability_snapshot().metrics
lines = [
"# HELP user_engine_ready Whether runtime dependencies are ready.",
"# TYPE user_engine_ready gauge",
f"user_engine_ready {1 if report.ready else 0}",
"# HELP user_engine_records Durable logical record counts by kind.",
"# TYPE user_engine_records gauge",
]
for kind, count in sorted(counts.items()):
safe_kind = "".join(
character for character in str(kind)
if character.isalnum() or character in "_-"
)
lines.append(f'user_engine_records{{kind="{safe_kind}"}} {int(count)}')
data = ("\n".join(lines) + "\n").encode()
start_response(
"200 OK",
[
("Content-Type", "text/plain; version=0.0.4; charset=utf-8"),
("Content-Length", str(len(data))),
*self._security_headers(correlation_id),
],
)
return [data]
def _error(self, start_response: StartResponse, status: str, code: str, message: str, correlation_id: str) -> list[bytes]:
return self._json(start_response, status, {"error": {"code": code, "message": message, "correlation_id": correlation_id}}, correlation_id)

View file

@ -73,6 +73,19 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", home["status"])
self.assertIn(b"Sign in with KeyCape", html)
def test_metrics_expose_only_bounded_aggregate_state(self):
denied, _ = invoke(self.app, "/metrics", claims={}, marker="")
self.assertEqual("403 Forbidden", denied["status"])
result, payload = invoke(self.app, "/metrics", claims={})
self.assertEqual("200 OK", result["status"])
self.assertEqual(
"text/plain; version=0.0.4; charset=utf-8",
result["headers"]["Content-Type"],
)
self.assertIn(b"user_engine_ready 1", payload)
self.assertIn(b'user_engine_records{kind="users"} 0', payload)
self.assertNotIn(SECRET.encode(), payload)
def test_protected_route_rejects_untrusted_claim_header(self):
result, payload = invoke(
self.app, "/api/v1/me", claims=self.claims, marker="attacker"