diff --git a/dev-packages/cloudflare-integration-tests/runtime.ts b/dev-packages/cloudflare-integration-tests/runtime.ts new file mode 100644 index 000000000000..9f175f0c94be --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/runtime.ts @@ -0,0 +1,31 @@ +import { parseSemver } from '@sentry/core'; +import { createRequire } from 'module'; + +// `wrangler dev` runs the workerd that its own miniflare depends on. +const wranglerRequire = createRequire(createRequire(__filename).resolve('wrangler/package.json')); +const miniflareRequire = createRequire(wranglerRequire.resolve('miniflare/package.json')); +const WORKERD_VERSION = parseSemver((miniflareRequire('workerd/package.json') as { version: string }).version); + +export type Requirements = { + /** Lowest workerd version that runs the tests, for example `1.20261009.1`. */ + minWorkerd?: string; +}; + +/** + * Whether the workerd that runs the Workers meets the requirements of the tests, for use in `it.runIf` and + * `it.skipIf`. The pinned wrangler of the required CI job runs an older workerd than the latest wrangler of the + * optional CI job. + */ +export function supports({ minWorkerd }: Requirements): boolean { + if (!minWorkerd) { + return true; + } + + const required = parseSemver(minWorkerd); + const difference = + (WORKERD_VERSION.major || 0) - (required.major || 0) || + (WORKERD_VERSION.minor || 0) - (required.minor || 0) || + (WORKERD_VERSION.patch || 0) - (required.patch || 0); + + return difference >= 0; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts index 33ba266ed7cc..ccef328f6741 100644 --- a/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../expect'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect'; import { createRunner } from '../../runner'; +import { supports } from '../../runtime'; import { getSpanOp, getSpansFromEnvelope } from '../../spanUtils'; it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { @@ -28,6 +29,13 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => { method: 'GET', url: expect.stringContaining('/error/param-123'), }, + contexts: { + trace: { + trace_id: UUID_MATCHER, + span_id: SHORT_UUID_MATCHER, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + }, + }, breadcrumbs: [ { timestamp: expect.any(Number), diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts index 0fa5d410368b..a8228037be3a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts @@ -14,6 +14,7 @@ import { } from '@sentry/conventions/attributes'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpansFromEnvelope } from '../../../spanUtils'; const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare'; @@ -283,6 +284,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa }, }, is_segment: true, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? expect.stringMatching(/^[\da-f]{16}$/) : undefined, trace_id: traceId, span_id: segmentSpanId, start_timestamp: expect.any(Number), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts new file mode 100644 index 000000000000..4da39ebf2b88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/index.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/cloudflare'; + +// `getActiveSpan` and `Span.spanContext` are newer than the installed workers types, and older runtimes do not have +// them. +interface CloudflareTracing { + getActiveSpan?(): { isTraced?: boolean; spanContext?(): { traceId: string; spanId: string } } | undefined; +} + +export default { + async fetch(_request, _env, ctx) { + const span = (ctx.tracing as unknown as CloudflareTracing | undefined)?.getActiveSpan?.(); + const spanContext = span?.isTraced ? span.spanContext?.() : undefined; + + Sentry.captureException(new Error('Error in a Cloudflare trace')); + + return Response.json(spanContext ? { traceId: spanContext.traceId, spanId: spanContext.spanId } : null); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts new file mode 100644 index 000000000000..c38c68786a23 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string; TRACES_SAMPLE_RATE: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: Number(env.TRACES_SAMPLE_RATE), +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts new file mode 100644 index 000000000000..4ca3cd30c9d9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/test.ts @@ -0,0 +1,118 @@ +import type { Event, SerializedStreamedSpanContainer } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect'; +import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; + +// `Span.spanContext()` ships in workerd 1.20261009.1. Without it, the Worker responds with `null`. +it.runIf(supports({ minWorkerd: '1.20261009.1' }))( + 'uses the Cloudflare trace for the error event and the segment span', + async ({ signal }) => { + const errorEvents: Event[] = []; + const spanContainers: SerializedStreamedSpanContainer[] = []; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + spanContainers.push(envelope[1][0][1] as SerializedStreamedSpanContainer); + }) + .start(signal); + + const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); + await runner.completed(); + + const segmentSpans = spanContainers.flatMap(container => container.items).filter(span => span.is_segment); + expect(cloudflareSpan).toEqual({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER }); + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).toBe(cloudflareSpan?.traceId); + expect(segmentSpans[0]?.parent_span_id).toBe(cloudflareSpan?.spanId); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(cloudflareSpan?.traceId); + }, +); + +it.runIf(supports({ minWorkerd: '1.20261009.1' }))( + 'uses the Cloudflare trace for the error event when no span is sampled', + async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:0') + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .start(signal); + + const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); + await runner.completed(); + + expect(cloudflareSpan).toEqual({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER }); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.contexts?.trace).toEqual({ + trace_id: cloudflareSpan?.traceId, + span_id: SHORT_UUID_MATCHER, + parent_span_id: cloudflareSpan?.spanId, + }); + }, +); + +it.skipIf(supports({ minWorkerd: '1.20261009.1' }))( + 'starts its own trace on runtimes without `Span.spanContext()`', + async ({ signal }) => { + const errorEvents: Event[] = []; + const spanContainers: SerializedStreamedSpanContainer[] = []; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + spanContainers.push(envelope[1][0][1] as SerializedStreamedSpanContainer); + }) + .start(signal); + + const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/'); + await runner.completed(); + + const segmentSpans = spanContainers.flatMap(container => container.items).filter(span => span.is_segment); + expect(cloudflareSpan).toBeNull(); + expect(errorEvents).toHaveLength(1); + expect(segmentSpans).toHaveLength(1); + expect(segmentSpans[0]?.trace_id).toEqual(UUID_MATCHER); + expect(segmentSpans[0]?.parent_span_id).toBeUndefined(); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id); + }, +); + +it('continues an incoming Sentry trace instead of the Cloudflare trace', async ({ signal }) => { + const errorEvents: Event[] = []; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + errorEvents.push(envelope[1][0][1] as Event); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + }) + .start(signal); + + await runner.makeRequest('get', '/', { + headers: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1' }, + }); + await runner.completed(); + + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe('12345678901234567890123456789012'); + expect(errorEvents[0]?.contexts?.trace?.parent_span_id).toBe('1234567890123456'); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc new file mode 100644 index 000000000000..7edd747472d8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/cloudflare-trace/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "cloudflare-trace-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "vars": { + "TRACES_SAMPLE_RATE": "1", + }, +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts index f9916e5c03c7..e4106335f8b0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; -import { eventEnvelope } from '../../../expect'; +import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => { @@ -29,6 +30,13 @@ it('omits the span name from the DSC for url-source spans when tracing is enable method: 'GET', url: expect.any(String), }, + contexts: { + trace: { + trace_id: UUID_MATCHER, + span_id: SHORT_UUID_MATCHER, + parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + }, + }, }, { includeSamplingFields: true, includeSampleRand: true, includeTransaction: false }, ), diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts index 53a5651214b9..4262a825c550 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import type { SerializedStreamedSpan } from '@sentry/core'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -15,8 +17,7 @@ type EchoedHeaders = { const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/; function startStubFetchScenario(signal: AbortSignal) { - let mainSpan: SerializedStreamedSpan | undefined; - let doSpan: SerializedStreamedSpan | undefined; + const segmentSpans: SerializedStreamedSpan[] = []; // The worker and the Durable Object stream their spans from separate isolates, so each one // arrives in its own envelope. @@ -25,15 +26,13 @@ function startStubFetchScenario(signal: AbortSignal) { const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); expect(getSpanOp(segmentSpan!)).toBe('http.server'); - expect(segmentSpan?.parent_span_id).toBeUndefined(); - mainSpan = segmentSpan; + segmentSpans.push(segmentSpan!); }) .expect(envelope => { const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment); expect(getSpanOp(segmentSpan!)).toBe('http.server'); - expect(segmentSpan?.parent_span_id).toBeDefined(); - doSpan = segmentSpan; + segmentSpans.push(segmentSpan!); }) .unordered() .start(signal); @@ -43,6 +42,10 @@ function startStubFetchScenario(signal: AbortSignal) { async completedWithTraceCheck(): Promise { await completed(); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const doSpan = segmentSpans.find(span => segmentSpans.some(parent => parent.span_id === span.parent_span_id)); + const mainSpan = segmentSpans.find(span => span !== doSpan); + // Both routes are raw URLs, so the streamed segment name keeps the method only. expect(mainSpan?.name).toBe('GET'); expect(doSpan?.name).toBe('GET'); @@ -50,6 +53,9 @@ function startStubFetchScenario(signal: AbortSignal) { expect(doSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare'); expect(doSpan?.trace_id).toBe(mainSpan?.trace_id); expect(doSpan?.parent_span_id).toBe(mainSpan?.span_id); + expect(mainSpan?.parent_span_id).toEqual( + supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + ); }, }; } diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts index 6fd5fa551512..6643e7627a23 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../../expect'; import { createRunner } from '../../../../runner'; +import { supports } from '../../../../runtime'; import { getSpanOp } from '../../../../spanUtils'; it('propagates trace from worker to worker via service binding', async ({ signal }) => { @@ -14,8 +16,9 @@ it('propagates trace from worker to worker via service binding', async ({ signal await runner.makeRequest('get', '/'); const segmentSpans = (await spansPromise).filter(span => span.is_segment); - const worker = segmentSpans.find(span => !span.parent_span_id); - const subWorker = segmentSpans.find(span => span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const subWorker = segmentSpans.find(span => segmentSpans.some(parent => parent.span_id === span.parent_span_id)); + const worker = segmentSpans.find(span => span !== subWorker); expect(worker?.name).toBe('GET /'); expect(worker?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -27,4 +30,5 @@ it('propagates trace from worker to worker via service binding', async ({ signal expect(subWorker?.trace_id).toBe(worker?.trace_id); expect(subWorker?.parent_span_id).toBe(worker?.span_id); + expect(worker?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts index 1630042291f6..fc883afbdbe5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is manually wrapped with @@ -49,12 +51,16 @@ it('leaves a manually wrapped Durable Object untouched while auto-wrapping a sib await runner.makeRequest('get', '/increment'); const durableObjectTraceSpans = await durableObjectSpansPromise; - const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = durableObjectTraceSpans.find( + span => span.is_segment && durableObjectTraceSpans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The storage pair comes from the manual wrap here. Exactly two of them also rules out a // double-wrap, which would nest proxies or break the build. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts index cf4768c16c01..600f7ec57f16 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A single worker exports a plain `WorkerEntrypoint`, a plain `DurableObject`, @@ -47,12 +49,16 @@ it('auto-instruments a WorkerEntrypoint and a Durable Object exported from the s await runner.makeRequest('get', '/increment'); const durableObjectTraceSpans = await durableObjectSpansPromise; - const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = durableObjectTraceSpans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = durableObjectTraceSpans.find( + span => span.is_segment && durableObjectTraceSpans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = durableObjectTraceSpans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts index e538a69912a9..d7f957a556a4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is already wrapped manually with @@ -20,8 +22,11 @@ it('leaves a manually wrapped Durable Object untouched and still wraps the defau await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -29,6 +34,7 @@ it('leaves a manually wrapped Durable Object untouched and still wraps the defau expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts index 972039a27bda..bfde29c35ddd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // One DO (`Manual`) is wrapped by hand, the other (`Auto`) is a plain inline @@ -21,8 +23,11 @@ it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', await runner.makeRequest('get', path); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); @@ -30,6 +35,9 @@ it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual( + supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + ); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap of `Manual`. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts index eaa634bf2073..74668c8d913b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // Two Durable Object classes are bound in wrangler. Hitting both must produce a @@ -19,8 +21,11 @@ it('auto-instruments multiple Durable Object classes in one entry', async ({ sig await runner.makeRequest('get', path); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['url.path']).toEqual({ type: 'string', value: path }); @@ -28,6 +33,9 @@ it('auto-instruments multiple Durable Object classes in one entry', async ({ sig expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual( + supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined, + ); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts index 63d164ff5c84..af64f11bb42c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // `Counter` is manually wrapped with `instrumentDurableObjectWithSentry` in a @@ -24,8 +26,11 @@ it('does not double-instrument an imported, already-wrapped Durable Object and s await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -33,6 +38,7 @@ it('does not double-instrument an imported, already-wrapped Durable Object and s expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts index a54d5dfa0308..665973271e70 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The wrangler binding references `Counter`, which is only an exported *alias* @@ -18,8 +20,11 @@ it('auto-instruments a Durable Object exported via an aliased specifier', async await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -27,6 +32,7 @@ it('auto-instruments a Durable Object exported via an aliased specifier', async expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts index 7b02c87d3df9..0a368a67707f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is exported through a specifier list (`export { Counter }`) @@ -18,8 +20,11 @@ it('auto-instruments a Durable Object exported via a specifier list', async ({ s await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -27,6 +32,7 @@ it('auto-instruments a Durable Object exported via a specifier list', async ({ s expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts index 4abc814568d3..47e4dd66613c 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // The Durable Object is wrapped by hand with `instrumentDurableObjectWithSentry` @@ -22,14 +24,18 @@ it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Wo await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. Exactly two of them also rules out a double-wrap. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts index ffefbcade6c3..a384dd2f683e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A Durable Object and a Workflow are both exported through a single specifier @@ -21,14 +23,18 @@ it('auto-instruments a Durable Object and a Workflow exported via a specifier li await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts index 9861748a30ce..347902151038 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A single entry exports both a Durable Object and a Workflow, neither wrapped by @@ -20,14 +22,18 @@ it('auto-instruments a Durable Object and a Workflow in the same entry', async ( await runner.makeRequest('get', '/increment'); const spans = await durableObjectSpansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); // The `auto.db.cloudflare.durable_object` storage pair (`get` + `put`) is the fingerprint of an // instrumented Durable Object. diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts index c7012293e240..e09064ca64a8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest'; +import { SHORT_UUID_MATCHER } from '../../../expect'; import { createRunner } from '../../../runner'; +import { supports } from '../../../runtime'; import { getSpanOp } from '../../../spanUtils'; // A Durable Object invoked via `fetch` gets its own `http.server` / @@ -26,8 +28,11 @@ it('auto-instruments the default handler and a Durable Object', async ({ signal await runner.makeRequest('get', '/increment'); const spans = await spansPromise; - const workerSpan = spans.find(span => span.is_segment && !span.parent_span_id); - const durableObjectSpan = spans.find(span => span.is_segment && span.parent_span_id); + // The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span. + const durableObjectSpan = spans.find( + span => span.is_segment && spans.some(parent => parent.span_id === span.parent_span_id), + ); + const workerSpan = spans.find(span => span.is_segment && span !== durableObjectSpan); expect(getSpanOp(workerSpan!)).toBe('http.server'); expect(workerSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); @@ -35,6 +40,7 @@ it('auto-instruments the default handler and a Durable Object', async ({ signal expect(getSpanOp(durableObjectSpan!)).toBe('http.server'); expect(durableObjectSpan?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' }); expect(durableObjectSpan?.parent_span_id).toBe(workerSpan?.span_id); + expect(workerSpan?.parent_span_id).toEqual(supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined); expect( spans diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 11a8fb9b419d..cafb160e896b 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -16,6 +16,7 @@ import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; @@ -38,6 +39,7 @@ function wrapEmailHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); return startSpan( { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts index b12f01474d6a..aea6ebb05d75 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentQueue.ts @@ -10,6 +10,7 @@ import { ensureInstrumented } from '../../instrument'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; @@ -32,6 +33,7 @@ function wrapQueueHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); return startSpan( { diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index 8285830b73c1..2cbe32129368 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -21,6 +21,7 @@ import type { CronTriggersIntegration } from '../../integrations/cronTriggers'; import { getFinalOptions } from '../../options'; import { addCloudResourceContext } from '../../scope-utils'; import { init } from '../../sdk'; +import { continueCloudflareTrace } from '../../utils/cloudflareTrace'; import { instrumentContext } from '../../utils/instrumentContext'; import { setInvocationState } from '../../utils/invocationContext'; import { instrumentEnv } from './instrumentEnv'; @@ -40,6 +41,7 @@ function wrapScheduledHandler( isolationScope.setClient(client); addCloudResourceContext(isolationScope); + continueCloudflareTrace(context); const description = `Scheduled Cron ${controller.cron}`; diff --git a/packages/cloudflare/src/utils/cloudflareTrace.ts b/packages/cloudflare/src/utils/cloudflareTrace.ts new file mode 100644 index 000000000000..1fbab71a9318 --- /dev/null +++ b/packages/cloudflare/src/utils/cloudflareTrace.ts @@ -0,0 +1,56 @@ +import { _INTERNAL_safeMathRandom, getCurrentScope } from '@sentry/core'; + +interface CloudflareSpanContext { + traceId: string; + spanId: string; +} + +// The part of `ctx.tracing` the SDK reads. `getActiveSpan` and `Span.spanContext` exist only in +// newer runtimes, so every member is optional. +interface CloudflareTracing { + getActiveSpan?: () => + | { + readonly isTraced?: boolean; + spanContext?: () => CloudflareSpanContext; + } + | undefined; +} + +const TRACE_ID_REGEXP = /^(?!0{32})[0-9a-f]{32}$/; +const SPAN_ID_REGEXP = /^(?!0{16})[0-9a-f]{16}$/; + +/** + * Sets the propagation context of the current scope to the Cloudflare trace of the invocation, so + * errors, logs and spans get the trace id of the spans that Cloudflare exports. + * + * Does nothing when the request `headers` have a `sentry-trace` header: an incoming Sentry trace + * keeps precedence, so traces from instrumented clients stay connected. Also does nothing when + * Cloudflare does not trace the invocation, or when the runtime does not expose + * `Span.spanContext()`. The sampling decision is left to the SDK. + */ +export function continueCloudflareTrace(context: unknown, headers?: Headers): void { + if (headers?.get('sentry-trace')) { + return; + } + + let spanContext: CloudflareSpanContext | undefined; + try { + const span = (context as { tracing?: CloudflareTracing } | undefined)?.tracing?.getActiveSpan?.(); + spanContext = span?.isTraced ? span.spanContext?.() : undefined; + } catch { + // Runtime getters on the execution context can throw on foreign or already-torn-down contexts. + return; + } + + if (!spanContext || !TRACE_ID_REGEXP.test(spanContext.traceId) || !SPAN_ID_REGEXP.test(spanContext.spanId)) { + return; + } + + // No `dsc` and no `sampled`: the SDK is the head of the Sentry trace, so it samples and builds + // the dynamic sampling context itself. + getCurrentScope().setPropagationContext({ + traceId: spanContext.traceId, + parentSpanId: spanContext.spanId, + sampleRand: _INTERNAL_safeMathRandom(), + }); +} diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index 71444c9a87e3..84924c5fa11f 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -27,6 +27,7 @@ import type { CloudflareClient, CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { addCloudResourceContext, addCultureContext, addRequest } from './scope-utils'; +import { continueCloudflareTrace } from './utils/cloudflareTrace'; import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext'; import { withInvocationIsolationScope } from './utils/invocationScope'; @@ -158,6 +159,8 @@ export function wrapRequestHandlerWithInit( return continueTrace( { sentryTrace: request.headers.get('sentry-trace') || '', baggage: request.headers.get('baggage') }, () => { + continueCloudflareTrace(context, request.headers); + // Note: This span will not have a duration unless I/O happens in the handler. This is // because of how the cloudflare workers runtime works. // See: https://developers.cloudflare.com/workers/runtime-apis/performance/ diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts index 2c15f5a39966..6ad0c13838d4 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -279,6 +279,35 @@ describe('instrumentEmail', () => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); + + test('makes the email span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + email(_message, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + await wrappedHandler.email?.(createMockEmailMessage(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + }); }); test('flush must be called when all waitUntil are done', async () => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts index afe51790adfd..4f6c40ac85cb 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentQueue.test.ts @@ -295,6 +295,35 @@ describe('instrumentQueue', () => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); + + test('makes the queue span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + queue(_batch, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + await wrappedHandler.queue?.(createMockQueueBatch(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + }); }); test('flush must be called when all waitUntil are done', async () => { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 843683be8925..709d8d2b218a 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -305,6 +305,35 @@ describe('instrumentScheduled', () => { test('keeps the descriptive span name when span streaming is disabled', async () => { expect(await spanNameFor('static')).toBe('Scheduled Cron 0 0 0 * * *'); }); + + test('makes the scheduled span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + const handler = { + scheduled(_controller, _env, _context) { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1 }), handler); + const context = { + ...createMockExecutionContext(), + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ + traceId: 'a1b2c3d4e5f60718293a4b5c6d7e8f90', + spanId: 'b1c2d3e4f5a60718', + traceFlags: 1, + }), + }), + }, + } as unknown as ExecutionContext; + + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, context); + + expect(rootSpan?.trace_id).toBe('a1b2c3d4e5f60718293a4b5c6d7e8f90'); + expect(rootSpan?.parent_span_id).toBe('b1c2d3e4f5a60718'); + }); }); describe('cron monitoring', () => { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a8c9a3f735f0..253b7cacef9e 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -648,6 +648,87 @@ describe('withSentry', () => { links: undefined, }); }); + + describe('with a Cloudflare trace', () => { + const CLOUDFLARE_TRACE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; + const CLOUDFLARE_SPAN_ID = 'b1c2d3e4f5a60718'; + const CLOUDFLARE_TRACING = { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }; + + test.each([0, undefined])('attaches errors to the Cloudflare trace with tracesSampleRate %s', async rate => { + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: MOCK_OPTIONS.dsn, + cacheClient: false, + tracesSampleRate: rate, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request: new Request('https://example.com'), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + }); + + test('makes the request span a child of the Cloudflare span', async () => { + let rootSpan: ReturnType | undefined; + await wrapRequestHandler( + { + options: { dsn: MOCK_OPTIONS.dsn, cacheClient: false, tracesSampleRate: 1 }, + request: new Request('https://example.com'), + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + rootSpan = SentryCore.spanToJSON(SentryCore.getRootSpan(SentryCore.getActiveSpan()!)); + return new Response('test'); + }, + ); + + expect(rootSpan?.trace_id).toBe(CLOUDFLARE_TRACE_ID); + expect(rootSpan?.parent_span_id).toBe(CLOUDFLARE_SPAN_ID); + }); + + test('prefers an incoming sentry-trace header', async () => { + const request = new Request('https://example.com'); + request.headers.set('sentry-trace', '12312012123120121231201212312012-1121201211212012'); + + let sentryEvent: Event = {}; + await wrapRequestHandler( + { + options: { + dsn: MOCK_OPTIONS.dsn, + cacheClient: false, + beforeSend(event) { + sentryEvent = event; + return null; + }, + }, + request, + context: { ...createMockExecutionContext(), tracing: CLOUDFLARE_TRACING } as unknown as ExecutionContext, + }, + () => { + SentryCore.captureException(new Error('test')); + return new Response('test'); + }, + ); + + expect(sentryEvent.contexts?.trace?.trace_id).toBe('12312012123120121231201212312012'); + }); + }); }); }); diff --git a/packages/cloudflare/test/utils/cloudflareTrace.test.ts b/packages/cloudflare/test/utils/cloudflareTrace.test.ts new file mode 100644 index 000000000000..639a8a79dc90 --- /dev/null +++ b/packages/cloudflare/test/utils/cloudflareTrace.test.ts @@ -0,0 +1,146 @@ +import { getCurrentScope, withScope } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { continueCloudflareTrace } from '../../src/utils/cloudflareTrace'; + +const CLOUDFLARE_TRACE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; +const CLOUDFLARE_SPAN_ID = 'b1c2d3e4f5a60718'; + +describe('continueCloudflareTrace()', () => { + it('continues the trace of a traced Cloudflare span', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + + withScope(() => { + continueCloudflareTrace(context); + + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: CLOUDFLARE_TRACE_ID, + parentSpanId: CLOUDFLARE_SPAN_ID, + sampleRand: expect.any(Number), + }); + }); + }); + + it('continues the trace when the sentry-trace header is empty', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + const headers = new Headers({ 'sentry-trace': '' }); + + withScope(() => { + continueCloudflareTrace(context, headers); + + expect(getCurrentScope().getPropagationContext()).toEqual({ + traceId: CLOUDFLARE_TRACE_ID, + parentSpanId: CLOUDFLARE_SPAN_ID, + sampleRand: expect.any(Number), + }); + }); + }); + + it('keeps the propagation context when the request has a sentry-trace header', () => { + const context = { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }; + const headers = new Headers({ 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1' }); + + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context, headers); + + expect(scope.getPropagationContext()).toBe(propagationContext); + }); + }); + + it.each([ + ['there is no execution context', undefined], + ['the runtime has no tracing API', {}], + ['the runtime has no getActiveSpan', { tracing: {} }], + ['there is no active span', { tracing: { getActiveSpan: () => undefined } }], + [ + 'the runtime has no spanContext', + { + tracing: { getActiveSpan: () => ({ isTraced: true }) }, + }, + ], + [ + 'Cloudflare does not trace the invocation', + { + tracing: { + getActiveSpan: () => ({ + isTraced: false, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: CLOUDFLARE_SPAN_ID, traceFlags: 0 }), + }), + }, + }, + ], + [ + 'the span context has null ids', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: '0'.repeat(32), spanId: '0'.repeat(16), traceFlags: 0 }), + }), + }, + }, + ], + [ + 'the span context has a malformed trace id', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: 'not-a-trace-id', spanId: CLOUDFLARE_SPAN_ID, traceFlags: 1 }), + }), + }, + }, + ], + [ + 'the span context has a malformed span id', + { + tracing: { + getActiveSpan: () => ({ + isTraced: true, + spanContext: () => ({ traceId: CLOUDFLARE_TRACE_ID, spanId: 'B1C2D3E4F5A60718', traceFlags: 1 }), + }), + }, + }, + ], + [ + 'reading the active span throws', + { + tracing: { + getActiveSpan: () => { + throw new Error('torn down'); + }, + }, + }, + ], + ])('keeps the propagation context when %s', (_, context) => { + withScope(scope => { + const propagationContext = scope.getPropagationContext(); + + continueCloudflareTrace(context); + + expect(scope.getPropagationContext()).toBe(propagationContext); + }); + }); +});