diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index 917de52e997d..610efadef033 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -20,6 +20,7 @@ jobs: worker-prefix: - e2e-send-to-sentry - e2e-flue-send-to-sentry + - e2e-without-tracing-send-to-sentry steps: - name: Set up Node uses: actions/setup-node@v7 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..ccef328f6741 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../expect'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; import { createRunner } from '../../runner'; +import { supports } from '../../runtime'; import { getSpanOp, getSpansFromEnvelope } from '../../spanUtils'; it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { @@ -28,6 +29,13 @@ 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, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + }, + }, 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..a8228037be3a 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 @@ -14,6 +14,7 @@ import { } from '@sentry/conventions/attributes'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpansFromEnvelope } from '../../../spanUtils'; const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; @@ -283,6 +284,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa }, }, is_segment: true, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? expect.stringMatching(/^[\da-f]{16}$/) : undefined, trace_id: traceId, span_id: segmentSpanId, start_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..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..4ca3cd30c9d9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts @@ -0,0 +1,118 @@ +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 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({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER }); + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).toBe(cloudflareSpan?.traceId); + expect(segmentSpans[0]?.parent_span_id).toBe(cloudflareSpan?.spanId); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(cloudflareSpan?.traceId); + }, +); + +it.runIf(supports({ minWorkerd: '1.20261009.1' }))( + '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({ 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.skipIf(supports({ minWorkerd: '1.20261009.1' }))( + 'starts its own trace on runtimes without `Span.spanContext()`', + 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).toBeNull(); + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).toEqual(UUID_MATCHER); + expect(segmentSpans[0]?.parent_span_id).toBeUndefined(); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id); + }, +); + +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..e4106335f8b0 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,6 +1,7 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../../expect'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => { @@ -29,6 +30,13 @@ 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, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + }, + }, }, { 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..4262a825c550 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import type { SerializedStreamedSpan } from '@sentry/core'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -15,8 +17,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 +26,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 +42,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'); @@ -50,6 +53,9 @@ function startStubFetchScenario(signal: AbortSignal) { expect(doSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); expect(doSpan?.trace_id).toBe(mainSpan?.trace_id); expect(doSpan?.parent_span_id).toBe(mainSpan?.span_id); + expect(mainSpan?.parent_span_id).toEqual( + supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + ); }, }; } 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..6643e7627a23 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { supports } from '../../../../runtime'; import { getSpanOp } from '../../../../spanUtils'; it('propagates trace from worker to worker via service binding', async ({ signal }) => { @@ -14,8 +16,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' }); @@ -27,4 +30,5 @@ it('propagates trace from worker to worker via service binding', async ({ signal expect(subWorker?.trace_id).toBe(worker?.trace_id); expect(subWorker?.parent_span_id).toBe(worker?.span_id); + expect(worker?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts index 4ff5eeed7fbb..113a282432c8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -1,6 +1,8 @@ import { expect, it } from 'vitest'; import type { Event, SerializedStreamedSpan } from '@sentry/core'; +import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { supports } from '../../../../runtime'; import { getSpanOp, getSpansFromEnvelope } from '../../../../spanUtils'; // Every route here is a raw URL, so the streamed segment name keeps the method only and the route @@ -225,3 +227,32 @@ it('captures errors from loopback WorkerEntrypoint RPC without trace propagation await runner.completed(); }); + +it.runIf(supports({ minWorkerd: '1.20261009.1' }))( + 'attaches errors from loopback WorkerEntrypoint RPC without trace propagation to the Cloudflare trace', + async ({ signal }) => { + const errorEvents: Event[] = []; + const segmentSpans: SerializedStreamedSpan[] = []; + + const runner = createRunner(__dirname) + .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'); + segmentSpans.push(...getSpansFromEnvelope(envelope).filter(span => span.is_segment)); + }) + .unordered() + .start(signal); + + await runner.makeRequest('get', '/call-loopback-rpc-error'); + await runner.completed(); + + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + // The RPC call carries no Sentry trace data, so only the Cloudflare trace connects the error to the caller. + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id); + expect(errorEvents[0]?.contexts?.trace?.parent_span_id).toEqual(SHORT_UUID_MATCHER); + }, +); 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..fc883afbdbe5 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is manually wrapped with @@ -49,12 +51,16 @@ 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'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The storage pair comes from the manual wrap here. Exactly two of them also rules out a // double-wrap, which would nest proxies or break the build. 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..600f7ec57f16 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A single worker exports a plain `WorkerEntrypoint`, a plain `DurableObject`, @@ -47,12 +49,16 @@ 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'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. 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..d7f957a556a4 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is already wrapped manually with @@ -20,8 +22,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' }); @@ -29,6 +34,7 @@ it('leaves a manually wrapped Durable Object untouched and still wraps the defau expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap. 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..bfde29c35ddd 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // One DO (`Manual`) is wrapped by hand, the other (`Auto`) is a plain inline @@ -21,8 +23,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 }); @@ -30,6 +35,9 @@ it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual( + supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + ); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap of `Manual`. 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..74668c8d913b 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // Two Durable Object classes are bound in wrangler. Hitting both must produce a @@ -19,8 +21,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 }); @@ -28,6 +33,9 @@ it('auto-instruments multiple Durable Object classes in one entry', async ({ sig expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual( + supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + ); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. 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..af64f11bb42c 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // `Counter` is manually wrapped with `instrumentDurableObjectWithSentry` in a @@ -24,8 +26,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' }); @@ -33,6 +38,7 @@ it('does not double-instrument an imported, already-wrapped Durable Object and s expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap. 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..665973271e70 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The wrangler binding references `Counter`, which is only an exported *alias* @@ -18,8 +20,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' }); @@ -27,6 +32,7 @@ it('auto-instruments a Durable Object exported via an aliased specifier', async expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. 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..0a368a67707f 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is exported through a specifier list (`export { Counter }`) @@ -18,8 +20,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' }); @@ -27,6 +32,7 @@ it('auto-instruments a Durable Object exported via a specifier list', async ({ s expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. 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..47e4dd66613c 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is wrapped by hand with `instrumentDurableObjectWithSentry` @@ -22,14 +24,18 @@ 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' }); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap. 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..a384dd2f683e 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A Durable Object and a Workflow are both exported through a single specifier @@ -21,14 +23,18 @@ 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' }); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. 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..347902151038 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A single entry exports both a Durable Object and a Workflow, neither wrapped by @@ -20,14 +22,18 @@ 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' }); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. 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..e09064ca64a8 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 @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A Durable Object invoked via `fetch` gets its own `http.server` / @@ -26,8 +28,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' }); @@ -35,6 +40,7 @@ it('auto-instruments the default handler and a Durable Object', async ({ signal expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); expect( spans diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 3815cae556f0..09b051d0e848 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -23,5 +23,7 @@ E2E_OPENAI_DECISIONS_API_KEY= # Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry). # The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a # warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead. +# Apps that read Workers Logs (e.g. cloudflare-workers-without-tracing-send-to-sentry) need an API token with +# "Workers Observability: Edit". CLOUDFLARE_API_TOKEN= CLOUDFLARE_ACCOUNT_ID= diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index d71ba9b1b8a3..ac4c3faebcbf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -146,6 +146,9 @@ Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Wor `cloudflare-flue-send-to-sentry/src/auth.ts`. - Add the `workerPrefix` to `.github/workflows/cleanup-e2e-workers.yml`, which deletes the Worker of a PR when the PR closes. +- To check what Cloudflare stored for a request (for example its Cloudflare trace id), read Workers Logs with + `fetchWorkersLogsEvents`. `CLOUDFLARE_API_TOKEN` then also needs "Workers Observability: Edit". See + `cloudflare-workers-without-tracing-send-to-sentry`. - Set `"sentryTest": { "optional": true }` in `package.json`, because only the optional CI job has the Cloudflare secrets. diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/errors.server.test.ts index a1d0709718b8..be65e336c239 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/errors.server.test.ts @@ -24,7 +24,8 @@ test.describe('server-side errors', () => { expect(traceId).toMatch(/[a-f0-9]{32}/); expect(spanId).toMatch(/[a-f0-9]{16}/); - expect(span.parent_span_id).toBeUndefined(); + // The parent is the Cloudflare span of the request. + expect(span.parent_span_id).toMatch(/^[a-f0-9]{16}$/); expect(errorEvent).toMatchObject({ contexts: { diff --git a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts index c6b2dd13c5b3..ddec1a6e53f4 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts +++ b/dev-packages/e2e-tests/test-applications/astro-6-cf-workers/tests/tracing.dynamic.test.ts @@ -110,8 +110,8 @@ test.describe('nested SSR routes (client, server, server request)', () => { expect(serverHTTPServerRequestSpan.trace_id).toEqual(traceId); expect(serverRequestHTTPClientSpan.trace_id).toEqual(traceId); - // serverPageRequest has no parent (root span) - expect(serverPageRequestSpan.parent_span_id).toBeUndefined(); + // serverPageRequest's parent is the Cloudflare span of the request + expect(serverPageRequestSpan.parent_span_id).toMatch(/^[a-f0-9]{16}$/); // clientPageload's parent and serverRequestHTTPClient's parent is serverPageRequest expect(clientPageloadSpan.parent_span_id).toEqual(serverPageRequestSpan.span_id); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +dist +.wrangler +node_modules +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts new file mode 100644 index 000000000000..5a3e6215ca04 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-setup.ts @@ -0,0 +1,3 @@ +import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare'; + +export default createWorkerGlobalSetup({ workerPrefix: 'e2e-without-tracing-send-to-sentry' }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts new file mode 100644 index 000000000000..c7bcfe696e66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/global-teardown.ts @@ -0,0 +1 @@ +export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json new file mode 100644 index 000000000000..9dfe01fc5709 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/package.json @@ -0,0 +1,34 @@ +{ + "name": "cloudflare-workers-without-tracing-send-to-sentry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "1.64.1", + "@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz", + "@types/node": "^26.1.2", + "sentry": "~0.44.1", + "typescript": "~6.0.3", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..9452ae9ac890 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/playwright.config.ts @@ -0,0 +1,13 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig(undefined, { + // The tests read what arrived in Sentry through the sentry CLI, so there is no event proxy to start. + webServer: undefined, + // The worker is deployed once for the whole run and deleted again afterwards. + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + // A test polls Workers Logs and then Sentry, each for up to 180s. + timeout: 400_000, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml new file mode 100644 index 000000000000..fd29a82085e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true + workerd: true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts new file mode 100644 index 000000000000..2f403c3d780f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/index.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/cloudflare'; + +export default { + async fetch(request) { + switch (new URL(request.url).pathname) { + case '/test-error': { + const eventId = Sentry.captureException(new Error('E2E test error in a Cloudflare trace')); + // Workers Logs store the `cf-ray` of the request with the invocation, so the test finds the Cloudflare trace + // with it. + return Response.json({ eventId, rayId: request.headers.get('cf-ray') }); + } + default: + return new Response('Hello World!'); + } + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts new file mode 100644 index 000000000000..51377aa24309 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/src/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.E2E_TEST_DSN, + tracesSampleRate: 0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..ec0da7fc8ce3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { EVENT_POLLING_OPTIONS, fetchEvent, traceTarget } from '@sentry-internal/test-utils/cli'; +import type { ApiEvent } from '@sentry-internal/test-utils/cli'; +import { fetchFromWorker, fetchWorkersLogsEvents } from '@sentry-internal/test-utils/cloudflare'; +import type { WorkersLogsEvent } from '@sentry-internal/test-utils/cloudflare'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL; + +test('Sends an error in the Cloudflare trace to Sentry', async () => { + const { eventId, rayId }: { eventId: string; rayId: string } = JSON.parse( + await fetchFromWorker(`${workerUrl}/test-error`, 200), + ); + + // The span of the request is the root span of the Cloudflare trace, and the only span with the `cf-ray` of the + // request. + let cloudflareSpans: WorkersLogsEvent[] = []; + await expect + .poll( + async () => + (cloudflareSpans = await fetchWorkersLogsEvents({ '$metadata.rayId': rayId, '$metadata.type': 'span' })), + EVENT_POLLING_OPTIONS, + ) + .toHaveLength(1); + const { traceId, spanId } = cloudflareSpans[0]!.$metadata; + expect(traceId).toEqual(expect.stringMatching(/^[\da-f]{32}$/)); + expect(spanId).toEqual(expect.stringMatching(/^[\da-f]{16}$/)); + + console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId!)}`); + + let event: ApiEvent | undefined; + await expect.poll(() => (event = fetchEvent(eventId)), EVENT_POLLING_OPTIONS).toBeDefined(); + + expect(event?.contexts?.trace?.trace_id).toBe(traceId); + expect(event?.contexts?.trace?.parent_span_id).toBe(spanId); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..94b03468f288 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json new file mode 100644 index 000000000000..c49bedb6b3e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/tsconfig.node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/vite.config.ts @@ -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/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..ea985e5a54b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-without-tracing-send-to-sentry/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // Placeholder only: every test run deploys under a unique name, see global-setup.ts. + "name": "cloudflare-workers-without-tracing-send-to-sentry", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + // The tests read the Cloudflare trace of each request from Workers Logs. + "observability": { "enabled": true, "traces": { "enabled": true } }, +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts index dd8edc2d9da4..a56fe093e904 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/performance/outgoing-fetch.server.test.ts @@ -24,9 +24,14 @@ test('propagates the trace to an outgoing fetch', async ({ request }) => { expect(fetchSpan.trace_id).toBe(traceId); await expect - .poll(() => streamedSpans.find(span => span.is_segment && span.trace_id === traceId && !span.parent_span_id)) + .poll(() => + streamedSpans.find( + span => span.is_segment && span.trace_id === traceId && span.name === 'GET /performance/outgoing-fetch', + ), + ) .toBeDefined(); - const serverSpan = streamedSpans.find(span => span.is_segment && span.trace_id === traceId && !span.parent_span_id)!; + const serverSpan = streamedSpans.find( + span => span.is_segment && span.trace_id === traceId && span.name === 'GET /performance/outgoing-fetch', + )!; expect(getSpanOp(serverSpan)).toBe('http.server'); - expect(serverSpan.name).toBe('GET /performance/outgoing-fetch'); }); diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts index 7e0d92dded70..212b83dba125 100644 --- a/dev-packages/test-utils/src/cli.ts +++ b/dev-packages/test-utils/src/cli.ts @@ -135,6 +135,7 @@ export interface ApiEvent { type: string; data: { values?: { type?: string; value?: string; mechanism?: { type?: string; handled?: boolean } }[] }; }[]; + contexts?: { trace?: { trace_id?: string; span_id?: string; parent_span_id?: string } }; } /** Fetch an error event of the E2E test project. Returns `undefined` while the event is not stored yet. */ diff --git a/dev-packages/test-utils/src/cloudflare.ts b/dev-packages/test-utils/src/cloudflare.ts index f50908f03dc4..7dfd0a3445ce 100644 --- a/dev-packages/test-utils/src/cloudflare.ts +++ b/dev-packages/test-utils/src/cloudflare.ts @@ -224,6 +224,62 @@ export function workerGlobalTeardown(config: FullConfig): void { } } +/** An event of Workers Logs: an invocation, a `console` call, or a span of the Cloudflare trace. */ +export interface WorkersLogsEvent { + $metadata: { + /** The `cf-ray` header of the request that the event belongs to. */ + rayId?: string; + /** `span` for a span of the Cloudflare trace, `cf-worker-event` for an invocation. */ + type?: string; + traceId?: string; + spanId?: string; + }; +} + +/** + * Returns the Workers Logs events of the last hour with the given values, for example + * `{ '$metadata.rayId': rayId }`. Returns an empty list while the events are not queryable yet. + * + * `CLOUDFLARE_API_TOKEN` needs the "Workers Observability: Edit" permission, which the query endpoint + * requires also for reads. A `wrangler login` session does not have it. + */ +export async function fetchWorkersLogsEvents(values: Record): Promise { + const now = Date.now(); + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/workers/observability/telemetry/query`, + { + method: 'POST', + headers: { Authorization: `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + queryId: 'sentry-e2e-tests', + // Runs the query without saving it to the account. + dry: true, + view: 'events', + limit: 100, + timeframe: { from: now - 60 * 60 * 1000, to: now }, + parameters: { + filters: Object.entries(values).map(([key, value]) => ({ key, operation: 'eq', type: 'string', value })), + }, + }), + }, + ); + + if (response.status === 429) { + return []; + } + + const body = await response.text(); + + if (!response.ok) { + throw new Error( + `The Workers Logs query failed with status ${response.status}: ${body}. ` + + 'CLOUDFLARE_API_TOKEN needs the "Workers Observability: Edit" permission.', + ); + } + + return (JSON.parse(body) as { result: { events?: { events?: WorkersLogsEvent[] } } }).result.events?.events ?? []; +} + /** * Sends a request until the Worker itself answers it, and returns the body of that answer. * 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..d0dd783fb349 --- /dev/null +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -0,0 +1,55 @@ +import { _INTERNAL_safeMathRandom, getCurrentScope, 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. + * + * 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()`. The sampling decision is left to the SDK. + */ +export function continueCloudflareTrace(context: unknown): void { + if (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 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/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 e852ad1cf661..994acd99a062 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -32,6 +32,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'; @@ -265,6 +266,8 @@ export function wrapRequestHandlerWithInit( return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + continueCloudflareTrace(context); + if (!createSpan) { return respond(undefined); } diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 3c7f114099c0..79850127d5dc 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -531,6 +531,100 @@ describe('instrumentWorkerEntrypoint', () => { expect(initAndBind).toHaveBeenCalledTimes(3); }); + + 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('makes the RPC span a child of the Cloudflare span when the SDK does not continue the RPC trace', () => { + 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).toBe(CLOUDFLARE_TRACE_ID); + expect(rpcSpan?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + + 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..6ad0c13838d4 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -279,6 +279,35 @@ 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) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } 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?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBe('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..4f6c40ac85cb 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -295,6 +295,35 @@ 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) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } 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?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBe('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..709d8d2b218a 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -305,6 +305,35 @@ 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) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } 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?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + }); }); describe('cron monitoring', () => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 972180c62a8c..e7b8b42d49c2 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -687,6 +687,132 @@ 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, + }, + () => { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + return new Response('test'); + }, + ); + + expect(rootSpan?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(rootSpan?.parent_span_id).toBe(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'); + }); + + 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..dc6f1cb11b44 --- /dev/null +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -0,0 +1,128 @@ +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('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); + }); + }); +});