Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, it } from 'vitest';
import { eventEnvelope } from '../../expect';
import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../expect';
import { createRunner } from '../../runner';
import { getSpanOp, getSpansFromEnvelope } from '../../spanUtils';

Expand Down Expand Up @@ -28,6 +28,11 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => {
method: 'GET',
url: expect.stringContaining('/error/param-123'),
},
contexts: {
// On runtimes with `Span.spanContext()`, the span continues the Cloudflare trace, so the trace context also
// has the Cloudflare span as `parent_span_id`.
trace: expect.objectContaining({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }),
},
breadcrumbs: [
{
timestamp: expect.any(Number),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,11 @@ it('sends a streamed span envelope with correct spans for a manually started spa
status: 'ok',
});

expect(segmentSpan).toEqual({
// On runtimes with `Span.spanContext()`, the segment continues the Cloudflare trace, so the Cloudflare span is its parent.
const { parent_span_id: segmentParentSpanId, ...segmentSpanWithoutParent } = segmentSpan!;
expect(segmentParentSpanId).toBeOneOf([undefined, expect.stringMatching(/^[\da-f]{16}$/)]);

expect(segmentSpanWithoutParent).toEqual({
attributes: {
'sentry.is_localhost': { value: true, type: 'boolean' },
[SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' },
Expand Down
Original file line number Diff line number Diff line change
@@ -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 cloudflareSpan = span?.isTraced ? (span.spanContext?.() ?? null) : null;

Sentry.captureException(new Error('Error in a Cloudflare trace'));

return Response.json(cloudflareSpan);
},
} satisfies ExportedHandler;
Original file line number Diff line number Diff line change
@@ -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),
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Event, SerializedStreamedSpanContainer } from '@sentry/core';
import { createRequire } from 'module';
import { expect, it } from 'vitest';
import { SHORT_UUID_MATCHER } from '../../../expect';
import { createRunner } from '../../../runner';

// `wrangler dev` runs the workerd of its own miniflare. `Span.spanContext()` ships in workerd 1.20261009.1, so the
// pinned wrangler runs these tests without it and the latest wrangler runs them with it.
const wranglerRequire = createRequire(createRequire(__filename).resolve('wrangler/package.json'));
const miniflareRequire = createRequire(wranglerRequire.resolve('miniflare/package.json'));
const [, workerdDate = 0, workerdPatch = 0] = (miniflareRequire('workerd/package.json') as { version: string }).version
.split('.')
.map(Number);
const HAS_SPAN_CONTEXT = workerdDate > 20261009 || (workerdDate === 20261009 && workerdPatch >= 1);

const VALID_TRACE_ID = expect.stringMatching(/^(?!0{32})[\da-f]{32}$/);
// Without `Span.spanContext()`, the Worker responds with `null` and the SDK starts its own trace. On a runtime with it,
// a `null` response fails the test, so the `VALID_TRACE_ID` fallbacks below apply only to older runtimes.
const EXPECTED_CLOUDFLARE_SPAN = HAS_SPAN_CONTEXT
? expect.objectContaining({ traceId: VALID_TRACE_ID, spanId: SHORT_UUID_MATCHER })
: null;

it('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(EXPECTED_CLOUDFLARE_SPAN);
expect(errorEvents).toHaveLength(1);
expect(segmentSpans).toHaveLength(1);
expect(segmentSpans[0]?.trace_id).toEqual(cloudflareSpan?.traceId ?? VALID_TRACE_ID);
expect(segmentSpans[0]?.parent_span_id).toBe(cloudflareSpan?.spanId);
expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id);
});

it('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(EXPECTED_CLOUDFLARE_SPAN);
expect(errorEvents).toHaveLength(1);
expect(errorEvents[0]?.contexts?.trace).toEqual({
trace_id: cloudflareSpan?.traceId ?? VALID_TRACE_ID,
span_id: SHORT_UUID_MATCHER,
parent_span_id: cloudflareSpan?.spanId,
});
});

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');
});
Original file line number Diff line number Diff line change
@@ -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()],
});
Original file line number Diff line number Diff line change
@@ -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",
},
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, it } from 'vitest';
import { eventEnvelope } from '../../../expect';
import { eventEnvelope, SHORT_UUID_MATCHER, UUID_MATCHER } from '../../../expect';
import { createRunner } from '../../../runner';
import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils';

Expand Down Expand Up @@ -29,6 +29,11 @@ it('omits the span name from the DSC for url-source spans when tracing is enable
method: 'GET',
url: expect.any(String),
},
contexts: {
// On runtimes with `Span.spanContext()`, the span continues the Cloudflare trace, so the trace context also
// has the Cloudflare span as `parent_span_id`.
trace: expect.objectContaining({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER }),
},
},
{ includeSamplingFields: true, includeSampleRand: true, includeTransaction: false },
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,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.
Expand All @@ -25,15 +24,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);
Expand All @@ -43,6 +40,10 @@ function startStubFetchScenario(signal: AbortSignal) {
async completedWithTraceCheck(): Promise<void> {
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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ 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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ 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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ 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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ 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' });
Expand Down
Loading
Loading