Skip to content

Commit 110bf2b

Browse files
committed
fix(v10/cloudflare): Prevent AI provider skips
Backport of: #22719
1 parent c19156c commit 110bf2b

7 files changed

Lines changed: 214 additions & 2 deletions

File tree

packages/cloudflare/src/client.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core';
2-
import { applySdkMetadata, debug, ServerRuntimeClient, spanIsSampled } from '@sentry/core';
2+
import {
3+
_INTERNAL_clearAiProviderSkips,
4+
applySdkMetadata,
5+
debug,
6+
ServerRuntimeClient,
7+
spanIsSampled,
8+
} from '@sentry/core';
39
import { DEBUG_BUILD } from './debug-build';
410
import type { ExecutionContextCompat } from './executionContext';
511
import type { makeFlushLock } from './flush';
@@ -140,6 +146,16 @@ export class CloudflareClient extends ServerRuntimeClient {
140146
(this as unknown as { _flushLock: ReturnType<typeof makeFlushLock> | void })._flushLock = undefined;
141147
}
142148

149+
/** @inheritDoc */
150+
protected override _setupIntegrations(): void {
151+
// Clear AI provider skip registrations before setting up integrations.
152+
// The registry is module-global and Cloudflare calls `init()` per request, so without this a
153+
// single `ai` SDK call would suppress direct `env.AI.run` spans for the rest of the isolate's
154+
// life. Mirrors the same reset in the Node client.
155+
_INTERNAL_clearAiProviderSkips();
156+
super._setupIntegrations();
157+
}
158+
143159
/**
144160
* Resets the span completion promise and resolve function.
145161
*/

