The OpenTelemetry middleware records the model, token counts, and tool time on your existing traces.
Add otelMiddleware to your chat() call. Each call then sends spans and metrics with the GenAI semantic-convention attributes.
@opentelemetry/api is an optional peer dependency of @tanstack/ai:
npm i @opentelemetry/apipnpm add @opentelemetry/apiyarn add @opentelemetry/apibun add @opentelemetry/apiStart the SDK the way your app already does, for example with @opentelemetry/sdk-node. The middleware uses the Tracer and Meter that you give it.
import { chat } from '@tanstack/ai'
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { openaiText } from '@tanstack/ai-openai'
import { trace, metrics } from '@opentelemetry/api'
const otel = otelMiddleware({
tracer: trace.getTracer('my-app'),
meter: metrics.getMeter('my-app'),
})
const { text } = await chat({
adapter: openaiText('gpt-6.1-sol'),
messages: [{ role: 'user', content: 'hi' }],
threadId: 'thread-1',
middleware: [otel],
stream: false,
})The middleware lives on its own subpath. Apps that do not import it do not need @opentelemetry/api.
Open your trace viewer. Each chat() call now shows as one span tree, with the model, the token counts, and each tool call.
Each chat() call makes a span tree:
chat gpt-6.1-sol (root, kind: INTERNAL)
├── chat gpt-6.1-sol #0 (iteration, kind: CLIENT)
│ ├── execute_tool get_weather
│ └── execute_tool get_time
└── chat gpt-6.1-sol #1 (iteration, kind: CLIENT)When you give a Meter, the middleware also records two token and duration histograms. See Metrics.
By default, only metadata goes onto the spans. Prompts and answers stay off until you turn them on.
Set captureContent: true to record prompt and answer content. Pass a redact function to remove personal data before anything is recorded:
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace } from '@opentelemetry/api'
const tracer = trace.getTracer('my-app')
otelMiddleware({
tracer,
captureContent: true,
redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'),
})If redact throws, the middleware writes the text "[redaction_failed]" into the span event and logs a warning. It never falls back to the raw content. So a broken redactor stops the capture, and no prompt leaks to a third-party backend.
For the event names, the length limit, and media parts, see Content capture details.
The media activities accept the same otelMiddleware value on their middleware option:
import { generateImage } from '@tanstack/ai'
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { openaiImage } from '@tanstack/ai-openai'
import { trace, metrics } from '@opentelemetry/api'
const otel = otelMiddleware({
tracer: trace.getTracer('my-app'),
meter: metrics.getMeter('my-app'),
})
const result = await generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: 'A serene mountain landscape at sunset',
threadId: 'thread-1',
middleware: [otel],
})A media call is one request and one response (or a submit and a poll, for video). So it makes one CLIENT span, not a span tree. You can pass the same otel value to chat(), decide(), and any media activity.
| Level | Attribute | Value |
|---|---|---|
| root / iteration | gen_ai.system | openai, anthropic, ... |
| iteration | gen_ai.operation.name | chat |
| root / iteration | gen_ai.request.model | requested model |
| iteration | gen_ai.response.model | actual model |
| iteration | gen_ai.request.temperature | from config |
| iteration | gen_ai.request.top_p | from config |
| iteration | gen_ai.request.max_tokens | from config |
| iteration | gen_ai.usage.input_tokens | per iteration |
| iteration | gen_ai.usage.output_tokens | per iteration |
| root / iteration | gen_ai.usage.total_tokens | total from the provider |
| root / iteration | gen_ai.usage.cost | cost from the provider, when available |
| root / iteration | gen_ai.usage.cache_read.input_tokens | cached prompt tokens, when reported |
| root / iteration | gen_ai.usage.cache_creation.input_tokens | cache-write prompt tokens, when reported |
| root / iteration | gen_ai.usage.reasoning.output_tokens | reasoning or thinking tokens, when reported |
| root / iteration | tanstack.ai.usage.billed_quantity | billed quantity that is not tokens, when reported |
| root / iteration | tanstack.ai.usage.billed_unit | unit of the billed quantity (seconds, units, ...) |
| root / iteration | tanstack.ai.usage.upstream_cost | upstream cost of a gateway (for example OpenRouter), when reported |
| root / iteration | tanstack.ai.usage.upstream_input_cost | upstream input cost, when reported |
| root / iteration | tanstack.ai.usage.upstream_output_cost | upstream output cost, when reported |
| iteration | gen_ai.response.finish_reasons | [stop], [tool_calls], ... |
| root | gen_ai.usage.input_tokens | sum of all iterations |
| root | gen_ai.usage.output_tokens | sum of all iterations |
| root | tanstack.ai.iterations | iteration count |
| tool | gen_ai.tool.name | tool name |
| tool | gen_ai.tool.call.id | tool call id |
| tool | gen_ai.tool.type | function |
| tool | tanstack.ai.tool.outcome | success / error |
How the usage attributes work:
For billing that is not in tokens (seconds of video or transcription, fal endpoint units), the middleware sends tanstack.ai.usage.billed_quantity and tanstack.ai.usage.billed_unit as a pair from usage.billed. Backends can then label and add up media usage without knowing the provider.
A structured-output call with no tools skips the agent loop and runs only the final request. That path still opens an iteration span, through the structuredOutput middleware phase. So backends that read generation spans (for example PostHog $ai_generation) and captureContent both work.
Native combined mode (supportsCombinedToolsAndSchema) does not run that phase. The single beforeModel span covers the combined call.
When you give a Meter, the middleware records two GenAI-standard histograms:
The metric attributes do not include gen_ai.response.id and gen_ai.response.model. This keeps the number of series low, because request ids and custom model names change on each request.
With captureContent: true, content goes onto the spans as OTel span events, with the GenAI names:
Limits and timing:
Multimodal messages (images, audio, video, documents) keep their parts in gen_ai.input.messages, in the OTel GenAI part shapes:
Span events stay flat strings, with the same placeholders for each media part (look at this [image]).
All four callbacks are optional. Each one runs inside a try/catch. A callback that throws makes a log line, and the chat continues.
| Callback | What it does |
|---|---|
| spanNameFormatter(info) | Returns the span name. info.kind is 'chat', 'iteration', 'tool', or 'generation'. |
| attributeEnricher(info) | Returns extra attributes for each span. Fires once for each span. |
| onBeforeSpanStart(info, options) | Returns the SpanOptions for tracer.startSpan(...). Use it to add links, custom start times, or default attributes. |
| onSpanEnd(info, span) | Fires immediately before each span.end(). Use it to record custom events, or per-tool metrics with your own Meter. |
Change the span names:
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace } from '@opentelemetry/api'
const tracer = trace.getTracer('my-app')
otelMiddleware({
tracer,
spanNameFormatter: (info) =>
info.kind === 'tool' ? `tool:${info.toolName}` : `chat:${info.ctx.model}`,
})Add an attribute to each span:
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace } from '@opentelemetry/api'
import { getCurrentTenant } from './context'
const tracer = trace.getTracer('my-app')
otelMiddleware({
tracer,
attributeEnricher: () => ({
'tenant.id': getCurrentTenant(),
}),
})Record a metric for each tool call:
import { otelMiddleware } from '@tanstack/ai/middlewares/otel'
import { trace, metrics } from '@opentelemetry/api'
const tracer = trace.getTracer('my-app')
const meter = metrics.getMeter('my-app')
const toolDuration = meter.createHistogram('tool.duration')
otelMiddleware({
tracer,
onSpanEnd: (info, span) => {
if (info.kind === 'tool') {
// The span still records. Read timestamps from your own store if you need them.
toolDuration.record(1, { 'tool.name': info.toolName })
}
},
})These activities accept otelMiddleware:
Each media call makes one CLIENT span, tagged with the gen_ai.operation.name of the activity:
| Activity | gen_ai.operation.name |
|---|---|
| generateImage | image_generation |
| generateVideo | video_generation |
| generateAudio | audio_generation |
| generateSpeech | text_to_speech |
| generateTranscription | transcription |
| generateWorld | world_generation |
| generateLiveVideo | live_video_generation |
| summarize | summarize |
| decide | evaluate |
What the span has:
How video spans work:
The four extension points also apply to media spans. Media spans report kind: 'generation'.
The shared lifecycle hooks (onStart, onUsage, onFinish, onAbort, onError) use the GenerationMiddlewareContext, which is the same for each activity. That is why one instance works everywhere. For a custom backend, implement the base GenerationMiddleware contract. Its hooks get the GenerationMiddlewareContext and fire for each activity, chat included.
The GenerationMiddleware types come from the package root. The otelMiddleware value lives on the @tanstack/ai/middlewares/otel subpath, so an import of @tanstack/ai never needs the optional @opentelemetry/api peer.
Set captureContent: true to record what a media call asked for and what came back. The media span then gets the same attributes as a chat iteration span:
Media parts use the part shapes from Content capture details:
For non-streaming video, the getVideoJobStatus() span has the output URL but not the prompt. Use generateVideo({ stream: true }) to get both on one span.
For quick console output while you develop, use Debug Logging. To learn the lifecycle that this middleware hooks into, see Middleware.