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
2 changes: 2 additions & 0 deletions dev-packages/cloudflare-integration-tests/expect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Contexts, Envelope, Event, SdkInfo } from '@sentry/core';
import { SDK_VERSION } from '@sentry/core';
import { expect } from 'vitest';
import { supports } from './runtime';

export const UUID_MATCHER = expect.stringMatching(/^[\da-f]{32}$/);
export const UUID_V4_MATCHER = expect.stringMatching(
Expand Down Expand Up @@ -39,6 +40,7 @@ function defaultContexts(eventContexts: Contexts = {}): Contexts {
trace: {
trace_id: UUID_MATCHER,
span_id: SHORT_UUID_MATCHER,
parent_span_id: supports({ minWorkerd: '1.20261009.1' }) ? SHORT_UUID_MATCHER : undefined,
},
cloud_resource: { 'cloud.provider': 'cloudflare' },
culture: { timezone: expect.any(String) },
Expand Down
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,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,12 @@ 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,
},
},
breadcrumbs: [
{
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: env.TRACES_SAMPLE_RATE ? Number(env.TRACES_SAMPLE_RATE) : undefined,
}));
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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 when tracing is not set up',
async ({ signal }) => {
const errorEvents: Event[] = [];

const runner = createRunner(__dirname)
.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.runIf(supports({ minWorkerd: '1.20261009.1' }))(
'starts its own trace when tracing is set up',
async ({ signal }) => {
const errorEvents: Event[] = [];
const spanContainers: SerializedStreamedSpanContainer[] = [];

const runner = createRunner(__dirname)
.withWranglerArgs('--var', 'TRACES_SAMPLE_RATE:1')
.unordered()
.expect(envelope => {
expect(envelope[1][0][0].type).toBe('event');
errorEvents.push(envelope[1][0][1] as Event);
})
.expect(envelope => {
expect(envelope[1][0][0].type).toBe('span');
spanContainers.push(envelope[1][0][1] as SerializedStreamedSpanContainer);
})
.start(signal);

const cloudflareSpan = await runner.makeRequest<{ traceId: string; spanId: string } | null>('get', '/');
await runner.completed();

const segmentSpans = spanContainers.flatMap(container => container.items).filter(span => span.is_segment);
expect(cloudflareSpan).toEqual({ traceId: UUID_MATCHER, spanId: SHORT_UUID_MATCHER });
expect(errorEvents).toHaveLength(1);
expect(segmentSpans).toHaveLength(1);
expect(segmentSpans[0]?.trace_id).not.toBe(cloudflareSpan?.traceId);
expect(segmentSpans[0]?.parent_span_id).toBeUndefined();
expect(errorEvents[0]?.contexts?.trace?.trace_id).toBe(segmentSpans[0]?.trace_id);
},
);

it.skipIf(supports({ minWorkerd: '1.20261009.1' }))(
'starts its own trace on runtimes without `Span.spanContext()`',
async ({ signal }) => {
const errorEvents: Event[] = [];

const runner = createRunner(__dirname)
.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).toBeNull();
expect(errorEvents).toHaveLength(1);
expect(errorEvents[0]?.contexts?.trace).toEqual({ trace_id: UUID_MATCHER, span_id: SHORT_UUID_MATCHER });
},
);

