feat(node): Add AI SDK v7 support via diagnostics_channel#20896
feat(node): Add AI SDK v7 support via diagnostics_channel#20896sergical wants to merge 2 commits into
Conversation
AI SDK v7 publishes all telemetry events to node:diagnostics_channel on 'aisdk:telemetry', regardless of which OpenTelemetry integration the user registers. This subscribes to that channel and creates spans directly with gen_ai.* attributes — no OTel span translation needed. - v3-v6: existing OTel instrumentation path (unchanged) - v7+: diagnostic channel subscriber creates spans from raw events - On v3-v6, the DC subscriber is inert (channel never published to) Handles: generateText, streamText, generateObject, streamObject, embed, embedMany, rerank, tool execution, tool errors, ToolLoopAgent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
size-limit report 📦
|
| @@ -0,0 +1,65 @@ | |||
| import { subscribe } from 'node:diagnostics_channel'; | |||
There was a problem hiding this comment.
m: The subscriber feels pretty thin, let's just merge both files here.
There was a problem hiding this comment.
Done — merged dc-subscriber.ts into dc-handlers.ts. The subscription logic and handler dispatch now live in a single file.
[AI-generated comment]
There was a problem hiding this comment.
Nothing changed here, did you push? 😅 if not then maybe keep it for now, looks like we will add a lot of handlers over there.
logaretm
left a comment
There was a problem hiding this comment.
Did a first pass, some minor concerns and questions.
| interface Usage { | ||
| inputTokens?: number; | ||
| outputTokens?: number; | ||
| inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }; | ||
| outputTokenDetails?: { reasoningTokens?: number }; | ||
| } | ||
|
|
||
| interface ContentPart { | ||
| type: string; | ||
| text?: string; | ||
| toolCallId?: string; | ||
| toolName?: string; | ||
| input?: unknown; | ||
| } | ||
| interface ToolCall { | ||
| toolCallId: string; | ||
| toolName: string; | ||
| input?: unknown; | ||
| } |
There was a problem hiding this comment.
m: we got quite a few of these types thrown around, let's move them out in a ./types.ts file to keep things focused here.
It would be cool to add link comments to their shape in the ai-sdk source so we can track them if something breaks. Or just copy their types over here.
There was a problem hiding this comment.
Done — moved `Usage`, `ContentPart`, and `ToolCall` to `./types.ts` (as `AiSdkUsage`, `AiSdkContentPart`, `AiSdkToolCall`). Added a source link comment pointing to the AI SDK telemetry source.
[AI-generated comment]
| @@ -0,0 +1,65 @@ | |||
| import { subscribe } from 'node:diagnostics_channel'; | |||
There was a problem hiding this comment.
Nothing changed here, did you push? 😅 if not then maybe keep it for now, looks like we will add a lot of handlers over there.
| function mapOperationName(operationId: string): string { | ||
| switch (operationId) { | ||
| case 'ai.generateText': | ||
| case 'ai.streamText': | ||
| case 'ai.generateObject': | ||
| case 'ai.streamObject': | ||
| return 'invoke_agent'; | ||
| case 'ai.embed': | ||
| case 'ai.embedMany': | ||
| return 'embeddings'; | ||
| case 'ai.rerank': | ||
| return 'rerank'; | ||
| default: | ||
| return operationId; | ||
| } | ||
| } |
There was a problem hiding this comment.
nitpick: a simple map would work better here, no?
const opMap = {
'ai.generateText': 'invoke_agent'
// ...
};My reasoning is fallthrough switches are a bit tricky to maintain or read at a glance. A map object gives us granularity and makes it easier to maintain.
There was a problem hiding this comment.
Done — replaced the switch with a `Record<AiSdkOperationId, string>` map object + nullish coalescing fallback.
[AI-generated comment]
|
|
||
| const callStates = new Map<string, CallState>(); | ||
|
|
||
| function mapOperationName(operationId: string): string { |
There was a problem hiding this comment.
l/nit: Can we type the expected string input/output here? as string literals.
There was a problem hiding this comment.
Done — added `AiSdkOperationId` string literal union type for the input and the map is typed as `Record<AiSdkOperationId, string>`.
[AI-generated comment]
| case 'onStart': | ||
| handleOnStart(msg.event); | ||
| break; | ||
| case 'onLanguageModelCallStart': | ||
| handleOnLanguageModelCallStart(msg.event); | ||
| break; | ||
| case 'onLanguageModelCallEnd': | ||
| handleOnLanguageModelCallEnd(msg.event); | ||
| break; | ||
| case 'onToolExecutionStart': | ||
| handleOnToolExecutionStart(msg.event); | ||
| break; | ||
| case 'onToolExecutionEnd': | ||
| handleOnToolExecutionEnd(msg.event); | ||
| break; | ||
| case 'onEnd': | ||
| handleOnEnd(msg.event); | ||
| break; | ||
| case 'onError': | ||
| handleOnError(msg.event); |
There was a problem hiding this comment.
h: looks like they have more events for us to instrument here, specifically around steps.
Vercel has onStepStart / onStepFinish and parents LM/tool spans under the step. We should aim for parity with their otel telemetry.
Maybe we should wait a bit for them to finalize their events shape.
There was a problem hiding this comment.
Agreed — the dc-subscriber.ts dispatch map is easy to extend once the step events stabilize. We can add `onStepStart`/`onStepFinish` handlers as a follow-up once Vercel finalizes the event shape.
[AI-generated comment]
| function normalizeFinishReason(reason: unknown): string { | ||
| if (typeof reason !== 'string') return 'stop'; | ||
| return reason === 'tool-calls' ? 'tool_call' : reason; | ||
| } |
There was a problem hiding this comment.
nit: Feels like the reason can be typed, WDYT?
There was a problem hiding this comment.
Done — added `AiSdkFinishReason` string literal union type and `normalizeFinishReason` now takes that as input.
[AI-generated comment]
| function buildOutputMessages(content: ContentPart[], finishReason: unknown): string | undefined { | ||
| const parts: Record<string, unknown>[] = []; | ||
| const text = content | ||
| .filter(p => p.type === 'text' && p.text) | ||
| .map(p => p.text) | ||
| .join(''); | ||
| if (text) parts.push({ type: 'text', content: text }); | ||
| for (const tc of content.filter(p => p.type === 'tool-call')) { | ||
| parts.push({ | ||
| type: 'tool_call', | ||
| id: tc.toolCallId, | ||
| name: tc.toolName, | ||
| arguments: typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input ?? {}), | ||
| }); | ||
| } | ||
| if (parts.length === 0) return undefined; | ||
| return JSON.stringify([{ role: 'assistant', parts, finish_reason: normalizeFinishReason(finishReason) }]); | ||
| } |
There was a problem hiding this comment.
m: Maybe I'm paranoid but since we are stitching spans lifecycle ourselves here, then we should probably be careful with any JSON.stringify calls, if they throw then we break the span tree.
So maybe let's create a util file for the instrumentation with safeJsonParse, you would need to consider reasonable fallbacks to use in case it does fail.
Same thing for any other parses here in this file.
There was a problem hiding this comment.
Good call — created `dc-utils.ts` with a `safeStringify` helper that returns `undefined` on failure. All `JSON.stringify` calls in dc-handlers.ts now go through it, with callers skipping the attribute when it returns `undefined`.
[AI-generated comment]
- Merge dc-subscriber.ts into dc-handlers.ts - Move DC event types (Usage, ContentPart, ToolCall) to types.ts with source links - Replace switch with Record map for operation name mapping - Add AiSdkFinishReason and AiSdkOperationId string literal types - Add safeStringify utility to prevent JSON.stringify from breaking span tree - Clean up child spans in handleOnEnd to prevent orphaned spans - Guard error handling in handleOnError with instanceof check Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| if (val) attributes['gen_ai.request.available_tools'] = val; | ||
| } | ||
| } | ||
| state.inferenceSpan = startInactiveSpan({ name: `generate_content ${modelId}`, attributes }); |
There was a problem hiding this comment.
Bug: Child spans in Vercel AI tracing handlers are not parented correctly because startInactiveSpan is called without an explicit parent or an active span in the scope.
Severity: HIGH
Suggested Fix
Explicitly pass the parent span when creating child spans. For example, when creating inferenceSpan, call startInactiveSpan({ ..., parentSpan: state.rootSpan }). Apply the same fix for tool spans. Alternatively, wrap the handler logic in withActiveSpan(state.rootSpan, ...).
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: packages/node/src/integrations/tracing/vercelai/dc-handlers.ts#L162
Potential issue: In the Vercel AI diagnostic channel handlers, child spans
(`inferenceSpan` and tool spans) are created using `startInactiveSpan`. However, the
`rootSpan` is not set as the active span in the execution context. When
`startInactiveSpan` is called for the child spans without an explicit `parentSpan`
argument, it looks for an active span on the current scope. Since no active span is
found, the new spans are created as disconnected root spans instead of children of the
intended `rootSpan`. This results in a broken trace hierarchy, where users will see a
flat list of unrelated spans instead of a correctly nested trace tree.
Also affects:
packages/node/src/integrations/tracing/vercelai/dc-handlers.ts:207~210
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8129519. Configure here.
| for (const tc of content.filter(p => p.type === 'tool-call')) { | ||
| const args = typeof tc.input === 'string' ? tc.input : safeStringify(tc.input ?? {}); | ||
| parts.push({ type: 'tool_call', id: tc.toolCallId, name: tc.toolName, arguments: args }); | ||
| } |
There was a problem hiding this comment.
Multiple iterations over same content array
Low Severity
In buildOutputMessages, the content array is iterated four times: .filter(), .map(), .join() for text parts, then .filter() again for tool-call parts. A single classic for loop could accumulate both text and tool-call parts in one pass.
Triggered by project rule: PR Review Guidelines for Cursor Bot
Reviewed by Cursor Bugbot for commit 8129519. Configure here.


Summary
node:diagnostics_channelsubscription'aisdk:telemetry'regardless of which OTel integration the user registers — we subscribe and create spans directly withgen_ai.*attributes<7)New files
packages/node/src/integrations/tracing/vercelai/dc-handlers.ts— event handlers mapping DC events → Sentry spanspackages/node/src/integrations/tracing/vercelai/dc-subscriber.ts— subscribes to'aisdk:telemetry'and dispatches to handlersTest coverage (14 tests, all passing)
generateTextwith and withoutsendDefaultPiistreamTextembedToolLoopAgentwithfunctionIdtelemetry: { isEnabled: false }suppresses spansKnown limitations
@sentry/vercel-edge) does not supportnode:diagnostics_channel— v7 edge users are not covered by this PRLegacyOpenTelemetryon v7, both DC and OTel paths would fire (duplicate spans). This requires manual user action and is not the recommended v7 path.Test plan
ai@^7.0.0-canaryyarn format,yarn lint,yarn build:devpass@sentry/nodeand@sentry/corepass🤖 Generated with Claude Code