OpenTelemetry
permdock/otel records a span and a counter per permission check with decision attributes, behind a structural logger interface, with @opentelemetry/api as an optional dependency that is never required.
Status: planned Phase: 2
permdock/otel subscribes to permdock.on('decision') and emits one span per check plus a decision counter, carrying the outcome, permission key, resource id and actor as attributes. It depends on @opentelemetry/api only if the host app already has it installed; without it, the adapter degrades to the structural logger interface and nothing else changes.
Purpose
Audit needs the result, not just the attempt: permix's audit hook lacked the outcome (permix #38), and @zap-studio/permit showed the right instrumentation shape (span per check, decision attribute, counter) but made @opentelemetry/api a required peer, which every consumer without OpenTelemetry then had to install. PermDock keeps core free of runtime dependencies and puts telemetry in an adapter that resolves the API at runtime. The events it consumes are the same Decision events every other adapter emits, so MCP refusals, AI SDK approvals and HTTP denials all appear in one trace.
API
import { createPermDock } from 'permdock'
import { instrument } from 'permdock/otel'
const permdock = await createPermDock(policy, user)
instrument(permdock, {
tracer: 'permdock', // tracer / meter name; default 'permdock'
logger, // optional structural logger: { debug, info, warn, error }
attributes: (event) => ({ 'app.tenant': event.subject?.orgId }), // extra attributes per check
redact: ['subject.email'], // attribute paths never recorded
})Server adapters accept the same options under otel so instrumentation applies to every request-scoped instance:
import { createPermDock } from 'permdock/hono'
export const { permdock, protect } = createPermDock(policy, { subject: (c) => c.get('user'), otel: { logger } })Recorded per check:
| Signal | Name | Attributes |
|---|---|---|
| Span | permdock.decide | permdock.outcome (granted / denied / approval-required), permdock.permission (key), permdock.scope, permdock.resource.type, permdock.resource.id, permdock.subject.id, permdock.actor.id, permdock.actor.kind, permdock.delegation.scopes, permdock.matched.role, permdock.denials.count, permdock.validate |
| Counter | permdock.decisions | permdock.outcome, permdock.permission, permdock.adapter |
| Histogram | permdock.decide.duration | permdock.outcome, permdock.permission |
| Log | permdock.decision | same fields as the span, through the structural logger |
The logger interface is a type-only structural contract (debug, info, warn, error taking a message and an attributes object), so console, pino, winston or a test spy all satisfy it without an adapter.
In an agent, the framework or the model SDK usually opens an execute_tool span from the OpenTelemetry GenAI semantic conventions around each tool call, with gen_ai.tool.name and gen_ai.tool.call.id. permdock.decide nests under it because the listener starts its span in the current context, so LLM-observability backends (Langfuse, LangSmith, Braintrust, Datadog LLM Observability, Sentry, PostHog) show the decision as a child of the tool call with no PermDock-specific integration. The adapter also copies gen_ai.tool.name and gen_ai.tool.call.id from the parent span onto its own when present, so a denial can be grouped by tool without joining spans. The GenAI conventions are still at Development status (the execute_tool span and its attributes are not yet stable), so these two attribute names are pinned in the adapter to a named semantic-conventions release (recorded here when Phase 2 starts) and revisited when the conventions stabilise; none of PermDock's own permdock.* attributes depend on them. This is the build draft posture of ADR 0025: the pinned names are the only draft content, the permdock.* attributes are the stable twin, and a pin bump ships with fixtures and a changeset.
Request lifecycle
instrument(or theoteloption) registers anon('decision')listener on the instance.- On each
can,decide,assert,filterorsimulatecall the instance emits a decision event containing the Decision, the permission, the resource identity (from the resourceidfield), subject, actor, delegation and timing. - If
@opentelemetry/apiresolves at runtime, the listener starts and ends a span under the current context (so the check nests under the HTTP or tool span the framework created), records the counter and histogram, and adds an event to the span ondeniedwith the denial reasons. - If it does not resolve, the listener writes a single structured log line through
logger; when no logger is given, nothing is written and the listener costs one function call. simulateemits one parent span with a child span per item;filteremits one span withpermdock.filter.totalandpermdock.filter.kept.
The adapter never awaits: recording is synchronous and cannot delay a decision.
What it validates
- It validates nothing about permissions; it observes. In development it warns when
redactpaths do not match any attribute and when@opentelemetry/apiis installed but no provider is registered (spans would be no-ops). - Attribute hygiene: resource ids and subject ids are recorded, resource payloads and condition values are not;
redactremoves anything else the app considers sensitive.
How denials surface
- Span status is left
UNSETfordeniedandapproval-required: a denial is a correct decision, not an error. SeterrorOnDeny: trueto mark denied spans asERRORfor alerting. - A
permdock.deniedspan event listsroleandreasonper denial and thealternativeskeys. PermDockValidationError(boundary validation failure) is recorded as a span exception with the issue count, and the check counts asdeniedin the counter.- Because the same event feeds the audit hook, an app can keep audit in its database and traces in its collector without duplicating decision logic.
Example app
None. The Hono example (apps/examples/hono) and the MCP example (apps/examples/mcp-server) enable otel and ship a console exporter configuration so traces are visible locally.
Related standards
- Audit and observability: the
on('decision')event contract. - Decision 0015: why the OpenTelemetry API is optional and lives outside core.
- Research: zap-studio permit: the instrumentation pattern adopted and the required-peer lesson.
Open questions
- Attribute naming:
permdock.*versus aligning with OpenTelemetry semantic conventions for authorization if such conventions stabilise. - Whether the histogram is worth its cardinality by default or should be opt-in.
- How to correlate an
approval-requiredspan with the later approval replay (span link byDecision.tokenis the current idea). - Whether the structural logger should be exported from core so other adapters can log without importing
permdock/otel.
OpenAPI
permdock/openapi emits OpenAPI 3.2 security schemes, per-operation security and x-permdock-permissions from permission references, hooks into hono-openapi, @hono/zod-openapi, oRPC and trpc-to-openapi, hands an Overlay to next-openapi-gen and other producers, and imports OpenAPI documents into a catalog.
Remote PDP
The permdock/pdp provider is an AuthZEN policy enforcement point client that asks a remote decision point such as Cerbos, Topaz, Keycloak, Axiomatics, PlainID or OPA, maps requests and responses to PermDock decisions, fails closed on anything unknown, and bridges to OpenFGA or SpiceDB relation graphs.