@ai-sdk/otel 1.0.123 → 1.0.126

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/otel",
3
- "version": "1.0.123",
3
+ "version": "1.0.126",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,12 +26,12 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/provider": "4.0.20",
29
+ "@ai-sdk/provider": "4.0.21",
30
30
  "@opentelemetry/api": "1.9.1",
31
- "ai": "7.0.123"
31
+ "ai": "7.0.126"
32
32
  },
33
33
  "devDependencies": {
34
- "@ai-sdk/provider-utils": "5.0.52",
34
+ "@ai-sdk/provider-utils": "5.0.53",
35
35
  "@opentelemetry/sdk-trace-base": "2.7.1",
36
36
  "@types/node": "22.19.19",
37
37
  "@vercel/ai-tsconfig": "0.0.0",
@@ -120,6 +120,9 @@ export function mapOperationName(operationId: string): string {
120
120
  'ai.embed': 'embeddings',
121
121
  'ai.embedMany': 'embeddings',
122
122
  'ai.rerank': 'rerank',
123
+ 'ai.generateSpeech': 'ai.generateSpeech',
124
+ 'ai.transcribe': 'ai.transcribe',
125
+ 'ai.streamTranscribe': 'ai.streamTranscribe',
123
126
  };
124
127
  return mapping[operationId] ?? operationId;
125
128
  }
