Skip to content

Commit 609fe63

Browse files
JPeer264claude
andcommitted
feat(core): Emit gen_ai.output.messages from Workers AI instrumentation
The Sentry product reads model output from `gen_ai.output.messages`, treating `gen_ai.response.text` and `gen_ai.response.tool_calls` as deprecated. Relay migrates `response.text` into `output.messages` at ingestion, but the tool-calls half of that migration is lossy, so tool-call turns rendered an empty Output tab. Build `gen_ai.output.messages` directly in both the streaming and non-streaming paths (mirroring the Vercel AI integration), normalizing the OpenAI-compatible (`function.{name,arguments}`) and native (top-level `name`/`arguments`) tool-call shapes. The deprecated attributes are still written for backward compatibility. The streaming parser is also extended to read the OpenAI-compatible SSE shape (`choices[].delta.content` / `choices[].delta.tool_calls`) that models routed through the OpenAI-compatible endpoint emit, which previously dropped both text and tool calls while still capturing usage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c2b4363 commit 609fe63

4 files changed

Lines changed: 357 additions & 15 deletions

File tree

packages/core/src/tracing/workers-ai/streaming.ts

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,87 @@ import { SPAN_STATUS_ERROR } from '../../tracing';
22
import type { Span } from '../../types/span';
33
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
44
import type { WorkersAiUsage } from './types';
5+
import { setOutputMessagesAttribute } from './utils';
6+
7+
interface WorkersAiStreamingToolCall {
8+
index?: number;
9+
id?: string;
10+
type?: string;
11+
function?: { name?: string; arguments?: string };
12+
// Some Workers AI models stream tool calls with the name/arguments at the top
13+
// level of the tool-call object instead of nested under `function`.
14+
name?: string;
15+
arguments?: string;
16+
}
517

618
interface WorkersAiStreamChunk {
19+
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
720
response?: unknown;
8-
usage?: WorkersAiUsage;
921
tool_calls?: unknown[];
22+
// OpenAI-compatible streaming shape emitted for models routed through the
23+
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
24+
choices?: Array<{
25+
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
26+
finish_reason?: unknown;
27+
}>;
28+
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
29+
}
30+
31+
/**
32+
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
33+
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
34+
*/
35+
function accumulateStreamingToolCalls(
36+
toolCalls: WorkersAiStreamingToolCall[],
37+
accumulator: Record<number, WorkersAiStreamingToolCall>,
38+
): void {
39+
for (const toolCall of toolCalls) {
40+
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
41+
const name = toolCall.function?.name ?? toolCall.name;
42+
const args = toolCall.function?.arguments ?? toolCall.arguments;
43+
44+
// A tool call must carry at least a name or argument fragment to be meaningful.
45+
if (name == null && args == null) {
46+
continue;
47+
}
48+
49+
const index = toolCall.index ?? 0;
50+
const existing = accumulator[index];
51+
52+
if (!existing) {
53+
accumulator[index] = {
54+
index,
55+
id: toolCall.id,
56+
type: toolCall.type,
57+
function: {
58+
name,
59+
arguments: args ?? '',
60+
},
61+
};
62+
} else if (existing.function) {
63+
if (name && !existing.function.name) {
64+
existing.function.name = name;
65+
}
66+
if (args) {
67+
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
68+
}
69+
}
70+
}
1071
}
1172

1273
/**
1374
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
75+
*
76+
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
77+
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
78+
* the same `run()` call transparently yields either format depending on the model.
1479
*/
15-
function processLine(line: string, state: StreamResponseState, recordOutputs: boolean): void {
80+
function processLine(
81+
line: string,
82+
state: StreamResponseState,
83+
recordOutputs: boolean,
84+
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
85+
): void {
1686
const trimmed = line.trim();
1787
if (!trimmed.startsWith('data:')) {
1888
return;
@@ -49,6 +119,20 @@ function processLine(line: string, state: StreamResponseState, recordOutputs: bo
49119
if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
50120
state.toolCalls.push(...parsed.tool_calls);
51121
}
122+
123+
if (Array.isArray(parsed.choices)) {
124+
for (const choice of parsed.choices) {
125+
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
126+
state.responseTexts.push(choice.delta.content);
127+
}
128+
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
129+
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
130+
}
131+
if (typeof choice.finish_reason === 'string') {
132+
state.finishReasons.push(choice.finish_reason);
133+
}
134+
}
135+
}
52136
}
53137

