Decision events
Setobservability.onDecision when you create the instance. It fires once per decision from authorize, authorizeByToken and policy.evaluate.
Event fields
The event never carries tokens or the raw
arguments of the call. Tool arguments stay in the audit row, where your retention rules apply.
delegationDepth costs a lineage lookup, so it is resolved only when a delegated grant took part in the decision or a hook already loaded the lineage. An agent allowed by its own permissions reports 0, even if it also holds delegations.Delivery guarantees
- The listener runs in a queued microtask after the decision is made.
authorizenever waits for it, and the decision cannot change because of it. - A throw, or a returned promise that rejects, is swallowed, counted, and logged at
warn. It never reaches the caller. - A listener that runs longer than
slowListenerMs(default 5, envTHEAUTH_OBSERVABILITY_SLOW_MS) is counted as slow. The call itself is not interrupted, so keep the listener cheap: push the event onto a queue or a metrics client and return. - Events from different calls can arrive out of order. Sort on
atif you need order. - Without a listener, nothing is built or queued. With one, the cost on the request path is a closure and a
queueMicrotask. In a local micro benchmark (in-memory SQLite, 3000 sequentialauthorizecalls) the difference was within noise, about 105 microseconds per call either way.
Logger
Pass alogger to receive the SDK’s diagnostics. The default is a no-op, so the library never writes to your console on its own.
beforeAuthorize hook times out or fails, and when an onDecision listener throws or runs slow. A logger that throws is ignored.
OpenTelemetry
@glinr/theauth-plugin-telemetry maps decision events to spans and metrics. It uses structural types, so it adds no @opentelemetry dependency: pass the tracer and meter you already have.
theauth..
Metrics
Metric attributes stay low cardinality on purpose: no agent id, no resource, no action.
Spans
One span per decision, backdated to cover it:
theauth.authorize, or theauth.policy.evaluate for policy.evaluate. Attributes: theauth.decision, theauth.decision.code, theauth.agent.id, theauth.owner.id, theauth.delegation.depth, theauth.guard.name, theauth.source, theauth.action, theauth.resource, theauth.cache_hit, theauth.audit.id, theauth.hook.outcome, theauth.hook.duration (ms). Set includeResource: false to drop the resource, or use redactResource in core.
Span events theauth.hook.timeout, theauth.hook.error and theauth.hook.fail_open mark a hook that did not finish. Timeouts and errors also set the span status to ERROR. A plain deny does not: a denial is a correct answer, not a fault.
Useful queries: denials by code (sum by (theauth_decision_code) (rate(theauth_decisions[5m]))), p99 of theauth.decision.duration split by theauth.source, and spans with a theauth.hook.timeout event to find a slow guard.
Dashboard
The audit page has a Code column and a code filter. Each row has a “Why” button that opens a drawer callingPOST /explain on the HTTP handler for that agent, action and resource at the row’s time. It shows the code, the matched grant, each constraint, the hook outcome and the closest grants that missed. The explanation is a fresh read-only evaluation against today’s rules, so it can differ from the recorded decision if permissions changed since.
The code filter is sent as a codes query parameter and applied again to the loaded page, so it works even when your audit endpoint ignores codes.
Related
- Lifecycle hooks for
beforeAuthorize, timeouts and the fail-closed policy that producehook_timeoutandhook_error. - Audit trail for the stored record behind each
auditId.