Skip to content

Commit 6a84103

Browse files
authored
feat(v10/cloudflare): Instrument Agents automatically (#22788)
Backport of: #22727
1 parent f0c3569 commit 6a84103

20 files changed

Lines changed: 1606 additions & 41 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dist
2+
.wrangler
3+
test-results
4+
pnpm-lock.yaml
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "cloudflare-autoinstrument",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev",
8+
"build": "vite build",
9+
"preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')",
10+
"test": "playwright test",
11+
"typecheck": "tsc --noEmit",
12+
"test:build": "pnpm install && pnpm build",
13+
"test:assert": "pnpm typecheck && pnpm test"
14+
},
15+
"dependencies": {
16+
"@cloudflare/ai-chat": "^0.10.0",
17+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
18+
"agents": "^0.20.0"
19+
},
20+
"devDependencies": {
21+
"@babel/core": "^8.0.1",
22+
"@babel/plugin-proposal-decorators": "^8.0.2",
23+
"@cloudflare/vite-plugin": "^1.47.0",
24+
"@cloudflare/workers-types": "^5.20260727.1",
25+
"@playwright/test": "~1.56.0",
26+
"@sentry-internal/test-utils": "link:../../../test-utils",
27+
"@types/node": "^26.1.2",
28+
"@types/ws": "^8.18.1",
29+
"typescript": "~6.0.3",
30+
"vite": "^8.1.5",
31+
"wrangler": "^4.114.0",
32+
"ws": "^8.21.1"
33+
},
34+
"volta": {
35+
"node": "24.15.0",
36+
"extends": "../../package.json"
37+
}
38+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
// `vite build` runs the Sentry auto-instrument transform over the worker entry;
4+
// `pnpm preview` (`wrangler dev`, following the vite plugin's `.wrangler/deploy`
5+
// redirect) serves the built output. The tests therefore assert on the wrapping
6+
// the plugin injected at build time, not on anything in the source entry.
7+
export default getPlaywrightConfig(
8+
{
9+
startCommand: 'pnpm preview',
10+
port: 8787,
11+
},
12+
{
13+
workers: '100%',
14+
retries: 0,
15+
},
16+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Agent } from 'agents';
2+
3+
/**
4+
* An Agent base class living outside the worker entry. `DerivedAgent` in
5+
* `index.ts` extends this, so the plugin only learns it is an Agent by
6+
* following the import into this module and resolving `MyBase -> Agent`.
7+
*/
8+
export class MyBase extends Agent<Env> {
9+
async onRequest(): Promise<Response> {
10+
return Response.json({ agent: 'derived' });
11+
}
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
interface Env {
2+
E2E_TEST_DSN: string;
3+
MyAgent: DurableObjectNamespace;
4+
MyChatAgent: DurableObjectNamespace;
5+
DerivedAgent: DurableObjectNamespace;
6+
PlainDO: DurableObjectNamespace;
7+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { AIChatAgent } from '@cloudflare/ai-chat';
2+
import { Agent, callable, routeAgentRequest } from 'agents';
3+
import { DurableObject } from 'cloudflare:workers';
4+
import { MyBase } from './base';
5+
6+
// NOTE: this file deliberately contains NO `Sentry.*` calls and no import of
7+
// `@sentry/cloudflare`. Everything below is wrapped at build time by
8+
// `sentryCloudflareVitePlugin({ _experimental: { autoInstrumentation: true } })`,
9+
// which reads wrangler.jsonc, wraps the default export with `withSentry`, and
10+
// picks a wrapper per class: `instrumentAgentWithSentry` for the three Agents,
11+
// `instrumentDurableObjectWithSentry` for the plain Durable Object.
12+
//
13+
// Options come from `instrument.server.ts` next to this entry.
14+
15+
/** Agent whose base class (`Agent`) is imported directly into the entry. */
16+
export class MyAgent extends Agent<Env> {
17+
@callable()
18+
async greet(name: string): Promise<string> {
19+
return `Hello, ${name}! (from MyAgent)`;
20+
}
21+
22+
async onRequest(): Promise<Response> {
23+
return Response.json({ agent: 'plain' });
24+
}
25+
}
26+
27+
/** Chat agent — `AIChatAgent` extends `Agent` inside `@cloudflare/ai-chat`. */
28+
export class MyChatAgent extends AIChatAgent<Env> {
29+
@callable()
30+
async greet(name: string): Promise<string> {
31+
return `Hello, ${name}! (from MyChatAgent)`;
32+
}
33+
34+
async onRequest(): Promise<Response> {
35+
return Response.json({ agent: 'chat' });
36+
}
37+
}
38+
39+
/** Agent whose base class lives in `./base` — resolvable only across modules. */
40+
export class DerivedAgent extends MyBase {
41+
@callable()
42+
async greet(name: string): Promise<string> {
43+
return `Hello, ${name}! (from DerivedAgent)`;
44+
}
45+
}
46+
47+
/**
48+
* A genuine Durable Object. Configured identically to the Agents above, so it
49+
* proves detection discriminates rather than upgrading every DO binding.
50+
*/
51+
export class PlainDO extends DurableObject<Env> {
52+
async fetch(): Promise<Response> {
53+
return Response.json({ durableObject: true });
54+
}
55+
}
56+
57+
export default {
58+
async fetch(request: Request, env: Env): Promise<Response> {
59+
const url = new URL(request.url);
60+
61+
if (url.pathname === '/plain-do') {
62+
const stub = env.PlainDO.get(env.PlainDO.idFromName('do-instance'));
63+
return stub.fetch(request);
64+
}
65+
66+
const agentResponse = await routeAgentRequest(request, env);
67+
if (agentResponse) {
68+
return agentResponse;
69+
}
70+
71+
return new Response('Not found', { status: 404 });
72+
},
73+
} satisfies ExportedHandler<Env>;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// The auto-instrument plugin picks this file up by convention (it sits next to
2+
// the worker entry named in wrangler's `main`) and imports its default export as
3+
// the options callback for every wrapper it injects.
4+
export default (env: Env) => ({
5+
traceLifecycle: 'static' as const,
6+
dsn: env.E2E_TEST_DSN,
7+
environment: 'qa',
8+
tunnel: 'http://localhost:3031/',
9+
tracesSampleRate: 1.0,
10+
enableRpcTracePropagation: true,
11+
transportOptions: {
12+
bufferSize: 1000,
13+
},
14+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'cloudflare-autoinstrument',
6+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import WebSocket from 'ws';
2+
3+
type AgentReply = { type?: string; id?: string; done?: boolean; result?: unknown };
4+
5+
/**
6+
* Opens a WebSocket to `/agents/<binding>/<instance>`, sends one RPC frame, and
7+
* resolves with the reply's `result` — the method's return value — once it arrives.
8+
*/
9+
export function callRpc(
10+
baseURL: string,
11+
options: { binding: string; instance: string; method: string; args: unknown[] },
12+
): Promise<unknown> {
13+
const id = `rpc-${options.method}`;
14+
const frame = { type: 'rpc', id, method: options.method, args: options.args };
15+
const wsUrl = `${baseURL.replace(/^http/, 'ws')}/agents/${options.binding}/${options.instance}`;
16+
17+
return new Promise<unknown>((resolveSocket, rejectSocket) => {
18+
const socket = new WebSocket(wsUrl);
19+
const timeout = setTimeout(() => {
20+
socket.close();
21+
rejectSocket(new Error(`Timed out waiting for RPC reply to "${options.method}"`));
22+
}, 15_000);
23+
24+
socket.on('open', () => {
25+
socket.send(JSON.stringify(frame));
26+
});
27+
28+
socket.on('message', data => {
29+
try {
30+
const parsed = JSON.parse(data.toString()) as AgentReply;
31+
if (parsed.type === 'rpc' && parsed.id === id && parsed.done) {
32+
clearTimeout(timeout);
33+
socket.close();
34+
resolveSocket(parsed.result);
35+
}
36+
} catch {
37+
// Ignore non-JSON / unrelated frames.
38+
}
39+
});
40+
41+
socket.on('error', err => {
42+
clearTimeout(timeout);
43+
rejectSocket(err);
44+
});
45+
});
46+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
import { callRpc } from './agent-socket';
4+
5+
// The worker entry (`src/index.ts`) contains no Sentry calls at all — every
6+
// wrapper below was injected by the Vite auto-instrument plugin at build time.
7+
// Any transaction arriving here therefore proves the injection happened.
8+
9+
test('wraps the default export with withSentry (options from instrument.server.ts)', async ({ baseURL }) => {
10+
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
11+
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
12+
});
13+
14+
const res = await fetch(`${baseURL}/plain-do`);
15+
expect(res.status).toBe(200);
16+
await expect(res.json()).resolves.toEqual({ durableObject: true });
17+
18+
const transaction = await transactionPromise;
19+
20+
expect(transaction.contexts?.trace?.origin).toBe('auto.http.cloudflare');
21+
// `environment: 'qa'` is only set in `instrument.server.ts`, so seeing it here
22+
// proves the plugin sourced its options callback from that file rather than
23+
// falling back to reading configuration off `env`.
24+
expect(transaction.environment).toBe('qa');
25+
});
26+
27+
// Each of these three classes is registered in wrangler.jsonc exactly like the
28+
// plain Durable Object below — only the base-class chain marks them as Agents.
29+
// An `rpc` span with origin `auto.faas.cloudflare.agents` is produced solely by
30+
// `instrumentAgentWithSentry`, so its presence is what distinguishes a correct
31+
// agent upgrade from a plain `instrumentDurableObjectWithSentry` wrap.
32+
for (const { title, binding, agentClass } of [
33+
{
34+
title: 'an Agent subclass declared in the entry',
35+
binding: 'my-agent',
36+
agentClass: 'MyAgent',
37+
},
38+
{
39+
title: 'an AIChatAgent subclass declared in the entry',
40+
binding: 'my-chat-agent',
41+
agentClass: 'MyChatAgent',
42+
},
43+
{
44+
title: 'an Agent subclass whose base class lives in another module',
45+
binding: 'derived-agent',
46+
agentClass: 'DerivedAgent',
47+
},
48+
]) {
49+
test(`applies agent instrumentation to ${title}`, async ({ baseURL }) => {
50+
const instance = `${binding}-instance`;
51+
52+
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
53+
return (
54+
event.transaction === 'webSocketMessage' &&
55+
(event.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
56+
);
57+
});
58+
59+
// Each agent's greet() returns a string naming its class, so the reply
60+
// identifies exactly which class handled the call.
61+
const reply = await callRpc(baseURL!, { binding, instance, method: 'greet', args: ['World'] });
62+
expect(reply).toBe(`Hello, World! (from ${agentClass})`);
63+
64+
const transaction = await transactionPromise;
65+
const rpcSpan = (transaction.spans ?? []).find(span => span.op === 'rpc' && span.description === 'greet');
66+
67+
expect(rpcSpan).toEqual(
68+
expect.objectContaining({
69+
op: 'rpc',
70+
description: 'greet',
71+
origin: 'auto.faas.cloudflare.agents',
72+
data: expect.objectContaining({
73+
// Read back off the instance at runtime (`_ParentClass.name`), so it
74+
// confirms the wrapper landed on the user's real class. Matched loosely
75+
// because the transform renames the class it wraps to
76+
// `__SENTRY_ORIGINAL_<name>__` and the bundler infers that name.
77+
'cloudflare.agent.class': expect.stringContaining(agentClass),
78+
'cloudflare.agent.name': instance,
79+
}),
80+
}),
81+
);
82+
});
83+
}
84+
85+
test('applies plain Durable Object instrumentation to a non-Agent class', async ({ baseURL }) => {
86+
const transactionPromise = waitForTransaction('cloudflare-autoinstrument', event => {
87+
return event.contexts?.trace?.op === 'http.server' && (event.request?.url ?? '').includes('/plain-do');
88+
});
89+
90+
const res = await fetch(`${baseURL}/plain-do`);
91+
expect(res.status).toBe(200);
92+
93+
const transaction = await transactionPromise;
94+
95+
// A plain Durable Object must NOT pick up agent instrumentation: detection has
96+
// to discriminate, not blanket-upgrade every `durable_objects` binding.
97+
const agentSpans = (transaction.spans ?? []).filter(span => span.origin === 'auto.faas.cloudflare.agents');
98+
expect(agentSpans).toEqual([]);
99+
});

0 commit comments

Comments
 (0)