# OpenTelemetry

Source: https://permdock.com/docs/adapters/otel

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 [#purpose]

Audit needs the result, not just the attempt: permix's audit hook lacked the outcome ([permix #38](https://github.com/letstri/permix/issues/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](/docs/concepts/decisions) events every other adapter emits, so MCP refusals, AI SDK approvals and HTTP denials all appear in one trace.

## API [#api]

```ts
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](/docs/guides/extending#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:

```ts
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:

| 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.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) |
| Counter | `permdock.decisions` | `permdock.outcome`, `permdock.permission`, `permdock.adapter` |
| Histogram | `permdock.decide.duration` (unit `s`) | `permdock.outcome`, `permdock.permission`; recorded by `withOtel` and the adapter `otel` option only |
| 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, 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](https://opentelemetry.io/docs/specs/semconv/gen-ai/) 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](/docs/standards/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 [#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 [#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 [#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](/docs/concepts/audit-and-observability), an app can keep audit in its database and traces in its collector without duplicating decision logic.

## Why [#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](/docs/concepts/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 [#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`.

## Related standards [#related-standards]

* [Audit and observability](/docs/concepts/audit-and-observability): the `on('decision')` event contract.
* [Installation](/docs/getting-started/installation): the OpenTelemetry API as an optional peer outside core.
* [Research: landscape](/docs/research/landscape): the zap-studio permit instrumentation pattern adopted and the required-peer lesson.
