Skip to content
Open
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
19 changes: 15 additions & 4 deletions dev-packages/cloudflare-integration-tests/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,17 @@ export function expectedEvent(event: Event, { sdk }: { sdk: 'cloudflare' | 'hono

export function eventEnvelope(
event: Event,
{ includeSampleRand = false, sdk = 'cloudflare' }: { includeSampleRand?: boolean; sdk?: 'cloudflare' | 'hono' } = {},
{
includeSamplingFields = false,
includeSampleRand = false,
includeTransaction = true,
sdk = 'cloudflare',
}: {
includeSamplingFields?: boolean;
includeSampleRand?: boolean;
includeTransaction?: boolean;
sdk?: 'cloudflare' | 'hono';
} = {},
): Envelope {
return [
{
Expand All @@ -72,10 +82,11 @@ export function eventEnvelope(
environment: event.environment || 'production',
public_key: 'public',
trace_id: UUID_MATCHER,
sample_rate: expect.any(String),
...(includeSamplingFields && { sample_rate: expect.any(String), sampled: expect.any(String) }),
...(includeSampleRand && { sample_rand: expect.stringMatching(/^[01](\.\d+)?$/) }),
sampled: expect.any(String),
transaction: expect.any(String),
// In TwP mode the span name is omitted from the DSC when the span source is `url`
// (raw URLs may contain PII), mirroring `getDynamicSamplingContextFromSpan`.
...(includeTransaction && { transaction: expect.any(String) }),
},
},
[[{ type: 'event' }, expectedEvent(event, { sdk })]],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,30 @@ import { createRunner } from '../../runner';
it('Only sends one error event when withSentry is called twice', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(
eventEnvelope({
level: 'error',
exception: {
values: [
{
type: 'Error',
value: 'Test error from double-instrumented worker',
stacktrace: {
frames: expect.any(Array),
eventEnvelope(
{
level: 'error',
exception: {
values: [
{
type: 'Error',
value: 'Test error from double-instrumented worker',
stacktrace: {
frames: expect.any(Array),
},
mechanism: { type: 'auto.http.cloudflare', handled: false },
},
mechanism: { type: 'auto.http.cloudflare', handled: false },
},
],
],
},
request: {
headers: expect.any(Object),
method: 'GET',
url: expect.any(String),
},
},
request: {
headers: expect.any(Object),
method: 'GET',
url: expect.any(String),
},
}),
// `/error` resolves to a raw URL span (source `url`), so the TwP DSC omits the span name.
{ includeTransaction: false },
),
)
// The http.server span produces a transaction envelope that is sent in parallel with the
// error event. Either can arrive first at the mock server, so ignore it here to keep the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ it('Hono app captures errors', async ({ signal }) => {
url: expect.any(String),
},
},
{ includeSampleRand: true },
{ includeSamplingFields: true, includeSampleRand: true },
),
)
// Second envelope: transaction event
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ it('Hono app captures parametrized errors (Hono SDK)', async ({ signal }) => {
},
],
},
{ includeSampleRand: true, sdk: 'hono' },
{ includeSamplingFields: true, includeSampleRand: true, sdk: 'hono' },
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ import { createRunner } from '../../../runner';
it('Captures JSON request body', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(
eventEnvelope({
level: 'info',
message: 'POST JSON request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-json'),
data: '{"username":"test","action":"login"}',
eventEnvelope(
{
level: 'info',
message: 'POST JSON request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-json'),
data: '{"username":"test","action":"login"}',
},
},
}),
// Raw URL span (source `url`), so the TwP DSC omits the span name.
{ includeTransaction: false },
),
)
.start(signal);

Expand All @@ -29,16 +33,20 @@ it('Captures JSON request body', async ({ signal }) => {
it('Captures form-urlencoded request body', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(
eventEnvelope({
level: 'info',
message: 'POST form request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-form'),
data: 'username=test&password=secret',
eventEnvelope(
{
level: 'info',
message: 'POST form request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-form'),
data: 'username=test&password=secret',
},
},
}),
// Raw URL span (source `url`), so the TwP DSC omits the span name.
{ includeTransaction: false },
),
)
.start(signal);

Expand All @@ -53,16 +61,20 @@ it('Captures form-urlencoded request body', async ({ signal }) => {
it('Captures plain text request body', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(
eventEnvelope({
level: 'info',
message: 'POST text request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-text'),
data: 'This is plain text content',
eventEnvelope(
{
level: 'info',
message: 'POST text request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-text'),
data: 'This is plain text content',
},
},
}),
// Raw URL span (source `url`), so the TwP DSC omits the span name.
{ includeTransaction: false },
),
)
.start(signal);

