diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts new file mode 100644 index 000000000000..5bceadff05d8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/index.ts @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class AlarmDurableObjectBase extends DurableObject { + public constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + } + + async setAlarm(): Promise { + await this.ctx.storage.setAlarm(Date.now() + 100); + } + + async alarm(): Promise { + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + enableRpcTracePropagation: true, + }), + AlarmDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/set-alarm') { + const id = url.searchParams.get('id') || 'default'; + const doId = env.TEST_DURABLE_OBJECT.idFromName(id); + const stub = env.TEST_DURABLE_OBJECT.get(doId) as unknown as AlarmDurableObjectBase; + await stub.setAlarm(); + return new Response('Alarm scheduled'); + } + + return new Response('OK'); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts new file mode 100644 index 000000000000..9bcfa2b0cadc --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/test.ts @@ -0,0 +1,64 @@ +import { expect, it } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('alarm links to the trace that scheduled it via sentry.previous_trace', async ({ signal }) => { + let setAlarmTransaction: TransactionEvent | undefined; + let alarmTransaction: TransactionEvent | undefined; + const testId = Date.now().toString(); + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: expect.stringContaining('/set-alarm'), + }), + ); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'setAlarm', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + }), + ); + setAlarmTransaction = transactionEvent; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'alarm', + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'function', + origin: 'auto.faas.cloudflare.durable_object', + }), + }), + }), + ); + alarmTransaction = transactionEvent; + }) + .unordered() + .start(signal); + + await runner.makeRequest('get', `/set-alarm?id=${testId}`); + await runner.completed(); + + const traceData = alarmTransaction!.contexts?.trace?.data as Record | undefined; + const previousTrace = traceData?.['sentry.previous_trace'] as string | undefined; + + expect(previousTrace).toBeDefined(); + expect(previousTrace).toMatch(/^[a-f0-9]{32}-[a-f0-9]{16}-[01]$/); + + const [linkedTraceId] = previousTrace!.split('-'); + expect(linkedTraceId).toBe(setAlarmTransaction!.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/wrangler.jsonc new file mode 100644 index 000000000000..e605296a46c5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-alarm-links/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "durableobject-alarm-links", + "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"], +} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index dffb0338c1da..106df4208fbd 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -170,37 +170,47 @@ export function wrapMethodWithSentry( const executeSpan = (): unknown => { return startSpan({ name: methodName, attributes }, span => { - // When linking to previous trace, fetch the stored context and add links asynchronously - // This avoids blocking the response while fetching from storage + // When linking to a previous trace, fetch the stored context in parallel with the + // user's handler and await it before the span ends, so `sentry.previous_trace` lands + // on the serialized transaction. Awaiting it ties the lookup into the handler's + // async lifecycle, so a separate `waitUntil` is not needed. + let linkPromise: Promise | undefined; + if (startNewTrace && storage) { - waitUntil?.( - getStoredSpanContext(storage, methodName).then(storedContext => { - if (storedContext) { - span.addLinks(buildSpanLinks(storedContext)); - // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we - // can obtain the previous trace information from the EAP store. Long-term, EAP will handle - // span links and then we should remove this again. Also throwing in a TODO(v11), to remind us - // to check this at v11 time :) - const sampledFlag = storedContext.sampled ? '1' : '0'; - span.setAttribute( - 'sentry.previous_trace', - `${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`, - ); - } - }), - ); + linkPromise = getStoredSpanContext(storage, methodName).then(storedContext => { + if (storedContext) { + span.addLinks(buildSpanLinks(storedContext)); + // TODO: Remove this once EAP can store span links. We currently only set this attribute so that we + // can obtain the previous trace information from the EAP store. Long-term, EAP will handle + // span links and then we should remove this again. Also throwing in a TODO(v11), to remind us + // to check this at v11 time :) + const sampledFlag = storedContext.sampled ? '1' : '0'; + span.setAttribute( + 'sentry.previous_trace', + `${storedContext.traceId}-${storedContext.spanId}-${sampledFlag}`, + ); + } + }); } + const awaitLink = async (): Promise => { + if (linkPromise) { + await linkPromise.catch(() => undefined); + } + }; + try { const result = Reflect.apply(target, thisArg, args); if (isThenable(result)) { return result.then( - (res: unknown) => { + async (res: unknown) => { + await awaitLink(); waitUntil?.(teardown()); return res; }, - (e: unknown) => { + async (e: unknown) => { + await awaitLink(); captureException(e, { mechanism: { type: 'auto.faas.cloudflare.durable_object',