Observability layer
Four decisions in this layer look wrong at first glance and are not. Each one is written down here because the obvious "fix" would break something that is not obvious.
Why the enforcement point was the urgent part
The control plane already had a Prometheus surface and an append-only decision record. The sidecar — the component that actually blocks calls, fail-closes when the control plane is unreachable, and runs in observe mode — emitted nothing.
That is not a monitoring gap, it is a safety gap. A sidecar that has been silently forwarding every call looks exactly like a healthy one. Everything else this layer does improves debugging; this part is the one where the absence of telemetry is itself a risk.
The sharpest expression of it is agentic_auth_pep_blocks_total{cause=...}. From the agent's
side, "policy refused this" and "the guardrail could not reach policy" are the same thing: a
refused call. One means the system is working. The other means it is broken and failing closed.
Nothing outside the enforcement point could tell them apart.
Why metrics stayed on Prometheus while traces and logs went to OpenTelemetry
Two telemetry stacks in one codebase is a smell, so it needs a reason.
The Phase 3 and Phase 4 metric names are a published contract. Integrators have written
dashboards and alerts against them. OpenTelemetry's PrometheusMetricReader cannot preserve
them:
- it appends
otel_scope_name,otel_scope_versionandotel_scope_schema_urllabels to every series by default, plus atarget_infometric — and adding a label keepssum by (...)working while silently breaking every exact-match query; - it registers a collector into a
CollectorRegistry, so it becomes a second writer into the same exposition with a second naming scheme; - it has no
PROMETHEUS_MULTIPROC_DIRequivalent, so multi-worker counts would regress to whichever worker answered the scrape.
Traces and logs have no installed base, so they had a free choice, and OpenTelemetry is the only one that makes "configuration only, no backend lock-in" true.
An OTLP-only integrator collects metrics through the Collector's prometheus receiver. That is
one line of Collector config and the documented path for exactly this case.
Why sampling needs a sampler and a processor
Refusals must survive the most aggressive sampling the layer permits. But Sampler.should_sample
runs when a span starts, and the outcome is not known until it ends. A sampler alone decides
too early.
So the work splits:
- At start, a custom sampler keeps every authorization span, overriding the caller's sampling decision. Without that override, an agent trace the integrator sampled out would take our deny span with it — and a deny nobody can see is the exact failure this layer exists to prevent.
- At end, a processor sheds a configurable fraction of allow spans, where the outcome attribute exists. Refusals never consult the ratio.
The cost is a partial trace: our span is exported, its parent is not. That is accepted. The
span still carries authz.action_id and resolves to the verified chain on its own; losing the
refusal would not be acceptable.
Collector-side tail sampling would also work, and was rejected: it moves a guarantee into infrastructure the SDK does not ship and cannot test, and the layer must not make a Collector mandatory.
Why a trace id is never evidence
Inbound trace context comes from an agent process the integrator may not control. It is untrusted input, and it gets three rules:
- The propagator is
tracecontextonly, pinned in code rather than read fromOTEL_PROPAGATORS. The SDK default includesbaggage, which would extract arbitrary attacker-controlled key/value data on the way in and re-inject it into every outbound call — a smuggling channel through our own telemetry. tracestateis discarded. Neither read nor propagated.- Nothing inbound reaches a decision. Not an outcome, not a deny reason, not an audit field, not a metric label.
What a caller can do is claim any trace id, and so place its spans inside someone else's trace. That is a correlation nuisance, not a security bypass, and it has one consequence worth stating plainly:
A trace id is a correlation handle. The audit record, keyed by
action_id, is the evidence.
Which is why every span carries an action_id: an operator who distrusts a trace resolves it
through the chain query and gets the verified answer from the append-only table.
Why one chain is one trace even with an uncooperative caller
The control plane and the enforcement point are separate processes with no shared context. When
no caller supplies a traceparent, both derive the same trace id from a value they already
hold — the session id — through a keyed HMAC:
trace_id = HMAC-SHA256(telemetry_key, "7:session:<session_id>")[:16]
No channel, no coordination, same trace.
The key matters: an unkeyed digest would make a trace id a reversible handle to a session id for anyone holding the telemetry.
:::note A planning decision that changed during implementation
The design called for a custom OpenTelemetry IdGenerator. Its generate_trace_id(self) takes
no arguments, so a generator cannot see the session id without process-global or contextvar
state. Building a synthetic non-recording remote parent from the derived id is deterministic
per request, needs no global state, and makes the trace id arrive from the parent context — so
the SDK never generates one for these spans, and the is_trace_id_random caveat the design
worried about does not arise at all.
:::
Why identities are pseudonymous by default
backend/app/services/metrics.py has carried one rule since Phase 3: labels never carry
identities, because that keeps audit identities behind the audit-reader check. Traces are where
that rule gets tested, since most of the value of a trace comes from knowing which agent.
The resolution: pseudonymous by default, with one explicit per-deployment opt-in whose state is itself exported as a gauge. A keyed, truncated HMAC is stable within a deployment — so telemetry can be grouped by agent — and not reversible by anyone holding only the telemetry.
The triage cost is one extra step: take authz.action_id to the audit API. That is the
deliberate price of keeping identities where they belong.
The leak path that was not obvious
The allowlist of permitted attribute keys catches a key someone added on purpose. It does not catch the keys the SDK adds on its own.
LoggingHandler._get_attributes copies every non-reserved LogRecord attribute into the
exported record — which is how extra= becomes structured attributes for free, and also how a
careless extra={"body": ...} would export a payload. And whenever exc_info is set, it adds
exception.message (as str(value.args[0])) and exception.stacktrace, the full formatted
traceback.
api/v1/authorize.py already called logger.exception() on three paths, and
api/v1/token.py on one. A traceback through token verification can carry a token fragment in a
frame-derived message. So those four sites lost their stack traces and record error.type
instead.
That is a real reduction in local debuggability, bought deliberately, and it is recorded in
specs/007-observability-layer/contracts/phase-3-4-deltas.md §6 — because a future reader will
otherwise be tempted to put logger.exception back.
Fail-open, against an audit log that fails closed
These two postures are opposite on purpose.
The audit log is fail-closed: POST /v1/authorize refuses to return an allow for a decision
it could not record. Evidence is mandatory.
Telemetry is fail-open: an unreachable, slow or rejecting collector changes no authorization outcome, and a telemetry setup failure leaves the process serving. A guardrail that refused to start because a collector was down would be an availability regression caused by an observability feature.
backend/tests/integration/test_telemetry_fail_open.py holds the second one to four injected
faults; test_audit_before_dispatch.py has held the first since Phase 3.
Why the verifier library takes a Protocol
app/verify/ is importable with only pyjwt[crypto], and
tests/unit/test_verify_imports.py enforces it. A resource server embeds it to guard its own
tools; it should not acquire an OpenTelemetry dependency for the privilege.
There is a second reason. trace.set_tracer_provider() is process-global and warns-and-ignores
on a second call. A library that installs telemetry in a host it does not own either hijacks
the host's pipeline or silently emits nothing. Accepting a sink the host constructed is the only
correct option — so TelemetrySink is a typing.Protocol, and the OTel-backed OtelSink lives
in app/observability/ where the integrator opts into it.
What this layer is not
The PRD lists "not a full agent observability/tracing platform — we only emit primitives observability tools need" as a non-goal. So:
- no SDK-served console or dashboard page; the dashboards ship as importable JSON;
- no telemetry backend, storage or hosted service;
- no agent-level or model-level observability — prompts, completions, token usage, model cost. This layer observes authorization, not inference.
What it does ship is the enumerated surface, the artifacts, and a test that fails the build when a renamed metric would have left a dashboard quietly empty.