Skip to content

Observability architecture

Three signals, one rule: nothing leaves the process that has not been allow-listed. Telemetry is useful exactly to the degree that it can be trusted not to carry a secret or a tenant's data.

The signals

Signal Module What it carries What it must not carry
traces jdlib.security.tracing (SecuritySpan, security_span, TracerLike) request ids, the route, the decision, the outcome, durations arguments, credentials, tenant data
metrics jdlib.security.telemetry (SecurityMetrics, safe_emit) counters and durations keyed by bounded labels high-cardinality or caller-controlled labels (MetricLabelError refuses them)
audit jdlib.security.audit (emitter, emitters, events, access) who asked, what was decided, for which tenant, when the data the operation touched
flowchart LR
    req["request / tool call"] --> span["SecuritySpan<br/>allow-listed attributes"]
    req --> metrics["SecurityMetrics<br/>bounded labels"]
    req --> decision["authorization decision"]
    decision --> audit["AuditEvent<br/>jdlib.security.audit"]
    span --> otel["OTel exporter<br/>the deployment's"]
    metrics --> otel
    audit --> sink["DatabaseAuditSink<br/>or the deployment's sink"]
    audit --> export["export surface<br/>compliance evidence"]
    red["jdlib.security.redaction<br/>is_sensitive_key, redact_text"] -.->|"last line of defence"| span
    red -.-> sink

The allow-list is the design

jdlib.security.tracing carries an explicit allow-list of span attributes: an attribute not on it is dropped rather than exported. That is a stronger rule than "do not log secrets", because it does not depend on every future caller remembering it — a new attribute is invisible until someone adds it deliberately. The metrics side has the same posture: MetricLabelError refuses a label that is not bounded, so a tenant slug or an identifier cannot become a metric dimension by accident.

Audit as evidence

The audit vocabulary is written for the decision, not for the data: an event names the principal, the action, the tenant, the outcome and the request identity, and it is written whether the outcome was an allow or a refusal. Two sinks ship — the database sink and an in-memory one for tests — and the compliance surface reads them through jdlib.security.audit.export and jdlib.security.compliance.evidence.

A denial writes to the audit plane from both surfaces: the MCP boundary writes three events for a refused tool call (the call, the decision, the outcome) where the HTTP path writes one — a difference the enterprise example asserts rather than glosses, because it is a real difference in what an incident can reconstruct.

What an incident reads

Question Where the answer is
was the request authenticated, and as whom? the audit event's principal and method
which tenant, and how was it resolved? the audit event's tenant, the resolver named in the span
was it allowed, and by what? the decision event: allowed, denied, or degraded
did the handler run? the span's outcome plus the audit event — a denial has no handler span
was the dependency healthy? the breaker's state transition metrics and /readyz
was anything dropped? the exporter's own counters, plus the audit sink's failure metric

The redaction floor

jdlib.security.redaction.is_sensitive_key and redact_text are the last line of defence: they run over a record before it is emitted, so a value that reached a log line despite the allow-list is still replaced. They are a floor, not the design — the design is that the attribute never qualified in the first place.