Skip to content

Commit 79a412b

Browse files
authored
feat(cloudflare): Emit low cardinality function span names and preserve span descriptions (#24246)
This PR makes a few adjustments to cloudflare `sentry.op: "function"` spans: - Email hander: This span included an email address in the span name/description, which I'd argue is sub-optimal, given neither name nor description get auto-scrubbed. I therefore opted to just call it `name` for both, streaming and transaction mode. - Scheduled/Cron handler: This span had a string and the cron string in its description. For span streaming, we now just call it `scheduled` (like the handled function name). Because we can't map the static string via span inference for function spans in Relay, I added the old name as a `sentry.description` override to avoid span description inference for streamed spans. For transactions, nothing changes - `wrapMethodWithSentry` - wrapped spans: Any spans that were previously of op `function` are already named after their function name (🎉). Therefore we only need to add the `code.function.name` attribute for description inference and we're good. Adjusted tests and added note in migration guide. ref #23954
1 parent b458319 commit 79a412b

10 files changed

Lines changed: 184 additions & 17 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
export default Sentry.withSentry(
8+
(env: Env) => ({
9+
dsn: env.SENTRY_DSN,
10+
traceLifecycle: 'stream',
11+
tracesSampleRate: 1.0,
12+
}),
13+
{
14+
async fetch(_request, _env, _ctx) {
15+
return new Response('OK');
16+
},
17+
async scheduled(_controller, _env, _ctx) {
18+
await new Promise(resolve => setTimeout(resolve, 10));
19+
},
20+
} satisfies ExportedHandler<Env>,
21+
);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
3+
import { expect, it } from 'vitest';
4+
import { createRunner } from '../../../runner';
5+
6+
function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer {
7+
const spanItem = envelope[1].find(item => item[0].type === 'span');
8+
expect(spanItem).toBeDefined();
9+
return spanItem![1] as SerializedStreamedSpanContainer;
10+
}
11+
12+
it('keeps the cron out of the scheduled span name when span streaming is enabled', async ({ signal }) => {
13+
const runner = createRunner(__dirname)
14+
.withWranglerArgs('--test-scheduled')
15+
.expect(envelope => {
16+
const segmentSpan = getSpanContainer(envelope).items.find(span => !!span.is_segment);
17+
18+
expect(segmentSpan).toBeDefined();
19+
expect(segmentSpan!.name).toBe('scheduled');
20+
expect(segmentSpan!.attributes).toEqual(
21+
expect.objectContaining({
22+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' },
23+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.scheduled' },
24+
'code.function.name': { type: 'string', value: 'scheduled' },
25+
// Relay infers the description from the span name, so the original, cron-bearing name is
26+
// preserved explicitly to keep it visible in the UI.
27+
'sentry.description': { type: 'string', value: expect.stringMatching(/^Scheduled Cron/) },
28+
'faas.trigger': { type: 'string', value: 'timer' },
29+
}),
30+
);
31+
})
32+
.start(signal);
33+
34+
await runner.makeRequest('get', '/__scheduled');
35+
await runner.completed();
36+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "scheduled-streamed-worker",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
"triggers": {
7+
"crons": ["* * * * *"],
8+
},
9+
}

dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ it('Scheduled handler creates transaction with correct attributes', async ({ sig
3131
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled',
3232
[SENTRY_SEGMENT_NAME_SOURCE]: 'task',
3333
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
34+
'sentry.description': expect.stringMatching(/^Scheduled Cron/),
35+
'code.function.name': 'scheduled',
3436
'faas.cron': expect.any(String),
3537
'faas.time': expect.any(String),
3638
'faas.trigger': 'timer',

packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import type { EmailMessage } from '@cloudflare/workers-types';
22
import type { AnyExportedHandler } from '../../types';
33
import type { env as cloudflareEnv } from 'cloudflare:workers';
4-
import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP } from '@sentry/conventions/attributes';
4+
import {
5+
SENTRY_SEGMENT_NAME_SOURCE,
6+
CODE_FUNCTION_NAME,
7+
SENTRY_OP,
8+
FAAS_TRIGGER,
9+
SENTRY_ORIGIN,
10+
} from '@sentry/conventions/attributes';
511
import { FUNCTION } from '@sentry/conventions/op';
6-
import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, withIsolationScope } from '@sentry/core';
12+
import { captureException, startSpan, withIsolationScope } from '@sentry/core';
713
import type { CloudflareOptions } from '../../client';
814
import { flushAndDispose } from '../../flush';
915
import { ensureInstrumented } from '../../instrument';
@@ -35,11 +41,12 @@ function wrapEmailHandler(
3541

3642
return startSpan(
3743
{
38-
name: `Handle Email ${emailMessage.to}`,
44+
name: 'email',
3945
attributes: {
4046
[SENTRY_OP]: FUNCTION,
41-
'faas.trigger': 'email',
42-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.email',
47+
[CODE_FUNCTION_NAME]: 'email',
48+
[FAAS_TRIGGER]: 'email',
49+
[SENTRY_ORIGIN]: 'auto.faas.cloudflare.email',
4350
[SENTRY_SEGMENT_NAME_SOURCE]: 'task',
4451
},
4552
},

packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import type { ScheduledController } from '@cloudflare/workers-types';
22
import type { AnyExportedHandler } from '../../types';
33
import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers';
4-
import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP } from '@sentry/conventions/attributes';
4+
import {
5+
SENTRY_SEGMENT_NAME_SOURCE,
6+
CODE_FUNCTION_NAME,
7+
SENTRY_OP,
8+
FAAS_CRON,
9+
FAAS_TIME,
10+
FAAS_TRIGGER,
11+
SENTRY_DESCRIPTION,
12+
SENTRY_ORIGIN,
13+
} from '@sentry/conventions/attributes';
514
import { FUNCTION } from '@sentry/conventions/op';
6-
import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, withIsolationScope } from '@sentry/core';
15+
import { captureException, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core';
716
import type { CloudflareOptions } from '../../client';
817
import { flushAndDispose } from '../../flush';
918
import { ensureInstrumented } from '../../instrument';
@@ -30,15 +39,21 @@ function wrapScheduledHandler(
3039

3140
addCloudResourceContext(isolationScope);
3241

42+
const description = `Scheduled Cron ${controller.cron}`;
43+
3344
return startSpan(
3445
{
35-
name: `Scheduled Cron ${controller.cron}`,
46+
name: client && hasSpanStreamingEnabled(client) ? 'scheduled' : description,
3647
attributes: {
3748
[SENTRY_OP]: FUNCTION,
38-
'faas.cron': controller.cron,
39-
'faas.time': new Date(controller.scheduledTime).toISOString(),
40-
'faas.trigger': 'timer',
41-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled',
49+
// override description inference by Relay to preserve the original (transaction-based) description.
50+
// sentry-conventions can't map the special case for the "Scheduled Cron" prefix and the chron string.
51+
[SENTRY_DESCRIPTION]: description,
52+
[CODE_FUNCTION_NAME]: 'scheduled',
53+
[FAAS_CRON]: controller.cron,
54+
[FAAS_TIME]: new Date(controller.scheduledTime).toISOString(),
55+
[FAAS_TRIGGER]: 'timer',
56+
[SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled',
4257
[SENTRY_SEGMENT_NAME_SOURCE]: 'task',
4358
},
4459
},

packages/cloudflare/src/wrapMethodWithSentry.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import type { DurableObjectStorage } from '@cloudflare/workers-types';
22
import type { SerializedTraceData } from '@sentry/core';
3+
import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes';
4+
import { FUNCTION } from '@sentry/conventions/op';
35
import {
46
isObjectLike,
57
captureException,
68
continueTrace,
79
isThenable,
810
type Scope,
9-
SEMANTIC_ATTRIBUTE_SENTRY_OP,
10-
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1111
startNewTrace as startNewTraceCore,
1212
startSpan,
1313
} from '@sentry/core';
@@ -203,8 +203,10 @@ export function wrapMethodWithSentry<T extends OriginalMethod>(
203203

204204
const attributes = wrapperOptions.spanOp
205205
? {
206-
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp,
207-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin,
206+
[SENTRY_OP]: wrapperOptions.spanOp,
207+
[SENTRY_ORIGIN]: origin,
208+
// `function` spans are already named like their function name, so we just set `code.function.name` here.
209+
...(wrapperOptions.spanOp === FUNCTION && { [CODE_FUNCTION_NAME]: methodName }),
208210
}
209211
: {};
210212

packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,12 +259,15 @@ describe('instrumentEmail', () => {
259259
const emailMessage = createMockEmailMessage();
260260
await wrappedHandler.email?.(emailMessage, MOCK_ENV, createMockExecutionContext());
261261

262-
expect(sentryEvent.transaction).toEqual(`Handle Email ${emailMessage.to}`);
262+
// The recipient is deliberately not carried over into a description: it is PII, and the span
263+
// name must stay low cardinality.
264+
expect(sentryEvent.transaction).toEqual('email');
263265
expect(sentryEvent.spans).toHaveLength(0);
264266
expect(sentryEvent.contexts?.trace).toEqual({
265267
data: {
266268
'sentry.origin': 'auto.faas.cloudflare.email',
267269
'sentry.op': 'function',
270+
'code.function.name': 'email',
268271
'faas.trigger': 'email',
269272
'sentry.sample_rate': 1,
270273
'sentry.segment.name.source': 'task',

packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,8 @@ describe('instrumentScheduled', () => {
259259
data: {
260260
'sentry.origin': 'auto.faas.cloudflare.scheduled',
261261
'sentry.op': 'function',
262+
'sentry.description': 'Scheduled Cron 0 0 0 * * *',
263+
'code.function.name': 'scheduled',
262264
'faas.cron': '0 0 0 * * *',
263265
'faas.time': expect.any(String),
264266
'faas.trigger': 'timer',
@@ -272,6 +274,32 @@ describe('instrumentScheduled', () => {
272274
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
273275
});
274276
});
277+
278+
async function spanNameFor(traceLifecycle: 'static' | 'stream'): Promise<string | undefined> {
279+
let spanName: string | undefined;
280+
281+
const handler = {
282+
scheduled(_controller, _env, _context) {
283+
// Read the name while the handler is in flight: the gate applies at span start.
284+
const activeSpan = SentryCore.getActiveSpan();
285+
spanName = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)).name : undefined;
286+
},
287+
} satisfies ExportedHandler<typeof MOCK_ENV>;
288+
289+
const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1, traceLifecycle }), handler);
290+
291+
await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext());
292+
293+
return spanName;
294+
}
295+
296+
test('keeps the cron out of the span name when span streaming is enabled', async () => {
297+
expect(await spanNameFor('stream')).toBe('scheduled');
298+
});
299+
300+
test('keeps the descriptive span name when span streaming is disabled', async () => {
301+
expect(await spanNameFor('static')).toBe('Scheduled Cron 0 0 0 * * *');
302+
});
275303
});
276304

277305
test('flush must be called when all waitUntil are done', async () => {

packages/cloudflare/test/wrapMethodWithSentry.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,50 @@ describe('wrapMethodWithSentry', () => {
251251
);
252252
});
253253

254+
it('sets code.function.name on function spans', async () => {
255+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
256+
const handler = vi.fn().mockResolvedValue('result');
257+
const options = {
258+
origin: 'auto.faas.cloudflare.durable_object',
259+
options: {},
260+
context: createMockContext(),
261+
spanName: 'fetch',
262+
spanOp: 'function',
263+
};
264+
265+
const wrapped = wrapMethodWithSentry(options, handler, 'fetch');
266+
await wrapped();
267+
268+
expect(startSpanSpy).toHaveBeenCalledWith(
269+
expect.objectContaining({
270+
attributes: expect.objectContaining({ 'code.function.name': 'fetch' }),
271+
}),
272+
expect.any(Function),
273+
);
274+
});
275+
276+
it('does not set code.function.name on spans with a different op', async () => {
277+
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
278+
const handler = vi.fn().mockResolvedValue('result');
279+
const options = {
280+
origin: 'auto.faas.cloudflare.durable_object',
281+
options: {},
282+
context: createMockContext(),
283+
spanName: 'fetch',
284+
spanOp: 'test-op',
285+
};
286+
287+
const wrapped = wrapMethodWithSentry(options, handler, 'fetch');
288+
await wrapped();
289+
290+
expect(startSpanSpy).toHaveBeenCalledWith(
291+
expect.objectContaining({
292+
attributes: expect.not.objectContaining({ 'code.function.name': expect.anything() }),
293+
}),
294+
expect.any(Function),
295+
);
296+
});
297+
254298
it('does not create span when spanName is not provided', async () => {
255299
const startSpanSpy = vi.spyOn(sentryCore, 'startSpan');
256300
const handler = vi.fn().mockResolvedValue('result');

0 commit comments

Comments
 (0)