diff --git a/CHANGELOG.md b/CHANGELOG.md index c90e60a23b06..0670b5d012b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- feat(cloudflare): Add `instrumentPrototypeMethods` option to instrument RPC methods for DurableObjects ([#17424](https://github.com/getsentry/sentry-javascript/pull/17424)) + +By default, `Sentry.instrumentDurableObjectWithSentry` will not wrap any RPC methods on the prototype. To enable wrapping for RPC methods, set `instrumentPrototypeMethods` to `true` or a list of methods you want to instrument: + +```js +class MyDurableObjectBase extends DurableObject { + method1() { + // ... + } + + method2() { + // ... + } + + method3() { + // ... + } +} +// Export your named class as defined in your wrangler config +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: "https://ac49b7af3017c458bd12dab9b3328bfc@o4508482761982032.ingest.de.sentry.io/4508482780987481", + tracesSampleRate: 1.0, + instrumentPrototypeMethods: ['method1', 'method3'], + }), + MyDurableObjectBase, +); +``` + Work in this release was contributed by @Karibash. Thank you for your contribution! ## 10.5.0 diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts new file mode 100644 index 000000000000..74ce2cbbdac4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -0,0 +1,41 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class TestDurableObjectBase extends DurableObject { + public constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility + async sayHello(name: string): Promise { + return `Hello, ${name}`; + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + instrumentPrototypeMethods: true, + }), + TestDurableObjectBase, +); + +export default { + async fetch(request: Request, env: Env): Promise { + const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test'); + const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase; + + if (request.url.includes('hello')) { + const greeting = await stub.sayHello('world'); + return new Response(greeting); + } + + return new Response('Usual response'); + }, +}; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts new file mode 100644 index 000000000000..cfb6841004a9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('traces a durable object method', async () => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1]; + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare_durableobjects', + }), + origin: 'auto.faas.cloudflare_durableobjects', + }), + }), + transaction: 'sayHello', + }), + ); + }) + .start(); + await runner.makeRequest('get', '/hello'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/wrangler.jsonc new file mode 100644 index 000000000000..8f27c3af7a22 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/wrangler.jsonc @@ -0,0 +1,23 @@ +{ + "name": "worker-name", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "migrations": [ + { + "new_sqlite_classes": ["TestDurableObject"], + "tag": "v1" + } + ], + "durable_objects": { + "bindings": [ + { + "class_name": "TestDurableObject", + "name": "TEST_DURABLE_OBJECT" + } + ] + }, + "compatibility_flags": ["nodejs_als"], + "vars": { + "SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552" + } +} diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index b6b4695835ba..2de5147d3d5a 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -68,6 +68,29 @@ interface BaseCloudflareOptions { * @default false */ skipOpenTelemetrySetup?: boolean; + + /** + * 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. + * + * This feature adds runtime overhead as it wraps methods at the prototype level. + * Only enable this if you need automatic instrumentation of prototype methods. + * + * @default false + * @example + * ```ts + * // Instrument all prototype methods + * instrumentPrototypeMethods: true + * + * // Instrument only specific methods + * instrumentPrototypeMethods: ['myMethod', 'anotherMethod'] + * ``` + */ + instrumentPrototypeMethods?: boolean | string[]; } /** diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 4efaf33c9b1c..bda7a9aa3538 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -110,8 +110,7 @@ function wrapMethodWithSentry( } : {}; - // Only create these spans if they have a parent span. - return startSpan({ name: wrapperOptions.spanName, attributes, onlyIfParent: true }, () => { + return startSpan({ name: wrapperOptions.spanName, attributes }, () => { try { const result = Reflect.apply(target, thisArg, args); @@ -273,46 +272,101 @@ export function instrumentDurableObjectWithSentry< ); } } - const instrumentedPrototype = instrumentPrototype(target, options, context); - Object.setPrototypeOf(obj, instrumentedPrototype); + + // Store context and options on the instance for prototype methods to access + Object.defineProperty(obj, '__SENTRY_CONTEXT__', { + value: context, + enumerable: false, + writable: false, + configurable: false, + }); + + Object.defineProperty(obj, '__SENTRY_OPTIONS__', { + value: options, + enumerable: false, + writable: false, + configurable: false, + }); + + if (options?.instrumentPrototypeMethods) { + instrumentPrototype(target, options.instrumentPrototypeMethods); + } return obj; }, }); } -function instrumentPrototype( - target: T, - options: CloudflareOptions, - context: MethodWrapperOptions['context'], -): T { - return new Proxy(target.prototype, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (prop === 'constructor' || typeof value !== 'function') { - return value; +function instrumentPrototype(target: T, methodsToInstrument: boolean | string[]): void { + const proto = target.prototype; + + // Get all methods from the prototype chain + const methodNames = new Set(); + let current = proto; + + while (current && current !== Object.prototype) { + Object.getOwnPropertyNames(current).forEach(name => { + if (name !== 'constructor' && typeof (current as Record)[name] === 'function') { + methodNames.add(name); + } + }); + current = Object.getPrototypeOf(current); + } + + // Create a set for efficient lookups when methodsToInstrument is an array + const methodsToInstrumentSet = Array.isArray(methodsToInstrument) ? new Set(methodsToInstrument) : null; + + // Instrument each method on the prototype + methodNames.forEach(methodName => { + const originalMethod = (proto as Record)[methodName]; + + if (!originalMethod || isInstrumented(originalMethod)) { + return; + } + + // If methodsToInstrument is an array, only instrument methods in that set + if (methodsToInstrumentSet && !methodsToInstrumentSet.has(methodName)) { + return; + } + + // Create a wrapper that gets context/options from the instance at runtime + const wrappedMethod = function (this: any, ...args: any[]): unknown { + const thisWithSentry = this as { + __SENTRY_CONTEXT__: DurableObjectState; + __SENTRY_OPTIONS__: CloudflareOptions; + }; + const instanceContext = thisWithSentry.__SENTRY_CONTEXT__; + const instanceOptions = thisWithSentry.__SENTRY_OPTIONS__; + + if (!instanceOptions) { + // Fallback to original method if no Sentry data found + return (originalMethod as (...args: any[]) => any).apply(this, args); } - const wrapped = wrapMethodWithSentry( - { options, context, spanName: prop.toString(), spanOp: 'rpc' }, - value, + + // Use the existing wrapper but with instance-specific context/options + const wrapper = wrapMethodWithSentry( + { + options: instanceOptions, + context: instanceContext, + spanName: methodName, + spanOp: 'rpc', + }, + originalMethod as (...args: any[]) => any, undefined, - true, + true, // noMark = true since we'll mark the prototype method ); - const instrumented = new Proxy(wrapped, { - get(target, p, receiver) { - if ('__SENTRY_INSTRUMENTED__' === p) { - return true; - } - return Reflect.get(target, p, receiver); - }, - }); - Object.defineProperty(receiver, prop, { - value: instrumented, - enumerable: true, - writable: true, - configurable: true, - }); - return instrumented; - }, + + return (wrapper as (...args: any[]) => any).apply(this, args); + }; + + markAsInstrumented(wrappedMethod); + + // Replace the prototype method + Object.defineProperty(proto, methodName, { + value: wrappedMethod, + enumerable: false, + writable: true, + configurable: true, + }); }); } diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 2add5dde9343..ce794dc7fb69 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -10,7 +10,7 @@ describe('instrumentDurableObjectWithSentry', () => { }); it('Generic functionality', () => { - const options = vi.fn(); + const options = vi.fn().mockReturnValue({}); const instrumented = instrumentDurableObjectWithSentry(options, vi.fn()); expect(instrumented).toBeTypeOf('function'); expect(() => Reflect.construct(instrumented, [])).not.toThrow(); @@ -23,7 +23,10 @@ describe('instrumentDurableObjectWithSentry', () => { return 'sync-result'; } }; - const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any), + [], + ) as any; expect(obj.method).toBe(obj.method); const result = obj.method(); @@ -37,7 +40,10 @@ describe('instrumentDurableObjectWithSentry', () => { return 'async-result'; } }; - const obj = Reflect.construct(instrumentDurableObjectWithSentry(vi.fn(), testClass as any), []) as any; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry(vi.fn().mockReturnValue({}), testClass as any), + [], + ) as any; expect(obj.asyncMethod).toBe(obj.asyncMethod); const result = obj.asyncMethod(); @@ -46,26 +52,42 @@ describe('instrumentDurableObjectWithSentry', () => { }); it('Instruments prototype methods without "sticking" to the options', () => { + const mockContext = { + waitUntil: vi.fn(), + } as any; + const mockEnv = {} as any; // Environment mock const initCore = vi.spyOn(SentryCore, 'initAndBind'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); const options = vi .fn() .mockReturnValueOnce({ orgId: 1, + instrumentPrototypeMethods: true, }) .mockReturnValueOnce({ orgId: 2, + instrumentPrototypeMethods: true, }); const testClass = class { method() {} }; - (Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method(); - (Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), []) as any).method(); + const instance1 = Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), [ + mockContext, + mockEnv, + ]) as any; + instance1.method(); + + const instance2 = Reflect.construct(instrumentDurableObjectWithSentry(options, testClass as any), [ + mockContext, + mockEnv, + ]) as any; + instance2.method(); + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); - it('All available durable object methods are instrumented', () => { + it('All available durable object methods are instrumented when instrumentPrototypeMethods is enabled', () => { const testClass = class { propertyFunction = vi.fn(); @@ -81,9 +103,11 @@ describe('instrumentDurableObjectWithSentry', () => { webSocketError() {} }; - const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); + const instrumented = instrumentDurableObjectWithSentry( + vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }), + testClass as any, + ); const obj = Reflect.construct(instrumented, []); - expect(Object.getPrototypeOf(obj), 'Prototype is instrumented').not.toBe(testClass.prototype); for (const method_name of [ 'propertyFunction', 'fetch', @@ -122,4 +146,93 @@ describe('instrumentDurableObjectWithSentry', () => { await Promise.all(waitUntil.mock.calls.map(([p]) => p)); expect(flush).toBeCalled(); }); + + describe('instrumentPrototypeMethods option', () => { + it('does not instrument prototype methods when option is not set', () => { + const testClass = class { + prototypeMethod() { + return 'prototype-result'; + } + }; + const options = vi.fn().mockReturnValue({}); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.prototypeMethod)).toBeFalsy(); + }); + + it('does not instrument prototype methods when option is false', () => { + const testClass = class { + prototypeMethod() { + return 'prototype-result'; + } + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.prototypeMethod)).toBeFalsy(); + }); + + it('instruments all prototype methods when option is true', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.methodOne)).toBeTruthy(); + expect(isInstrumented(obj.methodTwo)).toBeTruthy(); + }); + + it('instruments only specified methods when option is array', () => { + const testClass = class { + methodOne() { + return 'one'; + } + methodTwo() { + return 'two'; + } + methodThree() { + return 'three'; + } + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: ['methodOne', 'methodThree'] }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + expect(isInstrumented(obj.methodOne)).toBeTruthy(); + expect(isInstrumented(obj.methodTwo)).toBeFalsy(); + expect(isInstrumented(obj.methodThree)).toBeTruthy(); + }); + + it('still instruments instance methods regardless of prototype option', () => { + const testClass = class { + propertyFunction = vi.fn(); + + fetch() {} + alarm() {} + webSocketMessage() {} + webSocketClose() {} + webSocketError() {} + }; + const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); + const instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + const obj = Reflect.construct(instrumented, []) as any; + + // Instance methods should still be instrumented + expect(isInstrumented(obj.propertyFunction)).toBeTruthy(); + expect(isInstrumented(obj.fetch)).toBeTruthy(); + expect(isInstrumented(obj.alarm)).toBeTruthy(); + expect(isInstrumented(obj.webSocketMessage)).toBeTruthy(); + expect(isInstrumented(obj.webSocketClose)).toBeTruthy(); + expect(isInstrumented(obj.webSocketError)).toBeTruthy(); + }); + }); });