Skip to content
Merged
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,21 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
}

const handler = {
async fetch(request: Request, _env: Env, _ctx: ExecutionContext) {
if (request.url.includes('/error')) {
throw new Error('Test error from double-instrumented worker');
}
return new Response('ok');
},
};

// Deliberately call withSentry twice on the same handler object.
// This simulates scenarios where the module is re-evaluated or the handler
// is wrapped multiple times. The SDK should handle this gracefully
// without double-wrapping (which would cause duplicate error reports).
const once = Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN }), handler);
export default Sentry.withSentry((env: Env) => ({ dsn: env.SENTRY_DSN }), once);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, it } from 'vitest';
import { eventEnvelope } from '../../expect';
import { createRunner } from '../../runner';

it('Only sends one error event when withSentry is called twice', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(
eventEnvelope({
level: 'error',
exception: {
values: [
{
type: 'Error',
value: 'Test error from double-instrumented worker',
stacktrace: {
frames: expect.any(Array),
},
mechanism: { type: 'auto.http.cloudflare', handled: false },
},
],
},
request: {
headers: expect.any(Object),
method: 'GET',
url: expect.any(String),
},
}),
)
.start(signal);
await runner.makeRequest('get', '/error', { expectError: true });
await runner.completed();
});

it('Successful response works when withSentry is called twice', async ({ signal }) => {
const runner = createRunner(__dirname).start(signal);
const response = await runner.makeRequest<string>('get', '/');
expect(response).toBe('ok');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
TEST_DURABLE_OBJECT: DurableObjectNamespace;
}

class TestDurableObjectBase extends DurableObject<Env> {
public constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
}

// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
async doWork(): Promise<string> {
const results: string[] = [];

for (let i = 1; i <= 5; i++) {
await Sentry.startSpan({ name: `task-${i}`, op: 'task' }, async () => {
// Simulate async work
await new Promise<void>(resolve => setTimeout(resolve, 1));
results.push(`done-${i}`);
});
}

return results.join(',');
}
}

export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
instrumentPrototypeMethods: true,
}),
TestDurableObjectBase,
);

export default {
async fetch(_request: Request, env: Env): Promise<Response> {
const id: DurableObjectId = env.TEST_DURABLE_OBJECT.idFromName('test');
const stub = env.TEST_DURABLE_OBJECT.get(id) as unknown as TestDurableObjectBase;
const result = await stub.doWork();
return new Response(result);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, it } from 'vitest';
import { createRunner } from '../../../runner';

// Regression test for https://github.com/getsentry/sentry-javascript/issues/20030
// When a Durable Object method calls Sentry.startSpan multiple times, those spans
// must appear as children of the DO transaction. The first invocation always worked;
// the second invocation on the same DO instance previously lost its child spans
// because the client was disposed after the first call.
it('sends child spans on repeated Durable Object calls', async ({ signal }) => {
function assertDoWorkEnvelope(envelope: unknown): void {
const transactionEvent = (envelope as any)[1]?.[0]?.[1];

expect(transactionEvent).toEqual(
expect.objectContaining({
transaction: 'doWork',
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'rpc',
origin: 'auto.faas.cloudflare.durable_object',
}),
}),
}),
);

// All 5 child spans should be present
expect(transactionEvent.spans).toHaveLength(5);
expect(transactionEvent.spans).toEqual(
expect.arrayContaining([
expect.objectContaining({ description: 'task-1', op: 'task' }),
expect.objectContaining({ description: 'task-2', op: 'task' }),
expect.objectContaining({ description: 'task-3', op: 'task' }),
expect.objectContaining({ description: 'task-4', op: 'task' }),
expect.objectContaining({ description: 'task-5', op: 'task' }),
]),
);

// All child spans share the root trace_id
const rootTraceId = transactionEvent.contexts?.trace?.trace_id;
expect(rootTraceId).toBeDefined();
for (const span of transactionEvent.spans) {
expect(span.trace_id).toBe(rootTraceId);
}
}

// Expect 5 transaction envelopes — one per call.
const runner = createRunner(__dirname).expectN(5, assertDoWorkEnvelope).start(signal);

