Observability
Trace PAI runs, model steps, tools, and runtime work with OpenTelemetry.
PAI runtime telemetry records spans for runs, model steps, tool preparation and execution, suspended work, and runtime failures. Use it when you need traceable agent execution rather than application-level logging.
Install
pnpm add @pai/observabilityFor Langfuse export:
pnpm add @pai/langfuseConfigure Telemetry
// server/telemetry.ts
import { createPaiOpenTelemetry } from "@pai/observability";
export const telemetry = createPaiOpenTelemetry({
serviceName: "assistant-api",
contentCapture: {
recordRunInputs: false,
recordRunOutputs: false,
recordModelOutputs: false,
recordToolInputs: false,
recordToolOutputs: false,
recordToolData: false,
recordErrorDetails: false,
},
});Pass it to the PAI runtime registry:
const pai = createPai({
agents: { main: assistantAgent },
telemetry,
scopeKey: (identity) => identity.workspaceId,
telemetryAttributes: ({ identity, userKey }) => ({
"app.user.id": userKey,
"app.workspace.id": identity.workspaceId,
}),
});
createPaiHonoReceiver({ pai, resolveIdentity });Only enable content capture after reviewing what may leave the app boundary.
Run inputs and outputs, model outputs, tool inputs and outputs, and incremental
tool data can contain user content, retrieved documents, instructions, or
application payloads. Each object-form setting is independent and defaults to
false.
Error detail is a separate privacy boundary. PAI records safe error code,
diagnostic reference, operation, and status metadata by default. Original
exception messages and stacks require an explicit trusted error-detail policy;
set contentCapture.recordErrorDetails: true only when that sink may retain
them. Enabling individual model or tool capture fields does not enable raw
error details. The contentCapture: true shorthand deliberately enables every
PAI-owned content field, including raw error details.
PAI can record the bounded user input and final user-visible assistant text on
the owned pai.run.execute span with recordRunInputs and
recordRunOutputs. recordModelOutputs separately records post-transform
model-step text or tool calls on pai.model.step; results returned later by
local tools stay behind the tool-output setting. PAI does not label the run's
user message as each model step's input, because later steps may actually
receive tool results or lifecycle-patched context. Detailed provider-native
telemetry remains independently configured through the AI SDK or provider
integration you use.
Use onRuntimeError and onHttpError for application-level logging and error
reporting. OpenTelemetry is not required to learn why a request failed. See
Error Handling.
Langfuse
import { createLangfuseSpanProcessor } from "@pai/langfuse";
import { createPaiOpenTelemetry } from "@pai/observability";
const langfuse = createLangfuseSpanProcessor("env");
export const telemetry = createPaiOpenTelemetry({
serviceName: "assistant-api",
contentCapture: {
recordRunInputs: true,
recordRunOutputs: true,
recordModelOutputs: true,
},
spanProcessors: langfuse ? [langfuse] : [],
});Run payload capture populates Langfuse trace input and output fields directly from the root span. Model-output capture additionally populates each model observation's output. Enable only the fields your Langfuse project is approved to retain.
For "env", set PAI_LANGFUSE_PUBLIC_KEY and
PAI_LANGFUSE_SECRET_KEY. PAI_LANGFUSE_BASE_URL defaults to
http://localhost:7320, and PAI_LANGFUSE_OTLP_TRACES_ENDPOINT overrides the
derived OTLP endpoint.
Runtime Identity
Use userKey and telemetryAttributes to attach application identity without
coupling traces to raw identity objects.
const pai = createPai({
agents: { main: assistantAgent },
telemetry,
userKey: (identity) => identity.userId,
scopeKey: (identity) => identity.workspaceId,
telemetryAttributes: ({ identity, userKey, threadId }) => ({
"app.user.id": userKey,
"app.workspace.id": identity.workspaceId,
"app.thread.id": threadId,
}),
});
createPaiHonoReceiver({ pai, resolveIdentity });The hook can also resolve labels asynchronously from stable identity ids:
telemetryAttributes: async ({ identity, userKey }) => {
const labels = await directory.getLabels({
workspaceId: identity.workspaceId,
userId: identity.userId,
});
return {
"app.user.id": userKey,
"app.workspace.id": identity.workspaceId,
"app.workspace.name": labels.workspaceName,
};
},PAI resolves the hook once per execution, after the run is claimed and lease
protection is running. The root run span and that execution's model, tool,
title, and reasoning-summary spans share one snapshot. A resumed execution
resolves again, even though its logical runId stays the same. Queued sends
resolve when they execute using their captured identity, and concurrent
executions keep separate snapshots.
An asynchronous lookup runs beside the work PAI does to start an execution —
building runtime context, prepareRun, thread reads, generation start — and is
awaited only at the span that needs its labels. A lookup that finishes inside
that window costs no run latency; a slower one delays the first model step by
whatever it has left, up to one second, once per execution. Nothing other than a
span waits for it: lease renewal, queue admission, durable finalization, and the
run span's end are never held behind enrichment. PAI does not deduplicate across
executions, so memoize inside your resolver if the lookup is expensive: a
delegating agent resolves once per child execution.
Label under a prefix your application owns. pai. is reserved for runtime
correlation, and keys returned under it are dropped — app.workspace.name
works, pai.workspace.name is discarded and named in
pai.telemetry.attributes.reserved on every span of that execution so the
mistake is visible in the trace rather than silent.
clientData is absent when the executing agent declares no client-data schema,
which a delegated sub-agent run can hit even in a registry whose other agents
declare one, so guard it rather than assuming the shared registry type.
Attributes remain in memory for the execution and its associated background work. They are not added to identity, queued context, run records, or client messages. Keep identity limited to the trusted fields needed for application behavior; directory labels needed only for telemetry can be looked up here. The callback receives detached identity and client-data values, and PAI copies returned attribute arrays per span. Only explicitly returned fields leave through the telemetry provider, so choose labels appropriate for that destination.
Enrichment is scoped to executions. Reads, control operations, standalone
pending-tool-data writes, and receiver-owned spans such as pai.http.operation
do not resolve the hook — a client tool can write tool data on every progress
update, so PAI never puts an application lookup in front of that path. Those
spans still carry PAI's correlation fields and the pai.user.id derived from
identity; add labels to them from your own telemetry provider if you need them.
Disabled telemetry skips the hook entirely. A synchronous throw or rejected
promise omits app attributes and records
pai.telemetry.attributes.error: "resolver_failed". A span waits at most one
second for enrichment; expiring records "resolver_timeout" and proceeds
without app attributes. Run cancellation also stops waiting. Lease acquisition
and renewal are never held behind enrichment. That second is measured from the
moment a span needs the labels, not from when resolution started, so a lookup
slower than the budget is still used if PAI's own setup outlasted it. It is a
backstop against a resolver that never settles, not a tuning dial, and it bounds
only PAI's wait: use application-owned query timeouts or cancellation where
appropriate. Late results are ignored, and a synchronous callback that blocks the
JavaScript event loop cannot be preempted by a timer.
The root span starts before enrichment to preserve execution timing, and its app
attributes are attached by the first span that needs them, or before the run span
ends if nothing did. They are therefore available to exporters but not to a
sampler that decides at root-span creation, and a run whose lookup had not
finished by the time the run ended carries runtime correlation only. PAI keeps
its own correlation fields for agent, thread, run, tool, and model work, and
because pai. is reserved, application attributes can never override them.
pai.run.id remains stable for the logical run across execution episodes,
including a resume after a pending action. Run spans also record
pai.run.initiator, pai.run.admission.mode, and pai.run.input.count, so
direct, queued, steered, triggered, regenerated, and merged-input work can be
grouped without inspecting captured content.
PAI does not persist telemetry span or trace context in run records. A resumed
execution can therefore begin a new trace while retaining the same
pai.run.id correlation attribute. This keeps telemetry observational and
prevents an exporter or telemetry provider from becoming part of durable run
correctness.
The owned pai.run.execute span includes pai.run.input and pai.run.output
only with the corresponding capture settings. Failed runs omit pai.run.input
so its final visibility read cannot delay durable failure handling. The owned
pai.model.step span includes safe model/provider identity, tool counts, finish
reason, and token usage by default; recordModelOutputs adds the post-transform
step result as pai.model.output. Detailed provider-native model spans and wire
prompts are outside PaiTelemetryProvider; configure those independently
through the model or AI SDK integration in use.
When input capture is enabled, PAI reads the run's complete ordered
inputMessageIds set and records only transcript-visible inputs. Hidden
lifecycle context does not leak into pai.run.input, even when its
provider-facing role is "user".
Telemetry Cannot Affect Application Work
Telemetry is observational. A provider, exporter, span, context operation, or cleanup failure can cause missing diagnostics, but cannot fail a request, strand an admitted run, replace an application error, or repeat a model, tool, or storage callback.
Custom PaiTelemetryProvider implementations must enter withSpan and
withContext callbacks synchronously, exactly once, before the provider method
returns. They must preserve the callback's exact result or rejection.
Provider-owned failures must not escape. PAI also isolates telemetry-owned
failures and falls back to no-op instrumentation at runtime boundaries.
Do not put authorization, persistence, retries, or other application semantics inside a telemetry provider.