Skip to content

Commit baf6dc6

Browse files
committed
fix(otelMiddleware): open iteration span for structured-output finalization
No-tools + outputSchema skips the agent loop, so only phase=structuredOutput ran and otelMiddleware never opened a generation span — captureContent was a silent no-op and backends like PostHog saw bare traces. Treat structuredOutput as a model call for span lifecycle; keep native-combined single-span behavior. Closes #1054
1 parent aade077 commit baf6dc6

6 files changed

Lines changed: 192 additions & 8 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tanstack/ai": patch
3+
---
4+
5+
fix(otelMiddleware): open an iteration span for structured-output finalization (#1054)
6+
7+
No-tools + `outputSchema` calls skip the agent loop and only run the structured-output finalization request. That path previously emitted only a root `chat` span — no generation record, and `captureContent` was a silent no-op. `onConfig` now also opens an iteration span when `phase === 'structuredOutput'`, so each provider model call is observable. Native-combined mode is unaffected (it never fires that phase).

docs/advanced/otel.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ keywords:
1414
- semantic conventions
1515
---
1616

17-
The `otelMiddleware` factory wires TanStack AI into your existing OpenTelemetry setup. Every `chat()` call produces a root span, one child span per agent-loop iteration, and one grandchild span per tool call — all with [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/). It also records GenAI token and duration histograms when a `Meter` is provided.
17+
The `otelMiddleware` factory wires TanStack AI into your existing OpenTelemetry setup. Every `chat()` call produces a root span, one child span per provider model call (agent-loop turn **or** structured-output finalization), and one grandchild span per tool call — all with [GenAI semantic-convention attributes](https://opentelemetry.io/docs/specs/semconv/gen-ai/). It also records GenAI token and duration histograms when a `Meter` is provided.
18+
19+
Structured-output calls with no tools skip the agent loop and only run the finalization request. That path still opens an iteration span (via the `structuredOutput` middleware phase) so backends that key off generation spans (e.g. PostHog `$ai_generation`) and `captureContent` both work. Native combined mode (`supportsCombinedToolsAndSchema`) does not fire that phase — the single `beforeModel` span covers the combined call.
1820

1921
## Setup
2022

@@ -57,7 +59,7 @@ chat gpt-5.5 (root, kind: INTERNAL)
5759
└── chat gpt-5.5 #1 (iteration, kind: CLIENT)
5860
```
5961

60-
Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the same chat are easy to pick apart in trace viewers.
62+
Iteration spans are numbered (`#0`, `#1`, ...) in the order model calls are observed, so distinct provider round-trips of the same chat are easy to pick apart in trace viewers.
6163

6264
### Attribute reference
6365

docs/config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,7 @@
473473
"label": "OpenTelemetry",
474474
"to": "advanced/otel",
475475
"addedAt": "2026-05-08",
476-
"updatedAt": "2026-07-31"
476+
"updatedAt": "2026-08-06"
477477
}
478478
]
479479
},

