From e9fad917595b91e6bb8efc40e73ab2188ee5d21f Mon Sep 17 00:00:00 2001 From: cod1k Date: Mon, 23 Jun 2025 09:06:13 +0300 Subject: [PATCH 1/7] Introduce `makeFlushAfterAll` utility to streamline and customize Sentry's `flush` handling across Cloudflare handlers. --- packages/cloudflare/src/flush.ts | 35 ++++++++++++++++++++++++++++++ packages/cloudflare/src/handler.ts | 14 +++++++----- packages/cloudflare/src/request.ts | 7 +++--- 3 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 packages/cloudflare/src/flush.ts diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts new file mode 100644 index 000000000000..8fd6d631361f --- /dev/null +++ b/packages/cloudflare/src/flush.ts @@ -0,0 +1,35 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; +import { flush } from '@sentry/core'; + +type Flusher = (...params: Parameters) => void; + +/** + * Enhances the given execution context by wrapping its `waitUntil` method with a proxy + * to monitor pending tasks, and provides a flusher function to ensure all tasks + * have been completed before executing any subsequent logic. + * + * @param {ExecutionContext | void} context - The execution context to be enhanced. If no context is provided, the function returns undefined. + * @return {Flusher | void} Returns a flusher function if a valid context is provided, otherwise undefined. + */ +export function makeFlushAfterAll(context: ExecutionContext): Flusher { + let resolveAllDone: () => void = () => undefined; + const allDone = new Promise(res => { + resolveAllDone = res; + }); + let pending = 0; + const originalWaitUntil = context.waitUntil.bind(context) as typeof context.waitUntil; + context.waitUntil = promise => { + pending++; + return originalWaitUntil( + promise.finally(() => { + if (--pending === 0) resolveAllDone(); + }), + ); + }; + return (...params: Parameters) => { + if (pending === 0) { + return originalWaitUntil(flush(...params)); + } + originalWaitUntil(allDone.finally(() => flush(...params))); + }; +} diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index d3d1f80dbbd5..0a24f156bdb5 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,6 +1,5 @@ import { captureException, - flush, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -9,6 +8,7 @@ import { } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; +import { makeFlushAfterAll } from './flush'; import { isInstrumented, markAsInstrumented } from './instrument'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; @@ -72,6 +72,7 @@ export function withSentry>) { const [event, env, context] = args; + const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -99,7 +100,7 @@ export function withSentry>) { const [emailMessage, env, context] = args; + const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -139,7 +141,7 @@ export function withSentry>) { const [batch, env, context] = args; + const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -185,7 +188,7 @@ export function withSentry>) { const [, env, context] = args; + const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(async isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -215,7 +219,7 @@ export function withSentry Date: Mon, 23 Jun 2025 09:06:27 +0300 Subject: [PATCH 2/7] Add tests for ensuring `flush` is called after all `waitUntil` promises resolve --- packages/cloudflare/test/flush.test.ts | 35 +++++ packages/cloudflare/test/handler.test.ts | 172 ++++++++++++++++++++--- packages/cloudflare/test/request.test.ts | 7 +- 3 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 packages/cloudflare/test/flush.test.ts diff --git a/packages/cloudflare/test/flush.test.ts b/packages/cloudflare/test/flush.test.ts new file mode 100644 index 000000000000..d8cfea1d84bf --- /dev/null +++ b/packages/cloudflare/test/flush.test.ts @@ -0,0 +1,35 @@ +import { type ExecutionContext } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { makeFlushAfterAll } from '../src/flush'; + +describe('Flush buffer test', () => { + const waitUntilPromises: Promise[] = []; + const mockExecutionContext: ExecutionContext = { + waitUntil: vi.fn(prmise => { + waitUntilPromises.push(prmise); + }), + passThroughOnException: vi.fn(), + }; + it('should flush buffer immediately if no waitUntil were called', () => { + const coreFlush = vi.spyOn(SentryCore, 'flush'); + const flush = makeFlushAfterAll(mockExecutionContext); + flush(); + expect(coreFlush).toBeCalled(); + }); + it('should flush buffer only after all waitUntil were finished', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const task = new Promise(resolve => setTimeout(resolve, 100)); + const coreFlush = vi.spyOn(SentryCore, 'flush'); + const flush = makeFlushAfterAll(mockExecutionContext); + mockExecutionContext.waitUntil(task); + flush(); + expect(coreFlush).not.toBeCalled(); + vi.advanceTimersToNextTimer(); + await Promise.all(waitUntilPromises); + expect(coreFlush).toBeCalled(); + }); +}); diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index bced0fdbe277..cc814df876ae 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -1,10 +1,16 @@ // Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. -import type { ForwardableEmailMessage, MessageBatch, ScheduledController, TraceItem } from '@cloudflare/workers-types'; +import type { + ExecutionContext, + ForwardableEmailMessage, + MessageBatch, + ScheduledController, + TraceItem, +} from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; import { withSentry } from '../src/handler'; import { markAsInstrumented } from '../src/instrument'; @@ -24,6 +30,10 @@ const MOCK_ENV = { SENTRY_RELEASE: '1.1.1', }; +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + describe('withSentry', () => { beforeEach(() => { vi.clearAllMocks(); @@ -122,6 +132,32 @@ describe('withSentry', () => { expect(sentryEvent.release).toEqual('2.0.0'); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + fetch(_request, _env, _context) { + addDelayedWaitUntil(_context); + return new Response('test'); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.fetch?.(new Request('https://example.com'), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('scheduled handler', () => { @@ -198,13 +234,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -337,6 +372,32 @@ describe('withSentry', () => { }); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + scheduled(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('email handler', () => { @@ -413,13 +474,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -551,6 +611,32 @@ describe('withSentry', () => { }); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + email(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('queue handler', () => { @@ -627,13 +713,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -769,6 +854,32 @@ describe('withSentry', () => { }); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + queue(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('tail handler', () => { @@ -845,13 +956,12 @@ describe('withSentry', () => { } satisfies ExportedHandler; const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, context); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test('creates a cloudflare client and sets it on the handler', async () => { @@ -941,6 +1051,32 @@ describe('withSentry', () => { expect(thrownError).toBe(error); }); }); + + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const handler = { + tail(_controller, _env, _context) { + addDelayedWaitUntil(_context); + return; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(vi.fn(), handler); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + await wrappedHandler.tail?.(createMockTailEvent(), MOCK_ENV, { + waitUntil, + } as unknown as ExecutionContext); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimer(); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); }); describe('hono errorHandler', () => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 4fc9b308ec54..4e1baf3ecf18 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -33,15 +33,14 @@ describe('withSentry', () => { test('flushes the event after the handler is done using the cloudflare context.waitUntil', async () => { const context = createMockExecutionContext(); + const waitUntilSpy = vi.spyOn(context, 'waitUntil'); await wrapRequestHandler( { options: MOCK_OPTIONS, request: new Request('https://example.com'), context }, () => new Response('test'), ); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenCalledTimes(1); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(context.waitUntil).toHaveBeenLastCalledWith(expect.any(Promise)); + expect(waitUntilSpy).toHaveBeenCalledTimes(1); + expect(waitUntilSpy).toHaveBeenLastCalledWith(expect.any(Promise)); }); test("doesn't error if context is undefined", () => { From 86f52514b67236436590e3f5ede5e77f145f7d9d Mon Sep 17 00:00:00 2001 From: cod1k Date: Wed, 25 Jun 2025 16:27:07 +0300 Subject: [PATCH 3/7] Double import fix --- packages/cloudflare/test/integrations/fetch.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index 795b3e8c931c..724ff39c7dde 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -1,6 +1,5 @@ import type { HandlerDataFetch, Integration } from '@sentry/core'; import * as sentryCore from '@sentry/core'; -import * as sentryUtils from '@sentry/core'; import { createStackParser } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CloudflareClient } from '../../src/client'; @@ -12,7 +11,7 @@ class FakeClient extends CloudflareClient { } } -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); +const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); From 5af23523053313d12fbf76b78e1434c45b7ed5ce Mon Sep 17 00:00:00 2001 From: cod1k Date: Wed, 25 Jun 2025 16:29:43 +0300 Subject: [PATCH 4/7] Refactor `flush` handling by replacing `makeFlushAfterAll` with `makeFlushLock`, ensuring proper synchronization with `waitUntil`. Update tests to validate new `flush` behavior. --- packages/cloudflare/src/client.ts | 26 +++++++++++++++++--- packages/cloudflare/src/flush.ts | 25 ++++++++++--------- packages/cloudflare/src/handler.ts | 26 ++++++++++---------- packages/cloudflare/src/request.ts | 10 ++++---- packages/cloudflare/src/sdk.ts | 7 +++++- packages/cloudflare/test/flush.test.ts | 19 ++++++--------- packages/cloudflare/test/handler.test.ts | 21 ++++++++-------- packages/cloudflare/test/request.test.ts | 31 +++++++++++++++++++++++- 8 files changed, 109 insertions(+), 56 deletions(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 224865e3731e..50b84467b8b3 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -1,5 +1,6 @@ import type { ClientOptions, Options, ServerRuntimeClientOptions } from '@sentry/core'; import { applySdkMetadata, ServerRuntimeClient } from '@sentry/core'; +import type { makeFlushLock } from './flush'; import type { CloudflareTransportOptions } from './transport'; /** @@ -8,7 +9,8 @@ import type { CloudflareTransportOptions } from './transport'; * @see CloudflareClientOptions for documentation on configuration options. * @see ServerRuntimeClient for usage documentation. */ -export class CloudflareClient extends ServerRuntimeClient { +export class CloudflareClient extends ServerRuntimeClient { + private readonly _flushLock: ReturnType | void; /** * Creates a new Cloudflare SDK instance. * @param options Configuration options for this SDK. @@ -16,9 +18,10 @@ export class CloudflareClient extends ServerRuntimeClient} A promise that resolves to a boolean indicating whether the flush operation was successful. + */ + public async flush(timeout?: number): Promise { + if (this._flushLock) { + await this._flushLock.finalize(); + } + return super.flush(timeout); } } @@ -44,4 +62,6 @@ export interface CloudflareOptions extends Options, * * @see CloudflareClient for more information. */ -export interface CloudflareClientOptions extends ClientOptions, BaseCloudflareOptions {} +export interface CloudflareClientOptions extends ClientOptions, BaseCloudflareOptions { + flushLock?: ReturnType; +} diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index 8fd6d631361f..f38c805d0f8b 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -1,17 +1,19 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; -import { flush } from '@sentry/core'; -type Flusher = (...params: Parameters) => void; +type FlushLock = { + readonly ready: Promise; + readonly finalize: () => Promise; +}; /** * Enhances the given execution context by wrapping its `waitUntil` method with a proxy * to monitor pending tasks, and provides a flusher function to ensure all tasks * have been completed before executing any subsequent logic. * - * @param {ExecutionContext | void} context - The execution context to be enhanced. If no context is provided, the function returns undefined. - * @return {Flusher | void} Returns a flusher function if a valid context is provided, otherwise undefined. + * @param {ExecutionContext} context - The execution context to be enhanced. If no context is provided, the function returns undefined. + * @return {FlushLock} Returns a flusher function if a valid context is provided, otherwise undefined. */ -export function makeFlushAfterAll(context: ExecutionContext): Flusher { +export function makeFlushLock(context: ExecutionContext): FlushLock { let resolveAllDone: () => void = () => undefined; const allDone = new Promise(res => { resolveAllDone = res; @@ -26,10 +28,11 @@ export function makeFlushAfterAll(context: ExecutionContext): Flusher { }), ); }; - return (...params: Parameters) => { - if (pending === 0) { - return originalWaitUntil(flush(...params)); - } - originalWaitUntil(allDone.finally(() => flush(...params))); - }; + return Object.freeze({ + ready: allDone, + finalize: () => { + if (pending === 0) resolveAllDone(); + return allDone; + }, + }); } diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 0a24f156bdb5..1148e3664d77 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -8,7 +8,6 @@ import { } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; -import { makeFlushAfterAll } from './flush'; import { isInstrumented, markAsInstrumented } from './instrument'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; @@ -72,11 +71,11 @@ export function withSentry>) { const [event, env, context] = args; - const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); - const client = init(options); + const client = init(options, context); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -100,7 +99,7 @@ export function withSentry>) { const [emailMessage, env, context] = args; - const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); - const client = init(options); + const client = init(options, context); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -141,7 +140,7 @@ export function withSentry>) { const [batch, env, context] = args; - const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); + const waitUntil = context.waitUntil.bind(context); - const client = init(options); + const client = init(options, context); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -188,7 +187,7 @@ export function withSentry>) { const [, env, context] = args; - const flushAfterAll = makeFlushAfterAll(context); return withIsolationScope(async isolationScope => { const options = getFinalOptions(optionsCallback(env), env); - const client = init(options); + const waitUntil = context.waitUntil.bind(context); + + const client = init(options, context); isolationScope.setClient(client); addCloudResourceContext(isolationScope); @@ -219,7 +219,7 @@ export function withSentry { const waitUntilPromises: Promise[] = []; @@ -11,11 +10,9 @@ describe('Flush buffer test', () => { }), passThroughOnException: vi.fn(), }; - it('should flush buffer immediately if no waitUntil were called', () => { - const coreFlush = vi.spyOn(SentryCore, 'flush'); - const flush = makeFlushAfterAll(mockExecutionContext); - flush(); - expect(coreFlush).toBeCalled(); + it('should flush buffer immediately if no waitUntil were called', async () => { + const { finalize } = makeFlushLock(mockExecutionContext); + await expect(finalize()).resolves.toBeUndefined(); }); it('should flush buffer only after all waitUntil were finished', async () => { vi.useFakeTimers(); @@ -23,13 +20,11 @@ describe('Flush buffer test', () => { vi.useRealTimers(); }); const task = new Promise(resolve => setTimeout(resolve, 100)); - const coreFlush = vi.spyOn(SentryCore, 'flush'); - const flush = makeFlushAfterAll(mockExecutionContext); + const lock = makeFlushLock(mockExecutionContext); mockExecutionContext.waitUntil(task); - flush(); - expect(coreFlush).not.toBeCalled(); + void lock.finalize(); vi.advanceTimersToNextTimer(); await Promise.all(waitUntilPromises); - expect(coreFlush).toBeCalled(); + await expect(lock.ready).resolves.toBeUndefined(); }); }); diff --git a/packages/cloudflare/test/handler.test.ts b/packages/cloudflare/test/handler.test.ts index cc814df876ae..2e5c0f836e89 100644 --- a/packages/cloudflare/test/handler.test.ts +++ b/packages/cloudflare/test/handler.test.ts @@ -134,7 +134,7 @@ describe('withSentry', () => { }); test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore, 'flush'); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); onTestFinished(() => { vi.useRealTimers(); @@ -154,7 +154,7 @@ describe('withSentry', () => { } as unknown as ExecutionContext); expect(flush).not.toBeCalled(); expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer().runAllTimers(); await Promise.all(waits); expect(flush).toHaveBeenCalledOnce(); }); @@ -374,7 +374,7 @@ describe('withSentry', () => { }); test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore, 'flush'); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); onTestFinished(() => { vi.useRealTimers(); @@ -394,7 +394,7 @@ describe('withSentry', () => { } as unknown as ExecutionContext); expect(flush).not.toBeCalled(); expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer().runAllTimers(); await Promise.all(waits); expect(flush).toHaveBeenCalledOnce(); }); @@ -613,7 +613,7 @@ describe('withSentry', () => { }); test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore, 'flush'); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); onTestFinished(() => { vi.useRealTimers(); @@ -633,7 +633,7 @@ describe('withSentry', () => { } as unknown as ExecutionContext); expect(flush).not.toBeCalled(); expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer().runAllTimers(); await Promise.all(waits); expect(flush).toHaveBeenCalledOnce(); }); @@ -856,7 +856,7 @@ describe('withSentry', () => { }); test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore, 'flush'); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); onTestFinished(() => { vi.useRealTimers(); @@ -876,7 +876,7 @@ describe('withSentry', () => { } as unknown as ExecutionContext); expect(flush).not.toBeCalled(); expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer().runAllTimers(); await Promise.all(waits); expect(flush).toHaveBeenCalledOnce(); }); @@ -1053,10 +1053,11 @@ describe('withSentry', () => { }); test('flush must be called when all waitUntil are done', async () => { - const flush = vi.spyOn(SentryCore, 'flush'); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); vi.useFakeTimers(); onTestFinished(() => { vi.useRealTimers(); + flush.mockRestore(); }); const handler = { tail(_controller, _env, _context) { @@ -1073,7 +1074,7 @@ describe('withSentry', () => { } as unknown as ExecutionContext); expect(flush).not.toBeCalled(); expect(waitUntil).toBeCalled(); - vi.advanceTimersToNextTimer(); + vi.advanceTimersToNextTimer().runAllTimers(); await Promise.all(waits); expect(flush).toHaveBeenCalledOnce(); }); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 4e1baf3ecf18..32bc8068ba6d 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -1,9 +1,10 @@ // Note: These tests run the handler in Node.js, which has some differences to the cloudflare workers runtime. // Although this is not ideal, this is the best we can do until we have a better way to test cloudflare workers. +import type { ExecutionContext } from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; @@ -13,6 +14,10 @@ const MOCK_OPTIONS: CloudflareOptions = { dsn: 'https://public@dsn.ingest.sentry.io/1337', }; +function addDelayedWaitUntil(context: ExecutionContext) { + context.waitUntil(new Promise(resolve => setTimeout(() => resolve()))); +} + describe('withSentry', () => { beforeAll(() => { setAsyncLocalStorageAsyncContextStrategy(); @@ -63,6 +68,30 @@ describe('withSentry', () => { expect(initAndBindSpy).toHaveBeenLastCalledWith(CloudflareClient, expect.any(Object)); }); + test('flush must be called when all waitUntil are done', async () => { + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const waits: Promise[] = []; + const waitUntil = vi.fn(promise => waits.push(promise)); + + const context = { + waitUntil, + } as unknown as ExecutionContext; + + await wrapRequestHandler({ options: MOCK_OPTIONS, request: new Request('https://example.com'), context }, () => { + addDelayedWaitUntil(context); + return new Response('test'); + }); + expect(flush).not.toBeCalled(); + expect(waitUntil).toBeCalled(); + vi.advanceTimersToNextTimerAsync().then(() => vi.runAllTimers()); + await Promise.all(waits); + expect(flush).toHaveBeenCalledOnce(); + }); + describe('scope instrumentation', () => { test('adds cloud resource context', async () => { let sentryEvent: Event = {}; From b5e4ff6d51167bcc163a7c2daf91dbec9b30966c Mon Sep 17 00:00:00 2001 From: cod1k Date: Thu, 26 Jun 2025 09:01:29 +0300 Subject: [PATCH 5/7] Refactor `init` to support `ctx` option and streamline `flush` usage with `waitUntil`. --- packages/cloudflare/src/client.ts | 4 +++- packages/cloudflare/src/durableobject.ts | 8 +++++--- packages/cloudflare/src/handler.ts | 17 +++++++++-------- packages/cloudflare/src/request.ts | 7 ++++--- packages/cloudflare/src/sdk.ts | 6 +++--- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 50b84467b8b3..18647b9df94f 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -55,7 +55,9 @@ interface BaseCloudflareOptions {} * * @see @sentry/core Options for more information. */ -export interface CloudflareOptions extends Options, BaseCloudflareOptions {} +export interface CloudflareOptions extends Options, BaseCloudflareOptions { + ctx?: ExecutionContext +} /** * Configuration options for the Sentry Cloudflare SDK Client class diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 35fbb5096a41..0e919977025d 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -47,9 +47,11 @@ function wrapMethodWithSentry any>( // see: https://github.com/getsentry/sentry-javascript/issues/13217 const context = wrapperOptions.context as ExecutionContext | undefined; + const waitUntil = context?.waitUntil?.bind?.(context); + const currentClient = scope.getClient(); if (!currentClient) { - const client = init(wrapperOptions.options); + const client = init({ ...wrapperOptions.options, ctx: context }); scope.setClient(client); } @@ -68,7 +70,7 @@ function wrapMethodWithSentry any>( }); throw e; } finally { - context?.waitUntil(flush(2000)); + waitUntil?.(flush(2000)); } } @@ -92,7 +94,7 @@ function wrapMethodWithSentry any>( }); throw e; } finally { - context?.waitUntil(flush(2000)); + waitUntil?.(flush(2000)); } }); }); diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 1148e3664d77..3640d3cf7229 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -1,5 +1,6 @@ import { captureException, + flush, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -75,7 +76,7 @@ export function withSentry Date: Thu, 26 Jun 2025 17:45:49 +0300 Subject: [PATCH 6/7] Add tests for Durable Object Sentry instrumentation This commit introduces unit tests to validate the functionality of `instrumentDurableObjectWithSentry`. The tests ensure proper instrumentation of all methods, correct handling of `waitUntil` promises, and interaction with Sentry's `flush` logic. --- .../cloudflare/test/durableobject.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/cloudflare/test/durableobject.test.ts diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts new file mode 100644 index 000000000000..40d33741658e --- /dev/null +++ b/packages/cloudflare/test/durableobject.test.ts @@ -0,0 +1,55 @@ +import type { ExecutionContext } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { instrumentDurableObjectWithSentry } from '../src/durableobject'; +import { isInstrumented } from '../src/instrument'; + +describe('durable object', () => { + it('instrumentDurableObjectWithSentry generic functionality', () => { + const options = vi.fn(); + const instrumented = instrumentDurableObjectWithSentry(options, vi.fn()); + expect(instrumented).toBeTypeOf('function'); + expect(() => Reflect.construct(instrumented, [])).not.toThrow(); + expect(options).toHaveBeenCalledOnce(); + }); + it('all available durable object methods are instrumented', () => { + const testClass = vi.fn(() => ({ + customMethod: vi.fn(), + fetch: vi.fn(), + alarm: vi.fn(), + webSocketMessage: vi.fn(), + webSocketClose: vi.fn(), + webSocketError: vi.fn(), + })); + const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); + const dObject: any = Reflect.construct(instrumented, []); + for (const method of Object.getOwnPropertyNames(dObject)) { + expect(isInstrumented(dObject[method]), `Method ${method} is instrumented`).toBeTruthy(); + } + }); + it('flush performs after all waitUntil promises are finished', async () => { + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + const flush = vi.spyOn(SentryCore.Client.prototype, 'flush'); + const waitUntil = vi.fn(); + const testClass = vi.fn(context => ({ + fetch: () => { + context.waitUntil(new Promise(res => setTimeout(res))); + return new Response('test'); + }, + })); + const instrumented = instrumentDurableObjectWithSentry(vi.fn(), testClass as any); + const context = { + waitUntil, + } as unknown as ExecutionContext; + const dObject: any = Reflect.construct(instrumented, [context, {} as any]); + expect(() => dObject.fetch(new Request('https://example.com'))).not.toThrow(); + expect(flush).not.toBeCalled(); + expect(waitUntil).toHaveBeenCalledOnce(); + vi.advanceTimersToNextTimer(); + await Promise.all(waitUntil.mock.calls.map(([p]) => p)); + expect(flush).toBeCalled(); + }); +}); From 42b5b301f8e8c307a51b9b84fd10e59973b6a3d2 Mon Sep 17 00:00:00 2001 From: cod1k Date: Thu, 26 Jun 2025 19:20:21 +0300 Subject: [PATCH 7/7] Add missing semicolon in CloudflareOptions interface This commit ensures consistency by adding a missing semicolon in the `ctx` property of the `CloudflareOptions` interface. No functional changes were made, but it improves code style and adheres to linting rules. --- packages/cloudflare/src/client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 18647b9df94f..15e7da4dc575 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -11,6 +11,7 @@ import type { CloudflareTransportOptions } from './transport'; */ export class CloudflareClient extends ServerRuntimeClient { private readonly _flushLock: ReturnType | void; + /** * Creates a new Cloudflare SDK instance. * @param options Configuration options for this SDK. @@ -56,7 +57,7 @@ interface BaseCloudflareOptions {} * @see @sentry/core Options for more information. */ export interface CloudflareOptions extends Options, BaseCloudflareOptions { - ctx?: ExecutionContext + ctx?: ExecutionContext; } /**