it('continues an incoming Sentry trace instead of the Cloudflare trace', async ({ signal }) => {
const errorEvents: Event[] = [];

const runner = createRunner(__dirname)
.expect(envelope => {
expect(envelope[1][0][0].type).toBe('event');
errorEvents.push(envelope[1][0][1] as Event);
})
.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": "",
},
}
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,12 @@ 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,
},
},
},
{ includeSamplingFields: true, includeSampleRand: true, includeTransaction: false },
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +39,7 @@ function wrapEmailHandler(
isolationScope.setClient(client);

addCloudResourceContext(isolationScope);
continueCloudflareTrace(context);

return startSpan(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ensureInstrumented } from '../../instrument';
import { getFinalOptions } from '../../options';
import { addCloudResourceContext } from '../../scope-utils';
import { init } from '../../sdk';
import { continueCloudflareTrace } from '../../utils/cloudflareTrace';
import { instrumentContext } from '../../utils/instrumentContext';
import { setInvocationState } from '../../utils/invocationContext';
import { extractQueueTraceContext } from '../../utils/queueTraceMeta';
Expand Down Expand Up @@ -79,6 +80,7 @@ function wrapQueueHandler(
isolationScope.setClient(client);

addCloudResourceContext(isolationScope);
continueCloudflareTrace(context);

return startSpan(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -40,6 +41,7 @@ function wrapScheduledHandler(
isolationScope.setClient(client);

addCloudResourceContext(isolationScope);
continueCloudflareTrace(context);

const description = `Scheduled Cron ${controller.cron}`;

Expand Down
58 changes: 58 additions & 0 deletions packages/cloudflare/src/utils/cloudflareTrace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { _INTERNAL_safeMathRandom, getCurrentScope, hasSpansEnabled, isContinuingTrace } 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.
*
* Only runs when Sentry tracing is not set up (no `tracesSampleRate` or `tracesSampler`), so a trace
* has one source: either the SDK or Cloudflare, never a mix of both.
*
* Does nothing when the current scope already continues a trace, for example an incoming Sentry
* trace, when Cloudflare does not trace the invocation, or when the runtime does not expose
* `Span.spanContext()`.
*/
export function continueCloudflareTrace(context: unknown): void {
if (hasSpansEnabled() || isContinuingTrace(getCurrentScope().getPropagationContext())) {
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 builds the dynamic
// sampling context itself.
getCurrentScope().setPropagationContext({
traceId: spanContext.traceId,
parentSpanId: spanContext.spanId,
sampleRand: _INTERNAL_safeMathRandom(),
});
}
11 changes: 7 additions & 4 deletions packages/cloudflare/src/wrapMethodWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ExecutionContextCompat } from './executionContext';
import { flushAndDispose, getOriginalWaitUntil } from './flush';
import { ensureInstrumented } from './instrument';
import { init } from './sdk';
import { continueCloudflareTrace } from './utils/cloudflareTrace';
import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext';
import { withInvocationIsolationScope } from './utils/invocationScope';
import { extractRpcMeta } from './utils/rpcMeta';
Expand Down Expand Up @@ -183,6 +184,8 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
}
};

continueCloudflareTrace(context);

if (!spanName) {
try {
if (callback) {
Expand Down Expand Up @@ -272,10 +275,10 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(

if (rpcMeta) {
return runWithTeardown(() =>
continueTrace(
{ sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' },
executeSpan,
),
continueTrace({ sentryTrace: rpcMeta['sentry-trace'] || '', baggage: rpcMeta.baggage || '' }, () => {
continueCloudflareTrace(context);
return executeSpan();
}),
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/cloudflare/src/wrapRequestHandlerWithInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { CloudflareClient, CloudflareOptions } from './client';
import type { ExecutionContextCompat } from './executionContext';
import { flushAndDispose, getOriginalWaitUntil } from './flush';
import { addCloudResourceContext, addCultureContext } from './scope-utils';
import { continueCloudflareTrace } from './utils/cloudflareTrace';
import { getInvocationState, getInvocationWaitUntil } from './utils/invocationContext';
import { withInvocationIsolationScope } from './utils/invocationScope';

Expand Down Expand Up @@ -128,6 +129,7 @@ export function wrapRequestHandlerWithInit(
origin: 'auto.http.cloudflare',
mechanism: captureErrors ? { handled: false, type: getRequestErrorMechanismType(context) } : undefined,
attributes,
onTraceContinued: () => continueCloudflareTrace(context),
isStreamingResponse: response => {
const classification = safeCallback(
DEBUG_BUILD ? 'Error in `isStreamingResponse`, using the default classification:' : '',
Expand Down
Loading
Loading