Skip to content

Commit c7df2a3

Browse files
committed
feat(ai): emit full usage on otel spans (cost, totals, cache/reasoning details)
otelMiddleware only emitted gen_ai.usage.input_tokens/output_tokens even though TokenUsage already carries provider-reported cost, total tokens, cache/reasoning breakdowns, and duration-based billing. Backends like PostHog had to re-derive cost from their own price tables, losing cache discounts and gateway markup (OpenRouter), and duration-billed activities had no cost signal at all. A shared usageAttributes() helper now builds the full guarded attribute set at all three emission sites (RUN_FINISHED chunk, onUsage, onFinish rollup): - gen_ai.usage.total_tokens / gen_ai.usage.cost (de-facto extensions consumed directly by PostHog and LiteLLM-style backends) - gen_ai.usage.cache_read.input_tokens, cache_creation.input_tokens, reasoning.output_tokens (official GenAI semconv names) - tanstack.ai.usage.duration_seconds and the upstream cost split (no semconv equivalent exists) E2E: new /api/otel-usage route drives the existing openai-usage-details and openrouter-cost aimock mounts through otelMiddleware with a local capture tracer; middleware.spec.ts asserts the attributes land on iteration and root spans. Fixes #721
1 parent 984ac3c commit c7df2a3

8 files changed

