Skip to content

Commit c626710

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Auto-instrument Workers AI binding via env instrumentation
Detect the Workers AI binding (env.AI) in instrumentEnv via duck-typing (run + gateway + toMarkdown) and wrap it automatically, matching how D1, R2, and Queue bindings are instrumented. Manual wrapping via instrumentWorkersAiClient remains available for custom options and is now guarded against double-wrapping. Also removes the unused WORKERS_AI_INTEGRATION_NAME constant and aligns the integration test wrangler config with sibling suites (nodejs_als). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a22515d commit c626710

8 files changed

Lines changed: 280 additions & 2 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { MockAi } from './mocks';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
}
7+
8+
const ai = Sentry.instrumentWorkersAiClient(new MockAi());
9+
10+
export default Sentry.withSentry(
11+
(env: Env) => ({
12+
dsn: env.SENTRY_DSN,
13+
tracesSampleRate: 1.0,
14+
// Keep gen_ai spans embedded in the transaction (instead of streamed as a
15+
// separate envelope container) so they can be asserted on `transaction.spans`.
16+
streamGenAiSpans: false,
17+
}),
18+
{
19+
async fetch(request) {
20+
const url = new URL(request.url);
21+
22+
if (url.pathname === '/stream') {
23+
const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', {
24+
messages: [{ role: 'user', content: 'What is the capital of France?' }],
25+
stream: true,
26+
})) as ReadableStream;
27+
28+
const text = await new Response(stream).text();
29+
return new Response(text);
30+
}
31+
32+
const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', {
33+
messages: [
34+
{ role: 'system', content: 'You are a helpful assistant.' },
35+
{ role: 'user', content: 'What is the capital of France?' },
36+
],
37+
temperature: 0.7,
38+
max_tokens: 100,
39+
});
40+
41+
return new Response(JSON.stringify(result));
42+
},
43+
},
44+
);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { simulateReadableStream } from 'ai';
2+
3+
function createSseStream(events: string[]): ReadableStream<Uint8Array> {
4+
const encoder = new TextEncoder();
5+
return simulateReadableStream({
6+
initialDelayInMs: 0,
7+
chunkDelayInMs: 0,
8+
chunks: events.map(event => encoder.encode(`data: ${event}\n\n`)),
9+
});
10+
}
11+
12+
/**
13+
* Minimal mock of the Cloudflare Workers AI binding (`env.AI`).
14+
*/
15+
export class MockAi {
16+
public async run(model: string, inputs: Record<string, unknown>): Promise<unknown> {
17+
// Simulate processing time
18+
await new Promise(resolve => setTimeout(resolve, 10));
19+
20+
if (model === 'error-model') {
21+
const error = new Error('Model not found');
22+
(error as unknown as { status: number }).status = 404;
23+
throw error;
24+
}
25+
26+
if (inputs?.stream === true) {
27+
return createSseStream([
28+
'{"response":"The capital "}',
29+
'{"response":"of France "}',
30+
'{"response":"is Paris."}',
31+
'{"response":"","usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}',
32+
'[DONE]',
33+
]);
34+
}
35+
36+
return {
37+
response: 'The capital of France is Paris.',
38+
usage: {
39+
prompt_tokens: 12,
40+
completion_tokens: 7,
41+
total_tokens: 19,
42+
},
43+
};
44+
}
45+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { expect, it } from 'vitest';
2+
import {
3+
GEN_AI_OPERATION_NAME_ATTRIBUTE,
4+
GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE,
5+
GEN_AI_REQUEST_MODEL_ATTRIBUTE,
6+
GEN_AI_REQUEST_STREAM_ATTRIBUTE,
7+
GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE,
8+
GEN_AI_RESPONSE_STREAMING_ATTRIBUTE,
9+
GEN_AI_SYSTEM_ATTRIBUTE,
10+
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
11+
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
12+
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
13+
} from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes';
14+
import { createRunner } from '../../../runner';
15+
16+
// These tests are not exhaustive because the instrumentation is
17+
// already tested in the core unit tests and we merely want to test
18+
// that the instrumentation does not break in our cloudflare SDK.
19+
20+
it('traces a basic Workers AI text generation request', async ({ signal }) => {
21+
const runner = createRunner(__dirname)
22+
.ignore('event')
23+
.expect(envelope => {
24+
const transactionEvent = envelope[1]?.[0]?.[1] as any;
25+
26+
// The transaction event is framework-generated and carries non-deterministic fields
27+
// (random ports, ids, timestamps, sdk version), so we assert the stable subset.
28+
expect(transactionEvent).toEqual(
29+
expect.objectContaining({
30+
type: 'transaction',
31+
transaction: 'GET /',
32+
transaction_info: { source: 'route' },
33+
contexts: expect.objectContaining({
34+
trace: expect.objectContaining({
35+
op: 'http.server',
36+
origin: 'auto.http.cloudflare',
37+
status: 'ok',
38+
}),
39+
}),
40+
spans: [
41+
expect.objectContaining({
42+
description: 'chat @cf/meta/llama-3.1-8b-instruct',
43+
op: 'gen_ai.chat',
44+
origin: 'auto.ai.cloudflare.workers_ai',
45+
data: {
46+
'sentry.origin': 'auto.ai.cloudflare.workers_ai',
47+
'sentry.op': 'gen_ai.chat',
48+
[GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.workers_ai',
49+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat',
50+
[GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct',
51+
[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: 0.7,
52+
[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: 100,
53+
[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12,
54+
[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7,
55+
[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19,
56+
},
57+
}),
58+
],
59+
}),
60+
);
61+
})
62+
.start(signal);
63+
await runner.makeRequest('get', '/');
64+
await runner.completed();
65+
});
66+
67+
it('traces a streaming Workers AI text generation request', async ({ signal }) => {
68+
const runner = createRunner(__dirname)
69+
.ignore('event')
70+
.expect(envelope => {
71+
const transactionEvent = envelope[1]?.[0]?.[1] as any;
72+
73+
expect(transactionEvent).toEqual(
74+
expect.objectContaining({
75+
type: 'transaction',
76+
transaction: 'GET /stream',
77+
transaction_info: { source: 'url' },
78+
contexts: expect.objectContaining({
79+
trace: expect.objectContaining({
80+
op: 'http.server',
81+
origin: 'auto.http.cloudflare',
82+
status: 'ok',
83+
}),
84+
}),
85+
spans: [
86+
expect.objectContaining({
87+
description: 'chat @cf/meta/llama-3.1-8b-instruct',
88+
op: 'gen_ai.chat',
89+
origin: 'auto.ai.cloudflare.workers_ai',
90+
data: {
91+
'sentry.origin': 'auto.ai.cloudflare.workers_ai',
92+
'sentry.op': 'gen_ai.chat',
93+
[GEN_AI_SYSTEM_ATTRIBUTE]: 'cloudflare.workers_ai',
94+
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat',
95+
[GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct',
96+
[GEN_AI_REQUEST_STREAM_ATTRIBUTE]: true,
97+
[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true,
98+
[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12,
99+
[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7,
100+
[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19,
101+
},
102+
}),
103+
],
104+
}),
105+
);
106+
})
107+
.start(signal);
108+
await runner.makeRequest('get', '/stream');
109+
await runner.completed();
110+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "worker-name",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_als"],
6+
}

packages/cloudflare/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export {
7979
instrumentOpenAiClient,
8080
instrumentGoogleGenAIClient,
8181
instrumentAnthropicAiClient,
82+
instrumentWorkersAiClient,
8283
eventFiltersIntegration,
8384
linkedErrorsIntegration,
8485
requestDataIntegration,

packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
import { instrumentWorkersAiClient } from '@sentry/core';
12
import type { CloudflareOptions } from '../../client';
2-
import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding';
3+
import {
4+
isAiBinding,
5+
isD1Database,
6+
isDurableObjectNamespace,
7+
isJSRPC,
8+
isQueue,
9+
isR2Bucket,
10+
} from '../../utils/isBinding';
311
import { instrumentD1 } from './instrumentD1';
412
import { appendRpcMeta } from '../../utils/rpcMeta';
513
import { getEffectiveRpcPropagation } from '../../utils/rpcOptions';
@@ -23,6 +31,7 @@ const instrumentedBindings = new WeakMap<object, unknown>();
2331
* - Service bindings / JSRPC proxies
2432
* - Queue producers (via `send` + `sendBatch` duck-typing)
2533
* - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing)
34+
* - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing)
2635
*
2736
* @param env - The Cloudflare env object to instrument
2837
* @param options - Optional CloudflareOptions to control RPC trace propagation
@@ -68,6 +77,12 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
6877
return instrumented;
6978
}
7079

80+
if (isAiBinding(item)) {
81+
const instrumented = instrumentWorkersAiClient(item);
82+
instrumentedBindings.set(item, instrumented);
83+
return instrumented;
84+
}
85+
7186
if (!rpcPropagation) {
7287
return item;
7388
}

packages/cloudflare/src/utils/isBinding.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3232
*/
3333

34-
import type { D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types';
34+
import type { Ai, D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types';
3535

3636
/**
3737
* Checks if a value is a JSRPC proxy (service binding).
@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
8282
);
8383
}
8484

85+
/**
86+
* Duck-type check for Workers AI bindings.
87+
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
88+
*/
89+
export function isAiBinding(item: unknown): item is Ai {
90+
return (
91+
item != null &&
92+
isNotJSRPC(item) &&
93+
typeof item.run === 'function' &&
94+
typeof item.gateway === 'function' &&
95+
typeof item.toMarkdown === 'function'
96+
);
97+
}
98+
8599
/**
86100
* Duck-type check for R2 Bucket bindings.
87101
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.

packages/cloudflare/test/instrumentations/instrumentEnv.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,49 @@ describe('instrumentEnv', () => {
256256
expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace);
257257
});
258258

259+
describe('Workers AI bindings', () => {
260+
function createMockAiBinding() {
261+
return {
262+
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
263+
gateway: vi.fn(),
264+
toMarkdown: vi.fn(),
265+
models: vi.fn(),
266+
autorag: vi.fn(),
267+
};
268+
}
269+
270+
it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
271+
const ai = createMockAiBinding();
272+
const env = { AI: ai };
273+
const instrumented = instrumentEnv(env);
274+
275+
const wrapped = instrumented.AI as typeof ai;
276+
expect(wrapped).not.toBe(ai);
277+
278+
const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
279+
280+
expect(ai.run).toHaveBeenCalledTimes(1);
281+
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
282+
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
283+
});
284+
285+
it('caches the wrapped AI binding across repeated access', () => {
286+
const ai = createMockAiBinding();
287+
const env = { AI: ai };
288+
const instrumented = instrumentEnv(env);
289+
290+
expect(instrumented.AI).toBe(instrumented.AI);
291+
});
292+
293+
it('does not treat bindings with only a run method as AI bindings', () => {
294+
const notAi = { run: vi.fn() };
295+
const env = { RUNNER: notAi };
296+
const instrumented = instrumentEnv(env);
297+
298+
expect(instrumented.RUNNER).toBe(notAi);
299+
});
300+
});
301+
259302
describe('mTLS Fetcher bindings', () => {
260303
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
261304
return new Proxy(

0 commit comments

Comments
 (0)