@ai-sdk/otel 1.0.0-canary.99 → 1.0.1

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.0-canary.99",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -27,15 +27,16 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@opentelemetry/api": "1.9.1",
30
- "@ai-sdk/provider": "4.0.0-canary.17",
31
- "ai": "7.0.0-canary.153"
30
+ "@ai-sdk/provider": "4.0.0",
31
+ "ai": "7.0.1"
32
32
  },
33
33
  "devDependencies": {
34
+ "@opentelemetry/sdk-trace-base": "2.7.1",
34
35
  "@types/node": "22.19.19",
35
36
  "tsup": "^8.5.1",
36
37
  "typescript": "5.8.3",
37
38
  "zod": "3.25.76",
38
- "@ai-sdk/provider-utils": "5.0.0-canary.44",
39
+ "@ai-sdk/provider-utils": "5.0.0",
39
40
  "@vercel/ai-tsconfig": "0.0.0"
40
41
  },
41
42
  "engines": {
@@ -1,5 +1,6 @@
1
1
  import type { Attributes, AttributeValue } from '@opentelemetry/api';
2
2
  import type { LanguageModelCallOptions } from 'ai';
3
+ import { getRuntimeContextAttributes } from './supplemental-attributes';
3
4
 
4
5
  export function getBaseTelemetryAttributes({
5
6
  model,
@@ -23,12 +24,7 @@ export function getBaseTelemetryAttributes({
23
24
  }, {} as Attributes),
24
25
 
25
26
  // add context as attributes:
26
- ...Object.entries(context ?? {}).reduce((attributes, [key, value]) => {
27
- if (value != undefined) {
28
- attributes[`ai.settings.context.${key}`] = value as AttributeValue;
29
- }
30
- return attributes;
31
- }, {} as Attributes),
27
+ ...(getRuntimeContextAttributes(context) as Attributes),
32
28
 
33
29
  // request headers
34
30
  ...Object.entries(headers ?? {}).reduce((attributes, [key, value]) => {
@@ -19,13 +19,14 @@ import type {
19
19
  GenerateObjectStartEvent,
20
20
  GenerateObjectStepEndEvent,
21
21
  GenerateObjectStepStartEvent,
22
+ GenerateTextAbortEvent,
22
23
  GenerateTextEndEvent,
23
24
  GenerateTextStartEvent,
24
25
  GenerateTextStepEndEvent,
25
26
  GenerateTextStepStartEvent,
26
27
  ToolExecutionEndEvent,
27
28
  ToolExecutionStartEvent,
28
- OutputInterface as Output,
29
+ Output,
29
30
  RerankingModelCallEndEvent,
30
31
  RerankEndEvent,
31
32
  RerankStartEvent,
@@ -37,6 +38,7 @@ import type {
37
38
  } from 'ai';
38
39
  import { assembleOperationName } from './assemble-operation-name';
39
40
  import { getBaseTelemetryAttributes } from './get-base-telemetry-attributes';
41
+ import { sanitizeAttributeValue } from './sanitize-attribute-value';
40
42
  import { stringifyForTelemetry } from './stringify-for-telemetry';
41
43
 
42
44
  function recordSpanError(span: Span, error: unknown): void {
@@ -87,7 +89,10 @@ function selectAttributes(
87
89
  ) {
88
90
  if (telemetry?.recordInputs === false) continue;
89
91
  const resolved = value.input();
90
- if (resolved != null) result[key] = resolved;
92
+ if (resolved != null) {
93
+ const sanitized = sanitizeAttributeValue(resolved);
94
+ if (sanitized != null) result[key] = sanitized;
95
+ }
91
96
  continue;
92
97
  }
93
98
 
@@ -98,11 +103,15 @@ function selectAttributes(
98
103
  ) {
99
104
  if (telemetry?.recordOutputs === false) continue;
100
105
  const resolved = value.output();
101
- if (resolved != null) result[key] = resolved;
106
+ if (resolved != null) {
107
+ const sanitized = sanitizeAttributeValue(resolved);
108
+ if (sanitized != null) result[key] = sanitized;
109
+ }
102
110
  continue;
103
111
  }
104
112
 
105
- result[key] = value as AttributeValue;
113
+ const sanitized = sanitizeAttributeValue(value as AttributeValue);
114
+ if (sanitized != null) result[key] = sanitized;
106
115
  }
107
116
 
108
117
  return result;
@@ -111,7 +120,7 @@ function selectAttributes(
111
120
  interface OtelStepStartEvent<
112
121
  TOOLS extends ToolSet = ToolSet,
113
122
  RUNTIME_CONTEXT extends AISDKContext = AISDKContext,
114
- OUTPUT extends Output = Output,
123
+ OUTPUT extends Output.Output = Output.Output,
115
124
  > extends GenerateTextStepStartEvent<TOOLS, RUNTIME_CONTEXT, OUTPUT> {
116
125
  readonly promptMessages?: LanguageModelV4Prompt;
117
126
  readonly stepTools?: ReadonlyArray<Record<string, unknown>>;
@@ -174,6 +183,26 @@ export class LegacyOpenTelemetry implements Telemetry {
174
183
  return context.with(toolSpanEntry.context, execute);
175
184
  }
176
185
 
186
+ /**
187
+ * Runs the provider `doGenerate`/`doStream` call with the active legacy
188
+ * model-call context.
189
+ */
190
+ executeLanguageModelCall<T>({
191
+ callId,
192
+ execute,
193
+ }: {
194
+ callId: string;
195
+ execute: () => PromiseLike<T>;
196
+ }): PromiseLike<T> {
197
+ const stepContext = this.getCallState(callId)?.stepContext;
198
+
199
+ if (stepContext == null) {
200
+ return execute();
201
+ }
202
+
203
+ return context.with(stepContext, execute);
204
+ }
205
+
177
206
  onStart(
178
207
  event:
179
208
  | InferTelemetryEvent<GenerateTextStartEvent>
@@ -391,7 +420,7 @@ export class LegacyOpenTelemetry implements Telemetry {
391
420
  }
392
421
 
393
422
  /** @deprecated */
394
- onObjectStepFinish(event: GenerateObjectStepEndEvent): void {
423
+ onObjectStepEnd(event: GenerateObjectStepEndEvent): void {
395
424
  const state = this.getCallState(event.callId);
396
425
  if (!state?.stepSpan) return;
397
426
 
@@ -634,7 +663,7 @@ export class LegacyOpenTelemetry implements Telemetry {
634
663
  state.toolSpans.delete(event.toolCall.toolCallId);
635
664
  }
636
665
 
637
- onStepFinish(event: GenerateTextStepEndEvent<ToolSet>): void {
666
+ onStepEnd(event: GenerateTextStepEndEvent<ToolSet>): void {
638
667
  const state = this.getCallState(event.callId);
639
668
  if (!state?.stepSpan) return;
640
669
 
@@ -687,7 +716,7 @@ export class LegacyOpenTelemetry implements Telemetry {
687
716
  ? JSON.stringify(event.providerMetadata)
688
717
  : undefined,
689
718
  'ai.response.msToFirstChunk': isStreamText
690
- ? event.performance.timeToFirstOutputTokenMs
719
+ ? event.performance.timeToFirstOutputMs
691
720
  : undefined,
692
721
  'ai.response.msToFinish': isStreamText
693
722
  ? event.performance.responseTimeMs
@@ -722,10 +751,9 @@ export class LegacyOpenTelemetry implements Telemetry {
722
751
  }),
723
752
  );
724
753
 
725
- if (isStreamText && event.performance.timeToFirstOutputTokenMs != null) {
754
+ if (isStreamText && event.performance.timeToFirstOutputMs != null) {
726
755
  state.stepSpan.addEvent('ai.stream.firstChunk', {
727
- 'ai.response.msToFirstChunk':
728
- event.performance.timeToFirstOutputTokenMs,
756
+ 'ai.response.msToFirstChunk': event.performance.timeToFirstOutputMs,
729
757
  });
730
758
  }
731
759
 
@@ -742,6 +770,11 @@ export class LegacyOpenTelemetry implements Telemetry {
742
770
  state.stepContext = undefined;
743
771
  }
744
772
 
773
+ /** @deprecated Use `onStepEnd` instead. */
774
+ onStepFinish(event: GenerateTextStepEndEvent<ToolSet>): void {
775
+ this.onStepEnd(event);
776
+ }
777
+
745
778
  onEnd(
746
779
  event:
747
780
  | GenerateTextEndEvent<ToolSet>
@@ -790,8 +823,8 @@ export class LegacyOpenTelemetry implements Telemetry {
790
823
  },
791
824
  'ai.response.reasoning': {
792
825
  output: () =>
793
- event.reasoning.length > 0
794
- ? event.reasoning
826
+ event.finalStep.reasoning.length > 0
827
+ ? event.finalStep.reasoning
795
828
  .filter(part => 'text' in part)
796
829
  .map(part => part.text)
797
830
  .join('\n')
@@ -821,27 +854,27 @@ export class LegacyOpenTelemetry implements Telemetry {
821
854
  )
822
855
  : undefined,
823
856
  },
824
- 'ai.response.providerMetadata': event.providerMetadata
825
- ? JSON.stringify(event.providerMetadata)
857
+ 'ai.response.providerMetadata': event.finalStep.providerMetadata
858
+ ? JSON.stringify(event.finalStep.providerMetadata)
826
859
  : undefined,
827
860
 
828
- 'ai.usage.inputTokens': event.totalUsage.inputTokens,
829
- 'ai.usage.outputTokens': event.totalUsage.outputTokens,
830
- 'ai.usage.totalTokens': event.totalUsage.totalTokens,
861
+ 'ai.usage.inputTokens': event.usage.inputTokens,
862
+ 'ai.usage.outputTokens': event.usage.outputTokens,
863
+ 'ai.usage.totalTokens': event.usage.totalTokens,
831
864
  'ai.usage.reasoningTokens':
832
- event.totalUsage.outputTokenDetails?.reasoningTokens,
865
+ event.usage.outputTokenDetails?.reasoningTokens,
833
866
  'ai.usage.cachedInputTokens':
834
- event.totalUsage.inputTokenDetails?.cacheReadTokens,
867
+ event.usage.inputTokenDetails?.cacheReadTokens,
835
868
  'ai.usage.inputTokenDetails.noCacheTokens':
836
- event.totalUsage.inputTokenDetails?.noCacheTokens,
869
+ event.usage.inputTokenDetails?.noCacheTokens,
837
870
  'ai.usage.inputTokenDetails.cacheReadTokens':
838
- event.totalUsage.inputTokenDetails?.cacheReadTokens,
871
+ event.usage.inputTokenDetails?.cacheReadTokens,
839
872
  'ai.usage.inputTokenDetails.cacheWriteTokens':
840
- event.totalUsage.inputTokenDetails?.cacheWriteTokens,
873
+ event.usage.inputTokenDetails?.cacheWriteTokens,
841
874
  'ai.usage.outputTokenDetails.textTokens':
842
- event.totalUsage.outputTokenDetails?.textTokens,
875
+ event.usage.outputTokenDetails?.textTokens,
843
876
  'ai.usage.outputTokenDetails.reasoningTokens':
844
- event.totalUsage.outputTokenDetails?.reasoningTokens,
877
+ event.usage.outputTokenDetails?.reasoningTokens,
845
878
  }),
846
879
  );
847
880
 
@@ -1067,6 +1100,35 @@ export class LegacyOpenTelemetry implements Telemetry {
1067
1100
  state.rerankSpan = undefined;
1068
1101
  }
1069
1102
 
1103
+ onAbort(event: GenerateTextAbortEvent<ToolSet>): void {
1104
+ const state = this.getCallState(event.callId);
1105
+ if (!state?.rootSpan) return;
1106
+
1107
+ for (const { span: toolSpan } of state.toolSpans.values()) {
1108
+ toolSpan.end();
1109
+ }
1110
+ state.toolSpans.clear();
1111
+
1112
+ if (state.stepSpan) {
1113
+ state.stepSpan.end();
1114
+ state.stepSpan = undefined;
1115
+ state.stepContext = undefined;
1116
+ }
1117
+
1118
+ for (const { span: embedSpan } of state.embedSpans.values()) {
1119
+ embedSpan.end();
1120
+ }
1121
+ state.embedSpans.clear();
1122
+
1123
+ if (state.rerankSpan) {
1124
+ state.rerankSpan.span.end();
1125
+ state.rerankSpan = undefined;
1126
+ }
1127
+
1128
+ state.rootSpan.end();
1129
+ this.cleanupCallState(event.callId);
1130
+ }
1131
+
1070
1132
  onError(error: unknown): void {
1071
1133
  const event = error as { callId?: string; error?: unknown };
1072
1134
  if (!event?.callId) return;
@@ -18,6 +18,7 @@ import type {
18
18
  GenerateObjectStartEvent,
19
19
  GenerateObjectStepEndEvent,
20
20
  GenerateObjectStepStartEvent,
21
+ GenerateTextAbortEvent,
21
22
  GenerateTextEndEvent,
22
23
  GenerateTextStartEvent,
23
24
  GenerateTextStepEndEvent,
@@ -43,6 +44,7 @@ import {
43
44
  mapProviderName,
44
45
  } from './gen-ai-format-messages';
45
46
  import { recordErrorOnSpan } from './record-span';
47
+ import { sanitizeAttributes } from './sanitize-attribute-value';
46
48
  import { selectAttributes } from './select-attributes';
47
49
  import {
48
50
  getDetailedUsageAttributes,
@@ -85,6 +87,24 @@ interface CallState {
85
87
  baseSupplementalAttributes: Attributes;
86
88
  }
87
89
 
90
+ function msToSeconds(durationMs: number | undefined): number | undefined {
91
+ return durationMs == null ? undefined : durationMs / 1000;
92
+ }
93
+
94
+ function getGenAIClientPerformanceAttributes(
95
+ performance: LanguageModelCallEndEvent<ToolSet>['performance'],
96
+ ): Attributes {
97
+ return {
98
+ 'gen_ai.client.operation.duration': msToSeconds(performance.responseTimeMs),
99
+ 'gen_ai.client.operation.time_to_first_chunk': msToSeconds(
100
+ performance.timeToFirstOutputMs,
101
+ ),
102
+ 'gen_ai.client.operation.time_per_output_chunk': msToSeconds(
103
+ performance.timeBetweenOutputChunksMs?.avg,
104
+ ),
105
+ };
106
+ }
107
+
88
108
  export class OpenTelemetry implements Telemetry {
89
109
  private readonly callStates = new Map<string, CallState>();
90
110
 
@@ -133,7 +153,7 @@ export class OpenTelemetry implements Telemetry {
133
153
  }
134
154
 
135
155
  return {
136
- ...customAttributes,
156
+ ...sanitizeAttributes(customAttributes),
137
157
  ...attributes,
138
158
  };
139
159
  }
@@ -156,6 +176,27 @@ export class OpenTelemetry implements Telemetry {
156
176
  return context.with(toolSpanEntry.context, execute);
157
177
  }
158
178
 
179
+ /**
180
+ * Runs the provider `doGenerate`/`doStream` call with the active model-call
181
+ * context.
182
+ */
183
+ executeLanguageModelCall<T>({
184
+ callId,
185
+ execute,
186
+ }: {
187
+ callId: string;
188
+ execute: () => PromiseLike<T>;
189
+ }): PromiseLike<T> {
190
+ const state = this.getCallState(callId);
191
+ const modelCallContext = state?.inferenceContext ?? state?.stepContext;
192
+
193
+ if (modelCallContext == null) {
194
+ return execute();
195
+ }
196
+
197
+ return context.with(modelCallContext, execute);
198
+ }
199
+
159
200
  onStart(
160
201
  event:
161
202
  | InferTelemetryEvent<GenerateTextStartEvent>
@@ -456,7 +497,7 @@ export class OpenTelemetry implements Telemetry {
456
497
  }
457
498
 
458
499
  /** @deprecated */
459
- onObjectStepFinish(event: GenerateObjectStepEndEvent): void {
500
+ onObjectStepEnd(event: GenerateObjectStepEndEvent): void {
460
501
  const state = this.getCallState(event.callId);
461
502
  if (!state?.inferenceSpan) return;
462
503
 
@@ -691,6 +732,7 @@ export class OpenTelemetry implements Telemetry {
691
732
 
692
733
  state.inferenceSpan.setAttributes(
693
734
  selectAttributes(telemetry, {
735
+ ...getGenAIClientPerformanceAttributes(event.performance),
694
736
  'gen_ai.response.finish_reasons': [event.finishReason],
695
737
  'gen_ai.response.id': event.responseId,
696
738
  'gen_ai.usage.input_tokens': event.usage.inputTokens,
@@ -783,6 +825,12 @@ export class OpenTelemetry implements Telemetry {
783
825
  const { telemetry } = state;
784
826
 
785
827
  const { toolOutput } = event;
828
+ span.setAttributes(
829
+ selectAttributes(telemetry, {
830
+ 'gen_ai.execute_tool.duration': msToSeconds(event.toolExecutionMs),
831
+ }),
832
+ );
833
+
786
834
  if (toolOutput.type === 'tool-result') {
787
835
  try {
788
836
  span.setAttributes(
@@ -803,7 +851,7 @@ export class OpenTelemetry implements Telemetry {
803
851
  state.toolSpans.delete(event.toolCall.toolCallId);
804
852
  }
805
853
 
806
- onStepFinish(event: GenerateTextStepEndEvent<ToolSet>): void {
854
+ onStepEnd(event: GenerateTextStepEndEvent<ToolSet>): void {
807
855
  const state = this.getCallState(event.callId);
808
856
  if (!state?.stepSpan) return;
809
857
 
@@ -825,6 +873,11 @@ export class OpenTelemetry implements Telemetry {
825
873
  state.stepContext = undefined;
826
874
  }
827
875
 
876
+ /** @deprecated Use `onStepEnd` instead. */
877
+ onStepFinish(event: GenerateTextStepEndEvent<ToolSet>): void {
878
+ this.onStepEnd(event);
879
+ }
880
+
828
881
  onEnd(
829
882
  event:
830
883
  | GenerateTextEndEvent<ToolSet>
@@ -868,18 +921,20 @@ export class OpenTelemetry implements Telemetry {
868
921
  state.rootSpan.setAttributes(
869
922
  selectAttributes(telemetry, {
870
923
  'gen_ai.response.finish_reasons': [event.finishReason],
871
- 'gen_ai.usage.input_tokens': event.totalUsage.inputTokens,
872
- 'gen_ai.usage.output_tokens': event.totalUsage.outputTokens,
924
+ 'gen_ai.usage.input_tokens': event.usage.inputTokens,
925
+ 'gen_ai.usage.output_tokens': event.usage.outputTokens,
873
926
  'gen_ai.usage.cache_read.input_tokens':
874
- event.totalUsage.inputTokenDetails?.cacheReadTokens,
927
+ event.usage.inputTokenDetails?.cacheReadTokens,
875
928
  'gen_ai.usage.cache_creation.input_tokens':
876
- event.totalUsage.inputTokenDetails?.cacheWriteTokens,
929
+ event.usage.inputTokenDetails?.cacheWriteTokens,
877
930
  'gen_ai.output.messages': {
878
931
  output: () =>
879
932
  JSON.stringify(
880
933
  formatOutputMessages({
881
934
  text: event.text ?? undefined,
882
- reasoning: event.reasoning as ReadonlyArray<{ text?: string }>,
935
+ reasoning: event.finalStep.reasoning as ReadonlyArray<{
936
+ text?: string;
937
+ }>,
883
938
  toolCalls: event.toolCalls,
884
939
  files: event.files,
885
940
  finishReason: event.finishReason,
@@ -891,11 +946,11 @@ export class OpenTelemetry implements Telemetry {
891
946
  this.supplementalAttributes,
892
947
  {
893
948
  providerMetadata: {
894
- 'ai.response.providerMetadata': event.providerMetadata
895
- ? JSON.stringify(event.providerMetadata)
949
+ 'ai.response.providerMetadata': event.finalStep.providerMetadata
950
+ ? JSON.stringify(event.finalStep.providerMetadata)
896
951
  : undefined,
897
952
  },
898
- usage: getDetailedUsageAttributes(event.totalUsage),
953
+ usage: getDetailedUsageAttributes(event.usage),
899
954
  },
900
955
  ),
901
956
  }),
@@ -1197,6 +1252,41 @@ export class OpenTelemetry implements Telemetry {
1197
1252
  state.rerankSpan = undefined;
1198
1253
  }
1199
1254
 
1255
+ onAbort(event: GenerateTextAbortEvent<ToolSet>): void {
1256
+ const state = this.getCallState(event.callId);
1257
+ if (!state?.rootSpan) return;
1258
+
1259
+ for (const { span: toolSpan } of state.toolSpans.values()) {
1260
+ toolSpan.end();
1261
+ }
1262
+ state.toolSpans.clear();
1263
+
1264
+ if (state.inferenceSpan) {
1265
+ state.inferenceSpan.end();
1266
+ state.inferenceSpan = undefined;
1267
+ state.inferenceContext = undefined;
1268
+ }
1269
+
1270
+ if (state.stepSpan) {
1271
+ state.stepSpan.end();
1272
+ state.stepSpan = undefined;
1273
+ state.stepContext = undefined;
1274
+ }
1275
+
1276
+ for (const { span: embedSpan } of state.embedSpans.values()) {
1277
+ embedSpan.end();
1278
+ }
1279
+ state.embedSpans.clear();
1280
+
1281
+ if (state.rerankSpan) {
1282
+ state.rerankSpan.span.end();
1283
+ state.rerankSpan = undefined;
1284
+ }
1285
+
1286
+ state.rootSpan.end();
1287
+ this.cleanupCallState(event.callId);
1288
+ }
1289
+
1200
1290
  onError(error: unknown): void {
1201
1291
  const event = error as { callId?: string; error?: unknown };
1202
1292
  if (!event?.callId) return;
@@ -0,0 +1,53 @@
1
+ import type { Attributes, AttributeValue } from '@opentelemetry/api';
2
+
3
+ function isPrimitiveAttributeValue(
4
+ value: unknown,
5
+ ): value is string | number | boolean {
6
+ return (
7
+ typeof value === 'string' ||
8
+ typeof value === 'number' ||
9
+ typeof value === 'boolean'
10
+ );
11
+ }
12
+
13
+ export function sanitizeAttributeValue(
14
+ value: AttributeValue,
15
+ ): AttributeValue | undefined {
16
+ if (!Array.isArray(value)) {
17
+ return value;
18
+ }
19
+
20
+ const primitiveTypes = new Set(
21
+ value.filter(isPrimitiveAttributeValue).map(item => typeof item),
22
+ );
23
+
24
+ if (primitiveTypes.size !== 1) {
25
+ return undefined;
26
+ }
27
+
28
+ const [primitiveType] = primitiveTypes;
29
+
30
+ if (primitiveType === 'string') {
31
+ return value.filter((item): item is string => typeof item === 'string');
32
+ }
33
+
34
+ if (primitiveType === 'number') {
35
+ return value.filter((item): item is number => typeof item === 'number');
36
+ }
37
+
38
+ return value.filter((item): item is boolean => typeof item === 'boolean');
39
+ }
40
+
41
+ export function sanitizeAttributes(
42
+ attributes: Attributes | undefined,
43
+ ): Attributes {
44
+ const result: Attributes = {};
45
+
46
+ for (const [key, value] of Object.entries(attributes ?? {})) {
47
+ if (value == null) continue;
48
+ const sanitized = sanitizeAttributeValue(value);
49
+ if (sanitized != null) result[key] = sanitized;
50
+ }
51
+
52
+ return result;
53
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Attributes, AttributeValue } from '@opentelemetry/api';
2
2
  import type { TelemetryOptions } from 'ai';
3
+ import { sanitizeAttributeValue } from './sanitize-attribute-value';
3
4
 
4
5
  export type AttributeSpec =
5
6
  | AttributeValue
@@ -35,7 +36,10 @@ export function selectAttributes(
35
36
  ) {
36
37
  if (telemetry?.recordInputs === false) continue;
37
38
  const resolved = value.input();
38
- if (resolved != null) result[key] = resolved;
39
+ if (resolved != null) {
40
+ const sanitized = sanitizeAttributeValue(resolved);
41
+ if (sanitized != null) result[key] = sanitized;
42
+ }
39
43
  continue;
40
44
  }
41
45
 
@@ -46,11 +50,15 @@ export function selectAttributes(
46
50
  ) {
47
51
  if (telemetry?.recordOutputs === false) continue;
48
52
  const resolved = value.output();
49
- if (resolved != null) result[key] = resolved;
53
+ if (resolved != null) {
54
+ const sanitized = sanitizeAttributeValue(resolved);
55
+ if (sanitized != null) result[key] = sanitized;
56
+ }
50
57
  continue;
51
58
  }
52
59
 
53
- result[key] = value as AttributeValue;
60
+ const sanitized = sanitizeAttributeValue(value as AttributeValue);
61
+ if (sanitized != null) result[key] = sanitized;
54
62
  }
55
63
 
56
64
  return result;
@@ -1,6 +1,10 @@
1
1
  import type { Attributes, Tracer } from '@opentelemetry/api';
2
2
  import type { TelemetryOptions } from 'ai';
3
- import { selectAttributes, type AttributeSpecMap } from './select-attributes';
3
+ import {
4
+ selectAttributes,
5
+ type AttributeSpec,
6
+ type AttributeSpecMap,
7
+ } from './select-attributes';
4
8
 
5
9
  type SupplementalAttributeOption =
6
10
  | 'usage'
@@ -115,11 +119,36 @@ export function normalizeSupplementalAttributes(
115
119
  export function getRuntimeContextAttributes(
116
120
  context: Record<string, unknown> | undefined,
117
121
  ): AttributeSpecMap {
118
- return Object.fromEntries(
119
- Object.entries(context ?? {})
120
- .filter(([, value]) => value != null)
121
- .map(([key, value]) => [`ai.settings.context.${key}`, value]),
122
- ) as AttributeSpecMap;
122
+ const attributes: AttributeSpecMap = {};
123
+
124
+ for (const [key, value] of Object.entries(context ?? {})) {
125
+ addRuntimeContextAttribute(attributes, `ai.settings.context.${key}`, value);
126
+ }
127
+
128
+ return attributes;
129
+ }
130
+
131
+ /**
132
+ * Flattens nested runtime context objects into OTel-compatible attribute keys.
133
+ * Arrays are preserved because OTel supports primitive array attribute values.
134
+ */
135
+ function addRuntimeContextAttribute(
136
+ attributes: AttributeSpecMap,
137
+ key: string,
138
+ value: unknown,
139
+ ): void {
140
+ if (value == null) {
141
+ return;
142
+ }
143
+
144
+ if (Array.isArray(value) || typeof value !== 'object') {
145
+ attributes[key] = value as AttributeSpec;
146
+ return;
147
+ }
148
+
149
+ for (const [nestedKey, nestedValue] of Object.entries(value)) {
150
+ addRuntimeContextAttribute(attributes, `${key}.${nestedKey}`, nestedValue);
151
+ }
123
152
  }
124
153
 
125
154
  export function getHeaderAttributes(