diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa8c7179c9b..58d6753b7aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott - fix(cloudflare): Route Durable Object teardown through the original `waitUntil` to avoid a flush-lock deadlock that prevented WebSocket hibernation ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) +- fix(cloudflare): Bind Durable Object built-in handlers (`fetch`/`alarm`/`webSocket*`) per instance so a second instance in the same isolate no longer reuses the first instance's captured context and options ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) ## 10.66.0 diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e312c31b59ad..b809342178c8 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -72,9 +72,11 @@ export function instrumentDurableObjectWithSentry< // Any other public methods on the Durable Object instance are RPC calls. + // Bind each built-in handler to this instance before wrapping. + // See https://github.com/getsentry/sentry-javascript/issues/22328 if (obj.fetch && typeof obj.fetch === 'function') { obj.fetch = ensureInstrumented( - obj.fetch, + obj.fetch.bind(obj), original => new Proxy(original, { apply(target, thisArg, args) { @@ -90,25 +92,28 @@ export function instrumentDurableObjectWithSentry< // 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 }, - obj.alarm, + obj.alarm.bind(obj), ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { obj.webSocketMessage = wrapMethodWithSentry( { options, context, spanName: 'webSocketMessage' }, - obj.webSocketMessage, + obj.webSocketMessage.bind(obj), ); } if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { - obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); + obj.webSocketClose = wrapMethodWithSentry( + { options, context, spanName: 'webSocketClose' }, + obj.webSocketClose.bind(obj), + ); } if (obj.webSocketError && typeof obj.webSocketError === 'function') { obj.webSocketError = wrapMethodWithSentry( { options, context, spanName: 'webSocketError' }, - obj.webSocketError, + obj.webSocketError.bind(obj), (_, error) => captureException(error, { mechanism: { diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 83b473fabe4d..ec0c9e8ec708 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,6 +81,38 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); + // Regression for #22328 + // built-in handlers live on the class prototype. + // ensureInstrumented keys its global cache on the original function + // reference, so without per-instance binding a second instance in the + // same isolate reuses the first instance's wrapper. + it('Built-in handlers do not stick to the first instance options across a shared isolate', async () => { + const mockContext = { + waitUntil: vi.fn(), + } as any; + const mockEnv = {} as any; + const initCore = vi.spyOn(SentryCore, 'initAndBind'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + const options = vi.fn().mockReturnValueOnce({ orgId: 1 }).mockReturnValueOnce({ orgId: 2 }); + + const testClass = class { + webSocketMessage() {} + }; + const Instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + + const instance1 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + const instance2 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + + // Each instance must get its own wrapper, not the first instance's cached proxy. + expect(instance2.webSocketMessage).not.toBe(instance1.webSocketMessage); + + await instance1.webSocketMessage(); + await instance2.webSocketMessage(); + + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); + expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); + }); + it('does not create RPC spans without metadata when both RPC options are set', () => { const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined);