Telemetry

Pass a tracer, get GenAI spans for every model and tool call.

Recipes instruments the session for you. Pass a tracer and every model call, tool call and agent run is emitted as OpenTelemetry GenAI semantic-convention spans, so a recipe is observable in any OTLP backend without writing instrumentation and without a vendor SDK in the package.

host.ts
import { trace } from "@opentelemetry/api";

const handle = await createAgentSession({
  recipe,
  agentName,
  otel: {
    tracer: trace.getTracer("my-runtime"),
    // Omit and one is generated. Supply it to join runs into a thread.
    meta: { conversationId },
  },
});

// dispose() detaches instrumentation and finalizes any open span.
await handle.dispose();

The span tree

One invoke_agent span per run, with a chat span per model call and an execute_tool span per tool call nested under it.

invoke_agent acme-research        one per run
  chat anthropic/claude-sonnet-4-6   one per model call
  execute_tool read                  one per tool call
  execute_tool agent                 a delegated run, from the lead's side
invoke_agent reviewer              the delegated run itself

A delegated run inherits the conversation id and derives its own agent identity, so one trace shows the lead and everything it orchestrated while gen_ai.agent.name still tells them apart. If you already wrap a user turn in your own span, pass runSpans: false and getParentContext to keep your topology and let the chat and tool spans land under it.

What each span carries

Chat spanAttribute
Identitygen_ai.conversation.id, gen_ai.agent.id, gen_ai.agent.name
Modelgen_ai.provider.name, gen_ai.request.model, gen_ai.response.model
Requestgen_ai.request.temperature, .max_tokens, .reasoning.level, .stream
Contentgen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions, gen_ai.tool.definitions
Resultgen_ai.response.id, gen_ai.response.finish_reasons, gen_ai.conversation.compacted
Usagegen_ai.usage.input_tokens, .output_tokens, .reasoning.output_tokens, .cache_read.input_tokens, .cache_creation.input_tokens, gen_ai.cost.usd
Latencygen_ai.response.time_to_first_chunk

An execute_tool span carries the same identity attributes plus gen_ai.tool.name, gen_ai.tool.type, gen_ai.tool.call.id, and the call's arguments and result. Results are kept for failed calls as well as successful ones, so a conversation reconstructs losslessly from the trace rather than only when everything worked.

Metrics

Pass a meter alongside the tracer for the standard histograms: gen_ai.client.operation.duration, gen_ai.client.token.usage, gen_ai.client.operation.time_to_first_chunk, gen_ai.client.operation.time_per_output_chunk, gen_ai.execute_tool.duration and gen_ai.invoke_agent.duration.

Cancellations are not errors

This distinction matters more than it sounds. A user pressing escape, a runtime stopping a run, and an interaction pausing for an answer are all aborts, and treating them as failures poisons every error rate and alert you build on the trace.

A requested abort ends its span with gen_ai.response.finish_reasons = ["aborted"] and introspection.termination_reason of cancelled or awaiting_user, with no error status and no synthetic exception. Tool calls cut short the same way are marked and not failed. An unclaimed abort, or a real provider or model failure, is still recorded as an error with a standard exception event.

So status = ERROR on a recipe trace means something actually went wrong, which is what makes the number worth alerting on.

Conversation content

GenAI spans carry the full conversation: prompts, replies, system instructions, tool arguments and results. That is what makes them useful for judging and debugging, and it is also why they are not the spans you want in a general-purpose infrastructure backend.

Export one stream to two places by wrapping the second exporter. It strips the content-bearing attributes and keeps the structural signal: operation, provider, model, usage, timing and tool names.

otel.ts
import { GenAiContentScrubbingExporter } from "@introspection-sdk/introspection-pi";
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";

// Whole spans to your conversation store.
provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter()));

// Structure only to infrastructure observability.
provider.addSpanProcessor(
  new BatchSpanProcessor(
    new GenAiContentScrubbingExporter(new OTLPTraceExporter({ url: infraUrl })),
  ),
);

Scrubbing decides per attribute on every span rather than per scope, so there is no configuration that can fail open, and the original span is never mutated, so a second processor on the same stream still sees it whole. isGenAiContentAttribute(key) exposes the same predicate if you scrub in your own pipeline.