From 6d31a3fb77edc0a87d7ca956b89f46ad848f5f1a Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 22 Apr 2026 09:56:18 +0200 Subject: [PATCH 1/4] feat(cloudflare): Add trace propagation for RPC method calls --- .../suites/tracing/durableobject/index.ts | 32 ++- .../suites/tracing/durableobject/test.ts | 37 +++ .../worker-do-rpc-disabled/index.ts | 45 ++++ .../worker-do-rpc-disabled/test.ts | 40 ++++ .../worker-do-rpc-disabled/wrangler.jsonc | 20 ++ .../propagation/worker-do-rpc/index.ts | 54 +++++ .../tracing/propagation/worker-do-rpc/test.ts | 123 ++++++++++ .../propagation/worker-do-rpc/wrangler.jsonc | 20 ++ .../worker-worker-do-rpc/index-sub-worker.ts | 45 ++++ .../propagation/worker-worker-do-rpc/index.ts | 27 +++ .../propagation/worker-worker-do-rpc/test.ts | 103 ++++++++ .../wrangler-sub-worker.jsonc | 20 ++ .../worker-worker-do-rpc/wrangler.jsonc | 12 + packages/cloudflare/src/durableobject.ts | 201 ++++++---------- .../instrumentDurableObjectNamespace.ts | 17 +- .../instrumentations/worker/instrumentEnv.ts | 13 +- packages/cloudflare/src/utils/rpcMeta.ts | 59 +++++ packages/cloudflare/src/utils/rpcOptions.ts | 36 +-- .../cloudflare/src/wrapMethodWithSentry.ts | 24 +- .../cloudflare/test/durableobject.test.ts | 224 +++++++++--------- .../instrumentDurableObjectNamespace.test.ts | 79 ++++++ .../instrumentations/instrumentEnv.test.ts | 113 +++++++++ .../cloudflare/test/utils/rpcMeta.test.ts | 157 ++++++++++++ 23 files changed, 1213 insertions(+), 288 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc create mode 100644 packages/cloudflare/src/utils/rpcMeta.ts create mode 100644 packages/cloudflare/test/utils/rpcMeta.test.ts 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 74ce2cbbdac4..272ff69f2e53 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -6,22 +6,50 @@ interface Env { TEST_DURABLE_OBJECT: DurableObjectNamespace; } +// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 +// This class mimics a real-world DO with private fields/methods and multiple public methods class TestDurableObjectBase extends DurableObject { + // Real private field for internal state (not accessed by RPC methods due to proxy limitations) + #requestCount = 0; + public constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); } - // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + // Real private method for internal use + #incrementCount(): void { + this.#requestCount++; + } + + // Internal method that uses private fields (called from non-RPC context like alarm/fetch) + getRequestCount(): number { + return this.#requestCount; + } + + // The method being called in tests via RPC async sayHello(name: string): Promise { return `Hello, ${name}`; } + + // Other public methods that are not called - should not interfere with RPC + async getStatus(): Promise { + return 'OK'; + } + + async processData(data: Record): Promise> { + return { ...data, processed: true }; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } } export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), TestDurableObjectBase, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index e86508c0f101..564c31b2743c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -25,3 +25,40 @@ it('traces a durable object method', async ({ signal }) => { await runner.makeRequest('get', '/hello'); await runner.completed(); }); + +// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 +// The RPC receiver does not implement the method error on consecutive calls +it('handles consecutive RPC calls without throwing "RPC receiver does not implement method" error', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .unordered() + .start(signal); + + // First request - this always worked + const response1 = await runner.makeRequest('get', '/hello'); + expect(response1).toBe('Hello, world'); + + // Second consecutive request - this used to fail with: + // "The RPC receiver does not implement the method 'sayHello'" + const response2 = await runner.makeRequest('get', '/hello'); + expect(response2).toBe('Hello, world'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts new file mode 100644 index 000000000000..eb21c2918155 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } +} + +// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + // enableRpcTracePropagation: false (default) + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts new file mode 100644 index 000000000000..cba40af5a43d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => { + let receivedTransactions: string[] = []; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Should only receive the worker HTTP transaction, not the DO RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + receivedTransactions.push(transactionEvent.transaction as string); + }) + .start(signal); + + // The RPC call should still work, just not be instrumented + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + // Verify we only got the worker transaction, no RPC transaction + expect(receivedTransactions).toEqual(['GET /rpc/hello']); + expect(receivedTransactions).not.toContain('sayHello'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc new file mode 100644 index 000000000000..0711a1d68d37 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-do-rpc-disabled", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts new file mode 100644 index 000000000000..8c6ab60fbdd5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/index.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async multiply(a: number, b: number): Promise { + return a * b; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + + if (url.pathname === '/rpc/hello') { + const result = await stub.sayHello('World'); + return new Response(result); + } + + if (url.pathname === '/rpc/multiply') { + const result = await stub.multiply(6, 7); + return new Response(String(result)); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts new file mode 100644 index 000000000000..f86348ab6fbc --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/test.ts @@ -0,0 +1,123 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to durable object via RPC method call', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'sayHello', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /rpc/hello', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/hello'); + expect(response).toBe('Hello, World!'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); + +it('propagates trace for RPC method with multiple arguments', async ({ signal }) => { + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + }), + }), + transaction: 'multiply', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + }), + }), + transaction: 'GET /rpc/multiply', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/multiply'); + expect(response).toBe('42'); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(workerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..3f909c489513 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts new file mode 100644 index 000000000000..f9a6fd2ed8ff --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index-sub-worker.ts @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; +import type { RpcTarget } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject implements RpcTarget { + async computeAnswer(): Promise { + return 42; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/call-do') { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const result = await stub.computeAnswer(); + return new Response(`The answer is ${result}`); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts new file mode 100644 index 000000000000..3465449ba2fe --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/index.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SUB_WORKER: Fetcher; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/call-do')); + const text = await response.text(); + return new Response(text); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts new file mode 100644 index 000000000000..a6f5818b8489 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/test.ts @@ -0,0 +1,103 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace from worker to worker to durable object (3 levels deep)', async ({ signal }) => { + let mainWorkerTraceId: string | undefined; + let mainWorkerSpanId: string | undefined; + let subWorkerTraceId: string | undefined; + let subWorkerSpanId: string | undefined; + let subWorkerParentSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Main worker HTTP server transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /chain', + }), + ); + mainWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + mainWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Sub-worker HTTP server transaction (from service binding fetch) + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-do', + }), + ); + subWorkerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + subWorkerSpanId = transactionEvent.contexts?.trace?.span_id as string; + subWorkerParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + // Durable Object RPC transaction + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.durable_object', + }), + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + transaction: 'computeAnswer', + }), + ); + doTraceId = transactionEvent.contexts?.trace?.trace_id as string; + doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/chain'); + expect(response).toBe('The answer is 42'); + + await runner.completed(); + + // All three transactions should share the same trace_id + expect(mainWorkerTraceId).toBeDefined(); + expect(subWorkerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(mainWorkerTraceId).toBe(subWorkerTraceId); + expect(subWorkerTraceId).toBe(doTraceId); + + // Verify the parent-child relationships form a chain: + // Main Worker -> Sub Worker -> DO + expect(mainWorkerSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBeDefined(); + expect(subWorkerParentSpanId).toBe(mainWorkerSpanId); + + expect(subWorkerSpanId).toBeDefined(); + expect(doParentSpanId).toBeDefined(); + expect(doParentSpanId).toBe(subWorkerSpanId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc new file mode 100644 index 000000000000..063d8e9224ad --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler-sub-worker.jsonc @@ -0,0 +1,20 @@ +{ + "name": "cloudflare-worker-worker-do-rpc-sub", + "main": "index-sub-worker.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + ], + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc new file mode 100644 index 000000000000..ddf9c607d906 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-worker-do-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-worker-worker-do-rpc", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "services": [ + { + "binding": "SUB_WORKER", + "service": "cloudflare-worker-worker-do-rpc-sub", + }, + ], +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index d21ca8d10bf1..b544c54ff408 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -3,26 +3,34 @@ import { captureException } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; -import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument'; +import { ensureInstrumented } from './instrument'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { instrumentContext } from './utils/instrumentContext'; -import { getPrototypeMethodFilter } from './utils/rpcOptions'; -import type { UncheckedMethod } from './wrapMethodWithSentry'; -import { wrapMethodWithSentry } from './wrapMethodWithSentry'; +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. * - * Instruments the following methods: + * Instruments the following methods by default: * - fetch * - alarm * - webSocketMessage * - webSocketClose * - webSocketError * - * as well as any other public RPC methods on the Durable Object instance. + * To instrument RPC methods (prototype methods), enable the `enableRpcTracePropagation` option. * * @param optionsCallback Function that returns the options for the SDK initialization. * @param DurableObjectClass The Durable Object class to instrument. @@ -116,140 +124,63 @@ export function instrumentDurableObjectWithSentry< ); } - for (const method of Object.getOwnPropertyNames(obj)) { - if ( - method === 'fetch' || - method === 'alarm' || - method === 'webSocketError' || - method === 'webSocketClose' || - method === 'webSocketMessage' - ) { - continue; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - const value = (obj as any)[method] as unknown; - if (typeof value === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - (obj as any)[method] = wrapMethodWithSentry( - { options, context, spanName: method, spanOp: 'rpc' }, - value as UncheckedMethod, - ); - } - } - - // Store context and options on the instance for prototype methods to access - Object.defineProperty(obj, '__SENTRY_CONTEXT__', { - value: context, - enumerable: false, - writable: false, - configurable: false, - }); + // Get effective RPC propagation setting (handles deprecation of instrumentPrototypeMethods) + const rpcPropagation = getEffectiveRpcPropagation(options); - Object.defineProperty(obj, '__SENTRY_OPTIONS__', { - value: options, - enumerable: false, - writable: false, - configurable: false, - }); - - const methodFilter = getPrototypeMethodFilter(options); - - if (methodFilter) { - instrumentPrototype(target, methodFilter); + // Skip RPC instrumentation if not enabled + if (!rpcPropagation) { + return obj; } - return obj; - }, - }); -} - -function instrumentPrototype(target: T, methodsToInstrument: boolean | string[]): void { - const proto = target.prototype; - - // Get all methods from the prototype chain - const methodNames = new Set(); - let current = proto; + // If `instrumentPrototypeMethods` was passed as an array (deprecated), + // only the listed method names should be instrumented. + const instrumentPrototypeMethods = Array.isArray(options.instrumentPrototypeMethods) + ? options.instrumentPrototypeMethods + : 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(); + + return new Proxy(obj, { + get(proxyTarget, prop, receiver) { + if (typeof prop !== 'string' || BUILT_IN_DO_METHODS.has(prop)) { + return Reflect.get(proxyTarget, prop, receiver); + } + + const cached = rpcMethodCache.get(prop); + + if (cached) { + return cached; + } + + const value = Reflect.get(proxyTarget, prop, receiver); + if (typeof value !== 'function') { + return value; + } + + if (Object.prototype.hasOwnProperty.call(proxyTarget, prop)) { + return value; + } + + if (allowSet && !allowSet.has(prop)) { + return value; + } + + const wrapped = wrapMethodWithSentry( + { options, context, spanName: prop, spanOp: 'rpc' }, + value as UncheckedMethod, + undefined, + true, + ); - while (current && current !== Object.prototype) { - Object.getOwnPropertyNames(current).forEach(name => { - if (name !== 'constructor' && typeof (current as Record)[name] === 'function') { - methodNames.add(name); - } - }); - current = Object.getPrototypeOf(current); - } - - // Create a set for efficient lookups when methodsToInstrument is an array - const methodsToInstrumentSet = Array.isArray(methodsToInstrument) ? new Set(methodsToInstrument) : null; - - // Instrument each method on the prototype - methodNames.forEach(methodName => { - const originalMethod = (proto as Record)[methodName]; - - if (!originalMethod) { - return; - } - - const existingInstrumented = getInstrumented(originalMethod); - if (existingInstrumented) { - Object.defineProperty(proto, methodName, { - value: existingInstrumented, - enumerable: false, - writable: true, - configurable: true, - }); - return; - } - - // If methodsToInstrument is an array, only instrument methods in that set - if (methodsToInstrumentSet && !methodsToInstrumentSet.has(methodName)) { - return; - } - - // Create a wrapper that gets context/options from the instance at runtime - const wrappedMethod = function (this: unknown, ...args: unknown[]): unknown { - const thisWithSentry = this as { - __SENTRY_CONTEXT__: DurableObjectState; - __SENTRY_OPTIONS__: CloudflareOptions; - }; - const instanceContext = thisWithSentry.__SENTRY_CONTEXT__; - const instanceOptions = thisWithSentry.__SENTRY_OPTIONS__; - - if (!instanceOptions) { - // Fallback to original method if no Sentry data found - return (originalMethod as UncheckedMethod).apply(this, args); - } + rpcMethodCache.set(prop, wrapped); - // Use the existing wrapper but with instance-specific context/options - const wrapper = wrapMethodWithSentry( - { - options: instanceOptions, - context: instanceContext, - spanName: methodName, - spanOp: 'rpc', + return wrapped; }, - originalMethod as UncheckedMethod, - undefined, - true, // noMark = true since we'll mark the prototype method - ); - - return wrapper.apply(this, args); - }; - - // Only mark wrappedMethod as instrumented (not originalMethod → wrappedMethod). - // originalMethod must stay unmapped because wrappedMethod calls - // wrapMethodWithSentry(options, originalMethod) on each invocation to create - // a per-instance proxy. If originalMethod mapped to wrappedMethod, that call - // would return wrappedMethod itself, causing infinite recursion. - markAsInstrumented(wrappedMethod); - - // Replace the prototype method - Object.defineProperty(proto, methodName, { - value: wrappedMethod, - enumerable: false, - writable: true, - configurable: true, - }); + }); + }, }); } diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index ebbabd9855ad..4c29f6e9595e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -1,6 +1,10 @@ import type { DurableObjectNamespace, DurableObjectStub } from '@cloudflare/workers-types'; +import { appendRpcMeta } from '../utils/rpcMeta'; import { instrumentFetcher } from './worker/instrumentFetcher'; +// Built-in DurableObjectStub methods that are not RPC calls. +export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); + /** * Instruments a DurableObjectNamespace binding to create spans for DO interactions. * @@ -33,17 +37,22 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa } /** - * Instruments a DurableObjectStub to propagate trace context across fetch calls. + * Instruments a DurableObjectStub to create spans for outgoing fetch calls + * and propagate trace context across RPC calls. * * @param stub - The DurableObjectStub to instrument */ function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { return new Proxy(stub, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); + get(target, prop) { + const value = Reflect.get(target, prop); if (prop === 'fetch' && typeof value === 'function') { - return instrumentFetcher((input, init) => Reflect.apply(value, target, [input, init])); + return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + } + + if (typeof value === 'function' && typeof prop === 'string' && !STUB_NON_RPC_METHODS.has(prop)) { + return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } return value; diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index fd6a3c72c097..a29bec79e2e5 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,7 +1,8 @@ import type { CloudflareOptions } from '../../client'; import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; -import { instrumentDurableObjectNamespace } from '../instrumentDurableObjectNamespace'; +import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; function isProxyable(item: unknown): item is object { @@ -58,11 +59,15 @@ export function instrumentEnv>(env: Env, opt if (isJSRPC(item)) { const instrumented = new Proxy(item, { - get(target, p, rcv) { - const value = Reflect.get(target, p, rcv); + get(target, p) { + const value = Reflect.get(target, p); if (p === 'fetch' && typeof value === 'function') { - return instrumentFetcher(value.bind(target)); + return instrumentFetcher((...args) => Reflect.apply(value, target, args)); + } + + if (typeof value === 'function' && typeof p === 'string' && !STUB_NON_RPC_METHODS.has(p)) { + return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } return value; diff --git a/packages/cloudflare/src/utils/rpcMeta.ts b/packages/cloudflare/src/utils/rpcMeta.ts new file mode 100644 index 000000000000..c17fd3c35924 --- /dev/null +++ b/packages/cloudflare/src/utils/rpcMeta.ts @@ -0,0 +1,59 @@ +import { getTraceData, type SerializedTraceData } from '@sentry/core'; + +/** + * Key used to identify Sentry RPC metadata in a trailing argument. + * This enables transparent trace propagation across Cloudflare Workers RPC + * calls (Cap'n Proto), which have no native header/metadata support. + */ +const SENTRY_RPC_META_KEY = '__sentry'; + +interface SentryRpcMeta { + __sentry: SerializedTraceData; +} + +function isSentryRpcMeta(value: unknown): value is SentryRpcMeta { + if (typeof value !== 'object' || value === null || !(SENTRY_RPC_META_KEY in value)) { + return false; + } + const sentry = (value as SentryRpcMeta).__sentry; + return typeof sentry === 'object' && sentry !== null; +} + +/** + * Appends Sentry RPC metadata to an args array for trace propagation. + * If no active trace exists, returns the original args unchanged. + */ +export function appendRpcMeta(args: unknown[]): unknown[] { + const traceData = getTraceData(); + + if (!traceData['sentry-trace']) { + return args; + } + + return [...args, { [SENTRY_RPC_META_KEY]: traceData }]; +} + +/** + * Extracts Sentry RPC metadata from the trailing argument of an args array. + * Returns cleaned args (without meta) and the extracted trace data if found. + */ +export function extractRpcMeta( + args: T, +): { + args: T; + rpcMeta?: SerializedTraceData; +} { + if (args.length === 0) { + return { args }; + } + + const last = args[args.length - 1]; + if (isSentryRpcMeta(last)) { + return { + args: args.slice(0, -1) as T, + rpcMeta: last.__sentry, + }; + } + + return { args }; +} diff --git a/packages/cloudflare/src/utils/rpcOptions.ts b/packages/cloudflare/src/utils/rpcOptions.ts index 6e71bb84a46f..5e920675d85a 100644 --- a/packages/cloudflare/src/utils/rpcOptions.ts +++ b/packages/cloudflare/src/utils/rpcOptions.ts @@ -7,7 +7,7 @@ import { DEBUG_BUILD } from '../debug-build'; * * Priority: * 1. If `enableRpcTracePropagation` is set, use it (ignore `instrumentPrototypeMethods`) - * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning (converted to boolean) + * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning * 3. If neither is set, return `false` * * @returns The effective setting for RPC trace propagation @@ -43,37 +43,3 @@ export function getEffectiveRpcPropagation(options: CloudflareOptions): boolean return false; } - -/** - * Gets the method filter for prototype method instrumentation. - * - * Returns: - * - `null` if no instrumentation should occur - * - `true` if all methods should be instrumented - * - `string[]` if only specific methods should be instrumented (deprecated behavior) - * - * @returns The method filter or null if no instrumentation - */ -export function getPrototypeMethodFilter(options: CloudflareOptions): boolean | string[] { - const { enableRpcTracePropagation, instrumentPrototypeMethods } = options; - - // If the new option is explicitly set, use it (boolean only, no filtering) - if (enableRpcTracePropagation !== undefined) { - return !!enableRpcTracePropagation; - } - - // Fall back to deprecated option - preserve array filtering behavior - if (instrumentPrototypeMethods !== undefined) { - if (instrumentPrototypeMethods === true) { - return true; - } - - if (Array.isArray(instrumentPrototypeMethods) && instrumentPrototypeMethods.length > 0) { - return instrumentPrototypeMethods; - } - - return false; - } - - return false; -} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 3a7218057c4a..dffb0338c1da 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -1,6 +1,8 @@ import type { DurableObjectStorage } from '@cloudflare/workers-types'; +import type { SerializedTraceData } from '@sentry/core'; import { captureException, + continueTrace, getClient, isThenable, type Scope, @@ -15,6 +17,7 @@ import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; +import { extractRpcMeta } from './utils/rpcMeta'; import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks'; /** Extended DurableObjectState with originalStorage exposed by instrumentContext */ @@ -64,9 +67,21 @@ export function wrapMethodWithSentry( handler, original => new Proxy(original, { - apply(target, thisArg, args: Parameters) { + apply(target, thisArg, rawArgs: Parameters) { const { startNewTrace } = wrapperOptions; + // For RPC methods, extract Sentry trace context from the trailing argument. + // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; + // we strip it here so the user's method never sees it. + let args = rawArgs; + let rpcMeta: SerializedTraceData | undefined; + + if (wrapperOptions.spanOp === 'rpc') { + const extracted = extractRpcMeta(rawArgs); + args = extracted.args; + rpcMeta = extracted.rpcMeta; + } + // For startNewTrace, always use withIsolationScope to ensure a fresh scope // Otherwise, use existing client's scope or isolation scope const currentClient = getClient(); @@ -213,6 +228,13 @@ export function wrapMethodWithSentry( }); }; + if (rpcMeta) { + return continueTrace( + { sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' }, + executeSpan, + ); + } + if (startNewTrace) { return startNewTraceCore(() => executeSpan()); } diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index efce592a6cdd..1913f787e230 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,12 +81,8 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); - it('All available durable object methods are instrumented when instrumentPrototypeMethods is enabled', () => { + it('Built-in durable object methods are always instrumented', () => { const testClass = class { - propertyFunction = vi.fn(); - - rpcMethod() {} - fetch() {} alarm() {} @@ -97,24 +93,127 @@ describe('instrumentDurableObjectWithSentry', () => { webSocketError() {} }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); - for (const method_name of [ - 'propertyFunction', - 'fetch', - 'alarm', - 'webSocketMessage', - 'webSocketClose', - 'webSocketError', - 'rpcMethod', - ]) { + + // Built-in DO methods are always instrumented + for (const method_name of ['fetch', 'alarm', 'webSocketMessage', 'webSocketClose', 'webSocketError']) { expect(getInstrumented((obj as any)[method_name]), `Method ${method_name} is instrumented`).toBeTruthy(); } }); + it('Does not instrument RPC methods when instrumentPrototypeMethods is not set', () => { + const testClass = class { + rpcMethod() { + return 'result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); + const obj = Reflect.construct(instrumented, []); + + // RPC method should not be wrapped + expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); + expect(obj.rpcMethod()).toBe('result'); + }); + + describe('instrumentPrototypeMethods option', () => { + it('instruments all RPC methods when option is true', () => { + const testClass = class { + rpcMethodOne() { + return 'one'; + } + rpcMethodTwo() { + return 'two'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // RPC methods (prototype methods) are wrapped via Proxy - verify they are callable and cached + expect(typeof obj.rpcMethodOne).toBe('function'); + expect(typeof obj.rpcMethodTwo).toBe('function'); + expect(obj.rpcMethodOne).toBe(obj.rpcMethodOne); // Cached wrapper + expect(obj.rpcMethodTwo).toBe(obj.rpcMethodTwo); // Cached wrapper + expect(obj.rpcMethodOne()).toBe('one'); + expect(obj.rpcMethodTwo()).toBe('two'); + }); + + it('instruments only specified methods when option is array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + methodThree() { + return 'three'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // methodOne and methodThree should be wrapped — i.e. they should NOT be + // identical to the underlying prototype method. + 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); + + // All methods should still be callable and behave correctly. + expect(obj.methodOne()).toBe('one'); + expect(obj.methodTwo()).toBe('two'); + expect(obj.methodThree()).toBe('three'); + }); + + it('does not instrument any RPC methods when option is empty array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: [] }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // Empty array means no methods are allowed → none should be wrapped. + expect(obj.methodOne).toBe(testClass.prototype.methodOne); + expect(obj.methodTwo).toBe(testClass.prototype.methodTwo); + expect(obj.methodOne()).toBe('one'); + expect(obj.methodTwo()).toBe('two'); + }); + + it('does not instrument RPC methods when option is false', () => { + const testClass = class { + rpcMethod() { + return 'result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, []); + + // RPC method should not be wrapped + expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); + expect(obj.rpcMethod()).toBe('result'); + }); + }); + it('flush performs after all waitUntil promises are finished', async () => { // Spy on Client.prototype.flush and mock it to resolve immediately to avoid timeout issues with fake timers const flush = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); @@ -164,93 +263,4 @@ describe('instrumentDurableObjectWithSentry', () => { // Verify that exactly one flush call was made during this test expect(delta).toBe(1); }); - - describe('instrumentPrototypeMethods option', () => { - it('does not instrument prototype methods when option is not set', () => { - const testClass = class { - prototypeMethod() { - return 'prototype-result'; - } - }; - const options = vi.fn().mockReturnValue({}); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); - }); - - it('does not instrument prototype methods when option is false', () => { - const testClass = class { - prototypeMethod() { - return 'prototype-result'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); - }); - - it('instruments all prototype methods when option is true', () => { - const testClass = class { - methodOne() { - return 'one'; - } - methodTwo() { - return 'two'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.methodOne)).toBeTruthy(); - expect(getInstrumented(obj.methodTwo)).toBeTruthy(); - }); - - it('instruments only specified methods when option is array', () => { - const testClass = class { - methodOne() { - return 'one'; - } - methodTwo() { - return 'two'; - } - methodThree() { - return 'three'; - } - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - expect(getInstrumented(obj.methodOne)).toBeTruthy(); - expect(getInstrumented(obj.methodTwo)).toBeFalsy(); - expect(getInstrumented(obj.methodThree)).toBeTruthy(); - }); - - it('still instruments instance methods regardless of prototype option', () => { - const testClass = class { - propertyFunction = vi.fn(); - - fetch() {} - alarm() {} - webSocketMessage() {} - webSocketClose() {} - webSocketError() {} - }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); - const obj = Reflect.construct(instrumented, []); - - // Instance methods should still be instrumented - expect(getInstrumented(obj.propertyFunction)).toBeTruthy(); - expect(getInstrumented(obj.fetch)).toBeTruthy(); - expect(getInstrumented(obj.alarm)).toBeTruthy(); - expect(getInstrumented(obj.webSocketMessage)).toBeTruthy(); - expect(getInstrumented(obj.webSocketClose)).toBeTruthy(); - expect(getInstrumented(obj.webSocketError)).toBeTruthy(); - }); - }); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 1b29d5062ce2..a0a19a45304e 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -1,3 +1,4 @@ +import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; @@ -177,6 +178,84 @@ describe('instrumentDurableObjectNamespace', () => { }); }); + describe('RPC method instrumentation', () => { + it('injects Sentry RPC meta into RPC method calls', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('rpc-result'); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject meta when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + + it('does not wrap built-in stub methods (connect, dup)', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + }); + + const connectFn = vi.fn(); + const dupFn = vi.fn(); + const { namespace: originalNamespace } = createMockNamespace(); + const namespace = { + ...originalNamespace, + get: vi.fn().mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + connect: connectFn, + dup: dupFn, + }), + }; + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); + + // connect and dup should be the original functions, not wrapped + expect((stub as any).connect).toBe(connectFn); + expect((stub as any).dup).toBe(dupFn); + }); + }); + describe('non-function properties', () => { it('returns non-function properties unchanged', () => { const { namespace: originalNamespace } = createMockNamespace(); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index c127406b8c7e..328e7377ed82 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -1,3 +1,4 @@ +import * as SentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; @@ -6,6 +7,7 @@ vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ __instrumented: true, __original: namespace, })), + STUB_NON_RPC_METHODS: new Set(['fetch', 'connect', 'dup']), })); import { instrumentDurableObjectNamespace } from '../../src/instrumentations/instrumentDurableObjectNamespace'; @@ -173,4 +175,115 @@ describe('instrumentEnv', () => { expect(instrumented.NULL_VAL).toBeNull(); expect(instrumented.UNDEF_VAL).toBeUndefined(); }); + + describe('JSRPC RPC method instrumentation', () => { + it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env); + + (instrumented.SERVICE as any).myRpcMethod('arg1', 42); + + // Without enableRpcTracePropagation, no metadata should be injected + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); + }); + + it('injects Sentry RPC meta when enableRpcTracePropagation is true', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).myRpcMethod('arg1', 42); + + expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { + __sentry: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not inject meta into JSRPC fetch calls', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + baggage: 'sentry-baggage=value', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + const jsrpcProxy = new Proxy( + { fetch: mockFetch }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).fetch('https://example.com'); + + // fetch should use HTTP header injection, not trailing arg + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs).not.toContainEqual(expect.objectContaining({ __sentry: expect.anything() })); + }); + + it('does not inject meta into JSRPC RPC calls when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const rpcMethod = vi.fn().mockReturnValue('result'); + const jsrpcProxy = new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + const env = { SERVICE: jsrpcProxy }; + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + + (instrumented.SERVICE as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + }); }); diff --git a/packages/cloudflare/test/utils/rpcMeta.test.ts b/packages/cloudflare/test/utils/rpcMeta.test.ts new file mode 100644 index 000000000000..7ecbd4256705 --- /dev/null +++ b/packages/cloudflare/test/utils/rpcMeta.test.ts @@ -0,0 +1,157 @@ +import * as SentryCore from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { appendRpcMeta, extractRpcMeta } from '../../src/utils/rpcMeta'; + +describe('rpcMeta', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('appendRpcMeta', () => { + it('appends meta with trace data when active trace exists', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const result = appendRpcMeta(['arg1', 42]); + + expect(result).toEqual([ + 'arg1', + 42, + { + __sentry: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + ]); + }); + + it('returns original args when no active trace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({}); + + const args = ['arg1', 'arg2']; + const result = appendRpcMeta(args); + + expect(result).toBe(args); + }); + + it('returns original args when sentry-trace is empty', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '' }); + + const args = ['arg1']; + const result = appendRpcMeta(args); + + expect(result).toBe(args); + }); + + it('appends meta to empty args', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + baggage: 'sentry-sample_rate=1.0', + }); + + const result = appendRpcMeta([]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + __sentry: { 'sentry-trace': 'abc-def-1', baggage: 'sentry-sample_rate=1.0' }, + }); + }); + + it('does not mutate original args array', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': 'abc-def-1', + }); + + const args = ['arg1']; + appendRpcMeta(args); + + expect(args).toEqual(['arg1']); + }); + }); + + describe('extractRpcMeta', () => { + it('extracts meta from trailing argument', () => { + const args = [ + 'arg1', + 42, + { + __sentry: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }, + ]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(['arg1', 42]); + expect(result.rpcMeta).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + }); + + it('returns original args when no meta present', () => { + const args = ['arg1', { someKey: 'value' }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(['arg1', { someKey: 'value' }]); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('returns empty args unchanged', () => { + const result = extractRpcMeta([]); + + expect(result.args).toEqual([]); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('does not extract if __sentry value is not an object', () => { + const args = ['arg1', { __sentry: 'not-an-object' }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(args); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('does not extract if __sentry value is null', () => { + const args = ['arg1', { __sentry: null }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual(args); + expect(result.rpcMeta).toBeUndefined(); + }); + + it('handles meta with only trace (no baggage)', () => { + const args = [{ __sentry: { 'sentry-trace': 'abc-def-1' } }]; + + const result = extractRpcMeta(args); + + expect(result.args).toEqual([]); + expect(result.rpcMeta).toEqual({ 'sentry-trace': 'abc-def-1' }); + }); + + it('round-trips with appendRpcMeta', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const originalArgs = ['hello', { data: true }, 42]; + const withMeta = appendRpcMeta(originalArgs); + const { args, rpcMeta } = extractRpcMeta(withMeta); + + expect(args).toEqual(originalArgs); + expect(rpcMeta).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + }); + }); +}); From f76e06ca491e59d4d0943ee432b091f8cea01a23 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 24 Apr 2026 10:53:50 +0200 Subject: [PATCH 2/4] fix: Do not instrument prototype functions --- packages/cloudflare/src/durableobject.ts | 16 +++++----- .../cloudflare/test/durableobject.test.ts | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index b544c54ff408..e2ce943503b9 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -157,15 +157,15 @@ export function instrumentDurableObjectWithSentry< } const value = Reflect.get(proxyTarget, prop, receiver); - if (typeof value !== 'function') { - return value; - } - - if (Object.prototype.hasOwnProperty.call(proxyTarget, prop)) { - return value; - } - if (allowSet && !allowSet.has(prop)) { + if ( + typeof value !== 'function' || + 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 + ) { return value; } diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 1913f787e230..dc65cb44f6cc 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -212,6 +212,36 @@ describe('instrumentDurableObjectWithSentry', () => { expect(getInstrumented(obj.rpcMethod)).toBeFalsy(); expect(obj.rpcMethod()).toBe('result'); }); + + it('does not wrap Object.prototype methods as RPC methods', () => { + const testClass = class { + rpcMethod() { + return 'rpc-result'; + } + }; + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), + testClass as any, + ); + 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 + expect(obj.toString()).toBe('[object Object]'); + expect(obj.hasOwnProperty('rpcMethod')).toBe(false); // It's on prototype, not own + expect(obj.valueOf()).toBe(obj); + + // Meanwhile, actual RPC methods SHOULD be wrapped (not equal to prototype method) + expect(obj.rpcMethod).not.toBe(testClass.prototype.rpcMethod); + expect(obj.rpcMethod()).toBe('rpc-result'); + }); }); it('flush performs after all waitUntil promises are finished', async () => { From 7c90dce07008945466a707f8f8f9fd43f700ac10 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 27 Apr 2026 09:16:53 +0200 Subject: [PATCH 3/4] fixup! feat(cloudflare): Add trace propagation for RPC method calls --- packages/cloudflare/src/durableobject.ts | 6 +++--- packages/cloudflare/src/utils/rpcMeta.ts | 8 ++++---- .../instrumentDurableObjectNamespace.test.ts | 2 +- .../test/instrumentations/instrumentEnv.test.ts | 2 +- packages/cloudflare/test/utils/rpcMeta.test.ts | 16 ++++++++-------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e2ce943503b9..f1dfed8af2bf 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -146,8 +146,10 @@ export function instrumentDurableObjectWithSentry< 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)) { - return Reflect.get(proxyTarget, prop, receiver); + return value; } const cached = rpcMethodCache.get(prop); @@ -156,8 +158,6 @@ export function instrumentDurableObjectWithSentry< return cached; } - const value = Reflect.get(proxyTarget, prop, receiver); - if ( typeof value !== 'function' || Object.prototype.hasOwnProperty.call(proxyTarget, prop) || diff --git a/packages/cloudflare/src/utils/rpcMeta.ts b/packages/cloudflare/src/utils/rpcMeta.ts index c17fd3c35924..9389a221230c 100644 --- a/packages/cloudflare/src/utils/rpcMeta.ts +++ b/packages/cloudflare/src/utils/rpcMeta.ts @@ -5,17 +5,17 @@ import { getTraceData, type SerializedTraceData } from '@sentry/core'; * This enables transparent trace propagation across Cloudflare Workers RPC * calls (Cap'n Proto), which have no native header/metadata support. */ -const SENTRY_RPC_META_KEY = '__sentry'; +const SENTRY_RPC_META_KEY = '__sentry_rpc_meta__'; interface SentryRpcMeta { - __sentry: SerializedTraceData; + __sentry_rpc_meta__: SerializedTraceData; } function isSentryRpcMeta(value: unknown): value is SentryRpcMeta { if (typeof value !== 'object' || value === null || !(SENTRY_RPC_META_KEY in value)) { return false; } - const sentry = (value as SentryRpcMeta).__sentry; + const sentry = (value as SentryRpcMeta).__sentry_rpc_meta__; return typeof sentry === 'object' && sentry !== null; } @@ -51,7 +51,7 @@ export function extractRpcMeta( if (isSentryRpcMeta(last)) { return { args: args.slice(0, -1) as T, - rpcMeta: last.__sentry, + rpcMeta: last.__sentry_rpc_meta__, }; } diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index a0a19a45304e..67c6420147ac 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -201,7 +201,7 @@ describe('instrumentDurableObjectNamespace', () => { (stub as any).myRpcMethod('arg1', 42); expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { - __sentry: { + __sentry_rpc_meta__: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', }, diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 328e7377ed82..ab115317b7b0 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -228,7 +228,7 @@ describe('instrumentEnv', () => { (instrumented.SERVICE as any).myRpcMethod('arg1', 42); expect(rpcMethod).toHaveBeenCalledWith('arg1', 42, { - __sentry: { + __sentry_rpc_meta__: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', }, diff --git a/packages/cloudflare/test/utils/rpcMeta.test.ts b/packages/cloudflare/test/utils/rpcMeta.test.ts index 7ecbd4256705..38af0de23115 100644 --- a/packages/cloudflare/test/utils/rpcMeta.test.ts +++ b/packages/cloudflare/test/utils/rpcMeta.test.ts @@ -20,7 +20,7 @@ describe('rpcMeta', () => { 'arg1', 42, { - __sentry: { + __sentry_rpc_meta__: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', }, @@ -56,7 +56,7 @@ describe('rpcMeta', () => { expect(result).toHaveLength(1); expect(result[0]).toEqual({ - __sentry: { 'sentry-trace': 'abc-def-1', baggage: 'sentry-sample_rate=1.0' }, + __sentry_rpc_meta__: { 'sentry-trace': 'abc-def-1', baggage: 'sentry-sample_rate=1.0' }, }); }); @@ -78,7 +78,7 @@ describe('rpcMeta', () => { 'arg1', 42, { - __sentry: { + __sentry_rpc_meta__: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', }, @@ -110,8 +110,8 @@ describe('rpcMeta', () => { expect(result.rpcMeta).toBeUndefined(); }); - it('does not extract if __sentry value is not an object', () => { - const args = ['arg1', { __sentry: 'not-an-object' }]; + it('does not extract if __sentry_rpc_meta__ value is not an object', () => { + const args = ['arg1', { __sentry_rpc_meta__: 'not-an-object' }]; const result = extractRpcMeta(args); @@ -119,8 +119,8 @@ describe('rpcMeta', () => { expect(result.rpcMeta).toBeUndefined(); }); - it('does not extract if __sentry value is null', () => { - const args = ['arg1', { __sentry: null }]; + it('does not extract if __sentry_rpc_meta__ value is null', () => { + const args = ['arg1', { __sentry_rpc_meta__: null }]; const result = extractRpcMeta(args); @@ -129,7 +129,7 @@ describe('rpcMeta', () => { }); it('handles meta with only trace (no baggage)', () => { - const args = [{ __sentry: { 'sentry-trace': 'abc-def-1' } }]; + const args = [{ __sentry_rpc_meta__: { 'sentry-trace': 'abc-def-1' } }]; const result = extractRpcMeta(args); From 759c7f66e967bd7ec8765ad711abd5bca2294323 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Mon, 27 Apr 2026 10:08:39 +0200 Subject: [PATCH 4/4] fix: Allow private fields to be called --- .../suites/tracing/durableobject/index.ts | 29 ++++++++------- .../suites/tracing/durableobject/test.ts | 35 +++++++++++++++++++ packages/cloudflare/src/durableobject.ts | 7 +++- 3 files changed, 57 insertions(+), 14 deletions(-) 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 272ff69f2e53..659b04a3f488 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -9,26 +9,22 @@ interface Env { // Regression test for https://github.com/getsentry/sentry-javascript/issues/17127 // This class mimics a real-world DO with private fields/methods and multiple public methods class TestDurableObjectBase extends DurableObject { - // Real private field for internal state (not accessed by RPC methods due to proxy limitations) - #requestCount = 0; + // Private field used by RPC methods - tests that private fields work with instrumentation + #greeting = 'Hello'; public constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); } - // Real private method for internal use - #incrementCount(): void { - this.#requestCount++; - } - - // Internal method that uses private fields (called from non-RPC context like alarm/fetch) - getRequestCount(): number { - return this.#requestCount; + // RPC method that uses a private field - this would throw TypeError if the Proxy + // doesn't correctly bind `this` to the original object + async sayHello(name: string): Promise { + return `${this.#greeting}, ${name}`; } - // The method being called in tests via RPC - async sayHello(name: string): Promise { - return `Hello, ${name}`; + // RPC method that modifies a private field + async setGreeting(greeting: string): Promise { + this.#greeting = greeting; } // Other public methods that are not called - should not interfere with RPC @@ -64,6 +60,13 @@ export default { 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'); }, }; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index 564c31b2743c..4e9e65f22118 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,4 +1,5 @@ import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; it('traces a durable object method', async ({ signal }) => { @@ -62,3 +63,37 @@ it('handles consecutive RPC calls without throwing "RPC receiver does not implem await runner.completed(); }); + +// Regression test: RPC methods that access private fields should work correctly. +// When enableRpcTracePropagation wraps the DO in a Proxy, calling methods through +// the Proxy must ensure `this` refers to the original object (not the Proxy), +// otherwise private field access throws: "Cannot read private member from an object +// whose class did not declare it" +it('allows RPC methods to access private class fields', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'setGreeting', + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'sayHello', + }), + ); + }) + .unordered() + .start(signal); + + // This calls setGreeting (writes private field) then sayHello (reads private field) + // Would throw TypeError if `this` is the Proxy instead of the original object + const response = await runner.makeRequest('get', '/custom-greeting'); + expect(response).toBe('Howdy, partner'); + + await runner.completed(); +}); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index f1dfed8af2bf..95068c7c9697 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -169,9 +169,14 @@ export function instrumentDurableObjectWithSentry< return value; } + // 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); + const wrapped = wrapMethodWithSentry( { options, context, spanName: prop, spanOp: 'rpc' }, - value as UncheckedMethod, + boundValue, undefined, true, );