From a831f0a93b88d3024b072a3f7dd76f9222340e33 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sun, 25 Oct 2026 11:55:28 +0100 Subject: [PATCH] feat(cloudflare): Continue the Cloudflare trace of the invocation The SDK now reads `ctx.tracing.getActiveSpan().spanContext()` and sets the propagation context to the Cloudflare trace id and span id. Errors, logs and spans then use the same trace id as the spans that Cloudflare exports, also when `tracesSampleRate` is 0. An incoming `sentry-trace` header keeps precedence. Runtimes without `spanContext()` and invocations that Cloudflare does not trace keep the current behavior. `spanContext()` ships in workerd 1.20261009.1, so only the optional CI job with the latest wrangler runs the new code path. The integration tests select the expected values of each runtime with `supports({ minWorkerd })`. The latest wrangler traces every local invocation, so a root span can have the Cloudflare span as parent, and the tests find the child segment by its parent instead. Co-Authored-By: Claude Opus 5.5 --- .../cloudflare-integration-tests/runtime.ts | 31 ++++ .../suites/hono-sdk/test.ts | 10 +- .../suites/public-api/startSpan/test.ts | 2 + .../suites/tracing/cloudflare-trace/index.ts | 18 +++ .../cloudflare-trace/instrument.server.ts | 6 + .../suites/tracing/cloudflare-trace/test.ts | 118 ++++++++++++++ .../tracing/cloudflare-trace/vite.config.mts | 7 + .../tracing/cloudflare-trace/wrangler.jsonc | 9 ++ .../suites/tracing/dsc-url-source/test.ts | 10 +- .../suites/tracing/instrument-fetcher/test.ts | 18 ++- .../worker-service-binding/test.ts | 8 +- .../test.ts | 10 +- .../combination-entrypoint-do/test.ts | 10 +- .../durableobject-manual-wrap/test.ts | 10 +- .../durableobject-mixed/test.ts | 12 +- .../durableobject-multiple/test.ts | 12 +- .../test.ts | 10 +- .../durableobject-specifier-alias/test.ts | 10 +- .../durableobject-specifier/test.ts | 10 +- .../test.ts | 10 +- .../durableobject-workflow-specifier/test.ts | 10 +- .../durableobject-workflow/test.ts | 10 +- .../vite-autoinstrument/durableobject/test.ts | 10 +- .../worker/instrumentEmail.ts | 2 + .../worker/instrumentQueue.ts | 2 + .../worker/instrumentScheduled.ts | 2 + .../cloudflare/src/utils/cloudflareTrace.ts | 56 +++++++ .../src/wrapRequestHandlerWithInit.ts | 3 + .../worker/instrumentEmail.test.ts | 29 ++++ .../worker/instrumentQueue.test.ts | 29 ++++ .../worker/instrumentScheduled.test.ts | 29 ++++ packages/cloudflare/test/request.test.ts | 81 ++++++++++ .../test/utils/cloudflareTrace.test.ts | 146 ++++++++++++++++++ 33 files changed, 706 insertions(+), 34 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/runtime.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc create mode 100644 packages/cloudflare/src/utils/cloudflareTrace.ts create mode 100644 packages/cloudflare/test/utils/cloudflareTrace.test.ts 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/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/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..1fbab71a9318 --- /dev/null +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -0,0 +1,56 @@ +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 the request `headers` have a `sentry-trace` header: an incoming Sentry trace + * keeps precedence, so traces from instrumented clients stay connected. Also 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, headers?: Headers): void { + if (headers?.get('sentry-trace')) { + 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/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 71444c9a87e3..84924c5fa11f 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,8 @@ export function wrapRequestHandlerWithInit( return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + continueCloudflareTrace(context, request.headers); + // 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..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 a8c9a3f735f0..253b7cacef9e 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, + }, + () => { + 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'); + }); + }); }); }); diff --git a/packages/cloudflare/test/utils/cloudflareTrace.test.ts b/packages/cloudflare/test/utils/cloudflareTrace.test.ts new file mode 100644 index 000000000000..639a8a79dc90 --- /dev/null +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -0,0 +1,146 @@ +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('continues the trace when the sentry-trace header is empty', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + const headers = new Headers({ 'sentry-trace': '' }); + + withScope(() => { + continueCloudflareTrace(context, headers); + + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: CLOUDFLARE_TRACE_ID, + parentSpanId: CLOUDFLARE_SPAN_ID, + sampleRand: expect.any(Number), + }); + }); + }); + + it('keeps the propagation context when the request has a sentry-trace header', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + const headers = new Headers({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1' }); + + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context, headers); + + 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); + }); + }); +});