@tanstack/ai 0.14.0 → 0.16.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.
Files changed (30) hide show
  1. package/dist/esm/activities/chat/adapter.d.ts +6 -3
  2. package/dist/esm/activities/chat/adapter.js.map +1 -1
  3. package/dist/esm/activities/chat/index.js +61 -9
  4. package/dist/esm/activities/chat/index.js.map +1 -1
  5. package/dist/esm/activities/chat/messages.js +26 -3
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/stream/message-updaters.d.ts +4 -2
  8. package/dist/esm/activities/chat/stream/message-updaters.js +10 -4
  9. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  10. package/dist/esm/activities/chat/stream/processor.d.ts +21 -5
  11. package/dist/esm/activities/chat/stream/processor.js +119 -19
  12. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  13. package/dist/esm/activities/chat/stream/types.d.ts +9 -1
  14. package/dist/esm/activities/chat/tools/tool-calls.js +3 -4
  15. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  16. package/dist/esm/middlewares/otel.d.ts +75 -0
  17. package/dist/esm/middlewares/otel.js +624 -0
  18. package/dist/esm/middlewares/otel.js.map +1 -0
  19. package/dist/esm/types.d.ts +23 -7
  20. package/package.json +15 -2
  21. package/src/activities/chat/adapter.ts +8 -2
  22. package/src/activities/chat/index.ts +74 -11
  23. package/src/activities/chat/messages.ts +29 -1
  24. package/src/activities/chat/stream/message-updaters.ts +18 -3
  25. package/src/activities/chat/stream/processor.ts +152 -17
  26. package/src/activities/chat/stream/types.ts +9 -1
  27. package/src/activities/chat/tools/tool-calls.ts +4 -4
  28. package/src/middlewares/index.ts +5 -0
  29. package/src/middlewares/otel.ts +861 -0
  30. package/src/types.ts +20 -7
