-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(cloudflare): Fork the isolation scope for Durable Object methods #22969
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JPeer264
wants to merge
2
commits into
develop
Choose a base branch
from
jp/cloudflare-do-isolation-scope
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
115 changes: 115 additions & 0 deletions
115
dev-packages/cloudflare-integration-tests/suites/durableobject-scope/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import * as Sentry from '@sentry/cloudflare'; | ||
| import { DurableObject } from 'cloudflare:workers'; | ||
|
|
||
| interface Env { | ||
| SENTRY_DSN: string; | ||
| SCOPE_DO: DurableObjectNamespace; | ||
| } | ||
|
|
||
| class ScopeDurableObjectBase extends DurableObject<Env> { | ||
| /** | ||
| * `setTag`/`setUser` write to the isolation scope, which a Durable Object keeps across | ||
| * invocations. Only the seeding invocation writes, so whatever a later invocation reports it | ||
| * must have inherited from a scope the two shared. | ||
| */ | ||
| async scopeCheck(seed: boolean): Promise<string> { | ||
| if (seed) { | ||
| Sentry.setTag('seeded_tag', 'from-seeding-invocation'); | ||
| Sentry.setUser({ id: 'user-from-seeding-invocation' }); | ||
| } | ||
|
|
||
| Sentry.captureException(new Error(seed ? 'Scope seed' : 'Scope probe')); | ||
|
|
||
| return 'ok'; | ||
| } | ||
|
|
||
| /** | ||
| * A direct method call on the same Durable Object is part of the calling invocation, so it | ||
| * must see — and be able to extend — the same isolation scope. Only the outer method captures: | ||
| * if the nested call ran in its own scope, the outer event would miss `inner_tag` and the user. | ||
| */ | ||
| async nestedScopeCheck(): Promise<string> { | ||
| Sentry.setTag('outer_tag', 'from-outer-method'); | ||
|
|
||
| await this.innerScopeHelper(); | ||
|
|
||
| Sentry.captureException(new Error('Nested outer')); | ||
|
|
||
| return 'ok'; | ||
| } | ||
|
|
||
| async innerScopeHelper(): Promise<void> { | ||
| Sentry.setTag('inner_tag', 'from-inner-method'); | ||
| Sentry.setUser({ id: 'user-from-inner-method' }); | ||
| } | ||
|
|
||
| /** | ||
| * Same as `nestedScopeCheck`, but the nested call lands on `fetch` — an instrumented handler that | ||
| * opens an isolation scope of its own. Reaching it from inside another invocation must not fork | ||
| * again, or the nested handler would not see what the calling method set. | ||
| * | ||
| * The capture happens inside the nested call rather than after it: the nested handler tears its | ||
| * client down on the way out, so a capture in the calling method would have no transport left. | ||
| */ | ||
| async reentrantScopeCheck(): Promise<string> { | ||
| Sentry.setTag('reentrant_outer_tag', 'from-rpc-method'); | ||
| Sentry.setUser({ id: 'user-from-rpc-method' }); | ||
|
|
||
| await this.fetch(new Request('https://durable-object.invalid/inner')); | ||
|
|
||
| return 'ok'; | ||
| } | ||
|
|
||
| async fetch(_request: Request): Promise<Response> { | ||
| Sentry.setTag('fetch_tag', 'from-nested-fetch'); | ||
| Sentry.captureException(new Error('Reentrant inner')); | ||
|
|
||
| // Deliberately bodyless. A `text/plain` body without a `content-length` is classified as | ||
| // streaming, and nothing here ever reads the nested response, so the span would stay open and | ||
| // hold up the flush. | ||
| return new Response(null, { status: 204 }); | ||
| } | ||
| } | ||
|
|
||
| export const ScopeDurableObject = Sentry.instrumentDurableObjectWithSentry( | ||
| (env: Env) => ({ | ||
| dsn: env.SENTRY_DSN, | ||
| tracesSampleRate: 1, | ||
| enableRpcTracePropagation: true, | ||
| }), | ||
| ScopeDurableObjectBase, | ||
| ); | ||
|
|
||
| export default Sentry.withSentry( | ||
| (env: Env) => ({ | ||
| dsn: env.SENTRY_DSN, | ||
| tracesSampleRate: 1, | ||
| enableRpcTracePropagation: true, | ||
| }), | ||
| { | ||
| async fetch(request, env) { | ||
| const url = new URL(request.url); | ||
|
|
||
| if (url.pathname === '/scope') { | ||
| // Always the same instance, so both invocations land on the same Durable Object. | ||
| const stub = env.SCOPE_DO.get(env.SCOPE_DO.idFromName('scope-do')) as DurableObjectStub<ScopeDurableObjectBase>; | ||
|
|
||
| return new Response(await stub.scopeCheck(url.searchParams.get('seed') === '1')); | ||
| } | ||
|
|
||
| if (url.pathname === '/nested') { | ||
| const stub = env.SCOPE_DO.get(env.SCOPE_DO.idFromName('scope-do')) as DurableObjectStub<ScopeDurableObjectBase>; | ||
|
|
||
| return new Response(await stub.nestedScopeCheck()); | ||
| } | ||
|
|
||
| if (url.pathname === '/reentrant') { | ||
| const stub = env.SCOPE_DO.get(env.SCOPE_DO.idFromName('scope-do')) as DurableObjectStub<ScopeDurableObjectBase>; | ||
|
|
||
| return new Response(await stub.reentrantScopeCheck()); | ||
| } | ||
|
|
||
| return new Response('Hello World!'); | ||
| }, | ||
| } satisfies ExportedHandler<Env>, | ||
| ); |
77 changes: 77 additions & 0 deletions
77
dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import type { Envelope, Event } from '@sentry/core'; | ||
| import { expect, it } from 'vitest'; | ||
| import { createRunner } from '../../runner'; | ||
|
|
||
| it('two consecutive invocations get different isolation scopes', async ({ signal }) => { | ||
| const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); | ||
|
|
||
| await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=1', (envelope: Envelope) => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('Scope seed'); | ||
| // Guards the probe assertions below against passing vacuously: the seeding invocation really | ||
| // did write to its isolation scope. | ||
| expect(event.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-invocation' })); | ||
| expect(event.user).toEqual({ id: 'user-from-seeding-invocation' }); | ||
| }); | ||
|
|
||
| await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=0', (envelope: Envelope) => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('Scope probe'); | ||
| expect(event.tags?.seeded_tag).toBeUndefined(); | ||
| expect(event.user).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| it('a nested direct call within one invocation shares the same isolation scope', async ({ signal }) => { | ||
| const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); | ||
|
|
||
| await runner.makeRequestAndWaitForEnvelope('get', '/nested', (envelope: Envelope) => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('Nested outer'); | ||
| // The event must carry data written on both sides of the nested call: `outer_tag` from | ||
| // before it, `inner_tag` and the user from inside it — anything less means the nested | ||
| // call ran in its own scope. | ||
| expect(event.tags).toEqual( | ||
| expect.objectContaining({ | ||
| outer_tag: 'from-outer-method', | ||
| inner_tag: 'from-inner-method', | ||
| }), | ||
| ); | ||
| expect(event.user).toEqual({ id: 'user-from-inner-method' }); | ||
| }); | ||
|
|
||
| // Whatever the nested invocation wrote must not survive into the next invocation. | ||
| await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=0', (envelope: Envelope) => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('Scope probe'); | ||
| expect(event.tags?.outer_tag).toBeUndefined(); | ||
| expect(event.tags?.inner_tag).toBeUndefined(); | ||
| expect(event.user).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| it('a nested call into another instrumented handler shares the same isolation scope', async ({ signal }) => { | ||
| const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal); | ||
|
|
||
| await runner.makeRequestAndWaitForEnvelope('get', '/reentrant', (envelope: Envelope) => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('Reentrant inner'); | ||
| // `fetch` is itself instrumented and opens an isolation scope. Reached from inside the RPC | ||
| // invocation it must not fork again, or it would not see what the RPC method set. | ||
| expect(event.tags).toEqual( | ||
| expect.objectContaining({ | ||
| reentrant_outer_tag: 'from-rpc-method', | ||
| fetch_tag: 'from-nested-fetch', | ||
| }), | ||
| ); | ||
| expect(event.user).toEqual({ id: 'user-from-rpc-method' }); | ||
| }); | ||
|
|
||
| await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=0', (envelope: Envelope) => { | ||
| const event = envelope[1]?.[0]?.[1] as Event; | ||
| expect(event.exception?.values?.[0]?.value).toBe('Scope probe'); | ||
| expect(event.tags?.reentrant_outer_tag).toBeUndefined(); | ||
| expect(event.tags?.fetch_tag).toBeUndefined(); | ||
| expect(event.user).toBeUndefined(); | ||
| }); | ||
| }); |
15 changes: 15 additions & 0 deletions
15
dev-packages/cloudflare-integration-tests/suites/durableobject-scope/wrangler.jsonc
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "name": "durable-object-scope-test", | ||
| "compatibility_date": "2025-06-17", | ||
| "main": "index.ts", | ||
| "compatibility_flags": ["nodejs_compat"], | ||
| "durable_objects": { | ||
| "bindings": [{ "name": "SCOPE_DO", "class_name": "ScopeDurableObject" }], | ||
| }, | ||
| "migrations": [ | ||
| { | ||
| "tag": "v1", | ||
| "new_sqlite_classes": ["ScopeDurableObject"], | ||
| }, | ||
| ], | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { getDefaultIsolationScope, getIsolationScope, type Scope, withIsolationScope } from '@sentry/core'; | ||
|
|
||
| /** | ||
| * Runs `callback` on the isolation scope for the current invocation. | ||
| * | ||
| * An instrumented handler is either the entry point of an invocation or reentrant — reached from | ||
| * another instrumented handler already serving the same invocation (a Durable Object method calling | ||
| * its own `fetch`, an RPC method reaching a sibling method). Only the entry point may fork: | ||
| * | ||
| * - Forking at the entry point is mandatory. `setUser`/`setTag` write to the isolation scope, and a | ||
| * Durable Object's isolation scope outlives the invocation that touched it, so without a fork one | ||
| * invocation's user and tags reappear on the next invocation's events in the same isolate. | ||
| * Forking clones, so request data set by an enclosing wrapper is still inherited. | ||
| * - Forking again when reentrant would be wrong. Everything below the entry point is one logical | ||
| * unit of work: a nested call must see what the caller set and be able to add to it, the way it | ||
| * would if the SDK were not wrapping it at all. | ||
| * | ||
| * The AsyncLocalStorage strategy hands the default isolation scope back whenever no invocation is in | ||
| * flight, and a forked one while inside `withIsolationScope`. Reference-comparing against the default | ||
| * is therefore enough to tell the two cases apart. The stack fallback does not fork, so it reports the | ||
| * default scope even inside an invocation; there the fork degrades to a no-op, which the stack strategy | ||
| * tolerates. This matches the approach used by `patchEventHandler` in Nuxt. | ||
| */ | ||
| export function withInvocationIsolationScope<T>(callback: (scope: Scope) => T): T { | ||
| const isolationScope = getIsolationScope(); | ||
|
|
||
| const newIsolationScope = isolationScope === getDefaultIsolationScope() ? isolationScope.clone() : isolationScope; | ||
|
|
||
| return withIsolationScope(newIsolationScope, () => callback(newIsolationScope)); | ||
| } | ||
|
JPeer264 marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.