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 6480d9e83770..cbb6de4f514c 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 @@ -31,6 +31,7 @@ export default withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), { 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/no-propagation-worker-do/index.ts new file mode 100644 index 000000000000..6ec278fc9ace --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + MY_QUEUE: Queue; +} + +class MyDurableObjectBase extends DurableObject { + async fetch(_request: Request) { + return new Response('DO is fine'); + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/queue/send') { + await env.MY_QUEUE.send({ action: 'test' }); + return new Response('Queued'); + } + + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + const response = await stub.fetch(new Request('http://fake-host/hello')); + const text = await response.text(); + return new Response(text); + }, + + async queue(batch, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + for (const message of batch.messages) { + await stub.fetch(new Request('http://fake-host/hello')); + message.ack(); + } + }, + + async scheduled(controller, env, _ctx) { + const id = env.MY_DURABLE_OBJECT.idFromName('test'); + const stub = env.MY_DURABLE_OBJECT.get(id); + await stub.fetch(new Request('http://fake-host/hello')); + }, + } satisfies ExportedHandler, +); 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/no-propagation-worker-do/test.ts new file mode 100644 index 000000000000..287d62b25c1f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/test.ts @@ -0,0 +1,209 @@ +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 ({ + 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: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + 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 /', + }), + ); + workerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + workerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(workerTraceId).not.toBe(doTraceId); + + expect(workerSpanId).toBeDefined(); + expect(doParentSpanId).toBeUndefined(); +}); + +it('does not propagate trace from queue handler to durable object when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + let queueTraceId: string | undefined; + let queueSpanId: 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: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /hello', + }), + ); + 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: 'queue.process', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.queue', + }), + origin: 'auto.faas.cloudflare.queue', + }), + }), + transaction: 'process my-queue', + }), + ); + queueTraceId = transactionEvent.contexts?.trace?.trace_id as string; + queueSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + // Also expect the fetch transaction from the /queue/send request + .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 /queue/send', + }), + ); + }) + .unordered() + .start(signal); + // The fetch handler sends a message to the queue, which triggers the queue consumer + await runner.makeRequest('get', '/queue/send'); + await runner.completed(); + + expect(queueTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(queueTraceId).not.toBe(doTraceId); + + expect(queueSpanId).toBeDefined(); + expect(doParentSpanId).toBeUndefined(); +}); + +it('does not propagate trace from scheduled handler to durable object when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + let scheduledTraceId: string | undefined; + let scheduledSpanId: string | undefined; + let doTraceId: string | undefined; + let doParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .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 /hello', + }), + ); + 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: 'faas.cron', + data: expect.objectContaining({ + 'sentry.origin': 'auto.faas.cloudflare.scheduled', + }), + origin: 'auto.faas.cloudflare.scheduled', + }), + }), + }), + ); + scheduledTraceId = transactionEvent.contexts?.trace?.trace_id as string; + scheduledSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .unordered() + .start(signal); + await runner.makeRequest('get', '/__scheduled?cron=*+*+*+*+*'); + await runner.completed(); + + expect(scheduledTraceId).toBeDefined(); + expect(doTraceId).toBeDefined(); + expect(scheduledTraceId).not.toBe(doTraceId); + + expect(scheduledSpanId).toBeDefined(); + expect(doParentSpanId).toBeUndefined(); +}); 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/no-propagation-worker-do/wrangler.jsonc new file mode 100644 index 000000000000..b6dc58439427 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/no-propagation-worker-do/wrangler.jsonc @@ -0,0 +1,39 @@ +{ + "name": "cloudflare-durable-objects", + "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", + }, + ], + }, + "queues": { + "producers": [ + { + "binding": "MY_QUEUE", + "queue": "my-queue", + }, + ], + "consumers": [ + { + "queue": "my-queue", + }, + ], + }, + "triggers": { + "crons": ["* * * * *"], + }, + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552", + }, +} 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 6ec278fc9ace..32464998b413 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 @@ -17,6 +17,7 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -25,6 +26,7 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), { 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 dc178759f51d..a049a1c796b3 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 @@ -9,6 +9,7 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), { async fetch(request, env) { 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 0ffd77866e1f..3eb2eb4331ed 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 @@ -37,6 +37,7 @@ export const MyWorkflow = Sentry.instrumentWorkflowWithSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), MyWorkflowBase, ); @@ -45,6 +46,7 @@ export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, + enableRpcTracePropagation: true, }), { 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 b76eb516e221..6828f6ee02d6 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,7 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -101,6 +101,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, + enableRpcTracePropagation: true, }), { 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 9c0159b26327..c82f324e3c75 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,7 @@ export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, - instrumentPrototypeMethods: true, + enableRpcTracePropagation: true, }), MyDurableObjectBase, ); @@ -118,6 +118,7 @@ export default Sentry.withSentry( // We are doing a lot of events at once in this test bufferSize: 1000, }, + enableRpcTracePropagation: true, }), MyWorker, ); diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 957cb7274d8e..050d89f39dbd 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -161,26 +161,56 @@ interface BaseCloudflareOptions { skipOpenTelemetrySetup?: boolean; /** - * Enable instrumentation of prototype methods for DurableObjects. + * Enable trace propagation for RPC calls between Workers, Durable Objects, and Service Bindings. * - * When `true`, the SDK will wrap all methods on the DurableObject prototype chain - * to automatically create spans and capture errors for RPC method calls. + * 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 (via trailing argument) * - * When an array of strings is provided, only the specified method names will be instrumented. + * When enabled on the **receiver side** (DurableObject), 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 * - * This feature adds runtime overhead as it wraps methods at the prototype level. - * Only enable this if you need automatic instrumentation of prototype methods. + * **Important:** This option should be enabled on **both sides** for full trace propagation. * * @default false * @example * ```ts - * // Instrument all prototype methods - * instrumentPrototypeMethods: true + * // Worker side (caller) + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * enableRpcTracePropagation: true, + * }), + * handler, + * ); * - * // Instrument only specific methods - * instrumentPrototypeMethods: ['myMethod', 'anotherMethod'] + * // Durable Object side (receiver) + * export const MyDO = Sentry.instrumentDurableObjectWithSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * enableRpcTracePropagation: true, + * }), + * MyDOBase, + * ); * ``` */ + enableRpcTracePropagation?: boolean; + + /** + * @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version. + * + * Enable instrumentation of prototype methods for DurableObjects. + * + * When `true`, the SDK will wrap all methods on the DurableObject prototype chain + * to automatically create spans and capture errors for RPC method calls. + * + * When an array of strings is provided, only the specified method names will be instrumented. + * + * @default false + */ instrumentPrototypeMethods?: boolean | string[]; } diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 4dbe7ea3d2f0..d21ca8d10bf1 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -8,6 +8,7 @@ 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'; @@ -54,7 +55,7 @@ export function instrumentDurableObjectWithSentry< setAsyncLocalStorageAsyncContextStrategy(); const context = instrumentContext(ctx); const options = getFinalOptions(optionsCallback(env), env); - const instrumentedEnv = instrumentEnv(env); + const instrumentedEnv = instrumentEnv(env, options); const obj = new target(context, instrumentedEnv); @@ -152,8 +153,10 @@ export function instrumentDurableObjectWithSentry< configurable: false, }); - if (options?.instrumentPrototypeMethods) { - instrumentPrototype(target, options.instrumentPrototypeMethods); + const methodFilter = getPrototypeMethodFilter(options); + + if (methodFilter) { + instrumentPrototype(target, methodFilter); } return obj; diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index 886b14a6ab5c..ebbabd9855ad 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -7,6 +7,8 @@ import { instrumentFetcher } from './worker/instrumentFetcher'; * Wraps: * - `namespace.get(id)` / `namespace.getByName(name)` with a span + instruments returned stub * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs + * + * @param namespace - The DurableObjectNamespace to instrument */ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespace): DurableObjectNamespace { return new Proxy(namespace, { @@ -31,7 +33,9 @@ export function instrumentDurableObjectNamespace(namespace: DurableObjectNamespa } /** - * Instruments a DurableObjectStub to create spans for outgoing fetch calls. + * Instruments a DurableObjectStub to propagate trace context across fetch calls. + * + * @param stub - The DurableObjectStub to instrument */ function instrumentDurableObjectStub(stub: DurableObjectStub): DurableObjectStub { return new Proxy(stub, { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index a557bdcb164d..ee4a4c73b69f 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -76,10 +76,9 @@ export function instrumentExportedHandlerEmail>) { const [emailMessage, env, ctx] = args; const context = instrumentContext(ctx); - args[1] = instrumentEnv(env); - args[2] = context; - const options = getFinalOptions(optionsCallback(env), env); + args[1] = instrumentEnv(env, options); + args[2] = context; return wrapEmailHandler(emailMessage, options, context, () => target.apply(thisArg, args)); }, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 6a2e83214093..fd6a3c72c097 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,4 +1,6 @@ +import type { CloudflareOptions } from '../../client'; import { isDurableObjectNamespace, isJSRPC } from '../../utils/isBinding'; +import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; import { instrumentDurableObjectNamespace } from '../instrumentDurableObjectNamespace'; import { instrumentFetcher } from './instrumentFetcher'; @@ -17,12 +19,23 @@ const instrumentedBindings = new WeakMap(); * - Service bindings / JSRPC proxies (wraps `fetch` for trace propagation) * * Extensible for future binding types (KV, D1, Queue, etc.). + * + * @param env - The Cloudflare env object to instrument + * @param options - Optional CloudflareOptions to control RPC trace propagation */ -export function instrumentEnv>(env: Env): Env { +export function instrumentEnv>(env: Env, options?: CloudflareOptions): Env { if (!env || typeof env !== 'object') { return env; } + const rpcPropagation = options ? getEffectiveRpcPropagation(options) : false; + + // As of now only trace propagation is used for the instrumentEnv + // so this is an optimization to avoid wrapping the env in a proxy if trace propagation is disabled + if (!rpcPropagation) { + return env; + } + return new Proxy(env, { get(target, prop, receiver) { const item = Reflect.get(target, prop, receiver); diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts index a7904f5177da..2673b86d6a97 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentFetch.ts @@ -26,10 +26,9 @@ export function instrumentExportedHandlerFetch>) { const [request, env, ctx] = args; const context = instrumentContext(ctx); - args[1] = instrumentEnv(env); - args[2] = context; - const options = getFinalOptions(optionsCallback(env), env); + args[1] = instrumentEnv(env, options); + args[2] = context; return wrapRequestHandler({ options, request, context }, () => target.apply(thisArg, args)); }, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index 0f7df2e27adc..c57b7abd8aaa 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -82,10 +82,9 @@ export function instrumentExportedHandlerQueue>) { const [batch, env, ctx] = args; const context = instrumentContext(ctx); - args[1] = instrumentEnv(env); - args[2] = context; - const options = getFinalOptions(optionsCallback(env), env); + args[1] = instrumentEnv(env, options); + args[2] = context; return wrapQueueHandler(batch, options, context, () => target.apply(thisArg, args)); }, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 455acab2b0dd..94f57208f0fb 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -75,10 +75,9 @@ export function instrumentExportedHandlerScheduled>) { const [controller, env, ctx] = args; const context = instrumentContext(ctx); - args[1] = instrumentEnv(env); - args[2] = context; - const options = getFinalOptions(optionsCallback(env), env); + args[1] = instrumentEnv(env, options); + args[2] = context; return wrapScheduledHandler(controller, options, context, () => target.apply(thisArg, args)); }, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts index 283a238053c0..9b1dcc2d22a0 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentTail.ts @@ -53,10 +53,9 @@ export function instrumentExportedHandlerTail>) { const [, env, ctx] = args; const context = instrumentContext(ctx); - args[1] = instrumentEnv(env); - args[2] = context; - const options = getFinalOptions(optionsCallback(env), env); + args[1] = instrumentEnv(env, options); + args[2] = context; return wrapTailHandler(options, context, () => target.apply(thisArg, args)); }, diff --git a/packages/cloudflare/src/utils/rpcOptions.ts b/packages/cloudflare/src/utils/rpcOptions.ts new file mode 100644 index 000000000000..6e71bb84a46f --- /dev/null +++ b/packages/cloudflare/src/utils/rpcOptions.ts @@ -0,0 +1,79 @@ +import { debug } from '@sentry/core'; +import type { CloudflareOptions } from '../client'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * Gets the effective RPC propagation setting, handling deprecation of `instrumentPrototypeMethods`. + * + * Priority: + * 1. If `enableRpcTracePropagation` is set, use it (ignore `instrumentPrototypeMethods`) + * 2. If only `instrumentPrototypeMethods` is set, use it with deprecation warning (converted to boolean) + * 3. If neither is set, return `false` + * + * @returns The effective setting for RPC trace propagation + */ +export function getEffectiveRpcPropagation(options: CloudflareOptions): boolean { + const { enableRpcTracePropagation, instrumentPrototypeMethods } = options; + + // If the new option is explicitly set, use it + if (enableRpcTracePropagation !== undefined) { + if (instrumentPrototypeMethods !== undefined) { + DEBUG_BUILD && + debug.warn( + '[Sentry] Both `enableRpcTracePropagation` and `instrumentPrototypeMethods` are set. ' + + 'Using `enableRpcTracePropagation` and ignoring `instrumentPrototypeMethods`.', + ); + } + return enableRpcTracePropagation; + } + + // Fall back to deprecated option with warning + if (instrumentPrototypeMethods !== undefined) { + DEBUG_BUILD && + debug.warn( + '[Sentry] `instrumentPrototypeMethods` is deprecated and will be removed in a future major version. ' + + 'Please use `enableRpcTracePropagation` instead.', + ); + // instrumentPrototypeMethods can be boolean or string[], convert to boolean + return ( + instrumentPrototypeMethods === true || + (Array.isArray(instrumentPrototypeMethods) && instrumentPrototypeMethods.length > 0) + ); + } + + 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/workflows.ts b/packages/cloudflare/src/workflows.ts index c44a9c436bcf..be9b363715eb 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -164,10 +164,9 @@ export function instrumentWorkflowWithSentry< construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { const [ctx, env] = args; const context = instrumentContext(ctx); - args[0] = context; - args[1] = instrumentEnv(env as Record) as E; - const options = optionsCallback(env); + args[0] = context; + args[1] = instrumentEnv(env as Record, options) as E; const instance = Reflect.construct(target, args, newTarget) as T; return new Proxy(instance, { get(obj, prop, receiver) { diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index ef713eadcea4..c127406b8c7e 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -32,7 +32,7 @@ describe('instrumentEnv', () => { expect(instrumented.UNKNOWN).toBe(unknownBinding); }); - it('detects and instruments DurableObjectNamespace bindings', () => { + it('returns env as-is when enableRpcTracePropagation is disabled', () => { const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), @@ -42,6 +42,22 @@ describe('instrumentEnv', () => { const env = { COUNTER: doNamespace }; const instrumented = instrumentEnv(env); + // When trace propagation is disabled, env is returned as-is + expect(instrumented).toBe(env); + expect(instrumented.COUNTER).toBe(doNamespace); + expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled(); + }); + + 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 }); + const result = instrumented.COUNTER; expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); expect((result as any).__instrumented).toBe(true); @@ -55,7 +71,7 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { COUNTER: doNamespace }; - const instrumented = instrumentEnv(env); + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); const first = instrumented.COUNTER; const second = instrumented.COUNTER; @@ -78,7 +94,7 @@ describe('instrumentEnv', () => { newUniqueId: vi.fn(), }; const env = { COUNTER: doNamespace1, SESSIONS: doNamespace2 }; - const instrumented = instrumentEnv(env); + const instrumented = instrumentEnv(env, { enableRpcTracePropagation: true }); instrumented.COUNTER; instrumented.SESSIONS; @@ -88,7 +104,7 @@ describe('instrumentEnv', () => { expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace2); }); - it('wraps JSRPC proxy with a Proxy that instruments fetch', () => { + it('does not wrap JSRPC proxy when enableRpcTracePropagation is disabled', () => { const mockFetch = vi.fn(); const jsrpcProxy = new Proxy( { fetch: mockFetch }, @@ -105,6 +121,29 @@ 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 expect(result).not.toBe(jsrpcProxy); diff --git a/packages/cloudflare/test/utils/rpcOptions.test.ts b/packages/cloudflare/test/utils/rpcOptions.test.ts new file mode 100644 index 000000000000..d9930f7024f5 --- /dev/null +++ b/packages/cloudflare/test/utils/rpcOptions.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CloudflareOptions } from '../../src/client'; +import { getEffectiveRpcPropagation } from '../../src/utils/rpcOptions'; + +// Mock the debug module +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + debug: { + warn: vi.fn(), + }, + }; +}); + +// Mock DEBUG_BUILD +vi.mock('../../src/debug-build', () => ({ + DEBUG_BUILD: true, +})); + +import { debug } from '@sentry/core'; + +describe('getEffectiveRpcPropagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns false when neither option is set', () => { + const options: CloudflareOptions = {}; + expect(getEffectiveRpcPropagation(options)).toBe(false); + }); + + it('returns enableRpcTracePropagation when only it is set (boolean true)', () => { + const options: CloudflareOptions = { enableRpcTracePropagation: true }; + expect(getEffectiveRpcPropagation(options)).toBe(true); + }); + + it('returns enableRpcTracePropagation when only it is set (boolean false)', () => { + const options: CloudflareOptions = { enableRpcTracePropagation: false }; + expect(getEffectiveRpcPropagation(options)).toBe(false); + }); + + it('returns true for instrumentPrototypeMethods when only it is set (with deprecation warning)', () => { + const options: CloudflareOptions = { instrumentPrototypeMethods: true }; + expect(getEffectiveRpcPropagation(options)).toBe(true); + expect(debug.warn).toHaveBeenCalledWith(expect.stringContaining('`instrumentPrototypeMethods` is deprecated')); + }); + + it('returns true for instrumentPrototypeMethods array when only it is set (with deprecation warning)', () => { + const options: CloudflareOptions = { instrumentPrototypeMethods: ['myMethod'] }; + expect(getEffectiveRpcPropagation(options)).toBe(true); + expect(debug.warn).toHaveBeenCalledWith(expect.stringContaining('`instrumentPrototypeMethods` is deprecated')); + }); + + it('returns false for empty instrumentPrototypeMethods array (with deprecation warning)', () => { + const options: CloudflareOptions = { instrumentPrototypeMethods: [] }; + expect(getEffectiveRpcPropagation(options)).toBe(false); + expect(debug.warn).toHaveBeenCalledWith(expect.stringContaining('`instrumentPrototypeMethods` is deprecated')); + }); + + it('prefers enableRpcTracePropagation over instrumentPrototypeMethods when both are set', () => { + const options: CloudflareOptions = { + enableRpcTracePropagation: true, + instrumentPrototypeMethods: false, + }; + expect(getEffectiveRpcPropagation(options)).toBe(true); + expect(debug.warn).toHaveBeenCalledWith( + expect.stringContaining('Both `enableRpcTracePropagation` and `instrumentPrototypeMethods` are set'), + ); + }); + + it('prefers enableRpcTracePropagation (false) over instrumentPrototypeMethods (true) when both are set', () => { + const options: CloudflareOptions = { + enableRpcTracePropagation: false, + instrumentPrototypeMethods: true, + }; + expect(getEffectiveRpcPropagation(options)).toBe(false); + expect(debug.warn).toHaveBeenCalledWith( + expect.stringContaining('Both `enableRpcTracePropagation` and `instrumentPrototypeMethods` are set'), + ); + }); +}); diff --git a/packages/cloudflare/test/workflow.test.ts b/packages/cloudflare/test/workflow.test.ts index 14bb7e78a90e..18dee2c09cfd 100644 --- a/packages/cloudflare/test/workflow.test.ts +++ b/packages/cloudflare/test/workflow.test.ts @@ -168,7 +168,10 @@ describe.skipIf(NODE_MAJOR_VERSION < 20)('workflows', () => { new TestWorkflowInstrumented(mockContext, mockEnv as any); expect(instrumentEnv).toHaveBeenCalledTimes(1); - expect(instrumentEnv).toHaveBeenCalledWith(mockEnv); + expect(instrumentEnv).toHaveBeenCalledWith( + mockEnv, + expect.objectContaining({ dsn: 'https://8@ingest.sentry.io/4' }), + ); }); test('Calls expected functions with non-uuid instance id', async () => {