Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -460,7 +460,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '183 KiB',
limit: '193 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand All@@ -480,7 +480,7 @@ module.exports = [
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: false,
brotli: false,
limit: '448 KiB',
limit: '475 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
1 change: 1 addition & 0 deletions dev-packages/cloudflare-integration-tests/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@
"@prisma/adapter-d1": "6.15.0",
"@prisma/client": "6.15.0",
"@sentry/cloudflare": "10.66.0",
"@sentry/core": "10.66.0",
"@sentry/hono": "10.66.0",
"hono": "^4.12.25",
"openai": "5.18.1"
Expand Down
Original file line numberDiff line numberDiff 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.

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: 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, and toMarkdown methods, placing it on the env object, and verifying that withSentry correctly detects and wraps it without manual intervention.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts#L23
Potential issue: The integration test for Workers AI auto-instrumentation does not
exercise the new auto-detection logic. The test manually instruments a mock AI client
using `instrumentWorkersAiClient` instead of placing the binding in the `env` object for
the `instrumentEnv` function to discover. Furthermore, the `MockAi` object used in the
test is missing the `gateway` and `toMarkdown` methods, which are required by the
`isAiBinding` duck-typing check. As a result, the auto-instrumentation path is
completely untested at the integration level, meaning potential bugs in this new feature
would not be caught by the current test suite.

// 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 numberDiff line numberDiff 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

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: The MockAi class in integration tests is missing gateway and toMarkdown methods, which means the new auto-instrumentation logic for AI bindings is not being tested.
Severity: LOW

Suggested Fix

Update the MockAi class in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts to include mock implementations for the gateway and toMarkdown methods. Modify the integration test to rely on instrumentEnv to automatically detect and instrument the env.AI binding, which will properly test the new auto-instrumentation code path.

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:
dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts#L15-L44
Potential issue: The integration test for Workers AI uses a `MockAi` class that only
implements the `run` method. The new auto-instrumentation logic in `instrumentEnv`
checks for the presence of `run`, `gateway`, and `toMarkdown` to identify an AI binding.
Because the mock is incomplete, the auto-detection path is never triggered in the test
suite. Instead, the test manually instruments the client, bypassing the new logic. This
creates a gap in test coverage, as the auto-instrumentation feature for AI bindings is
not validated by the integration tests.

Also affects:

  • dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts:10~10
  • packages/cloudflare/src/utils/isBinding.ts:94~96
  • packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts:91~95

}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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;
}
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
JPeer264 marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.

if (!rpcPropagation) {
return item;
}
Expand Down
16 changes: 15 additions & 1 deletion packages/cloudflare/src/utils/isBinding.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

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

/**
* Checks if a value is a JSRPC proxy (service binding).
Expand DownExpand Up@@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database {
);
}

/**
* Duck-type check for Workers AI bindings.
* The Ai binding has `run`, `gateway`, and `toMarkdown` methods.
*/
export function isAiBinding(item: unknown): item is Ai {
return (
item != null &&
isNotJSRPC(item) &&
typeof item.run === 'function' &&
typeof item.gateway === 'function' &&
typeof item.toMarkdown === 'function'
);
}

/**
* Duck-type check for R2 Bucket bindings.
* R2Bucket has `head`, `put`, and `createMultipartUpload` methods.
Expand Down
43 changes: 43 additions & 0 deletions packages/cloudflare/test/instrumentations/instrumentEnv.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -284,6 +284,49 @@ describe('instrumentEnv', () => {
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
});

describe('Workers AI bindings', () => {
function createMockAiBinding() {
return {
run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }),
gateway: vi.fn(),
toMarkdown: vi.fn(),
models: vi.fn(),
autorag: vi.fn(),
};
}

it('detects and wraps AI bindings, forwarding run calls unchanged', async () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

const wrapped = instrumented.AI as typeof ai;
expect(wrapped).not.toBe(ai);

const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });

expect(ai.run).toHaveBeenCalledTimes(1);
expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' });
expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } });
});

it('caches the wrapped AI binding across repeated access', () => {
const ai = createMockAiBinding();
const env = { AI: ai };
const instrumented = instrumentEnv(env);

expect(instrumented.AI).toBe(instrumented.AI);
});

it('does not treat bindings with only a run method as AI bindings', () => {
const notAi = { run: vi.fn() };
const env = { RUNNER: notAi };
const instrumented = instrumentEnv(env);

expect(instrumented.RUNNER).toBe(notAi);
});
});

describe('mTLS Fetcher bindings', () => {
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
return new Proxy(
Expand Down
Loading