@@ -24,6 +24,8 @@ import type {
24
24
  GenerateTextStartEvent,
25
25
  GenerateTextStepEndEvent,
26
26
  GenerateTextStepStartEvent,
27
+ GenerateSpeechEndEvent,
28
+ GenerateSpeechStartEvent,
27
29
  ToolExecutionEndEvent,
28
30
  ToolExecutionStartEvent,
29
31
  Experimental_EvaluateEndEvent as EvaluateEndEvent,
@@ -35,6 +37,10 @@ import type {
35
37
  RerankEndEvent,
36
38
  RerankStartEvent,
37
39
  RerankingModelCallStartEvent,
40
+ TranscriptionEndEvent,
41
+ TranscriptionStartEvent,
42
+ Experimental_StreamTranscriptionEndEvent as StreamTranscriptionEndEvent,
43
+ Experimental_StreamTranscriptionStartEvent as StreamTranscriptionStartEvent,
38
44
  InferTelemetryEvent,
39
45
  Telemetry,
40
46
  TelemetryOptions,
@@ -42,6 +48,7 @@ import type {
42
48
  } from 'ai';
43
49
  import { assembleOperationName } from './assemble-operation-name';
44
50
  import { getBaseTelemetryAttributes } from './get-base-telemetry-attributes';
51
+ import { getProviderUsageAttributes } from './provider-usage-attributes';
45
52
  import { sanitizeAttributeValue } from './sanitize-attribute-value';
46
53
  import { stringifyForTelemetry } from './stringify-for-telemetry';
47
54
 
@@ -213,8 +220,24 @@ export class LegacyOpenTelemetry implements Telemetry {
213
220
  | InferTelemetryEvent<GenerateTextStartEvent>
214
221
  | InferTelemetryEvent<GenerateObjectStartEvent>
215
222
  | InferTelemetryEvent<EmbedStartEvent>
216
- | InferTelemetryEvent<RerankStartEvent>,
223
+ | InferTelemetryEvent<RerankStartEvent>
224
+ | InferTelemetryEvent<GenerateSpeechStartEvent>
225
+ | InferTelemetryEvent<TranscriptionStartEvent>,
217
226
  ): void {
227
+ if (event.operationId === 'ai.generateSpeech') {
228
+ this.onAudioOperationStart(
229
+ event as InferTelemetryEvent<GenerateSpeechStartEvent>,
230
+ );
231
+ return;
232
+ }
233
+
234
+ if (event.operationId === 'ai.transcribe') {
235
+ this.onAudioOperationStart(
236
+ event as InferTelemetryEvent<TranscriptionStartEvent>,
237
+ );
238
+ return;
239
+ }
240
+
218
241
  if (
219
242
  event.operationId === 'ai.embed' ||
220
243
  event.operationId === 'ai.embedMany'
@@ -243,6 +266,69 @@ export class LegacyOpenTelemetry implements Telemetry {
243
266
  this.onGenerateStart(event as InferTelemetryEvent<GenerateTextStartEvent>);
244
267
  }
245
268
 
269
+ experimental_onStreamTranscriptionStart(
270
+ event: InferTelemetryEvent<StreamTranscriptionStartEvent>,
271
+ ): void {
272
+ this.onAudioOperationStart(event);
273
+ }
274
+
275
+ private onAudioOperationStart(
276
+ event: InferTelemetryEvent<
277
+ | GenerateSpeechStartEvent
278
+ | TranscriptionStartEvent
279
+ | StreamTranscriptionStartEvent
280
+ >,
281
+ ): void {
282
+ const telemetry: TelemetryOptions = {
283
+ recordInputs: event.recordInputs,
284
+ recordOutputs: event.recordOutputs,
285
+ functionId: event.functionId,
286
+ };
287
+ const audio = 'audio' in event ? event.audio : undefined;
288
+ const text = 'text' in event ? event.text : undefined;
289
+ const baseTelemetryAttributes: Attributes = {
290
+ 'ai.model.provider': event.provider,
291
+ 'ai.model.id': event.modelId,
292
+ ...Object.fromEntries(
293
+ Object.entries(event.headers ?? {}).map(([key, value]) => [
294
+ `ai.request.headers.${key}`,
295
+ value,
296
+ ]),
297
+ ),
298
+ };
299
+ const attributes = selectAttributes(telemetry, {
300
+ ...assembleOperationName({
301
+ operationId: event.operationId,
302
+ telemetry,
303
+ }),
304
+ ...baseTelemetryAttributes,
305
+ 'ai.request.text': text == null ? undefined : { input: () => text },
306
+ 'ai.request.audio.size':
307
+ audio?.byteLength == null
308
+ ? undefined
309
+ : { input: () => audio.byteLength },
310
+ 'ai.request.audio.mediaType':
311
+ audio == null ? undefined : { input: () => audio.mediaType },
312
+ });
313
+
314
+ const rootSpan = this.tracer.startSpan(event.operationId, { attributes });
315
+ const rootContext = trace.setSpan(context.active(), rootSpan);
316
+ this.callStates.set(event.callId, {
317
+ operationId: event.operationId,
318
+ telemetry,
319
+ rootSpan,
320
+ rootContext,
321
+ stepSpan: undefined,
322
+ stepContext: undefined,
323
+ embedSpans: new Map(),
324
+ rerankSpan: undefined,
325
+ evaluationSpan: undefined,
326
+ toolSpans: new Map(),
327
+ baseTelemetryAttributes,
328
+ settings: {},
329
+ });
330
+ }
331
+
246
332
  private onGenerateStart(
247
333
  event: InferTelemetryEvent<GenerateTextStartEvent>,
248
334
  ): void {
@@ -793,7 +879,9 @@ export class LegacyOpenTelemetry implements Telemetry {
793
879
  | GenerateTextEndEvent<ToolSet>
794
880
  | GenerateObjectEndEvent<unknown>
795
881
  | EmbedEndEvent
796
- | RerankEndEvent,
882
+ | RerankEndEvent
883
+ | GenerateSpeechEndEvent
884
+ | TranscriptionEndEvent,
797
885
  ): void {
798
886
  const state = this.getCallState(event.callId);
799
887
  if (!state?.rootSpan) return;
@@ -811,6 +899,16 @@ export class LegacyOpenTelemetry implements Telemetry {
811
899
  return;
812
900
  }
813
901
 
902
+ if (
903
+ state.operationId === 'ai.generateSpeech' ||
904
+ state.operationId === 'ai.transcribe'
905
+ ) {
906
+ this.onAudioOperationEnd(
907
+ event as GenerateSpeechEndEvent | TranscriptionEndEvent,
908
+ );
909
+ return;
910
+ }
911
+
814
912
  if (
815
913
  state.operationId === 'ai.generateObject' ||
816
914
  state.operationId === 'ai.streamObject'
@@ -822,6 +920,59 @@ export class LegacyOpenTelemetry implements Telemetry {
822
920
  this.onGenerateEnd(event as GenerateTextEndEvent<ToolSet>);
823
921
  }
824
922
 
923
+ experimental_onStreamTranscriptionEnd(
924
+ event: InferTelemetryEvent<StreamTranscriptionEndEvent>,
925
+ ): void {
926
+ this.onAudioOperationEnd(event);
927
+ }
928
+
929
+ private onAudioOperationEnd(
930
+ event:
931
+ | GenerateSpeechEndEvent
932
+ | TranscriptionEndEvent
933
+ | StreamTranscriptionEndEvent,
934
+ ): void {
935
+ const state = this.getCallState(event.callId);
936
+ if (!state?.rootSpan) return;
937
+
938
+ const isSpeech = event.operationId === 'ai.generateSpeech';
939
+ const audio = event.audio;
940
+ state.rootSpan.setAttributes(
941
+ selectAttributes(state.telemetry, {
942
+ 'ai.request.audio.size': isSpeech
943
+ ? undefined
944
+ : { input: () => audio.byteLength },
945
+ 'ai.request.audio.mediaType': isSpeech
946
+ ? undefined
947
+ : { input: () => audio.mediaType },
948
+ 'ai.response.text':
949
+ 'segments' in event ? { output: () => event.text } : undefined,
950
+ 'ai.response.audio.size': isSpeech
951
+ ? { output: () => audio.byteLength }
952
+ : undefined,
953
+ 'ai.response.audio.mediaType': isSpeech
954
+ ? { output: () => audio.mediaType }
955
+ : undefined,
956
+ 'ai.response.audio.format':
957
+ isSpeech && 'format' in audio
958
+ ? { output: () => audio.format }
959
+ : undefined,
960
+ ...getProviderUsageAttributes({
961
+ usage: event.usage,
962
+ prefix: 'ai.usage',
963
+ }),
964
+ 'ai.response.usage':
965
+ event.usage == null ? undefined : JSON.stringify(event.usage),
966
+ 'ai.response.providerMetadata':
967
+ event.providerMetadata == null
968
+ ? undefined
969
+ : JSON.stringify(event.providerMetadata),
970
+ }),
971
+ );
972
+ state.rootSpan.end();
973
+ this.cleanupCallState(event.callId);
974
+ }
975
+
825
976
  private onGenerateEnd(event: GenerateTextEndEvent<ToolSet>): void {
826
977
  const state = this.getCallState(event.callId);
827
978
  if (!state?.rootSpan) return;
@@ -23,6 +23,8 @@ import type {
23
23
  GenerateTextStartEvent,
24
24
  GenerateTextStepEndEvent,
25
25
  GenerateTextStepStartEvent,
26
+ GenerateSpeechEndEvent,
27
+ GenerateSpeechStartEvent,
26
28
  ToolExecutionEndEvent,
27
29
  ToolExecutionStartEvent,
28
30
  Experimental_EvaluateEndEvent as EvaluateEndEvent,
@@ -33,6 +35,10 @@ import type {
33
35
  RerankEndEvent,
34
36
  RerankStartEvent,
35
37
  RerankingModelCallStartEvent,
38
+ TranscriptionEndEvent,
39
+ TranscriptionStartEvent,
40
+ Experimental_StreamTranscriptionEndEvent as StreamTranscriptionEndEvent,
41
+ Experimental_StreamTranscriptionStartEvent as StreamTranscriptionStartEvent,
36
42
  InferTelemetryEvent,
37
43
  Telemetry,
38
44
  TelemetryOptions,
@@ -48,6 +54,7 @@ import {
48
54
  mapProviderName,
49
55
  } from './gen-ai-format-messages';
50
56
  import { recordErrorOnSpan } from './record-span';
57
+ import { getProviderUsageAttributes } from './provider-usage-attributes';
51
58
  import { sanitizeAttributes } from './sanitize-attribute-value';
52
59
  import { selectAttributes } from './select-attributes';
53
60
  import {
@@ -209,8 +216,24 @@ export class OpenTelemetry implements Telemetry {
209
216
  | InferTelemetryEvent<GenerateTextStartEvent>
210
217
  | InferTelemetryEvent<GenerateObjectStartEvent>
211
218
  | InferTelemetryEvent<EmbedStartEvent>
212
- | InferTelemetryEvent<RerankStartEvent>,
219
+ | InferTelemetryEvent<RerankStartEvent>
220
+ | InferTelemetryEvent<GenerateSpeechStartEvent>
221
+ | InferTelemetryEvent<TranscriptionStartEvent>,
213
222
  ): void {
223
+ if (event.operationId === 'ai.generateSpeech') {
224
+ this.onAudioOperationStart(
225
+ event as InferTelemetryEvent<GenerateSpeechStartEvent>,
226
+ );
227
+ return;
228
+ }
229
+
230
+ if (event.operationId === 'ai.transcribe') {
231
+ this.onAudioOperationStart(
232
+ event as InferTelemetryEvent<TranscriptionStartEvent>,
233
+ );
234
+ return;
235
+ }
236
+
214
237
  if (
215
238
  event.operationId === 'ai.embed' ||
216
239
  event.operationId === 'ai.embedMany'
@@ -239,6 +262,102 @@ export class OpenTelemetry implements Telemetry {
239
262
  this.onGenerateStart(event as InferTelemetryEvent<GenerateTextStartEvent>);
240
263
  }
241
264
 
265
+ experimental_onStreamTranscriptionStart(
266
+ event: InferTelemetryEvent<StreamTranscriptionStartEvent>,
267
+ ): void {
268
+ this.onAudioOperationStart(event);
269
+ }
270
+
271
+ private onAudioOperationStart(
272
+ event: InferTelemetryEvent<
273
+ | GenerateSpeechStartEvent
274
+ | TranscriptionStartEvent
275
+ | StreamTranscriptionStartEvent
276
+ >,
277
+ ): void {
278
+ const telemetry: TelemetryOptions = {
279
+ recordInputs: event.recordInputs,
280
+ recordOutputs: event.recordOutputs,
281
+ functionId: event.functionId,
282
+ };
283
+ const providerName = mapProviderName(event.provider);
284
+ const operationName = mapOperationName(event.operationId);
285
+ const audio = 'audio' in event ? event.audio : undefined;
286
+ const text = 'text' in event ? event.text : undefined;
287
+ const baseSupplementalAttributes = selectSupplementalAttributes(
288
+ telemetry,
289
+ this.supplementalAttributes,
290
+ {
291
+ headers: getHeaderAttributes(event.headers),
292
+ },
293
+ );
294
+
295
+ const attributes = selectAttributes(telemetry, {
296
+ 'gen_ai.operation.name': operationName,
297
+ 'gen_ai.provider.name': providerName,
298
+ 'gen_ai.request.model': event.modelId,
299
+ 'gen_ai.agent.name': telemetry.functionId,
300
+ 'gen_ai.output.type':
301
+ event.operationId === 'ai.generateSpeech' ? 'speech' : 'text',
302
+ 'gen_ai.request.stream': event.operationId === 'ai.streamTranscribe',
303
+ 'gen_ai.input.messages':
304
+ text == null
305
+ ? undefined
306
+ : {
307
+ input: () =>
308
+ JSON.stringify([
309
+ {
310
+ role: 'user',
311
+ parts: [{ type: 'text', content: text }],
312
+ },
313
+ ]),
314
+ },
315
+ 'ai.request.text': text == null ? undefined : { input: () => text },
316
+ 'ai.request.audio.size':
317
+ audio?.byteLength == null
318
+ ? undefined
319
+ : { input: () => audio.byteLength },
320
+ 'ai.request.audio.media_type':
321
+ audio == null ? undefined : { input: () => audio.mediaType },
322
+ ...baseSupplementalAttributes,
323
+ });
324
+
325
+ const rootSpan = this.tracer.startSpan(
326
+ `${operationName} ${event.modelId}`,
327
+ {
328
+ attributes: this.getSpanAttributes({
329
+ attributes,
330
+ spanType: 'operation',
331
+ operationId: event.operationId,
332
+ callId: event.callId,
333
+ runtimeContext: undefined,
334
+ }),
335
+ kind: SpanKind.CLIENT,
336
+ },
337
+ );
338
+ const rootContext = trace.setSpan(context.active(), rootSpan);
339
+
340
+ this.callStates.set(event.callId, {
341
+ operationId: event.operationId,
342
+ telemetry,
343
+ rootSpan,
344
+ rootContext,
345
+ stepSpan: undefined,
346
+ stepContext: undefined,
347
+ inferenceSpan: undefined,
348
+ inferenceContext: undefined,
349
+ embedSpans: new Map(),
350
+ rerankSpan: undefined,
351
+ evaluationSpan: undefined,
352
+ toolSpans: new Map(),
353
+ settings: {},
354
+ provider: event.provider,
355
+ modelId: event.modelId,
356
+ runtimeContext: undefined,
357
+ baseSupplementalAttributes,
358
+ });
359
+ }
360
+
242
361
  private onGenerateStart(
243
362
  event: InferTelemetryEvent<GenerateTextStartEvent>,
244
363
  ): void {
@@ -1032,7 +1151,9 @@ export class OpenTelemetry implements Telemetry {
1032
1151
  | GenerateTextEndEvent<ToolSet>
1033
1152
  | GenerateObjectEndEvent<unknown>
1034
1153
  | EmbedEndEvent
1035
- | RerankEndEvent,
1154
+ | RerankEndEvent
1155
+ | GenerateSpeechEndEvent
1156
+ | TranscriptionEndEvent,
1036
1157
  ): void {
1037
1158
  const state = this.getCallState(event.callId);
1038
1159
  if (!state?.rootSpan) return;
@@ -1050,6 +1171,16 @@ export class OpenTelemetry implements Telemetry {
1050
1171
  return;
1051
1172
  }
1052
1173
 
1174
+ if (
1175
+ state.operationId === 'ai.generateSpeech' ||
1176
+ state.operationId === 'ai.transcribe'
1177
+ ) {
1178
+ this.onAudioOperationEnd(
1179
+ event as GenerateSpeechEndEvent | TranscriptionEndEvent,
1180
+ );
1181
+ return;
1182
+ }
1183
+
1053
1184
  if (
1054
1185
  state.operationId === 'ai.generateObject' ||
1055
1186
  state.operationId === 'ai.streamObject'
@@ -1061,6 +1192,90 @@ export class OpenTelemetry implements Telemetry {
1061
1192
  this.onGenerateEnd(event as GenerateTextEndEvent<ToolSet>);
1062
1193
  }
1063
1194
 
1195
+ experimental_onStreamTranscriptionEnd(
1196
+ event: InferTelemetryEvent<StreamTranscriptionEndEvent>,
1197
+ ): void {
1198
+ this.onAudioOperationEnd(event);
1199
+ }
1200
+
1201
+ private onAudioOperationEnd(
1202
+ event:
1203
+ | GenerateSpeechEndEvent
1204
+ | TranscriptionEndEvent
1205
+ | StreamTranscriptionEndEvent,
1206
+ ): void {
1207
+ const state = this.getCallState(event.callId);
1208
+ if (!state?.rootSpan) return;
1209
+
1210
+ const outputAudio =
1211
+ event.operationId === 'ai.generateSpeech' ? event.audio : undefined;
1212
+ const inputAudio =
1213
+ event.operationId === 'ai.generateSpeech' ? undefined : event.audio;
1214
+ const transcript = 'segments' in event ? event.text : undefined;
1215
+
1216
+ state.rootSpan.setAttributes(
1217
+ selectAttributes(state.telemetry, {
1218
+ 'ai.request.audio.size':
1219
+ inputAudio == null
1220
+ ? undefined
1221
+ : { input: () => inputAudio.byteLength },
1222
+ 'ai.request.audio.media_type':
1223
+ inputAudio == null
1224
+ ? undefined
1225
+ : { input: () => inputAudio.mediaType },
1226
+ 'ai.response.text':
1227
+ transcript == null ? undefined : { output: () => transcript },
1228
+ 'gen_ai.output.messages':
1229
+ transcript == null
1230
+ ? undefined
1231
+ : {
1232
+ output: () =>
1233
+ JSON.stringify([
1234
+ {
1235
+ role: 'assistant',
1236
+ parts: [{ type: 'text', content: transcript }],
1237
+ },
1238
+ ]),
1239
+ },
1240
+ 'ai.response.audio.size':
1241
+ outputAudio == null
1242
+ ? undefined
1243
+ : { output: () => outputAudio.byteLength },
1244
+ 'ai.response.audio.media_type':
1245
+ outputAudio == null
1246
+ ? undefined
1247
+ : { output: () => outputAudio.mediaType },
1248
+ 'ai.response.audio.format':
1249
+ outputAudio == null || !('format' in outputAudio)
1250
+ ? undefined
1251
+ : { output: () => String(outputAudio.format) },
1252
+ ...getProviderUsageAttributes({
1253
+ usage: event.usage,
1254
+ prefix: 'gen_ai.usage',
1255
+ }),
1256
+ ...selectSupplementalAttributes(
1257
+ state.telemetry,
1258
+ this.supplementalAttributes,
1259
+ {
1260
+ usage: {
1261
+ 'ai.response.usage':
1262
+ event.usage == null ? undefined : JSON.stringify(event.usage),
1263
+ },
1264
+ providerMetadata: {
1265
+ 'ai.response.providerMetadata':
1266
+ event.providerMetadata == null
1267
+ ? undefined
1268
+ : JSON.stringify(event.providerMetadata),
1269
+ },
1270
+ },
1271
+ ),
1272
+ }),
1273
+ );
1274
+
1275
+ state.rootSpan.end();
1276
+ this.cleanupCallState(event.callId);
1277
+ }
1278
+
1064
1279
  private onGenerateEnd(event: GenerateTextEndEvent<ToolSet>): void {
1065
1280
  const state = this.getCallState(event.callId);
1066
1281
  if (!state?.rootSpan) return;
@@ -0,0 +1,69 @@
1
+ import type { Attributes } from '@opentelemetry/api';
2
+ import type { JSONObject, JSONValue } from '@ai-sdk/provider';
3
+
4
+ const usageKeyAliases: Record<string, string> = {
5
+ inputtokens: 'input_tokens',
6
+ prompttokens: 'input_tokens',
7
+ prompttokencount: 'input_tokens',
8
+ totalinputtokens: 'input_tokens',
9
+ outputtokens: 'output_tokens',
10
+ completiontokens: 'output_tokens',
11
+ completiontokencount: 'output_tokens',
12
+ candidatestokencount: 'output_tokens',
13
+ totaltokens: 'total_tokens',
14
+ totaltokencount: 'total_tokens',
15
+ };
16
+
17
+ function normalizeUsageKey(key: string): string {
18
+ const aliasKey = key.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
19
+ const alias = usageKeyAliases[aliasKey];
20
+
21
+ if (alias != null) {
22
+ return alias;
23
+ }
24
+
25
+ return key
26
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
27
+ .replace(/[^a-zA-Z0-9]+/g, '_')
28
+ .replace(/^_+|_+$/g, '')
29
+ .toLowerCase();
30
+ }
31
+
32
+ /**
33
+ * Converts provider-native usage objects into numeric span attributes.
34
+ *
35
+ * Usage units such as tokens, characters, and seconds remain distinct so
36
+ * observability backends can apply the provider/model-specific price unit.
37
+ */
38
+ export function getProviderUsageAttributes({
39
+ usage,
40
+ prefix,
41
+ }: {
42
+ usage: JSONObject | undefined;
43
+ prefix: string;
44
+ }): Attributes {
45
+ const attributes: Attributes = {};
46
+
47
+ const addValue = (value: JSONValue | undefined, path: string[]) => {
48
+ if (typeof value === 'number') {
49
+ if (Number.isFinite(value) && path.length > 0) {
50
+ attributes[`${prefix}.${path.join('.')}`] = value;
51
+ }
52
+ return;
53
+ }
54
+
55
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) {
56
+ return;
57
+ }
58
+
59
+ for (const [key, nestedValue] of Object.entries(value)) {
60
+ const normalizedKey = normalizeUsageKey(key);
61
+ if (normalizedKey !== '') {
62
+ addValue(nestedValue, [...path, normalizedKey]);
63
+ }
64
+ }
65
+ };
66
+
67
+ addValue(usage, []);
68
+ return attributes;
69
+ }