diff --git a/MIGRATION.md b/MIGRATION.md index f2af8d53b6f5..a974fd45b465 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -887,14 +887,26 @@ Sentry.init({ ); ``` -- The `instrumentPrototypeMethods` option of `instrumentDurableObjectWithSentry` was removed. Use `enableRpcTracePropagation` instead, which was introduced as its replacement in v10. +- The `enableRpcTracePropagation` option was removed. Trace context is no longer appended to every RPC call on `env`. List the bindings you call in `rpcTracePropagationBindings` instead. Strings match a binding name exactly, regular expressions match by pattern. The option covers RPC method calls only, because they carry the trace context as a trailing argument that a non-Sentry receiver would see as a real argument. `stub.fetch()` and service binding `fetch()` carry it in HTTP headers, so they propagate regardless of this option. Receivers no longer take the option at all: an instrumented Durable Object or WorkerEntrypoint reads the trace context whenever a caller sends it. + +```diff + export default Sentry.withSentry( + (env) => ({ + dsn: env.SENTRY_DSN, +- enableRpcTracePropagation: true, ++ rpcTracePropagationBindings: ['ORDERS', /^SVC_/], + }), + handler, + ); +``` + +- The `instrumentPrototypeMethods` option of `instrumentDurableObjectWithSentry` was removed. A Durable Object's prototype methods are now wrapped unconditionally, so every RPC method is instrumented and there is no longer an option to turn this on. Delete the option from your config. ```diff export const MyDO = Sentry.instrumentDurableObjectWithSentry( (env) => ({ dsn: env.SENTRY_DSN, - instrumentPrototypeMethods: true, -+ enableRpcTracePropagation: true, }), MyDOBase, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/index.ts index 86eec9128c5b..0bfbce89a969 100644 --- a/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject-scope/index.ts @@ -75,7 +75,6 @@ export const ScopeDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1, - enableRpcTracePropagation: true, }), ScopeDurableObjectBase, ); @@ -84,7 +83,7 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['SCOPE_DO'], }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts index 2f595fd137fa..4b239a05ee13 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links-sync/index.ts @@ -27,7 +27,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), SyncAlarmDurableObjectBase, ); @@ -37,7 +36,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), { async fetch(request: Request, env: Env): Promise { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts index 4de067e5c2a4..b5318b8c32a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts @@ -25,7 +25,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), AlarmDurableObjectBase, ); @@ -35,7 +34,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), { async fetch(request: Request, env: Env): Promise { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts index 0e687e8bda16..deac1c937381 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/index.ts @@ -37,7 +37,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -47,7 +46,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts index 0d7609f1c772..576d14127e13 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-rpc-private-fields/test.ts @@ -3,7 +3,7 @@ import type { Event } from '@sentry/core'; import { createRunner } from '../../../runner'; // Regression for #23040 — a Durable Object using native private fields must stay functional when -// instrumented with `enableRpcTracePropagation: true`. Native RPC dispatch (Durable Object facets, +// instrumented with Sentry. Native RPC dispatch (Durable Object facets, // the Agents SDK bootstrap) invokes prototype methods with the stored instance as the receiver, // so the instrumented instance must not be a Proxy: a Proxy does not carry the private-field // brand and `this.#field` throws "Cannot read private member". diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts index 40e9f463a2e2..c155720c3ba6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-spans/index.ts @@ -32,7 +32,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), TestDurableObjectBase, ); @@ -42,7 +41,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), { async fetch(_request: Request, env: Env): Promise { 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 df44042c0f5f..26af95ddcef1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -46,7 +46,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), TestDurableObjectBase, ); @@ -56,7 +55,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['TEST_DURABLE_OBJECT'], }), { async fetch(request: Request, env: Env): Promise { 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 4e9e65f22118..2ffa03ddca17 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -65,7 +65,7 @@ it('handles consecutive RPC calls without throwing "RPC receiver does not implem }); // Regression test: RPC methods that access private fields should work correctly. -// When enableRpcTracePropagation wraps the DO in a Proxy, calling methods through +// When rpcTracePropagationBindings 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" diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts index 85fc93e1f477..d24257f17cf2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/index.ts @@ -33,7 +33,7 @@ export default withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['ECHO_HEADERS_DO'], }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/index.ts similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/index.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/index.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts similarity index 89% rename from dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts index 6431cc07b13a..48abf6f3d609 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; -it('does not propagate trace from worker to durable object when enableRpcTracePropagation is disabled', async ({ +it('propagates trace from worker to durable object over stub.fetch() when rpcTracePropagationBindings is empty', async ({ signal, }) => { let workerTraceId: string | undefined; @@ -57,14 +57,13 @@ it('does not propagate trace from worker to durable object when enableRpcTracePr await runner.completed(); expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).not.toBe(doTraceId); + expect(doTraceId).toBe(workerTraceId); expect(workerSpanId).toBeDefined(); - expect(doParentSpanId).toBeUndefined(); + expect(doParentSpanId).toBe(workerSpanId); }); -it('does not propagate trace from queue handler to durable object when enableRpcTracePropagation is disabled', async ({ +it('propagates trace from queue handler to durable object over stub.fetch() when rpcTracePropagationBindings is empty', async ({ signal, }) => { let queueTraceId: string | undefined; @@ -139,14 +138,13 @@ it('does not propagate trace from queue handler to durable object when enableRpc await runner.completed(); expect(queueTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(queueTraceId).not.toBe(doTraceId); + expect(doTraceId).toBe(queueTraceId); expect(queueSpanId).toBeDefined(); - expect(doParentSpanId).toBeUndefined(); + expect(doParentSpanId).toBe(queueSpanId); }); -it('does not propagate trace from scheduled handler to durable object when enableRpcTracePropagation is disabled', async ({ +it('propagates trace from scheduled handler to durable object over stub.fetch() when rpcTracePropagationBindings is empty', async ({ signal, }) => { let scheduledTraceId: string | undefined; @@ -201,9 +199,8 @@ it('does not propagate trace from scheduled handler to durable object when enabl await runner.completed(); expect(scheduledTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(scheduledTraceId).not.toBe(doTraceId); + expect(doTraceId).toBe(scheduledTraceId); expect(scheduledSpanId).toBeDefined(); - expect(doParentSpanId).toBeUndefined(); + expect(doParentSpanId).toBe(scheduledSpanId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/wrangler.jsonc similarity index 100% rename from dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/wrangler.jsonc rename to dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-fetch-no-bindings/wrangler.jsonc 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 index 9871523e7bbd..75b9e36cd377 100644 --- 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 @@ -7,12 +7,8 @@ interface Env { } class MyDurableObjectBase extends DurableObject { - async fetch(request: Request): Promise { - const url = new URL(request.url); - if (url.pathname === '/hello') { - return new Response('Hello, World!'); - } - return new Response('Not found', { status: 404 }); + async sayHello(name: string): Promise { + return `Hello, ${name}!`; } } @@ -37,11 +33,14 @@ export default Sentry.withSentry( const id = env.MY_DURABLE_OBJECT.idFromName('test'); const stub = env.MY_DURABLE_OBJECT.get(id); - if (url.pathname === '/do/hello') { - // Call DO via fetch instead of RPC - const doResponse = await stub.fetch(new Request('http://do/hello')); - const text = await doResponse.text(); - return new Response(text); + if (url.pathname === '/rpc/hello') { + return new Response(await stub.sayHello('World')); + } + + // Sentinel: makes the absence of a DO transaction deterministic. It is sent after the RPC + // call, so once it arrives everything the RPC call could have produced has arrived too. + if (url.pathname === '/sentinel') { + return new Response('Sentinel'); } return new Response('Not found', { status: 404 }); 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 index 4fe2b98956d5..38e320c7a41d 100644 --- 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 @@ -2,10 +2,7 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; -it('does not propagate trace when enableRpcTracePropagation is disabled', async ({ signal }) => { - let workerTraceId: string | undefined; - let doTraceId: string | undefined; - +it('does not trace an RPC method call when rpcTracePropagationBindings is empty', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -13,54 +10,30 @@ it('does not propagate trace when enableRpcTracePropagation is disabled', async expect(transactionEvent).toEqual( expect.objectContaining({ contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), + trace: expect.objectContaining({ op: 'http.server' }), }), + transaction: 'GET /rpc/hello', }), ); - - const txName = transactionEvent.transaction as string; - const traceId = transactionEvent.contexts?.trace?.trace_id as string; - - if (txName === 'GET /do/hello') { - workerTraceId = traceId; - } else if (txName === 'GET /hello') { - doTraceId = traceId; - } }) + // Ordered: a `sayHello` transaction from the receiver would arrive here and fail this + // expectation. Without the trailing Sentry argument the receiver never traces the call. .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; expect(transactionEvent).toEqual( expect.objectContaining({ contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), + trace: expect.objectContaining({ op: 'http.server' }), }), + transaction: 'GET /sentinel', }), ); - - const txName = transactionEvent.transaction as string; - const traceId = transactionEvent.contexts?.trace?.trace_id as string; - - if (txName === 'GET /do/hello') { - workerTraceId = traceId; - } else if (txName === 'GET /hello') { - doTraceId = traceId; - } }) - .unordered() .start(signal); - const response = await runner.makeRequest('get', '/do/hello'); - expect(response).toBe('Hello, World!'); + expect(await runner.makeRequest('get', '/rpc/hello')).toBe('Hello, World!'); + expect(await runner.makeRequest('get', '/sentinel')).toBe('Sentinel'); await runner.completed(); - - // Both transactions should exist but have different trace IDs (no propagation) - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).not.toBe(doTraceId); }); 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 index 13aa8b6214b9..50bbdf5a8a2c 100644 --- 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 @@ -21,7 +21,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -31,7 +30,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts index a6c32b5a5d3e..42b5ec00a180 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do/index.ts @@ -18,7 +18,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -28,7 +27,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts index 158d32889959..93f2d260e213 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/index.ts @@ -10,7 +10,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['ANOTHER_WORKER'], }), { async fetch(request, env) { 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 index fd548a39ac0e..6979952ed448 100644 --- 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 @@ -17,7 +17,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -27,7 +26,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { 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 index 8f5d330ff71f..b5d5984c6a12 100644 --- 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 @@ -10,7 +10,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['SUB_WORKER'], }), { async fetch(request, env) { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts index 6c8638be5ad1..46508f2791be 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -46,7 +46,6 @@ export const BindingEntrypoint = Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, initialScope: { tags: { initial_scope: 'applied' } }, beforeSend(event) { event.tags = { ...event.tags, before_send: 'applied' }; @@ -57,6 +56,8 @@ export const BindingEntrypoint = Sentry.withSentry( MySubWorkerEntrypointBase, ); +// Instrumented like any other receiver. It is the caller that leaves this binding out of its +// targets, which is now the only way to opt a binding out of trace propagation. export const NoPropagationEntrypoint = Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, @@ -67,4 +68,12 @@ export const NoPropagationEntrypoint = Sentry.withSentry( MySubWorkerEntrypointBase, ); +// Deliberately not wrapped with Sentry: nothing strips a trailing RPC metadata argument here, so +// this is what a caller corrupts if it propagates to a receiver it has no guarantees about. +export class UninstrumentedEntrypoint extends WorkerEntrypoint { + get(key: string): { argumentCount: number; key: string } { + return { argumentCount: arguments.length, key }; + } +} + export default BindingEntrypoint; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts index d366e7d71afc..a34ea3edb338 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -11,6 +11,9 @@ interface Env { SUB_WORKER_NO_PROPAGATION: Fetcher & { get(key: string): Promise<{ argumentCount: number; key: string }>; }; + SUB_WORKER_UNINSTRUMENTED: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + }; } class LoopbackEntrypointBase extends WorkerEntrypoint { @@ -29,7 +32,10 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + // Targeted by binding name. Two bindings are deliberately left out: + // `SUB_WORKER_UNINSTRUMENTED`, whose receiver has no Sentry to strip a trailing metadata + // argument, and `SUB_WORKER_NO_PROPAGATION`, which covers the untargeted-binding path. + rpcTracePropagationBindings: ['SUB_WORKER'], }), { async fetch(request, env, ctx) { @@ -61,6 +67,10 @@ export default Sentry.withSentry( } } + if (url.pathname === '/call-uninstrumented-rpc') { + return Response.json(await env.SUB_WORKER_UNINSTRUMENTED.get('uninstrumented-key')); + } + if (url.pathname === '/call-entrypoint-rpc-no-propagation') { const result = await env.SUB_WORKER_NO_PROPAGATION.get('no-prop-key'); return Response.json(result); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts index 9309d0f4f97b..623ea9779fcc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -244,7 +244,24 @@ it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ sig await runner.completed(); }); -it('does not inject RPC trace metadata into receiver calls when enableRpcTracePropagation is disabled', async ({ +// Regression test for https://github.com/getsentry/sentry-javascript/issues/23233: a receiver that +// is not instrumented never strips Sentry's trailing metadata argument, so a caller must only +// propagate to bindings it was explicitly told about. +it('does not change RPC method arguments for a binding left off the allowlist', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + expect(transactionEvent.transaction).toBe('GET /call-uninstrumented-rpc'); + }) + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; key: string }>('get', '/call-uninstrumented-rpc'); + expect(response).toEqual({ argumentCount: 1, key: 'uninstrumented-key' }); + + await runner.completed(); +}); + +it('does not inject RPC trace metadata into receiver calls when rpcTracePropagationBindings is empty', async ({ signal, }) => { const runner = createRunner(__dirname) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc index badfcd962843..6327e35d9fff 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc @@ -14,5 +14,10 @@ "service": "cloudflare-worker-workerentrypoint-rpc-sub", "entrypoint": "NoPropagationEntrypoint", }, + { + "binding": "SUB_WORKER_UNINSTRUMENTED", + "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "UninstrumentedEntrypoint", + }, ], } diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts index 33889dbbb473..30190e3c0603 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/index.ts @@ -7,12 +7,8 @@ interface Env { } class MyDurableObjectBase extends DurableObject { - async fetch(request: Request): Promise { - const url = new URL(request.url); - if (url.pathname === '/hello') { - return new Response('Hello, World!'); - } - return new Response('Not found', { status: 404 }); + async sayHello(name: string): Promise { + return `Hello, ${name}!`; } } @@ -31,10 +27,14 @@ class MyWorkerEntrypointBase extends WorkerEntrypoint { const id = (this.env as Env).MY_DURABLE_OBJECT.idFromName('test'); const stub = (this.env as Env).MY_DURABLE_OBJECT.get(id); - if (url.pathname === '/do/hello') { - const doResponse = await stub.fetch(new Request('http://do/hello')); - const text = await doResponse.text(); - return new Response(text); + if (url.pathname === '/rpc/hello') { + return new Response(await stub.sayHello('World')); + } + + // Sentinel: makes the absence of a DO transaction deterministic. It is sent after the RPC + // call, so once it arrives everything the RPC call could have produced has arrived too. + if (url.pathname === '/sentinel') { + return new Response('Sentinel'); } return new Response('Not found', { status: 404 }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts index 4882f09ccaaa..f0bef1e2e402 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc-disabled/test.ts @@ -2,10 +2,9 @@ import { expect, it } from 'vitest'; import type { Event } from '@sentry/core'; import { createRunner } from '../../../../runner'; -it('does not propagate trace when enableRpcTracePropagation is disabled (WorkerEntrypoint)', async ({ signal }) => { - let workerTraceId: string | undefined; - let doTraceId: string | undefined; - +it('does not trace an RPC method call when rpcTracePropagationBindings is empty (WorkerEntrypoint)', async ({ + signal, +}) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; @@ -13,54 +12,30 @@ it('does not propagate trace when enableRpcTracePropagation is disabled (WorkerE expect(transactionEvent).toEqual( expect.objectContaining({ contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), + trace: expect.objectContaining({ op: 'http.server' }), }), + transaction: 'GET /rpc/hello', }), ); - - const txName = transactionEvent.transaction as string; - const traceId = transactionEvent.contexts?.trace?.trace_id as string; - - if (txName === 'GET /do/hello') { - workerTraceId = traceId; - } else if (txName === 'GET /hello') { - doTraceId = traceId; - } }) + // Ordered: a `sayHello` transaction from the receiver would arrive here and fail this + // expectation. Without the trailing Sentry argument the receiver never traces the call. .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as Event; expect(transactionEvent).toEqual( expect.objectContaining({ contexts: expect.objectContaining({ - trace: expect.objectContaining({ - op: 'http.server', - }), + trace: expect.objectContaining({ op: 'http.server' }), }), + transaction: 'GET /sentinel', }), ); - - const txName = transactionEvent.transaction as string; - const traceId = transactionEvent.contexts?.trace?.trace_id as string; - - if (txName === 'GET /do/hello') { - workerTraceId = traceId; - } else if (txName === 'GET /hello') { - doTraceId = traceId; - } }) - .unordered() .start(signal); - const response = await runner.makeRequest('get', '/do/hello'); - expect(response).toBe('Hello, World!'); + expect(await runner.makeRequest('get', '/rpc/hello')).toBe('Hello, World!'); + expect(await runner.makeRequest('get', '/sentinel')).toBe('Sentinel'); await runner.completed(); - - // Both transactions should exist but have different trace IDs (no propagation) - expect(workerTraceId).toBeDefined(); - expect(doTraceId).toBeDefined(); - expect(workerTraceId).not.toBe(doTraceId); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts index d742bd1b120d..5c74e8824ee3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-do-rpc/index.ts @@ -21,7 +21,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -51,7 +50,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), MyWorkerEntrypointBase, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts index 7007d35e91b3..f3576ebf5210 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index-sub-worker.ts @@ -17,7 +17,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -42,7 +41,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), MySubWorkerEntrypointBase, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts index 26e89570aaf3..654d37c411fe 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workerentrypoint-workerentrypoint-do-rpc/index.ts @@ -25,7 +25,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['SUB_WORKER'], }), MyWorkerEntrypointBase, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts index d2c4e6a03ac5..8badbd2fd65e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/workflow-do/index.ts @@ -39,7 +39,8 @@ export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + // The workflow is itself a caller: `run` reaches the Durable Object through `this.env`. + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), MyWorkflowBase, ); @@ -49,7 +50,7 @@ export default Sentry.withSentry( dsn: env.SENTRY_DSN, traceLifecycle: 'static', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_WORKFLOW'], }), { async fetch(request, env) { 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 index 53de97a457c6..66ea74f98cf3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/worker/index.ts @@ -12,7 +12,7 @@ const sentryOptions = (env: Env) => ({ dsn: env.E2E_TEST_DSN, tunnel: `http://localhost:3031/`, tracesSampleRate: 1, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MyAgent', 'MyChatAgent', 'MyManualChatAgent'], durableObjectStorageSpanAllowlist: ['cf_user_key'], }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts index 73432eb22c8b..2461cc5fd5aa 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument/src/instrument.server.ts @@ -7,7 +7,6 @@ export default (env: Env) => ({ environment: 'qa', tunnel: 'http://localhost:3031/', tracesSampleRate: 1.0, - enableRpcTracePropagation: true, transportOptions: { bufferSize: 1000, }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts index efd4b805647d..f59df9e8b33e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-streaming/src/index.ts @@ -85,7 +85,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -101,7 +100,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index bc5eec66c8b6..0887283330e8 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -85,7 +85,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -101,7 +100,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), { async fetch(request, env) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts index a5acdfdd7fee..1c82c5678dc0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/src/index.ts @@ -72,7 +72,6 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -118,7 +117,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - enableRpcTracePropagation: true, + rpcTracePropagationBindings: ['MY_DURABLE_OBJECT'], }), MyWorker, ); diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index fc606a040f1a..995bd2208a0b 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -1036,16 +1036,13 @@ Sentry.httpIntegration({ ); ``` -- The `enableRpcTracePropagation` option now defaults to `true`. Trace context is propagated across RPC calls (service bindings, Durable Objects, WorkerEntrypoints) unless you explicitly set `enableRpcTracePropagation: false`. - -- The `instrumentPrototypeMethods` option of `instrumentDurableObjectWithSentry` was removed. Use `enableRpcTracePropagation` instead, which was introduced as its replacement in v10. +- The `instrumentPrototypeMethods` option of `instrumentDurableObjectWithSentry` was removed. A Durable Object instruments its RPC methods unconditionally now, so there is nothing to replace it with on the receiver. ```diff export const MyDO = Sentry.instrumentDurableObjectWithSentry( (env) => ({ dsn: env.SENTRY_DSN, - instrumentPrototypeMethods: true, -+ enableRpcTracePropagation: true, }), MyDOBase, ); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index ba072062bf62..ea53f22b32c9 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,4 +1,4 @@ -import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core'; +import type { ClientOptions, Options, ServerRuntimeClientOptions, TracePropagationTargets } from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -323,49 +323,37 @@ interface BaseCloudflareOptions { enableOpenTelemetrySetup?: boolean; /** - * Enable trace propagation for RPC calls between Workers, Durable Objects, and Service Bindings. + * The bindings on `env` that outgoing RPC calls propagate trace context to. * - * When enabled, trace context (sentry-trace + baggage) is propagated across: - * - `stub.fetch()` calls to Durable Objects (via HTTP headers) - * - Service binding `fetch()` calls (via HTTP headers) - * - RPC method calls to Durable Objects and WorkerEntrypoints (via trailing argument) + * Strings match a binding name exactly, regular expressions match by pattern. An empty array + * (the default) propagates to nothing. * - * When enabled on the **receiver side** (DurableObject or WorkerEntrypoint), the SDK will also: - * - Extract and continue traces from incoming RPC calls - * - Create spans for each RPC method invocation - * - Capture errors thrown by RPC methods + * RPC has no headers to carry trace context, so the SDK appends it as a trailing argument to + * every RPC method call on a matching binding. Only a Sentry-instrumented receiver strips that + * argument again. Anywhere else it arrives as a real argument and changes what the method was + * called with, so list only the bindings whose receiver you know runs Sentry. * - * **Important:** This option should be enabled on **both sides** for full trace propagation. + * Propagation over `stub.fetch()` and service binding `fetch()` uses HTTP headers and is not + * affected by this option. * - * @default false + * When you build with the Sentry Cloudflare Vite plugin, bindings that resolve to *this* worker + * (its own Durable Objects, its self service bindings) are added for you, because the plugin + * instruments those receivers itself. Whatever you list here is added on top of them. + * + * @default [] * @example * ```ts - * // Worker side (caller) + * // Propagate to `env.ORDERS` and every `env.SVC_*` binding * export default Sentry.withSentry( - * (env) => ({ + * env => ({ * dsn: env.SENTRY_DSN, - * enableRpcTracePropagation: true, + * rpcTracePropagationBindings: ['ORDERS', /^SVC_/], * }), * handler, * ); - * - * // Durable Object side (receiver) - * export const MyDO = Sentry.instrumentDurableObjectWithSentry( - * (env) => ({ - * dsn: env.SENTRY_DSN, - * enableRpcTracePropagation: true, - * }), - * MyDOBase, - * ); - * - * // WorkerEntrypoint side (receiver) - * export const MyEntrypoint = Sentry.withSentry( - * env => ({ dsn: env.SENTRY_DSN, enableRpcTracePropagation: true }), - * MyEntrypointBase, - * ); * ``` */ - enableRpcTracePropagation?: boolean; + rpcTracePropagationBindings?: TracePropagationTargets; /** * Table names that should stay instrumented even though they match the reserved `cf_` prefix used diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index af8e3de9abd4..5d21dceb28b3 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -277,8 +277,7 @@ const rpcInstanceStates = new WeakMap(); * visible to Cloudflare's RPC dispatcher. Built-in handlers, Agent handlers, and methods managed by * another framework are left untouched. * - * Call this after all per-instance instrumentation has been applied. If RPC trace propagation is - * disabled, the object is returned unchanged. + * Call this after all per-instance instrumentation has been applied. * * @param obj The constructed Durable Object instance. * @param options The resolved SDK options for this instance. @@ -293,11 +292,6 @@ export function finalizeWithRpcInstrumentation( context: InstrumentedDurableObjectContext, excludedMethods?: ReadonlySet, ): T { - // Skip RPC instrumentation if not enabled - if (!options.enableRpcTracePropagation) { - return obj; - } - rpcInstanceStates.set(obj, { options, context }); instrumentPrototypeRpcMethods(obj, excludedMethods); @@ -422,7 +416,7 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked * - webSocketClose * - webSocketError * - * To instrument RPC methods (prototype methods), enable the `enableRpcTracePropagation` option. + * RPC methods (prototype methods) are instrumented too, so an incoming trace continues into them. * * @param optionsCallback Function that returns the options for the SDK initialization. * @param DurableObjectClass The Durable Object class to instrument. @@ -502,7 +496,6 @@ export function instrumentDurableObjectWithSentry< * env => ({ * dsn: env.SENTRY_DSN, * tracesSampleRate: 1.0, - * enableRpcTracePropagation: true, * }), * MyAgentBase, * ); diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index 4c29f6e9595e..321a90dd3c4e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -13,8 +13,12 @@ export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs * * @param namespace - The DurableObjectNamespace to instrument + * @param propagateRpcTrace - Whether RPC method calls on the returned stubs carry trace context */ -export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespace): DurableObjectNamespace { +export function instrumentDurableObjectNamespace( + namespace: DurableObjectNamespace, + propagateRpcTrace = false, +): DurableObjectNamespace { return new Proxy(namespace, { get(target, prop, _receiver) { const value = Reflect.get(target, prop) as unknown; @@ -27,7 +31,7 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa return function (this: unknown, ...args: unknown[]) { const stub = Reflect.apply(value, target, args); - return instrumentDurableObjectStub(stub); + return instrumentDurableObjectStub(stub, propagateRpcTrace); }; } @@ -41,8 +45,9 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa * and propagate trace context across RPC calls. * * @param stub - The DurableObjectStub to instrument + * @param propagateRpcTrace - Whether RPC method calls carry trace context */ -function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { +function instrumentDurableObjectStub(stub: DurableObjectStub, propagateRpcTrace: boolean): DurableObjectStub { return new Proxy(stub, { get(target, prop) { const value = Reflect.get(target, prop); @@ -51,7 +56,12 @@ function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub return instrumentFetcher((...args) => Reflect.apply(value, target, args)); } - if (typeof value === 'function' && typeof prop === 'string' && !STUB_NON_RPC_METHODS.has(prop)) { + if ( + propagateRpcTrace && + typeof value === 'function' && + typeof prop === 'string' && + !STUB_NON_RPC_METHODS.has(prop) + ) { return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index be82607463db..ab71092492dd 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -4,7 +4,6 @@ import type { CloudflareOptions } from '../client'; import { getFinalOptions } from '../options'; import type { DefaultEnv, ResolveEnv, StrictCloudflareOptions } from '../types'; import { instrumentContext } from '../utils/instrumentContext'; -import { extractRpcMeta } from '../utils/rpcMeta'; import { type UncheckedMethod, wrapMethodWithSentry } from '../wrapMethodWithSentry'; import { instrumentEnv } from './worker/instrumentEnv'; import { instrumentWorkerEntrypointFetch } from './worker/instrumentFetch'; @@ -87,28 +86,18 @@ function instrumentMethod( return boundMethod; } - const captureMethod = wrapMethodWithSentry( - { options, context, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, - boundMethod, - undefined, - true, - ); - - if (!options.enableRpcTracePropagation) { - return captureMethod; - } - - const tracedMethod = wrapMethodWithSentry( - { options, context, spanName: prop, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, + return wrapMethodWithSentry( + { + options, + context, + spanName: rpcMeta => (rpcMeta ? prop : undefined), + spanOp: 'rpc', + origin: WORKER_ENTRYPOINT_ORIGIN, + }, boundMethod, undefined, true, ); - - return (...args: unknown[]) => { - const { rpcMeta } = extractRpcMeta(args); - return rpcMeta ? tracedMethod.call(proxy, ...args) : captureMethod.call(proxy, ...args); - }; } /** diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 5a440503a4ee..d566c01bea7a 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -12,6 +12,7 @@ import { } from '../../utils/isBinding'; import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; +import { createRpcPropagationResolver } from '../../utils/rpcPropagation'; import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; import { instrumentQueueProducer } from './instrumentQueueProducer'; @@ -44,6 +45,8 @@ export function instrumentEnv>(env: Env, opt return env; } + const shouldPropagateRpcTrace = createRpcPropagationResolver(options); + return new Proxy(env, { get(target, prop, receiver) { const item = Reflect.get(target, prop, receiver); @@ -91,12 +94,10 @@ export function instrumentEnv>(env: Env, opt return instrumented; } - if (!options?.enableRpcTracePropagation) { - return item; - } + const propagateRpcTrace = shouldPropagateRpcTrace(String(prop)); if (isDurableObjectNamespace(item)) { - const instrumented = instrumentDurableObjectNamespace(item); + const instrumented = instrumentDurableObjectNamespace(item, propagateRpcTrace); instrumentedBindings.set(item, instrumented); return instrumented; } @@ -110,7 +111,12 @@ export function instrumentEnv>(env: Env, opt return instrumentFetcher((...args) => Reflect.apply(value, target, args)); } - if (typeof value === 'function' && typeof p === 'string' && !STUB_NON_RPC_METHODS.has(p)) { + if ( + propagateRpcTrace && + typeof value === 'function' && + typeof p === 'string' && + !STUB_NON_RPC_METHODS.has(p) + ) { return (...args: unknown[]) => Reflect.apply(value, target, appendRpcMeta(args)); } diff --git a/packages/cloudflare/src/utils/rpcPropagation.ts b/packages/cloudflare/src/utils/rpcPropagation.ts new file mode 100644 index 000000000000..66188640b81f --- /dev/null +++ b/packages/cloudflare/src/utils/rpcPropagation.ts @@ -0,0 +1,23 @@ +import { stringMatchesSomePattern } from '@sentry/core'; +import type { CloudflareOptions } from '../client'; + +const PROPAGATE_TO_NONE = () => false; + +/** + * Builds the per-binding predicate that decides whether a binding takes part in RPC trace + * propagation. + * + * Callers only. Receivers continue an incoming trace whenever one arrives, so they have nothing to + * match against. + */ +export function createRpcPropagationResolver(options: CloudflareOptions | undefined): (bindingName: string) => boolean { + const bindings = options?.rpcTracePropagationBindings; + + if (!bindings?.length) { + return PROPAGATE_TO_NONE; + } + + // Strings must match a binding name exactly, without this, an entry of `DB` would also enable + // propagation for a binding named `MY_DB`. Regular expressions still give pattern matching. + return (bindingName: string) => stringMatchesSomePattern(bindingName, bindings, true); +} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 8e6f2f44c951..95a69473f667 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -50,7 +50,11 @@ function resolveOriginalStorage( } type MethodWrapperOptions = { - spanName?: string; + /** + * The span name, or a resolver called with the RPC metadata of the current call. + * Returning `undefined` skips the span and only captures errors. + */ + spanName?: string | ((rpcMeta: SerializedTraceData | undefined) => string | undefined); spanOp?: string; options: CloudflareOptions; context: ExecutionContext | InstrumentedDurableObjectState; @@ -111,6 +115,9 @@ export function wrapMethodWithSentry( rpcMeta = extracted.rpcMeta; } + const spanName = + typeof wrapperOptions.spanName === 'function' ? wrapperOptions.spanName(rpcMeta) : wrapperOptions.spanName; + const wrappedFunction = (scope: Scope): unknown | Promise => { // In certain situations, the passed context can become undefined. // For example, for Astro while prerendering pages at build time. @@ -144,7 +151,7 @@ export function wrapMethodWithSentry( } const clientToDispose = scopeClient; - const methodName = wrapperOptions.spanName || 'unknown'; + const methodName = spanName || 'unknown'; const teardown = async (): Promise => { if (startNewTrace && storage) { @@ -176,7 +183,7 @@ export function wrapMethodWithSentry( } }; - if (!wrapperOptions.spanName) { + if (!spanName) { try { if (callback) { callback(...args); diff --git a/packages/cloudflare/test/agents.test.ts b/packages/cloudflare/test/agents.test.ts index 93c6abda81f0..c34826374147 100644 --- a/packages/cloudflare/test/agents.test.ts +++ b/packages/cloudflare/test/agents.test.ts @@ -52,10 +52,7 @@ describe('instrumentAgentWithSentry', () => { } }; - const instrumented = instrumentAgentWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentAgentWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); // Agent-specific handlers become own properties, so they are excluded from RPC method tracing. diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 353cc0e1d04a..8fbade776cb5 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -59,12 +59,10 @@ describe('instrumentDurableObjectWithSentry', () => { .fn() .mockReturnValueOnce({ orgId: 1, - enableRpcTracePropagation: true, cacheClient: false, }) .mockReturnValueOnce({ orgId: 2, - enableRpcTracePropagation: true, cacheClient: false, }); const testClass = class { @@ -123,7 +121,7 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); - it('does not create RPC spans without metadata when enableRpcTracePropagation is true', () => { + it('does not create RPC spans without metadata', () => { const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); @@ -132,28 +130,20 @@ describe('instrumentDurableObjectWithSentry', () => { return 'result'; } }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ - enableRpcTracePropagation: true, - }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); expect(obj.rpcMethod()).toBe('result'); expect(startSpanSpy).not.toHaveBeenCalled(); }); - it('Invokes prototype methods with the instance as receiver when enableRpcTracePropagation is true', () => { + it('Invokes prototype methods with the instance as receiver', () => { const testClass = class { method() { return this; } }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); // The instance is not proxied, so the receiver is the instance itself — this is what keeps @@ -285,10 +275,7 @@ describe('instrumentDurableObjectWithSentry', () => { return 'rpc'; } }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); // Built-in DO methods are set as own properties (not on prototype) @@ -309,10 +296,7 @@ describe('instrumentDurableObjectWithSentry', () => { return 'result'; } }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); // constructor must remain the original class reference for identity/type checks @@ -348,7 +332,9 @@ describe('instrumentDurableObjectWithSentry', () => { expect(getInstrumented(obj.alarm)).toBeTruthy(); }); - it('Does not instrument RPC methods when enableRpcTracePropagation is not set', () => { + // A receiver has no propagation option to switch on: it continues an incoming trace whenever + // one arrives, so its RPC methods are always instrumented. + it('Instruments RPC methods without any propagation option', () => { const testClass = class { rpcMethod() { return 'result'; @@ -357,8 +343,7 @@ describe('instrumentDurableObjectWithSentry', () => { 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(getInstrumented(obj.rpcMethod)).toBeTruthy(); expect(obj.rpcMethod()).toBe('result'); }); @@ -380,10 +365,7 @@ describe('instrumentDurableObjectWithSentry', () => { }); const originalSealedMethod = testClass.prototype.sealedMethod; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); let obj: any; expect(() => { @@ -408,10 +390,7 @@ describe('instrumentDurableObjectWithSentry', () => { // Capture the original before construction wraps the prototype const originalRpcMethod = testClass.prototype.rpcMethod; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); const obj = Reflect.construct(instrumented, []); // Object.prototype methods should NOT be wrapped with Sentry tracing. @@ -457,10 +436,7 @@ describe('instrumentDurableObjectWithSentry', () => { const originalFetchData = FrameworkLike.prototype.fetchData; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - FrameworkLike as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), FrameworkLike as any); const obj = Reflect.construct(instrumented, []) as FrameworkLike; // Left as the framework installed it, so its identity-keyed dispatch keeps resolving @@ -494,10 +470,7 @@ describe('instrumentDurableObjectWithSentry', () => { } } - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - FrameworkLike as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), FrameworkLike as any); Reflect.construct(instrumented, []); const second = Reflect.construct(instrumented, []) as FrameworkLike; @@ -516,10 +489,7 @@ describe('instrumentDurableObjectWithSentry', () => { } }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); Reflect.construct(instrumented, []); expect(testClass.prototype.rpcMethod.name).toBe('rpcMethod'); @@ -551,10 +521,7 @@ describe('instrumentDurableObjectWithSentry', () => { rpcMethod: testClass.prototype.rpcMethod, }; - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - testClass as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any); Reflect.construct(instrumented, []); expect(testClass.prototype.connect).toBe(originals.connect); @@ -584,10 +551,7 @@ describe('instrumentDurableObjectWithSentry', () => { } } - const instrumented = instrumentAgentWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - PartyServerLike as any, - ); + const instrumented = instrumentAgentWithSentry(vi.fn().mockReturnValue({}), PartyServerLike as any); const obj = Reflect.construct(instrumented, []) as PartyServerLike; // This is how native RPC invokes the method: resolved on the prototype, called with the @@ -609,10 +573,7 @@ describe('instrumentDurableObjectWithSentry', () => { } } - const instrumented = instrumentDurableObjectWithSentry( - vi.fn().mockReturnValue({ enableRpcTracePropagation: true }), - WithSecret as any, - ); + const instrumented = instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), WithSecret as any); const obj = Reflect.construct(instrumented, []) as WithSecret; const rpcMeta = { diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 67c6420147ac..05c78ae40089 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -195,7 +195,7 @@ describe('instrumentDurableObjectNamespace', () => { myRpcMethod: rpcMethod, }), }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); (stub as any).myRpcMethod('arg1', 42); @@ -221,7 +221,7 @@ describe('instrumentDurableObjectNamespace', () => { myRpcMethod: rpcMethod, }), }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); (stub as any).myRpcMethod('arg1'); @@ -229,6 +229,29 @@ describe('instrumentDurableObjectNamespace', () => { expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); + it('does not inject meta when RPC trace propagation is off for the binding', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }); + + const rpcMethod = vi.fn(); + 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); + }); + it('does not wrap built-in stub methods (connect, dup)', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': 'abc-def-1', @@ -246,7 +269,7 @@ describe('instrumentDurableObjectNamespace', () => { dup: dupFn, }), }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); const stub = instrumented.get({ toString: () => 'id', equals: () => false } as any); @@ -263,7 +286,7 @@ describe('instrumentDurableObjectNamespace', () => { ...originalNamespace, someProperty: 'value', }; - const instrumented = instrumentDurableObjectNamespace(namespace); + const instrumented = instrumentDurableObjectNamespace(namespace, true); expect((instrumented as any).someProperty).toBe('value'); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 72f9d0774507..f0a0380db3aa 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -3,9 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentEnv } from '../../src/instrumentations/worker/instrumentEnv'; vi.mock('../../src/instrumentations/instrumentDurableObjectNamespace', () => ({ - instrumentDurableObjectNamespace: vi.fn((namespace: unknown) => ({ + instrumentDurableObjectNamespace: vi.fn((namespace: unknown, propagateRpcTrace: boolean) => ({ __instrumented: true, __original: namespace, + __propagateRpcTrace: propagateRpcTrace, })), STUB_NON_RPC_METHODS: new Set(['fetch', 'connect', 'dup']), })); @@ -74,7 +75,7 @@ describe('instrumentEnv', () => { expect(instrumented.UNKNOWN).toBe(unknownBinding); }); - it('does not instrument DurableObjectNamespace when enableRpcTracePropagation is disabled', () => { + it('instruments DurableObjectNamespace bindings without RPC propagation when the allowlist is empty', () => { const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), @@ -84,24 +85,36 @@ describe('instrumentEnv', () => { const env = { COUNTER: doNamespace }; const instrumented = instrumentEnv(env); - // DO bindings pass through untouched when RPC propagation is disabled - expect(instrumented.COUNTER).toBe(doNamespace); - expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + expect((instrumented.COUNTER as any).__instrumented).toBe(true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, false); }); - it('detects and instruments DurableObjectNamespace bindings when enableRpcTracePropagation is enabled', () => { - const doNamespace = { - idFromName: vi.fn(), - idFromString: vi.fn(), - get: vi.fn(), - newUniqueId: vi.fn(), - }; - const env = { COUNTER: doNamespace }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + it('enables RPC propagation only for the DurableObjectNamespace bindings named in the allowlist', () => { + const allowed = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const denied = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const env = { COUNTER: allowed, SESSIONS: denied }; + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: ['COUNTER'] }); - const result = instrumented.COUNTER; - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); - expect((result as any).__instrumented).toBe(true); + expect((instrumented.COUNTER as any).__instrumented).toBe(true); + expect((instrumented.SESSIONS as any).__instrumented).toBe(true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(allowed, true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(denied, false); + }); + + it('matches allowlisted binding names exactly rather than as substrings', () => { + const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const env = { MY_COUNTER: doNamespace }; + instrumentEnv(env, { rpcTracePropagationBindings: ['COUNTER'] }).MY_COUNTER; + + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, false); + }); + + it('supports regular expressions in the allowlist', () => { + const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() }; + const env = { SVC_ORDERS: doNamespace }; + instrumentEnv(env, { rpcTracePropagationBindings: [/^SVC_/] }).SVC_ORDERS; + + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, true); }); it('caches instrumented bindings across repeated access', () => { @@ -112,7 +125,7 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { COUNTER: doNamespace }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); const first = instrumented.COUNTER; const second = instrumented.COUNTER; @@ -135,20 +148,20 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { COUNTER: doNamespace1, SESSIONS: doNamespace2 }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.COUNTER; instrumented.SESSIONS; expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(2); - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace1); - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace1, true); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2, true); }); - it('does not wrap JSRPC proxy when enableRpcTracePropagation is disabled', () => { - const mockFetch = vi.fn(); + it('wraps JSRPC bindings for fetch instrumentation even when the allowlist is empty', () => { + const rpcMethod = vi.fn(); const jsrpcProxy = new Proxy( - { fetch: mockFetch }, + { fetch: vi.fn(), myRpcMethod: rpcMethod }, { get(target, prop) { if (prop in target) { @@ -162,33 +175,11 @@ describe('instrumentEnv', () => { const env = { SERVICE: jsrpcProxy }; const instrumented = instrumentEnv(env); - const result = instrumented.SERVICE; - // Should be the same reference — not wrapped when propagation is disabled - expect(result).toBe(jsrpcProxy); - expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); - }); - - it('wraps JSRPC proxy with a Proxy that instruments fetch when enableRpcTracePropagation is enabled', () => { - const mockFetch = vi.fn(); - const jsrpcProxy = new Proxy( - { fetch: mockFetch }, - { - get(target, prop) { - if (prop in target) { - return Reflect.get(target, prop); - } - // JSRPC behavior: return truthy for any property - return () => {}; - }, - }, - ); - const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); - - const result = instrumented.SERVICE; - // Should NOT be the same reference — it's wrapped in a Proxy + const result = instrumented.SERVICE as { myRpcMethod: (arg: string) => void }; expect(result).not.toBe(jsrpcProxy); - expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + + result.myRpcMethod('arg1'); + expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); it('does not instrument JSRPC proxies as DurableObjectNamespace', () => { @@ -248,12 +239,12 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { MY_QUEUE: queue, COUNTER: doNamespace }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); // Access both — DO instrumentation only fires on property access expect(instrumented.MY_QUEUE).not.toBe(queue); instrumented.COUNTER; - expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); + expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace, true); }); it('wraps RateLimit bindings in a proxy and forwards calls', async () => { @@ -342,13 +333,24 @@ describe('instrumentEnv', () => { ); } - it('does not instrument mTLS Fetcher when enableRpcTracePropagation is disabled', () => { - const mockFetch = vi.fn(); + it('instruments mTLS Fetcher fetch when rpcTracePropagationBindings is empty', async () => { + vi.spyOn(SentryCore, '_INTERNAL_getTracingHeadersForFetchRequest').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }); + + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); const mtlsFetcher = createMtlsFetcherProxy(mockFetch); const env = { MY_CERT: mtlsFetcher }; const instrumented = instrumentEnv(env); - expect(instrumented.MY_CERT).toBe(mtlsFetcher); + expect(instrumented.MY_CERT).not.toBe(mtlsFetcher); + + await instrumented.MY_CERT.fetch('https://example.com/api'); + + const [, init] = mockFetch.mock.calls[0]!; + expect(new Headers(init?.headers).get('sentry-trace')).toBe( + '12345678901234567890123456789012-1234567890123456-1', + ); }); it('preserves existing headers and response on mTLS Fetcher fetch', async () => { @@ -361,7 +363,7 @@ describe('instrumentEnv', () => { const mockFetch = vi.fn().mockResolvedValue(new Response('mtls-response')); const mtlsFetcher = createMtlsFetcherProxy(mockFetch); const env = { MY_CERT: mtlsFetcher }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); const response = await instrumented.MY_CERT.fetch('https://example.com/api', { headers: { Authorization: 'Bearer client-cert-token' }, @@ -378,7 +380,7 @@ describe('instrumentEnv', () => { }); describe('JSRPC RPC method instrumentation', () => { - it('does not inject Sentry RPC meta by default (enableRpcTracePropagation not set)', () => { + it('does not inject Sentry RPC meta by default (rpcTracePropagationBindings not set)', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -401,11 +403,11 @@ describe('instrumentEnv', () => { instrumented.SERVICE.myRpcMethod('arg1', 42); - // Without enableRpcTracePropagation, no metadata should be injected + // Without rpcTracePropagationBindings, no metadata should be injected expect(rpcMethod).toHaveBeenCalledWith('arg1', 42); }); - it('injects Sentry RPC meta when enableRpcTracePropagation is true', () => { + it('injects Sentry RPC meta when rpcTracePropagationBindings matches', () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -424,7 +426,7 @@ describe('instrumentEnv', () => { }, ); const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.SERVICE.myRpcMethod('arg1', 42); @@ -455,7 +457,7 @@ describe('instrumentEnv', () => { }, ); const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.SERVICE.fetch('https://example.com'); @@ -480,11 +482,50 @@ describe('instrumentEnv', () => { }, ); const env = { SERVICE: jsrpcProxy }; - const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: [/.*/] }); instrumented.SERVICE.myRpcMethod('arg1'); expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); + + // A receiver without Sentry never strips the trailing metadata argument, so a caller has to be + // able to limit propagation to the bindings it knows are instrumented. + // See https://github.com/getsentry/sentry-javascript/issues/23233. + it('injects meta only into JSRPC calls on allowlisted bindings', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + + const allowedMethod = vi.fn(); + const deniedMethod = vi.fn(); + const createJsrpcBinding = (rpcMethod: ReturnType) => + new Proxy( + { fetch: vi.fn(), myRpcMethod: rpcMethod }, + { + get(target, prop) { + if (prop in target) { + return Reflect.get(target, prop); + } + return () => {}; + }, + }, + ); + + const env = { ORDERS: createJsrpcBinding(allowedMethod), EXTERNAL: createJsrpcBinding(deniedMethod) }; + const instrumented = instrumentEnv(env, { rpcTracePropagationBindings: ['ORDERS'] }); + + instrumented.ORDERS.myRpcMethod('first'); + instrumented.EXTERNAL.myRpcMethod('first'); + + expect(allowedMethod).toHaveBeenCalledWith('first', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + expect(deniedMethod).toHaveBeenCalledWith('first'); + }); }); }); diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 4dddbc2b23d5..e99afb986319 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -300,10 +300,7 @@ describe('instrumentWorkerEntrypoint', () => { } } const obj = Reflect.construct( - instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: true }), - TestClass as unknown as WorkerEntrypointConstructor, - ), + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), [createMockExecutionContext(), {}], ); @@ -315,7 +312,7 @@ describe('instrumentWorkerEntrypoint', () => { expect(obj.readValue(rpcMeta)).toBe('secret'); }); - it('strips RPC metadata even when trace propagation is disabled', () => { + it('strips RPC metadata without any propagation option', () => { const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; const TestClass = class extends WorkerEntrypoint { inspect(...args: unknown[]) { @@ -323,10 +320,7 @@ describe('instrumentWorkerEntrypoint', () => { } }; const obj = Reflect.construct( - instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: false }), - TestClass as unknown as WorkerEntrypointConstructor, - ), + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), [createMockExecutionContext(), {}], ); @@ -542,7 +536,7 @@ describe('instrumentWorkerEntrypoint', () => { vi.clearAllMocks(); }); - it('passes instrumented env to the constructor when enableRpcTracePropagation is enabled', () => { + it('passes instrumented env to the constructor when rpcTracePropagationBindings matches', () => { const mockContext = createMockExecutionContext(); const doNamespace = { idFromName: vi.fn(), @@ -564,7 +558,7 @@ describe('instrumentWorkerEntrypoint', () => { }; const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: true }), + () => ({ rpcTracePropagationBindings: [/.*/] }), TestClass as unknown as WorkerEntrypointConstructor, ); Reflect.construct(instrumented, [mockContext, mockEnv]); @@ -572,7 +566,7 @@ describe('instrumentWorkerEntrypoint', () => { expect(constructorEnv).not.toBe(mockEnv); }); - it('exposes instrumented DurableObjectNamespace via this.env when enableRpcTracePropagation is enabled', async () => { + it('exposes instrumented DurableObjectNamespace via this.env when rpcTracePropagationBindings matches', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -603,7 +597,7 @@ describe('instrumentWorkerEntrypoint', () => { }; const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: true }), + () => ({ rpcTracePropagationBindings: [/.*/] }), TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); @@ -617,7 +611,7 @@ describe('instrumentWorkerEntrypoint', () => { }); }); - it('returns original DurableObjectNamespace via this.env when enableRpcTracePropagation is disabled', async () => { + it('returns original DurableObjectNamespace via this.env when rpcTracePropagationBindings is empty', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -647,17 +641,14 @@ describe('instrumentWorkerEntrypoint', () => { } }; - const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: false }), - TestClass as unknown as WorkerEntrypointConstructor, - ); + const instrumented = instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); await obj.fetch(new Request('https://example.com')); expect(rpcMethod).toHaveBeenCalledWith('arg1'); }); - it('injects Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is enabled', async () => { + it('injects Sentry RPC meta into JSRPC calls via this.env when rpcTracePropagationBindings matches', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -687,7 +678,7 @@ describe('instrumentWorkerEntrypoint', () => { }; const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: true }), + () => ({ rpcTracePropagationBindings: [/.*/] }), TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); @@ -701,7 +692,7 @@ describe('instrumentWorkerEntrypoint', () => { }); }); - it('does not inject Sentry RPC meta into JSRPC calls via this.env when enableRpcTracePropagation is disabled', async () => { + it('does not inject Sentry RPC meta into JSRPC calls via this.env when rpcTracePropagationBindings is empty', async () => { vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', baggage: 'sentry-environment=production', @@ -730,10 +721,7 @@ describe('instrumentWorkerEntrypoint', () => { } }; - const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: false }), - TestClass as unknown as WorkerEntrypointConstructor, - ); + const instrumented = instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); await obj.fetch(new Request('https://example.com')); @@ -762,7 +750,7 @@ describe('instrumentWorkerEntrypoint', () => { }; const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: true }), + () => ({ rpcTracePropagationBindings: [/.*/] }), TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); @@ -789,7 +777,7 @@ describe('instrumentWorkerEntrypoint', () => { }; const instrumented = instrumentWorkerEntrypoint( - () => ({ enableRpcTracePropagation: true }), + () => ({ rpcTracePropagationBindings: [/.*/] }), TestClass as unknown as WorkerEntrypointConstructor, ); const obj = Reflect.construct(instrumented, [mockContext, mockEnv]); diff --git a/packages/cloudflare/test/options.test-d.ts b/packages/cloudflare/test/options.test-d.ts index a8ea84a92740..f93a24bb7497 100644 --- a/packages/cloudflare/test/options.test-d.ts +++ b/packages/cloudflare/test/options.test-d.ts @@ -105,7 +105,7 @@ describe('valid options keep compiling', () => { dsn: env.SENTRY_DSN, tracesSampleRate: 1, serverName: 'my-worker', - enableRpcTracePropagation: false, + rpcTracePropagationBindings: ['ORDERS', /^SVC_/], durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], beforeSend: event => event, integrations: [], diff --git a/packages/cloudflare/test/utils/rpcPropagation.test.ts b/packages/cloudflare/test/utils/rpcPropagation.test.ts new file mode 100644 index 000000000000..e8900e435457 --- /dev/null +++ b/packages/cloudflare/test/utils/rpcPropagation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { createRpcPropagationResolver } from '../../src/utils/rpcPropagation'; + +describe('createRpcPropagationResolver', () => { + it('propagates to nothing when no options are available', () => { + const shouldPropagate = createRpcPropagationResolver(undefined); + + expect(shouldPropagate('MY_DO')).toBe(false); + }); + + it('propagates to nothing when the option is unset', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: undefined }); + + expect(shouldPropagate('MY_DO')).toBe(false); + expect(shouldPropagate('EXTERNAL')).toBe(false); + }); + + it('propagates to nothing for an empty target list', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: [] }); + + expect(shouldPropagate('MY_DO')).toBe(false); + }); + + it('propagates only to the targeted binding names', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: ['MY_DO', 'EXTERNAL'] }); + + expect(shouldPropagate('MY_DO')).toBe(true); + expect(shouldPropagate('EXTERNAL')).toBe(true); + expect(shouldPropagate('OTHER')).toBe(false); + }); + + it('matches binding names exactly, never as a substring', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: ['DB'] }); + + expect(shouldPropagate('DB')).toBe(true); + expect(shouldPropagate('MY_DB')).toBe(false); + expect(shouldPropagate('DB_REPLICA')).toBe(false); + }); + + it('supports regular expressions for pattern matching', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: [/^SVC_/] }); + + expect(shouldPropagate('SVC_ORDERS')).toBe(true); + expect(shouldPropagate('SVC_USERS')).toBe(true); + expect(shouldPropagate('ORDERS')).toBe(false); + expect(shouldPropagate('PREFIXED_SVC_ORDERS')).toBe(false); + }); +});