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..91baf7ea70b7 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,11 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { method: 'GET', url: expect.stringContaining('/error/param-123'), }, + contexts: { + // On runtimes with `Span.spanContext()`, the span continues the Cloudflare trace, so the trace context also + // has the Cloudflare span as `parent_span_id`. + trace: expect.objectContaining({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }), + }, breadcrumbs: [ { timestamp: expect.any(Number), diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts index 0fa5d410368b..477de95533ee 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts @@ -183,7 +183,11 @@ it('sends a streamed span envelope with correct spans for a manually started spa status: 'ok', }); - expect(segmentSpan).toEqual({ + // On runtimes with `Span.spanContext()`, the segment continues the Cloudflare trace, so the Cloudflare span is its parent. + const { parent_span_id: segmentParentSpanId, ...segmentSpanWithoutParent } = segmentSpan!; + expect(segmentParentSpanId).toBeOneOf([undefined, expect.stringMatching(/^[\da-f]{16}$/)]); + + expect(segmentSpanWithoutParent).toEqual({ attributes: { 'sentry.is_localhost': { value: true, type: 'boolean' }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, 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..7be3482cf480 --- /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 cloudflareSpan = span?.isTraced ? (span.spanContext?.() ?? null) : null; + + Sentry.captureException(new Error('Error in a Cloudflare trace')); + + return Response.json(cloudflareSpan); + }, +} 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..c38c68786a23 --- /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: Number(env.TRACES_SAMPLE_RATE), +})); 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..2d7a2502080b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts @@ -0,0 +1,96 @@ +import type { Event, SerializedStreamedSpanContainer } from '@sentry/core'; +import { createRequire } from 'module'; +import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; +import { createRunner } from '../../../runner'; + +// `wrangler dev` runs the workerd of its own miniflare. `Span.spanContext()` ships in workerd 1.20261009.1, so the +// pinned wrangler runs these tests without it and the latest wrangler runs them with it. +const wranglerRequire = createRequire(createRequire(__filename).resolve('wrangler/package.json')); +const miniflareRequire = createRequire(wranglerRequire.resolve('miniflare/package.json')); +const [, workerdDate = 0, workerdPatch = 0] = (miniflareRequire('workerd/package.json') as { version: string }).version + .split('.') + .map(Number); +const HAS_SPAN_CONTEXT = workerdDate > 20261009 || (workerdDate === 20261009 && workerdPatch >= 1); + +const VALID_TRACE_ID = expect.stringMatching(/^(?!0{32})[\da-f]{32}$/); +// Without `Span.spanContext()`, the Worker responds with `null` and the SDK starts its own trace. On a runtime with it, +// a `null` response fails the test, so the `VALID_TRACE_ID` fallbacks below apply only to older runtimes. +const EXPECTED_CLOUDFLARE_SPAN = HAS_SPAN_CONTEXT + ? expect.objectContaining({ traceId: VALID_TRACE_ID, spanId: SHORT_UUID_MATCHER }) + : null; + +it('uses the Cloudflare trace for the error event and the segment span', async ({ signal }) => { + const errorEvents: Event[] = []; + const spanContainers: SerializedStreamedSpanContainer[] = []; + + const runner = createRunner(__dirname) + .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(EXPECTED_CLOUDFLARE_SPAN); + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).toEqual(cloudflareSpan?.traceId ?? VALID_TRACE_ID); + expect(segmentSpans[0]?.parent_span_id).toBe(cloudflareSpan?.spanId); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id); +}); + +it('uses the Cloudflare trace for the error event when no span is sampled', async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:0') + .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(EXPECTED_CLOUDFLARE_SPAN); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.contexts?.trace).toEqual({ + trace_id: cloudflareSpan?.traceId ?? VALID_TRACE_ID, + span_id: SHORT_UUID_MATCHER, + parent_span_id: cloudflareSpan?.spanId, + }); +}); + +it('continues an incoming Sentry trace instead of the Cloudflare trace', async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .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'); + }) + .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..7edd747472d8 --- /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": "1", + }, +} 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..c8c8506c1842 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,11 @@ it('omits the span name from the DSC for url-source spans when tracing is enable method: 'GET', url: expect.any(String), }, + contexts: { + // On runtimes with `Span.spanContext()`, the span continues the Cloudflare trace, so the trace context also + // has the Cloudflare span as `parent_span_id`. + trace: expect.objectContaining({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }), + }, }, { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, ), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts index 53a5651214b9..ec312ba550fd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -15,8 +15,7 @@ type EchoedHeaders = { const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; function startStubFetchScenario(signal: AbortSignal) { - let mainSpan: SerializedStreamedSpan | undefined; - let doSpan: SerializedStreamedSpan | undefined; + const segmentSpans: SerializedStreamedSpan[] = []; // The worker and the Durable Object stream their spans from separate isolates, so each one // arrives in its own envelope. @@ -25,15 +24,13 @@ function startStubFetchScenario(signal: AbortSignal) { const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); expect(getSpanOp(segmentSpan!)).toBe('http.server'); - expect(segmentSpan?.parent_span_id).toBeUndefined(); - mainSpan = segmentSpan; + segmentSpans.push(segmentSpan!); }) .expect(envelope => { const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); expect(getSpanOp(segmentSpan!)).toBe('http.server'); - expect(segmentSpan?.parent_span_id).toBeDefined(); - doSpan = segmentSpan; + segmentSpans.push(segmentSpan!); }) .unordered() .start(signal); @@ -43,6 +40,10 @@ function startStubFetchScenario(signal: AbortSignal) { async completedWithTraceCheck(): Promise { await completed(); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const doSpan = segmentSpans.find(span => segmentSpans.some(parent => parent.span_id === span.parent_span_id)); + const mainSpan = segmentSpans.find(span => span !== doSpan); + // Both routes are raw URLs, so the streamed segment name keeps the method only. expect(mainSpan?.name).toBe('GET'); expect(doSpan?.name).toBe('GET'); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts index 6fd5fa551512..b66a6fe38aee 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts @@ -14,8 +14,9 @@ it('propagates trace from worker to worker via service binding', async ({ signal await runner.makeRequest('get', '/'); const segmentSpans = (await spansPromise).filter(span => span.is_segment); - const worker = segmentSpans.find(span => !span.parent_span_id); - const subWorker = segmentSpans.find(span => span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const subWorker = segmentSpans.find(span => segmentSpans.some(parent => parent.span_id === span.parent_span_id)); + const worker = segmentSpans.find(span => span !== subWorker); expect(worker?.name).toBe('GET /'); expect(worker?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts index 1630042291f6..349a1f3f91c5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts @@ -49,8 +49,11 @@ it('leaves a manually wrapped Durable Object untouched while auto-wrapping a sib await runner.makeRequest('get', '/increment'); const durableObjectTraceSpans = await durableObjectSpansPromise; - const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = durableObjectTraceSpans.find( + span => span.is_segment && durableObjectTraceSpans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts index cf4768c16c01..dbe54655d7d6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts @@ -47,8 +47,11 @@ it('auto-instruments a WorkerEntrypoint and a Durable Object exported from the s await runner.makeRequest('get', '/increment'); const durableObjectTraceSpans = await durableObjectSpansPromise; - const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = durableObjectTraceSpans.find( + span => span.is_segment && durableObjectTraceSpans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts index e538a69912a9..df2d9926230c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts @@ -20,8 +20,11 @@ it('leaves a manually wrapped Durable Object untouched and still wraps the defau await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts index 972039a27bda..eb2008e86da4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts @@ -21,8 +21,11 @@ it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', await runner.makeRequest('get', path); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts index eaa634bf2073..bec562208e14 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts @@ -19,8 +19,11 @@ it('auto-instruments multiple Durable Object classes in one entry', async ({ sig await runner.makeRequest('get', path); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts index 63d164ff5c84..04bda0f2c764 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts @@ -24,8 +24,11 @@ it('does not double-instrument an imported, already-wrapped Durable Object and s await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts index a54d5dfa0308..293da45171a3 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts @@ -18,8 +18,11 @@ it('auto-instruments a Durable Object exported via an aliased specifier', async await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts index 7b02c87d3df9..256f4766b891 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts @@ -18,8 +18,11 @@ it('auto-instruments a Durable Object exported via a specifier list', async ({ s await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts index 4abc814568d3..4bc8d855cfc2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts @@ -22,8 +22,11 @@ it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Wo await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts index ffefbcade6c3..d28ae9a14e7c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts @@ -21,8 +21,11 @@ it('auto-instruments a Durable Object and a Workflow exported via a specifier li await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts index 9861748a30ce..8a159ac29206 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts @@ -20,8 +20,11 @@ it('auto-instruments a Durable Object and a Workflow in the same entry', async ( await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts index c7012293e240..be4a9e3ba6be 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts @@ -26,8 +26,11 @@ it('auto-instruments the default handler and a Durable Object', async ({ signal await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); 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 b12f01474d6a..aea6ebb05d75 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -10,6 +10,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'; @@ -32,6 +33,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..7d5bbae1b264 --- /dev/null +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -0,0 +1,50 @@ +import { _INTERNAL_safeMathRandom, getCurrentScope } 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. + * + * Does nothing when Cloudflare does not trace the invocation, or when the runtime does not expose + * `Span.spanContext()`. The sampling decision is left to the SDK. + */ +export function continueCloudflareTrace(context: unknown): void { + 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 samples and builds + // the dynamic sampling context itself. + getCurrentScope().setPropagationContext({ + traceId: spanContext.traceId, + parentSpanId: spanContext.spanId, + sampleRand: _INTERNAL_safeMathRandom(), + }); +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 71444c9a87e3..a2247757ffed 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -27,6 +27,7 @@ import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; +import { continueCloudflareTrace } from './utils/cloudflareTrace'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; @@ -158,6 +159,11 @@ export function wrapRequestHandlerWithInit( return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + // An incoming Sentry trace keeps precedence, so traces from instrumented clients stay connected. + if (!request.headers.get('sentry-trace')) { + continueCloudflareTrace(context); + } + // Note: This span will not have a duration unless I/O happens in the handler. This is // because of how the cloudflare workers runtime works. // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts index 2c15f5a39966..eda2b0c13af6 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -279,6 +279,38 @@ describe('instrumentEmail', () => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); + + test('makes the email span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + email(_message, _env, _context) { + const activeSpan = SentryCore.getActiveSpan(); + rootSpan = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)) : undefined; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + expect(rootSpan).toMatchObject({ + trace_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + parent_span_id: 'b1c2d3e4f5a60718', + }); + }); }); 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 afe51790adfd..1c404289773b 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -295,6 +295,38 @@ describe('instrumentQueue', () => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); + + test('makes the queue span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + queue(_batch, _env, _context) { + const activeSpan = SentryCore.getActiveSpan(); + rootSpan = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)) : undefined; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(rootSpan).toMatchObject({ + trace_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + parent_span_id: 'b1c2d3e4f5a60718', + }); + }); }); 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..dac85e65ea8d 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -305,6 +305,38 @@ describe('instrumentScheduled', () => { test('keeps the descriptive span name when span streaming is disabled', async () => { expect(await spanNameFor('static')).toBe('Scheduled Cron 0 0 0 * * *'); }); + + test('makes the scheduled span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + scheduled(_controller, _env, _context) { + const activeSpan = SentryCore.getActiveSpan(); + rootSpan = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)) : undefined; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + + expect(rootSpan).toMatchObject({ + trace_id: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + parent_span_id: 'b1c2d3e4f5a60718', + }); + }); }); describe('cron monitoring', () => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a8c9a3f735f0..d136e68d2ce8 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -648,6 +648,87 @@ 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.each([0, undefined])('attaches errors to the Cloudflare 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).toBe(CLOUDFLARE_TRACE_ID); + }); + + test('makes 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, + }, + () => { + const activeSpan = SentryCore.getActiveSpan(); + rootSpan = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)) : undefined; + return new Response('test'); + }, + ); + + expect(rootSpan).toMatchObject({ trace_id: CLOUDFLARE_TRACE_ID, parent_span_id: CLOUDFLARE_SPAN_ID }); + }); + + 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'); + }); + }); }); }); diff --git a/packages/cloudflare/test/utils/cloudflareTrace.test.ts b/packages/cloudflare/test/utils/cloudflareTrace.test.ts new file mode 100644 index 000000000000..39e230d0000b --- /dev/null +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -0,0 +1,93 @@ +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([ + ['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 malformed ids', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: 'not-a-trace-id', spanId: CLOUDFLARE_SPAN_ID, 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); + }); + }); +});