@tanstack/ai 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/activities/chat/index.js +47 -20
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/middleware/types.d.ts +7 -0
- package/dist/esm/activities/chat/tools/lazy-tool-manager.d.ts +25 -1
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js +26 -2
- package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
- package/dist/esm/activities/chat/tools/schema-converter.d.ts +13 -0
- package/dist/esm/activities/chat/tools/schema-converter.js +61 -33
- package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
- package/dist/esm/activities/generateAudio/index.d.ts +7 -0
- package/dist/esm/activities/generateAudio/index.js +26 -1
- package/dist/esm/activities/generateAudio/index.js.map +1 -1
- package/dist/esm/activities/generateImage/index.d.ts +7 -0
- package/dist/esm/activities/generateImage/index.js +26 -1
- package/dist/esm/activities/generateImage/index.js.map +1 -1
- package/dist/esm/activities/generateSpeech/index.d.ts +7 -0
- package/dist/esm/activities/generateSpeech/index.js +26 -1
- package/dist/esm/activities/generateSpeech/index.js.map +1 -1
- package/dist/esm/activities/generateTranscription/index.d.ts +7 -0
- package/dist/esm/activities/generateTranscription/index.js +26 -1
- package/dist/esm/activities/generateTranscription/index.js.map +1 -1
- package/dist/esm/activities/generateVideo/index.d.ts +9 -0
- package/dist/esm/activities/generateVideo/index.js +52 -2
- package/dist/esm/activities/generateVideo/index.js.map +1 -1
- package/dist/esm/activities/middleware/index.d.ts +2 -0
- package/dist/esm/activities/middleware/run.d.ts +20 -0
- package/dist/esm/activities/middleware/run.js +42 -0
- package/dist/esm/activities/middleware/run.js.map +1 -0
- package/dist/esm/activities/middleware/types.d.ts +118 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/middlewares/otel.d.ts +8 -2
- package/dist/esm/middlewares/otel.js +145 -95
- package/dist/esm/middlewares/otel.js.map +1 -1
- package/dist/esm/middlewares/usage-attributes.d.ts +24 -0
- package/dist/esm/middlewares/usage-attributes.js +43 -0
- package/dist/esm/middlewares/usage-attributes.js.map +1 -0
- package/dist/esm/types.d.ts +7 -7
- package/dist/esm/utilities/errors.d.ts +13 -0
- package/dist/esm/utilities/errors.js +22 -0
- package/dist/esm/utilities/errors.js.map +1 -0
- package/dist/esm/utilities/numbers.d.ts +8 -0
- package/dist/esm/utilities/numbers.js +12 -0
- package/dist/esm/utilities/numbers.js.map +1 -0
- package/package.json +3 -2
- package/src/activities/chat/index.ts +125 -35
- package/src/activities/chat/middleware/types.ts +7 -0
- package/src/activities/chat/tools/lazy-tool-manager.ts +46 -4
- package/src/activities/chat/tools/schema-converter.ts +146 -93
- package/src/activities/generateAudio/index.ts +42 -1
- package/src/activities/generateImage/index.ts +42 -1
- package/src/activities/generateSpeech/index.ts +42 -1
- package/src/activities/generateTranscription/index.ts +42 -1
- package/src/activities/generateVideo/index.ts +88 -2
- package/src/activities/middleware/index.ts +20 -0
- package/src/activities/middleware/run.ts +88 -0
- package/src/activities/middleware/types.ts +173 -0
- package/src/index.ts +19 -0
- package/src/middlewares/otel.ts +195 -120
- package/src/middlewares/usage-attributes.ts +65 -0
- package/src/types.ts +7 -7
- package/src/utilities/errors.ts +29 -0
- package/src/utilities/numbers.ts +15 -0
package/src/middlewares/otel.ts
CHANGED
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
MAX_TOKENS_KEYS,
|
|
9
9
|
NESTED_MAX_TOKENS_KEY,
|
|
10
10
|
} from '../utilities/sampling-keys'
|
|
11
|
+
import { firstNumber } from '../utilities/numbers'
|
|
12
|
+
import { errorMessage, errorTypeName } from '../utilities/errors'
|
|
13
|
+
import { usageAttributes } from './usage-attributes'
|
|
11
14
|
import type {
|
|
12
15
|
AttributeValue,
|
|
13
16
|
Exception,
|
|
@@ -20,7 +23,11 @@ import type {
|
|
|
20
23
|
ChatMiddleware,
|
|
21
24
|
ChatMiddlewareContext,
|
|
22
25
|
} from '../activities/chat/middleware/types'
|
|
23
|
-
import type {
|
|
26
|
+
import type {
|
|
27
|
+
GenerationActivity,
|
|
28
|
+
GenerationMiddleware,
|
|
29
|
+
GenerationMiddlewareContext,
|
|
30
|
+
} from '../activities/middleware/types'
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
33
|
* Scope (role) of an OTel span emitted by this middleware.
|
|
@@ -28,8 +35,10 @@ import type { TokenUsage } from '../types'
|
|
|
28
35
|
* - `chat` — the root span for a single `chat()` call
|
|
29
36
|
* - `iteration` — one per agent-loop iteration (one model call)
|
|
30
37
|
* - `tool` — one per tool execution inside an iteration
|
|
38
|
+
* - `generation` — the single span for a media activity call
|
|
39
|
+
* (`generateImage`, `generateVideo`, `generateSpeech`, …)
|
|
31
40
|
*/
|
|
32
|
-
export type OtelSpanScope = 'chat' | 'iteration' | 'tool'
|
|
41
|
+
export type OtelSpanScope = 'chat' | 'iteration' | 'tool' | 'generation'
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
44
|
* Alias retained for backwards compatibility. Prefer {@link OtelSpanScope}.
|
|
@@ -57,7 +66,24 @@ export type OtelSpanInfo<TScope extends OtelSpanScope = OtelSpanScope> =
|
|
|
57
66
|
toolName: string
|
|
58
67
|
toolCallId: string
|
|
59
68
|
}
|
|
60
|
-
:
|
|
69
|
+
: TScope extends 'generation'
|
|
70
|
+
? { kind: 'generation'; ctx: GenerationMiddlewareContext }
|
|
71
|
+
: never
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `gen_ai.operation.name` per activity. Chat uses the GenAI semconv value;
|
|
75
|
+
* media operations have no semconv entry yet, so these are the de-facto names
|
|
76
|
+
* consumed by GenAI backends (PostHog, Langfuse, …). Documented in
|
|
77
|
+
* `docs/advanced/otel.md`.
|
|
78
|
+
*/
|
|
79
|
+
const OPERATION_NAME: Record<GenerationActivity, string> = {
|
|
80
|
+
chat: 'chat',
|
|
81
|
+
image: 'image_generation',
|
|
82
|
+
video: 'video_generation',
|
|
83
|
+
audio: 'audio_generation',
|
|
84
|
+
tts: 'text_to_speech',
|
|
85
|
+
transcription: 'transcription',
|
|
86
|
+
}
|
|
61
87
|
|
|
62
88
|
export interface OtelMiddlewareOptions {
|
|
63
89
|
/** OTel `Tracer` used to start root, iteration, and tool spans. */
|
|
@@ -106,6 +132,12 @@ interface RequestState {
|
|
|
106
132
|
assistantTextBuffer: string
|
|
107
133
|
assistantTextBufferTruncated: boolean
|
|
108
134
|
startTime: number
|
|
135
|
+
/**
|
|
136
|
+
* Finish reason from the most recent `RUN_FINISHED` chunk. Captured in
|
|
137
|
+
* `onChunk` so `onFinish` can stamp it on the root span without reading it
|
|
138
|
+
* from the (base-shaped) finish info, which doesn't carry it.
|
|
139
|
+
*/
|
|
140
|
+
lastFinishReason: string | null
|
|
109
141
|
}
|
|
110
142
|
|
|
111
143
|
const stateByCtx = new WeakMap<ChatMiddlewareContext, RequestState>()
|
|
@@ -167,91 +199,6 @@ function messageEventName(role: string): string {
|
|
|
167
199
|
}
|
|
168
200
|
}
|
|
169
201
|
|
|
170
|
-
/**
|
|
171
|
-
* Return the first candidate that is a finite `number`, or `undefined`. Used to
|
|
172
|
-
* pick a sampling attribute from among the several provider-native spellings.
|
|
173
|
-
*/
|
|
174
|
-
function firstNumber(...candidates: Array<unknown>): number | undefined {
|
|
175
|
-
for (const candidate of candidates) {
|
|
176
|
-
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
|
177
|
-
return candidate
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return undefined
|
|
181
|
-
}
|
|
182
|
-
|
|
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
|
-
|
|
236
|
-
function errorMessage(err: unknown): string | undefined {
|
|
237
|
-
if (err instanceof Error) return err.message
|
|
238
|
-
if (typeof err === 'string') return err
|
|
239
|
-
if (err && typeof err === 'object' && 'message' in err) {
|
|
240
|
-
const m = (err as { message?: unknown }).message
|
|
241
|
-
if (typeof m === 'string') return m
|
|
242
|
-
}
|
|
243
|
-
return undefined
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function errorTypeName(err: unknown): string {
|
|
247
|
-
if (err instanceof Error) return err.name || 'Error'
|
|
248
|
-
if (err && typeof err === 'object' && 'name' in err) {
|
|
249
|
-
const n = (err as { name?: unknown }).name
|
|
250
|
-
if (typeof n === 'string') return n
|
|
251
|
-
}
|
|
252
|
-
return 'Error'
|
|
253
|
-
}
|
|
254
|
-
|
|
255
202
|
function safeCall<T>(label: string, fn: () => T): T | undefined {
|
|
256
203
|
try {
|
|
257
204
|
return fn()
|
|
@@ -264,7 +211,9 @@ function safeCall<T>(label: string, fn: () => T): T | undefined {
|
|
|
264
211
|
}
|
|
265
212
|
}
|
|
266
213
|
|
|
267
|
-
export function otelMiddleware(
|
|
214
|
+
export function otelMiddleware(
|
|
215
|
+
options: OtelMiddlewareOptions,
|
|
216
|
+
): GenerationMiddleware & ChatMiddleware {
|
|
268
217
|
const {
|
|
269
218
|
tracer,
|
|
270
219
|
meter,
|
|
@@ -333,20 +282,91 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
333
282
|
state.currentIterationSpan = null
|
|
334
283
|
}
|
|
335
284
|
|
|
285
|
+
// --- Media activities -----------------------------------------------------
|
|
286
|
+
// Media calls (image/video/audio/tts/transcription) are single request →
|
|
287
|
+
// response, so they get exactly one CLIENT span — opened in `onStart` and
|
|
288
|
+
// closed in the terminal hook. Keyed by the per-call context object, which is
|
|
289
|
+
// distinct from the chat state map above so the two paths never collide.
|
|
290
|
+
const mediaSpans = new WeakMap<GenerationMiddlewareContext, Span>()
|
|
291
|
+
|
|
292
|
+
const recordMediaDuration = (
|
|
293
|
+
ctx: GenerationMiddlewareContext,
|
|
294
|
+
durationMs: number,
|
|
295
|
+
errorType?: string,
|
|
296
|
+
): void => {
|
|
297
|
+
if (!durationHistogram) return
|
|
298
|
+
durationHistogram.record(durationMs / 1000, {
|
|
299
|
+
'gen_ai.system': ctx.provider,
|
|
300
|
+
'gen_ai.operation.name': OPERATION_NAME[ctx.activity],
|
|
301
|
+
'gen_ai.request.model': ctx.model,
|
|
302
|
+
...(errorType ? { 'error.type': errorType } : {}),
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const startMediaSpan = (ctx: GenerationMiddlewareContext): void => {
|
|
307
|
+
safeCall('otel.onStart', () => {
|
|
308
|
+
const operationName = OPERATION_NAME[ctx.activity]
|
|
309
|
+
const info: OtelSpanInfo<'generation'> = { kind: 'generation', ctx }
|
|
310
|
+
const name =
|
|
311
|
+
safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
|
|
312
|
+
`${operationName} ${ctx.model}`
|
|
313
|
+
const baseOptions: SpanOptions = {
|
|
314
|
+
kind: SpanKind.CLIENT,
|
|
315
|
+
attributes: {
|
|
316
|
+
'gen_ai.system': ctx.provider,
|
|
317
|
+
'gen_ai.operation.name': operationName,
|
|
318
|
+
'gen_ai.request.model': ctx.model,
|
|
319
|
+
},
|
|
320
|
+
}
|
|
321
|
+
const spanOptions =
|
|
322
|
+
safeCall('otel.onBeforeSpanStart', () =>
|
|
323
|
+
onBeforeSpanStart?.(info, baseOptions),
|
|
324
|
+
) ?? baseOptions
|
|
325
|
+
const span = tracer.startSpan(name, spanOptions)
|
|
326
|
+
const enriched = safeCall('otel.attributeEnricher', () =>
|
|
327
|
+
attributeEnricher?.(info),
|
|
328
|
+
)
|
|
329
|
+
if (enriched) span.setAttributes(enriched)
|
|
330
|
+
mediaSpans.set(ctx, span)
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const endMediaSpan = (
|
|
335
|
+
ctx: GenerationMiddlewareContext,
|
|
336
|
+
finalize: (span: Span) => void,
|
|
337
|
+
): void => {
|
|
338
|
+
const span = mediaSpans.get(ctx)
|
|
339
|
+
mediaSpans.delete(ctx)
|
|
340
|
+
if (!span) return
|
|
341
|
+
finalize(span)
|
|
342
|
+
safeCall('otel.onSpanEnd', () =>
|
|
343
|
+
onSpanEnd?.({ kind: 'generation', ctx }, span),
|
|
344
|
+
)
|
|
345
|
+
span.end()
|
|
346
|
+
}
|
|
347
|
+
|
|
336
348
|
return {
|
|
337
349
|
name: 'otel',
|
|
338
350
|
|
|
339
351
|
onStart(ctx) {
|
|
352
|
+
// Media activities get one CLIENT span; chat builds the root/iteration
|
|
353
|
+
// tree below. The cast is sound: the chat runner only ever passes a
|
|
354
|
+
// ChatMiddlewareContext, which `activity: 'chat'` narrows to at runtime.
|
|
355
|
+
if (ctx.activity !== 'chat') {
|
|
356
|
+
startMediaSpan(ctx)
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const chatCtx = ctx as ChatMiddlewareContext
|
|
340
360
|
safeCall('otel.onStart', () => {
|
|
341
|
-
const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx }
|
|
361
|
+
const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx: chatCtx }
|
|
342
362
|
const name =
|
|
343
363
|
safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
|
|
344
|
-
`chat ${
|
|
364
|
+
`chat ${chatCtx.model}`
|
|
345
365
|
const baseOptions: SpanOptions = {
|
|
346
366
|
kind: SpanKind.INTERNAL,
|
|
347
367
|
attributes: {
|
|
348
|
-
'gen_ai.system':
|
|
349
|
-
'gen_ai.request.model':
|
|
368
|
+
'gen_ai.system': chatCtx.provider,
|
|
369
|
+
'gen_ai.request.model': chatCtx.model,
|
|
350
370
|
// NOTE: `gen_ai.operation.name` is deliberately NOT set on the
|
|
351
371
|
// root span. The root represents a `chat()` invocation that may
|
|
352
372
|
// span multiple model calls; only iteration spans correspond to
|
|
@@ -366,7 +386,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
366
386
|
)
|
|
367
387
|
if (enriched) rootSpan.setAttributes(enriched)
|
|
368
388
|
|
|
369
|
-
stateByCtx.set(
|
|
389
|
+
stateByCtx.set(chatCtx, {
|
|
370
390
|
rootSpan,
|
|
371
391
|
currentIterationSpan: null,
|
|
372
392
|
toolSpans: new Map(),
|
|
@@ -374,6 +394,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
374
394
|
assistantTextBuffer: '',
|
|
375
395
|
assistantTextBufferTruncated: false,
|
|
376
396
|
startTime: Date.now(),
|
|
397
|
+
lastFinishReason: null,
|
|
377
398
|
})
|
|
378
399
|
})
|
|
379
400
|
},
|
|
@@ -562,6 +583,9 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
562
583
|
}
|
|
563
584
|
|
|
564
585
|
if (chunk.type !== 'RUN_FINISHED') return
|
|
586
|
+
// Capture for the root-span finish_reasons attribute set in onFinish,
|
|
587
|
+
// which receives base-shaped info without a finishReason field.
|
|
588
|
+
if (chunk.finishReason) state.lastFinishReason = chunk.finishReason
|
|
565
589
|
const span = state.currentIterationSpan
|
|
566
590
|
if (!span) return
|
|
567
591
|
|
|
@@ -611,8 +635,19 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
611
635
|
},
|
|
612
636
|
|
|
613
637
|
onUsage(ctx, usage) {
|
|
638
|
+
if (ctx.activity !== 'chat') {
|
|
639
|
+
// Media: stamp usage on the single span. No token histogram — media
|
|
640
|
+
// unit billing lands as span attributes via usageAttributes, matching
|
|
641
|
+
// prior media behavior and avoiding chat-shaped token metrics.
|
|
642
|
+
safeCall('otel.onUsage', () => {
|
|
643
|
+
const span = mediaSpans.get(ctx)
|
|
644
|
+
if (span) span.setAttributes(usageAttributes(usage))
|
|
645
|
+
})
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
const chatCtx = ctx as ChatMiddlewareContext
|
|
614
649
|
safeCall('otel.onUsage', () => {
|
|
615
|
-
const state = stateByCtx.get(
|
|
650
|
+
const state = stateByCtx.get(chatCtx)
|
|
616
651
|
if (!state) return
|
|
617
652
|
|
|
618
653
|
// Always record the token histogram — metrics don't depend on having
|
|
@@ -620,9 +655,9 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
620
655
|
// adapter emits `onUsage` outside the iteration window.
|
|
621
656
|
if (tokenHistogram) {
|
|
622
657
|
const metricAttrs = {
|
|
623
|
-
'gen_ai.system':
|
|
658
|
+
'gen_ai.system': chatCtx.provider,
|
|
624
659
|
'gen_ai.operation.name': 'chat',
|
|
625
|
-
'gen_ai.request.model':
|
|
660
|
+
'gen_ai.request.model': chatCtx.model,
|
|
626
661
|
}
|
|
627
662
|
tokenHistogram.record(usage.promptTokens, {
|
|
628
663
|
...metricAttrs,
|
|
@@ -773,8 +808,23 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
773
808
|
},
|
|
774
809
|
|
|
775
810
|
onError(ctx, info) {
|
|
811
|
+
if (ctx.activity !== 'chat') {
|
|
812
|
+
safeCall('otel.onError', () => {
|
|
813
|
+
const message = errorMessage(info.error)
|
|
814
|
+
endMediaSpan(ctx, (span) => {
|
|
815
|
+
span.recordException(info.error as Exception)
|
|
816
|
+
span.setStatus({
|
|
817
|
+
code: SpanStatusCode.ERROR,
|
|
818
|
+
...(message !== undefined ? { message } : {}),
|
|
819
|
+
})
|
|
820
|
+
})
|
|
821
|
+
recordMediaDuration(ctx, info.duration, errorTypeName(info.error))
|
|
822
|
+
})
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
const chatCtx = ctx as ChatMiddlewareContext
|
|
776
826
|
safeCall('otel.onError', () => {
|
|
777
|
-
const state = stateByCtx.get(
|
|
827
|
+
const state = stateByCtx.get(chatCtx)
|
|
778
828
|
if (!state) return
|
|
779
829
|
|
|
780
830
|
const errType = errorTypeName(info.error)
|
|
@@ -794,7 +844,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
794
844
|
onSpanEnd?.(
|
|
795
845
|
{
|
|
796
846
|
kind: 'iteration',
|
|
797
|
-
ctx,
|
|
847
|
+
ctx: chatCtx,
|
|
798
848
|
iteration: state.iterationCount - 1,
|
|
799
849
|
},
|
|
800
850
|
iterationSpan,
|
|
@@ -812,7 +862,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
812
862
|
onSpanEnd?.(
|
|
813
863
|
{
|
|
814
864
|
kind: 'tool',
|
|
815
|
-
ctx,
|
|
865
|
+
ctx: chatCtx,
|
|
816
866
|
toolCallId: id,
|
|
817
867
|
toolName,
|
|
818
868
|
iteration: state.iterationCount - 1,
|
|
@@ -832,24 +882,39 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
832
882
|
|
|
833
883
|
if (durationHistogram) {
|
|
834
884
|
durationHistogram.record(info.duration / 1000, {
|
|
835
|
-
'gen_ai.system':
|
|
885
|
+
'gen_ai.system': chatCtx.provider,
|
|
836
886
|
'gen_ai.operation.name': 'chat',
|
|
837
|
-
'gen_ai.request.model':
|
|
887
|
+
'gen_ai.request.model': chatCtx.model,
|
|
838
888
|
'error.type': errType,
|
|
839
889
|
})
|
|
840
890
|
}
|
|
841
891
|
|
|
842
892
|
safeCall('otel.onSpanEnd', () =>
|
|
843
|
-
onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
|
|
893
|
+
onSpanEnd?.({ kind: 'chat', ctx: chatCtx }, state.rootSpan),
|
|
844
894
|
)
|
|
845
895
|
state.rootSpan.end()
|
|
846
|
-
stateByCtx.delete(
|
|
896
|
+
stateByCtx.delete(chatCtx)
|
|
847
897
|
})
|
|
848
898
|
},
|
|
849
899
|
|
|
850
900
|
onAbort(ctx, info) {
|
|
901
|
+
if (ctx.activity !== 'chat') {
|
|
902
|
+
// Media abandonment (e.g. a video stream dropped before completion).
|
|
903
|
+
safeCall('otel.onAbort', () => {
|
|
904
|
+
endMediaSpan(ctx, (span) => {
|
|
905
|
+
span.setAttribute('tanstack.ai.completion.reason', 'cancelled')
|
|
906
|
+
span.setStatus({
|
|
907
|
+
code: SpanStatusCode.ERROR,
|
|
908
|
+
message: info.reason ?? 'cancelled',
|
|
909
|
+
})
|
|
910
|
+
})
|
|
911
|
+
recordMediaDuration(ctx, info.duration, 'cancelled')
|
|
912
|
+
})
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
const chatCtx = ctx as ChatMiddlewareContext
|
|
851
916
|
safeCall('otel.onAbort', () => {
|
|
852
|
-
const state = stateByCtx.get(
|
|
917
|
+
const state = stateByCtx.get(chatCtx)
|
|
853
918
|
if (!state) return
|
|
854
919
|
|
|
855
920
|
const closeCancelled = (span: Span): void => {
|
|
@@ -867,7 +932,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
867
932
|
onSpanEnd?.(
|
|
868
933
|
{
|
|
869
934
|
kind: 'iteration',
|
|
870
|
-
ctx,
|
|
935
|
+
ctx: chatCtx,
|
|
871
936
|
iteration: state.iterationCount - 1,
|
|
872
937
|
},
|
|
873
938
|
iterationSpan,
|
|
@@ -883,7 +948,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
883
948
|
onSpanEnd?.(
|
|
884
949
|
{
|
|
885
950
|
kind: 'tool',
|
|
886
|
-
ctx,
|
|
951
|
+
ctx: chatCtx,
|
|
887
952
|
toolCallId: id,
|
|
888
953
|
toolName,
|
|
889
954
|
iteration: state.iterationCount - 1,
|
|
@@ -898,24 +963,34 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
898
963
|
|
|
899
964
|
if (durationHistogram) {
|
|
900
965
|
durationHistogram.record(info.duration / 1000, {
|
|
901
|
-
'gen_ai.system':
|
|
966
|
+
'gen_ai.system': chatCtx.provider,
|
|
902
967
|
'gen_ai.operation.name': 'chat',
|
|
903
|
-
'gen_ai.request.model':
|
|
968
|
+
'gen_ai.request.model': chatCtx.model,
|
|
904
969
|
'error.type': 'cancelled',
|
|
905
970
|
})
|
|
906
971
|
}
|
|
907
972
|
|
|
908
973
|
safeCall('otel.onSpanEnd', () =>
|
|
909
|
-
onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
|
|
974
|
+
onSpanEnd?.({ kind: 'chat', ctx: chatCtx }, state.rootSpan),
|
|
910
975
|
)
|
|
911
976
|
state.rootSpan.end()
|
|
912
|
-
stateByCtx.delete(
|
|
977
|
+
stateByCtx.delete(chatCtx)
|
|
913
978
|
})
|
|
914
979
|
},
|
|
915
980
|
|
|
916
981
|
onFinish(ctx, info) {
|
|
982
|
+
if (ctx.activity !== 'chat') {
|
|
983
|
+
safeCall('otel.onFinish', () => {
|
|
984
|
+
endMediaSpan(ctx, (span) => {
|
|
985
|
+
if (info.usage) span.setAttributes(usageAttributes(info.usage))
|
|
986
|
+
})
|
|
987
|
+
recordMediaDuration(ctx, info.duration)
|
|
988
|
+
})
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
const chatCtx = ctx as ChatMiddlewareContext
|
|
917
992
|
safeCall('otel.onFinish', () => {
|
|
918
|
-
const state = stateByCtx.get(
|
|
993
|
+
const state = stateByCtx.get(chatCtx)
|
|
919
994
|
if (!state) return
|
|
920
995
|
|
|
921
996
|
// Close any tool spans that never received `onAfterToolCall` (adapter
|
|
@@ -928,7 +1003,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
928
1003
|
onSpanEnd?.(
|
|
929
1004
|
{
|
|
930
1005
|
kind: 'tool',
|
|
931
|
-
ctx,
|
|
1006
|
+
ctx: chatCtx,
|
|
932
1007
|
toolCallId: id,
|
|
933
1008
|
toolName,
|
|
934
1009
|
iteration: state.iterationCount - 1,
|
|
@@ -942,22 +1017,22 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
942
1017
|
|
|
943
1018
|
// The final iteration's span is still open because we keep it open
|
|
944
1019
|
// through tool execution and `onUsage`. Close it now.
|
|
945
|
-
closeIterationSpan(state,
|
|
1020
|
+
closeIterationSpan(state, chatCtx)
|
|
946
1021
|
|
|
947
1022
|
if (durationHistogram) {
|
|
948
1023
|
durationHistogram.record(info.duration / 1000, {
|
|
949
|
-
'gen_ai.system':
|
|
1024
|
+
'gen_ai.system': chatCtx.provider,
|
|
950
1025
|
'gen_ai.operation.name': 'chat',
|
|
951
|
-
'gen_ai.request.model':
|
|
1026
|
+
'gen_ai.request.model': chatCtx.model,
|
|
952
1027
|
})
|
|
953
1028
|
}
|
|
954
1029
|
|
|
955
1030
|
if (info.usage) {
|
|
956
1031
|
state.rootSpan.setAttributes(usageAttributes(info.usage))
|
|
957
1032
|
}
|
|
958
|
-
if (
|
|
1033
|
+
if (state.lastFinishReason) {
|
|
959
1034
|
state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [
|
|
960
|
-
|
|
1035
|
+
state.lastFinishReason,
|
|
961
1036
|
])
|
|
962
1037
|
}
|
|
963
1038
|
state.rootSpan.setAttribute(
|
|
@@ -966,10 +1041,10 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
|
|
|
966
1041
|
)
|
|
967
1042
|
|
|
968
1043
|
safeCall('otel.onSpanEnd', () =>
|
|
969
|
-
onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
|
|
1044
|
+
onSpanEnd?.({ kind: 'chat', ctx: chatCtx }, state.rootSpan),
|
|
970
1045
|
)
|
|
971
1046
|
state.rootSpan.end()
|
|
972
|
-
stateByCtx.delete(
|
|
1047
|
+
stateByCtx.delete(chatCtx)
|
|
973
1048
|
})
|
|
974
1049
|
},
|
|
975
1050
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { firstNumber } from '../utilities/numbers'
|
|
2
|
+
import type { AttributeValue } from '@opentelemetry/api'
|
|
3
|
+
import type { TokenUsage } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the full set of `gen_ai.usage.*` span attributes from a `TokenUsage`.
|
|
7
|
+
*
|
|
8
|
+
* Beyond input/output tokens, this emits provider-reported cost, total tokens,
|
|
9
|
+
* cache and reasoning breakdowns, duration-based billing, and media unit counts
|
|
10
|
+
* — every field is guarded so spans stay clean when a provider doesn't report
|
|
11
|
+
* it. Cache and reasoning use the official GenAI semconv names;
|
|
12
|
+
* `gen_ai.usage.cost` and `gen_ai.usage.total_tokens` are de-facto extensions
|
|
13
|
+
* consumed by backends like PostHog (which otherwise re-derive cost from their
|
|
14
|
+
* own price tables, losing cache discounts and gateway markup). Fields with no
|
|
15
|
+
* semconv or de-facto convention (`costDetails`, `durationSeconds`,
|
|
16
|
+
* `unitsBilled`) are TanStack-namespaced.
|
|
17
|
+
*
|
|
18
|
+
* Shared by `otelMiddleware` across every activity (chat and the media
|
|
19
|
+
* activities) so usage lands identically whichever activity produced the span.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately not emitted: `providerUsageDetails` (a provider-shaped bag,
|
|
22
|
+
* unsafe to spread onto spans) and the per-modality token breakdowns
|
|
23
|
+
* (`promptTokensDetails.audioTokens`, etc.) — those can balloon the attribute
|
|
24
|
+
* set and have no agreed convention yet.
|
|
25
|
+
*/
|
|
26
|
+
export function usageAttributes(
|
|
27
|
+
usage: TokenUsage,
|
|
28
|
+
): Record<string, AttributeValue> {
|
|
29
|
+
const attrs: Record<string, AttributeValue> = {
|
|
30
|
+
'gen_ai.usage.input_tokens': usage.promptTokens,
|
|
31
|
+
'gen_ai.usage.output_tokens': usage.completionTokens,
|
|
32
|
+
}
|
|
33
|
+
const optional: Array<[key: string, value: unknown]> = [
|
|
34
|
+
['gen_ai.usage.total_tokens', usage.totalTokens],
|
|
35
|
+
['gen_ai.usage.cost', usage.cost],
|
|
36
|
+
[
|
|
37
|
+
'gen_ai.usage.cache_read.input_tokens',
|
|
38
|
+
usage.promptTokensDetails?.cachedTokens,
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'gen_ai.usage.cache_creation.input_tokens',
|
|
42
|
+
usage.promptTokensDetails?.cacheWriteTokens,
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
'gen_ai.usage.reasoning.output_tokens',
|
|
46
|
+
usage.completionTokensDetails?.reasoningTokens,
|
|
47
|
+
],
|
|
48
|
+
['tanstack.ai.usage.duration_seconds', usage.durationSeconds],
|
|
49
|
+
['tanstack.ai.usage.units_billed', usage.unitsBilled],
|
|
50
|
+
['tanstack.ai.usage.upstream_cost', usage.costDetails?.upstreamCost],
|
|
51
|
+
[
|
|
52
|
+
'tanstack.ai.usage.upstream_input_cost',
|
|
53
|
+
usage.costDetails?.upstreamInputCost,
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
'tanstack.ai.usage.upstream_output_cost',
|
|
57
|
+
usage.costDetails?.upstreamOutputCost,
|
|
58
|
+
],
|
|
59
|
+
]
|
|
60
|
+
for (const [key, value] of optional) {
|
|
61
|
+
const num = firstNumber(value)
|
|
62
|
+
if (num !== undefined) attrs[key] = num
|
|
63
|
+
}
|
|
64
|
+
return attrs
|
|
65
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -820,14 +820,14 @@ export interface TextOptions<
|
|
|
820
820
|
systemPrompts?: Array<SystemPrompt>
|
|
821
821
|
agentLoopStrategy?: AgentLoopStrategy
|
|
822
822
|
/**
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
823
|
+
* Observability metadata attached to this call. Surfaced to middleware,
|
|
824
|
+
* devtools, and the event client; values may be arbitrarily structured
|
|
825
|
+
* (objects, arrays). Adapters never forward this field onto the provider
|
|
826
|
+
* wire request.
|
|
826
827
|
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
* - Gemini: Not directly available in TextProviderOptions
|
|
828
|
+
* To send provider-side request metadata, use the provider's
|
|
829
|
+
* `modelOptions` field instead, where the provider supports one (e.g.
|
|
830
|
+
* OpenAI's and OpenRouter's `metadata` are both Record<string, string>).
|
|
831
831
|
*/
|
|
832
832
|
metadata?: Record<string, any> | undefined
|
|
833
833
|
modelOptions?: TProviderOptionsForModel
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort extraction of a human-readable message from an unknown thrown
|
|
3
|
+
* value, returning `undefined` when none can be found.
|
|
4
|
+
*
|
|
5
|
+
* Used by `otelMiddleware` so error reporting stays identical across chat and
|
|
6
|
+
* media spans.
|
|
7
|
+
*/
|
|
8
|
+
export function errorMessage(err: unknown): string | undefined {
|
|
9
|
+
if (err instanceof Error) return err.message
|
|
10
|
+
if (typeof err === 'string') return err
|
|
11
|
+
if (err && typeof err === 'object' && 'message' in err) {
|
|
12
|
+
const m = (err as { message?: unknown }).message
|
|
13
|
+
if (typeof m === 'string') return m
|
|
14
|
+
}
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort extraction of an error's type name (used for the `error.type`
|
|
20
|
+
* metric attribute), falling back to `'Error'` when no name is available.
|
|
21
|
+
*/
|
|
22
|
+
export function errorTypeName(err: unknown): string {
|
|
23
|
+
if (err instanceof Error) return err.name || 'Error'
|
|
24
|
+
if (err && typeof err === 'object' && 'name' in err) {
|
|
25
|
+
const n = (err as { name?: unknown }).name
|
|
26
|
+
if (typeof n === 'string') return n
|
|
27
|
+
}
|
|
28
|
+
return 'Error'
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return the first candidate that is a finite `number`, or `undefined`.
|
|
3
|
+
*
|
|
4
|
+
* Handy for picking a value from among several possible spellings/sources where
|
|
5
|
+
* only some are populated — e.g. the provider-native sampling option names read
|
|
6
|
+
* by the OTel middleware, or the optional numeric fields on `TokenUsage`.
|
|
7
|
+
*/
|
|
8
|
+
export function firstNumber(...candidates: Array<unknown>): number | undefined {
|
|
9
|
+
for (const candidate of candidates) {
|
|
10
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
|
11
|
+
return candidate
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return undefined
|
|
15
|
+
}
|