Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/core/src/shared-exports.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -230,6 +230,8 @@ export {
export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils';
export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants';
export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types';
export { instrumentWorkersAiClient } from './tracing/workers-ai';
export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types';
// eslint-disable-next-line typescript/no-deprecated
export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types';
export type {
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/tracing/workers-ai/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
/**
* The provider value for the `gen_ai.provider.name` attribute.
* @see https://developers.cloudflare.com/workers-ai/
*/
export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';

/**
* The Sentry origin for spans created by the Workers AI instrumentation.
*/
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
133 changes: 133 additions & 0 deletions packages/core/src/tracing/workers-ai/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import { startSpan, startSpanManual } from '../../tracing/trace';
import type { Span } from '../../types/span';
import { isObjectLike } from '../../utils/is';
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
import { instrumentWorkersAiStream } from './streaming';
import type { WorkersAiOptions } from './types';
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';

// Adapted from /server-utils/src/vercel-ai/util.ts
// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils
// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`.
function isReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return (
isObjectLike(value) &&
typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' &&
typeof (value as { getReader?: unknown }).getReader === 'function'
);
}

/**
* Wrap the `run` method of the Workers AI binding with Sentry tracing.
*/
function instrumentRun(
originalRun: (...args: unknown[]) => Promise<unknown>,
context: unknown,
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
): (...args: unknown[]) => Promise<unknown> {
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];

const operationName = getOperationName(inputs);
const requestAttributes = extractRequestAttributes(model, inputs, operationName);
const modelName = typeof model === 'string' ? model : 'unknown';

const isStreamRequested =
!!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true;
const returnsRawResponse =
!!runOptions &&
typeof runOptions === 'object' &&
(runOptions.returnRawResponse === true || runOptions.websocket === true);

const spanConfig = {
name: `${operationName} ${modelName}`,
op: `gen_ai.${operationName}`,
attributes: requestAttributes,
};

if (isStreamRequested && !returnsRawResponse) {
return startSpanManual(spanConfig, (span: Span) => {
// `startSpanManual` does not auto-end the span, so we must end it on every exit path,
// including a synchronous throw from `run`.
const handleError = (error: unknown): never => {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
span.end();
throw error;
};

let originalResult: Promise<unknown>;

try {
originalResult = originalRun.apply(context, args) as Promise<unknown>;
} catch (error) {
return handleError(error);
}

if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (isReadableStream(result)) {
return instrumentWorkersAiStream(result, span, options.recordOutputs);
}

// The model did not actually return a stream — finalize the span eagerly.
addResponseAttributes(span, result, options.recordOutputs);
span.end();
return result;
}, handleError);
});
}

return startSpan(spanConfig, (span: Span) => {
const originalResult = originalRun.apply(context, args) as Promise<unknown>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Synchronous errors in the non-streaming run method are not reported to Sentry as exceptions, only as a span status.
Severity: LOW

Suggested Fix

Wrap the originalRun.apply(context, args) call in a try...catch block, similar to the streaming path. In the catch block, call captureException to report the error to Sentry before re-throwing it.

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/core/src/tracing/workers-ai/index.ts#L90
Potential issue: In the non-streaming path for Workers AI tracing, if the original `run`
method throws a synchronous error, the error is not reported to Sentry via
`captureException`. The error is caught by `startSpan`'s internal `handleCallbackErrors`
function, which sets the span status to error but does not create a Sentry exception
event. This is inconsistent with the streaming path, which explicitly catches
synchronous errors and reports them. While synchronous throws from async methods are
unlikely, this represents a gap in error monitoring.


if (options.recordInputs) {
addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation));
}

return originalResult.then(result => {
if (!returnsRawResponse) {
addResponseAttributes(span, result, options.recordOutputs);
}
return result;
});
});
};
}

/**
* Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing.
*
* This wraps the binding's `run` method to create `gen_ai` spans following the
* Sentry AI Agents conventions. All other methods are passed through untouched.
*
* In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically —
* wrapping manually is only needed to pass custom options.
*
* @example
* ```javascript
* const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true });
* const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
* ```
*/
export function instrumentWorkersAiClient<T extends object>(client: T, options?: WorkersAiOptions): T {
const resolvedOptions = resolveAIRecordingOptions(options);

const instrumented = new Proxy(client, {
get(target: object, prop: string | symbol, receiver: unknown): unknown {
Comment thread
isaacs marked this conversation as resolved.
const value = Reflect.get(target, prop, receiver);

if (prop === 'run' && typeof value === 'function') {
return instrumentRun(value as (...args: unknown[]) => Promise<unknown>, target, resolvedOptions);
}

// Bind passed-through functions to the original target to preserve `this` (e.g. private fields).
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value;
},
}) as T;

return instrumented;
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
229 changes: 229 additions & 0 deletions packages/core/src/tracing/workers-ai/streaming.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
import { SPAN_STATUS_ERROR } from '../../tracing';
import type { Span } from '../../types/span';
import { endStreamSpan, type StreamResponseState } from '../ai/utils';
import type { WorkersAiUsage } from './types';
import { setOutputMessagesAttribute } from './utils';

interface WorkersAiStreamingToolCall {
index?: number;
id?: string;
type?: string;
function?: { name?: string; arguments?: string };
// Some Workers AI models stream tool calls with the name/arguments at the top
// level of the tool-call object instead of nested under `function`.
name?: string;
arguments?: string;
}

interface WorkersAiStreamChunk {
// Native Workers AI streaming shape (`env.AI.run` with `stream: true`).
response?: unknown;
tool_calls?: unknown[];
// OpenAI-compatible streaming shape emitted for models routed through the
// OpenAI-compatible endpoint (e.g. via `workers-ai-provider`).
choices?: Array<{
delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] };
finish_reason?: unknown;
}>;
usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
}