packages/ai/src/middlewares/otel.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ import type {
3333
* Scope (role) of an OTel span emitted by this middleware.
3434
*
3535
* - `chat` — the root span for a single `chat()` call
36-
* - `iteration` — one per agent-loop iteration (one model call)
36+
* - `iteration` — one per provider model call (agent-loop `beforeModel`
37+
* turn, or the separate `structuredOutput` finalization when
38+
* `outputSchema` skips the agent loop — see #1054)
3739
* - `tool` — one per tool execution inside an iteration
3840
* - `generation` — the single span for a media activity call
3941
* (`generateImage`, `generateVideo`, `generateSpeech`, …)
@@ -401,7 +403,16 @@ export function otelMiddleware(
401403
},
402404

403405
onConfig(ctx, config) {
404-
if (ctx.phase !== 'beforeModel') return
406+
// Open an iteration span for every provider model call:
407+
// - `beforeModel`: agent-loop chatStream turns
408+
// - `structuredOutput`: separate structured-output finalization
409+
// (no-tools + outputSchema skips the agent loop, so without this
410+
// phase there is no generation span and captureContent is a silent
411+
// no-op — see #1054).
412+
// Native-combined mode never fires `structuredOutput`, so a run that
413+
// already opened spans via `beforeModel` is not double-counted.
414+
if (ctx.phase !== 'beforeModel' && ctx.phase !== 'structuredOutput')
415+
return
405416
safeCall('otel.onConfig', () => {
406417
const state = stateByCtx.get(ctx)
407418
if (!state) return
@@ -411,20 +422,26 @@ export function otelMiddleware(
411422
// on it. Close it here, just before opening the next iteration.
412423
closeIterationSpan(state, ctx)
413424

425+
// Number spans by the order of model calls this middleware has seen,
426+
// not by `ctx.iteration`. After an agent-loop turn, structured-output
427+
// finalization reuses the engine's last iteration index; using our
428+
// own counter keeps finalization as a distinct leaf (#N+1).
429+
const iteration = state.iterationCount
430+
414431
const info: OtelSpanInfo<'iteration'> = {
415432
kind: 'iteration',
416433
ctx,
417-
iteration: ctx.iteration,
434+
iteration,
418435
}
419436
const name =
420437
safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
421-
`chat ${ctx.model} #${ctx.iteration}`
438+
`chat ${ctx.model} #${iteration}`
422439

423440
const baseAttrs: Record<string, AttributeValue> = {
424441
'gen_ai.system': ctx.provider,
425442
'gen_ai.operation.name': 'chat',
426443
'gen_ai.request.model': ctx.model,
427-
'tanstack.ai.iteration': ctx.iteration,
444+
'tanstack.ai.iteration': iteration,
428445
}
429446
// Sampling options now live in provider-native `modelOptions`, and
430447
// providers spell them differently (e.g. `max_output_tokens`,

packages/ai/tests/middlewares/otel.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,107 @@ describe('otelMiddleware — iteration span lifecycle', () => {
198198
expect(spans[1]!.name).toBe('chat gpt-4o #0')
199199
expect(spans[2]!.name).toBe('chat gpt-4o #1')
200200
})
201+
202+
// #1054 — no-tools + outputSchema skips the agent loop, so the only
203+
// onConfig that fires is phase=structuredOutput. That must open a
204+
// generation (iteration) span or captureContent is a silent no-op and
205+
// backends that key off iteration spans (PostHog $ai_generation) see
206+
// an empty trace.
207+
it('opens an iteration span on onConfig(structuredOutput) — no-tools + outputSchema path', async () => {
208+
const { tracer, spans } = createFakeTracer()
209+
const mw = otelMiddleware({ tracer, captureContent: true })
210+
const ctx = makeCtx()
211+
212+
await mw.onStart?.(ctx)
213+
ctx.phase = 'structuredOutput'
214+
await mw.onConfig?.(ctx, {
215+
messages: [{ role: 'user', content: 'Describe this scene' }],
216+
systemPrompts: [],
217+
tools: [],
218+
})
219+
220+
expect(spans).toHaveLength(2)
221+
const [rootSpan, iterSpan] = spans
222+
expect(iterSpan!.parent).toBe(rootSpan)
223+
expect(iterSpan!.name).toBe('chat gpt-4o #0')
224+
expect(iterSpan!.kind).toBe(SpanKind.CLIENT)
225+
expect(iterSpan!.attributes['gen_ai.operation.name']).toBe('chat')
226+
expect(iterSpan!.attributes['tanstack.ai.iteration']).toBe(0)
227+
expect(iterSpan!.attributes['gen_ai.input.messages']).toBe(
228+
JSON.stringify([{ role: 'user', content: 'Describe this scene' }]),
229+
)
230+
231+
await mw.onChunk?.(ctx, ev.textContent('{"description":"a sunny park"}'))
232+
await mw.onChunk?.(ctx, {
233+
...ev.runFinished('stop'),
234+
model: 'gpt-4o',
235+
usage: { promptTokens: 12, completionTokens: 8, totalTokens: 20 },
236+
})
237+
expect(iterSpan!.attributes['gen_ai.output.messages']).toBe(
238+
JSON.stringify([
239+
{ role: 'assistant', content: '{"description":"a sunny park"}' },
240+
]),
241+
)
242+
expect(iterSpan!.attributes['gen_ai.usage.input_tokens']).toBe(12)
243+
244+
await mw.onFinish?.(ctx, {
245+
finishReason: 'stop',
246+
duration: 10,
247+
content: '',
248+
})
249+
expect(iterSpan!.ended).toBe(true)
250+
expect(rootSpan!.ended).toBe(true)
251+
})
252+
253+
it('does not open an iteration span for non-model-call phases', async () => {
254+
const { tracer, spans } = createFakeTracer()
255+
const mw = otelMiddleware({ tracer })
256+
const ctx = makeCtx()
257+
258+
await mw.onStart?.(ctx)
259+
for (const phase of [
260+
'init',
261+
'modelStream',
262+
'beforeTools',
263+
'afterTools',
264+
] as const) {
265+
ctx.phase = phase
266+
await mw.onConfig?.(ctx, {
267+
messages: [],
268+
systemPrompts: [],
269+
tools: [],
270+
})
271+
}
272+
273+
// Root span only — none of those phases are a provider model call.
274+
expect(spans).toHaveLength(1)
275+
})
276+
277+
it('numbers structuredOutput finalization after a prior beforeModel span (#N+1)', async () => {
278+
// Tools + outputSchema: agent loop opens #0, then finalization must open
279+
// a distinct #1 rather than reusing ctx.iteration from the last turn.
280+
const { tracer, spans } = createFakeTracer()
281+
const mw = otelMiddleware({ tracer })
282+
const ctx = makeCtx()
283+
284+
await runToIterationStart(mw, ctx)
285+
await mw.onChunk?.(ctx, ev.runFinished('tool_calls'))
286+
// Engine leaves ctx.iteration at 0 for finalization; middleware must
287+
// still mint a distinct leaf.
288+
ctx.phase = 'structuredOutput'
289+
ctx.iteration = 0
290+
await mw.onConfig?.(ctx, {
291+
messages: [{ role: 'user', content: 'hi' }],
292+
systemPrompts: [],
293+
tools: [],
294+
})
295+
296+
expect(spans).toHaveLength(3)
297+
expect(spans[1]!.ended).toBe(true)
298+
expect(spans[1]!.name).toBe('chat gpt-4o #0')
299+
expect(spans[2]!.name).toBe('chat gpt-4o #1')
300+
expect(spans[2]!.attributes['tanstack.ai.iteration']).toBe(1)
301+
})
201302
})
202303

203304
describe('otelMiddleware — token histogram', () => {

testing/e2e/tests/middleware.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,63 @@ test.describe('Middleware Lifecycle', () => {
189189
).toBeUndefined()
190190
})
191191

192+
// #1054 — no-tools + outputSchema skips the agent loop. The only model
193+
// call is structured-output finalization (`phase=structuredOutput`).
194+
// otelMiddleware must open an iteration (CLIENT) span for that call so
195+
// backends that key off generation spans (PostHog $ai_generation) and
196+
// captureContent both work. Pin to claude-3-7-sonnet so we take the
197+
// legacy finalization path, not native-combined (#605) which already
198+
// emits a beforeModel iteration span.
199+
test('otel middleware emits iteration span + captureContent for no-tools structured-output finalization', async ({
200+
page,
201+
testId,
202+
aimockPort,
203+
baseURL,
204+
}) => {
205+
const params = new URLSearchParams()
206+
if (testId) params.set('testId', testId)
207+
if (aimockPort) params.set('aimockPort', String(aimockPort))
208+
params.set('provider', 'anthropic')
209+
params.set('model', 'claude-3-7-sonnet')
210+
const qs = params.toString()
211+
await page.goto(`/middleware-test?${qs}`)
212+
await page.waitForTimeout(2000)
213+
await page.locator('#mw-scenario-select').selectOption('structured-output')
214+
await page.locator('#mw-mode-select').selectOption('otel')
215+
await page.locator('#mw-run-button').click()
216+
217+
await page.waitForFunction(
218+
() =>
219+
document
220+
.querySelector('#mw-metadata')
221+
?.getAttribute('data-test-complete') === 'true',
222+
{ timeout: 15000 },
223+
)
224+
225+
const capture = await fetchOtelCapture(page, baseURL, testId)
226+
227+
const chatSpans = capture.spans.filter(
228+
(s: any) => s.kind === SpanKind.INTERNAL,
229+
)
230+
expect(chatSpans).toHaveLength(1)
231+
232+
const iterationSpans = capture.spans.filter(
233+
(s: any) => s.kind === SpanKind.CLIENT,
234+
)
235+
// Exactly one provider call on the skip-agent-loop path → one generation.
236+
expect(iterationSpans).toHaveLength(1)
237+
const iter = iterationSpans[0]
238+
expect(iter.ended).toBe(true)
239+
expect(iter.attributes['gen_ai.operation.name']).toBe('chat')
240+
expect(iter.attributes['tanstack.ai.iteration']).toBe(0)
241+
242+
// captureContent is enabled on the harness otel middleware — prompt must
243+
// land on the iteration span (the pre-fix silent no-op left this empty).
244+
const inputMessages = iter.attributes['gen_ai.input.messages']
245+
expect(typeof inputMessages).toBe('string')
246+
expect(inputMessages.length).toBeGreaterThan(0)
247+
})
248+
192249
test('otel middleware nests tool spans under the iteration span that triggered them', async ({
193250
page,
194251
testId,

0 commit comments

Comments
 (0)