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..2ddb0da4815c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts @@ -0,0 +1,44 @@ +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, + beforeSendTransaction: transaction => { + console.log('beforeSendTransaction', transaction); + return transaction; + }, + }), + TestDurableObjectBase, +); + +export default { + async fetch(request, 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/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 4efaf33c9b1c..53a5a881f034 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,87 @@ 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, + }); + + instrumentPrototype(target); 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): 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[name] === 'function') { + methodNames.add(name); + } + }); + current = Object.getPrototypeOf(current); + } + + // Instrument each method on the prototype + methodNames.forEach(methodName => { + const originalMethod = proto[methodName]; + + if (!originalMethod || isInstrumented(originalMethod)) { + return; + } + + // Create a wrapper that gets context/options from the instance at runtime + const wrappedMethod = function (this: any, ...args: any[]) { + const instanceContext = this.__SENTRY_CONTEXT__; + const instanceOptions = this.__SENTRY_OPTIONS__; + + if (!instanceOptions) { + // Fallback to original method if no Sentry data found + return originalMethod.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, 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.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..7768adb6d08b 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -46,8 +46,12 @@ 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 getClientSpy = vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); const options = vi .fn() .mockReturnValueOnce({ @@ -59,8 +63,12 @@ describe('instrumentDurableObjectWithSentry', () => { 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 })); }); @@ -83,7 +91,6 @@ describe('instrumentDurableObjectWithSentry', () => { }; const instrumented = instrumentDurableObjectWithSentry(vi.fn(), 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',