Lines changed: 479 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': minor
3+
---
4+
5+
`otelMiddleware` now emits the rest of the reported `TokenUsage` on spans instead of only input/output tokens (#721). When the provider reports them, spans carry `gen_ai.usage.total_tokens`, `gen_ai.usage.cost` (provider-reported cost — cache discounts and gateway markup included, so backends like PostHog no longer re-derive cost from price tables), the official semconv cache/reasoning breakdowns (`gen_ai.usage.cache_read.input_tokens`, `gen_ai.usage.cache_creation.input_tokens`, `gen_ai.usage.reasoning.output_tokens`), and TanStack-namespaced attributes for duration-based billing (`tanstack.ai.usage.duration_seconds`) and the upstream cost split (`tanstack.ai.usage.upstream_cost` / `upstream_input_cost` / `upstream_output_cost`). All attributes are guarded — spans stay unchanged when a provider doesn't report a field. Media-oriented fields (`unitsBilled`, per-modality token breakdowns) and the provider-shaped `providerUsageDetails` bag are intentionally not emitted; media-activity observability is tracked in #720.

docs/advanced/otel.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the sam
7272
| iteration | `gen_ai.request.max_tokens` | from config |
7373
| iteration | `gen_ai.usage.input_tokens` | per iteration |
7474
| iteration | `gen_ai.usage.output_tokens` | per iteration |
75+
| root / iteration | `gen_ai.usage.total_tokens` | provider-reported total |
76+
| root / iteration | `gen_ai.usage.cost` | provider-reported cost, when available |
77+
| root / iteration | `gen_ai.usage.cache_read.input_tokens` | cached prompt tokens, when reported |
78+
| root / iteration | `gen_ai.usage.cache_creation.input_tokens` | cache-write prompt tokens, when reported |
79+
| root / iteration | `gen_ai.usage.reasoning.output_tokens` | reasoning/thinking tokens, when reported |
80+
| root / iteration | `tanstack.ai.usage.duration_seconds` | duration-based billing (e.g. transcription), when reported |
81+
| root / iteration | `tanstack.ai.usage.upstream_cost` | gateway upstream cost (e.g. OpenRouter), when reported |
82+
| root / iteration | `tanstack.ai.usage.upstream_input_cost` | upstream input cost split, when reported |
83+
| root / iteration | `tanstack.ai.usage.upstream_output_cost` | upstream output cost split, when reported |
7584
| iteration | `gen_ai.response.finish_reasons` | `[stop]`, `[tool_calls]`, ... |
7685
| root | `gen_ai.usage.input_tokens` | rolled up |
7786
| root | `gen_ai.usage.output_tokens` | rolled up |
@@ -81,6 +90,8 @@ Iteration spans are numbered (`#0`, `#1`, ...) so distinct iterations of the sam
8190
| tool | `gen_ai.tool.type` | `function` |
8291
| tool | `tanstack.ai.tool.outcome` | `success` / `error` |
8392

93+
Usage attributes beyond input/output tokens are emitted only when the provider reports them, so spans stay clean otherwise. Cache and reasoning breakdowns use the official GenAI semconv names; `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions consumed directly by backends like PostHog — without them, backends re-derive cost from their own price tables and lose cache discounts and gateway markup. Fields with no established convention (duration-based billing, the upstream cost split) are TanStack-namespaced.
94+
8495
### Metrics
8596

8697
Two GenAI-standard histograms:

docs/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,8 @@
280280
{
281281
"label": "OpenTelemetry",
282282
"to": "advanced/otel",
283-
"addedAt": "2026-05-08"
283+
"addedAt": "2026-05-08",
284+
"updatedAt": "2026-06-11"
284285
}
285286
]
286287
},

packages/ai/src/middlewares/otel.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
ChatMiddleware,
2121
ChatMiddlewareContext,
2222
} from '../activities/chat/middleware/types'
23+
import type { TokenUsage } from '../types'
2324

2425
/**
2526
* Scope (role) of an OTel span emitted by this middleware.
@@ -179,6 +180,59 @@ function firstNumber(...candidates: Array<unknown>): number | undefined {
179180
return undefined
180181
}
181182

183+
/**
184+
* Build the full set of `gen_ai.usage.*` span attributes from a `TokenUsage`.
185+
*
186+
* Beyond input/output tokens, this emits provider-reported cost, total tokens,
187+
* cache and reasoning breakdowns, and duration-based billing — every field is
188+
* guarded so spans stay clean when a provider doesn't report it. Cache and
189+
* reasoning use the official GenAI semconv names; `gen_ai.usage.cost` and
190+
* `gen_ai.usage.total_tokens` are de-facto extensions consumed by backends
191+
* like PostHog (which otherwise re-derive cost from their own price tables,
192+
* losing cache discounts and gateway markup). Fields with no semconv or
193+
* de-facto convention (`costDetails`, `durationSeconds`) are
194+
* TanStack-namespaced. Deliberately not emitted: `unitsBilled`,
195+
* `providerUsageDetails`, and the per-modality token breakdowns — those are
196+
* media-oriented; media-activity observability is tracked in #720.
197+
*/
198+
function usageAttributes(usage: TokenUsage): Record<string, AttributeValue> {
199+
const attrs: Record<string, AttributeValue> = {
200+
'gen_ai.usage.input_tokens': usage.promptTokens,
201+
'gen_ai.usage.output_tokens': usage.completionTokens,
202+
}
203+
const optional: Array<[key: string, value: unknown]> = [
204+
['gen_ai.usage.total_tokens', usage.totalTokens],
205+
['gen_ai.usage.cost', usage.cost],
206+
[
207+
'gen_ai.usage.cache_read.input_tokens',
208+
usage.promptTokensDetails?.cachedTokens,
209+
],
210+
[
211+
'gen_ai.usage.cache_creation.input_tokens',
212+
usage.promptTokensDetails?.cacheWriteTokens,
213+
],
214+
[
215+
'gen_ai.usage.reasoning.output_tokens',
216+
usage.completionTokensDetails?.reasoningTokens,
217+
],
218+
['tanstack.ai.usage.duration_seconds', usage.durationSeconds],
219+
['tanstack.ai.usage.upstream_cost', usage.costDetails?.upstreamCost],
220+
[
221+
'tanstack.ai.usage.upstream_input_cost',
222+
usage.costDetails?.upstreamInputCost,
223+
],
224+
[
225+
'tanstack.ai.usage.upstream_output_cost',
226+
usage.costDetails?.upstreamOutputCost,
227+
],
228+
]
229+
for (const [key, value] of optional) {
230+
const num = firstNumber(value)
231+
if (num !== undefined) attrs[key] = num
232+
}
233+
return attrs
234+
}
235+
182236
function errorMessage(err: unknown): string | undefined {
183237
if (err instanceof Error) return err.message
184238
if (typeof err === 'string') return err
@@ -524,10 +578,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
524578
// `runOnUsage` when `chunk.usage` is present, and `onUsage` is the
525579
// canonical place for the metric. Recording in both would double-count.
526580
if (chunk.usage) {
527-
span.setAttributes({
528-
'gen_ai.usage.input_tokens': chunk.usage.promptTokens,
529-
'gen_ai.usage.output_tokens': chunk.usage.completionTokens,
530-
})
581+
span.setAttributes(usageAttributes(chunk.usage))
531582
}
532583

533584
if (captureContent && state.assistantTextBuffer.length > 0) {
@@ -584,10 +635,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
584635
}
585636

586637
const span = state.currentIterationSpan ?? state.rootSpan
587-
span.setAttributes({
588-
'gen_ai.usage.input_tokens': usage.promptTokens,
589-
'gen_ai.usage.output_tokens': usage.completionTokens,
590-
})
638+
span.setAttributes(usageAttributes(usage))
591639
})
592640
},
593641

@@ -905,10 +953,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
905953
}
906954

907955
if (info.usage) {
908-
state.rootSpan.setAttributes({
909-
'gen_ai.usage.input_tokens': info.usage.promptTokens,
910-
'gen_ai.usage.output_tokens': info.usage.completionTokens,
911-
})
956+
state.rootSpan.setAttributes(usageAttributes(info.usage))
912957
}
913958
if (info.finishReason) {
914959
state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [

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

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,151 @@ describe('otelMiddleware — duration histogram and rollup', () => {
307307
})
308308
})
309309

310+
describe('otelMiddleware — full usage emission', () => {
311+
// Everything `TokenUsage` carries beyond input/output tokens: cost,
312+
// totals, cache/reasoning breakdowns, duration-based billing, and the
313+
// upstream cost split. Backends like PostHog consume `gen_ai.usage.cost`
314+
// directly; without it they re-derive cost from their own price tables
315+
// and lose cache discounts / gateway markup (OpenRouter).
316+
const fullUsage = {
317+
promptTokens: 100,
318+
completionTokens: 50,
319+
totalTokens: 165,
320+
promptTokensDetails: { cachedTokens: 80, cacheWriteTokens: 10 },
321+
completionTokensDetails: { reasoningTokens: 15 },
322+
durationSeconds: 2.5,
323+
cost: 0.0123,
324+
costDetails: {
325+
upstreamCost: 0.01,
326+
upstreamInputCost: 0.004,
327+
upstreamOutputCost: 0.006,
328+
},
329+
}
330+
331+
const expectFullUsageAttrs = (span: FakeSpan) => {
332+
expect(span.attributes['gen_ai.usage.input_tokens']).toBe(100)
333+
expect(span.attributes['gen_ai.usage.output_tokens']).toBe(50)
334+
expect(span.attributes['gen_ai.usage.total_tokens']).toBe(165)
335+
expect(span.attributes['gen_ai.usage.cost']).toBe(0.0123)
336+
expect(span.attributes['gen_ai.usage.cache_read.input_tokens']).toBe(80)
337+
expect(span.attributes['gen_ai.usage.cache_creation.input_tokens']).toBe(
338+
10,
339+
)
340+
expect(span.attributes['gen_ai.usage.reasoning.output_tokens']).toBe(15)
341+
expect(span.attributes['tanstack.ai.usage.duration_seconds']).toBe(2.5)
342+
expect(span.attributes['tanstack.ai.usage.upstream_cost']).toBe(0.01)
343+
expect(span.attributes['tanstack.ai.usage.upstream_input_cost']).toBe(
344+
0.004,
345+
)
346+
expect(span.attributes['tanstack.ai.usage.upstream_output_cost']).toBe(
347+
0.006,
348+
)
349+
}
350+
351+
it('emits cost, totals, and detail breakdowns from RUN_FINISHED chunk.usage', async () => {
352+
const { tracer, spans } = createFakeTracer()
353+
const mw = otelMiddleware({ tracer })
354+
const ctx = makeCtx()
355+
356+
await runToIterationStart(mw, ctx)
357+
await mw.onChunk?.(ctx, {
358+
...ev.runFinished('stop'),
359+
model: 'gpt-4o',
360+
usage: fullUsage,
361+
})
362+
363+
expectFullUsageAttrs(spans[1]!)
364+
})
365+
366+
it('emits cost, totals, and detail breakdowns from onUsage', async () => {
367+
const { tracer, spans } = createFakeTracer()
368+
const mw = otelMiddleware({ tracer })
369+
const ctx = makeCtx()
370+
371+
await runToIterationStart(mw, ctx)
372+
await mw.onUsage?.(ctx, fullUsage)
373+
374+
expectFullUsageAttrs(spans[1]!)
375+
})
376+
377+
it('rolls up cost, totals, and detail breakdowns onto the root span on onFinish', async () => {
378+
const { tracer, spans } = createFakeTracer()
379+
const mw = otelMiddleware({ tracer })
380+
const ctx = makeCtx()
381+
382+
await runToIterationStart(mw, ctx)
383+
await mw.onChunk?.(ctx, { ...ev.runFinished('stop'), model: 'gpt-4o' })
384+
await mw.onFinish?.(ctx, {
385+
finishReason: 'stop',
386+
duration: 1250,
387+
content: '',
388+
usage: fullUsage,
389+
})
390+
391+
expectFullUsageAttrs(spans[0]!)
392+
})
393+
394+
it('omits optional usage attributes when the provider does not report them', async () => {
395+
const { tracer, spans } = createFakeTracer()
396+
const mw = otelMiddleware({ tracer })
397+
const ctx = makeCtx()
398+
399+
await runToIterationStart(mw, ctx)
400+
await mw.onUsage?.(ctx, {
401+
promptTokens: 100,
402+
completionTokens: 50,
403+
totalTokens: 150,
404+
})
405+
406+
const span = spans[1]!
407+
expect(span.attributes['gen_ai.usage.input_tokens']).toBe(100)
408+
expect(span.attributes['gen_ai.usage.output_tokens']).toBe(50)
409+
expect(span.attributes['gen_ai.usage.total_tokens']).toBe(150)
410+
expect(span.attributes['gen_ai.usage.cost']).toBeUndefined()
411+
expect(
412+
span.attributes['gen_ai.usage.cache_read.input_tokens'],
413+
).toBeUndefined()
414+
expect(
415+
span.attributes['gen_ai.usage.cache_creation.input_tokens'],
416+
).toBeUndefined()
417+
expect(
418+
span.attributes['gen_ai.usage.reasoning.output_tokens'],
419+
).toBeUndefined()
420+
expect(
421+
span.attributes['tanstack.ai.usage.duration_seconds'],
422+
).toBeUndefined()
423+
expect(span.attributes['tanstack.ai.usage.upstream_cost']).toBeUndefined()
424+
expect(
425+
span.attributes['tanstack.ai.usage.upstream_input_cost'],
426+
).toBeUndefined()
427+
expect(
428+
span.attributes['tanstack.ai.usage.upstream_output_cost'],
429+
).toBeUndefined()
430+
})
431+
432+
it('emits zero-valued usage fields instead of dropping them', async () => {
433+
// cost 0 is a real report (OpenRouter free models), and the OpenRouter
434+
// extractor deliberately preserves it. Pin that the presence guard is
435+
// `!== undefined`, not truthiness — a truthy guard would drop zeros.
436+
const { tracer, spans } = createFakeTracer()
437+
const mw = otelMiddleware({ tracer })
438+
const ctx = makeCtx()
439+
440+
await runToIterationStart(mw, ctx)
441+
await mw.onUsage?.(ctx, {
442+
promptTokens: 100,
443+
completionTokens: 50,
444+
totalTokens: 150,
445+
cost: 0,
446+
promptTokensDetails: { cachedTokens: 0 },
447+
})
448+
449+
const span = spans[1]!
450+
expect(span.attributes['gen_ai.usage.cost']).toBe(0)
451+
expect(span.attributes['gen_ai.usage.cache_read.input_tokens']).toBe(0)
452+
})
453+
})
454+
310455
describe('otelMiddleware — tool spans', () => {
311456
it('creates a tool span as child of the iteration span (including after RUN_FINISHED)', async () => {
312457
const { tracer, spans } = createFakeTracer()

testing/e2e/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription
2626
import { Route as ApiToolsTestRouteImport } from './routes/api.tools-test'
2727
import { Route as ApiToolCallLifecycleWireRouteImport } from './routes/api.tool-call-lifecycle-wire'
2828
import { Route as ApiSummarizeRouteImport } from './routes/api.summarize'
29+
import { Route as ApiOtelUsageRouteImport } from './routes/api.otel-usage'
2930
import { Route as ApiOpenrouterWebToolsWireRouteImport } from './routes/api.openrouter-web-tools-wire'
3031
import { Route as ApiOpenrouterCostRouteImport } from './routes/api.openrouter-cost'
3132
import { Route as ApiOpenaiUsageDetailsRouteImport } from './routes/api.openai-usage-details'
@@ -136,6 +137,11 @@ const ApiSummarizeRoute = ApiSummarizeRouteImport.update({
136137
path: '/api/summarize',
137138
getParentRoute: () => rootRouteImport,
138139
} as any)
140+
const ApiOtelUsageRoute = ApiOtelUsageRouteImport.update({
141+
id: '/api/otel-usage',
142+
path: '/api/otel-usage',
143+
getParentRoute: () => rootRouteImport,
144+
} as any)
139145
const ApiOpenrouterWebToolsWireRoute =
140146
ApiOpenrouterWebToolsWireRouteImport.update({
141147
id: '/api/openrouter-web-tools-wire',
@@ -284,6 +290,7 @@ export interface FileRoutesByFullPath {
284290
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
285291
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
286292
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
293+
'/api/otel-usage': typeof ApiOtelUsageRoute
287294
'/api/summarize': typeof ApiSummarizeRoute
288295
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
289296
'/api/tools-test': typeof ApiToolsTestRoute
@@ -326,6 +333,7 @@ export interface FileRoutesByTo {
326333
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
327334
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
328335
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
336+
'/api/otel-usage': typeof ApiOtelUsageRoute
329337
'/api/summarize': typeof ApiSummarizeRoute
330338
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
331339
'/api/tools-test': typeof ApiToolsTestRoute
@@ -369,6 +377,7 @@ export interface FileRoutesById {
369377
'/api/openai-usage-details': typeof ApiOpenaiUsageDetailsRoute
370378
'/api/openrouter-cost': typeof ApiOpenrouterCostRoute
371379
'/api/openrouter-web-tools-wire': typeof ApiOpenrouterWebToolsWireRoute
380+
'/api/otel-usage': typeof ApiOtelUsageRoute
372381
'/api/summarize': typeof ApiSummarizeRoute
373382
'/api/tool-call-lifecycle-wire': typeof ApiToolCallLifecycleWireRoute
374383
'/api/tools-test': typeof ApiToolsTestRoute
@@ -413,6 +422,7 @@ export interface FileRouteTypes {
413422
| '/api/openai-usage-details'
414423
| '/api/openrouter-cost'
415424
| '/api/openrouter-web-tools-wire'
425+
| '/api/otel-usage'
416426
| '/api/summarize'
417427
| '/api/tool-call-lifecycle-wire'
418428
| '/api/tools-test'
@@ -455,6 +465,7 @@ export interface FileRouteTypes {
455465
| '/api/openai-usage-details'
456466
| '/api/openrouter-cost'
457467
| '/api/openrouter-web-tools-wire'
468+
| '/api/otel-usage'
458469
| '/api/summarize'
459470
| '/api/tool-call-lifecycle-wire'
460471
| '/api/tools-test'
@@ -497,6 +508,7 @@ export interface FileRouteTypes {
497508
| '/api/openai-usage-details'
498509
| '/api/openrouter-cost'
499510
| '/api/openrouter-web-tools-wire'
511+
| '/api/otel-usage'
500512
| '/api/summarize'
501513
| '/api/tool-call-lifecycle-wire'
502514
| '/api/tools-test'
@@ -540,6 +552,7 @@ export interface RootRouteChildren {
540552
ApiOpenaiUsageDetailsRoute: typeof ApiOpenaiUsageDetailsRoute
541553
ApiOpenrouterCostRoute: typeof ApiOpenrouterCostRoute
542554
ApiOpenrouterWebToolsWireRoute: typeof ApiOpenrouterWebToolsWireRoute
555+
ApiOtelUsageRoute: typeof ApiOtelUsageRoute
543556
ApiSummarizeRoute: typeof ApiSummarizeRoute
544557
ApiToolCallLifecycleWireRoute: typeof ApiToolCallLifecycleWireRoute
545558
ApiToolsTestRoute: typeof ApiToolsTestRoute
@@ -670,6 +683,13 @@ declare module '@tanstack/react-router' {
670683
preLoaderRoute: typeof ApiSummarizeRouteImport
671684
parentRoute: typeof rootRouteImport
672685
}
686+
'/api/otel-usage': {
687+
id: '/api/otel-usage'
688+
path: '/api/otel-usage'
689+
fullPath: '/api/otel-usage'
690+
preLoaderRoute: typeof ApiOtelUsageRouteImport
691+
parentRoute: typeof rootRouteImport
692+
}
673693
'/api/openrouter-web-tools-wire': {
674694
id: '/api/openrouter-web-tools-wire'
675695
path: '/api/openrouter-web-tools-wire'
@@ -921,6 +941,7 @@ const rootRouteChildren: RootRouteChildren = {
921941
ApiOpenaiUsageDetailsRoute: ApiOpenaiUsageDetailsRoute,
922942
ApiOpenrouterCostRoute: ApiOpenrouterCostRoute,
923943
ApiOpenrouterWebToolsWireRoute: ApiOpenrouterWebToolsWireRoute,
944+
ApiOtelUsageRoute: ApiOtelUsageRoute,
924945
ApiSummarizeRoute: ApiSummarizeRoute,
925946
ApiToolCallLifecycleWireRoute: ApiToolCallLifecycleWireRoute,
926947
ApiToolsTestRoute: ApiToolsTestRoute,

0 commit comments

Comments
 (0)