packages/core/src/tracing/vercel-ai/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '
55
import { shouldEnableTruncation } from '../ai/utils';
66
import type { Event } from '../../types/event';
77
import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span';
8+
import { _INTERNAL_skipAiProviderWrapping } from '../../utils/ai/providerSkip';
89
import { spanToJSON } from '../../utils/spanUtils';
10+
import { WORKERS_AI_INTEGRATION_NAME } from '../workers-ai/constants';
911
import {
1012
GEN_AI_CONVERSATION_ID_ATTRIBUTE,
1113
GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE,
@@ -86,6 +88,12 @@ function onVercelAiSpanStart(span: Span): void {
8688
return;
8789
}
8890

91+
// Registered lazily here (not at `setupOnce`) so a direct `env.AI.run` call made before any `ai`
92+
// SDK call still gets its own span.
93+
if (SPAN_TO_OPERATION_NAME.get(name) === 'generate_content') {
94+
_INTERNAL_skipAiProviderWrapping([WORKERS_AI_INTEGRATION_NAME]);
95+
}
96+
8997
const client = getClient();
9098
const integration = client?.getIntegrationByName('VercelAI') as
9199
| { options?: { enableTruncation?: boolean } }

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@ export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai';
88
* The Sentry origin for spans created by the Workers AI instrumentation.
99
*/
1010
export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai';
11+
12+
/**
13+
* The key used to register this provider in the AI provider skip registry.
14+
*
15+
* @see `_INTERNAL_skipAiProviderWrapping`
16+
*/
17+
export const WORKERS_AI_INTEGRATION_NAME = 'WorkersAI' as const;

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { SPAN_STATUS_ERROR } from '../../tracing';
22
import { startSpan, startSpanManual } from '../../tracing/trace';
33
import type { Span } from '../../types/span';
4+
import { _INTERNAL_shouldSkipAiProviderWrapping } from '../../utils/ai/providerSkip';
45
import { isObjectLike } from '../../utils/is';
56
import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils';
7+
import { WORKERS_AI_INTEGRATION_NAME } from './constants';
68
import { instrumentWorkersAiStream } from './streaming';
79
import type { WorkersAiOptions } from './types';
810
import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils';
@@ -27,6 +29,12 @@ function instrumentRun(
2729
options: WorkersAiOptions & Required<Pick<WorkersAiOptions, 'recordInputs' | 'recordOutputs'>>,
2830
): (...args: unknown[]) => Promise<unknown> {
2931
return function instrumentedRun(...args: unknown[]): Promise<unknown> {
32+
// When another integration (e.g. Vercel AI via `workers-ai-provider`) is driving this binding,
33+
// it records the spans itself and marks this provider as skipped; skip here to avoid double spans.
34+
if (_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)) {
35+
return originalRun.apply(context, args);
36+
}
37+
3038
const [model, inputs, runOptions] = args as [unknown, unknown, Record<string, unknown> | undefined];
3139

3240
const operationName = getOperationName(inputs);

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { getCurrentScope, getGlobalScope, getIsolationScope, setCurrentClient, startSpan } from '../../../src';
3+
import { addVercelAiProcessors } from '../../../src/tracing/vercel-ai';
4+
import { AI_OPERATION_ID_ATTRIBUTE } from '../../../src/tracing/vercel-ai/vercel-ai-attributes';
25
import { instrumentWorkersAiClient } from '../../../src/tracing/workers-ai';
6+
import { _INTERNAL_clearAiProviderSkips } from '../../../src/utils/ai/providerSkip';
7+
import { spanToJSON } from '../../../src/utils/spanUtils';
8+
import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
39

410
describe('instrumentWorkersAiClient', () => {
511
it('passes through non-run methods bound to the original client', () => {
@@ -28,4 +34,85 @@ describe('instrumentWorkersAiClient', () => {
2834
expect(client.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
2935
expect(result).toEqual({ response: 'Paris' });
3036
});
37+
38+
describe('when the Vercel AI SDK drives the binding', () => {
39+
let spans: string[];
40+
41+
/** Set up a client with the Vercel AI processors registered, recording every ended span. */
42+
function setupClient(): void {
43+
getCurrentScope().clear();
44+
getIsolationScope().clear();
45+
getGlobalScope().clear();
46+
47+
spans = [];
48+
const client = new TestClient(getDefaultTestClientOptions({ tracesSampleRate: 1 }));
49+
client.on('spanEnd', span => {
50+
spans.push(spanToJSON(span).description ?? '');
51+
});
52+
setCurrentClient(client);
53+
addVercelAiProcessors(client);
54+
}
55+
56+
beforeEach(() => {
57+
_INTERNAL_clearAiProviderSkips();
58+
setupClient();
59+
});
60+
61+
afterEach(() => {
62+
_INTERNAL_clearAiProviderSkips();
63+
});
64+
65+
/**
66+
* Emit the span the `ai` SDK creates for a model call. Its `spanStart` handler is what marks
67+
* Workers AI as skipped, exactly as it would at runtime.
68+
*/
69+
async function withVercelAiModelCall(callback: () => Promise<unknown>): Promise<void> {
70+
await startSpan(
71+
{ name: 'ai.streamText.doStream', attributes: { [AI_OPERATION_ID_ATTRIBUTE]: 'ai.streamText.doStream' } },
72+
async () => {
73+
await callback();
74+
},
75+
);
76+
}
77+
78+
it('does not create a duplicate span for the nested `run` call', async () => {
79+
const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) };
80+
const instrumented = instrumentWorkersAiClient(client);
81+
82+
await withVercelAiModelCall(() => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }));
83+
84+
// The call is forwarded, but no duplicate `gen_ai.chat` span is emitted — only the
85+
// Vercel AI model-call span remains.
86+
expect(client.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
87+
expect(spans).not.toContain('chat @cf/meta/llama-3.1-8b-instruct');
88+
expect(spans).toEqual(['streamText.doStream']);
89+
});
90+
91+
it('still creates a span for a direct `run` call made before any Vercel AI call', async () => {
92+
const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) };
93+
const instrumented = instrumentWorkersAiClient(client);
94+
95+
await startSpan({ name: 'root' }, () => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }));
96+
97+
expect(spans).toContain('chat @cf/meta/llama-3.1-8b-instruct');
98+
});
99+
100+
it('clears the skip between clients so a later isolate reuse is unaffected', async () => {
101+
const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) };
102+
const instrumented = instrumentWorkersAiClient(client);
103+
104+
// First request: the `ai` SDK runs and marks Workers AI as skipped.
105+
await withVercelAiModelCall(() => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }));
106+
expect(spans).not.toContain('chat @cf/meta/llama-3.1-8b-instruct');
107+
108+
// Second request on the same isolate: `_setupIntegrations` resets the registry, so a direct
109+
// `env.AI.run` call must get its span back. Without the reset this would stay suppressed.
110+
_INTERNAL_clearAiProviderSkips();
111+
setupClient();
112+
113+
await startSpan({ name: 'root' }, () => instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }));
114+
115+
expect(spans).toContain('chat @cf/meta/llama-3.1-8b-instruct');
116+
});
117+
});
31118
});

packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
import { GEN_AI_EXECUTE_TOOL_SPAN_OP, GEN_AI_INVOKE_AGENT_SPAN_OP } from '@sentry/conventions/op';
2929
import type { Span, SpanAttributes } from '@sentry/core';
3030
import {
31+
_INTERNAL_skipAiProviderWrapping,
3132
captureException,
3233
GEN_AI_CONVERSATION_ID_ATTRIBUTE,
3334
GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE,
@@ -66,6 +67,8 @@ const GEN_AI_RERANK_OPERATION = 'rerank';
6667
// The model-call op matches the Vercel AI OTel integration (`gen_ai.generate_content`) rather than
6768
// the generic `gen_ai.chat`, so v6 (OTel) and v7 (channel) produce the same spans.
6869
const GEN_AI_GENERATE_CONTENT_OPERATION = 'generate_content';
70+
// TODO(v11): export the constant from server-utils and import it here instead.
71+
const WORKERS_AI_INTEGRATION_NAME = 'WorkersAI';
6972

7073
// Subset of the `vercel.ai.*` passthrough attributes the OTel integration emits that we reproduce.
7174
const VERCEL_AI_OPERATION_ID_ATTRIBUTE = 'vercel.ai.operationId';
@@ -395,6 +398,8 @@ export function createSpanFromMessage(
395398
// the OTel path derives from the SDK's Zod schema is not reconstructed on the channel path.
396399
return buildInvokeAgentSpan(event, baseAttributes, recordInputs, enableTruncation, callId, type === 'streamText');
397400
case 'languageModelCall':
401+
_INTERNAL_skipAiProviderWrapping([WORKERS_AI_INTEGRATION_NAME]);
402+
398403
return buildModelCallSpan(event, baseAttributes, recordInputs, enableTruncation, callId, modelId);
399404
case 'executeTool':
400405
return buildToolSpan(event, recordInputs);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
_INTERNAL_clearAiProviderSkips,
3+
_INTERNAL_shouldSkipAiProviderWrapping,
4+
Client,
5+
createTransport,
6+
getCurrentScope,
7+
getGlobalScope,
8+
getIsolationScope,
9+
initAndBind,
10+
resolvedSyncPromise,
11+
} from '@sentry/core';
12+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13+
import { createSpanFromMessage } from '../../src/vercel-ai/vercel-ai-dc-subscriber';
14+
15+
// Must match `WORKERS_AI_INTEGRATION_NAME` in core's `tracing/workers-ai/constants`.
16+
const WORKERS_AI_INTEGRATION_NAME = 'WorkersAI';
17+
18+
class TestClient extends Client<any> {
19+
public eventFromException(): PromiseLike<any> {
20+
return resolvedSyncPromise({});
21+
}
22+
23+
public eventFromMessage(): PromiseLike<any> {
24+
return resolvedSyncPromise({});
25+
}
26+
}
27+
28+
function initTestClient(): void {
29+
initAndBind(TestClient, {
30+
dsn: 'https://username@domain/123',
31+
integrations: [],
32+
sendClientReports: false,
33+
stackParser: () => [],
34+
tracesSampleRate: 1,
35+
transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})),
36+
});
37+
}
38+
39+
describe('vercel ai tracing channel: workers-ai dedup', () => {
40+
beforeEach(() => {
41+
_INTERNAL_clearAiProviderSkips();
42+
getCurrentScope().clear();
43+
getIsolationScope().clear();
44+
getGlobalScope().clear();
45+
initTestClient();
46+
});
47+
48+
afterEach(() => {
49+
_INTERNAL_clearAiProviderSkips();
50+
});
51+
52+
it('marks Workers AI as skipped on a model call, so the binding does not double-instrument', () => {
53+
expect(_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)).toBe(false);
54+
55+
const span = createSpanFromMessage(
56+
{
57+
type: 'languageModelCall',
58+
event: { provider: 'workers-ai', modelId: '@cf/meta/llama-3.1-8b-instruct' },
59+
} as Parameters<typeof createSpanFromMessage>[0],
60+
{} as Parameters<typeof createSpanFromMessage>[1],
61+
);
62+
span?.end();
63+
64+
expect(_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)).toBe(true);
65+
});
66+
67+
it('does not mark Workers AI as skipped for tool calls', () => {
68+
// A tool calling `env.AI.run` itself is a genuine separate inference the `ai` SDK does not
69+
// instrument, so it must keep its own span.
70+
const span = createSpanFromMessage(
71+
{
72+
type: 'executeTool',
73+
event: { toolName: 'getWeather', toolCallId: 'call_1' },
74+
} as Parameters<typeof createSpanFromMessage>[0],
75+
{} as Parameters<typeof createSpanFromMessage>[1],
76+
);
77+
span?.end();
78+
79+
expect(_INTERNAL_shouldSkipAiProviderWrapping(WORKERS_AI_INTEGRATION_NAME)).toBe(false);
80+
});
81+
});

0 commit comments

Comments
 (0)