Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(cloudflare): Auto-instrument Workers AI binding via env instrumentation#22126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
c99fc28964cfeee0f5d1753240753396d1015e1152bae6740cb6a88bb1b50ffFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import*asSentryfrom'@sentry/cloudflare'; | ||
| import{instrumentWorkersAiClient}from'@sentry/core'; | ||
| import{MockAi}from'./mocks'; | ||
| interfaceEnv{ | ||
| SENTRY_DSN: string; | ||
| } | ||
| constai=instrumentWorkersAiClient(newMockAi()); | ||
| exportdefaultSentry.withSentry( | ||
| (env: Env)=>({ | ||
| dsn: env.SENTRY_DSN, | ||
| tracesSampleRate: 1.0, | ||
| // Keep gen_ai spans embedded in the transaction (instead of streamed as a | ||
| // separate envelope container) so they can be asserted on `transaction.spans`. | ||
| streamGenAiSpans: false, | ||
| }), | ||
| { | ||
| asyncfetch(request){ | ||
| consturl=newURL(request.url); | ||
| if(url.pathname==='/error'){ | ||
| // The Workers AI integration deliberately does not call `captureException` itself. | ||
| // A failing `run` must bubble up out of the handler so the top-level Cloudflare | ||
| // instrumentation reports it instead — showing up in Sentry exactly once. | ||
| constresult=awaitai.run('error-model',{prompt: 'Hello'}); | ||
| returnnewResponse(JSON.stringify(result)); | ||
| } | ||
| if(url.pathname==='/stream'){ | ||
| conststream=(awaitai.run('@cf/meta/llama-3.1-8b-instruct',{ | ||
| messages: [{role: 'user',content: 'What is the capital of France?'}], | ||
| stream: true, | ||
| }))asReadableStream; | ||
| consttext=awaitnewResponse(stream).text(); | ||
| returnnewResponse(text); | ||
| } | ||
| constresult=awaitai.run('@cf/meta/llama-3.1-8b-instruct',{ | ||
| messages: [ | ||
| {role: 'system',content: 'You are a helpful assistant.'}, | ||
| {role: 'user',content: 'What is the capital of France?'}, | ||
| ], | ||
| temperature: 0.7, | ||
| max_tokens: 100, | ||
| }); | ||
| returnnewResponse(JSON.stringify(result)); | ||
| }, | ||
| }, | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import { simulateReadableStream } from 'ai'; | ||
| function createSseStream(events: string[]): ReadableStream<Uint8Array> { | ||
| const encoder = new TextEncoder(); | ||
| return simulateReadableStream({ | ||
| initialDelayInMs: 0, | ||
| chunkDelayInMs: 0, | ||
| chunks: events.map(event => encoder.encode(`data: ${event}\n\n`)), | ||
| }); | ||
| } | ||
| /** | ||
| * Minimal mock of the Cloudflare Workers AI binding (`env.AI`). | ||
| */ | ||
| export class MockAi { | ||
| public async run(model: string, inputs: Record<string, unknown>): Promise<unknown> { | ||
| // Simulate processing time | ||
| await new Promise(resolve => setTimeout(resolve, 10)); | ||
| if (model === 'error-model') { | ||
| const error = new Error('Model not found'); | ||
| (error as unknown as { status: number }).status = 404; | ||
| throw error; | ||
| } | ||
| if (inputs?.stream === true) { | ||
| return createSseStream([ | ||
| '{"response":"The capital "}', | ||
| '{"response":"of France "}', | ||
| '{"response":"is Paris."}', | ||
| '{"response":"","usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}', | ||
| '[DONE]', | ||
| ]); | ||
| } | ||
| return { | ||
| response: 'The capital of France is Paris.', | ||
| usage: { | ||
| prompt_tokens: 12, | ||
| completion_tokens: 7, | ||
| total_tokens: 19, | ||
| }, | ||
| }; | ||
| } | ||
Comment on lines
+34
to
+44
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The Suggested FixUpdate the Prompt for AI AgentAlso affects:
| ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; | ||
| import { expect, it } from 'vitest'; | ||
| import { | ||
| GEN_AI_OPERATION_NAME_ATTRIBUTE, | ||
| GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, | ||
| GEN_AI_REQUEST_MODEL_ATTRIBUTE, | ||
| GEN_AI_REQUEST_STREAM_ATTRIBUTE, | ||
| GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, | ||
| GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, | ||
| GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, | ||
| GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, | ||
| GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, | ||
| } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; | ||
| import { createRunner } from '../../../runner'; | ||
| // These tests are not exhaustive because the instrumentation is | ||
| // already tested in the core unit tests and we merely want to test | ||
| // that the instrumentation does not break in our cloudflare SDK. | ||
| it('traces a basic Workers AI text generation request', async ({ signal }) => { | ||
| const runner = createRunner(__dirname) | ||
| .ignore('event') | ||
| .expect(envelope => { | ||
| const transactionEvent = envelope[1]?.[0]?.[1] as any; | ||
| // The transaction event is framework-generated and carries non-deterministic fields | ||
| // (random ports, ids, timestamps, sdk version), so we assert the stable subset. | ||
| expect(transactionEvent).toEqual( | ||
| expect.objectContaining({ | ||
| type: 'transaction', | ||
| transaction: 'GET /', | ||
| transaction_info: { source: 'route' }, | ||
| contexts: expect.objectContaining({ | ||
| trace: expect.objectContaining({ | ||
| op: 'http.server', | ||
| origin: 'auto.http.cloudflare', | ||
| status: 'ok', | ||
| }), | ||
| }), | ||
| spans: [ | ||
| expect.objectContaining({ | ||
| description: 'chat @cf/meta/llama-3.1-8b-instruct', | ||
| op: 'gen_ai.chat', | ||
| origin: 'auto.ai.cloudflare.workers_ai', | ||
| data: { | ||
| 'sentry.origin': 'auto.ai.cloudflare.workers_ai', | ||
| 'sentry.op': 'gen_ai.chat', | ||
| [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', | ||
| [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', | ||
| [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct', | ||
| [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: 0.7, | ||
| [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: 100, | ||
| [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12, | ||
| [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7, | ||
| [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19, | ||
| }, | ||
| }), | ||
| ], | ||
| }), | ||
| ); | ||
| }) | ||
| .start(signal); | ||
| await runner.makeRequest('get', '/'); | ||
| await runner.completed(); | ||
| }); | ||
| it('traces a streaming Workers AI text generation request', async ({ signal }) => { | ||
| const runner = createRunner(__dirname) | ||
| .ignore('event') | ||
| .expect(envelope => { | ||
| const transactionEvent = envelope[1]?.[0]?.[1] as any; | ||
| expect(transactionEvent).toEqual( | ||
| expect.objectContaining({ | ||
| type: 'transaction', | ||
| transaction: 'GET /stream', | ||
| transaction_info: { source: 'url' }, | ||
| contexts: expect.objectContaining({ | ||
| trace: expect.objectContaining({ | ||
| op: 'http.server', | ||
| origin: 'auto.http.cloudflare', | ||
| status: 'ok', | ||
| }), | ||
| }), | ||
| spans: [ | ||
| expect.objectContaining({ | ||
| description: 'chat @cf/meta/llama-3.1-8b-instruct', | ||
| op: 'gen_ai.chat', | ||
| origin: 'auto.ai.cloudflare.workers_ai', | ||
| data: { | ||
| 'sentry.origin': 'auto.ai.cloudflare.workers_ai', | ||
| 'sentry.op': 'gen_ai.chat', | ||
| [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', | ||
| [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', | ||
| [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct', | ||
| [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: true, | ||
| [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, | ||
| [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12, | ||
| [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7, | ||
| [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19, | ||
| }, | ||
| }), | ||
| ], | ||
| }), | ||
| ); | ||
| }) | ||
| .start(signal); | ||
| await runner.makeRequest('get', '/stream'); | ||
| await runner.completed(); | ||
| }); | ||
| // The Workers AI integration deliberately does not call `captureException` itself. | ||
| // When a `run` call fails, the error must bubble up out of the fetch handler and be | ||
| // reported by the top-level Cloudflare instrumentation instead — so it shows up in | ||
| // Sentry exactly once, with the `auto.http.cloudflare` mechanism. | ||
| it('bubbles up Workers AI errors to be captured by the top-level handler', async ({ signal }) => { | ||
| const runner = createRunner(__dirname) | ||
| // A failing run still produces a (sampled) transaction; we only care about the error event here. | ||
| .ignore('transaction') | ||
| .expect(envelope => { | ||
| const errorEvent = envelope[1]?.[0]?.[1] as any; | ||
| expect(errorEvent).toEqual( | ||
| expect.objectContaining({ | ||
| level: 'error', | ||
| exception: { | ||
| values: [ | ||
| expect.objectContaining({ | ||
| type: 'Error', | ||
| value: 'Model not found', | ||
| stacktrace: { | ||
| frames: expect.any(Array), | ||
| }, | ||
| mechanism: { type: 'auto.http.cloudflare', handled: false }, | ||
| }), | ||
| ], | ||
| }, | ||
| request: expect.objectContaining({ | ||
| method: 'GET', | ||
| url: expect.any(String), | ||
| }), | ||
| }), | ||
| ); | ||
| }) | ||
| .start(signal); | ||
| await runner.makeRequest('get', '/error', { expectError: true }); | ||
| await runner.completed(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "name": "workers-ai-worker", | ||
| "compatibility_date": "2025-06-17", | ||
| "main": "index.ts", | ||
| "compatibility_flags": ["nodejs_als"], | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| import { isObjectLike } from '@sentry/core'; | ||
| import { instrumentWorkersAiClient } from '@sentry/core'; | ||
| import type { CloudflareOptions } from '../../client'; | ||
| import { | ||
| isAiBinding, | ||
| isD1Database, | ||
| isDurableObjectNamespace, | ||
| isJSRPC, | ||
| @@ -33,6 +35,7 @@ const instrumentedBindings = new WeakMap<object, unknown>(); | ||
| * - Queue producers (via `send` + `sendBatch` duck-typing) | ||
| * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) | ||
| * - Rate limiters (via `limit` duck-typing) | ||
| * - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing) | ||
| * | ||
| * @param env - The Cloudflare env object to instrument | ||
| * @param options - Optional CloudflareOptions to control RPC trace propagation | ||
| @@ -85,6 +88,12 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt | ||
| return instrumented; | ||
| } | ||
| if (isAiBinding(item)) { | ||
| const instrumented = instrumentWorkersAiClient(item); | ||
| instrumentedBindings.set(item, instrumented); | ||
| return instrumented; | ||
| } | ||
JPeer264 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. sentry[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. JPeer264 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. sentry[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if (!rpcPropagation) { | ||
| return item; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The integration test for Workers AI tracing bypasses the new auto-instrumentation logic. It only tests manual instrumentation, leaving the auto-detection path untested.
Severity: MEDIUM
Suggested Fix
Modify the integration test to validate the auto-instrumentation path. This involves creating a proper mock AI binding with
run,gateway, andtoMarkdownmethods, placing it on theenvobject, and verifying thatwithSentrycorrectly detects and wraps it without manual intervention.Prompt for AI Agent