54138
/**
@@ -76,6 +160,10 @@ export function instrumentWorkersAiStream(
76160
totalTokens: undefined,
77161
};
78162

163+
// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
164+
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
165+
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};
166+
79167
let buffer = '';
80168
let spanEnded = false;
81169

@@ -84,6 +172,21 @@ export function instrumentWorkersAiStream(
84172
return;
85173
}
86174
spanEnded = true;
175+
176+
if (recordOutputs) {
177+
const accumulatedToolCalls = Object.values(toolCallAccumulator);
178+
if (accumulatedToolCalls.length > 0) {
179+
state.toolCalls.push(...accumulatedToolCalls);
180+
}
181+
182+
// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
183+
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
184+
setOutputMessagesAttribute(span, {
185+
responseText: state.responseTexts.join(''),
186+
toolCalls: state.toolCalls,
187+
});
188+
}
189+
87190
endStreamSpan(span, state, recordOutputs);
88191
};
89192

@@ -92,7 +195,7 @@ export function instrumentWorkersAiStream(
92195
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
93196
buffer = isDone ? '' : (lines.pop() ?? '');
94197
for (const line of lines) {
95-
processLine(line, state, recordOutputs);
198+
processLine(line, state, recordOutputs, toolCallAccumulator);
96199
}
97200
};
98201

packages/core/src/tracing/workers-ai/utils.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ import {
1010
GEN_AI_REQUEST_TOP_K,
1111
GEN_AI_REQUEST_TOP_P,
1212
GEN_AI_PROVIDER_NAME,
13-
GEN_AI_OUTPUT_MESSAGES,
1413
GEN_AI_SYSTEM_INSTRUCTIONS,
1514
} from '@sentry/conventions/attributes';
1615
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
1716
import type { Span, SpanAttributeValue } from '../../types/span';
1817
import {
1918
GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE,
19+
GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE,
2020
GEN_AI_REQUEST_STREAM_ATTRIBUTE,
21+
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
22+
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
2123
} from '../ai/gen-ai-attributes';
2224
import { extractSystemInstructions, getTruncatedJsonString, setTokenUsageAttributes } from '../ai/utils';
2325
import { stringify } from '../../utils/string';
@@ -134,6 +136,55 @@ export function addRequestAttributes(
134136
);
135137
}
136138

139+
/**
140+
* Build the `gen_ai.output.messages` value (a single assistant message with text and/or
141+
* tool-call parts) from the response text and tool calls.
142+
*
143+
* We set this in addition to the deprecated `gen_ai.response.text` / `gen_ai.response.tool_calls`
144+
* attributes because Sentry's product reads the model output from `gen_ai.output.messages` first.
145+
* Relay migrates `gen_ai.response.text` into `gen_ai.output.messages`, but the tool-calls half of
146+
* that migration is lossy — so tool-call turns would otherwise render an empty Output. Emitting the
147+
* normalized message here (mirroring the Vercel AI integration) keeps tool calls visible.
148+
*/
149+
export function setOutputMessagesAttribute(
150+
span: Span,
151+
{ responseText, toolCalls }: { responseText?: string; toolCalls?: unknown[] },
152+
): void {
153+
const parts: Array<Record<string, unknown>> = [];
154+
155+
if (typeof responseText === 'string' && responseText.length > 0) {
156+
parts.push({ type: 'text', content: responseText });
157+
}
158+
159+
if (Array.isArray(toolCalls)) {
160+
for (const toolCall of toolCalls) {
161+
if (!toolCall || typeof toolCall !== 'object') {
162+
continue;
163+
}
164+
const call = toolCall as {
165+
id?: unknown;
166+
function?: { name?: unknown; arguments?: unknown };
167+
name?: unknown;
168+
arguments?: unknown;
169+
};
170+
// Normalize both the OpenAI-compatible shape (name/arguments nested under `function`)
171+
// and the native Workers AI shape (name/arguments at the top level).
172+
const name = call.function?.name ?? call.name;
173+
const args = call.function?.arguments ?? call.arguments;
174+
parts.push({
175+
type: 'tool_call',
176+
id: call.id,
177+
name,
178+
arguments: typeof args === 'string' ? args : JSON.stringify(args ?? {}),
179+
});
180+
}
181+
}
182+
183+
if (parts.length > 0) {
184+
span.setAttribute(GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, JSON.stringify([{ role: 'assistant', parts }]));
185+
}
186+
}
187+
137188
/**
138189
* Record the response attributes (token usage, response text, tool calls) on the span.
139190
*/
@@ -154,14 +205,21 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs
154205
}
155206

156207
if (recordOutputs) {
208+
let responseText: string | undefined;
157209
if (typeof response.response === 'string') {
158-
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, response.response);
210+
responseText = response.response;
211+
span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, response.response);
159212
} else if (response.response != null) {
160-
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, JSON.stringify(response.response));
213+
responseText = JSON.stringify(response.response);
214+
span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, responseText);
161215
}
162216

163-
if (Array.isArray(response.tool_calls) && response.tool_calls.length > 0) {
164-
span.setAttribute(GEN_AI_OUTPUT_MESSAGES, JSON.stringify(response.tool_calls));
217+
const toolCalls =
218+
Array.isArray(response.tool_calls) && response.tool_calls.length > 0 ? response.tool_calls : undefined;
219+
if (toolCalls) {
220+
span.setAttribute(GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, JSON.stringify(toolCalls));
165221
}
222+
223+
setOutputMessagesAttribute(span, { responseText, toolCalls });
166224
}
167225
}

packages/core/test/lib/tracing/workers-ai-streaming.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, expect, it } from 'vitest';
22
import type { Span } from '../../../src';
33
import {
4+
GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE,
45
GEN_AI_RESPONSE_STREAMING_ATTRIBUTE,
56
GEN_AI_RESPONSE_TEXT_ATTRIBUTE,
7+
GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE,
68
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
79
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
810
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
@@ -99,6 +101,114 @@ describe('instrumentWorkersAiStream', () => {
99101
expect(ended()).toBe(true);
100102
});
101103

104+
// Models routed through the OpenAI-compatible endpoint (e.g. via `workers-ai-provider`,
105+
// which the Cloudflare Agents SDK uses) stream `choices[].delta.content` instead of the
106+
// native top-level `response` field. Usage still arrives top-level, which is why the
107+
// pre-fix parser captured tokens but dropped the response text entirely.
108+
it('accumulates response text from OpenAI-compatible choices[].delta.content chunks', async () => {
109+
const { span, attributes, ended } = createMockSpan();
110+
const chunks = [
111+
'data: {"choices":[{"delta":{"content":"The capital "},"finish_reason":null}]}\n\n',
112+
'data: {"choices":[{"delta":{"content":"of France "},"finish_reason":null}]}\n\n',
113+
'data: {"choices":[{"delta":{"content":"is Paris."},"finish_reason":"stop"}]}\n\n',
114+
'data: {"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}\n\ndata: [DONE]\n\n',
115+
];
116+
117+
const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true);
118+
await new Response(instrumented).text();
119+
120+
expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true);
121+
expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('The capital of France is Paris.');
122+
expect(attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toBe(12);
123+
expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(7);
124+
expect(attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toBe(19);
125+
expect(ended()).toBe(true);
126+
});
127+
128+
it('assembles fragmented OpenAI-compatible tool calls from choices[].delta.tool_calls', async () => {
129+
const { span, attributes } = createMockSpan();
130+
const chunks = [
131+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"getRepoInfo","arguments":"{\\"owner\\":"}}]}}]}\n\n',
132+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"cloudflare\\",\\"name\\":\\"agents\\"}"}}]}}]}\n\n',
133+
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n',
134+
];
135+
136+
const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true);
137+
await new Response(instrumented).text();
138+
139+
const toolCalls = JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] as string);
140+
expect(toolCalls).toEqual([
141+
{
142+
index: 0,
143+
id: 'call_1',
144+
type: 'function',
145+
function: { name: 'getRepoInfo', arguments: '{"owner":"cloudflare","name":"agents"}' },
146+
},
147+
]);
148+
149+
// The product reads model output from `gen_ai.output.messages`; tool calls must appear there too.
150+
expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE] as string)).toEqual([
151+
{
152+
role: 'assistant',
153+
parts: [
154+
{
155+
type: 'tool_call',
156+
id: 'call_1',
157+
name: 'getRepoInfo',
158+
arguments: '{"owner":"cloudflare","name":"agents"}',
159+
},
160+
],
161+
},
162+
]);
163+
});
164+
165+
// Some Workers AI models (e.g. `@cf/moonshotai/kimi-k2.6`) stream tool calls with the
166+
// name/arguments at the top level of the tool-call object rather than nested under `function`.
167+
it('assembles tool calls whose name/arguments are at the top level of the delta', async () => {
168+
const { span, attributes } = createMockSpan();
169+
const chunks = [
170+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","name":"getRepoInfo","arguments":"{\\"owner\\":"}]}}]}\n\n',
171+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"arguments":"\\"cloudflare\\"}"}]}}]}\n\n',
172+
'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n',
173+
];
174+
175+
const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true);
176+
await new Response(instrumented).text();
177+
178+
const toolCalls = JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] as string);
179+
expect(toolCalls).toEqual([
180+
{
181+
index: 0,
182+
id: 'call_1',
183+
type: undefined,
184+
function: { name: 'getRepoInfo', arguments: '{"owner":"cloudflare"}' },
185+
},
186+
]);
187+
188+
expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE] as string)).toEqual([
189+
{
190+
role: 'assistant',
191+
parts: [{ type: 'tool_call', id: 'call_1', name: 'getRepoInfo', arguments: '{"owner":"cloudflare"}' }],
192+
},
193+
]);
194+
});
195+
196+
it('does not record OpenAI-compatible output when recordOutputs is false', async () => {
197+
const { span, attributes } = createMockSpan();
198+
const chunks = [
199+
'data: {"choices":[{"delta":{"content":"secret"}}]}\n\n',
200+
'data: {"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}\n\ndata: [DONE]\n\n',
201+
];
202+
203+
const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, false);
204+
await new Response(instrumented).text();
205+
206+
expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined();
207+
expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBeUndefined();
208+
expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBeUndefined();
209+
expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(1);
210+
});
211+
102212
it('ends the span when the consumer cancels the stream', async () => {
103213
const { span, attributes, ended } = createMockSpan();
104214

0 commit comments

Comments
 (0)