/**
* Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple
* `choices[].delta.tool_calls` chunks) into the index-keyed accumulator.
*/
function accumulateStreamingToolCalls(
toolCalls: WorkersAiStreamingToolCall[],
accumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
for (const toolCall of toolCalls) {
// Normalize both shapes: name/arguments nested under `function`, or at the top level.
const name = toolCall.function?.name ?? toolCall.name;
const args = toolCall.function?.arguments ?? toolCall.arguments;

// A tool call must carry at least a name or argument fragment to be meaningful.
if (name == null && args == null) {
continue;
}

const index = toolCall.index ?? 0;
const existing = accumulator[index];

if (!existing) {
accumulator[index] = {
index,
id: toolCall.id,
type: toolCall.type,
function: {
name,
arguments: args ?? '',
},
};
} else if (existing.function) {
if (name && !existing.function.name) {
existing.function.name = name;
}
if (args) {
existing.function.arguments = `${existing.function.arguments ?? ''}${args}`;
}
}
}
}

/**
* Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state.
*
* Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the
* OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because
* the same `run()` call transparently yields either format depending on the model.
*/
function processLine(
line: string,
state: StreamResponseState,
recordOutputs: boolean,
toolCallAccumulator: Record<number, WorkersAiStreamingToolCall>,
): void {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) {
return;
}

const data = trimmed.slice('data:'.length).trim();
if (!data || data === '[DONE]') {
return;
}

let parsed: WorkersAiStreamChunk;
try {
parsed = JSON.parse(data) as WorkersAiStreamChunk;
} catch {
return;
}

if (parsed.usage) {
if (typeof parsed.usage.prompt_tokens === 'number') {
state.promptTokens = parsed.usage.prompt_tokens;
}
if (typeof parsed.usage.completion_tokens === 'number') {
state.completionTokens = parsed.usage.completion_tokens;
}
if (typeof parsed.usage.total_tokens === 'number') {
state.totalTokens = parsed.usage.total_tokens;
}
}

if (recordOutputs && typeof parsed.response === 'string') {
state.responseTexts.push(parsed.response);
}

if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) {
state.toolCalls.push(...parsed.tool_calls);
}

if (Array.isArray(parsed.choices)) {
for (const choice of parsed.choices) {
if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) {
state.responseTexts.push(choice.delta.content);
}
if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) {
accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator);
}
if (typeof choice.finish_reason === 'string') {
state.finishReasons.push(choice.finish_reason);
}
}
}
}

/**
* Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can
* accumulate the response text and token usage while passing the original bytes through untouched.
*
* The span is ended once the consumer finishes reading (or cancels) the stream.
*/
export function instrumentWorkersAiStream(
stream: ReadableStream<Uint8Array>,
span: Span,
recordOutputs: boolean,
): ReadableStream<Uint8Array> {
const reader = stream.getReader();
const decoder = new TextDecoder();

const state: StreamResponseState = {
responseId: '',
responseModel: '',
finishReasons: [],
responseTexts: [],
toolCalls: [],
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
};

// OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index;
// accumulate them here and flatten into `state.toolCalls` once the stream ends.
const toolCallAccumulator: Record<number, WorkersAiStreamingToolCall> = {};

let buffer = '';
let spanEnded = false;

const finish = (): void => {
if (spanEnded) {
return;
}
spanEnded = true;

if (recordOutputs) {
const accumulatedToolCalls = Object.values(toolCallAccumulator);
if (accumulatedToolCalls.length > 0) {
state.toolCalls.push(...accumulatedToolCalls);
}

// Set the authoritative `gen_ai.output.messages` alongside the deprecated response
// attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration.
setOutputMessagesAttribute(span, {
responseText: state.responseTexts.join(''),
toolCalls: state.toolCalls,
});
}

endStreamSpan(span, state, recordOutputs);
};

const flushBuffer = (isDone: boolean): void => {
const lines = buffer.split('\n');
// Keep the last (potentially incomplete) line in the buffer unless the stream is done.
buffer = isDone ? '' : (lines.pop() ?? '');
for (const line of lines) {
processLine(line, state, recordOutputs, toolCallAccumulator);
}
};

return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();

if (done) {
buffer += decoder.decode();
flushBuffer(true);
finish();
controller.close();
return;
}

buffer += decoder.decode(value, { stream: true });
flushBuffer(false);
controller.enqueue(value);
} catch (error) {
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' });
finish();
controller.error(error);
}
},
async cancel(reason) {
finish();
await reader.cancel(reason);
},
});
}
Loading
Loading