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
31 changes: 31 additions & 0 deletions dev-packages/cloudflare-integration-tests/runtime.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
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 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;
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,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');
});
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,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 }) => {
Expand Down Expand Up @@ -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 },
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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.
Expand All @@ -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);
Expand All @@ -43,13 +42,20 @@ 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');
expect(mainSpan?.attributes['sentry.origin']?.value).toBe('auto.http.cloudflare');
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,
);
},
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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' });
Expand All @@ -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);
});
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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`,
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading