Skip to content

Commit f0fbaa4

Browse files
JPeer264cursoragentclaude
committed
fix(cloudflare): Fork the isolation scope for Durable Object methods
`setUser`/`setTag` write to the isolation scope, and a Durable Object keeps that scope across invocations. Methods only forked the current scope while no client was bound — but disposing a client at the invocation boundary does not unbind it, so from the second invocation onward the still-assigned client made every entry point look reentrant and skip its fork. Data from one invocation thus reappeared on the next, and a user identity could attach itself to an unrelated event. An instrumented handler is either an invocation's entry point or reentrant (a DO method calling its own `fetch`, an RPC method reaching a sibling). Only the entry point may fork; a bound client can't distinguish the two, so `withInvocationIsolationScope` records the fact directly as a marker in SDK processing metadata (stripped before send). The stack fallback doesn't clone, so its scope is left unmarked rather than making every later entry point look reentrant. Forking loses nothing — it clones, inheriting enclosing request data — and matches how the Worker `fetch` path already behaves. Covered by integration tests against a real Durable Object (consecutive invocations, a nested direct call, a nested call onto the instrumented `fetch`) and unit tests for the reentrancy logic plus the `instrumentWorkerEntrypoint` RPC and `webSocketMessage`/`alarm` consumers. Co-authored-by: Cursor <cursoragent@cursor.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent f9c83f8 commit f0fbaa4

10 files changed

