@ai-sdk/otel 0.0.1-beta.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.
@@ -0,0 +1,1097 @@
1
+ import { LanguageModelV4Prompt } from '@ai-sdk/provider';
2
+ import {
3
+ context,
4
+ trace,
5
+ Span,
6
+ Context,
7
+ Attributes,
8
+ AttributeValue,
9
+ SpanStatusCode,
10
+ Tracer,
11
+ } from '@opentelemetry/api';
12
+ import type {
13
+ EmbedOnStartEvent,
14
+ EmbedOnFinishEvent,
15
+ EmbedStartEvent,
16
+ EmbedFinishEvent,
17
+ RerankOnStartEvent,
18
+ RerankOnFinishEvent,
19
+ RerankStartEvent,
20
+ RerankFinishEvent,
21
+ OnChunkEvent,
22
+ OnFinishEvent,
23
+ OnStartEvent,
24
+ OnStepFinishEvent,
25
+ OnStepStartEvent,
26
+ OnToolCallFinishEvent,
27
+ OnToolCallStartEvent,
28
+ ObjectOnStartEvent,
29
+ ObjectOnFinishEvent,
30
+ ObjectOnStepStartEvent,
31
+ ObjectOnStepFinishEvent,
32
+ TelemetryIntegration,
33
+ TelemetrySettings,
34
+ ToolSet,
35
+ } from 'ai';
36
+ import type { OutputInterface as Output } from 'ai';
37
+ import { assembleOperationName } from './assemble-operation-name';
38
+ import { getBaseTelemetryAttributes } from './get-base-telemetry-attributes';
39
+ import { stringifyForTelemetry } from './stringify-for-telemetry';
40
+
41
+ function recordSpanError(span: Span, error: unknown): void {
42
+ if (error instanceof Error) {
43
+ span.recordException({
44
+ name: error.name,
45
+ message: error.message,
46
+ stack: error.stack,
47
+ });
48
+ span.setStatus({
49
+ code: SpanStatusCode.ERROR,
50
+ message: error.message,
51
+ });
52
+ } else {
53
+ span.setStatus({ code: SpanStatusCode.ERROR });
54
+ }
55
+ }
56
+
57
+ function shouldRecord(
58
+ telemetry: TelemetrySettings | undefined,
59
+ ): telemetry is TelemetrySettings {
60
+ return telemetry?.isEnabled === true;
61
+ }
62
+
63
+ function selectAttributes(
64
+ telemetry: TelemetrySettings | undefined,
65
+ attributes: Record<
66
+ string,
67
+ | AttributeValue
68
+ | { input: () => AttributeValue | undefined }
69
+ | { output: () => AttributeValue | undefined }
70
+ | undefined
71
+ >,
72
+ ): Attributes {
73
+ if (!shouldRecord(telemetry)) {
74
+ return {};
75
+ }
76
+
77
+ const result: Attributes = {};
78
+
79
+ for (const [key, value] of Object.entries(attributes)) {
80
+ if (value == null) continue;
81
+
82
+ if (
83
+ typeof value === 'object' &&
84
+ 'input' in value &&
85
+ typeof value.input === 'function'
86
+ ) {
87
+ if (telemetry?.recordInputs === false) continue;
88
+ const resolved = value.input();
89
+ if (resolved != null) result[key] = resolved;
90
+ continue;
91
+ }
92
+
93
+ if (
94
+ typeof value === 'object' &&
95
+ 'output' in value &&
96
+ typeof value.output === 'function'
97
+ ) {
98
+ if (telemetry?.recordOutputs === false) continue;
99
+ const resolved = value.output();
100
+ if (resolved != null) result[key] = resolved;
101
+ continue;
102
+ }
103
+
104
+ result[key] = value as AttributeValue;
105
+ }
106
+
107
+ return result;
108
+ }
109
+
110
+ interface OtelStepStartEvent<
111
+ TOOLS extends ToolSet = ToolSet,
112
+ OUTPUT extends Output = Output,
113
+ > extends OnStepStartEvent<TOOLS, OUTPUT> {
114
+ readonly promptMessages?: LanguageModelV4Prompt;
115
+ readonly stepTools?: ReadonlyArray<Record<string, unknown>>;
116
+ readonly stepToolChoice?: unknown;
117
+ }
118
+
119
+ interface CallState {
120
+ operationId: string;
121
+ telemetry: TelemetrySettings | undefined;
122
+ rootSpan: Span | undefined;
123
+ rootContext: Context | undefined;
124
+ stepSpan: Span | undefined;
125
+ stepContext: Context | undefined;
126
+ embedSpans: Map<string, { span: Span; context: Context }>;
127
+ rerankSpan: { span: Span; context: Context } | undefined;
128
+ toolSpans: Map<string, { span: Span; context: Context }>;
129
+ baseTelemetryAttributes: Attributes;
130
+ settings: Record<string, unknown>;
131
+ }
132
+
133
+ export class OpenTelemetryIntegration implements TelemetryIntegration {
134
+ private readonly callStates = new Map<string, CallState>();
135
+
136
+ /**
137
+ * The tracer to use for the telemetry data.
138
+ */
139
+ private readonly tracer: Tracer;
140
+
141
+ constructor(
142
+ options: {
143
+ tracer?: Tracer;
144
+ } = {},
145
+ ) {
146
+ this.tracer = options.tracer ?? trace.getTracer('ai');
147
+ }
148
+
149
+ private getCallState(callId: string): CallState | undefined {
150
+ return this.callStates.get(callId);
151
+ }
152
+
153
+ private cleanupCallState(callId: string): void {
154
+ this.callStates.delete(callId);
155
+ }
156
+
157
+ executeTool<T>({
158
+ callId,
159
+ toolCallId,
160
+ execute,
161
+ }: {
162
+ callId: string;
163
+ toolCallId: string;
164
+ execute: () => PromiseLike<T>;
165
+ }): PromiseLike<T> {
166
+ const toolSpanEntry = this.getCallState(callId)?.toolSpans.get(toolCallId);
167
+
168
+ if (toolSpanEntry == null) {
169
+ return execute();
170
+ }
171
+
172
+ return context.with(toolSpanEntry.context, execute);
173
+ }
174
+
175
+ onStart(
176
+ event:
177
+ | OnStartEvent<ToolSet, Output>
178
+ | ObjectOnStartEvent
179
+ | EmbedOnStartEvent
180
+ | RerankOnStartEvent,
181
+ ): void {
182
+ if (event.isEnabled !== true) return;
183
+
184
+ if (
185
+ event.operationId === 'ai.embed' ||
186
+ event.operationId === 'ai.embedMany'
187
+ ) {
188
+ this.onEmbedOperationStart(event as EmbedOnStartEvent);
189
+ return;
190
+ }
191
+
192
+ if (event.operationId === 'ai.rerank') {
193
+ this.onRerankOperationStart(event as RerankOnStartEvent);
194
+ return;
195
+ }
196
+
197
+ if (
198
+ event.operationId === 'ai.generateObject' ||
199
+ event.operationId === 'ai.streamObject'
200
+ ) {
201
+ this.onObjectOperationStart(event as ObjectOnStartEvent);
202
+ return;
203
+ }
204
+
205
+ this.onGenerateStart(event as OnStartEvent<ToolSet, Output>);
206
+ }
207
+
208
+ private onGenerateStart(event: OnStartEvent<ToolSet, Output>): void {
209
+ const telemetry: TelemetrySettings = {
210
+ isEnabled: event.isEnabled,
211
+ recordInputs: event.recordInputs,
212
+ recordOutputs: event.recordOutputs,
213
+ functionId: event.functionId,
214
+ metadata: event.metadata,
215
+ };
216
+
217
+ const settings: Record<string, unknown> = {
218
+ maxOutputTokens: event.maxOutputTokens,
219
+ temperature: event.temperature,
220
+ topP: event.topP,
221
+ topK: event.topK,
222
+ presencePenalty: event.presencePenalty,
223
+ frequencyPenalty: event.frequencyPenalty,
224
+ stopSequences: event.stopSequences,
225
+ seed: event.seed,
226
+ maxRetries: event.maxRetries,
227
+ };
228
+
229
+ const baseTelemetryAttributes = getBaseTelemetryAttributes({
230
+ model: { provider: event.provider, modelId: event.modelId },
231
+ telemetry,
232
+ headers: event.headers,
233
+ settings,
234
+ });
235
+
236
+ const attributes = selectAttributes(telemetry, {
237
+ ...assembleOperationName({
238
+ operationId: event.operationId,
239
+ telemetry,
240
+ }),
241
+ ...baseTelemetryAttributes,
242
+ 'ai.model.provider': event.provider,
243
+ 'ai.model.id': event.modelId,
244
+ 'ai.prompt': {
245
+ input: () =>
246
+ JSON.stringify({
247
+ system: event.system,
248
+ prompt: event.prompt,
249
+ messages: event.messages,
250
+ }),
251
+ },
252
+ });
253
+
254
+ const rootSpan = this.tracer.startSpan(event.operationId, { attributes });
255
+ const rootContext = trace.setSpan(context.active(), rootSpan);
256
+
257
+ this.callStates.set(event.callId, {
258
+ operationId: event.operationId,
259
+ telemetry,
260
+ rootSpan,
261
+ rootContext,
262
+ stepSpan: undefined,
263
+ stepContext: undefined,
264
+ embedSpans: new Map(),
265
+ rerankSpan: undefined,
266
+ toolSpans: new Map(),
267
+ baseTelemetryAttributes,
268
+ settings,
269
+ });
270
+ }
271
+
272
+ private onObjectOperationStart(event: ObjectOnStartEvent): void {
273
+ const telemetry: TelemetrySettings = {
274
+ isEnabled: event.isEnabled,
275
+ recordInputs: event.recordInputs,
276
+ recordOutputs: event.recordOutputs,
277
+ functionId: event.functionId,
278
+ metadata: event.metadata,
279
+ };
280
+
281
+ const settings: Record<string, unknown> = {
282
+ maxOutputTokens: event.maxOutputTokens,
283
+ temperature: event.temperature,
284
+ topP: event.topP,
285
+ topK: event.topK,
286
+ presencePenalty: event.presencePenalty,
287
+ frequencyPenalty: event.frequencyPenalty,
288
+ seed: event.seed,
289
+ maxRetries: event.maxRetries,
290
+ };
291
+
292
+ const baseTelemetryAttributes = getBaseTelemetryAttributes({
293
+ model: { provider: event.provider, modelId: event.modelId },
294
+ telemetry,
295
+ headers: event.headers,
296
+ settings,
297
+ });
298
+
299
+ const attributes = selectAttributes(telemetry, {
300
+ ...assembleOperationName({
301
+ operationId: event.operationId,
302
+ telemetry,
303
+ }),
304
+ ...baseTelemetryAttributes,
305
+ 'ai.prompt': {
306
+ input: () =>
307
+ JSON.stringify({
308
+ system: event.system,
309
+ prompt: event.prompt,
310
+ messages: event.messages,
311
+ }),
312
+ },
313
+ 'ai.schema': event.schema
314
+ ? { input: () => JSON.stringify(event.schema) }
315
+ : undefined,
316
+ 'ai.schema.name': event.schemaName,
317
+ 'ai.schema.description': event.schemaDescription,
318
+ 'ai.settings.output': event.output,
319
+ });
320
+
321
+ const rootSpan = this.tracer.startSpan(event.operationId, { attributes });
322
+ const rootContext = trace.setSpan(context.active(), rootSpan);
323
+
324
+ this.callStates.set(event.callId, {
325
+ operationId: event.operationId,
326
+ telemetry,
327
+ rootSpan,
328
+ rootContext,
329
+ stepSpan: undefined,
330
+ stepContext: undefined,
331
+ embedSpans: new Map(),
332
+ rerankSpan: undefined,
333
+ toolSpans: new Map(),
334
+ baseTelemetryAttributes,
335
+ settings,
336
+ });
337
+ }
338
+
339
+ /** @deprecated */
340
+ onObjectStepStart(event: ObjectOnStepStartEvent): void {
341
+ const state = this.getCallState(event.callId);
342
+ if (!state?.rootSpan || !state.rootContext) return;
343
+
344
+ const { telemetry } = state;
345
+
346
+ const stepOperationId =
347
+ state.operationId === 'ai.streamObject'
348
+ ? 'ai.streamObject.doStream'
349
+ : 'ai.generateObject.doGenerate';
350
+
351
+ const attributes = selectAttributes(telemetry, {
352
+ ...assembleOperationName({
353
+ operationId: stepOperationId,
354
+ telemetry,
355
+ }),
356
+ ...state.baseTelemetryAttributes,
357
+ 'ai.prompt.messages': {
358
+ input: () =>
359
+ event.promptMessages
360
+ ? stringifyForTelemetry(event.promptMessages)
361
+ : undefined,
362
+ },
363
+
364
+ 'gen_ai.system': event.provider,
365
+ 'gen_ai.request.model': event.modelId,
366
+ 'gen_ai.request.frequency_penalty': state.settings.frequencyPenalty as
367
+ | number
368
+ | undefined,
369
+ 'gen_ai.request.max_tokens': state.settings.maxOutputTokens as
370
+ | number
371
+ | undefined,
372
+ 'gen_ai.request.presence_penalty': state.settings.presencePenalty as
373
+ | number
374
+ | undefined,
375
+ 'gen_ai.request.temperature': (state.settings.temperature ?? undefined) as
376
+ | number
377
+ | undefined,
378
+ 'gen_ai.request.top_k': state.settings.topK as number | undefined,
379
+ 'gen_ai.request.top_p': state.settings.topP as number | undefined,
380
+ });
381
+
382
+ state.stepSpan = this.tracer.startSpan(
383
+ stepOperationId,
384
+ { attributes },
385
+ state.rootContext,
386
+ );
387
+ state.stepContext = trace.setSpan(state.rootContext, state.stepSpan);
388
+ }
389
+
390
+ /** @deprecated */
391
+ onObjectStepFinish(event: ObjectOnStepFinishEvent): void {
392
+ const state = this.getCallState(event.callId);
393
+ if (!state?.stepSpan) return;
394
+
395
+ const { telemetry } = state;
396
+
397
+ state.stepSpan.setAttributes(
398
+ selectAttributes(telemetry, {
399
+ 'ai.response.finishReason': event.finishReason,
400
+ 'ai.response.object': {
401
+ output: () => {
402
+ try {
403
+ return JSON.stringify(JSON.parse(event.objectText));
404
+ } catch {
405
+ return event.objectText;
406
+ }
407
+ },
408
+ },
409
+ 'ai.response.id': event.response.id,
410
+ 'ai.response.model': event.response.modelId,
411
+ 'ai.response.timestamp': event.response.timestamp.toISOString(),
412
+ 'ai.response.providerMetadata': event.providerMetadata
413
+ ? JSON.stringify(event.providerMetadata)
414
+ : undefined,
415
+
416
+ 'ai.usage.inputTokens': event.usage.inputTokens,
417
+ 'ai.usage.outputTokens': event.usage.outputTokens,
418
+ 'ai.usage.totalTokens': event.usage.totalTokens,
419
+ 'ai.usage.reasoningTokens': event.usage.reasoningTokens,
420
+ 'ai.usage.cachedInputTokens': event.usage.cachedInputTokens,
421
+
422
+ 'gen_ai.response.finish_reasons': [event.finishReason],
423
+ 'gen_ai.response.id': event.response.id,
424
+ 'gen_ai.response.model': event.response.modelId,
425
+ 'gen_ai.usage.input_tokens': event.usage.inputTokens,
426
+ 'gen_ai.usage.output_tokens': event.usage.outputTokens,
427
+ }),
428
+ );
429
+
430
+ if (event.msToFirstChunk != null) {
431
+ state.stepSpan.addEvent('ai.stream.firstChunk', {
432
+ 'ai.stream.msToFirstChunk': event.msToFirstChunk,
433
+ });
434
+ state.stepSpan.setAttributes({
435
+ 'ai.stream.msToFirstChunk': event.msToFirstChunk,
436
+ });
437
+ }
438
+
439
+ state.stepSpan.end();
440
+ state.stepSpan = undefined;
441
+ state.stepContext = undefined;
442
+ }
443
+
444
+ private onEmbedOperationStart(event: EmbedOnStartEvent): void {
445
+ const telemetry: TelemetrySettings = {
446
+ isEnabled: event.isEnabled,
447
+ recordInputs: event.recordInputs,
448
+ recordOutputs: event.recordOutputs,
449
+ functionId: event.functionId,
450
+ metadata: event.metadata,
451
+ };
452
+
453
+ const settings: Record<string, unknown> = {
454
+ maxRetries: event.maxRetries,
455
+ };
456
+
457
+ const baseTelemetryAttributes = getBaseTelemetryAttributes({
458
+ model: { provider: event.provider, modelId: event.modelId },
459
+ telemetry,
460
+ headers: event.headers,
461
+ settings,
462
+ });
463
+
464
+ const value = event.value;
465
+ const isMany = event.operationId === 'ai.embedMany';
466
+
467
+ const attributes = selectAttributes(telemetry, {
468
+ ...assembleOperationName({
469
+ operationId: event.operationId,
470
+ telemetry,
471
+ }),
472
+ ...baseTelemetryAttributes,
473
+ ...(isMany
474
+ ? {
475
+ 'ai.values': {
476
+ input: () => (value as string[]).map(v => JSON.stringify(v)),
477
+ },
478
+ }
479
+ : {
480
+ 'ai.value': {
481
+ input: () => JSON.stringify(value),
482
+ },
483
+ }),
484
+ });
485
+
486
+ const rootSpan = this.tracer.startSpan(event.operationId, { attributes });
487
+ const rootContext = trace.setSpan(context.active(), rootSpan);
488
+
489
+ this.callStates.set(event.callId, {
490
+ operationId: event.operationId,
491
+ telemetry,
492
+ rootSpan,
493
+ rootContext,
494
+ stepSpan: undefined,
495
+ stepContext: undefined,
496
+ embedSpans: new Map(),
497
+ rerankSpan: undefined,
498
+ toolSpans: new Map(),
499
+ baseTelemetryAttributes,
500
+ settings,
501
+ });
502
+ }
503
+
504
+ onStepStart(event: OtelStepStartEvent<ToolSet, Output>): void {
505
+ const state = this.getCallState(event.callId);
506
+ if (!state?.rootSpan || !state.rootContext) return;
507
+
508
+ const { telemetry } = state;
509
+
510
+ const stepOperationId =
511
+ state.operationId === 'ai.streamText'
512
+ ? 'ai.streamText.doStream'
513
+ : 'ai.generateText.doGenerate';
514
+
515
+ const attributes = selectAttributes(telemetry, {
516
+ ...assembleOperationName({
517
+ operationId: stepOperationId,
518
+ telemetry,
519
+ }),
520
+ ...state.baseTelemetryAttributes,
521
+ 'ai.model.provider': event.provider,
522
+ 'ai.model.id': event.modelId,
523
+
524
+ 'ai.prompt.messages': {
525
+ input: () =>
526
+ event.promptMessages
527
+ ? stringifyForTelemetry(event.promptMessages)
528
+ : undefined,
529
+ },
530
+ 'ai.prompt.tools': {
531
+ input: () => event.stepTools?.map(tool => JSON.stringify(tool)),
532
+ },
533
+ 'ai.prompt.toolChoice': {
534
+ input: () =>
535
+ event.stepToolChoice != null
536
+ ? JSON.stringify(event.stepToolChoice)
537
+ : undefined,
538
+ },
539
+
540
+ 'gen_ai.system': event.provider,
541
+ 'gen_ai.request.model': event.modelId,
542
+ 'gen_ai.request.frequency_penalty': state.settings.frequencyPenalty as
543
+ | number
544
+ | undefined,
545
+ 'gen_ai.request.max_tokens': state.settings.maxOutputTokens as
546
+ | number
547
+ | undefined,
548
+ 'gen_ai.request.presence_penalty': state.settings.presencePenalty as
549
+ | number
550
+ | undefined,
551
+ 'gen_ai.request.stop_sequences': state.settings.stopSequences as
552
+ | string[]
553
+ | undefined,
554
+ 'gen_ai.request.temperature': (state.settings.temperature ?? undefined) as
555
+ | number
556
+ | undefined,
557
+ 'gen_ai.request.top_k': state.settings.topK as number | undefined,
558
+ 'gen_ai.request.top_p': state.settings.topP as number | undefined,
559
+ });
560
+
561
+ state.stepSpan = this.tracer.startSpan(
562
+ stepOperationId,
563
+ { attributes },
564
+ state.rootContext,
565
+ );
566
+ state.stepContext = trace.setSpan(state.rootContext, state.stepSpan);
567
+ }
568
+
569
+ onToolCallStart(event: OnToolCallStartEvent<ToolSet>): void {
570
+ const state = this.getCallState(event.callId);
571
+ if (!state?.stepContext) return;
572
+
573
+ const { telemetry } = state;
574
+ const { toolCall } = event;
575
+
576
+ const attributes = selectAttributes(telemetry, {
577
+ ...assembleOperationName({
578
+ operationId: 'ai.toolCall',
579
+ telemetry,
580
+ }),
581
+ 'ai.toolCall.name': toolCall.toolName,
582
+ 'ai.toolCall.id': toolCall.toolCallId,
583
+ 'ai.toolCall.args': {
584
+ output: () => JSON.stringify(toolCall.input),
585
+ },
586
+ });
587
+
588
+ const toolSpan = this.tracer.startSpan(
589
+ 'ai.toolCall',
590
+ { attributes },
591
+ state.stepContext,
592
+ );
593
+ const toolContext = trace.setSpan(state.stepContext, toolSpan);
594
+
595
+ state.toolSpans.set(toolCall.toolCallId, {
596
+ span: toolSpan,
597
+ context: toolContext,
598
+ });
599
+ }
600
+
601
+ onToolCallFinish(event: OnToolCallFinishEvent<ToolSet>): void {
602
+ const state = this.getCallState(event.callId);
603
+ if (!state) return;
604
+
605
+ const toolSpanEntry = state.toolSpans.get(event.toolCall.toolCallId);
606
+ if (!toolSpanEntry) return;
607
+
608
+ const { span } = toolSpanEntry;
609
+ const { telemetry } = state;
610
+
611
+ if (event.success) {
612
+ try {
613
+ span.setAttributes(
614
+ selectAttributes(telemetry, {
615
+ 'ai.toolCall.result': {
616
+ output: () => JSON.stringify(event.output),
617
+ },
618
+ }),
619
+ );
620
+ } catch (_ignored) {
621
+ // JSON.stringify might fail for non-serializable results
622
+ }
623
+ } else {
624
+ recordSpanError(span, event.error);
625
+ }
626
+
627
+ span.end();
628
+ state.toolSpans.delete(event.toolCall.toolCallId);
629
+ }
630
+
631
+ onStepFinish(event: OnStepFinishEvent<ToolSet>): void {
632
+ const state = this.getCallState(event.callId);
633
+ if (!state?.stepSpan) return;
634
+
635
+ const { telemetry } = state;
636
+
637
+ state.stepSpan.setAttributes(
638
+ selectAttributes(telemetry, {
639
+ 'ai.response.finishReason': event.finishReason,
640
+ 'ai.response.text': {
641
+ output: () => event.text ?? undefined,
642
+ },
643
+ 'ai.response.reasoning': {
644
+ output: () =>
645
+ event.reasoning.length > 0
646
+ ? event.reasoning
647
+ .filter(part => 'text' in part)
648
+ .map(part => part.text)
649
+ .join('\n')
650
+ : undefined,
651
+ },
652
+ 'ai.response.toolCalls': {
653
+ output: () =>
654
+ event.toolCalls.length > 0
655
+ ? JSON.stringify(
656
+ event.toolCalls.map(toolCall => ({
657
+ toolCallId: toolCall.toolCallId,
658
+ toolName: toolCall.toolName,
659
+ input: toolCall.input,
660
+ })),
661
+ )
662
+ : undefined,
663
+ },
664
+ 'ai.response.files': {
665
+ output: () =>
666
+ event.files.length > 0
667
+ ? JSON.stringify(
668
+ event.files.map(file => ({
669
+ type: 'file',
670
+ mediaType: file.mediaType,
671
+ data: file.base64,
672
+ })),
673
+ )
674
+ : undefined,
675
+ },
676
+ 'ai.response.id': event.response.id,
677
+ 'ai.response.model': event.response.modelId,
678
+ 'ai.response.timestamp': event.response.timestamp.toISOString(),
679
+ 'ai.response.providerMetadata': event.providerMetadata
680
+ ? JSON.stringify(event.providerMetadata)
681
+ : undefined,
682
+
683
+ 'ai.usage.inputTokens': event.usage.inputTokens,
684
+ 'ai.usage.outputTokens': event.usage.outputTokens,
685
+ 'ai.usage.totalTokens': event.usage.totalTokens,
686
+ 'ai.usage.reasoningTokens': event.usage.reasoningTokens,
687
+ 'ai.usage.cachedInputTokens': event.usage.cachedInputTokens,
688
+ 'ai.usage.inputTokenDetails.noCacheTokens':
689
+ event.usage.inputTokenDetails?.noCacheTokens,
690
+ 'ai.usage.inputTokenDetails.cacheReadTokens':
691
+ event.usage.inputTokenDetails?.cacheReadTokens,
692
+ 'ai.usage.inputTokenDetails.cacheWriteTokens':
693
+ event.usage.inputTokenDetails?.cacheWriteTokens,
694
+ 'ai.usage.outputTokenDetails.textTokens':
695
+ event.usage.outputTokenDetails?.textTokens,
696
+ 'ai.usage.outputTokenDetails.reasoningTokens':
697
+ event.usage.outputTokenDetails?.reasoningTokens,
698
+
699
+ 'gen_ai.response.finish_reasons': [event.finishReason],
700
+ 'gen_ai.response.id': event.response.id,
701
+ 'gen_ai.response.model': event.response.modelId,
702
+ 'gen_ai.usage.input_tokens': event.usage.inputTokens,
703
+ 'gen_ai.usage.output_tokens': event.usage.outputTokens,
704
+ }),
705
+ );
706
+
707
+ state.stepSpan.end();
708
+ state.stepSpan = undefined;
709
+ state.stepContext = undefined;
710
+ }
711
+
712
+ onFinish(
713
+ event:
714
+ | OnFinishEvent<ToolSet>
715
+ | ObjectOnFinishEvent<unknown>
716
+ | EmbedOnFinishEvent
717
+ | RerankOnFinishEvent,
718
+ ): void {
719
+ const state = this.getCallState(event.callId);
720
+ if (!state?.rootSpan) return;
721
+
722
+ if (
723
+ state.operationId === 'ai.embed' ||
724
+ state.operationId === 'ai.embedMany'
725
+ ) {
726
+ this.onEmbedOperationFinish(event as EmbedOnFinishEvent);
727
+ return;
728
+ }
729
+
730
+ if (state.operationId === 'ai.rerank') {
731
+ this.onRerankOperationFinish(event as RerankOnFinishEvent);
732
+ return;
733
+ }
734
+
735
+ if (
736
+ state.operationId === 'ai.generateObject' ||
737
+ state.operationId === 'ai.streamObject'
738
+ ) {
739
+ this.onObjectOperationFinish(event as ObjectOnFinishEvent<unknown>);
740
+ return;
741
+ }
742
+
743
+ this.onGenerateFinish(event as OnFinishEvent<ToolSet>);
744
+ }
745
+
746
+ private onGenerateFinish(event: OnFinishEvent<ToolSet>): void {
747
+ const state = this.getCallState(event.callId);
748
+ if (!state?.rootSpan) return;
749
+
750
+ const { telemetry } = state;
751
+
752
+ state.rootSpan.setAttributes(
753
+ selectAttributes(telemetry, {
754
+ 'ai.response.finishReason': event.finishReason,
755
+ 'ai.response.text': {
756
+ output: () => event.text ?? undefined,
757
+ },
758
+ 'ai.response.reasoning': {
759
+ output: () =>
760
+ event.reasoning.length > 0
761
+ ? event.reasoning
762
+ .filter(part => 'text' in part)
763
+ .map(part => part.text)
764
+ .join('\n')
765
+ : undefined,
766
+ },
767
+ 'ai.response.toolCalls': {
768
+ output: () =>
769
+ event.toolCalls.length > 0
770
+ ? JSON.stringify(
771
+ event.toolCalls.map(toolCall => ({
772
+ toolCallId: toolCall.toolCallId,
773
+ toolName: toolCall.toolName,
774
+ input: toolCall.input,
775
+ })),
776
+ )
777
+ : undefined,
778
+ },
779
+ 'ai.response.files': {
780
+ output: () =>
781
+ event.files.length > 0
782
+ ? JSON.stringify(
783
+ event.files.map(file => ({
784
+ type: 'file',
785
+ mediaType: file.mediaType,
786
+ data: file.base64,
787
+ })),
788
+ )
789
+ : undefined,
790
+ },
791
+ 'ai.response.providerMetadata': event.providerMetadata
792
+ ? JSON.stringify(event.providerMetadata)
793
+ : undefined,
794
+
795
+ 'ai.usage.inputTokens': event.totalUsage.inputTokens,
796
+ 'ai.usage.outputTokens': event.totalUsage.outputTokens,
797
+ 'ai.usage.totalTokens': event.totalUsage.totalTokens,
798
+ 'ai.usage.reasoningTokens': event.totalUsage.reasoningTokens,
799
+ 'ai.usage.cachedInputTokens': event.totalUsage.cachedInputTokens,
800
+ 'ai.usage.inputTokenDetails.noCacheTokens':
801
+ event.totalUsage.inputTokenDetails?.noCacheTokens,
802
+ 'ai.usage.inputTokenDetails.cacheReadTokens':
803
+ event.totalUsage.inputTokenDetails?.cacheReadTokens,
804
+ 'ai.usage.inputTokenDetails.cacheWriteTokens':
805
+ event.totalUsage.inputTokenDetails?.cacheWriteTokens,
806
+ 'ai.usage.outputTokenDetails.textTokens':
807
+ event.totalUsage.outputTokenDetails?.textTokens,
808
+ 'ai.usage.outputTokenDetails.reasoningTokens':
809
+ event.totalUsage.outputTokenDetails?.reasoningTokens,
810
+ }),
811
+ );
812
+
813
+ state.rootSpan.end();
814
+ this.cleanupCallState(event.callId);
815
+ }
816
+
817
+ private onObjectOperationFinish(event: ObjectOnFinishEvent<unknown>): void {
818
+ const state = this.getCallState(event.callId);
819
+ if (!state?.rootSpan) return;
820
+
821
+ const { telemetry } = state;
822
+
823
+ state.rootSpan.setAttributes(
824
+ selectAttributes(telemetry, {
825
+ 'ai.response.finishReason': event.finishReason,
826
+ 'ai.response.object': {
827
+ output: () =>
828
+ event.object != null ? JSON.stringify(event.object) : undefined,
829
+ },
830
+ 'ai.response.providerMetadata': event.providerMetadata
831
+ ? JSON.stringify(event.providerMetadata)
832
+ : undefined,
833
+
834
+ 'ai.usage.inputTokens': event.usage.inputTokens,
835
+ 'ai.usage.outputTokens': event.usage.outputTokens,
836
+ 'ai.usage.totalTokens': event.usage.totalTokens,
837
+ 'ai.usage.reasoningTokens': event.usage.reasoningTokens,
838
+ 'ai.usage.cachedInputTokens': event.usage.cachedInputTokens,
839
+ }),
840
+ );
841
+
842
+ state.rootSpan.end();
843
+ this.cleanupCallState(event.callId);
844
+ }
845
+
846
+ private onEmbedOperationFinish(event: EmbedOnFinishEvent): void {
847
+ const state = this.getCallState(event.callId);
848
+ if (!state?.rootSpan) return;
849
+
850
+ const { telemetry } = state;
851
+ const isMany = state.operationId === 'ai.embedMany';
852
+
853
+ state.rootSpan.setAttributes(
854
+ selectAttributes(telemetry, {
855
+ ...(isMany
856
+ ? {
857
+ 'ai.embeddings': {
858
+ output: () =>
859
+ (event.embedding as number[][]).map(e => JSON.stringify(e)),
860
+ },
861
+ }
862
+ : {
863
+ 'ai.embedding': {
864
+ output: () => JSON.stringify(event.embedding),
865
+ },
866
+ }),
867
+ 'ai.usage.tokens': event.usage.tokens,
868
+ }),
869
+ );
870
+
871
+ state.rootSpan.end();
872
+ this.cleanupCallState(event.callId);
873
+ }
874
+
875
+ onEmbedStart(event: EmbedStartEvent): void {
876
+ const state = this.getCallState(event.callId);
877
+ if (!state?.rootSpan || !state.rootContext) return;
878
+
879
+ const { telemetry } = state;
880
+
881
+ const attributes = selectAttributes(telemetry, {
882
+ ...assembleOperationName({
883
+ operationId: event.operationId,
884
+ telemetry,
885
+ }),
886
+ ...state.baseTelemetryAttributes,
887
+ 'ai.values': {
888
+ input: () => event.values.map(v => JSON.stringify(v)),
889
+ },
890
+ });
891
+
892
+ const embedSpan = this.tracer.startSpan(
893
+ event.operationId,
894
+ { attributes },
895
+ state.rootContext,
896
+ );
897
+ const embedContext = trace.setSpan(state.rootContext, embedSpan);
898
+
899
+ state.embedSpans.set(event.embedCallId, {
900
+ span: embedSpan,
901
+ context: embedContext,
902
+ });
903
+ }
904
+
905
+ onEmbedFinish(event: EmbedFinishEvent): void {
906
+ const state = this.getCallState(event.callId);
907
+ if (!state) return;
908
+
909
+ const embedSpanEntry = state.embedSpans.get(event.embedCallId);
910
+ if (!embedSpanEntry) return;
911
+
912
+ const { span } = embedSpanEntry;
913
+ const { telemetry } = state;
914
+
915
+ span.setAttributes(
916
+ selectAttributes(telemetry, {
917
+ 'ai.embeddings': {
918
+ output: () =>
919
+ event.embeddings.map(embedding => JSON.stringify(embedding)),
920
+ },
921
+ 'ai.usage.tokens': event.usage.tokens,
922
+ }),
923
+ );
924
+
925
+ span.end();
926
+ state.embedSpans.delete(event.embedCallId);
927
+ }
928
+
929
+ private onRerankOperationStart(event: RerankOnStartEvent): void {
930
+ const telemetry: TelemetrySettings = {
931
+ isEnabled: event.isEnabled,
932
+ recordInputs: event.recordInputs,
933
+ recordOutputs: event.recordOutputs,
934
+ functionId: event.functionId,
935
+ metadata: event.metadata,
936
+ };
937
+
938
+ const settings: Record<string, unknown> = {
939
+ maxRetries: event.maxRetries,
940
+ };
941
+
942
+ const baseTelemetryAttributes = getBaseTelemetryAttributes({
943
+ model: { provider: event.provider, modelId: event.modelId },
944
+ telemetry,
945
+ headers: event.headers,
946
+ settings,
947
+ });
948
+
949
+ const attributes = selectAttributes(telemetry, {
950
+ ...assembleOperationName({
951
+ operationId: event.operationId,
952
+ telemetry,
953
+ }),
954
+ ...baseTelemetryAttributes,
955
+ 'ai.documents': {
956
+ input: () => event.documents.map(d => JSON.stringify(d)),
957
+ },
958
+ });
959
+
960
+ const rootSpan = this.tracer.startSpan(event.operationId, { attributes });
961
+ const rootContext = trace.setSpan(context.active(), rootSpan);
962
+
963
+ this.callStates.set(event.callId, {
964
+ operationId: event.operationId,
965
+ telemetry,
966
+ rootSpan,
967
+ rootContext,
968
+ stepSpan: undefined,
969
+ stepContext: undefined,
970
+ embedSpans: new Map(),
971
+ rerankSpan: undefined,
972
+ toolSpans: new Map(),
973
+ baseTelemetryAttributes,
974
+ settings,
975
+ });
976
+ }
977
+
978
+ private onRerankOperationFinish(event: RerankOnFinishEvent): void {
979
+ const state = this.getCallState(event.callId);
980
+ if (!state?.rootSpan) return;
981
+
982
+ state.rootSpan.end();
983
+ this.cleanupCallState(event.callId);
984
+ }
985
+
986
+ onRerankStart(event: RerankStartEvent): void {
987
+ const state = this.getCallState(event.callId);
988
+ if (!state?.rootSpan || !state.rootContext) return;
989
+
990
+ const { telemetry } = state;
991
+
992
+ const attributes = selectAttributes(telemetry, {
993
+ ...assembleOperationName({
994
+ operationId: event.operationId,
995
+ telemetry,
996
+ }),
997
+ ...state.baseTelemetryAttributes,
998
+ 'ai.documents': {
999
+ input: () => event.documents.map(d => JSON.stringify(d)),
1000
+ },
1001
+ });
1002
+
1003
+ const rerankSpan = this.tracer.startSpan(
1004
+ event.operationId,
1005
+ { attributes },
1006
+ state.rootContext,
1007
+ );
1008
+ const rerankContext = trace.setSpan(state.rootContext, rerankSpan);
1009
+
1010
+ state.rerankSpan = { span: rerankSpan, context: rerankContext };
1011
+ }
1012
+
1013
+ onRerankFinish(event: RerankFinishEvent): void {
1014
+ const state = this.getCallState(event.callId);
1015
+ if (!state?.rerankSpan) return;
1016
+
1017
+ const { span } = state.rerankSpan;
1018
+ const { telemetry } = state;
1019
+
1020
+ span.setAttributes(
1021
+ selectAttributes(telemetry, {
1022
+ 'ai.ranking.type': event.documentsType,
1023
+ 'ai.ranking': {
1024
+ output: () => event.ranking.map(r => JSON.stringify(r)),
1025
+ },
1026
+ }),
1027
+ );
1028
+
1029
+ span.end();
1030
+ state.rerankSpan = undefined;
1031
+ }
1032
+
1033
+ onChunk(event: OnChunkEvent<ToolSet>): void {
1034
+ const chunk = event.chunk as {
1035
+ type: string;
1036
+ callId?: unknown;
1037
+ attributes?: unknown;
1038
+ };
1039
+
1040
+ if (typeof chunk.callId !== 'string') {
1041
+ return;
1042
+ }
1043
+
1044
+ if (
1045
+ chunk.type !== 'ai.stream.firstChunk' &&
1046
+ chunk.type !== 'ai.stream.finish'
1047
+ ) {
1048
+ return;
1049
+ }
1050
+
1051
+ const state = this.getCallState(chunk.callId);
1052
+ if (!state?.stepSpan) return;
1053
+
1054
+ const attributes = Object.fromEntries(
1055
+ Object.entries(
1056
+ (chunk.attributes as Record<string, unknown>) ?? {},
1057
+ ).filter(([, value]) => value != null),
1058
+ ) as Attributes;
1059
+
1060
+ state.stepSpan.addEvent(chunk.type, attributes);
1061
+ if (Object.keys(attributes).length > 0) {
1062
+ state.stepSpan.setAttributes(attributes);
1063
+ }
1064
+ }
1065
+
1066
+ onError(error: unknown): void {
1067
+ const event = error as { callId?: string; error?: unknown };
1068
+ if (!event?.callId) return;
1069
+
1070
+ const state = this.getCallState(event.callId);
1071
+ if (!state?.rootSpan) return;
1072
+
1073
+ const actualError = event.error ?? error;
1074
+
1075
+ if (state.stepSpan) {
1076
+ recordSpanError(state.stepSpan, actualError);
1077
+ state.stepSpan.end();
1078
+ }
1079
+
1080
+ for (const { span: embedSpan } of state.embedSpans.values()) {
1081
+ recordSpanError(embedSpan, actualError);
1082
+ embedSpan.end();
1083
+ }
1084
+ state.embedSpans.clear();
1085
+
1086
+ if (state.rerankSpan) {
1087
+ recordSpanError(state.rerankSpan.span, actualError);
1088
+ state.rerankSpan.span.end();
1089
+ state.rerankSpan = undefined;
1090
+ }
1091
+
1092
+ recordSpanError(state.rootSpan, actualError);
1093
+
1094
+ state.rootSpan.end();
1095
+ this.cleanupCallState(event.callId);
1096
+ }
1097
+ }