|
1 | 1 | import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; |
2 | 2 | import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; |
| 3 | +import type { Span } from '@sentry/core'; |
3 | 4 | import { |
4 | 5 | _INTERNAL_cleanupToolCallSpanContext, |
5 | 6 | _INTERNAL_getSpanContextForToolCallId, |
@@ -42,114 +43,108 @@ interface RecordingOptions { |
42 | 43 | recordOutputs?: boolean; |
43 | 44 | } |
44 | 45 |
|
45 | | -interface ToolErrorPart { |
46 | | - type: 'tool-error'; |
| 46 | +interface ToolError { |
| 47 | + type: 'tool-error' | 'tool-result' | 'tool-call'; |
47 | 48 | toolCallId: string; |
48 | 49 | toolName: string; |
| 50 | + input?: { |
| 51 | + [key: string]: unknown; |
| 52 | + }; |
49 | 53 | error: Error; |
| 54 | + dynamic?: boolean; |
50 | 55 | } |
51 | 56 |
|
52 | | -interface ToolResultPart { |
53 | | - type: 'tool-result'; |
54 | | - toolCallId: string; |
55 | | - toolName: string; |
56 | | -} |
57 | | - |
58 | | -function isToolErrorPart(obj: unknown): obj is ToolErrorPart { |
| 57 | +function isToolError(obj: unknown): obj is ToolError { |
59 | 58 | if (typeof obj !== 'object' || obj === null) { |
60 | 59 | return false; |
61 | 60 | } |
62 | 61 |
|
63 | 62 | const candidate = obj as Record<string, unknown>; |
64 | 63 | return ( |
| 64 | + 'type' in candidate && |
| 65 | + 'error' in candidate && |
| 66 | + 'toolName' in candidate && |
| 67 | + 'toolCallId' in candidate && |
65 | 68 | candidate.type === 'tool-error' && |
66 | | - typeof candidate.toolName === 'string' && |
67 | | - typeof candidate.toolCallId === 'string' && |
68 | 69 | candidate.error instanceof Error |
69 | 70 | ); |
70 | 71 | } |
71 | 72 |
|
72 | | -function isToolResultPart(obj: unknown): obj is ToolResultPart { |
| 73 | +function isToolResult(obj: unknown): obj is { type: 'tool-result'; toolCallId: string } { |
73 | 74 | if (typeof obj !== 'object' || obj === null) { |
74 | 75 | return false; |
75 | 76 | } |
76 | 77 |
|
77 | 78 | const candidate = obj as Record<string, unknown>; |
78 | | - return ( |
79 | | - candidate.type === 'tool-result' && |
80 | | - typeof candidate.toolName === 'string' && |
81 | | - typeof candidate.toolCallId === 'string' |
82 | | - ); |
| 79 | + return candidate.type === 'tool-result' && typeof candidate.toolCallId === 'string'; |
83 | 80 | } |
84 | 81 |
|
85 | 82 | /** |
86 | 83 | * Check for tool errors in the result and capture them |
87 | 84 | * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content |
88 | 85 | */ |
89 | | -function checkResultForToolErrors(result: unknown): void { |
| 86 | +export function _INTERNAL_checkResultForToolErrors(result: unknown): void { |
90 | 87 | if (typeof result !== 'object' || result === null || !('content' in result)) { |
91 | 88 | return; |
92 | 89 | } |
93 | 90 |
|
94 | | - const resultObj = result as { content: unknown }; |
| 91 | + const resultObj = result as { content: Array<object> }; |
95 | 92 | if (!Array.isArray(resultObj.content)) { |
96 | 93 | return; |
97 | 94 | } |
98 | 95 |
|
99 | 96 | for (const item of resultObj.content) { |
100 | | - // Successful tool calls should not keep toolCallId -> span context mappings alive. |
101 | | - if (isToolResultPart(item)) { |
| 97 | + // Clean up successful tool call entries to prevent memory leaks |
| 98 | + if (isToolResult(item)) { |
102 | 99 | _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); |
103 | 100 | continue; |
104 | 101 | } |
105 | 102 |
|
106 | | - if (!isToolErrorPart(item)) { |
107 | | - continue; |
108 | | - } |
109 | | - |
110 | | - // Try to get the span context associated with this tool call ID |
111 | | - const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); |
| 103 | + if (isToolError(item)) { |
| 104 | + // Try to get the span context associated with this tool call ID |
| 105 | + const spanContext = _INTERNAL_getSpanContextForToolCallId(item.toolCallId); |
112 | 106 |
|
113 | | - if (spanContext) { |
114 | | - // We have a span context, so link the error using span and trace IDs from the span |
115 | | - withScope(scope => { |
116 | | - // Set the span and trace context for proper linking |
117 | | - scope.setContext('trace', { |
118 | | - trace_id: spanContext.traceId, |
119 | | - span_id: spanContext.spanId, |
120 | | - }); |
| 107 | + if (spanContext) { |
| 108 | + // We have the span context, so link the error using span and trace IDs |
| 109 | + withScope(scope => { |
| 110 | + // Set the span and trace context for proper linking |
| 111 | + scope.setContext('trace', { |
| 112 | + trace_id: spanContext.traceId, |
| 113 | + span_id: spanContext.spanId, |
| 114 | + }); |
121 | 115 |
|
122 | | - scope.setTag('vercel.ai.tool.name', item.toolName); |
123 | | - scope.setTag('vercel.ai.tool.callId', item.toolCallId); |
| 116 | + scope.setTag('vercel.ai.tool.name', item.toolName); |
| 117 | + scope.setTag('vercel.ai.tool.callId', item.toolCallId); |
124 | 118 |
|
125 | | - scope.setLevel('error'); |
| 119 | + scope.setLevel('error'); |
126 | 120 |
|
127 | | - captureException(item.error, { |
128 | | - mechanism: { |
129 | | - type: 'auto.vercelai.otel', |
130 | | - handled: false, |
131 | | - }, |
| 121 | + captureException(item.error, { |
| 122 | + mechanism: { |
| 123 | + type: 'auto.vercelai.otel', |
| 124 | + handled: false, |
| 125 | + }, |
| 126 | + }); |
132 | 127 | }); |
133 | | - }); |
134 | | - } else { |
135 | | - // Fallback: capture without span linking |
136 | | - withScope(scope => { |
137 | | - scope.setTag('vercel.ai.tool.name', item.toolName); |
138 | | - scope.setTag('vercel.ai.tool.callId', item.toolCallId); |
139 | | - scope.setLevel('error'); |
140 | | - |
141 | | - captureException(item.error, { |
142 | | - mechanism: { |
143 | | - type: 'auto.vercelai.otel', |
144 | | - handled: false, |
145 | | - }, |
| 128 | + |
| 129 | + // Clean up the span mapping since we've processed this tool error |
| 130 | + // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. |
| 131 | + _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); |
| 132 | + } else { |
| 133 | + // Fallback: capture without span linking |
| 134 | + withScope(scope => { |
| 135 | + scope.setTag('vercel.ai.tool.name', item.toolName); |
| 136 | + scope.setTag('vercel.ai.tool.callId', item.toolCallId); |
| 137 | + scope.setLevel('error'); |
| 138 | + |
| 139 | + captureException(item.error, { |
| 140 | + mechanism: { |
| 141 | + type: 'auto.vercelai.otel', |
| 142 | + handled: false, |
| 143 | + }, |
| 144 | + }); |
146 | 145 | }); |
147 | | - }); |
| 146 | + } |
148 | 147 | } |
149 | | - |
150 | | - // Clean up the span mapping since we've processed this tool error |
151 | | - // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. |
152 | | - _INTERNAL_cleanupToolCallSpanContext(item.toolCallId); |
153 | 148 | } |
154 | 149 | } |
155 | 150 |
|
@@ -270,7 +265,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { |
270 | 265 | }, |
271 | 266 | () => {}, |
272 | 267 | result => { |
273 | | - checkResultForToolErrors(result); |
| 268 | + _INTERNAL_checkResultForToolErrors(result); |
274 | 269 | }, |
275 | 270 | ); |
276 | 271 | }, |
|
0 commit comments