diff --git a/dev-packages/cloudflare-integration-tests/expect.ts b/dev-packages/cloudflare-integration-tests/expect.ts index 71eb5c9963c4..5d7699e70367 100644 --- a/dev-packages/cloudflare-integration-tests/expect.ts +++ b/dev-packages/cloudflare-integration-tests/expect.ts @@ -1,6 +1,7 @@ import type { Contexts, Envelope, Event, SdkInfo } from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { expect } from 'vitest'; +import { supports } from './runtime'; export const UUID_MATCHER = expect.stringMatching(/^[\da-f]{32}$/); export const UUID_V4_MATCHER = expect.stringMatching( @@ -39,6 +40,7 @@ function defaultContexts(eventContexts: Contexts = {}): Contexts { trace: { trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, }, cloud_resource: { 'cloud.provider': 'cloudflare' }, culture: { timezone: expect.any(String) }, diff --git a/dev-packages/cloudflare-integration-tests/runtime.ts b/dev-packages/cloudflare-integration-tests/runtime.ts new file mode 100644 index 000000000000..9f175f0c94be --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/runtime.ts @@ -0,0 +1,31 @@ +import { parseSemver } from '@sentry/core'; +import { createRequire } from 'module'; + +// `wrangler dev` runs the workerd that its own miniflare depends on. +const wranglerRequire = createRequire(createRequire(__filename).resolve('wrangler/package.json')); +const miniflareRequire = createRequire(wranglerRequire.resolve('miniflare/package.json')); +const WORKERD_VERSION = parseSemver((miniflareRequire('workerd/package.json') as { version: string }).version); + +export type Requirements = { + /** Lowest workerd version that runs the tests, for example `1.20261009.1`. */ + minWorkerd?: string; +}; + +/** + * Whether the workerd that runs the Workers meets the requirements of the tests, for use in `it.runIf` and + * `it.skipIf`. The pinned wrangler of the required CI job runs an older workerd than the latest wrangler of the + * optional CI job. + */ +export function supports({ minWorkerd }: Requirements): boolean { + if (!minWorkerd) { + return true; + } + + const required = parseSemver(minWorkerd); + const difference = + (WORKERD_VERSION.major || 0) - (required.major || 0) || + (WORKERD_VERSION.minor || 0) - (required.minor || 0) || + (WORKERD_VERSION.patch || 0) - (required.patch || 0); + + return difference >= 0; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts index 33ba266ed7cc..ebf929489edd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../expect'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; import { createRunner } from '../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../spanUtils'; @@ -28,6 +28,12 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { method: 'GET', url: expect.stringContaining('/error/param-123'), }, + contexts: { + trace: { + trace_id: UUID_MATCHER, + span_id: SHORT_UUID_MATCHER, + }, + }, breadcrumbs: [ { timestamp: expect.any(Number), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts new file mode 100644 index 000000000000..4da39ebf2b88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/cloudflare'; + +// `getActiveSpan` and `Span.spanContext` are newer than the installed workers types, and older runtimes do not have +// them. +interface CloudflareTracing { + getActiveSpan?(): { isTraced?: boolean; spanContext?(): { traceId: string; spanId: string } } | undefined; +} + +export default { + async fetch(_request, _env, ctx) { + const span = (ctx.tracing as unknown as CloudflareTracing | undefined)?.getActiveSpan?.(); + const spanContext = span?.isTraced ? span.spanContext?.() : undefined; + + Sentry.captureException(new Error('Error in a Cloudflare trace')); + + return Response.json(spanContext ? { traceId: spanContext.traceId, spanId: spanContext.spanId } : null); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts new file mode 100644 index 000000000000..0d33b7bb23d0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string; TRACES_SAMPLE_RATE: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: env.TRACES_SAMPLE_RATE ? Number(env.TRACES_SAMPLE_RATE) : undefined, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts new file mode 100644 index 000000000000..2f6323969634 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts @@ -0,0 +1,104 @@ +import type { Event, SerializedStreamedSpanContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect'; +import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; + +// `Span.spanContext()` ships in workerd 1.20261009.1. Without it, the Worker responds with `null`. +it.runIf(supports({ minWorkerd: '1.20261009.1' }))( + 'uses the Cloudflare trace for the error event when tracing is not set up', + async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .start(signal); + + const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); + await runner.completed(); + + expect(cloudflareSpan).toEqual({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER }); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.contexts?.trace).toEqual({ + trace_id: cloudflareSpan?.traceId, + span_id: SHORT_UUID_MATCHER, + parent_span_id: cloudflareSpan?.spanId, + }); + }, +); + +it.runIf(supports({ minWorkerd: '1.20261009.1' }))( + 'starts its own trace when tracing is set up', + async ({ signal }) => { + const errorEvents: Event[] = []; + const spanContainers: SerializedStreamedSpanContainer[] = []; + + const runner = createRunner(__dirname) + .withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:1') + .unordered() + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + spanContainers.push(envelope[1][0][1] as SerializedStreamedSpanContainer); + }) + .start(signal); + + const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); + await runner.completed(); + + const segmentSpans = spanContainers.flatMap(container => container.items).filter(span => span.is_segment); + expect(cloudflareSpan).toEqual({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER }); + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).not.toBe(cloudflareSpan?.traceId); + expect(segmentSpans[0]?.parent_span_id).toBeUndefined(); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id); + }, +); + +it.skipIf(supports({ minWorkerd: '1.20261009.1' }))( + 'starts its own trace on runtimes without `Span.spanContext()`', + async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .start(signal); + + const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); + await runner.completed(); + + expect(cloudflareSpan).toBeNull(); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.contexts?.trace).toEqual({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }); + }, +); + +it('continues an incoming Sentry trace instead of the Cloudflare trace', async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .start(signal); + + await runner.makeRequest('get', '/', { + headers: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1' }, + }); + await runner.completed(); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe('12345678901234567890123456789012'); + expect(errorEvents[0]?.contexts?.trace?.parent_span_id).toBe('1234567890123456'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc new file mode 100644 index 000000000000..37b1010b07a7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "cloudflare-trace-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "TRACES_SAMPLE_RATE": "", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts index f9916e5c03c7..fb98b6ed2bc5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../../expect'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -29,6 +29,12 @@ it('omits the span name from the DSC for url-source spans when tracing is enable method: 'GET', url: expect.any(String), }, + contexts: { + trace: { + trace_id: UUID_MATCHER, + span_id: SHORT_UUID_MATCHER, + }, + }, }, { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, ), diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 11a8fb9b419d..cafb160e896b 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -16,6 +16,7 @@ import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; @@ -38,6 +39,7 @@ function wrapEmailHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); return startSpan( { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index f6339248726f..045cf261ca9f 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -24,6 +24,7 @@ import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { extractQueueTraceContext } from '../../utils/queueTraceMeta'; @@ -79,6 +80,7 @@ function wrapQueueHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); return startSpan( { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 8285830b73c1..2cbe32129368 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -21,6 +21,7 @@ import type { CronTriggersIntegration } from '../../integrations/cronTriggers'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; @@ -40,6 +41,7 @@ function wrapScheduledHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); const description = `Scheduled Cron ${controller.cron}`; diff --git a/packages/cloudflare/src/utils/cloudflareTrace.ts b/packages/cloudflare/src/utils/cloudflareTrace.ts new file mode 100644 index 000000000000..6b9fa51e93d2 --- /dev/null +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -0,0 +1,58 @@ +import { _INTERNAL_safeMathRandom, getCurrentScope, hasSpansEnabled, isContinuingTrace } from '@sentry/core'; + +interface CloudflareSpanContext { + traceId: string; + spanId: string; +} + +// The part of `ctx.tracing` the SDK reads. `getActiveSpan` and `Span.spanContext` exist only in +// newer runtimes, so every member is optional. +interface CloudflareTracing { + getActiveSpan?: () => + | { + readonly isTraced?: boolean; + spanContext?: () => CloudflareSpanContext; + } + | undefined; +} + +const TRACE_ID_REGEXP = /^(?!0{32})[0-9a-f]{32}$/; +const SPAN_ID_REGEXP = /^(?!0{16})[0-9a-f]{16}$/; + +/** + * Sets the propagation context of the current scope to the Cloudflare trace of the invocation, so + * errors, logs and spans get the trace id of the spans that Cloudflare exports. + * + * Only runs when Sentry tracing is not set up (no `tracesSampleRate` or `tracesSampler`), so a trace + * has one source: either the SDK or Cloudflare, never a mix of both. + * + * Does nothing when the current scope already continues a trace, for example an incoming Sentry + * trace, when Cloudflare does not trace the invocation, or when the runtime does not expose + * `Span.spanContext()`. + */ +export function continueCloudflareTrace(context: unknown): void { + if (hasSpansEnabled() || isContinuingTrace(getCurrentScope().getPropagationContext())) { + return; + } + + let spanContext: CloudflareSpanContext | undefined; + try { + const span = (context as { tracing?: CloudflareTracing } | undefined)?.tracing?.getActiveSpan?.(); + spanContext = span?.isTraced ? span.spanContext?.() : undefined; + } catch { + // Runtime getters on the execution context can throw on foreign or already-torn-down contexts. + return; + } + + if (!spanContext || !TRACE_ID_REGEXP.test(spanContext.traceId) || !SPAN_ID_REGEXP.test(spanContext.spanId)) { + return; + } + + // No `dsc` and no `sampled`: the SDK is the head of the Sentry trace, so it builds the dynamic + // sampling context itself. + getCurrentScope().setPropagationContext({ + traceId: spanContext.traceId, + parentSpanId: spanContext.spanId, + sampleRand: _INTERNAL_safeMathRandom(), + }); +} diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index fe1927833e7e..bb701c1530d8 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -16,6 +16,7 @@ import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; +import { continueCloudflareTrace } from './utils/cloudflareTrace'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; import { extractRpcMeta } from './utils/rpcMeta'; @@ -183,6 +184,8 @@ export function wrapMethodWithSentry( } }; + continueCloudflareTrace(context); + if (!spanName) { try { if (callback) { @@ -272,10 +275,10 @@ export function wrapMethodWithSentry( if (rpcMeta) { return runWithTeardown(() => - continueTrace( - { sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' }, - executeSpan, - ), + continueTrace({ sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' }, () => { + continueCloudflareTrace(context); + return executeSpan(); + }), ); } diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 76470db655f4..837cf36c5d99 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -14,6 +14,7 @@ import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { addCloudResourceContext, addCultureContext } from './scope-utils'; +import { continueCloudflareTrace } from './utils/cloudflareTrace'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; @@ -128,6 +129,7 @@ export function wrapRequestHandlerWithInit( origin: 'auto.http.cloudflare', mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined, attributes, + onTraceContinued: () => continueCloudflareTrace(context), isStreamingResponse: response => { const classification = safeCallback( DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 875e03aab0db..dcef18a8800a 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -574,6 +574,101 @@ describe('instrumentWorkerEntrypoint', () => { expect(initAndBind).toHaveBeenCalledTimes(5); }); + + describe('with a Cloudflare trace', () => { + const CLOUDFLARE_TRACE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + const CLOUDFLARE_SPAN_ID = 'b1c2d3e4f5a60718'; + const INCOMING_TRACE_ID = '12312012123120121231201212312012'; + const INCOMING_SPAN_ID = '1121201211212012'; + const CLOUDFLARE_TRACING = { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }; + + it('attaches errors of RPC calls without RPC metadata to the Cloudflare trace', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext; + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); + const TestClass = class extends WorkerEntrypoint { + async fail(): Promise { + throw new Error('rpc failure'); + } + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await expect(obj.fail()).rejects.toThrow('rpc failure'); + await Promise.all(waits); + + expect(events).toHaveLength(1); + expect(events[0]?.contexts?.trace?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(events[0]?.contexts?.trace?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + + it('does not make the RPC span a child of the Cloudflare span when tracing is set up', () => { + let rpcSpan: ReturnType | undefined; + const TestClass = class extends WorkerEntrypoint { + read() { + rpcSpan = SentryCore.spanToJSON(SentryCore.getActiveSpan()!); + } + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ dsn: 'https://public@o1.ingest.sentry.io/1337', tracesSampleRate: 1 }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [{ ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING }, {}], + ); + + obj.read({ + __sentry_rpc_meta__: { + 'sentry-trace': `${INCOMING_TRACE_ID}-${INCOMING_SPAN_ID}-1`, + baggage: `sentry-trace_id=${INCOMING_TRACE_ID},sentry-org_id=2`, + }, + }); + + expect(rpcSpan?.trace_id).not.toBe(CLOUDFLARE_TRACE_ID); + expect(rpcSpan?.trace_id).not.toBe(INCOMING_TRACE_ID); + expect(rpcSpan?.parent_span_id).toBeUndefined(); + }); + + it('prefers the Sentry trace of the RPC metadata', () => { + let rpcSpan: ReturnType | undefined; + const TestClass = class extends WorkerEntrypoint { + read() { + rpcSpan = SentryCore.spanToJSON(SentryCore.getActiveSpan()!); + } + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ dsn: 'https://public@o1.ingest.sentry.io/1337', tracesSampleRate: 1 }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [{ ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING }, {}], + ); + + obj.read({ __sentry_rpc_meta__: { 'sentry-trace': `${INCOMING_TRACE_ID}-${INCOMING_SPAN_ID}-1` } }); + + expect(rpcSpan?.trace_id).toBe(INCOMING_TRACE_ID); + expect(rpcSpan?.parent_span_id).toBe(INCOMING_SPAN_ID); + }); + }); }); describe('env instrumentation', () => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts index 2c15f5a39966..50fadfa56e8f 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -279,6 +279,52 @@ describe('instrumentEmail', () => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); + + describe('with a Cloudflare trace', () => { + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + test('continues the Cloudflare trace when tracing is not set up', async () => { + let propagationContext: ReturnType | undefined; + const handler = { + email(_arg, _env, _context) { + propagationContext = SentryCore.getCurrentScope().getPropagationContext(); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + expect(propagationContext?.traceId).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(propagationContext?.parentSpanId).toBe('b1c2d3e4f5a60718'); + }); + + test('does not make the email span a child of the Cloudflare span when tracing is set up', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + email(_arg, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).not.toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); + }); }); test('flush must be called when all waitUntil are done', async () => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts index d58d1e5ff4fa..dbfe1ae0e031 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -398,6 +398,52 @@ describe('instrumentQueue', () => { }, ]); }); + + describe('with a Cloudflare trace', () => { + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + test('continues the Cloudflare trace when tracing is not set up', async () => { + let propagationContext: ReturnType | undefined; + const handler = { + queue(_arg, _env, _context) { + propagationContext = SentryCore.getCurrentScope().getPropagationContext(); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(propagationContext?.traceId).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(propagationContext?.parentSpanId).toBe('b1c2d3e4f5a60718'); + }); + + test('does not make the queue span a child of the Cloudflare span when tracing is set up', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + queue(_arg, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).not.toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); + }); }); test('flush must be called when all waitUntil are done', async () => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 843683be8925..851d2fe11ac9 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -305,6 +305,52 @@ describe('instrumentScheduled', () => { test('keeps the descriptive span name when span streaming is disabled', async () => { expect(await spanNameFor('static')).toBe('Scheduled Cron 0 0 0 * * *'); }); + + describe('with a Cloudflare trace', () => { + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + test('continues the Cloudflare trace when tracing is not set up', async () => { + let propagationContext: ReturnType | undefined; + const handler = { + scheduled(_arg, _env, _context) { + propagationContext = SentryCore.getCurrentScope().getPropagationContext(); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + + expect(propagationContext?.traceId).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(propagationContext?.parentSpanId).toBe('b1c2d3e4f5a60718'); + }); + + test('does not make the scheduled span a child of the Cloudflare span when tracing is set up', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + scheduled(_arg, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).not.toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); + }); }); describe('cron monitoring', () => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index aba83d5f66c3..1d09b4ccd1f7 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -703,6 +703,159 @@ describe('withSentry', () => { links: undefined, }); }); + + describe('with a Cloudflare trace', () => { + const CLOUDFLARE_TRACE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + const CLOUDFLARE_SPAN_ID = 'b1c2d3e4f5a60718'; + const CLOUDFLARE_TRACING = { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }; + + test('attaches errors to the Cloudflare trace when tracing is not set up', async () => { + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: MOCK_OPTIONS.dsn, + cacheClient: false, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com'), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(sentryEvent.contexts?.trace?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + + test.each([0, 1])('starts its own trace with tracesSampleRate %s', async rate => { + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: MOCK_OPTIONS.dsn, + cacheClient: false, + tracesSampleRate: rate, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com'), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(sentryEvent.contexts?.trace?.trace_id).not.toBe(CLOUDFLARE_TRACE_ID); + expect(sentryEvent.contexts?.trace?.parent_span_id).toBeUndefined(); + }); + + test('does not make the request span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + await wrapRequestHandler( + { + options: { dsn: MOCK_OPTIONS.dsn, cacheClient: false, tracesSampleRate: 1 }, + request: new Request('https://example.com'), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + return new Response('test'); + }, + ); + + expect(rootSpan?.trace_id).not.toBe(CLOUDFLARE_TRACE_ID); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); + + test('prefers an incoming sentry-trace header', async () => { + const request = new Request('https://example.com'); + request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012'); + + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: MOCK_OPTIONS.dsn, + cacheClient: false, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request, + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toBe('12312012123120121231201212312012'); + }); + + test.each([ + { + reason: 'the org ids do not match', + headers: { + 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-trace_id=12312012123120121231201212312012,sentry-org_id=2', + }, + options: {}, + }, + { + reason: 'strictTraceContinuation is set and the baggage has no org id', + headers: { 'sentry-trace': '12312012123120121231201212312012-1121201211212012-1' }, + options: { strictTraceContinuation: true }, + }, + { + reason: 'the sentry-trace header is malformed', + headers: { 'sentry-trace': 'not-a-sentry-trace' }, + options: {}, + }, + ])('uses the Cloudflare trace when $reason', async ({ headers, options }) => { + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: 'https://public@o1.ingest.sentry.io/1337', + cacheClient: false, + ...options, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com', { headers }), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(sentryEvent.contexts?.trace?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + }); }); describe('httpServerIntegration span options', () => { diff --git a/packages/cloudflare/test/utils/cloudflareTrace.test.ts b/packages/cloudflare/test/utils/cloudflareTrace.test.ts new file mode 100644 index 000000000000..21b1f38cc667 --- /dev/null +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -0,0 +1,153 @@ +import type { Client } from '@sentry/core'; +import { getCurrentScope, withScope } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { continueCloudflareTrace } from '../../src/utils/cloudflareTrace'; + +const CLOUDFLARE_TRACE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; +const CLOUDFLARE_SPAN_ID = 'b1c2d3e4f5a60718'; + +describe('continueCloudflareTrace()', () => { + it('continues the trace of a traced Cloudflare span', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + + withScope(() => { + continueCloudflareTrace(context); + + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: CLOUDFLARE_TRACE_ID, + parentSpanId: CLOUDFLARE_SPAN_ID, + sampleRand: expect.any(Number), + }); + }); + }); + + it.each([ + ['tracesSampleRate', { tracesSampleRate: 1 }], + ['tracesSampleRate 0', { tracesSampleRate: 0 }], + ['tracesSampler', { tracesSampler: () => 1 }], + ])('keeps the propagation context when tracing is set up with %s', (_, options) => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + + withScope(scope => { + scope.setClient({ getOptions: () => options } as unknown as Client); + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context); + + expect(scope.getPropagationContext()).toBe(propagationContext); + }); + }); + + it('keeps the propagation context when the scope continues a trace', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + + withScope(scope => { + scope.setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.5, + }); + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context); + + expect(scope.getPropagationContext()).toBe(propagationContext); + }); + }); + + it.each([ + ['there is no execution context', undefined], + ['the runtime has no tracing API', {}], + ['the runtime has no getActiveSpan', { tracing: {} }], + ['there is no active span', { tracing: { getActiveSpan: () => undefined } }], + [ + 'the runtime has no spanContext', + { + tracing: { getActiveSpan: () => ({ isTraced: true }) }, + }, + ], + [ + 'Cloudflare does not trace the invocation', + { + tracing: { + getActiveSpan: () => ({ + isTraced: false, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 0 }), + }), + }, + }, + ], + [ + 'the span context has null ids', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags: 0 }), + }), + }, + }, + ], + [ + 'the span context has a malformed trace id', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: 'not-a-trace-id', spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }, + ], + [ + 'the span context has a malformed span id', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: 'B1C2D3E4F5A60718', traceFlags: 1 }), + }), + }, + }, + ], + [ + 'reading the active span throws', + { + tracing: { + getActiveSpan: () => { + throw new Error('torn down'); + }, + }, + }, + ], + ])('keeps the propagation context when %s', (_, context) => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context); + + expect(scope.getPropagationContext()).toBe(propagationContext); + }); + }); +}); diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts index fec8f40b7f78..4daac8988428 100644 --- a/packages/server-utils/src/utils/wrapServerRequestHandler.ts +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -57,6 +57,11 @@ export interface ServerRequestHandlerConfig { routeName?: string; /** Span attributes that only the runtime knows, e.g. the route parameters. */ attributes?: SpanAttributes; + /** + * Called in the scope of the continued trace, before the span starts. Lets the runtime continue + * its own trace, e.g. the trace that Cloudflare exports for the invocation. + */ + onTraceContinued?: () => void; /** Whether the response streams. Return `undefined` to use the default classification. */ isStreamingResponse?: (response: Response) => boolean | undefined; /** @@ -174,6 +179,8 @@ export async function wrapServerRequestHandler { + config.onTraceContinued?.(); + if (!createSpan) { let response: R; try {