From 72346740a44620368fe07478362cd272d14abe3d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 21 May 2026 15:21:30 +0200 Subject: [PATCH] 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 --- .../suites/tracing/durableobject/index.ts | 41 ++++++----- .../cloudflare-agent/index.html | 13 ++++ .../cloudflare-agent/package.json | 42 +++++++++++ .../cloudflare-agent/playwright.config.ts | 10 +++ .../cloudflare-agent/src/App.tsx | 47 ++++++++++++ .../cloudflare-agent/src/main.tsx | 9 +++ .../cloudflare-agent/start-event-proxy.mjs | 3 + .../cloudflare-agent/tests/callable.test.ts | 64 +++++++++++++++++ .../cloudflare-agent/tsconfig.app.json | 25 +++++++ .../cloudflare-agent/tsconfig.json | 14 ++++ .../cloudflare-agent/tsconfig.node.json | 24 +++++++ .../cloudflare-agent/tsconfig.worker.json | 9 +++ .../cloudflare-agent/vite.config.ts | 9 +++ .../worker-configuration.d.ts | 21 ++++++ .../cloudflare-agent/worker/index.ts | 39 ++++++++++ .../cloudflare-agent/wrangler.jsonc | 34 +++++++++ packages/cloudflare/src/durableobject.ts | 71 +++++++++++-------- .../cloudflare/test/durableobject.test.ts | 70 ++++++++++++++---- 18 files changed, 484 insertions(+), 61 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/index.html create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/src/App.tsx create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/src/main.tsx create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.app.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.node.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.worker.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts index 659b04a3f488..170b2fe5d499 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -50,23 +50,30 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( TestDurableObjectBase, ); -export default { - async fetch(request: Request, env: Env): Promise { - const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test'); - const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase; +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request: Request, env: Env): Promise { + const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test'); + const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase; - if (request.url.includes('hello')) { - const greeting = await stub.sayHello('world'); - return new Response(greeting); - } + if (request.url.includes('hello')) { + const greeting = await stub.sayHello('world'); + return new Response(greeting); + } - // Test endpoint that modifies and reads a private field via RPC - if (request.url.includes('custom-greeting')) { - await stub.setGreeting('Howdy'); - const greeting = await stub.sayHello('partner'); - return new Response(greeting); - } + // Test endpoint that modifies and reads a private field via RPC + if (request.url.includes('custom-greeting')) { + await stub.setGreeting('Howdy'); + const greeting = await stub.sayHello('partner'); + return new Response(greeting); + } - return new Response('Usual response'); - }, -}; + return new Response('Usual response'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/index.html b/dev-packages/e2e-tests/test-applications/cloudflare-agent/index.html new file mode 100644 index 000000000000..6ce1b364b889 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/index.html @@ -0,0 +1,13 @@ + + + + + + + temp-cloudflare-react + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json new file mode 100644 index 000000000000..6c834c1d9f47 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json @@ -0,0 +1,42 @@ +{ + "name": "cloudflare-agent", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "cf-typegen": "wrangler types --include-runtime=false", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:dev && pnpm test:prod", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test" + }, + "dependencies": { + "@cloudflare/ai-chat": "^0.7.1", + "@sentry/cloudflare": "^10.53.1", + "agents": "^0.13.1", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@cloudflare/vite-plugin": "^1.37.2", + "@cloudflare/workers-types": "^4.20260520.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^24.12.4", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "globals": "^17.6.0", + "typescript": "~6.0.3", + "vite": "^8.0.14", + "wrangler": "^4.93.0", + "ws": "^8.20.1" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/playwright.config.ts new file mode 100644 index 000000000000..cbd1dadb7d4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/playwright.config.ts @@ -0,0 +1,10 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig({ + testDir: './tests', + port: 4173, + startCommand: 'pnpm preview', + use: { + baseURL: 'http://localhost:4173', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/src/App.tsx b/dev-packages/e2e-tests/test-applications/cloudflare-agent/src/App.tsx new file mode 100644 index 000000000000..5a5a7ff9fe30 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/src/App.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { useAgent } from 'agents/react'; + +function App() { + const [greeting, setGreeting] = useState(''); + const [connected, setConnected] = useState(false); + + const agent = useAgent({ + agent: 'my-agent', + name: 'user-123', + onOpen: () => setConnected(true), + onClose: () => setConnected(false), + }); + + const handleGreet = async () => { + if (!connected) { + setGreeting('Not connected yet...'); + return; + } + try { + const result = await agent.call('greet', ['World']); + setGreeting(result as string); + } catch (err) { + setGreeting(`Error: ${err}`); + console.error('Agent call failed:', err); + } + }; + + return ( +
+ + {greeting &&

{greeting}

} +

{connected ? 'Connected' : 'Connecting...'}

+
+ ); +} + +export default App; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/src/main.tsx b/dev-packages/e2e-tests/test-applications/cloudflare-agent/src/main.tsx new file mode 100644 index 000000000000..e17d50b10310 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-agent/start-event-proxy.mjs new file mode 100644 index 000000000000..882426136853 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/start-event-proxy.mjs @@ -0,0 +1,3 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ port: 3031, proxyServerName: 'cloudflare-agent' }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts new file mode 100644 index 000000000000..7bbb181ad192 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('@callable() methods work correctly with Sentry instrumentDurableObjectWithSentry', async ({ page, baseURL }) => { + const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => { + console.log(transactionEvent); + return ( + transactionEvent.transaction === 'GET /agents/my-agent/user-123' && + transactionEvent.contexts?.trace?.parent_span_id !== undefined + ); + }); + + await page.goto(baseURL!); + + await expect(page.getByText('Connected')).toBeVisible(); + await page.getByRole('button', { name: 'Call Agent' }).click(); + await expect(page.getByText('Hello, World!')).toBeVisible(); + + const transaction = await transactionPromise; + + expect(transaction).toEqual({ + contexts: { + trace: { + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: expect.any(Object), + op: 'http.server', + status: 'ok', + origin: 'auto.http.cloudflare', + }, + cloud_resource: { 'cloud.provider': 'cloudflare' }, + culture: { timezone: expect.any(String) }, + runtime: { name: 'cloudflare' }, + }, + spans: expect.arrayContaining([ + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_get', + }), + ]), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: 'GET /agents/my-agent/user-123', + type: 'transaction', + request: { + headers: expect.any(Object), + method: 'GET', + url: expect.stringContaining('/agents/my-agent/user-123'), + query_string: expect.any(String), + }, + transaction_info: { source: 'url' }, + platform: 'javascript', + event_id: expect.stringMatching(/[a-f0-9]{32}/), + environment: expect.any(String), + release: expect.any(String), + sdk: { + integrations: expect.any(Array), + name: 'sentry.javascript.cloudflare', + version: expect.any(String), + packages: expect.any(Array), + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.app.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.app.json new file mode 100644 index 000000000000..7f42e5f7cd2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.json new file mode 100644 index 000000000000..ec7f8daabfda --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.worker.json" + } + ] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.node.json new file mode 100644 index 000000000000..d3c52ea64c6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.worker.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.worker.json new file mode 100644 index 000000000000..7cff07bade2f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tsconfig.worker.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["@cloudflare/workers-types", "vite/client"], + "erasableSyntaxOnly": false + }, + "include": ["./worker-configuration.d.ts", "worker"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/vite.config.ts new file mode 100644 index 000000000000..4bdcb8712920 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { cloudflare } from '@cloudflare/vite-plugin'; +import agents from 'agents/vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [agents(), react(), cloudflare()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts new file mode 100644 index 000000000000..7e95ce232f43 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker-configuration.d.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 603190983556d18d2fa32cebc898cb0b) +interface __BaseEnv_Env { + CF_VERSION_METADATA: WorkerVersionMetadata; + E2E_TEST_DSN: string; + MyAgent: DurableObjectNamespace; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import('./worker/index'); + durableNamespaces: 'MyAgent'; + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts new file mode 100644 index 000000000000..fa795b85d8f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts @@ -0,0 +1,39 @@ +import * as Sentry from '@sentry/cloudflare'; +import { routeAgentRequest, Agent, callable } from 'agents'; + +class MyBaseAgent extends Agent { + @callable() + async greet(name: string): Promise { + return `Hello, ${name}!`; + } +} + +export const MyAgent = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1, + enableRpcTracePropagation: true, + }), + MyBaseAgent, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1, + enableRpcTracePropagation: true, + }), + { + async fetch(request: Request, env: Env): Promise { + const agentResponse = await routeAgentRequest(request, env); + + if (agentResponse) { + return agentResponse; + } + + return new Response(null, { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc new file mode 100644 index 000000000000..be99e5dee5c2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/wrangler.jsonc @@ -0,0 +1,34 @@ +/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-agent", + "main": "worker/index.ts", + "compatibility_date": "2026-05-20", + + "assets": { + "not_found_handling": "single-page-application", + }, + "secrets": { + "required": ["E2E_TEST_DSN"], + }, + + "observability": { + "enabled": true, + }, + + "upload_source_maps": true, + "compatibility_flags": ["nodejs_compat", "nodejs_als"], + + "durable_objects": { + "bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }], + }, + + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }], + + "version_metadata": { + "binding": "CF_VERSION_METADATA", + }, +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 95068c7c9697..ba97fe31ee4c 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -8,18 +8,10 @@ import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; +import { extractRpcMeta } from './utils/rpcMeta'; import { getEffectiveRpcPropagation } from './utils/rpcOptions'; import { type UncheckedMethod, wrapMethodWithSentry } from './wrapMethodWithSentry'; -const BUILT_IN_DO_METHODS = new Set([ - 'constructor', - 'fetch', - 'alarm', - 'webSocketError', - 'webSocketClose', - 'webSocketMessage', -]); - /** * Instruments a Durable Object class to capture errors and performance data. * @@ -139,51 +131,70 @@ export function instrumentDurableObjectWithSentry< : undefined; const allowSet = instrumentPrototypeMethods ? new Set(instrumentPrototypeMethods) : null; - // Return a Proxy that lazily wraps prototype methods on access. - // This avoids iterating the prototype chain at construction time — - // we only check if a property is an RPC method when it's accessed. - const rpcMethodCache = new Map(); + // When using the deprecated `instrumentPrototypeMethods` option, always create spans. + // When using the new `enableRpcTracePropagation`, only create spans when RPC metadata is present. + const alwaysTrace = options.instrumentPrototypeMethods !== undefined; + + // Return a Proxy that binds all methods to the original object and creates spans + // for RPC calls that have Sentry trace context propagated. + // Binding is required because frameworks may use private fields (babel WeakMap pattern), + // which fail if `this` is the Proxy instead of the original object. + const methodCache = new Map(); return new Proxy(obj, { get(proxyTarget, prop, receiver) { const value = Reflect.get(proxyTarget, prop, receiver); - if (typeof prop !== 'string' || BUILT_IN_DO_METHODS.has(prop)) { + if (typeof prop !== 'string' || typeof value !== 'function') { return value; } - const cached = rpcMethodCache.get(prop); + const cached = methodCache.get(prop); if (cached) { return cached; } + const boundMethod = (value as UncheckedMethod).bind(proxyTarget); + if ( - typeof value !== 'function' || + prop in Object.prototype || Object.prototype.hasOwnProperty.call(proxyTarget, prop) || - (allowSet && !allowSet.has(prop)) || - // Exclude inherited Object.prototype methods (toString, valueOf, etc.) - // These are not RPC methods and should not create spans - prop in Object.prototype + (allowSet && !allowSet.has(prop)) ) { - return value; - } + methodCache.set(prop, boundMethod); - // Bind the method to the original object to ensure private fields work correctly. - // When called via the Proxy, `this` would be the Proxy, but private fields require - // the original object. Bound functions ignore the thisArg passed via Reflect.apply. - const boundValue = (value as UncheckedMethod).bind(proxyTarget); + return boundMethod; + } - const wrapped = wrapMethodWithSentry( + // Pre-create the traced version + const tracedMethod = wrapMethodWithSentry( { options, context, spanName: prop, spanOp: 'rpc' }, - boundValue, + boundMethod, undefined, true, ); - rpcMethodCache.set(prop, wrapped); + // For deprecated `instrumentPrototypeMethods`, always trace. + // For new `enableRpcTracePropagation`, only trace when RPC metadata is present. + if (alwaysTrace) { + methodCache.set(prop, tracedMethod); + + return tracedMethod; + } + + // Wrapper that checks for Sentry RPC metadata at call time + const wrappedMethod = ((...args: unknown[]) => { + const { rpcMeta } = extractRpcMeta(args); + + // If Sentry RPC metadata is present, use the traced version (creates span) + // Otherwise, call the bound method directly (no span) + return rpcMeta ? tracedMethod(...args) : boundMethod(...args); + }) as UncheckedMethod; + + methodCache.set(prop, wrappedMethod); - return wrapped; + return wrappedMethod; }, }); }, diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index dc65cb44f6cc..3f6cb884a8c5 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,6 +81,27 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); + it('Binds prototype methods to original object when enableRpcTracePropagation is true', () => { + const testClass = class { + method() { + return this; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Method should be callable and return the original object (not the proxy) + const result = obj.method(); + expect(result).not.toBe(obj); // result is original object, obj is proxy + expect(typeof result.method).toBe('function'); // original object still has method + + // Methods should be cached (same reference on repeated access) + expect(obj.method).toBe(obj.method); + }); + it('Built-in durable object methods are always instrumented', () => { const testClass = class { fetch() {} @@ -102,6 +123,36 @@ describe('instrumentDurableObjectWithSentry', () => { } }); + it('Built-in durable object methods are own properties and not wrapped as RPC', () => { + const testClass = class { + fetch() { + return new Response('fetch'); + } + + alarm() {} + + rpcMethod() { + return 'rpc'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Built-in DO methods are set as own properties (not on prototype) + // This ensures they are not wrapped as RPC methods by the Proxy + expect(Object.prototype.hasOwnProperty.call(obj, 'fetch')).toBe(true); + expect(Object.prototype.hasOwnProperty.call(obj, 'alarm')).toBe(true); + + // RPC methods remain on the prototype + expect(Object.prototype.hasOwnProperty.call(obj, 'rpcMethod')).toBe(false); + + // All methods should still work correctly + expect(obj.rpcMethod()).toBe('rpc'); + }); + it('Does not instrument RPC methods when instrumentPrototypeMethods is not set', () => { const testClass = class { rpcMethod() { @@ -164,10 +215,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(obj.methodOne).not.toBe(testClass.prototype.methodOne); expect(obj.methodThree).not.toBe(testClass.prototype.methodThree); - // methodTwo is not in the allow-list and must remain the original - // prototype method (i.e. not wrapped). - expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); - + // methodTwo is not in the allow-list — it's bound but not wrapped with Sentry tracing. // All methods should still be callable and behave correctly. expect(obj.methodOne()).toBe('one'); expect(obj.methodTwo()).toBe('two'); @@ -225,18 +273,12 @@ describe('instrumentDurableObjectWithSentry', () => { ); const obj = Reflect.construct(instrumented, []); - // Object.prototype methods should NOT be wrapped - they should be the original methods - expect(obj.toString).toBe(Object.prototype.toString); - expect(obj.valueOf).toBe(Object.prototype.valueOf); - expect(obj.hasOwnProperty).toBe(Object.prototype.hasOwnProperty); - expect(obj.propertyIsEnumerable).toBe(Object.prototype.propertyIsEnumerable); - expect(obj.isPrototypeOf).toBe(Object.prototype.isPrototypeOf); - expect(obj.toLocaleString).toBe(Object.prototype.toLocaleString); - - // They should still work correctly + // Object.prototype methods should NOT be wrapped with Sentry tracing. + // They are bound to the original object but still work correctly. expect(obj.toString()).toBe('[object Object]'); expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own - expect(obj.valueOf()).toBe(obj); + // valueOf returns the original object, not the proxy + expect(obj.valueOf()).not.toBe(obj); // Meanwhile, actual RPC methods SHOULD be wrapped (not equal to prototype method) expect(obj.rpcMethod).not.toBe(testClass.prototype.rpcMethod);