From 83093bf71bdd432d9cfdfac65fba2c7d6a7079a3 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 11 Dec 2025 13:39:11 +0100 Subject: [PATCH] fix(cloudflare): Missing events inside waitUntil --- .../cloudflare-workers/src/index.ts | 16 ++++++++- .../cloudflare-workers/tests/index.test.ts | 33 ++++++++++++++++++- packages/cloudflare/src/flush.ts | 16 +++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts index ab438432a004..a42aed262089 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/src/index.ts @@ -81,7 +81,7 @@ export default Sentry.withSentry( }, }), { - async fetch(request, env) { + async fetch(request, env, ctx) { const url = new URL(request.url); switch (url.pathname) { case '/rpc/throwException': @@ -96,6 +96,20 @@ export default Sentry.withSentry( } } break; + case '/waitUntil': + console.log('waitUntil called'); + + const longRunningTask = async () => { + await new Promise(resolve => setTimeout(resolve, 3000)); + + console.log('ʕっ•ᴥ•ʔっ'); + Sentry.captureException(new Error('ʕノ•ᴥ•ʔノ ︵ ┻━┻')); + }; + + ctx.waitUntil(longRunningTask()); + + return new Response(null, { status: 200 }); + case '/throwException': throw new Error('To be recorded in Sentry.'); default: diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index 8c09693c81ed..7b674fd7f122 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; -import { waitForError, waitForRequest } from '@sentry-internal/test-utils'; +import { waitForError, waitForRequest, waitForTransaction } from '@sentry-internal/test-utils'; import { SDK_VERSION } from '@sentry/cloudflare'; +import { waitForDebugger } from 'inspector'; import { WebSocket } from 'ws'; test('Index page', async ({ baseURL }) => { @@ -82,3 +83,33 @@ test('sends user-agent header with SDK name and version in envelope requests', a 'user-agent': `sentry.javascript.cloudflare/${SDK_VERSION}`, }); }); + +test('waitUntil', async ({ baseURL }) => { + const errorWaiter = waitForError('cloudflare-workers', () => true); + const waitUntilSpanWaiter = waitForTransaction('cloudflare-workers', span => span.transaction === 'waitUntil'); + const httpTransactionWaiter = waitForTransaction( + 'cloudflare-workers', + transactionEvent => transactionEvent.contexts?.trace?.op === 'http.server', + ); + + const response = await fetch(`${baseURL}/waitUntil`); + + const transactionRequest = await httpTransactionWaiter; + const waitUntilSpan = await waitUntilSpanWaiter; + const errorEvent = await errorWaiter; + + expect(response.status).toBe(200); + + // All traceIds should be the same + expect(transactionRequest.contexts?.trace?.trace_id).toBe(waitUntilSpan.contexts?.trace?.trace_id); + expect(transactionRequest.contexts?.trace?.trace_id).toBe(errorEvent.contexts?.trace?.trace_id); + + expect(errorEvent.exception?.values?.[0]?.value).toBe('ʕノ•ᴥ•ʔノ ︵ ┻━┻'); + expect(errorEvent.breadcrumbs).toStrictEqual(waitUntilSpan.breadcrumbs); + + expect(waitUntilSpan.contexts?.trace?.parent_span_id).toBe(transactionRequest.contexts?.trace?.span_id); + + console.log(JSON.stringify(transactionRequest, null, 2)); + console.log(JSON.stringify(waitUntilSpan, null, 2)); + console.log(JSON.stringify(errorEvent, null, 2)); +}); diff --git a/packages/cloudflare/src/flush.ts b/packages/cloudflare/src/flush.ts index f38c805d0f8b..a6568d81ab46 100644 --- a/packages/cloudflare/src/flush.ts +++ b/packages/cloudflare/src/flush.ts @@ -1,4 +1,5 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; +import { startSpan, withScope } from '@sentry/core'; type FlushLock = { readonly ready: Promise; @@ -22,9 +23,20 @@ export function makeFlushLock(context: ExecutionContext): FlushLock { const originalWaitUntil = context.waitUntil.bind(context) as typeof context.waitUntil; context.waitUntil = promise => { pending++; + return originalWaitUntil( - promise.finally(() => { - if (--pending === 0) resolveAllDone(); + // Wrap the promise in a new scope and transaction so spans created inside + // waitUntil callbacks are properly isolated from the HTTP request transaction + withScope(() => + startSpan({ forceTransaction: true, op: 'cloudflare.wait_until', name: 'wait_until' }, async () => { + // By awaiting the promise inside the new scope, all of its continuations + // will execute in this isolated scope + await promise; + }), + ).finally(() => { + if (--pending === 0) { + resolveAllDone(); + } }), ); };