@@ -0,0 +1,861 @@
1
+ import {
2
+ SpanKind,
3
+ SpanStatusCode,
4
+ context as otelContext,
5
+ trace as otelTrace,
6
+ } from '@opentelemetry/api'
7
+ import type {
8
+ AttributeValue,
9
+ Exception,
10
+ Meter,
11
+ Span,
12
+ SpanOptions,
13
+ Tracer,
14
+ } from '@opentelemetry/api'
15
+ import type {
16
+ ChatMiddleware,
17
+ ChatMiddlewareContext,
18
+ } from '../activities/chat/middleware/types'
19
+
20
+ /**
21
+ * Scope (role) of an OTel span emitted by this middleware.
22
+ *
23
+ * - `chat` — the root span for a single `chat()` call
24
+ * - `iteration` — one per agent-loop iteration (one model call)
25
+ * - `tool` — one per tool execution inside an iteration
26
+ */
27
+ export type OtelSpanScope = 'chat' | 'iteration' | 'tool'
28
+
29
+ /**
30
+ * Alias retained for backwards compatibility. Prefer {@link OtelSpanScope}.
31
+ *
32
+ * @deprecated Use `OtelSpanScope` instead — the name shadows OTel's built-in
33
+ * `SpanKind` which is also imported by integrations of this middleware.
34
+ */
35
+ export type OtelSpanKind = OtelSpanScope
36
+
37
+ /**
38
+ * Span metadata passed to `spanNameFormatter`, `attributeEnricher`,
39
+ * `onBeforeSpanStart`, and `onSpanEnd`. Discriminated by `kind` so that
40
+ * tool-only fields narrow automatically inside callback bodies.
41
+ */
42
+ export type OtelSpanInfo<TScope extends OtelSpanScope = OtelSpanScope> =
43
+ TScope extends 'chat'
44
+ ? { kind: 'chat'; ctx: ChatMiddlewareContext }
45
+ : TScope extends 'iteration'
46
+ ? { kind: 'iteration'; ctx: ChatMiddlewareContext; iteration: number }
47
+ : TScope extends 'tool'
48
+ ? {
49
+ kind: 'tool'
50
+ ctx: ChatMiddlewareContext
51
+ iteration: number
52
+ toolName: string
53
+ toolCallId: string
54
+ }
55
+ : never
56
+
57
+ export interface OtelMiddlewareOptions {
58
+ /** OTel `Tracer` used to start root, iteration, and tool spans. */
59
+ tracer: Tracer
60
+ /**
61
+ * Optional OTel `Meter`. When provided, the middleware records
62
+ * `gen_ai.client.operation.duration` and `gen_ai.client.token.usage`
63
+ * histograms. Omit to disable metrics without disabling tracing.
64
+ */
65
+ meter?: Meter
66
+ /**
67
+ * When `true`, prompt and completion content is attached to iteration spans
68
+ * as `gen_ai.*.message` / `gen_ai.choice` events. Defaults to `false` so
69
+ * that PII never lands on a span by accident.
70
+ */
71
+ captureContent?: boolean
72
+ /**
73
+ * Invoked on every captured content string before it lands on a span.
74
+ * Return a redacted version. If this function throws, the middleware emits
75
+ * the literal sentinel `"[redaction_failed]"` instead of the original text
76
+ * — it never falls back to raw content.
77
+ */
78
+ redact?: (text: string) => string
79
+ /**
80
+ * Maximum characters kept in the per-iteration assistant text buffer used
81
+ * to emit `gen_ai.choice` events. Extra characters are truncated with a
82
+ * trailing `"…"` marker. Defaults to 100 000. Set to `0` to disable the
83
+ * cap. Exporters typically truncate long attribute values anyway.
84
+ */
85
+ maxContentLength?: number
86
+ /** Override the default span name for each `kind`. */
87
+ spanNameFormatter?: (info: OtelSpanInfo) => string
88
+ /** Add extra attributes to each span. */
89
+ attributeEnricher?: (info: OtelSpanInfo) => Record<string, AttributeValue>
90
+ /** Mutate `SpanOptions` immediately before `tracer.startSpan(...)`. */
91
+ onBeforeSpanStart?: (info: OtelSpanInfo, options: SpanOptions) => SpanOptions
92
+ /** Fires just before every `span.end()`. */
93
+ onSpanEnd?: (info: OtelSpanInfo, span: Span) => void
94
+ }
95
+
96
+ interface RequestState {
97
+ rootSpan: Span
98
+ currentIterationSpan: Span | null
99
+ toolSpans: Map<string, { span: Span; toolName: string }>
100
+ iterationCount: number
101
+ assistantTextBuffer: string
102
+ assistantTextBufferTruncated: boolean
103
+ startTime: number
104
+ }
105
+
106
+ const stateByCtx = new WeakMap<ChatMiddlewareContext, RequestState>()
107
+
108
+ const DEFAULT_MAX_CONTENT_LENGTH = 100_000
109
+ const REDACTION_FAILED_SENTINEL = '[redaction_failed]'
110
+
111
+ function serializeContent(content: unknown): string {
112
+ if (typeof content === 'string') return content
113
+ if (!Array.isArray(content)) return ''
114
+ const parts: Array<string> = []
115
+ for (const part of content) {
116
+ if (!part || typeof part !== 'object') continue
117
+ const type = (part as { type?: string }).type
118
+ switch (type) {
119
+ case 'text':
120
+ parts.push(
121
+ (
122
+ (part as { text?: string }).text ??
123
+ (part as { content?: string }).content ??
124
+ ''
125
+ ).toString(),
126
+ )
127
+ break
128
+ case 'image':
129
+ parts.push('[image]')
130
+ break
131
+ case 'audio':
132
+ parts.push('[audio]')
133
+ break
134
+ case 'video':
135
+ parts.push('[video]')
136
+ break
137
+ case 'document':
138
+ parts.push('[document]')
139
+ break
140
+ default:
141
+ parts.push(`[${type ?? 'unknown'}]`)
142
+ }
143
+ }
144
+ return parts.join(' ')
145
+ }
146
+
147
+ function messageEventName(role: string): string {
148
+ switch (role) {
149
+ case 'user':
150
+ return 'gen_ai.user.message'
151
+ case 'assistant':
152
+ return 'gen_ai.assistant.message'
153
+ case 'tool':
154
+ return 'gen_ai.tool.message'
155
+ case 'system':
156
+ return 'gen_ai.system.message'
157
+ default:
158
+ return `gen_ai.${role}.message`
159
+ }
160
+ }
161
+
162
+ function errorMessage(err: unknown): string | undefined {
163
+ if (err instanceof Error) return err.message
164
+ if (typeof err === 'string') return err
165
+ if (err && typeof err === 'object' && 'message' in err) {
166
+ const m = (err as { message?: unknown }).message
167
+ if (typeof m === 'string') return m
168
+ }
169
+ return undefined
170
+ }
171
+
172
+ function errorTypeName(err: unknown): string {
173
+ if (err instanceof Error) return err.name || 'Error'
174
+ if (err && typeof err === 'object' && 'name' in err) {
175
+ const n = (err as { name?: unknown }).name
176
+ if (typeof n === 'string') return n
177
+ }
178
+ return 'Error'
179
+ }
180
+
181
+ function safeCall<T>(label: string, fn: () => T): T | undefined {
182
+ try {
183
+ return fn()
184
+ } catch (err) {
185
+ // Keep middleware non-fatal, but surface callback failures so that broken
186
+ // extension points (attributeEnricher, spanNameFormatter, onSpanEnd, ...)
187
+ // are observable. Matches the guarantee documented in docs/advanced/otel.md.
188
+ console.warn(`[otelMiddleware] ${label} failed`, err)
189
+ return undefined
190
+ }
191
+ }
192
+
193
+ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
194
+ const {
195
+ tracer,
196
+ meter,
197
+ captureContent = false,
198
+ redact = (s) => s,
199
+ maxContentLength = DEFAULT_MAX_CONTENT_LENGTH,
200
+ spanNameFormatter,
201
+ attributeEnricher,
202
+ onBeforeSpanStart,
203
+ onSpanEnd,
204
+ } = options
205
+
206
+ const durationHistogram = meter?.createHistogram(
207
+ 'gen_ai.client.operation.duration',
208
+ {
209
+ description: 'GenAI client operation duration',
210
+ unit: 's',
211
+ },
212
+ )
213
+ const tokenHistogram = meter?.createHistogram('gen_ai.client.token.usage', {
214
+ description: 'GenAI client token usage',
215
+ unit: '{token}',
216
+ })
217
+
218
+ // Redact user content, failing closed to a sentinel string instead of ever
219
+ // letting raw text through. Callers that pass `captureContent: true` with a
220
+ // third-party PII redactor depend on this invariant.
221
+ const redactContent = (text: string): string => {
222
+ try {
223
+ return redact(text)
224
+ } catch (err) {
225
+ console.warn('[otelMiddleware] otel.redact failed', err)
226
+ return REDACTION_FAILED_SENTINEL
227
+ }
228
+ }
229
+
230
+ const appendAssistantText = (state: RequestState, delta: string): void => {
231
+ if (maxContentLength > 0) {
232
+ if (state.assistantTextBufferTruncated) return
233
+ const remaining = maxContentLength - state.assistantTextBuffer.length
234
+ if (remaining <= 0) {
235
+ state.assistantTextBufferTruncated = true
236
+ state.assistantTextBuffer += '…'
237
+ return
238
+ }
239
+ if (delta.length > remaining) {
240
+ state.assistantTextBuffer += delta.slice(0, remaining) + '…'
241
+ state.assistantTextBufferTruncated = true
242
+ return
243
+ }
244
+ }
245
+ state.assistantTextBuffer += delta
246
+ }
247
+
248
+ const closeIterationSpan = (
249
+ state: RequestState,
250
+ ctx: ChatMiddlewareContext,
251
+ ): void => {
252
+ if (!state.currentIterationSpan) return
253
+ const span = state.currentIterationSpan
254
+ const iteration = state.iterationCount - 1
255
+ safeCall('otel.onSpanEnd', () =>
256
+ onSpanEnd?.(
257
+ { kind: 'iteration', ctx, iteration } as OtelSpanInfo<'iteration'>,
258
+ span,
259
+ ),
260
+ )
261
+ span.end()
262
+ state.currentIterationSpan = null
263
+ }
264
+
265
+ return {
266
+ name: 'otel',
267
+
268
+ onStart(ctx) {
269
+ safeCall('otel.onStart', () => {
270
+ const info: OtelSpanInfo<'chat'> = { kind: 'chat', ctx }
271
+ const name =
272
+ safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
273
+ `chat ${ctx.model}`
274
+ const baseOptions: SpanOptions = {
275
+ kind: SpanKind.INTERNAL,
276
+ attributes: {
277
+ 'gen_ai.system': ctx.provider,
278
+ 'gen_ai.request.model': ctx.model,
279
+ // NOTE: `gen_ai.operation.name` is deliberately NOT set on the
280
+ // root span. The root represents a `chat()` invocation that may
281
+ // span multiple model calls; only iteration spans correspond to
282
+ // a single chat operation. Backends that map `operation.name=chat`
283
+ // to a "generation" event (e.g. PostHog LLM Analytics) would
284
+ // otherwise emit a duplicate generation for the wrapper span.
285
+ },
286
+ }
287
+ const spanOptions =
288
+ safeCall('otel.onBeforeSpanStart', () =>
289
+ onBeforeSpanStart?.(info, baseOptions),
290
+ ) ?? baseOptions
291
+ const rootSpan = tracer.startSpan(name, spanOptions)
292
+
293
+ const enriched = safeCall('otel.attributeEnricher', () =>
294
+ attributeEnricher?.(info),
295
+ )
296
+ if (enriched) rootSpan.setAttributes(enriched)
297
+
298
+ stateByCtx.set(ctx, {
299
+ rootSpan,
300
+ currentIterationSpan: null,
301
+ toolSpans: new Map(),
302
+ iterationCount: 0,
303
+ assistantTextBuffer: '',
304
+ assistantTextBufferTruncated: false,
305
+ startTime: Date.now(),
306
+ })
307
+ })
308
+ },
309
+
310
+ onConfig(ctx, config) {
311
+ if (ctx.phase !== 'beforeModel') return
312
+ safeCall('otel.onConfig', () => {
313
+ const state = stateByCtx.get(ctx)
314
+ if (!state) return
315
+
316
+ // The previous iteration's span stays open through tool execution and
317
+ // onUsage so that tool spans nest under it and token attributes land
318
+ // on it. Close it here, just before opening the next iteration.
319
+ closeIterationSpan(state, ctx)
320
+
321
+ const info: OtelSpanInfo<'iteration'> = {
322
+ kind: 'iteration',
323
+ ctx,
324
+ iteration: ctx.iteration,
325
+ }
326
+ const name =
327
+ safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
328
+ `chat ${ctx.model} #${ctx.iteration}`
329
+
330
+ const baseAttrs: Record<string, AttributeValue> = {
331
+ 'gen_ai.system': ctx.provider,
332
+ 'gen_ai.operation.name': 'chat',
333
+ 'gen_ai.request.model': ctx.model,
334
+ 'tanstack.ai.iteration': ctx.iteration,
335
+ }
336
+ if (config.temperature !== undefined)
337
+ baseAttrs['gen_ai.request.temperature'] = config.temperature
338
+ if (config.topP !== undefined)
339
+ baseAttrs['gen_ai.request.top_p'] = config.topP
340
+ if (config.maxTokens !== undefined)
341
+ baseAttrs['gen_ai.request.max_tokens'] = config.maxTokens
342
+
343
+ const baseOptions: SpanOptions = {
344
+ kind: SpanKind.CLIENT,
345
+ attributes: baseAttrs,
346
+ }
347
+ const spanOptions =
348
+ safeCall('otel.onBeforeSpanStart', () =>
349
+ onBeforeSpanStart?.(info, baseOptions),
350
+ ) ?? baseOptions
351
+
352
+ const parentCtx = otelTrace.setSpan(
353
+ otelContext.active(),
354
+ state.rootSpan,
355
+ )
356
+ let iterSpan!: Span
357
+ otelContext.with(parentCtx, () => {
358
+ // Pass the parent context explicitly as the 3rd arg — this is a
359
+ // real-OTel-compatible way to ensure the span is parented to
360
+ // `rootSpan` even when the host app has not registered a context
361
+ // manager (e.g. in tests or minimal setups).
362
+ iterSpan = tracer.startSpan(name, spanOptions, parentCtx)
363
+ })
364
+
365
+ const enriched = safeCall('otel.attributeEnricher', () =>
366
+ attributeEnricher?.(info),
367
+ )
368
+ if (enriched) iterSpan.setAttributes(enriched)
369
+
370
+ state.currentIterationSpan = iterSpan
371
+ state.assistantTextBuffer = ''
372
+ state.assistantTextBufferTruncated = false
373
+
374
+ if (captureContent) {
375
+ // Span events follow the original GenAI semconv (one event per
376
+ // message). Backends that read events get content this way.
377
+ for (const sys of config.systemPrompts) {
378
+ iterSpan.addEvent('gen_ai.system.message', {
379
+ content: redactContent(sys),
380
+ })
381
+ }
382
+ for (const m of config.messages) {
383
+ const body = serializeContent(m.content)
384
+ if (body.length === 0) continue
385
+ iterSpan.addEvent(messageEventName(m.role), {
386
+ content: redactContent(body),
387
+ })
388
+ }
389
+
390
+ // Also emit the current GenAI-semconv attribute form
391
+ // (`gen_ai.input.messages`) — backends like PostHog read prompt
392
+ // content from this attribute, not from span events.
393
+ const inputMessages: Array<{ role: string; content: string }> = []
394
+ for (const sys of config.systemPrompts) {
395
+ inputMessages.push({
396
+ role: 'system',
397
+ content: redactContent(sys),
398
+ })
399
+ }
400
+ for (const m of config.messages) {
401
+ const body = serializeContent(m.content)
402
+ if (body.length === 0) continue
403
+ inputMessages.push({
404
+ role: m.role,
405
+ content: redactContent(body),
406
+ })
407
+ }
408
+ if (inputMessages.length > 0) {
409
+ const inputJson = JSON.stringify(inputMessages)
410
+ // Current OTel GenAI semconv — Sentry / PostHog / Datadog read
411
+ // prompt content from this attribute.
412
+ iterSpan.setAttribute('gen_ai.input.messages', inputJson)
413
+ // Langfuse-native attribute. Highest priority in Langfuse's OTLP
414
+ // ingestion (checked before events and gen_ai.input.messages) so
415
+ // the Input panel populates reliably. Harmless to other backends —
416
+ // the attribute is namespaced and unrecognised keys are ignored.
417
+ iterSpan.setAttribute('langfuse.observation.input', inputJson)
418
+
419
+ // Mirror the first iteration's input onto the root span and at
420
+ // trace level so Langfuse fills Input on the trace card and the
421
+ // chat-level observation. Later iterations append tool-call /
422
+ // assistant messages that are useful per-iteration but noise at
423
+ // the chat / trace level.
424
+ if (state.iterationCount === 0) {
425
+ state.rootSpan.setAttribute(
426
+ 'langfuse.observation.input',
427
+ inputJson,
428
+ )
429
+ state.rootSpan.setAttribute('langfuse.trace.input', inputJson)
430
+ }
431
+ }
432
+ }
433
+
434
+ state.iterationCount += 1
435
+ })
436
+ return undefined
437
+ },
438
+
439
+ onChunk(ctx, chunk) {
440
+ safeCall('otel.onChunk', () => {
441
+ const state = stateByCtx.get(ctx)
442
+ if (!state) return
443
+
444
+ if (captureContent && chunk.type === 'TEXT_MESSAGE_CONTENT') {
445
+ appendAssistantText(state, chunk.delta)
446
+ }
447
+
448
+ if (chunk.type !== 'RUN_FINISHED') return
449
+ const span = state.currentIterationSpan
450
+ if (!span) return
451
+
452
+ if (chunk.finishReason) {
453
+ span.setAttribute('gen_ai.response.finish_reasons', [
454
+ chunk.finishReason,
455
+ ])
456
+ }
457
+ if (chunk.model) span.setAttribute('gen_ai.response.model', chunk.model)
458
+
459
+ // Set usage attributes on the iteration span directly from the chunk
460
+ // so they're available before `onUsage` fires. Histogram recording is
461
+ // deliberately NOT done here — the chat runner always invokes
462
+ // `runOnUsage` when `chunk.usage` is present, and `onUsage` is the
463
+ // canonical place for the metric. Recording in both would double-count.
464
+ if (chunk.usage) {
465
+ span.setAttributes({
466
+ 'gen_ai.usage.input_tokens': chunk.usage.promptTokens,
467
+ 'gen_ai.usage.output_tokens': chunk.usage.completionTokens,
468
+ })
469
+ }
470
+
471
+ if (captureContent && state.assistantTextBuffer.length > 0) {
472
+ const completion = redactContent(state.assistantTextBuffer)
473
+ const outputJson = JSON.stringify([
474
+ { role: 'assistant', content: completion },
475
+ ])
476
+ // Event form (older semconv) — kept for backends that consume it.
477
+ span.addEvent('gen_ai.choice', { content: completion })
478
+ // Attribute form (current semconv) — required by backends like
479
+ // PostHog that read completion content from `gen_ai.output.messages`.
480
+ span.setAttribute('gen_ai.output.messages', outputJson)
481
+ // Langfuse-native attribute (highest priority in Langfuse mapping).
482
+ span.setAttribute('langfuse.observation.output', outputJson)
483
+ // Mirror to the root span and trace card. Each iteration overwrites,
484
+ // so the final iteration's completion lands on the root — which is
485
+ // the final answer the user saw, not an intermediate tool-call turn.
486
+ state.rootSpan.setAttribute('langfuse.observation.output', outputJson)
487
+ state.rootSpan.setAttribute('langfuse.trace.output', outputJson)
488
+ state.assistantTextBuffer = ''
489
+ state.assistantTextBufferTruncated = false
490
+ }
491
+
492
+ // Intentionally leave the iteration span open: tool spans started
493
+ // after `RUN_FINISHED` (tool_calls finishReason) must nest under it,
494
+ // and `onUsage` may still fire. The span is closed in `onConfig` when
495
+ // the next iteration starts, or in `onFinish` / `onError` / `onAbort`.
496
+ })
497
+ return undefined
498
+ },
499
+
500
+ onUsage(ctx, usage) {
501
+ safeCall('otel.onUsage', () => {
502
+ const state = stateByCtx.get(ctx)
503
+ if (!state) return
504
+
505
+ // Always record the token histogram — metrics don't depend on having
506
+ // an iteration span, and skipping here would drop metric data if an
507
+ // adapter emits `onUsage` outside the iteration window.
508
+ if (tokenHistogram) {
509
+ const metricAttrs = {
510
+ 'gen_ai.system': ctx.provider,
511
+ 'gen_ai.operation.name': 'chat',
512
+ 'gen_ai.request.model': ctx.model,
513
+ }
514
+ tokenHistogram.record(usage.promptTokens, {
515
+ ...metricAttrs,
516
+ 'gen_ai.token.type': 'input',
517
+ })
518
+ tokenHistogram.record(usage.completionTokens, {
519
+ ...metricAttrs,
520
+ 'gen_ai.token.type': 'output',
521
+ })
522
+ }
523
+
524
+ const span = state.currentIterationSpan ?? state.rootSpan
525
+ span.setAttributes({
526
+ 'gen_ai.usage.input_tokens': usage.promptTokens,
527
+ 'gen_ai.usage.output_tokens': usage.completionTokens,
528
+ })
529
+ })
530
+ },
531
+
532
+ onBeforeToolCall(ctx, hookCtx) {
533
+ safeCall('otel.onBeforeToolCall', () => {
534
+ const state = stateByCtx.get(ctx)
535
+ if (!state) return
536
+ const parent = state.currentIterationSpan ?? state.rootSpan
537
+
538
+ const info: OtelSpanInfo<'tool'> = {
539
+ kind: 'tool',
540
+ ctx,
541
+ toolName: hookCtx.toolName,
542
+ toolCallId: hookCtx.toolCallId,
543
+ iteration: state.iterationCount - 1,
544
+ }
545
+ const name =
546
+ safeCall('otel.spanNameFormatter', () => spanNameFormatter?.(info)) ??
547
+ `execute_tool ${hookCtx.toolName}`
548
+
549
+ const baseAttrs: Record<string, AttributeValue> = {
550
+ 'gen_ai.tool.name': hookCtx.toolName,
551
+ 'gen_ai.tool.call.id': hookCtx.toolCallId,
552
+ 'gen_ai.tool.type': 'function',
553
+ }
554
+ const baseOptions: SpanOptions = {
555
+ kind: SpanKind.INTERNAL,
556
+ attributes: baseAttrs,
557
+ }
558
+ const spanOptions =
559
+ safeCall('otel.onBeforeSpanStart', () =>
560
+ onBeforeSpanStart?.(info, baseOptions),
561
+ ) ?? baseOptions
562
+
563
+ const parentCtx = otelTrace.setSpan(otelContext.active(), parent)
564
+ let toolSpan!: Span
565
+ otelContext.with(parentCtx, () => {
566
+ toolSpan = tracer.startSpan(name, spanOptions, parentCtx)
567
+ })
568
+
569
+ const enriched = safeCall('otel.attributeEnricher', () =>
570
+ attributeEnricher?.(info),
571
+ )
572
+ if (enriched) toolSpan.setAttributes(enriched)
573
+
574
+ // Stamp the tool args onto the tool span so backends that render an
575
+ // input panel per span (e.g. PostHog) have something to show.
576
+ if (captureContent) {
577
+ const argsBody =
578
+ typeof hookCtx.args === 'string'
579
+ ? hookCtx.args
580
+ : (safeCall('otel.serializeToolArgs', () =>
581
+ JSON.stringify(hookCtx.args ?? null),
582
+ ) ?? '[unserializable_tool_args]')
583
+ const redactedArgs = redactContent(argsBody)
584
+ const toolInputJson = JSON.stringify([
585
+ { role: 'tool', content: redactedArgs },
586
+ ])
587
+ toolSpan.setAttribute('gen_ai.input.messages', toolInputJson)
588
+ // Langfuse-native (highest priority in Langfuse mapping).
589
+ toolSpan.setAttribute('langfuse.observation.input', toolInputJson)
590
+ }
591
+
592
+ state.toolSpans.set(hookCtx.toolCallId, {
593
+ span: toolSpan,
594
+ toolName: hookCtx.toolName,
595
+ })
596
+ })
597
+ return undefined
598
+ },
599
+
600
+ onAfterToolCall(ctx, info) {
601
+ safeCall('otel.onAfterToolCall', () => {
602
+ const state = stateByCtx.get(ctx)
603
+ if (!state) return
604
+ const entry = state.toolSpans.get(info.toolCallId)
605
+ if (!entry) return
606
+ const { span: toolSpan } = entry
607
+
608
+ const outcome = info.ok ? 'success' : 'error'
609
+ toolSpan.setAttribute('tanstack.ai.tool.outcome', outcome)
610
+
611
+ if (!info.ok && info.error !== undefined) {
612
+ toolSpan.recordException(info.error as Exception)
613
+ toolSpan.setStatus({
614
+ code: SpanStatusCode.ERROR,
615
+ message: errorMessage(info.error),
616
+ })
617
+ }
618
+
619
+ if (captureContent) {
620
+ // Serialization can throw on circular refs or `BigInt` values. If it
621
+ // does, fall back to a sentinel so the rest of this handler (span
622
+ // end, onSpanEnd, toolSpans cleanup) still runs — otherwise the tool
623
+ // span would dangle until the onFinish/onError sweep.
624
+ const body =
625
+ typeof info.result === 'string'
626
+ ? info.result
627
+ : (safeCall('otel.serializeToolResult', () =>
628
+ JSON.stringify(info.result ?? null),
629
+ ) ?? '[unserializable_tool_result]')
630
+ const redactedBody = redactContent(body)
631
+ if (state.currentIterationSpan) {
632
+ state.currentIterationSpan.addEvent('gen_ai.tool.message', {
633
+ content: redactedBody,
634
+ tool_call_id: info.toolCallId,
635
+ })
636
+ }
637
+ // Output panel of the tool span itself — `gen_ai.output.messages` is
638
+ // what current GenAI semconv consumers (e.g. PostHog) read.
639
+ const toolOutputJson = JSON.stringify([
640
+ { role: 'tool', content: redactedBody },
641
+ ])
642
+ toolSpan.setAttribute('gen_ai.output.messages', toolOutputJson)
643
+ // Langfuse-native (highest priority in Langfuse mapping).
644
+ toolSpan.setAttribute('langfuse.observation.output', toolOutputJson)
645
+ }
646
+
647
+ safeCall('otel.onSpanEnd', () =>
648
+ onSpanEnd?.(
649
+ {
650
+ kind: 'tool',
651
+ ctx,
652
+ toolName: info.toolName,
653
+ toolCallId: info.toolCallId,
654
+ iteration: state.iterationCount - 1,
655
+ } as OtelSpanInfo<'tool'>,
656
+ toolSpan,
657
+ ),
658
+ )
659
+ toolSpan.end()
660
+ state.toolSpans.delete(info.toolCallId)
661
+ })
662
+ },
663
+
664
+ onError(ctx, info) {
665
+ safeCall('otel.onError', () => {
666
+ const state = stateByCtx.get(ctx)
667
+ if (!state) return
668
+
669
+ const errType = errorTypeName(info.error)
670
+ const message = errorMessage(info.error)
671
+ const exception = info.error as Exception
672
+
673
+ if (state.currentIterationSpan) {
674
+ state.currentIterationSpan.recordException(exception)
675
+ state.currentIterationSpan.setStatus({
676
+ code: SpanStatusCode.ERROR,
677
+ message,
678
+ })
679
+ safeCall('otel.onSpanEnd', () =>
680
+ onSpanEnd?.(
681
+ {
682
+ kind: 'iteration',
683
+ ctx,
684
+ iteration: state.iterationCount - 1,
685
+ } as OtelSpanInfo<'iteration'>,
686
+ state.currentIterationSpan!,
687
+ ),
688
+ )
689
+ state.currentIterationSpan.end()
690
+ state.currentIterationSpan = null
691
+ }
692
+
693
+ for (const [id, entry] of state.toolSpans) {
694
+ const { span, toolName } = entry
695
+ span.recordException(exception)
696
+ span.setStatus({ code: SpanStatusCode.ERROR, message })
697
+ safeCall('otel.onSpanEnd', () =>
698
+ onSpanEnd?.(
699
+ {
700
+ kind: 'tool',
701
+ ctx,
702
+ toolCallId: id,
703
+ toolName,
704
+ iteration: state.iterationCount - 1,
705
+ } as OtelSpanInfo<'tool'>,
706
+ span,
707
+ ),
708
+ )
709
+ span.end()
710
+ state.toolSpans.delete(id)
711
+ }
712
+
713
+ state.rootSpan.recordException(exception)
714
+ state.rootSpan.setStatus({ code: SpanStatusCode.ERROR, message })
715
+
716
+ if (durationHistogram) {
717
+ durationHistogram.record(info.duration / 1000, {
718
+ 'gen_ai.system': ctx.provider,
719
+ 'gen_ai.operation.name': 'chat',
720
+ 'gen_ai.request.model': ctx.model,
721
+ 'error.type': errType,
722
+ })
723
+ }
724
+
725
+ safeCall('otel.onSpanEnd', () =>
726
+ onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
727
+ )
728
+ state.rootSpan.end()
729
+ stateByCtx.delete(ctx)
730
+ })
731
+ },
732
+
733
+ onAbort(ctx, info) {
734
+ safeCall('otel.onAbort', () => {
735
+ const state = stateByCtx.get(ctx)
736
+ if (!state) return
737
+
738
+ const closeCancelled = (span: Span): void => {
739
+ // `gen_ai.completion.reason` is not part of the GenAI semconv; use a
740
+ // TanStack-namespaced attribute so downstream exporters don't treat
741
+ // it as standard. The span status still carries the error code.
742
+ span.setAttribute('tanstack.ai.completion.reason', 'cancelled')
743
+ span.setStatus({ code: SpanStatusCode.ERROR, message: 'cancelled' })
744
+ }
745
+
746
+ if (state.currentIterationSpan) {
747
+ closeCancelled(state.currentIterationSpan)
748
+ safeCall('otel.onSpanEnd', () =>
749
+ onSpanEnd?.(
750
+ {
751
+ kind: 'iteration',
752
+ ctx,
753
+ iteration: state.iterationCount - 1,
754
+ } as OtelSpanInfo<'iteration'>,
755
+ state.currentIterationSpan!,
756
+ ),
757
+ )
758
+ state.currentIterationSpan.end()
759
+ state.currentIterationSpan = null
760
+ }
761
+ for (const [id, entry] of state.toolSpans) {
762
+ const { span, toolName } = entry
763
+ closeCancelled(span)
764
+ safeCall('otel.onSpanEnd', () =>
765
+ onSpanEnd?.(
766
+ {
767
+ kind: 'tool',
768
+ ctx,
769
+ toolCallId: id,
770
+ toolName,
771
+ iteration: state.iterationCount - 1,
772
+ } as OtelSpanInfo<'tool'>,
773
+ span,
774
+ ),
775
+ )
776
+ span.end()
777
+ state.toolSpans.delete(id)
778
+ }
779
+ closeCancelled(state.rootSpan)
780
+
781
+ if (durationHistogram) {
782
+ durationHistogram.record(info.duration / 1000, {
783
+ 'gen_ai.system': ctx.provider,
784
+ 'gen_ai.operation.name': 'chat',
785
+ 'gen_ai.request.model': ctx.model,
786
+ 'error.type': 'cancelled',
787
+ })
788
+ }
789
+
790
+ safeCall('otel.onSpanEnd', () =>
791
+ onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
792
+ )
793
+ state.rootSpan.end()
794
+ stateByCtx.delete(ctx)
795
+ })
796
+ },
797
+
798
+ onFinish(ctx, info) {
799
+ safeCall('otel.onFinish', () => {
800
+ const state = stateByCtx.get(ctx)
801
+ if (!state) return
802
+
803
+ // Close any tool spans that never received `onAfterToolCall` (adapter
804
+ // quirk). Done before the iteration span so the hierarchy is closed
805
+ // in depth-first order.
806
+ for (const [id, entry] of state.toolSpans) {
807
+ const { span, toolName } = entry
808
+ span.setAttribute('tanstack.ai.tool.outcome', 'unknown')
809
+ safeCall('otel.onSpanEnd', () =>
810
+ onSpanEnd?.(
811
+ {
812
+ kind: 'tool',
813
+ ctx,
814
+ toolCallId: id,
815
+ toolName,
816
+ iteration: state.iterationCount - 1,
817
+ } as OtelSpanInfo<'tool'>,
818
+ span,
819
+ ),
820
+ )
821
+ span.end()
822
+ state.toolSpans.delete(id)
823
+ }
824
+
825
+ // The final iteration's span is still open because we keep it open
826
+ // through tool execution and `onUsage`. Close it now.
827
+ closeIterationSpan(state, ctx)
828
+
829
+ if (durationHistogram) {
830
+ durationHistogram.record(info.duration / 1000, {
831
+ 'gen_ai.system': ctx.provider,
832
+ 'gen_ai.operation.name': 'chat',
833
+ 'gen_ai.request.model': ctx.model,
834
+ })
835
+ }
836
+
837
+ if (info.usage) {
838
+ state.rootSpan.setAttributes({
839
+ 'gen_ai.usage.input_tokens': info.usage.promptTokens,
840
+ 'gen_ai.usage.output_tokens': info.usage.completionTokens,
841
+ })
842
+ }
843
+ if (info.finishReason) {
844
+ state.rootSpan.setAttribute('gen_ai.response.finish_reasons', [
845
+ info.finishReason,
846
+ ])
847
+ }
848
+ state.rootSpan.setAttribute(
849
+ 'tanstack.ai.iterations',
850
+ state.iterationCount,
851
+ )
852
+
853
+ safeCall('otel.onSpanEnd', () =>
854
+ onSpanEnd?.({ kind: 'chat', ctx }, state.rootSpan),
855
+ )
856
+ state.rootSpan.end()
857
+ stateByCtx.delete(ctx)
858
+ })
859
+ },
860
+ }
861
+ }