await runner.makeRequest('get', '/');
await runner.makeRequest('get', '/');
await runner.makeRequest('get', '/');
await runner.makeRequest('get', '/');
await runner.makeRequest('get', '/');
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "worker-name",
"main": "index.ts",
"compatibility_date": "2025-06-17",
"migrations": [
{
"new_sqlite_classes": ["TestDurableObject"],
"tag": "v1",
},
],
"durable_objects": {
"bindings": [
{
"class_name": "TestDurableObject",
"name": "TEST_DURABLE_OBJECT",
},
],
},
"compatibility_flags": ["nodejs_als"],
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,4 @@
],
},
"compatibility_flags": ["nodejs_als"],
"vars": {
"SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552",
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,4 @@
"main": "index-sub-worker.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_als"],
"vars": {
"SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552",
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
"main": "index.ts",
"compatibility_date": "2025-06-17",
"compatibility_flags": ["nodejs_als"],
"vars": {
"SENTRY_DSN": "https://932e620ee3921c3b4a61c72558ad88ce@o447951.ingest.us.sentry.io/4509553159831552",
},
"services": [
{
"binding": "ANOTHER_WORKER",
Expand Down
42 changes: 30 additions & 12 deletions packages/cloudflare/src/durableobject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { captureException } from '@sentry/core';
import type { DurableObject } from 'cloudflare:workers';
import { setAsyncLocalStorageAsyncContextStrategy } from './async';
import type { CloudflareOptions } from './client';
import { isInstrumented, markAsInstrumented } from './instrument';
import { ensureInstrumented, getInstrumented, markAsInstrumented } from './instrument';
import { getFinalOptions } from './options';
import { wrapRequestHandler } from './request';
import { instrumentContext } from './utils/instrumentContext';
Expand Down Expand Up @@ -67,16 +67,18 @@ export function instrumentDurableObjectWithSentry<

// Any other public methods on the Durable Object instance are RPC calls.

if (obj.fetch && typeof obj.fetch === 'function' && !isInstrumented(obj.fetch)) {
obj.fetch = new Proxy(obj.fetch, {
apply(target, thisArg, args) {
return wrapRequestHandler({ options, request: args[0], context }, () =>
Reflect.apply(target, thisArg, args),
);
},
});

markAsInstrumented(obj.fetch);
if (obj.fetch && typeof obj.fetch === 'function') {
obj.fetch = ensureInstrumented(
obj.fetch,
original =>
new Proxy(original, {
apply(target, thisArg, args) {
return wrapRequestHandler({ options, request: args[0], context }, () => {
return Reflect.apply(target, thisArg, args);
});
},
}),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DO lifecycle proxies cache stale context across instances

Medium Severity

In the DurableObject construct handler, lifecycle methods like fetch, alarm, webSocketMessage, etc. that live on the prototype are passed to ensureInstrumented / wrapMethodWithSentry. The first construction creates a proxy that captures the instance-specific context (DurableObjectState) and options in its closure, then caches the mapping protoMethod → proxy in the global WeakMap. When a second instance of the same class is constructed, obj.fetch resolves to the same prototype function, ensureInstrumented finds the cached proxy, and returns it — with the first instance's stale context and options baked in. This means waitUntil and other context-dependent operations target the wrong DurableObjectState.

Additional Locations (1)
Fix in Cursor Fix in Web

}

if (obj.alarm && typeof obj.alarm === 'function') {
Expand Down Expand Up @@ -177,7 +179,18 @@ function instrumentPrototype<T extends NewableFunction>(target: T, methodsToInst
methodNames.forEach(methodName => {
const originalMethod = (proto as Record<string, unknown>)[methodName];

if (!originalMethod || isInstrumented(originalMethod)) {
if (!originalMethod) {
return;
}

const existingInstrumented = getInstrumented(originalMethod);
if (existingInstrumented) {
Object.defineProperty(proto, methodName, {
value: existingInstrumented,
enumerable: false,
writable: true,
configurable: true,
});
return;
}

Expand Down Expand Up @@ -216,6 +229,11 @@ function instrumentPrototype<T extends NewableFunction>(target: T, methodsToInst
return wrapper.apply(this, args);
};

// Only mark wrappedMethod as instrumented (not originalMethod → wrappedMethod).
// originalMethod must stay unmapped because wrappedMethod calls
// wrapMethodWithSentry(options, originalMethod) on each invocation to create
// a per-instance proxy. If originalMethod mapped to wrappedMethod, that call
// would return wrappedMethod itself, causing infinite recursion.
markAsInstrumented(wrappedMethod);

// Replace the prototype method
Expand Down
79 changes: 69 additions & 10 deletions packages/cloudflare/src/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,84 @@
type SentryInstrumented<T> = T & {
__SENTRY_INSTRUMENTED__?: boolean;
};
// Use a global WeakMap to track instrumented objects across module instances.
// This is important for Cloudflare Workers where the module may be bundled
// separately for workers and Durable Objects, but they share the same global scope.
// The WeakMap stores original -> instrumented mappings so we can retrieve the
// instrumented version even if we only have a reference to the original.
const GLOBAL_KEY = '__SENTRY_INSTRUMENTED_MAP__' as const;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getInstrumentedMap(): WeakMap<any, any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const globalObj = globalThis as typeof globalThis & { [GLOBAL_KEY]?: WeakMap<any, any> };
if (!globalObj[GLOBAL_KEY]) {
globalObj[GLOBAL_KEY] = new WeakMap();
}
return globalObj[GLOBAL_KEY];
}

/**
* Mark an object as instrumented.
* Check if a value can be used as a WeakMap key.
* WeakMap keys must be objects or non-registered symbols.
*/
export function markAsInstrumented<T>(obj: T): void {
function isWeakMapKey(value: unknown): value is object {
return (typeof value === 'object' && value !== null) || typeof value === 'function';
}

/**
* Mark an object as instrumented, storing the instrumented version.
* @param original The original uninstrumented object
* @param instrumented The instrumented version (defaults to original if not provided)
*/
export function markAsInstrumented<T>(original: T, instrumented?: T): void {
try {
(obj as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__ = true;
if (isWeakMapKey(original)) {
Comment on lines +25 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The construct trap reuses a cached instrumented proxy with a stale context when a Durable Object class is instantiated multiple times, corrupting telemetry.
Severity: CRITICAL

Suggested Fix

When wrapping methods within the construct trap, pass noMark = true to ensureInstrumented (or the equivalent in wrapMethodWithSentry). This will prevent caching the instrumented proxy and ensure a new wrapper with the correct instance-specific context is created for each new Durable Object instance.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/cloudflare/src/instrument.ts#L25-L33

Potential issue: When a Durable Object class is instantiated multiple times, methods
instrumented in the `construct` trap (e.g., `alarm`, `webSocketMessage`) are wrapped
using `ensureInstrumented` without preventing caching. This causes the instrumented
proxy from the first instance, which has a closure over the initial `context` and
`options`, to be reused for all subsequent instances. As a result, errors and spans from
these later instances will be reported with the incorrect context of the first instance,
leading to corrupted monitoring data.

Did we get this right? 👍 / 👎 to inform future reviews.

// Store mapping from original to instrumented version
// If instrumented is not provided, store original (for backwards compat)
getInstrumentedMap().set(original, instrumented ?? original);
}
// Also mark the instrumented version itself so we recognize it
if (isWeakMapKey(instrumented) && instrumented !== original) {
getInstrumentedMap().set(instrumented, instrumented);
}
} catch {
// ignore errors here
}
}

/**
* Check if an object is instrumented.
* Get the instrumented version of an object, if available.
* Returns the instrumented version if the object was previously instrumented,
* or undefined if not found.
*/
export function isInstrumented<T>(obj: T): boolean | undefined {
export function getInstrumented<T>(obj: T): T | undefined {
try {
return (obj as SentryInstrumented<T>).__SENTRY_INSTRUMENTED__;
if (isWeakMapKey(obj)) {
return getInstrumentedMap().get(obj) as T | undefined;
}

return undefined;
} catch {
return false;
return undefined;
}
}

/**
* Returns the already-instrumented version of `original` if one exists,
* otherwise calls `instrumentFn` to create it, marks the mapping, and returns it.
*
* @param noMark - If true, skips storing the original→instrumented mapping.
*/
export function ensureInstrumented<T>(original: T, instrumentFn: (original: T) => T, noMark?: boolean): T {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For later, I think this is such a clean function that we can use it and its friends in core.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it is fitting everywhere, as usually we don't need to overwrite functions again.

Copy link
Copy Markdown
Member

@logaretm logaretm Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't anything that gets run in cloudflare workers is subject to the same issue?

const existing = getInstrumented(original);

if (existing) {
return existing;
}

const instrumented = instrumentFn(original);

if (!noMark) {
markAsInstrumented(original, instrumented);
}

return instrumented;
}
Loading
Loading