Skip to content

Commit 7234674

Browse files
JPeer264claude
andcommitted
fix(cloudflare): Fix instrumentDurableObjectWithSentry breaking Cloudflare Agents
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 a563b18 commit 7234674

18 files changed

Lines changed: 484 additions & 61 deletions

File tree

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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
console.log(transactionEvent);
7+
return (
8+
transactionEvent.transaction === 'GET /agents/my-agent/user-123' &&
9+
transactionEvent.contexts?.trace?.parent_span_id !== undefined
10+
);
11+
});
12+
13+
await page.goto(baseURL!);
14+
15+
await expect(page.getByText('Connected')).toBeVisible();
16+
await page.getByRole('button', { name: 'Call Agent' }).click();
17+
await expect(page.getByText('Hello, World!')).toBeVisible();
18+
19+
const transaction = await transactionPromise;
20+
21+
expect(transaction).toEqual({
22+
contexts: {
23+
trace: {
24+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
25+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
26+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
27+
data: expect.any(Object),
28+
op: 'http.server',
29+
status: 'ok',
30+
origin: 'auto.http.cloudflare',
31+
},
32+
cloud_resource: { 'cloud.provider': 'cloudflare' },
33+
culture: { timezone: expect.any(String) },
34+
runtime: { name: 'cloudflare' },
35+
},
36+
spans: expect.arrayContaining([
37+
expect.objectContaining({
38+
op: 'db',
39+
description: 'durable_object_storage_get',
40+
}),
41+
]),
42+
start_timestamp: expect.any(Number),
43+
timestamp: expect.any(Number),
44+
transaction: 'GET /agents/my-agent/user-123',
45+
type: 'transaction',
46+
request: {
47+
headers: expect.any(Object),
48+
method: 'GET',
49+
url: expect.stringContaining('/agents/my-agent/user-123'),
50+
query_string: expect.any(String),
51+
},
52+
transaction_info: { source: 'url' },
53+
platform: 'javascript',
54+
event_id: expect.stringMatching(/[a-f0-9]{32}/),
55+
environment: expect.any(String),
56+
release: expect.any(String),
57+
sdk: {
58+
integrations: expect.any(Array),
59+
name: 'sentry.javascript.cloudflare',
60+
version: expect.any(String),
61+
packages: expect.any(Array),
62+
},
63+
});
64+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"compilerOptions": {
3+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+
"target": "es2023",
5+
"lib": ["ES2023", "DOM"],
6+
"module": "esnext",
7+
"types": ["vite/client"],
8+
"skipLibCheck": true,
9+
10+
/* Bundler mode */
11+
"moduleResolution": "bundler",
12+
"allowImportingTsExtensions": true,
13+
"verbatimModuleSyntax": true,
14+
"moduleDetection": "force",
15+
"noEmit": true,
16+
"jsx": "react-jsx",
17+
18+
/* Linting */
19+
"noUnusedLocals": true,
20+
"noUnusedParameters": true,
21+
"erasableSyntaxOnly": true,
22+
"noFallthroughCasesInSwitch": true
23+
},
24+
"include": ["src"]
25+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"files": [],
3+
"references": [
4+
{
5+
"path": "./tsconfig.app.json"
6+
},
7+
{
8+
"path": "./tsconfig.node.json"
9+
},
10+
{
11+
"path": "./tsconfig.worker.json"
12+
}
13+
]
14+
}

0 commit comments

Comments
 (0)