PermDock
Adapters

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.

Draft posture: build (OpenTelemetry GenAI semantic conventions 1.37.0; copies gen_ai.tool.name and gen_ai.tool.call.id only)

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 { withOtel } from "permdock/otel";

const permdock = withOtel(await createPermDock(policy, user), {
  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
});

withOtel(instance, options) returns an instrumented instance: it times can, decide, assert, filter, pick and actions, so spans carry a real start and end time and the histogram records durations, and it wraps the instances tenant(), team() and derive() return. It is built on wrapPermDock, which an adapter's wrap option uses too (wrapping an instance). instrument(instance, options) only attaches an on('decision') listener to the instance you pass. A listener sees a decision after it is made, so its spans have no duration and it records no histogram; use it when the instance is not yours to replace.

Server adapters take an otel function that wraps every request-scoped instance. Passing withOtel in yourself keeps the instrumentation out of apps that do not use it:

import { createPermDock } from "permdock/hono";
import { withOtel } from "permdock/otel";

export const { permdock, protect } = createPermDock(policy, {
  subject: (c) => c.get("user"),
  otel: (permdock) => withOtel(permdock, { logger }),
});

Recorded per check:

SignalNameAttributes
Spanpermdock.decidepermdock.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.matched.name (the grant's declared name), permdock.obligations (app obligation names, comma-joined), permdock.denials.count, permdock.validate, permdock.token (the Decision token on granted and approval-required; an ask and its resume share it)
Counterpermdock.decisionspermdock.outcome, permdock.permission, permdock.adapter
Histogrampermdock.decide.duration (unit s)permdock.outcome, permdock.permission; recorded by withOtel and the adapter otel option only
Logpermdock.decisionsame 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, and core exports no logger of its own. The permdock.* attribute names are PermDock's own namespace and stay stable whatever authorization conventions OpenTelemetry later adopts. The histogram is on whenever withOtel or the adapter otel option is used; instrument records none.

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 semantic-conventions release 1.37.0 and revisited when the conventions stabilise; none of PermDock's own permdock.* attributes depend on them. This is the build draft posture of watch list: 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

  1. withOtel (and the adapter otel option, which uses it) wraps the instance and times each check; instrument registers an on('decision') listener on the instance.
  2. On each can, decide, assert, filter or simulate call the instance emits a decision event containing the Decision, the permission, the resource identity (from the resource id field), subject, actor, delegation and timing.
  3. If @opentelemetry/api resolves 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 on denied with the denial reasons.
  4. 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.
  5. simulate emits one parent span with a child span per item; filter emits one span with permdock.filter.total and permdock.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 redact paths do not match any attribute and when @opentelemetry/api is 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; redact removes anything else the app considers sensitive.

How denials surface

  • Span status is left UNSET for denied and approval-required: a denial is a correct decision, not an error. Set errorOnDeny: true to mark denied spans as ERROR for alerting.
  • A permdock.denied span event lists role and reason per denial and the alternatives keys.
  • PermDockValidationError (boundary validation failure) is recorded as a span exception with the issue count, and the check counts as denied in 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.

Why

  • No log record per denial. The span already carries the outcome and a permdock.denied event with the reasons, and the DecisionSink holds the durable record. An OpenTelemetry log record per denial would be a third copy of the same event on the path that samples and drops most, and would tempt teams to treat logs as evidence (audit and observability). The structural logger line exists only for processes with no OpenTelemetry API.
  • The approval token is an attribute, not a span link. The ask and the resume happen in different requests, often hours apart and in different traces. A span link needs the earlier span context, which the resuming request does not have; the token is already the join key the sink, the ApprovalStore and the approval events share. Searching the tracing backend for one permdock.token value finds both spans. The token is a hash bound to permission, resource, subject and actor; add it to redact if a backend should not see it.

Example app

None. The Hono example (apps/examples/hono) and the MCP example (apps/examples/mcp-server) enable otel with a structural logger so decision lines are recorded locally without requiring @opentelemetry/api.

Last updated on

On this page