Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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: {
Expand Down
32 changes: 32 additions & 0 deletions packages/cloudflare/test/durableobject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading