From d0534ecc2ccad601f2a14c8cf5dc11af27096fe7 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Sun, 25 Oct 2026 11:55:28 +0100 Subject: [PATCH 1/4] feat(cloudflare): Continue the Cloudflare trace of the invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-Authored-By: Jan Peer Stöcklmair --- .../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 | 2 + .../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 ++++++++++++++++++ .../src/utils/wrapServerRequestHandler.ts | 7 + 34 files changed, 712 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 adf52419022f..a59ec410c37a 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 @@ -15,6 +15,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'; @@ -293,6 +294,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 f6339248726f..045cf261ca9f 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -24,6 +24,7 @@ import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { extractQueueTraceContext } from '../../utils/queueTraceMeta'; @@ -79,6 +80,7 @@ function wrapQueueHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); return startSpan( { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 8285830b73c1..2cbe32129368 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -21,6 +21,7 @@ import type { CronTriggersIntegration } from '../../integrations/cronTriggers'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; @@ -40,6 +41,7 @@ function wrapScheduledHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); const description = `Scheduled Cron ${controller.cron}`; diff --git a/packages/cloudflare/src/utils/cloudflareTrace.ts b/packages/cloudflare/src/utils/cloudflareTrace.ts new file mode 100644 index 000000000000..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 76470db655f4..58d63a1ddbe8 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -14,6 +14,7 @@ import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { addCloudResourceContext, addCultureContext } from './scope-utils'; +import { continueCloudflareTrace } from './utils/cloudflareTrace'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; @@ -128,6 +129,7 @@ export function wrapRequestHandlerWithInit( origin: 'auto.http.cloudflare', mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined, attributes, + onTraceContinued: () => continueCloudflareTrace(context, request.headers), isStreamingResponse: response => { const classification = safeCallback( DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', 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 d58d1e5ff4fa..b0f643e8e7b7 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -398,6 +398,35 @@ describe('instrumentQueue', () => { }, ]); }); + + 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 aba83d5f66c3..0d207c59a58b 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -703,6 +703,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'); + }); + }); }); 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..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); + }); + }); +}); diff --git a/packages/server-utils/src/utils/wrapServerRequestHandler.ts b/packages/server-utils/src/utils/wrapServerRequestHandler.ts index fec8f40b7f78..4daac8988428 100644 --- a/packages/server-utils/src/utils/wrapServerRequestHandler.ts +++ b/packages/server-utils/src/utils/wrapServerRequestHandler.ts @@ -57,6 +57,11 @@ export interface ServerRequestHandlerConfig { routeName?: string; /** Span attributes that only the runtime knows, e.g. the route parameters. */ attributes?: SpanAttributes; + /** + * Called in the scope of the continued trace, before the span starts. Lets the runtime continue + * its own trace, e.g. the trace that Cloudflare exports for the invocation. + */ + onTraceContinued?: () => void; /** Whether the response streams. Return `undefined` to use the default classification. */ isStreamingResponse?: (response: Response) => boolean | undefined; /** @@ -174,6 +179,8 @@ export async function wrapServerRequestHandler { + config.onTraceContinued?.(); + if (!createSpan) { let response: R; try { From fe52c292e895c578f2f79997be6ac6c3fa10ea77 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 27 Oct 2026 10:33:49 -0600 Subject: [PATCH 2/4] fixup! feat(cloudflare): Continue the Cloudflare trace of the invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Jan Peer Stöcklmair --- dev-packages/cloudflare-integration-tests/expect.ts | 2 ++ .../astro-6-cf-workers/tests/errors.server.test.ts | 3 ++- .../astro-6-cf-workers/tests/tracing.dynamic.test.ts | 4 ++-- .../tests/performance/outgoing-fetch.server.test.ts | 11 ++++++++--- 4 files changed, 14 insertions(+), 6 deletions(-) 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/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/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'); }); From 66599aaa6e77066a9684e16ad987f60de6f0fb2d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 28 Oct 2026 17:06:22 -0600 Subject: [PATCH 3/4] fixup! feat(cloudflare): Continue the Cloudflare trace of the invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Jan Peer Stöcklmair --- .../worker-workerentrypoint-rpc/test.ts | 31 ++++++ .../cloudflare/src/utils/cloudflareTrace.ts | 11 +-- .../cloudflare/src/wrapMethodWithSentry.ts | 11 ++- .../src/wrapRequestHandlerWithInit.ts | 2 +- .../instrumentWorkerEntrypoint.test.ts | 94 +++++++++++++++++++ packages/cloudflare/test/request.test.ts | 45 +++++++++ .../test/utils/cloudflareTrace.test.ts | 32 ++----- 7 files changed, 190 insertions(+), 36 deletions(-) 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/packages/cloudflare/src/utils/cloudflareTrace.ts b/packages/cloudflare/src/utils/cloudflareTrace.ts index 1fbab71a9318..d0dd783fb349 100644 --- a/packages/cloudflare/src/utils/cloudflareTrace.ts +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -1,4 +1,4 @@ -import { _INTERNAL_safeMathRandom, getCurrentScope } from '@sentry/core'; +import { _INTERNAL_safeMathRandom, getCurrentScope, isContinuingTrace } from '@sentry/core'; interface CloudflareSpanContext { traceId: string; @@ -23,13 +23,12 @@ 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 + * 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, headers?: Headers): void { - if (headers?.get('sentry-trace')) { +export function continueCloudflareTrace(context: unknown): void { + if (isContinuingTrace(getCurrentScope().getPropagationContext())) { return; } 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 58d63a1ddbe8..837cf36c5d99 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -129,7 +129,7 @@ export function wrapRequestHandlerWithInit( origin: 'auto.http.cloudflare', mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined, attributes, - onTraceContinued: () => continueCloudflareTrace(context, request.headers), + onTraceContinued: () => continueCloudflareTrace(context), isStreamingResponse: response => { const classification = safeCallback( DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '', diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 875e03aab0db..548c04b4e375 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -574,6 +574,100 @@ describe('instrumentWorkerEntrypoint', () => { expect(initAndBind).toHaveBeenCalledTimes(5); }); + + describe('with a Cloudflare trace', () => { + const CLOUDFLARE_TRACE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + const CLOUDFLARE_SPAN_ID = 'b1c2d3e4f5a60718'; + const INCOMING_TRACE_ID = '12312012123120121231201212312012'; + const INCOMING_SPAN_ID = '1121201211212012'; + const CLOUDFLARE_TRACING = { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }; + + it('attaches errors of RPC calls without RPC metadata to the Cloudflare trace', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext; + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); + const TestClass = class extends WorkerEntrypoint { + async fail(): Promise { + throw new Error('rpc failure'); + } + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await expect(obj.fail()).rejects.toThrow('rpc failure'); + await Promise.all(waits); + + expect(events).toHaveLength(1); + expect(events[0]?.contexts?.trace?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(events[0]?.contexts?.trace?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + + it('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/request.test.ts b/packages/cloudflare/test/request.test.ts index 0d207c59a58b..626c98d6c615 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -783,6 +783,51 @@ describe('withSentry', () => { 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); + }); }); }); diff --git a/packages/cloudflare/test/utils/cloudflareTrace.test.ts b/packages/cloudflare/test/utils/cloudflareTrace.test.ts index 639a8a79dc90..dc6f1cb11b44 100644 --- a/packages/cloudflare/test/utils/cloudflareTrace.test.ts +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -27,7 +27,7 @@ describe('continueCloudflareTrace()', () => { }); }); - it('continues the trace when the sentry-trace header is empty', () => { + it('keeps the propagation context when the scope continues a trace', () => { const context = { tracing: { getActiveSpan: () => ({ @@ -36,34 +36,16 @@ describe('continueCloudflareTrace()', () => { }), }, }; - 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 => { + scope.setPropagationContext({ + traceId: '12345678901234567890123456789012', + parentSpanId: '1234567890123456', + sampleRand: 0.5, + }); const propagationContext = scope.getPropagationContext(); - continueCloudflareTrace(context, headers); + continueCloudflareTrace(context); expect(scope.getPropagationContext()).toBe(propagationContext); }); From c4d32365439108fce1712fd07dfc41d1cfb983e5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:29:00 +0000 Subject: [PATCH 4/4] fixup! feat(cloudflare): Continue the Cloudflare trace of the invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only continue the Cloudflare trace when Sentry tracing is not set up (no tracesSampleRate or tracesSampler), so a trace has a single source and Cloudflare and SDK spans never mix. Co-Authored-By: Jan Peer Stöcklmair --- .../suites/hono-sdk/test.ts | 2 - .../suites/public-api/startSpan/test.ts | 2 - .../cloudflare-trace/instrument.server.ts | 2 +- .../suites/tracing/cloudflare-trace/test.ts | 54 +++++++------------ .../tracing/cloudflare-trace/wrangler.jsonc | 2 +- .../suites/tracing/dsc-url-source/test.ts | 2 - .../suites/tracing/instrument-fetcher/test.ts | 18 +++---- .../worker-service-binding/test.ts | 8 +-- .../worker-workerentrypoint-rpc/test.ts | 31 ----------- .../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 +--- .../tests/errors.server.test.ts | 3 +- .../tests/tracing.dynamic.test.ts | 4 +- .../performance/outgoing-fetch.server.test.ts | 11 ++-- .../cloudflare/src/utils/cloudflareTrace.ts | 13 +++-- .../instrumentWorkerEntrypoint.test.ts | 7 +-- .../worker/instrumentEmail.test.ts | 41 +++++++++----- .../worker/instrumentQueue.test.ts | 41 +++++++++----- .../worker/instrumentScheduled.test.ts | 41 +++++++++----- packages/cloudflare/test/request.test.ts | 37 +++++++++++-- .../test/utils/cloudflareTrace.test.ts | 25 +++++++++ 31 files changed, 216 insertions(+), 252 deletions(-) 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 ccef328f6741..ebf929489edd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -1,7 +1,6 @@ import { expect, it } from 'vitest'; 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 }) => { @@ -33,7 +32,6 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { trace: { trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER, - parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, }, }, breadcrumbs: [ 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 a59ec410c37a..adf52419022f 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 @@ -15,7 +15,6 @@ 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'; @@ -294,7 +293,6 @@ 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/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts index c38c68786a23..0d33b7bb23d0 100644 --- 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 @@ -2,5 +2,5 @@ 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), + tracesSampleRate: env.TRACES_SAMPLE_RATE ? Number(env.TRACES_SAMPLE_RATE) : undefined, })); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts index 4ca3cd30c9d9..2f6323969634 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts @@ -6,59 +6,59 @@ 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', + 'uses the Cloudflare trace for the error event when tracing is not set up', 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); + expect(errorEvents[0]?.contexts?.trace).toEqual({ + trace_id: cloudflareSpan?.traceId, + span_id: SHORT_UUID_MATCHER, + parent_span_id: cloudflareSpan?.spanId, + }); }, ); it.runIf(supports({ minWorkerd: '1.20261009.1' }))( - 'uses the Cloudflare trace for the error event when no span is sampled', + 'starts its own trace when tracing is set up', async ({ signal }) => { const errorEvents: Event[] = []; + const spanContainers: SerializedStreamedSpanContainer[] = []; const runner = createRunner(__dirname) - .withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:0') + .withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:1') + .unordered() .expect(envelope => { expect(envelope[1][0][0].type).toBe('event'); errorEvents.push(envelope[1][0][1] as Event); }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + spanContainers.push(envelope[1][0][1] as SerializedStreamedSpanContainer); + }) .start(signal); const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); await runner.completed(); + const segmentSpans = spanContainers.flatMap(container => container.items).filter(span => span.is_segment); expect(cloudflareSpan).toEqual({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER }); expect(errorEvents).toHaveLength(1); - expect(errorEvents[0]?.contexts?.trace).toEqual({ - trace_id: cloudflareSpan?.traceId, - span_id: SHORT_UUID_MATCHER, - parent_span_id: cloudflareSpan?.spanId, - }); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).not.toBe(cloudflareSpan?.traceId); + expect(segmentSpans[0]?.parent_span_id).toBeUndefined(); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id); }, ); @@ -66,30 +66,20 @@ 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); + expect(errorEvents[0]?.contexts?.trace).toEqual({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }); }, ); @@ -97,14 +87,10 @@ it('continues an incoming Sentry trace instead of the Cloudflare trace', async ( 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', '/', { 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 index 7edd747472d8..37b1010b07a7 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc @@ -4,6 +4,6 @@ "main": "index.ts", "compatibility_flags": ["nodejs_compat"], "vars": { - "TRACES_SAMPLE_RATE": "1", + "TRACES_SAMPLE_RATE": "", }, } diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts index e4106335f8b0..fb98b6ed2bc5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -1,7 +1,6 @@ import { expect, it } from 'vitest'; 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 }) => { @@ -34,7 +33,6 @@ it('omits the span name from the DSC for url-source spans when tracing is enable trace: { trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER, - parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, }, }, }, 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 4262a825c550..53a5651214b9 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,7 +1,5 @@ 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'; @@ -17,7 +15,8 @@ type EchoedHeaders = { const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; function startStubFetchScenario(signal: AbortSignal) { - const segmentSpans: SerializedStreamedSpan[] = []; + let mainSpan: SerializedStreamedSpan | undefined; + let doSpan: SerializedStreamedSpan | undefined; // The worker and the Durable Object stream their spans from separate isolates, so each one // arrives in its own envelope. @@ -26,13 +25,15 @@ function startStubFetchScenario(signal: AbortSignal) { const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); expect(getSpanOp(segmentSpan!)).toBe('http.server'); - segmentSpans.push(segmentSpan!); + expect(segmentSpan?.parent_span_id).toBeUndefined(); + mainSpan = segmentSpan; }) .expect(envelope => { const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); expect(getSpanOp(segmentSpan!)).toBe('http.server'); - segmentSpans.push(segmentSpan!); + expect(segmentSpan?.parent_span_id).toBeDefined(); + doSpan = segmentSpan; }) .unordered() .start(signal); @@ -42,10 +43,6 @@ 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'); @@ -53,9 +50,6 @@ 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 6643e7627a23..6fd5fa551512 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,7 +1,5 @@ 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 }) => { @@ -16,9 +14,8 @@ 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); - // 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); + const worker = segmentSpans.find(span => !span.parent_span_id); + const subWorker = segmentSpans.find(span => span.parent_span_id); expect(worker?.name).toBe('GET /'); expect(worker?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -30,5 +27,4 @@ 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 113a282432c8..4ff5eeed7fbb 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,8 +1,6 @@ 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 @@ -227,32 +225,3 @@ 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 fc883afbdbe5..1630042291f6 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,7 +1,5 @@ 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 @@ -51,16 +49,12 @@ it('leaves a manually wrapped Durable Object untouched while auto-wrapping a sib await runner.makeRequest('get', '/increment'); const durableObjectTraceSpans = await durableObjectSpansPromise; - // 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); + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); 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 600f7ec57f16..cf4768c16c01 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,7 +1,5 @@ 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`, @@ -49,16 +47,12 @@ it('auto-instruments a WorkerEntrypoint and a Durable Object exported from the s await runner.makeRequest('get', '/increment'); const durableObjectTraceSpans = await durableObjectSpansPromise; - // 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); + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); 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 d7f957a556a4..e538a69912a9 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,7 +1,5 @@ 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 @@ -22,11 +20,8 @@ it('leaves a manually wrapped Durable Object untouched and still wraps the defau await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -34,7 +29,6 @@ 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 bfde29c35ddd..972039a27bda 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,7 +1,5 @@ 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 @@ -23,11 +21,8 @@ it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', await runner.makeRequest('get', path); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); @@ -35,9 +30,6 @@ 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 74668c8d913b..eaa634bf2073 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,7 +1,5 @@ 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 @@ -21,11 +19,8 @@ it('auto-instruments multiple Durable Object classes in one entry', async ({ sig await runner.makeRequest('get', path); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); @@ -33,9 +28,6 @@ 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 af64f11bb42c..63d164ff5c84 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,7 +1,5 @@ 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 @@ -26,11 +24,8 @@ it('does not double-instrument an imported, already-wrapped Durable Object and s await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -38,7 +33,6 @@ 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 665973271e70..a54d5dfa0308 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,7 +1,5 @@ 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* @@ -20,11 +18,8 @@ it('auto-instruments a Durable Object exported via an aliased specifier', async await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -32,7 +27,6 @@ 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 0a368a67707f..7b02c87d3df9 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,7 +1,5 @@ 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 }`) @@ -20,11 +18,8 @@ it('auto-instruments a Durable Object exported via a specifier list', async ({ s await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -32,7 +27,6 @@ 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 47e4dd66613c..4abc814568d3 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,7 +1,5 @@ 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` @@ -24,18 +22,14 @@ it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Wo await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); 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 a384dd2f683e..ffefbcade6c3 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,7 +1,5 @@ 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 @@ -23,18 +21,14 @@ it('auto-instruments a Durable Object and a Workflow exported via a specifier li await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); 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 347902151038..9861748a30ce 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,7 +1,5 @@ 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 @@ -22,18 +20,14 @@ it('auto-instruments a Durable Object and a Workflow in the same entry', async ( await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); 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 e09064ca64a8..c7012293e240 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,7 +1,5 @@ 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` / @@ -28,11 +26,8 @@ it('auto-instruments the default handler and a Durable Object', async ({ signal await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - // 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); + const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); + const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -40,7 +35,6 @@ 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/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 be65e336c239..a1d0709718b8 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,8 +24,7 @@ test.describe('server-side errors', () => { expect(traceId).toMatch(/[a-f0-9]{32}/); expect(spanId).toMatch(/[a-f0-9]{16}/); - // The parent is the Cloudflare span of the request. - expect(span.parent_span_id).toMatch(/^[a-f0-9]{16}$/); + expect(span.parent_span_id).toBeUndefined(); 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 ddec1a6e53f4..c6b2dd13c5b3 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's parent is the Cloudflare span of the request - expect(serverPageRequestSpan.parent_span_id).toMatch(/^[a-f0-9]{16}$/); + // serverPageRequest has no parent (root span) + expect(serverPageRequestSpan.parent_span_id).toBeUndefined(); // 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/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 a56fe093e904..dd8edc2d9da4 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,14 +24,9 @@ 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.name === 'GET /performance/outgoing-fetch', - ), - ) + .poll(() => streamedSpans.find(span => span.is_segment && span.trace_id === traceId && !span.parent_span_id)) .toBeDefined(); - const serverSpan = streamedSpans.find( - span => span.is_segment && span.trace_id === traceId && span.name === 'GET /performance/outgoing-fetch', - )!; + const serverSpan = streamedSpans.find(span => span.is_segment && span.trace_id === traceId && !span.parent_span_id)!; expect(getSpanOp(serverSpan)).toBe('http.server'); + expect(serverSpan.name).toBe('GET /performance/outgoing-fetch'); }); diff --git a/packages/cloudflare/src/utils/cloudflareTrace.ts b/packages/cloudflare/src/utils/cloudflareTrace.ts index d0dd783fb349..6b9fa51e93d2 100644 --- a/packages/cloudflare/src/utils/cloudflareTrace.ts +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -1,4 +1,4 @@ -import { _INTERNAL_safeMathRandom, getCurrentScope, isContinuingTrace } from '@sentry/core'; +import { _INTERNAL_safeMathRandom, getCurrentScope, hasSpansEnabled, isContinuingTrace } from '@sentry/core'; interface CloudflareSpanContext { traceId: string; @@ -23,12 +23,15 @@ const SPAN_ID_REGEXP = /^(?!0{16})[0-9a-f]{16}$/; * Sets the propagation context of the current scope to the Cloudflare trace of the invocation, so * errors, logs and spans get the trace id of the spans that Cloudflare exports. * + * Only runs when Sentry tracing is not set up (no `tracesSampleRate` or `tracesSampler`), so a trace + * has one source: either the SDK or Cloudflare, never a mix of both. + * * Does nothing when the current scope already continues a trace, for example an incoming Sentry * trace, when Cloudflare does not trace the invocation, or when the runtime does not expose - * `Span.spanContext()`. The sampling decision is left to the SDK. + * `Span.spanContext()`. */ export function continueCloudflareTrace(context: unknown): void { - if (isContinuingTrace(getCurrentScope().getPropagationContext())) { + if (hasSpansEnabled() || isContinuingTrace(getCurrentScope().getPropagationContext())) { return; } @@ -45,8 +48,8 @@ export function continueCloudflareTrace(context: unknown): void { 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. + // No `dsc` and no `sampled`: the SDK is the head of the Sentry trace, so it builds the dynamic + // sampling context itself. getCurrentScope().setPropagationContext({ traceId: spanContext.traceId, parentSpanId: spanContext.spanId, diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 548c04b4e375..dcef18a8800a 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -621,7 +621,7 @@ describe('instrumentWorkerEntrypoint', () => { 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', () => { + it('does not make the RPC span a child of the Cloudflare span when tracing is set up', () => { let rpcSpan: ReturnType | undefined; const TestClass = class extends WorkerEntrypoint { read() { @@ -643,8 +643,9 @@ describe('instrumentWorkerEntrypoint', () => { }, }); - expect(rpcSpan?.trace_id).toBe(CLOUDFLARE_TRACE_ID); - expect(rpcSpan?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + expect(rpcSpan?.trace_id).not.toBe(CLOUDFLARE_TRACE_ID); + expect(rpcSpan?.trace_id).not.toBe(INCOMING_TRACE_ID); + expect(rpcSpan?.parent_span_id).toBeUndefined(); }); it('prefers the Sentry trace of the RPC metadata', () => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts index 6ad0c13838d4..50fadfa56e8f 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -280,15 +280,7 @@ describe('instrumentEmail', () => { }); }); - 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); + describe('with a Cloudflare trace', () => { const context = { ...createMockExecutionContext(), tracing: { @@ -303,10 +295,35 @@ describe('instrumentEmail', () => { }, } as unknown as ExecutionContext; - await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + test('continues the Cloudflare trace when tracing is not set up', async () => { + let propagationContext: ReturnType | undefined; + const handler = { + email(_arg, _env, _context) { + propagationContext = SentryCore.getCurrentScope().getPropagationContext(); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); - expect(rootSpan?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); - expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + expect(propagationContext?.traceId).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(propagationContext?.parentSpanId).toBe('b1c2d3e4f5a60718'); + }); + + test('does not make the email span a child of the Cloudflare span when tracing is set up', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + email(_arg, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).not.toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); }); }); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts index b0f643e8e7b7..dbfe1ae0e031 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -399,15 +399,7 @@ describe('instrumentQueue', () => { ]); }); - 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); + describe('with a Cloudflare trace', () => { const context = { ...createMockExecutionContext(), tracing: { @@ -422,10 +414,35 @@ describe('instrumentQueue', () => { }, } as unknown as ExecutionContext; - await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + test('continues the Cloudflare trace when tracing is not set up', async () => { + let propagationContext: ReturnType | undefined; + const handler = { + queue(_arg, _env, _context) { + propagationContext = SentryCore.getCurrentScope().getPropagationContext(); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(propagationContext?.traceId).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(propagationContext?.parentSpanId).toBe('b1c2d3e4f5a60718'); + }); - expect(rootSpan?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); - expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + test('does not make the queue span a child of the Cloudflare span when tracing is set up', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + queue(_arg, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).not.toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); }); }); diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 709d8d2b218a..851d2fe11ac9 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -306,15 +306,7 @@ describe('instrumentScheduled', () => { 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); + describe('with a Cloudflare trace', () => { const context = { ...createMockExecutionContext(), tracing: { @@ -329,10 +321,35 @@ describe('instrumentScheduled', () => { }, } as unknown as ExecutionContext; - await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + test('continues the Cloudflare trace when tracing is not set up', async () => { + let propagationContext: ReturnType | undefined; + const handler = { + scheduled(_arg, _env, _context) { + propagationContext = SentryCore.getCurrentScope().getPropagationContext(); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN }), handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); - expect(rootSpan?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); - expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + expect(propagationContext?.traceId).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(propagationContext?.parentSpanId).toBe('b1c2d3e4f5a60718'); + }); + + test('does not make the scheduled span a child of the Cloudflare span when tracing is set up', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + scheduled(_arg, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).not.toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBeUndefined(); + }); }); }); diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index 626c98d6c615..1d09b4ccd1f7 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -714,14 +714,13 @@ describe('withSentry', () => { }), }; - test.each([0, undefined])('attaches errors to the Cloudflare trace with tracesSampleRate %s', async rate => { + test('attaches errors to the Cloudflare trace when tracing is not set up', async () => { let sentryEvent: Event = {}; await wrapRequestHandler( { options: { dsn: MOCK_OPTIONS.dsn, cacheClient: false, - tracesSampleRate: rate, beforeSend(event) { sentryEvent = event; return null; @@ -737,9 +736,37 @@ describe('withSentry', () => { ); expect(sentryEvent.contexts?.trace?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(sentryEvent.contexts?.trace?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + + test.each([0, 1])('starts its own trace with tracesSampleRate %s', async rate => { + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: MOCK_OPTIONS.dsn, + cacheClient: false, + tracesSampleRate: rate, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com'), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toMatch(/^[a-f0-9]{32}$/); + expect(sentryEvent.contexts?.trace?.trace_id).not.toBe(CLOUDFLARE_TRACE_ID); + expect(sentryEvent.contexts?.trace?.parent_span_id).toBeUndefined(); }); - test('makes the request span a child of the Cloudflare span', async () => { + test('does not make the request span a child of the Cloudflare span', async () => { let rootSpan: ReturnType | undefined; await wrapRequestHandler( { @@ -753,8 +780,8 @@ describe('withSentry', () => { }, ); - expect(rootSpan?.trace_id).toBe(CLOUDFLARE_TRACE_ID); - expect(rootSpan?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + expect(rootSpan?.trace_id).not.toBe(CLOUDFLARE_TRACE_ID); + expect(rootSpan?.parent_span_id).toBeUndefined(); }); test('prefers an incoming sentry-trace header', async () => { diff --git a/packages/cloudflare/test/utils/cloudflareTrace.test.ts b/packages/cloudflare/test/utils/cloudflareTrace.test.ts index dc6f1cb11b44..21b1f38cc667 100644 --- a/packages/cloudflare/test/utils/cloudflareTrace.test.ts +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -1,3 +1,4 @@ +import type { Client } from '@sentry/core'; import { getCurrentScope, withScope } from '@sentry/core'; import { describe, expect, it } from 'vitest'; import { continueCloudflareTrace } from '../../src/utils/cloudflareTrace'; @@ -27,6 +28,30 @@ describe('continueCloudflareTrace()', () => { }); }); + it.each([ + ['tracesSampleRate', { tracesSampleRate: 1 }], + ['tracesSampleRate 0', { tracesSampleRate: 0 }], + ['tracesSampler', { tracesSampler: () => 1 }], + ])('keeps the propagation context when tracing is set up with %s', (_, options) => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + + withScope(scope => { + scope.setClient({ getOptions: () => options } as unknown as Client); + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context); + + expect(scope.getPropagationContext()).toBe(propagationContext); + }); + }); + it('keeps the propagation context when the scope continues a trace', () => { const context = { tracing: {