Expand All @@ -77,15 +89,19 @@ it('Captures plain text request body', async ({ signal }) => {
it('Does not capture body for POST without content', async ({ signal }) => {
const runner = createRunner(__dirname)
.expect(
eventEnvelope({
level: 'info',
message: 'POST no body request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-no-body'),
eventEnvelope(
{
level: 'info',
message: 'POST no body request',
request: {
headers: expect.any(Object),
method: 'POST',
url: expect.stringContaining('/post-no-body'),
},
},
}),
// Raw URL span (source `url`), so the TwP DSC omits the span name.
{ includeTransaction: false },
),
)
.start(signal);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as Sentry from '@sentry/cloudflare';

interface Env {
SENTRY_DSN: string;
}

// Tracing is enabled (not TwP), but the route is a raw, non-parametrized URL so the
// http.server span source is `url`. The span name must therefore be omitted from the
// DSC (raw URLs may contain PII), even though a real transaction is recorded.
export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}),
{
async fetch(_request, _env, _ctx) {
throw new Error('Test error from URL-source worker');
},
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, it } from 'vitest';
import { eventEnvelope } from '../../../expect';
import { createRunner } from '../../../runner';

it('omits the span name from the DSC for url-source spans when tracing is enabled', async ({ signal }) => {
const runner = createRunner(__dirname)
// Error event: because tracing is enabled, the DSC carries the sampling fields. But the span
// source is `url`, so the span name is omitted from the DSC (raw URLs may contain PII).
.expect(
eventEnvelope(
{
level: 'error',
exception: {
values: [
{
type: 'Error',
value: 'Test error from URL-source worker',
stacktrace: {
frames: expect.any(Array),
},
mechanism: { type: 'auto.http.cloudflare', handled: false },
},
],
},
request: {
headers: expect.any(Object),
method: 'GET',
url: expect.any(String),
},
},
{ includeSamplingFields: true, includeSampleRand: true, includeTransaction: false },
),
)
// Transaction event: proves we are NOT in TwP — the span is recorded with a `url` source and
// carries the name on the event itself, even though it is intentionally absent from the DSC.
.expect(envelope => {
const transactionEvent = envelope[1]?.[0]?.[1];
expect(transactionEvent).toEqual(
expect.objectContaining({
type: 'transaction',
transaction: 'GET /error',
contexts: expect.objectContaining({
trace: expect.objectContaining({
op: 'http.server',
data: expect.objectContaining({ 'sentry.source': 'url' }),
}),
}),
}),
);
})
.unordered()
.start(signal);
await runner.makeRequest('get', '/error', { expectError: true });
await runner.completed();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "worker-name",
"compatibility_date": "2025-06-17",
"main": "index.ts",
"compatibility_flags": ["nodejs_compat"],
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ it('Tracing headers', async ({ signal }) => {
const [SERVER_URL, closeTestServer] = await createTestServer()
.get('/', headers => {
expect(headers['baggage']).toEqual(expect.any(String));
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/));
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0');
expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/));
expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000');
expect(headers['traceparent']).toEqual(expect.stringMatching(/^00-([a-f\d]{32})-([a-f\d]{16})-00$/));
})
.start();
Expand Down
34 changes: 32 additions & 2 deletions packages/core/src/tracing/dynamicSamplingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '../semanticAttributes';
import type { DynamicSamplingContext } from '../types/envelope';
import type { Span } from '../types/span';
import type { Span, SpanAttributes } from '../types/span';
import { baggageHeaderToDynamicSamplingContext, dynamicSamplingContextToSentryBaggageHeader } from '../utils/baggage';
import { extractOrgIdFromClient } from '../utils/dsn';
import { hasSpansEnabled } from '../utils/hasSpansEnabled';
import { addNonEnumerableProperty } from '../utils/object';
import { addNonEnumerableProperty, dropUndefinedKeys } from '../utils/object';
import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils';
import { getCapturedScopesOnSpan } from './utils';

Expand All @@ -34,6 +34,36 @@ export function freezeDscOnSpan(span: Span, dsc: Partial<DynamicSamplingContext>
addNonEnumerableProperty(spanWithMaybeDsc, FROZEN_DSC_FIELD, dsc);
}

/**
* Freeze the DSC on a Tracing-without-Performance root placeholder span.
*
* A continued trace's DSC (`incomingDsc`) is frozen and must win as-is, even when it's an
* empty `{}` (a `sentry-trace` header without baggage): we are not head of trace, so we
* neither fabricate client fields nor inject the local span name. Only when starting a new
* trace do we derive the DSC from the client and attach the local span name.
*
* As in `getDynamicSamplingContextFromSpan`, the span name is skipped when its source is
* "url" because URLs might contain PII.
*/
export function freezeDscOnTwpRootSpan(
span: Span,
{
name,
attributes,
incomingDsc,
}: { name?: string; attributes?: SpanAttributes; incomingDsc?: Partial<DynamicSamplingContext> },
): void {
// TODO(v11): Only read `SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` again, once we renamed it to `sentry.span.source`
const source = attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] ?? attributes?.['sentry.span.source'];
const dsc =
incomingDsc ??
dropUndefinedKeys({
...getDynamicSamplingContextFromSpan(span),
transaction: source === 'url' ? undefined : name,
});
freezeDscOnSpan(span, dsc);
}

/**
* Creates a dynamic sampling context from a client.
*
Expand Down
Loading
Loading