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
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>,
);
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();
});
});
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"],
},
],
}
4 changes: 2 additions & 2 deletions packages/cloudflare/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import {
setHttpStatus,
startSpanManual,
winterCGHeadersToDict,
withIsolationScope,
} from '@sentry/core';
import { captureIncomingRequestBody } from './integrations/httpServer';
import { initBaseSdk } from './baseSdk';
import type { CloudflareClient, CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { flushAndDispose, getOriginalWaitUntil } from './flush';
import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils';
import { withInvocationIsolationScope } from './utils/invocationScope';
import { classifyResponseStreaming } from './utils/streaming';

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

Expand Down
30 changes: 30 additions & 0 deletions packages/cloudflare/src/utils/invocationScope.ts
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));
Comment thread
JPeer264 marked this conversation as resolved.
}
Comment thread
JPeer264 marked this conversation as resolved.
11 changes: 2 additions & 9 deletions packages/cloudflare/src/wrapMethodWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,19 @@ import {
isObjectLike,
captureException,
continueTrace,
getClient,
isThenable,
type Scope,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
startNewTrace as startNewTraceCore,
startSpan,
withIsolationScope,
withScope,
} from '@sentry/core';
import type { CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { flushAndDispose, getOriginalWaitUntil } from './flush';
import { ensureInstrumented } from './instrument';
import { init } from './sdk';
import { withInvocationIsolationScope } from './utils/invocationScope';
import { extractRpcMeta } from './utils/rpcMeta';
import { buildSpanLinks, getStoredSpanContext, storeSpanContext } from './utils/traceLinks';

Expand Down Expand Up @@ -112,11 +110,6 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
rpcMeta = extracted.rpcMeta;
}

// For startNewTrace, always use withIsolationScope to ensure a fresh scope
// Otherwise, use existing client's scope or isolation scope
const currentClient = getClient();
const sentryWithScope = startNewTrace ? withIsolationScope : currentClient ? withScope : withIsolationScope;

const wrappedFunction = (scope: Scope): unknown | Promise<unknown> => {
// In certain situations, the passed context can become undefined.
// For example, for Astro while prerendering pages at build time.
Expand Down Expand Up @@ -241,7 +234,7 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
return executeSpan();
};

return sentryWithScope(wrappedFunction);
return withInvocationIsolationScope(wrappedFunction);
},
}),
noMark,
Expand Down
63 changes: 63 additions & 0 deletions packages/cloudflare/test/durableobject.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { ExecutionContext } from '@cloudflare/workers-types';
import type { Event } from '@sentry/core';
import * as SentryCore from '@sentry/core';
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest';
import { instrumentDurableObjectWithSentry } from '../src';
import { getInstrumented } from '../src/instrument';
import { resetSdk } from './testUtils';

describe('instrumentDurableObjectWithSentry', () => {
afterEach(() => {
vi.restoreAllMocks();
resetSdk();
});

it('Generic functionality', () => {
Expand Down Expand Up @@ -197,6 +200,66 @@ describe('instrumentDurableObjectWithSentry', () => {
expect(obj.method).toBe(obj.method);
});

// Hibernation-woken WebSocket messages and alarms arrive as their own invocations with no
// enclosing instrumented handler, so each must open a fresh isolation scope. The Durable Object
// instance outlives them, so a leak here would follow the isolate for its remaining lifetime.
it('Runtime-invoked built-in handlers each get their own isolation scope', async () => {
const events: Event[] = [];
const waits: Promise<unknown>[] = [];
const mockContext = {
waitUntil: vi.fn((promise: Promise<unknown>) => {
waits.push(promise);
}),
} as any;

const testClass = class {
webSocketMessage(_ws: unknown, message: string) {
if (message === 'seed') {
SentryCore.setTag('seeded_tag', 'from-seeding-message');
SentryCore.setUser({ id: 'user-from-seeding-message' });
}

SentryCore.captureMessage(message);
}

alarm() {
SentryCore.captureMessage('alarm');
}
};
const obj = Reflect.construct(
instrumentDurableObjectWithSentry(
() => ({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
beforeSend(event: Event) {
events.push(event);
return null;
},
}),
testClass as any,
),
[mockContext, {} as any],
);

await obj.webSocketMessage({}, 'seed');
await Promise.all(waits.splice(0));
await obj.webSocketMessage({}, 'probe');
await Promise.all(waits.splice(0));
await obj.alarm();
await Promise.all(waits);

// Guards the assertions below against passing vacuously.
expect(events[0]?.tags).toEqual(expect.objectContaining({ seeded_tag: 'from-seeding-message' }));
expect(events[0]?.user).toEqual({ id: 'user-from-seeding-message' });

expect(events[1]?.message).toBe('probe');
expect(events[1]?.tags?.seeded_tag).toBeUndefined();
expect(events[1]?.user).toBeUndefined();

expect(events[2]?.message).toBe('alarm');
expect(events[2]?.tags?.seeded_tag).toBeUndefined();
expect(events[2]?.user).toBeUndefined();
});

it('Built-in durable object methods are always instrumented', () => {
const testClass = class {
fetch() {}
Expand Down
Loading
Loading