Skip to content

Commit 0ac234a

Browse files
JPeer264claude
andcommitted
feat(cloudflare): Continue the Cloudflare trace of the invocation
The SDK now reads `ctx.tracing.getActiveSpan().spanContext()` and sets the propagation context to the Cloudflare trace id and span id. Errors, logs and spans then use the same trace id as the spans that Cloudflare exports, also when `tracesSampleRate` is 0. An incoming `sentry-trace` header keeps precedence. Runtimes without `spanContext()` and invocations that Cloudflare does not trace keep the current behavior. `spanContext()` ships in workerd 1.20261009.1, so only the optional CI job with the latest wrangler runs the new code path. The tests pass on both wranglers. 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 <noreply@anthropic.com>
1 parent 8042719 commit 0ac234a

32 files changed

Lines changed: 555 additions & 35 deletions

File tree

‎dev-packages/cloudflare-integration-tests/suites/hono-sdk/test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, it } from 'vitest';
2-
import { eventEnvelope } from '../../expect';
2+
import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect';
33
import { createRunner } from '../../runner';
44
import { getSpanOp, getSpansFromEnvelope } from '../../spanUtils';
55

@@ -28,6 +28,11 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => {
2828
method: 'GET',
2929
url: expect.stringContaining('/error/param-123'),
3030
},
31+
contexts: {
32+
// On runtimes with `Span.spanContext()`, the span continues the Cloudflare trace, so the trace context also
33+
// has the Cloudflare span as `parent_span_id`.
34+
trace: expect.objectContaining({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }),
35+
},
3136
breadcrumbs: [
3237
{
3338
timestamp: expect.any(Number),

‎dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,11 @@ it('sends a streamed span envelope with correct spans for a manually started spa
183183
status: 'ok',
184184
});
185185

186-
expect(segmentSpan).toEqual({
186+
// On runtimes with `Span.spanContext()`, the segment continues the Cloudflare trace, so the Cloudflare span is its parent.
187+
const { parent_span_id: segmentParentSpanId, ...segmentSpanWithoutParent } = segmentSpan!;
188+
expect(segmentParentSpanId).toBeOneOf([undefined, expect.stringMatching(/^[\da-f]{16}$/)]);
189+
190+
expect(segmentSpanWithoutParent).toEqual({
187191
attributes: {
188192
'sentry.is_localhost': { value: true, type: 'boolean' },
189193
[SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' },
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
// `getActiveSpan` and `Span.spanContext` are newer than the installed workers types, and older runtimes do not have
4+
// them.
5+
interface CloudflareTracing {
6+
getActiveSpan?(): { isTraced?: boolean; spanContext?(): { traceId: string; spanId: string } } | undefined;
7+
}
8+
9+
export default {
10+
async fetch(_request, _env, ctx) {
11+
const span = (ctx.tracing as unknown as CloudflareTracing | undefined)?.getActiveSpan?.();
12+
const cloudflareSpan = span?.isTraced ? (span.spanContext?.() ?? null) : null;
13+
14+
Sentry.captureException(new Error('Error in a Cloudflare trace'));
15+
16+
return Response.json(cloudflareSpan);
17+
},
18+
} satisfies ExportedHandler;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string; TRACES_SAMPLE_RATE: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: Number(env.TRACES_SAMPLE_RATE),
6+
}));
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Event, SerializedStreamedSpanContainer } from '@sentry/core';
2+
import { createRequire } from 'module';
3+
import { expect, it } from 'vitest';
4+
import { SHORT_UUID_MATCHER } from '../../../expect';
5+
import { createRunner } from '../../../runner';
6+
7+
// `wrangler dev` runs the workerd of its own miniflare. `Span.spanContext()` ships in workerd 1.20261009.1, so the
8+
// pinned wrangler runs these tests without it and the latest wrangler runs them with it.
9+
const wranglerRequire = createRequire(createRequire(__filename).resolve('wrangler/package.json'));
10+
const miniflareRequire = createRequire(wranglerRequire.resolve('miniflare/package.json'));
11+
const [, workerdDate = 0, workerdPatch = 0] = (miniflareRequire('workerd/package.json') as { version: string }).version
12+
.split('.')
13+
.map(Number);
14+
const HAS_SPAN_CONTEXT = workerdDate > 20261009 || (workerdDate === 20261009 && workerdPatch >= 1);
15+
16+
const VALID_TRACE_ID = expect.stringMatching(/^(?!0{32})[\da-f]{32}$/);
17+
// Without `Span.spanContext()`, the Worker responds with `null` and the SDK starts its own trace. On a runtime with it,
18+
// a `null` response fails the test, so the `VALID_TRACE_ID` fallbacks below apply only to older runtimes.
19+
const EXPECTED_CLOUDFLARE_SPAN = HAS_SPAN_CONTEXT
20+
? expect.objectContaining({ traceId: VALID_TRACE_ID, spanId: SHORT_UUID_MATCHER })
21+
: null;
22+
23+
it('uses the Cloudflare trace for the error event and the segment span', async ({ signal }) => {
24+
const errorEvents: Event[] = [];
25+
const spanContainers: SerializedStreamedSpanContainer[] = [];
26+
27+
const runner = createRunner(__dirname)
28+
.unordered()
29+
.expect(envelope => {
30+
expect(envelope[1][0][0].type).toBe('event');
31+
errorEvents.push(envelope[1][0][1] as Event);
32+
})
33+
.expect(envelope => {
34+
expect(envelope[1][0][0].type).toBe('span');
35+
spanContainers.push(envelope[1][0][1] as SerializedStreamedSpanContainer);
36+
})
37+
.start(signal);
38+
39+
const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/');
40+
await runner.completed();
41+
42+
const segmentSpans = spanContainers.flatMap(container => container.items).filter(span => span.is_segment);
43+
expect(cloudflareSpan).toEqual(EXPECTED_CLOUDFLARE_SPAN);
44+
expect(errorEvents).toHaveLength(1);
45+
expect(segmentSpans).toHaveLength(1);
46+
expect(segmentSpans[0]?.trace_id).toEqual(cloudflareSpan?.traceId ?? VALID_TRACE_ID);
47+
expect(segmentSpans[0]?.parent_span_id).toBe(cloudflareSpan?.spanId);
48+
expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id);
49+
});
50+
51+
it('uses the Cloudflare trace for the error event when no span is sampled', async ({ signal }) => {
52+
const errorEvents: Event[] = [];
53+
54+
const runner = createRunner(__dirname)
55+
.withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:0')
56+
.expect(envelope => {
57+
expect(envelope[1][0][0].type).toBe('event');
58+
errorEvents.push(envelope[1][0][1] as Event);
59+
})
60+
.start(signal);
61+
62+
const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/');
63+
await runner.completed();
64+
65+
expect(cloudflareSpan).toEqual(EXPECTED_CLOUDFLARE_SPAN);
66+
expect(errorEvents).toHaveLength(1);
67+
expect(errorEvents[0]?.contexts?.trace).toEqual({
68+
trace_id: cloudflareSpan?.traceId ?? VALID_TRACE_ID,
69+
span_id: SHORT_UUID_MATCHER,
70+
parent_span_id: cloudflareSpan?.spanId,
71+
});
72+
});
73+
74+
it('continues an incoming Sentry trace instead of the Cloudflare trace', async ({ signal }) => {
75+
const errorEvents: Event[] = [];
76+
77+
const runner = createRunner(__dirname)
78+
.unordered()
79+
.expect(envelope => {
80+
expect(envelope[1][0][0].type).toBe('event');
81+
errorEvents.push(envelope[1][0][1] as Event);
82+
})
83+
.expect(envelope => {
84+
expect(envelope[1][0][0].type).toBe('span');
85+
})
86+
.start(signal);
87+
88+
await runner.makeRequest('get', '/', {
89+
headers: { 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1' },
90+
});
91+
await runner.completed();
92+
93+
expect(errorEvents).toHaveLength(1);
94+
expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe('12345678901234567890123456789012');
95+
expect(errorEvents[0]?.contexts?.trace?.parent_span_id).toBe('1234567890123456');
96+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
7+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "cloudflare-trace-worker",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
"vars": {
7+
"TRACES_SAMPLE_RATE": "1",
8+
},
9+
}

‎dev-packages/cloudflare-integration-tests/suites/tracing/dsc-url-source/test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, it } from 'vitest';
2-
import { eventEnvelope } from '../../../expect';
2+
import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect';
33
import { createRunner } from '../../../runner';
44
import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';
55

@@ -29,6 +29,11 @@ it('omits the span name from the DSC for url-source spans when tracing is enable
2929
method: 'GET',
3030
url: expect.any(String),
3131
},
32+
contexts: {
33+
// On runtimes with `Span.spanContext()`, the span continues the Cloudflare trace, so the trace context also
34+
// has the Cloudflare span as `parent_span_id`.
35+
trace: expect.objectContaining({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }),
36+
},
3237
},
3338
{ includeSamplingFields: true, includeSampleRand: true, includeTransaction: false },
3439
),

‎dev-packages/cloudflare-integration-tests/suites/tracing/instrument-fetcher/test.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ type EchoedHeaders = {
1515
const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/;
1616

1717
function startStubFetchScenario(signal: AbortSignal) {
18-
let mainSpan: SerializedStreamedSpan | undefined;
19-
let doSpan: SerializedStreamedSpan | undefined;
18+
const segmentSpans: SerializedStreamedSpan[] = [];
2019

2120
// The worker and the Durable Object stream their spans from separate isolates, so each one
2221
// arrives in its own envelope.
@@ -25,15 +24,13 @@ function startStubFetchScenario(signal: AbortSignal) {
2524
const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
2625

2726
expect(getSpanOp(segmentSpan!)).toBe('http.server');
28-
expect(segmentSpan?.parent_span_id).toBeUndefined();
29-
mainSpan = segmentSpan;
27+
segmentSpans.push(segmentSpan!);
3028
})
3129
.expect(envelope => {
3230
const segmentSpan = getSpansFromEnvelope(envelope).find(span => span.is_segment);
3331

3432
expect(getSpanOp(segmentSpan!)).toBe('http.server');
35-
expect(segmentSpan?.parent_span_id).toBeDefined();
36-
doSpan = segmentSpan;
33+
segmentSpans.push(segmentSpan!);
3734
})
3835
.unordered()
3936
.start(signal);
@@ -43,6 +40,10 @@ function startStubFetchScenario(signal: AbortSignal) {
4340
async completedWithTraceCheck(): Promise<void> {
4441
await completed();
4542

43+
// The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span.
44+
const doSpan = segmentSpans.find(span => segmentSpans.some(parent => parent.span_id === span.parent_span_id));
45+
const mainSpan = segmentSpans.find(span => span !== doSpan);
46+
4647
// Both routes are raw URLs, so the streamed segment name keeps the method only.
4748
expect(mainSpan?.name).toBe('GET');
4849
expect(doSpan?.name).toBe('GET');

‎dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-service-binding/test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ it('propagates trace from worker to worker via service binding', async ({ signal
1414
await runner.makeRequest('get', '/');
1515

1616
const segmentSpans = (await spansPromise).filter(span => span.is_segment);
17-
const worker = segmentSpans.find(span => !span.parent_span_id);
18-
const subWorker = segmentSpans.find(span => span.parent_span_id);
17+
// The worker segment has no parent in this trace. On runtimes with `Span.spanContext()`, its parent is the Cloudflare span.
18+
const subWorker = segmentSpans.find(span => segmentSpans.some(parent => parent.span_id === span.parent_span_id));
19+
const worker = segmentSpans.find(span => span !== subWorker);
1920

2021
expect(worker?.name).toBe('GET /');
2122
expect(worker?.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.http.cloudflare' });

0 commit comments

Comments
 (0)