From feb3ccd16a04415448edef806ea3fe77d66eeadd Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Thu, 16 Jul 2026 12:52:43 +0530 Subject: [PATCH 1/4] fix(cloudflare): Instrument custom WorkerEntrypoint RPC methods --- CHANGELOG.md | 2 + .../index-sub-worker.ts | 31 ++- .../worker-workerentrypoint-rpc/index.ts | 42 +++- .../worker-workerentrypoint-rpc/test.ts | 86 ++++++++ .../wrangler.jsonc | 1 + packages/cloudflare/src/client.ts | 10 +- .../instrumentWorkerEntrypoint.ts | 146 +++++++++++-- .../cloudflare/src/wrapMethodWithSentry.ts | 7 +- .../instrumentWorkerEntrypoint.test.ts | 196 +++++++++++++++--- 9 files changed, 469 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cf389a6a4a4..6d117c5ccd73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Work in this release was contributed by @dobladov and @PeterWadie. Thank you for your contributions! +- feat(cloudflare): Instrument custom WorkerEntrypoint RPC receiver methods + ## 10.65.0 - feat(angular): Set `url` attributes on pageload and navigation spans ([#21985](https://github.com/getsentry/sentry-javascript/pull/21985)) 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 f37d654d9831..233c71b9d1ce 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 @@ -5,7 +5,17 @@ interface Env { SENTRY_DSN: string; } -class MySubWorkerEntrypointBase extends WorkerEntrypoint { +interface Props { + accountId: string; +} + +class BaseEntrypoint extends WorkerEntrypoint { + inherited(value: string): string { + return value; + } +} + +class MySubWorkerEntrypointBase extends BaseEntrypoint { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -20,13 +30,30 @@ class MySubWorkerEntrypointBase extends WorkerEntrypoint { return new Response('Not found', { status: 404 }); } + + get(key: string): { argumentCount: number; key: string } { + Sentry.setTag('key', key); + return { argumentCount: arguments.length, key }; + } + + throwError(): never { + throw new Error('custom RPC receiver failed'); + } } -export default Sentry.withSentry( +export const BindingEntrypoint = Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, enableRpcTracePropagation: true, + initialScope: { tags: { initial_scope: 'applied' } }, + beforeSend(event) { + event.tags = { ...event.tags, before_send: 'applied' }; + return event; + }, + transportOptions: { fetch: fetch.bind(globalThis) }, }), MySubWorkerEntrypointBase, ); + +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 e46d7ffd4daf..1cc65f2d0671 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 @@ -1,10 +1,26 @@ import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint, exports as workerExports } from 'cloudflare:workers'; interface Env { SENTRY_DSN: string; - SUB_WORKER: Fetcher; + SUB_WORKER: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + inherited(value: string): Promise; + throwError(): Promise; + }; } +class LoopbackEntrypointBase extends WorkerEntrypoint { + throwError(): never { + throw new Error('loopback RPC receiver failed'); + } +} + +export const LoopbackEntrypoint = Sentry.withSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0 }), + LoopbackEntrypointBase, +); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, @@ -27,6 +43,30 @@ export default Sentry.withSentry( return new Response(text); } + if (url.pathname === '/call-entrypoint-rpc') { + const result = await env.SUB_WORKER.get('feature-key'); + const inherited = await env.SUB_WORKER.inherited('base-value'); + return Response.json({ inherited, ...result }); + } + + if (url.pathname === '/call-entrypoint-rpc-error') { + try { + await env.SUB_WORKER.throwError(); + } catch { + return new Response('fallback'); + } + } + + if (url.pathname === '/call-loopback-rpc-error') { + try { + await ( + workerExports as unknown as { LoopbackEntrypoint: { throwError(): Promise } } + ).LoopbackEntrypoint.throwError(); + } catch { + return new Response('fallback'); + } + } + return new Response('Not found', { status: 404 }); }, } satisfies ExportedHandler, 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 3b76e28e9e88..c04c57696feb 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 @@ -127,3 +127,89 @@ it('propagates trace for request with query params from Worker to WorkerEntrypoi expect(entrypointParentSpanId).toBeDefined(); expect(entrypointParentSpanId).toBe(workerSpanId); }); + +it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadata', async ({ signal }) => { + let callerTraceId: string | undefined; + let receiverTraceId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-entrypoint-rpc'); + callerTraceId = event.contexts?.trace?.trace_id; + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('get'); + expect(event.contexts?.trace?.op).toBe('rpc'); + receiverTraceId = event.contexts?.trace?.trace_id; + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('inherited'); + expect(event.contexts?.trace?.op).toBe('rpc'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; inherited: string; key: string }>( + 'get', + '/call-entrypoint-rpc', + ); + expect(response).toEqual({ argumentCount: 1, inherited: 'base-value', key: 'feature-key' }); + + await runner.completed(); + expect(receiverTraceId).toBe(callerTraceId); +}); + +it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('custom RPC receiver failed'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.faas.cloudflare.worker_entrypoint', + }); + expect(event.tags?.initial_scope).toBe('applied'); + expect(event.tags?.before_send).toBe('applied'); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('throwError'); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-entrypoint-rpc-error'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint-rpc-error'); + expect(response).toBe('fallback'); + + await runner.completed(); +}); + +it('captures errors from loopback WorkerEntrypoint RPC without trace propagation', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('loopback RPC receiver failed'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.faas.cloudflare.worker_entrypoint', + }); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-loopback-rpc-error'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-loopback-rpc-error'); + expect(response).toBe('fallback'); + + await runner.completed(); +}); 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 1638b8a00a18..e3db52a11d7e 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 @@ -7,6 +7,7 @@ { "binding": "SUB_WORKER", "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "BindingEntrypoint", }, ], } diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 21be86bf4c59..b9a2d2614ebf 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -177,9 +177,9 @@ interface BaseCloudflareOptions { * 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) + * - RPC method calls to Durable Objects and WorkerEntrypoints (via trailing argument) * - * When enabled on the **receiver side** (DurableObject), the SDK will also: + * 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 @@ -206,6 +206,12 @@ interface BaseCloudflareOptions { * }), * MyDOBase, * ); + * + * // WorkerEntrypoint side (receiver) + * export const MyEntrypoint = Sentry.withSentry( + * env => ({ dsn: env.SENTRY_DSN, enableRpcTracePropagation: true }), + * MyEntrypointBase, + * ); * ``` */ enableRpcTracePropagation?: boolean; diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index e8b2466da821..35ba45782380 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -1,19 +1,108 @@ +import type { WorkerEntrypoint } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; -import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import type { CloudflareOptions } from '../client'; +import { getFinalOptions } from '../options'; +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'; import { instrumentWorkerEntrypointQueue } from './worker/instrumentQueue'; import { instrumentWorkerEntrypointScheduled } from './worker/instrumentScheduled'; import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; -import { getFinalOptions } from '../options'; -import { instrumentContext } from '../utils/instrumentContext'; -import { instrumentEnv } from './worker/instrumentEnv'; -export type WorkerEntrypointConstructor = new ( +const WORKER_ENTRYPOINT_ORIGIN = 'auto.faas.cloudflare.worker_entrypoint'; + +const RESERVED_METHODS = new Set([ + 'connect', + 'constructor', + 'dup', + 'email', + 'fetch', + 'queue', + 'scheduled', + 'tail', + 'tailStream', + 'test', + 'trace', +]); + +interface CachedMethod { + source: UncheckedMethod; + wrapped: UncheckedMethod; +} + +export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, - env: typeof cloudflareEnv, + // oxlint-disable-next-line typescript/no-explicit-any + env: any, ) => InstanceType>; +function isPrototypeMethod(instance: object, prop: string, method: UncheckedMethod): boolean { + let prototype = Object.getPrototypeOf(instance); + + while (prototype && prototype !== Object.prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, prop); + if (descriptor) { + return descriptor.value === method; + } + prototype = Object.getPrototypeOf(prototype); + } + + return false; +} + +function bindToInstance(instance: object, proxy: object, method: UncheckedMethod): UncheckedMethod { + return new Proxy(method, { + apply(target, thisArg, args) { + return Reflect.apply(target, thisArg === proxy ? instance : thisArg, args); + }, + }); +} + +function instrumentMethod( + instance: object, + proxy: object, + prop: PropertyKey, + method: UncheckedMethod, + options: CloudflareOptions, + context: ExecutionContext, +): UncheckedMethod { + const ownDescriptor = Object.getOwnPropertyDescriptor(instance, prop); + if (ownDescriptor && 'value' in ownDescriptor && !ownDescriptor.configurable && !ownDescriptor.writable) { + return method; + } + + const boundMethod = bindToInstance(instance, proxy, method); + + if (typeof prop !== 'string' || RESERVED_METHODS.has(prop) || !isPrototypeMethod(instance, prop, method)) { + return boundMethod; + } + + const captureMethod = wrapMethodWithSentry( + { options, context, 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 }, + boundMethod, + undefined, + true, + ); + + return (...args: unknown[]) => { + const { rpcMeta } = extractRpcMeta(args); + return rpcMeta ? tracedMethod(...args) : captureMethod(...args); + }; +} + /** * Instruments a WorkerEntrypoint class to capture errors and performance data. * @@ -51,10 +140,12 @@ export type WorkerEntrypointConstructor * ); * ``` */ -export function instrumentWorkerEntrypoint( - optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, - WorkerEntrypointClass: T, -): T { +export function instrumentWorkerEntrypoint< + Env, + Props, + T extends InstanceType>, + C extends new (ctx: ExecutionContext, env: Env) => T, +>(optionsCallback: (env: Env) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C { // Set up AsyncLocalStorage strategy ONCE at instrumentation time, not per-request // This is critical - calling this per-request would create a new AsyncLocalStorage // each time, breaking scope isolation for concurrent requests @@ -103,12 +194,37 @@ export function instrumentWorkerEntrypoint(); + + const proxy: typeof obj = new Proxy(obj, { + get(instance, prop) { + const value = Reflect.get(instance, prop, instance); + + if (typeof value !== 'function') { + return value; + } + + const method = value as UncheckedMethod; + + const cached = methodCache.get(prop); + if (cached?.source === method) { + return cached.wrapped; + } + + const wrapped = instrumentMethod(instance, proxy, prop, method, options, context); + methodCache.set(prop, { source: method, wrapped }); + return wrapped; + }, + }); - return obj; + return proxy; }, }); } diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 1479278446fa..6ecf48440431 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -65,6 +65,7 @@ type MethodWrapperOptions = { * @default false */ startNewTrace?: boolean; + origin?: string; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -92,7 +93,7 @@ export function wrapMethodWithSentry( original => new Proxy(original, { apply(target, thisArg, rawArgs: Parameters) { - const { startNewTrace } = wrapperOptions; + const { startNewTrace, origin = 'auto.faas.cloudflare.durable_object' } = wrapperOptions; // For RPC methods, extract Sentry trace context from the trailing argument. // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; @@ -152,7 +153,7 @@ export function wrapMethodWithSentry( const onRejected = (e: unknown) => { captureException(e, { mechanism: { - type: 'auto.faas.cloudflare.durable_object', + type: origin, handled: false, }, }); @@ -181,7 +182,7 @@ export function wrapMethodWithSentry( const attributes = wrapperOptions.spanOp ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.durable_object', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, } : {}; diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 54069e14c251..74d85646a7c5 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -1,4 +1,5 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getInstrumented } from '../../src/instrument'; @@ -6,6 +7,7 @@ import { instrumentWorkerEntrypoint, type WorkerEntrypointConstructor, } from '../../src/instrumentations/instrumentWorkerEntrypoint'; +import { resetSdk } from '../testUtils'; function createMockExecutionContext(): ExecutionContext { return { @@ -20,6 +22,7 @@ class WorkerEntrypoint {} describe('instrumentWorkerEntrypoint', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); it('Generic functionality', () => { @@ -235,50 +238,185 @@ describe('instrumentWorkerEntrypoint', () => { expect(testClientFlushCount).toBe(1); }); - describe('instrumentPrototypeMethods option', () => { - it('does not instrument prototype methods when option is not set', () => { - const TestClass = class Hello extends WorkerEntrypoint { - prototypeMethod() { - return 'prototype-result'; + describe('custom RPC methods', () => { + it('binds non-RPC methods and getters to the original instance', () => { + class TestClass extends WorkerEntrypoint { + #value = 'value'; + + ownMethod = function ownMethod(this: TestClass) { + return this.#value; + }; + + get value() { + return this.#value; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + + expect(obj.ownMethod).toBe(obj.ownMethod); + expect(obj.ownMethod()).toBe('value'); + expect(obj.value).toBe('value'); + expect(obj.toString()).toBe('[object Object]'); + expect(obj.ownMethod.name).toBe('ownMethod'); + + const other = { value: true }; + expect(obj.hasOwnProperty.call(other, 'value')).toBe(true); + }); + + it('handles method replacement and frozen own methods', () => { + const frozenMethod = () => 'frozen'; + class TestClass extends WorkerEntrypoint { + method() { + return 'first'; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + Object.defineProperty(obj, 'frozenMethod', { + configurable: false, + value: frozenMethod, + writable: false, + }); + + expect(obj.method()).toBe('first'); + TestClass.prototype.method = () => 'second'; + expect(obj.method()).toBe('second'); + expect(obj.frozenMethod).toBe(frozenMethod); + }); + + it('preserves arguments when RPC trace propagation is disabled', () => { + const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; + const TestClass = class extends WorkerEntrypoint { + inspect(...args: unknown[]) { + return args; } }; - const options = vi.fn().mockReturnValue({}); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, []); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [createMockExecutionContext(), {}], + ); - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); + expect(obj.inspect).toBe(obj.inspect); + expect(obj.inspect(rpcMeta)).toEqual([rpcMeta]); }); - it('does not instrument prototype methods when option is false', () => { + it('flushes repeated calls on the same instance', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); const TestClass = class extends WorkerEntrypoint { - prototypeMethod() { - return 'prototype-result'; + async get(value: string) { + SentryCore.captureMessage(value); + return value; } }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, []); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); + await expect(obj.get('first call')).resolves.toBe('first call'); + await Promise.all(waits.splice(0)); + await expect(obj.get('second call')).resolves.toBe('second call'); + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['first call', 'second call']); }); - it('does not instrument prototype methods when option is true (instrumentWorkerEntrypoint does not support instrumentPrototypeMethods)', () => { + it('does not create a second invocation for direct calls from RPC or lifecycle methods', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); const TestClass = class extends WorkerEntrypoint { - methodOne() { - return 'one'; + async outer() { + return this.inner(); } - methodTwo() { - return 'two'; + + async fetch() { + return this.inner(); + } + + async inner(): Promise { + throw new Error('inner failure'); } }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, [createMockExecutionContext(), {}]); - - expect(getInstrumented(obj.methodOne)).toBeFalsy(); - expect(getInstrumented(obj.methodTwo)).toBeFalsy(); - expect(obj.methodOne()).toBe('one'); - expect(obj.methodTwo()).toBe('two'); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await expect(obj.outer()).rejects.toThrow('inner failure'); + await Promise.all(waits.splice(0)); + expect(events).toHaveLength(1); + + await expect(obj.fetch(new Request('https://example.com'))).rejects.toThrow('inner failure'); + await Promise.all(waits); + expect(events).toHaveLength(2); + }); + + it('only excludes WorkerEntrypoint lifecycle methods from RPC instrumentation', async () => { + const initAndBind = vi.spyOn(SentryCore, 'initAndBind'); + const TestClass = class extends WorkerEntrypoint { + alarm() {} + + fetch() { + return new Response('ok'); + } + + tailStream() {} + + test() {} + + trace() {} + + webSocketMessage() {} + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + + const response = await obj.fetch(new Request('https://example.com')); + await response.text(); + obj.tailStream(); + obj.test(); + obj.trace(); + await obj.alarm(); + await obj.webSocketMessage(); + + expect(initAndBind).toHaveBeenCalledTimes(3); }); }); From bfcf622961c4ded5c59f1af909ac8ece2364bbc9 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Thu, 16 Jul 2026 15:20:47 +0530 Subject: [PATCH 2/4] fix: this binding for instrumented WorkerEntrypoint RPC methods --- .../instrumentWorkerEntrypoint.ts | 3 ++- .../instrumentWorkerEntrypoint.test.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index 35ba45782380..20b596727637 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -34,6 +34,7 @@ interface CachedMethod { export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, + // WorkerEntrypoint subclasses can define different `env` shapes, so this constructor must accept all of them. // oxlint-disable-next-line typescript/no-explicit-any env: any, ) => InstanceType>; @@ -99,7 +100,7 @@ function instrumentMethod( return (...args: unknown[]) => { const { rpcMeta } = extractRpcMeta(args); - return rpcMeta ? tracedMethod(...args) : captureMethod(...args); + return rpcMeta ? tracedMethod.call(proxy, ...args) : captureMethod.call(proxy, ...args); }; } diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 74d85646a7c5..3646432efbe9 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -289,6 +289,30 @@ describe('instrumentWorkerEntrypoint', () => { expect(obj.frozenMethod).toBe(frozenMethod); }); + it('preserves `this` for custom RPC methods when RPC trace propagation is enabled', () => { + class TestClass extends WorkerEntrypoint { + #value = 'secret'; + + readValue() { + return this.#value; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [createMockExecutionContext(), {}], + ); + + // No propagated trace metadata takes the error-capture-only path. + expect(obj.readValue()).toBe('secret'); + + // Propagated trace metadata takes the traced (span-creating) path. + const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; + expect(obj.readValue(rpcMeta)).toBe('secret'); + }); + it('preserves arguments when RPC trace propagation is disabled', () => { const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; const TestClass = class extends WorkerEntrypoint { From cd3ffd4263011936ee0b018ccb0c58ffd9fec101 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Fri, 17 Jul 2026 12:10:55 +0530 Subject: [PATCH 3/4] fix: resolve issues --- CHANGELOG.md | 2 - .../index-sub-worker.ts | 9 ++ .../worker-workerentrypoint-rpc/index.ts | 16 ++- .../worker-workerentrypoint-rpc/test.ts | 106 ++++++++++++++++-- .../wrangler.jsonc | 7 +- packages/cloudflare/src/durableobject.ts | 20 +++- .../instrumentWorkerEntrypoint.ts | 12 +- .../cloudflare/src/wrapMethodWithSentry.ts | 2 +- .../instrumentWorkerEntrypoint.test.ts | 4 +- .../test/wrapMethodWithSentry.test.ts | 2 + 10 files changed, 150 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d117c5ccd73..7cf389a6a4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,6 @@ Work in this release was contributed by @dobladov and @PeterWadie. Thank you for your contributions! -- feat(cloudflare): Instrument custom WorkerEntrypoint RPC receiver methods - ## 10.65.0 - feat(angular): Set `url` attributes on pageload and navigation spans ([#21985](https://github.com/getsentry/sentry-javascript/pull/21985)) 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 233c71b9d1ce..77218af60fa1 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 @@ -56,4 +56,13 @@ export const BindingEntrypoint = Sentry.withSentry( MySubWorkerEntrypointBase, ); +export const NoPropagationEntrypoint = Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + transportOptions: { fetch: fetch.bind(globalThis) }, + }), + MySubWorkerEntrypointBase, +); + 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 1cc65f2d0671..b37c2ab1ffe5 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 @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/cloudflare'; -import { WorkerEntrypoint, exports as workerExports } from 'cloudflare:workers'; +import { WorkerEntrypoint } from 'cloudflare:workers'; interface Env { SENTRY_DSN: string; @@ -8,6 +8,9 @@ interface Env { inherited(value: string): Promise; throwError(): Promise; }; + SUB_WORKER_NO_PROPAGATION: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + }; } class LoopbackEntrypointBase extends WorkerEntrypoint { @@ -28,7 +31,7 @@ export default Sentry.withSentry( enableRpcTracePropagation: true, }), { - async fetch(request, env) { + async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === '/call-entrypoint') { @@ -57,11 +60,16 @@ export default Sentry.withSentry( } } + if (url.pathname === '/call-entrypoint-rpc-no-propagation') { + const result = await env.SUB_WORKER_NO_PROPAGATION.get('no-prop-key'); + return Response.json(result); + } + if (url.pathname === '/call-loopback-rpc-error') { try { await ( - workerExports as unknown as { LoopbackEntrypoint: { throwError(): Promise } } - ).LoopbackEntrypoint.throwError(); + ctx as unknown as { exports: { LoopbackEntrypoint: { throwError(): Promise } } } + ).exports.LoopbackEntrypoint.throwError(); } catch { return new Response('fallback'); } 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 c04c57696feb..176db616a3ec 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 @@ -130,24 +130,70 @@ it('propagates trace for request with query params from Worker to WorkerEntrypoi it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadata', async ({ signal }) => { let callerTraceId: string | undefined; - let receiverTraceId: string | undefined; + let callerSpanId: string | undefined; + let receiverGetTraceId: string | undefined; + let receiverGetParentSpanId: string | undefined; const runner = createRunner(__dirname) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('GET /call-entrypoint-rpc'); - callerTraceId = event.contexts?.trace?.trace_id; + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + }), + }), + transaction: 'GET /call-entrypoint-rpc', + }), + ); + callerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + callerSpanId = transactionEvent.contexts?.trace?.span_id as string; }) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('get'); - expect(event.contexts?.trace?.op).toBe('rpc'); - receiverTraceId = event.contexts?.trace?.trace_id; + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.worker_entrypoint', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', + }), + }), + }), + transaction: 'get', + }), + ); + receiverGetTraceId = transactionEvent.contexts?.trace?.trace_id as string; + receiverGetParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; }) .expect(envelope => { - const event = envelope[1]?.[0]?.[1] as Event; - expect(event.transaction).toBe('inherited'); - expect(event.contexts?.trace?.op).toBe('rpc'); + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.worker_entrypoint', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', + }), + }), + }), + transaction: 'inherited', + }), + ); }) .unordered() .start(signal); @@ -159,7 +205,14 @@ it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadat expect(response).toEqual({ argumentCount: 1, inherited: 'base-value', key: 'feature-key' }); await runner.completed(); - expect(receiverTraceId).toBe(callerTraceId); + + expect(receiverGetTraceId).toBeDefined(); + expect(callerTraceId).toBeDefined(); + expect(receiverGetTraceId).toBe(callerTraceId); + + expect(receiverGetParentSpanId).toBeDefined(); + expect(callerSpanId).toBeDefined(); + expect(receiverGetParentSpanId).toBe(callerSpanId); }); it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ signal }) => { @@ -191,6 +244,35 @@ it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ sig await runner.completed(); }); +it('does not create a traced RPC transaction on the receiver when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + 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', + }), + }), + transaction: 'GET /call-entrypoint-rpc-no-propagation', + }), + ); + }) + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; key: string }>( + 'get', + '/call-entrypoint-rpc-no-propagation', + ); + expect(response).toEqual({ argumentCount: 1, key: 'no-prop-key' }); + + await runner.completed(); +}); + it('captures errors from loopback WorkerEntrypoint RPC without trace propagation', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { 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 e3db52a11d7e..bd19f421dcff 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 @@ -2,12 +2,17 @@ "name": "cloudflare-worker-workerentrypoint-rpc", "main": "index.ts", "compatibility_date": "2025-06-17", - "compatibility_flags": ["nodejs_als"], + "compatibility_flags": ["nodejs_als", "enable_ctx_exports"], "services": [ { "binding": "SUB_WORKER", "service": "cloudflare-worker-workerentrypoint-rpc-sub", "entrypoint": "BindingEntrypoint", }, + { + "binding": "SUB_WORKER_NO_PROPAGATION", + "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "NoPropagationEntrypoint", + }, ], } diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e312c31b59ad..0c0b1262156c 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -89,25 +89,35 @@ export function instrumentDurableObjectWithSentry< if (obj.alarm && typeof obj.alarm === 'function') { // Alarms are independent invocations, so we start a new trace and link to the previous alarm obj.alarm = wrapMethodWithSentry( - { options, context, spanName: 'alarm', spanOp: 'function', startNewTrace: true }, + { + options, + context, + spanName: 'alarm', + spanOp: 'function', + startNewTrace: true, + origin: 'auto.faas.cloudflare.durable_object', + }, obj.alarm, ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { obj.webSocketMessage = wrapMethodWithSentry( - { options, context, spanName: 'webSocketMessage' }, + { options, context, spanName: 'webSocketMessage', origin: 'auto.faas.cloudflare.durable_object' }, obj.webSocketMessage, ); } if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { - obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); + obj.webSocketClose = wrapMethodWithSentry( + { options, context, spanName: 'webSocketClose', origin: 'auto.faas.cloudflare.durable_object' }, + obj.webSocketClose, + ); } if (obj.webSocketError && typeof obj.webSocketError === 'function') { obj.webSocketError = wrapMethodWithSentry( - { options, context, spanName: 'webSocketError' }, + { options, context, spanName: 'webSocketError', origin: 'auto.faas.cloudflare.durable_object' }, obj.webSocketError, (_, error) => captureException(error, { @@ -174,7 +184,7 @@ export function instrumentDurableObjectWithSentry< // Pre-create the traced version const tracedMethod = wrapMethodWithSentry( - { options, context, spanName: prop, spanOp: 'rpc' }, + { options, context, spanName: prop, spanOp: 'rpc', origin: 'auto.faas.cloudflare.durable_object' }, boundMethod, undefined, true, diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index 20b596727637..bb53e124568e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -1,4 +1,4 @@ -import type { WorkerEntrypoint } from 'cloudflare:workers'; +import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; import type { CloudflareOptions } from '../client'; import { getFinalOptions } from '../options'; @@ -13,6 +13,12 @@ import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; const WORKER_ENTRYPOINT_ORIGIN = 'auto.faas.cloudflare.worker_entrypoint'; +type ReservedMethod = + | Exclude, '__WORKER_ENTRYPOINT_BRAND'> + | Extract + | Exclude unknown>, string>, '__RPC_STUB_BRAND'> + | 'constructor'; + const RESERVED_METHODS = new Set([ 'connect', 'constructor', @@ -25,7 +31,7 @@ const RESERVED_METHODS = new Set([ 'tailStream', 'test', 'trace', -]); +] satisfies ReservedMethod[]); interface CachedMethod { source: UncheckedMethod; @@ -81,7 +87,7 @@ function instrumentMethod( } const captureMethod = wrapMethodWithSentry( - { options, context, origin: WORKER_ENTRYPOINT_ORIGIN }, + { options, context, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, boundMethod, undefined, true, diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 6ecf48440431..3cba29f038c6 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -93,7 +93,7 @@ export function wrapMethodWithSentry( original => new Proxy(original, { apply(target, thisArg, rawArgs: Parameters) { - const { startNewTrace, origin = 'auto.faas.cloudflare.durable_object' } = wrapperOptions; + const { startNewTrace, origin } = wrapperOptions; // For RPC methods, extract Sentry trace context from the trailing argument. // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 3646432efbe9..353eabebe474 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -313,7 +313,7 @@ describe('instrumentWorkerEntrypoint', () => { expect(obj.readValue(rpcMeta)).toBe('secret'); }); - it('preserves arguments when RPC trace propagation is disabled', () => { + it('strips RPC metadata even when trace propagation is disabled', () => { const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; const TestClass = class extends WorkerEntrypoint { inspect(...args: unknown[]) { @@ -329,7 +329,7 @@ describe('instrumentWorkerEntrypoint', () => { ); expect(obj.inspect).toBe(obj.inspect); - expect(obj.inspect(rpcMeta)).toEqual([rpcMeta]); + expect(obj.inspect(rpcMeta)).toEqual([]); }); it('flushes repeated calls on the same instance', async () => { diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index ae2e376cc555..202948786d55 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -286,6 +286,7 @@ describe('wrapMethodWithSentry', () => { const options = { options: {}, context: createMockContext(), + origin: 'auto.faas.cloudflare.durable_object', }; const wrapped = wrapMethodWithSentry(options, handler); @@ -305,6 +306,7 @@ describe('wrapMethodWithSentry', () => { const options = { options: {}, context: createMockContext(), + origin: 'auto.faas.cloudflare.durable_object', }; const wrapped = wrapMethodWithSentry(options, handler); From 792cc854529bc6e3b9e9dd8e3c21f27c2fa3cd74 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Fri, 17 Jul 2026 12:33:28 +0530 Subject: [PATCH 4/4] fix: resolve issues --- .../worker-workerentrypoint-rpc/test.ts | 6 +++- .../cloudflare/src/wrapMethodWithSentry.ts | 6 +++- .../test/wrapMethodWithSentry.test.ts | 30 +++++++++++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) 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 176db616a3ec..9309d0f4f97b 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,7 @@ it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ sig await runner.completed(); }); -it('does not create a traced RPC transaction on the receiver when enableRpcTracePropagation is disabled', async ({ +it('does not inject RPC trace metadata into receiver calls when enableRpcTracePropagation is disabled', async ({ signal, }) => { const runner = createRunner(__dirname) @@ -256,6 +256,10 @@ it('does not create a traced RPC transaction on the receiver when enableRpcTrace contexts: expect.objectContaining({ trace: expect.objectContaining({ op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', }), }), transaction: 'GET /call-entrypoint-rpc-no-propagation', diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 3cba29f038c6..dd2b5079c83f 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -65,7 +65,11 @@ type MethodWrapperOptions = { * @default false */ startNewTrace?: boolean; - origin?: string; + /** + * The trace origin identifying which instrumentation created the span, e.g. `auto.faas.cloudflare.durable_object`. + * Used both as the span's `sentry.origin` attribute and as the `mechanism.type` for captured exceptions. + */ + origin: string; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index 202948786d55..5e609f8dcace 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -93,6 +93,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method and returns its result synchronously (not a Promise)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -108,6 +109,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method with spanName and preserves sync behavior', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), spanName: 'test-span', @@ -124,6 +126,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method with startNewTrace and preserves sync behavior (no storage)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext({ hasStorage: false }), spanName: 'test-span', @@ -142,6 +145,7 @@ describe('wrapMethodWithSentry', () => { it('wraps an async method and returns a promise', async () => { const handler = vi.fn().mockResolvedValue('async-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -159,6 +163,7 @@ describe('wrapMethodWithSentry', () => { const context = createMockContext({ hasStorage: true, hasWaitUntil: true }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, spanName: 'alarm', @@ -185,6 +190,7 @@ describe('wrapMethodWithSentry', () => { } as any; const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, spanName: 'alarm', @@ -202,6 +208,7 @@ describe('wrapMethodWithSentry', () => { it('marks handler as instrumented', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -216,6 +223,7 @@ describe('wrapMethodWithSentry', () => { it('does not re-wrap already instrumented handler', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -231,6 +239,7 @@ describe('wrapMethodWithSentry', () => { it('does not mark handler when noMark is true', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -245,6 +254,7 @@ describe('wrapMethodWithSentry', () => { it('creates span with spanName when provided', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), spanName: 'test-span', @@ -265,6 +275,7 @@ describe('wrapMethodWithSentry', () => { it('does not create span when spanName is not provided', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -284,9 +295,9 @@ describe('wrapMethodWithSentry', () => { throw error; }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), - origin: 'auto.faas.cloudflare.durable_object', }; const wrapped = wrapMethodWithSentry(options, handler); @@ -304,9 +315,9 @@ describe('wrapMethodWithSentry', () => { const error = new Error('Test async error'); const handler = vi.fn().mockRejectedValue(error); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), - origin: 'auto.faas.cloudflare.durable_object', }; const wrapped = wrapMethodWithSentry(options, handler); @@ -325,6 +336,7 @@ describe('wrapMethodWithSentry', () => { it('uses withIsolationScope when startNewTrace is true', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: true, @@ -340,6 +352,7 @@ describe('wrapMethodWithSentry', () => { it('uses startNewTrace when startNewTrace is true and spanName is set', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: true, @@ -355,6 +368,7 @@ describe('wrapMethodWithSentry', () => { it('does not use startNewTrace when startNewTrace is false', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: false, @@ -386,6 +400,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -420,6 +435,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -463,6 +479,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -498,6 +515,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: false, @@ -526,6 +544,7 @@ describe('wrapMethodWithSentry', () => { callOrder.push('callback'); }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -548,6 +567,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, }; @@ -566,6 +586,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, }; @@ -581,6 +602,7 @@ describe('wrapMethodWithSentry', () => { it('passes arguments to handler', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -597,6 +619,7 @@ describe('wrapMethodWithSentry', () => { return this.name; }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -617,6 +640,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -650,6 +674,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -683,6 +708,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), };