Skip to content

Commit a7cb7e6

Browse files
JPeer264claude
andauthored
fix(cloudflare): Fix instrumentDurableObjectWithSentry breaking Cloudflare Agents (#21101)
By investigating #20099 it seemed that Cloudflare Agents were not working when `enableRpcTracePropagation: true` was set. The main reason for that was that the original target was not bound to the call. By doing that I changed the logic a little to also make it more resistant. As the `rpc` trace was always everywhere attached, even though it could be that it was not directly a real RPC call. No the `rpc` trace is only attached when in the arguments the `__sentry_rpc_meta__` is there as last parameter - which gives an extra layer of safety. Also `BUILT_IN_DO_METHODS` is not needed and got removed, as we actually ignore these with the other checks (test was added for it) The deprecated `instrumentPrototypeMethods` is still using the previous behavior. This PR also proofs that we can instrument Agents on Cloudflare (`cloudflare-agent` e2e test) --- AI text on top: The fix binds ALL methods to the original object, ensuring private fields work correctly. Additionally, spans are now only created when Sentry RPC metadata (`__sentry_rpc_meta__`) is present in the arguments, which is only the case for actual RPC calls that have trace propagation enabled on the calling side. This prevents creating spans for internal framework method calls like those made by the Agents SDK over WebSocket. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d8015e2 commit a7cb7e6

23 files changed

Lines changed: 607 additions & 64 deletions

File tree

dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export default Sentry.withSentry(
3535
(env: Env) => ({
3636
dsn: env.SENTRY_DSN,
3737
tracesSampleRate: 1.0,
38+
enableRpcTracePropagation: true,
3839
}),
3940
{
4041
async fetch(request: Request, env: Env): Promise<Response> {

dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export default Sentry.withSentry(
3333
(env: Env) => ({
3434
dsn: env.SENTRY_DSN,
3535
tracesSampleRate: 1.0,
36+
enableRpcTracePropagation: true,
3637
}),
3738
{
3839
async fetch(request: Request, env: Env): Promise<Response> {

dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,23 +50,30 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
5050
TestDurableObjectBase,
5151
);
5252

53-
export default {
54-
async fetch(request: Request, env: Env): Promise<Response> {
55-
const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test');
56-
const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase;
53+
export default Sentry.withSentry(
54+
(env: Env) => ({
55+
dsn: env.SENTRY_DSN,
56+
tracesSampleRate: 1.0,
57+
enableRpcTracePropagation: true,
58+
}),
59+
{
60+
async fetch(request: Request, env: Env): Promise<Response> {
61+
const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test');
62+
const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase;
5763

58-
if (request.url.includes('hello')) {
59-
const greeting = await stub.sayHello('world');
60-
return new Response(greeting);
61-
}
64+
if (request.url.includes('hello')) {
65+
const greeting = await stub.sayHello('world');
66+
return new Response(greeting);
67+
}
6268

63-
// Test endpoint that modifies and reads a private field via RPC
64-
if (request.url.includes('custom-greeting')) {
65-
await stub.setGreeting('Howdy');
66-
const greeting = await stub.sayHello('partner');
67-
return new Response(greeting);
68-
}
69+
// Test endpoint that modifies and reads a private field via RPC
70+
if (request.url.includes('custom-greeting')) {
71+
await stub.setGreeting('Howdy');
72+
const greeting = await stub.sayHello('partner');
73+
return new Response(greeting);
74+
}
6975

70-
return new Response('Usual response');
71-
},
72-
};
76+
return new Response('Usual response');
77+
},
78+
} satisfies ExportedHandler<Env>,
79+
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>temp-cloudflare-react</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" src="/src/main.tsx"></script>
12+
</body>
13+
</html>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "cloudflare-agent",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc -b && vite build",
9+
"preview": "vite preview",
10+
"cf-typegen": "wrangler types --include-runtime=false",
11+
"test:build": "pnpm install && pnpm build",
12+
"test:assert": "pnpm test:dev && pnpm test:prod",
13+
"test:prod": "TEST_ENV=production playwright test",
14+
"test:dev": "TEST_ENV=development playwright test"
15+
},
16+
"dependencies": {
17+
"@cloudflare/ai-chat": "^0.7.1",
18+
"@sentry/cloudflare": "^10.53.1",
19+
"agents": "^0.13.1",
20+
"react": "^19.2.6",
21+
"react-dom": "^19.2.6"
22+
},
23+
"devDependencies": {
24+
"@playwright/test": "~1.56.0",
25+
"@cloudflare/vite-plugin": "^1.37.2",
26+
"@cloudflare/workers-types": "^4.20260520.1",
27+
"@sentry-internal/test-utils": "link:../../../test-utils",
28+
"@types/node": "^24.12.4",
29+
"@types/react": "^19.2.15",
30+
"@types/react-dom": "^19.2.3",
31+
"@vitejs/plugin-react": "^6.0.2",
32+
"globals": "^17.6.0",
33+
"typescript": "~6.0.3",
34+
"vite": "^8.0.14",
35+
"wrangler": "^4.93.0",
36+
"ws": "^8.20.1"
37+
},
38+
"volta": {
39+
"node": "24.15.0",
40+
"extends": "../../package.json"
41+
}
42+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
export default getPlaywrightConfig({
4+
testDir: './tests',
5+
port: 4173,
6+
startCommand: 'pnpm preview',
7+
use: {
8+
baseURL: 'http://localhost:4173',
9+
},
10+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useState } from 'react';
2+
import { useAgent } from 'agents/react';
3+
4+
function App() {
5+
const [greeting, setGreeting] = useState('');
6+
const [connected, setConnected] = useState(false);
7+
8+
const agent = useAgent({
9+
agent: 'my-agent',
10+
name: 'user-123',
11+
onOpen: () => setConnected(true),
12+
onClose: () => setConnected(false),
13+
});
14+
15+
const handleGreet = async () => {
16+
if (!connected) {
17+
setGreeting('Not connected yet...');
18+
return;
19+
}
20+
try {
21+
const result = await agent.call('greet', ['World']);
22+
setGreeting(result as string);
23+
} catch (err) {
24+
setGreeting(`Error: ${err}`);
25+
console.error('Agent call failed:', err);
26+
}
27+
};
28+
29+
return (
30+
<div
31+
style={{
32+
display: 'flex',
33+
flexDirection: 'column',
34+
alignItems: 'center',
35+
justifyContent: 'center',
36+
minHeight: '100vh',
37+
gap: '1rem',
38+
}}
39+
>
40+
<button onClick={handleGreet}>Call Agent</button>
41+
{greeting && <p>{greeting}</p>}
42+
<p style={{ fontSize: '0.8rem', color: '#666' }}>{connected ? 'Connected' : 'Connecting...'}</p>
43+
</div>
44+
);
45+
}
46+
47+
export default App;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { StrictMode } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import App from './App.tsx';
4+
5+
createRoot(document.getElementById('root')!).render(
6+
<StrictMode>
7+
<App />
8+
</StrictMode>,
9+
);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { startEventProxyServer } from '@sentry-internal/test-utils';
2+
3+
startEventProxyServer({ port: 3031, proxyServerName: 'cloudflare-agent' });
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('@callable() methods work correctly with Sentry instrumentDurableObjectWithSentry', async ({ page, baseURL }) => {
5+
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
6+
return (
7+
transactionEvent.transaction === 'GET /agents/my-agent/user-123' &&
8+
transactionEvent.contexts?.trace?.parent_span_id !== undefined
9+
);
10+
});
11+
12+
await page.goto(baseURL!);
13+
14+
await expect(page.getByText('Connected')).toBeVisible();
15+
await page.getByRole('button', { name: 'Call Agent' }).click();
16+
await expect(page.getByText('Hello, World!')).toBeVisible();
17+
18+
const transaction = await transactionPromise;
19+
20+
expect(transaction).toEqual({
21+
contexts: {
22+
trace: {
23+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
24+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
25+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
26+
data: expect.any(Object),
27+
op: 'http.server',
28+
status: 'ok',
29+
origin: 'auto.http.cloudflare',
30+
},
31+
cloud_resource: { 'cloud.provider': 'cloudflare' },
32+
culture: { timezone: expect.any(String) },
33+
runtime: { name: 'cloudflare' },
34+
},
35+
spans: expect.arrayContaining([
36+
expect.objectContaining({
37+
op: 'db',
38+
description: 'durable_object_storage_get',
39+
}),
40+
]),
41+
start_timestamp: expect.any(Number),
42+
timestamp: expect.any(Number),
43+
transaction: 'GET /agents/my-agent/user-123',
44+
type: 'transaction',
45+
request: {
46+
headers: expect.any(Object),
47+
method: 'GET',
48+
url: expect.stringContaining('/agents/my-agent/user-123'),
49+
query_string: expect.any(String),
50+
},
51+
transaction_info: { source: 'url' },
52+
platform: 'javascript',
53+
event_id: expect.stringMatching(/[a-f0-9]{32}/),
54+
environment: expect.any(String),
55+
release: expect.any(String),
56+
sdk: {
57+
integrations: expect.any(Array),
58+
name: 'sentry.javascript.cloudflare',
59+
version: expect.any(String),
60+
packages: expect.any(Array),
61+
},
62+
});
63+
});

0 commit comments

Comments
 (0)