Lines changed: 535 additions & 65 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
SCOPE_DO: DurableObjectNamespace;
7+
}
8+
9+
class ScopeDurableObjectBase extends DurableObject<Env> {
10+
/**
11+
* `setTag`/`setUser` write to the isolation scope, which a Durable Object keeps across
12+
* invocations. Only the seeding invocation writes, so whatever a later invocation reports it
13+
* must have inherited from a scope the two shared.
14+
*/
15+
async scopeCheck(seed: boolean): Promise<string> {
16+
if (seed) {
17+
Sentry.setTag('seeded_tag', 'from-seeding-invocation');
18+
Sentry.setUser({ id: 'user-from-seeding-invocation' });
19+
}
20+
21+
Sentry.captureException(new Error(seed ? 'Scope seed' : 'Scope probe'));
22+
23+
return 'ok';
24+
}
25+
26+
/**
27+
* A direct method call on the same Durable Object is part of the calling invocation, so it
28+
* must see — and be able to extend — the same isolation scope. Only the outer method captures:
29+
* if the nested call ran in its own scope, the outer event would miss `inner_tag` and the user.
30+
*/
31+
async nestedScopeCheck(): Promise<string> {
32+
Sentry.setTag('outer_tag', 'from-outer-method');
33+
34+
await this.innerScopeHelper();
35+
36+
Sentry.captureException(new Error('Nested outer'));
37+
38+
return 'ok';
39+
}
40+
41+
async innerScopeHelper(): Promise<void> {
42+
Sentry.setTag('inner_tag', 'from-inner-method');
43+
Sentry.setUser({ id: 'user-from-inner-method' });
44+
}
45+
46+
/**
47+
* Same as `nestedScopeCheck`, but the nested call lands on `fetch` — an instrumented handler that
48+
* opens an isolation scope of its own. Reaching it from inside another invocation must not fork
49+
* again, or the nested handler would not see what the calling method set.
50+
*
51+
* The capture happens inside the nested call rather than after it: the nested handler tears its
52+
* client down on the way out, so a capture in the calling method would have no transport left.
53+
*/
54+
async reentrantScopeCheck(): Promise<string> {
55+
Sentry.setTag('reentrant_outer_tag', 'from-rpc-method');
56+
Sentry.setUser({ id: 'user-from-rpc-method' });
57+
58+
await this.fetch(new Request('https://durable-object.invalid/inner'));
59+
60+
return 'ok';
61+
}
62+
63+
async fetch(_request: Request): Promise<Response> {
64+
Sentry.setTag('fetch_tag', 'from-nested-fetch');
65+
Sentry.captureException(new Error('Reentrant inner'));
66+
67+
// Deliberately bodyless. A `text/plain` body without a `content-length` is classified as
68+
// streaming, and nothing here ever reads the nested response, so the span would stay open and
69+
// hold up the flush.
70+
return new Response(null, { status: 204 });
71+
}
72+
}
73+
74+
export const ScopeDurableObject = Sentry.instrumentDurableObjectWithSentry(
75+
(env: Env) => ({
76+
dsn: env.SENTRY_DSN,
77+
tracesSampleRate: 1,
78+
enableRpcTracePropagation: true,
79+
}),
80+
ScopeDurableObjectBase,
81+
);
82+
83+
export default Sentry.withSentry(
84+
(env: Env) => ({
85+
dsn: env.SENTRY_DSN,
86+
tracesSampleRate: 1,
87+
enableRpcTracePropagation: true,
88+
}),
89+
{
90+
async fetch(request, env) {
91+
const url = new URL(request.url);
92+
93+
if (url.pathname === '/scope') {
94+
// Always the same instance, so both invocations land on the same Durable Object.
95+
const stub = env.SCOPE_DO.get(env.SCOPE_DO.idFromName('scope-do')) as DurableObjectStub<ScopeDurableObjectBase>;
96+
97+
return new Response(await stub.scopeCheck(url.searchParams.get('seed') === '1'));
98+
}
99+
100+
if (url.pathname === '/nested') {
101+
const stub = env.SCOPE_DO.get(env.SCOPE_DO.idFromName('scope-do')) as DurableObjectStub<ScopeDurableObjectBase>;
102+
103+
return new Response(await stub.nestedScopeCheck());
104+
}
105+
106+
if (url.pathname === '/reentrant') {
107+
const stub = env.SCOPE_DO.get(env.SCOPE_DO.idFromName('scope-do')) as DurableObjectStub<ScopeDurableObjectBase>;
108+
109+
return new Response(await stub.reentrantScopeCheck());
110+
}
111+
112+
return new Response('Hello World!');
113+
},
114+
} satisfies ExportedHandler<Env>,
115+
);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Envelope, Event } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
it('cacheClient: false - two consecutive invocations get different isolation scopes', async ({ signal }) => {
6+
const runner = createRunner(__dirname).ignore('transaction').start(signal);
7+
8+
await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=1', (envelope: Envelope) => {
9+
const event = envelope[1]?.[0]?.[1] as Event;
10+
expect(event.exception?.values?.[0]?.value).toBe('Scope seed');
11+
// Guards the probe assertions below against passing vacuously: the seeding invocation really
12+
// did write to its isolation scope.
13+
expect(event.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-invocation' }));
14+
expect(event.user).toEqual({ id: 'user-from-seeding-invocation' });
15+
});
16+
17+
await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=0', (envelope: Envelope) => {
18+
const event = envelope[1]?.[0]?.[1] as Event;
19+
expect(event.exception?.values?.[0]?.value).toBe('Scope probe');
20+
expect(event.tags?.seeded_tag).toBeUndefined();
21+
expect(event.user).toBeUndefined();
22+
});
23+
});
24+
25+
it('a nested direct call within one invocation shares the same isolation scope', async ({ signal }) => {
26+
const runner = createRunner(__dirname).ignore('transaction').start(signal);
27+
28+
await runner.makeRequestAndWaitForEnvelope('get', '/nested', (envelope: Envelope) => {
29+
const event = envelope[1]?.[0]?.[1] as Event;
30+
expect(event.exception?.values?.[0]?.value).toBe('Nested outer');
31+
// The event must carry data written on both sides of the nested call: `outer_tag` from
32+
// before it, `inner_tag` and the user from inside it — anything less means the nested
33+
// call ran in its own scope.
34+
expect(event.tags).toEqual(
35+
expect.objectContaining({
36+
outer_tag: 'from-outer-method',
37+
inner_tag: 'from-inner-method',
38+
}),
39+
);
40+
expect(event.user).toEqual({ id: 'user-from-inner-method' });
41+
});
42+
43+
// Whatever the nested invocation wrote must not survive into the next invocation.
44+
await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=0', (envelope: Envelope) => {
45+
const event = envelope[1]?.[0]?.[1] as Event;
46+
expect(event.exception?.values?.[0]?.value).toBe('Scope probe');
47+
expect(event.tags?.outer_tag).toBeUndefined();
48+
expect(event.tags?.inner_tag).toBeUndefined();
49+
expect(event.user).toBeUndefined();
50+
});
51+
});
52+
53+
it('a nested call into another instrumented handler shares the same isolation scope', async ({ signal }) => {
54+
const runner = createRunner(__dirname).ignore('transaction').start(signal);
55+
56+
await runner.makeRequestAndWaitForEnvelope('get', '/reentrant', (envelope: Envelope) => {
57+
const event = envelope[1]?.[0]?.[1] as Event;
58+
expect(event.exception?.values?.[0]?.value).toBe('Reentrant inner');
59+
// `fetch` is itself instrumented and opens an isolation scope. Reached from inside the RPC
60+
// invocation it must not fork again, or it would not see what the RPC method set.
61+
expect(event.tags).toEqual(
62+
expect.objectContaining({
63+
reentrant_outer_tag: 'from-rpc-method',
64+
fetch_tag: 'from-nested-fetch',
65+
}),
66+
);
67+
expect(event.user).toEqual({ id: 'user-from-rpc-method' });
68+
});
69+
70+
await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=0', (envelope: Envelope) => {
71+
const event = envelope[1]?.[0]?.[1] as Event;
72+
expect(event.exception?.values?.[0]?.value).toBe('Scope probe');
73+
expect(event.tags?.reentrant_outer_tag).toBeUndefined();
74+
expect(event.tags?.fetch_tag).toBeUndefined();
75+
expect(event.user).toBeUndefined();
76+
});
77+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "durable-object-scope-test",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_als"],
6+
"durable_objects": {
7+
"bindings": [{ "name": "SCOPE_DO", "class_name": "ScopeDurableObject" }],
8+
},
9+
"migrations": [
10+
{
11+
"tag": "v1",
12+
"new_sqlite_classes": ["ScopeDurableObject"],
13+
},
14+
],
15+
}

