-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(cloudflare): Ensure every request instruments functions #20044
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
| @@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: The Suggested FixWhen wrapping methods within the Prompt for AI AgentDid 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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
There was a problem hiding this comment.
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
constructhandler, lifecycle methods likefetch,alarm,webSocketMessage, etc. that live on the prototype are passed toensureInstrumented/wrapMethodWithSentry. The first construction creates a proxy that captures the instance-specificcontext(DurableObjectState) andoptionsin its closure, then caches the mappingprotoMethod → proxyin the global WeakMap. When a second instance of the same class is constructed,obj.fetchresolves to the same prototype function,ensureInstrumentedfinds the cached proxy, and returns it — with the first instance's stalecontextandoptionsbaked in. This meanswaitUntiland other context-dependent operations target the wrong DurableObjectState.Additional Locations (1)
packages/cloudflare/src/wrapMethodWithSentry.ts#L48-L170