Observability Hardening (Phase 7)¶
Status: COMPLETE — all six slices are implemented and verified. The safety-critical half (§7.3 structured logging, §7.4 bounded metrics, §7.6 failure isolation) landed first; the OpenTelemetry span layer (§7.1, §7.2) and the Docker collector stack (§7.5) followed, and the collector section below records what a real collector stored rather than what a mock was told.
Source: src/jdlib/security/telemetry.py (new), src/jdlib/security/redaction.py
(extended: header and cookie field names)
Tests: tests/unit/security/test_telemetry.py (26)
1. Structured logs (§7.3)¶
security_log_record(...) builds a record with the directive's fields in a stable
order — timestamp, level, service, environment, request_id, correlation_id,
trace_id, principal_id, tenant_id, event, security_code, operation, outcome — and
fills the identifiers from the ambient security context when the caller does not
supply them, so one request's logs, spans and audit rows join on the same ids.
The API surface is the protection: it takes no request object, no headers and no
token, so there is no parameter a raw credential could be passed to. Every string
that reaches a record passes the shared redaction set (the same one behind the
error responses and audit metadata), a field whose name is disqualifying
(authorization, cookie, set-cookie, signing_key, …) is redacted whatever
its value, reserved names cannot be spoofed, and non-scalars are refused outright
rather than serialized into a log line.
Service and environment come from JDLIB_SERVICE_NAME / JDLIB_ENVIRONMENT so a
deployment can label its own records without a code change.
2. Bounded metrics (§7.4)¶
SecurityMetrics counts the directive's metrics and validates every label
before any backend sees it:
- a label outside the bounded allow-list is refused,
- an unbounded identifier label (
tenant_id,user_id,request_id,token,session_id, …) is refused with an explicit reason, - a label value that looks like an identifier (a uuid, an over-long string) is
refused too — smuggling
tenant_idin as{"reason": "<uuid>"}is the same cardinality mistake with extra steps.
Cardinality is the failure mode that kills an observability stack quietly, so the check is in the write path, not in a code review.
3. Telemetry failure cannot disable security (§7.6)¶
safe_emit(...) runs an emission and swallows a telemetry error, reporting it
once per distinct message so a collector outage cannot become a log flood. The
test that matters is the realistic one: a decision path increments a counter, the
metrics backend raises, and the decision still completes. The inverse — a metrics
outage becoming an authorization outage — is the failure this arrangement exists
to prevent.
4. Trace context and spans (§7.1, §7.2)¶
parse_traceparent is strict: lowercase hex only, exactly four fields, non-zero
ids, and an unsupported version is refused rather than guessed at (the
recommendation's forward-compatibility rule). A trace id half-understood from a
malformed header silently breaks the join between a request, its logs and its
audit rows — the header's only purpose — so refusing is the safer failure.
request_ids_from_headers derives request, correlation and trace ids from
inbound headers and generates the first two when they are absent, because a
request with no ids cannot be joined to anything after the fact.
Spans carry dimensions, not identifiers. A span is not aggregated, so the
cardinality argument that governs metrics does not apply — but the identifiers
that make a trace findable already live in the log record and the audit row,
joined by trace_id. The attribute allow-list is therefore explicit
(security.*, plus operation/decision/principal.kind/auth.method/gateway.name/
tenant.strategy/outcome/mode/kind/status/reason/result), an identifier or
credential-shaped attribute is refused with that reason, and a string value still
passes the shared redaction set.
The layer is duck-typed against the OpenTelemetry API rather than importing it:
the library is inert without a tracer, an application that has one passes its
own, and the core never grows an optional import. Failures are swallowed in both
directions — a collector outage must not disable security, and a failing span
must not mask the exception the body is raising (the context manager returns
None, never True).
5. Docker observability stack (§7.5)¶
tests/infra/otel/ brings up a real OpenTelemetry Collector (contrib, OTLP
gRPC + HTTP receivers, batch processor, file + Prometheus exporters) the same way
the Kong harness does: a start.sh, a stop.sh, config in the repo, and an
environment file the tests read. The file exporter exists for the assertion that
matters — what the collector received, not what a mock was told.
The library ships no OTLP exporter (the core grows no optional import), so the
test plays the exporter role: a duck-typed tracer using the library's own
security_span API over OTLP/HTTP, then it reads back what the collector stored.
Four checks, all green against the real collector:
| Check | Result |
|---|---|
| A security span reaches the collector and is stored (trace id, name, attributes verified in the exporter's output) | pass |
A secret in an allowed attribute never reaches the collector end-to-end (s3cr3t-pw, db.internal absent from stored telemetry) |
pass |
| The collector's own metrics are exposed (it is collecting, not merely listening) | pass |
| The Prometheus exporter endpoint is serving | pass |
Skips are clean when the harness has not been started, but a listed yet unreachable collector is a failure, not a skip: a green run that never reached the collector would be a lie.
Two bugs were caught by running it rather than by review: the second span was constructed but never ended (so nothing was exported — OTel exports on span end), and the collector's internal metrics needed their own endpoint and port mapping before "it is collecting" could be asserted at all.
6. Phase 7 gate (§7.6)¶
| Verification | Where it is enforced |
|---|---|
| No secret leakage | test_telemetry.py (6 secret shapes × every field, sensitive field names, reserved names), test_otel_infra.py (end-to-end through the collector) |
| No unexpected high-cardinality metrics | test_telemetry.py — unbounded label names and identifier-shaped label values are refused in the write path |
| Trace propagation works | test_tracing.py (W3C parsing/formatting, 13 malformed shapes refused) + the collector test (real traceparent-shaped ids arriving) |
| Failure paths are observable | test_audit_events.py / test_audit_emitters.py emit denials and errors as first-class events; safe_emit keeps a telemetry outage from hiding them |
| Telemetry failure does not disable security | test_telemetry.py::test_a_broken_metric_sink_cannot_break_an_authorization_decision, test_tracing.py (failing tracer, failing span end) |
Gate: 1089 passed, 6 skipped, ruff and mypy clean (96 source files). The
six skips remain the documented Tyk limitation; the observability harness adds
none — it ran against a real collector.