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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
dist
.wrangler
test-results
pnpm-lock.yaml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
{
"name": "cloudflare-autoinstrument",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
"test": "playwright test",
"typecheck": "tsc --noEmit",
"test:build": "pnpm install && pnpm build",
"test:assert": "pnpm typecheck && pnpm test"
},
"dependencies": {
"@cloudflare/ai-chat": "^0.10.0",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"agents": "^0.20.0"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/plugin-proposal-decorators": "^8.0.2",
"@cloudflare/vite-plugin": "^1.47.0",
"@cloudflare/workers-types": "^5.20260727.1",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@types/node": "^26.1.2",
"@types/ws": "^8.18.1",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"wrangler": "^4.114.0",
"ws": "^8.21.1"
},
"volta": {
"node": "24.15.0",
"extends": "../../package.json"
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

// `vite build` runs the Sentry auto-instrument transform over the worker entry;
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
// redirect) serves the built output. The tests therefore assert on the wrapping
// the plugin injected at build time, not on anything in the source entry.
export default getPlaywrightConfig(
{
startCommand: 'pnpm preview',
port: 8787,
},
{
workers: '100%',
retries: 0,
},
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
import { Agent } from 'agents';

/**
* An Agent base class living outside the worker entry. `DerivedAgent` in
* `index.ts` extends this, so the plugin only learns it is an Agent by
* following the import into this module and resolving `MyBase -> Agent`.
*/
export class MyBase extends Agent<Env> {
async onRequest(): Promise<Response> {
return Response.json({ agent: 'derived' });
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
interfaceEnv{
E2E_TEST_DSN: string;
MyAgent: DurableObjectNamespace;
MyChatAgent: DurableObjectNamespace;
DerivedAgent: DurableObjectNamespace;
PlainDO: DurableObjectNamespace;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
import { AIChatAgent } from '@cloudflare/ai-chat';
import { Agent, callable, routeAgentRequest } from 'agents';
import { DurableObject } from 'cloudflare:workers';
import { MyBase } from './base';

// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
// `@sentry/cloudflare`. Everything below is wrapped at build time by
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
//
// Options come from `instrument.server.ts` next to this entry.
Comment thread
andreiborza marked this conversation as resolved.

/** Agent whose base class (`Agent`) is imported directly into the entry. */
export class MyAgent extends Agent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'plain' });
}
}

/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
export class MyChatAgent extends AIChatAgent<Env> {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from MyChatAgent)`;
}

async onRequest(): Promise<Response> {
return Response.json({ agent: 'chat' });
}
}

/** Agent whose base class lives in `./base` — resolvable only across modules. */
export class DerivedAgent extends MyBase {
@callable()
async greet(name: string): Promise<string> {
return `Hello, ${name}! (from DerivedAgent)`;
}
}

/**
* A genuine Durable Object. Configured identically to the Agents above, so it
* proves detection discriminates rather than upgrading every DO binding.
*/
export class PlainDO extends DurableObject<Env> {
async fetch(): Promise<Response> {
return Response.json({ durableObject: true });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === '/plain-do') {
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
return stub.fetch(request);
}

const agentResponse = await routeAgentRequest(request, env);
if (agentResponse) {
return agentResponse;
}

return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
// The auto-instrument plugin picks this file up by convention (it sits next to
// the worker entry named in wrangler's `main`) and imports its default export as
// the options callback for every wrapper it injects.
export default (env: Env) => ({
traceLifecycle: 'static' as const,
dsn: env.E2E_TEST_DSN,
environment: 'qa',
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
enableRpcTracePropagation: true,
transportOptions: {
bufferSize: 1000,
},
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
import{startEventProxyServer}from'@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'cloudflare-autoinstrument',
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import WebSocket from 'ws';

type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };

/**
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
* resolves with the reply's `result` — the method's return value — once it arrives.
*/
export function callRpc(
baseURL: string,
options: { binding: string; instance: string; method: string; args: unknown[] },
): Promise<unknown> {
const id = `rpc-${options.method}`;
const frame = { type: 'rpc', id, method: options.method, args: options.args };
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;

return new Promise<unknown>((resolveSocket, rejectSocket) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
}, 15_000);

socket.on('open', () => {
socket.send(JSON.stringify(frame));
});

socket.on('message', data => {
try {
const parsed = JSON.parse(data.toString()) as AgentReply;
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
clearTimeout(timeout);
socket.close();
resolveSocket(parsed.result);
}
} catch {
// Ignore non-JSON / unrelated frames.
}
});

socket.on('error', err => {
clearTimeout(timeout);
rejectSocket(err);
});
});
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';
import { callRpc } from './agent-socket';

// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
// wrapper below was injected by the Vite auto-instrument plugin at build time.
// Any transaction arriving here therefore proves the injection happened.

test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ durableObject: true });

const transaction = await transactionPromise;

expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
// proves the plugin sourced its options callback from that file rather than
// falling back to reading configuration off `env`.
expect(transaction.environment).toBe('qa');
});

// Each of these three classes is registered in wrangler.jsonc exactly like the
// plain Durable Object below — only the base-class chain marks them as Agents.
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
for (const { title, binding, agentClass } of [
{
title: 'an Agent subclass declared in the entry',
binding: 'my-agent',
agentClass: 'MyAgent',
},
{
title: 'an AIChatAgent subclass declared in the entry',
binding: 'my-chat-agent',
agentClass: 'MyChatAgent',
},
{
title: 'an Agent subclass whose base class lives in another module',
binding: 'derived-agent',
agentClass: 'DerivedAgent',
},
]) {
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
const instance = `${binding}-instance`;

const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return (
event.transaction === 'webSocketMessage' &&
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
);
});

// Each agent's greet() returns a string naming its class, so the reply
// identifies exactly which class handled the call.
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
expect(reply).toBe(`Hello, World! (from ${agentClass})`);

const transaction = await transactionPromise;
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');

expect(rpcSpan).toEqual(
expect.objectContaining({
op: 'rpc',
description: 'greet',
origin: 'auto.faas.cloudflare.agents',
data: expect.objectContaining({
// Read back off the instance at runtime (`_ParentClass.name`), so it
// confirms the wrapper landed on the user's real class. Matched loosely
// because the transform renames the class it wraps to
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
'cloudflare.agent.class': expect.stringContaining(agentClass),
'cloudflare.agent.name': instance,
}),
}),
);
});
}

test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
});

const res = await fetch(`${baseURL}/plain-do`);
expect(res.status).toBe(200);

const transaction = await transactionPromise;

// A plain Durable Object must NOT pick up agent instrumentation: detection has
// to discriminate, not blanket-upgrade every `durable_objects` binding.
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
expect(agentSpans).toEqual([]);
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types", "node"],
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
import { cloudflare } from '@cloudflare/vite-plugin';
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
import agents from 'agents/vite';
import { defineConfig } from 'vite';

// `agents()` supplies the TC39 decorator transform that `@callable()` needs.
// `autoInstrumentation` is the plugin under test: it rewrites `src/index.ts` at
// build time so the entry itself contains no Sentry calls.
export default defineConfig({
plugins: [
agents(),
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "cloudflare-autoinstrument",
"main": "src/index.ts",
"compatibility_date": "2026-05-20",
"compatibility_flags": ["nodejs_compat"],

// Every class below is registered the same way — as a Durable Object binding.
// An `agents` Agent *is* a Durable Object, so wrangler offers no way to say
// "this one is an Agent". Only the base-class chain distinguishes them, which
// is exactly what the plugin's detection has to work out at build time:
//
// MyAgent -> extends Agent (entry-local) => agent
// MyChatAgent -> extends AIChatAgent (entry-local) => agent
// DerivedAgent -> extends ./base#MyBase -> Agent => agent
// PlainDO -> extends DurableObject => durableObject
"durable_objects": {
"bindings": [
{ "name": "MyAgent", "class_name": "MyAgent" },
{ "name": "MyChatAgent", "class_name": "MyChatAgent" },
{ "name": "DerivedAgent", "class_name": "DerivedAgent" },
{ "name": "PlainDO", "class_name": "PlainDO" },
],
},

"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent", "MyChatAgent", "DerivedAgent", "PlainDO"],
},
],
}
Loading
Loading