Skip to content
Open
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 number Diff line number Diff 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: '445 KiB',
limit: '470 KiB',
disablePlugins: ['@size-limit/webpack'],
webpack: false,
modifyEsbuildConfig: function (config) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as Sentry from '@sentry/cloudflare';
import { MockAi } from './mocks';

interface Env {
SENTRY_DSN: string;
}

const ai = Sentry.instrumentWorkersAiClient(new MockAi());

export default Sentry.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,
}),
{
async fetch(request) {
const url = new URL(request.url);

if (url.pathname === '/stream') {
const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', {
messages: [{ role: 'user', content: 'What is the capital of France?' }],
stream: true,
})) as ReadableStream;

const text = await new Response(stream).text();
return new Response(text);
}

const result = await ai.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,
});

return new Response(JSON.stringify(result));
},
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { simulateReadableStream } from 'ai';

function createSseStream(events: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return simulateReadableStream({
initialDelayInMs: 0,
chunkDelayInMs: 0,
chunks: events.map(event => encoder.encode(`data: ${event}\n\n`)),
});
}

/**
* Minimal mock of the Cloudflare Workers AI binding (`env.AI`).
*/
export class MockAi {
public async run(model: string, inputs: Record<string, unknown>): Promise<unknown> {
// Simulate processing time
await new Promise(resolve => setTimeout(resolve, 10));

if (model === 'error-model') {
const error = new Error('Model not found');
(error as unknown as { status: number }).status = 404;
throw error;
}

if (inputs?.stream === true) {
return createSseStream([
'{"response":"The capital "}',
'{"response":"of France "}',
'{"response":"is Paris."}',
'{"response":"","usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}',
'[DONE]',
]);
}

return {
response: 'The capital of France is Paris.',
usage: {
prompt_tokens: 12,
completion_tokens: 7,
total_tokens: 19,
},
};
}
Comment on lines +34 to +44

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 number Diff line number Diff line change
@@ -0,0 +1,110 @@
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_SYSTEM_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(

Check failure on line 28 in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts

View workflow job for this annotation

GitHub Actions / Cloudflare Integration Tests

suites/tracing/workers-ai/test.ts > traces a basic Workers AI text generation request

AssertionError: expected { contexts: { …(4) }, …(11) } to deeply equal ObjectContaining{…} - Expected + Received @@ -1,32 +1,109 @@ - ObjectContaining { - "contexts": ObjectContaining { - "trace": ObjectContaining { + { + "contexts": { + "cloud_resource": { + "cloud.provider": "cloudflare", + }, + "culture": { + "timezone": "America/Chicago", + }, + "runtime": { + "name": "cloudflare", + }, + "trace": { + "data": { + "http.request.header.accept": "*/*", + "http.request.header.accept_encoding": "br, gzip", + "http.request.header.accept_language": "*", + "http.request.header.cf_connecting_ip": "[Filtered]", + "http.request.header.host": "localhost:35411", + "http.request.header.sec_fetch_mode": "cors", + "http.request.header.user_agent": "node", + "http.request.method": "GET", + "http.response.status_code": 200, + "network.protocol.name": "HTTP/1.1", + "sentry.op": "http.server", + "sentry.origin": "auto.http.cloudflare", + "sentry.sample_rate": 1, + "sentry.source": "route", + "server.address": "localhost", + "url.full": "http://localhost:35411/", + "url.path": "/", + "url.port": "35411", + "url.scheme": "http:", + "user_agent.original": "node", + }, "op": "http.server", "origin": "auto.http.cloudflare", + "span_id": "ae714575a8ffe777", "status": "ok", + "trace_id": "15125a711d2440f491d8fc87a13a957e", }, }, + "environment": "production", + "event_id": "2aa93a292b234f67aadcd9ccaee583df", + "platform": "javascript", + "request": { + "headers": { + "accept": "*/*", + "accept-encoding": "br, gzip", + "accept-language": "*", + "host": "localhost:35411", + "sec-fetch-mode": "cors", + "user-agent": "node", + }, + "method": "GET", + "url": "http://localhost:35411/", + }, + "sdk": { + "integrations": [ + "Dedupe", + "InboundFilters", + "FunctionToString", + "ConversationId", + "LinkedErrors", + "Fetch", + "Hono", + "HttpServer", + "RequestData", + "Console", + ], + "name": "sentry.javascript.cloudflare", + "packages": [ + { + "name": "npm:@sentry/cloudflare", + "version": "10.66.0", + }, + ], + "version": "10.66.0", + }, "spans": [ - ObjectContaining { + { "data": { "gen_ai.operation.name": "chat", + "gen_ai.provider.name": "cloudflare.workers_ai", "gen_ai.request.max_tokens": 100, "gen_ai.request.model": "@cf/meta/llama-3.1-8b-instruct", "gen_ai.request.temperature": 0.7, - "gen_ai.system": "cloudflare.workers_ai", "gen_ai.usage.input_tokens": 12, "gen_ai.usage.output_tokens": 7, "gen_ai.usage.total_tokens": 19, "sentry.op": "gen_ai.chat", "sentry.origin": "auto.ai.cloudflare.workers_ai", }, "description": "chat @cf/meta/llama-3.1-8b-instruct", "op": "gen_ai.chat", "origin": "auto.ai.cloudflare.workers_ai", + "parent_span_id": "ae714575a8ffe777", + "span_id": "ab7184b60a4b451a", + "start_timestamp": 1784308260.037, + "timestamp": 1784308260.047, + "trace_id": "15125a711d2440f491d8fc87a13a957e", }, ], + "start_timestamp": 1784308260.035, + "timestamp": 1784308260.049, "transaction": "GET /", "transaction_info": { "source": "route", }, "type": "transaction", ❯ suites/tracing/workers-ai/test.ts:28:32 ❯ assertEnvelopeMatches runner.ts:190:11 ❯ newEnvelope runner.ts:244:13 ❯ ../test-utils/src/server.ts:19:7 ❯ Layer.handle [as handle_request] ../../node_modules/express/lib/router/layer.js:95:5 ❯ next ../../node_modules/express/lib/router/route.js:149:13 ❯ Route.dispatch ../../node_modules/express/lib/router/route.js:119:3 ❯ Layer.handle [as h
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_SYSTEM_ATTRIBUTE]: '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(

Check failure on line 73 in dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts

View workflow job for this annotation

GitHub Actions / Cloudflare Integration Tests

suites/tracing/workers-ai/test.ts > traces a streaming Workers AI text generation request

AssertionError: expected { contexts: { …(4) }, …(11) } to deeply equal ObjectContaining{…} - Expected + Received @@ -1,32 +1,109 @@ - ObjectContaining { - "contexts": ObjectContaining { - "trace": ObjectContaining { + { + "contexts": { + "cloud_resource": { + "cloud.provider": "cloudflare", + }, + "culture": { + "timezone": "America/Chicago", + }, + "runtime": { + "name": "cloudflare", + }, + "trace": { + "data": { + "http.request.header.accept": "*/*", + "http.request.header.accept_encoding": "br, gzip", + "http.request.header.accept_language": "*", + "http.request.header.cf_connecting_ip": "[Filtered]", + "http.request.header.host": "localhost:38505", + "http.request.header.sec_fetch_mode": "cors", + "http.request.header.user_agent": "node", + "http.request.method": "GET", + "http.response.status_code": 200, + "network.protocol.name": "HTTP/1.1", + "sentry.op": "http.server", + "sentry.origin": "auto.http.cloudflare", + "sentry.sample_rate": 1, + "sentry.source": "url", + "server.address": "localhost", + "url.full": "http://localhost:38505/stream", + "url.path": "/stream", + "url.port": "38505", + "url.scheme": "http:", + "user_agent.original": "node", + }, "op": "http.server", "origin": "auto.http.cloudflare", + "span_id": "97e3b28ab29526c4", "status": "ok", + "trace_id": "6ba3acf8209c45bfad9ed0eb56c8cfb3", }, }, + "environment": "production", + "event_id": "77f784d0702a42f593c81708f8c13a01", + "platform": "javascript", + "request": { + "headers": { + "accept": "*/*", + "accept-encoding": "br, gzip", + "accept-language": "*", + "host": "localhost:38505", + "sec-fetch-mode": "cors", + "user-agent": "node", + }, + "method": "GET", + "url": "http://localhost:38505/stream", + }, + "sdk": { + "integrations": [ + "Dedupe", + "InboundFilters", + "FunctionToString", + "ConversationId", + "LinkedErrors", + "Fetch", + "Hono", + "HttpServer", + "RequestData", + "Console", + ], + "name": "sentry.javascript.cloudflare", + "packages": [ + { + "name": "npm:@sentry/cloudflare", + "version": "10.66.0", + }, + ], + "version": "10.66.0", + }, "spans": [ - ObjectContaining { + { "data": { "gen_ai.operation.name": "chat", + "gen_ai.provider.name": "cloudflare.workers_ai", "gen_ai.request.model": "@cf/meta/llama-3.1-8b-instruct", "gen_ai.request.stream": true, "gen_ai.response.streaming": true, - "gen_ai.system": "cloudflare.workers_ai", "gen_ai.usage.input_tokens": 12, "gen_ai.usage.output_tokens": 7, "gen_ai.usage.total_tokens": 19, "sentry.op": "gen_ai.chat", "sentry.origin": "auto.ai.cloudflare.workers_ai", }, "description": "chat @cf/meta/llama-3.1-8b-instruct", "op": "gen_ai.chat", "origin": "auto.ai.cloudflare.workers_ai", + "parent_span_id": "97e3b28ab29526c4", + "span_id": "af081f1f6b4455da", + "start_timestamp": 1784308258.746, + "timestamp": 1784308258.758, + "trace_id": "6ba3acf8209c45bfad9ed0eb56c8cfb3", }, ], + "start_timestamp": 1784308258.745, + "timestamp": 1784308258.759, "transaction": "GET /stream", "transaction_info": { "source": "url", }, "type": "transaction", ❯ suites/tracing/workers-ai/test.ts:73:32 ❯ assertEnvelopeMatches runner.ts:190:11 ❯ newEnvelope runner.ts:244:13 ❯ ../test-utils/src/server.ts:19:7 ❯ Layer.handle [as handle_request] ../../node_modules/express/lib/router/layer.js:95:5 ❯ next ../../node_modules/express/lib/router/route.js:149:13 ❯ Route.dispatch ../../node_modules/express/lib/router/route.js:119:3 ❯ L
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_SYSTEM_ATTRIBUTE]: '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();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
"compatibility_flags": ["nodejs_als"],
}
1 change: 1 addition & 0 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export {
instrumentOpenAiClient,
instrumentGoogleGenAIClient,
instrumentAnthropicAiClient,
instrumentWorkersAiClient,
eventFiltersIntegration,
linkedErrorsIntegration,
requestDataIntegration,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isObjectLike } from '@sentry/core';
import { instrumentWorkersAiClient } from '@sentry/core';
import type { CloudflareOptions } from '../../client';
import {
isAiBinding,
isD1Database,
isDurableObjectNamespace,
isJSRPC,
Expand Down Expand 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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