packages/cloudflare/src/request.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import {
1010
setHttpStatus,
1111
startSpanManual,
1212
winterCGHeadersToDict,
13-
withIsolationScope,
1413
} from '@sentry/core';
1514
import { captureIncomingRequestBody } from './integrations/httpServer';
1615
import { initBaseSdk } from './baseSdk';
1716
import type { CloudflareClient, CloudflareOptions } from './client';
1817
import type { ExecutionContextCompat } from './executionContext';
1918
import { flushAndDispose, getOriginalWaitUntil } from './flush';
2019
import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils';
20+
import { withInvocationIsolationScope } from './utils/invocationScope';
2121
import { classifyResponseStreaming } from './utils/streaming';
2222

2323
function getRequestErrorMechanismType(context: ExecutionContextCompat | undefined): string {
@@ -72,7 +72,7 @@ export function wrapRequestHandlerWithInit(
7272
handler: (...args: unknown[]) => Response | Promise<Response>,
7373
initSdk: InitSdk,
7474
): Promise<Response> {
75-
return withIsolationScope(async isolationScope => {
75+
return withInvocationIsolationScope(async isolationScope => {
7676
const { options, request, captureErrors = true } = wrapperOptions;
7777
const context = wrapperOptions.context;
7878

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { getDefaultIsolationScope, getIsolationScope, type Scope, withIsolationScope } from '@sentry/core';
2+
3+
/**
4+
* Runs `callback` on the isolation scope for the current invocation.
5+
*
6+
* An instrumented handler is either the entry point of an invocation or reentrant — reached from
7+
* another instrumented handler already serving the same invocation (a Durable Object method calling
8+
* its own `fetch`, an RPC method reaching a sibling method). Only the entry point may fork:
9+
*
10+
* - Forking at the entry point is mandatory. `setUser`/`setTag` write to the isolation scope, and a
11+
* Durable Object's isolation scope outlives the invocation that touched it, so without a fork one
12+
* invocation's user and tags reappear on the next invocation's events in the same isolate.
13+
* Forking clones, so request data set by an enclosing wrapper is still inherited.
14+
* - Forking again when reentrant would be wrong. Everything below the entry point is one logical
15+
* unit of work: a nested call must see what the caller set and be able to add to it, the way it
16+
* would if the SDK were not wrapping it at all.
17+
*
18+
* The AsyncLocalStorage strategy hands the default isolation scope back whenever no invocation is in
19+
* flight, and a forked one while inside `withIsolationScope`. Reference-comparing against the default
20+
* is therefore enough to tell the two cases apart. The stack fallback does not fork, so it reports the
21+
* default scope even inside an invocation; there the fork degrades to a no-op, which the stack strategy
22+
* tolerates. This matches the approach used by `patchEventHandler` in Nuxt.
23+
*/
24+
export function withInvocationIsolationScope<T>(callback: (scope: Scope) => T): T {
25+
const isolationScope = getIsolationScope();
26+
27+
const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope;
28+
29+
return withIsolationScope(newIsolationScope, () => callback(newIsolationScope));
30+
}

packages/cloudflare/src/wrapMethodWithSentry.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,19 @@ import {
44
isObjectLike,
55
captureException,
66
continueTrace,
7-
getClient,
87
isThenable,
98
type Scope,
109
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1110
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1211
startNewTrace as startNewTraceCore,
1312
startSpan,
14-
withIsolationScope,
15-
withScope,
1613
} from '@sentry/core';
1714
import type { CloudflareOptions } from './client';
1815
import type { ExecutionContextCompat } from './executionContext';
1916
import { flushAndDispose, getOriginalWaitUntil } from './flush';
2017
import { ensureInstrumented } from './instrument';
2118
import { init } from './sdk';
19+
import { withInvocationIsolationScope } from './utils/invocationScope';
2220
import { extractRpcMeta } from './utils/rpcMeta';
2321
import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks';
2422

@@ -112,11 +110,6 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
112110
rpcMeta = extracted.rpcMeta;
113111
}
114112

115-
// For startNewTrace, always use withIsolationScope to ensure a fresh scope
116-
// Otherwise, use existing client's scope or isolation scope
117-
const currentClient = getClient();
118-
const sentryWithScope = startNewTrace ? withIsolationScope : currentClient ? withScope : withIsolationScope;
119-
120113
const wrappedFunction = (scope: Scope): unknown | Promise<unknown> => {
121114
// In certain situations, the passed context can become undefined.
122115
// For example, for Astro while prerendering pages at build time.
@@ -241,7 +234,7 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
241234
return executeSpan();
242235
};
243236

244-
return sentryWithScope(wrappedFunction);
237+
return withInvocationIsolationScope(wrappedFunction);
245238
},
246239
}),
247240
noMark,

packages/cloudflare/test/durableobject.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { ExecutionContext } from '@cloudflare/workers-types';
2+
import type { Event } from '@sentry/core';
23
import * as SentryCore from '@sentry/core';
34
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest';
45
import { instrumentDurableObjectWithSentry } from '../src';
56
import { getInstrumented } from '../src/instrument';
7+
import { resetSdk } from './testUtils';
68

79
describe('instrumentDurableObjectWithSentry', () => {
810
afterEach(() => {
911
vi.restoreAllMocks();
12+
resetSdk();
1013
});
1114

1215
it('Generic functionality', () => {
@@ -197,6 +200,66 @@ describe('instrumentDurableObjectWithSentry', () => {
197200
expect(obj.method).toBe(obj.method);
198201
});
199202

203+
// Hibernation-woken WebSocket messages and alarms arrive as their own invocations with no
204+
// enclosing instrumented handler, so each must open a fresh isolation scope. The Durable Object
205+
// instance outlives them, so a leak here would follow the isolate for its remaining lifetime.
206+
it('Runtime-invoked built-in handlers each get their own isolation scope', async () => {
207+
const events: Event[] = [];
208+
const waits: Promise<unknown>[] = [];
209+
const mockContext = {
210+
waitUntil: vi.fn((promise: Promise<unknown>) => {
211+
waits.push(promise);
212+
}),
213+
} as any;
214+
215+
const testClass = class {
216+
webSocketMessage(_ws: unknown, message: string) {
217+
if (message === 'seed') {
218+
SentryCore.setTag('seeded_tag', 'from-seeding-message');
219+
SentryCore.setUser({ id: 'user-from-seeding-message' });
220+
}
221+
222+
SentryCore.captureMessage(message);
223+
}
224+
225+
alarm() {
226+
SentryCore.captureMessage('alarm');
227+
}
228+
};
229+
const obj = Reflect.construct(
230+
instrumentDurableObjectWithSentry(
231+
() => ({
232+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
233+
beforeSend(event: Event) {
234+
events.push(event);
235+
return null;
236+
},
237+
}),
238+
testClass as any,
239+
),
240+
[mockContext, {} as any],
241+
);
242+
243+
await obj.webSocketMessage({}, 'seed');
244+
await Promise.all(waits.splice(0));
245+
await obj.webSocketMessage({}, 'probe');
246+
await Promise.all(waits.splice(0));
247+
await obj.alarm();
248+
await Promise.all(waits);
249+
250+
// Guards the assertions below against passing vacuously.
251+
expect(events[0]?.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-message' }));
252+
expect(events[0]?.user).toEqual({ id: 'user-from-seeding-message' });
253+
254+
expect(events[1]?.message).toBe('probe');
255+
expect(events[1]?.tags?.seeded_tag).toBeUndefined();
256+
expect(events[1]?.user).toBeUndefined();
257+
258+
expect(events[2]?.message).toBe('alarm');
259+
expect(events[2]?.tags?.seeded_tag).toBeUndefined();
260+
expect(events[2]?.user).toBeUndefined();
261+
});
262+
200263
it('Built-in durable object methods are always instrumented', () => {
201264
const testClass = class {
202265
fetch() {}

0 commit comments

Comments
 (0)