From ce64c3f42d48cf0527313c46bc85366fb9bb3c55 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Sep 2026 15:10:08 +0200 Subject: [PATCH 1/3] feat(cloudflare): Emit low cardinality span names and preserve span descriptions --- .../worker/instrumentEmail.ts | 17 +++++++---- .../worker/instrumentScheduled.ts | 29 ++++++++++++++----- .../cloudflare/src/wrapMethodWithSentry.ts | 10 ++++--- .../worker/instrumentEmail.test.ts | 1 + .../worker/instrumentScheduled.test.ts | 1 + 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts index 5c54f03b2531..11a8fb9b419d 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEmail.ts @@ -1,9 +1,15 @@ import type { EmailMessage } from '@cloudflare/workers-types'; import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv } from 'cloudflare:workers'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + CODE_FUNCTION_NAME, + SENTRY_OP, + FAAS_TRIGGER, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; -import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, withIsolationScope } from '@sentry/core'; +import { captureException, startSpan, withIsolationScope } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; import { flushAndDispose } from '../../flush'; import { ensureInstrumented } from '../../instrument'; @@ -35,11 +41,12 @@ function wrapEmailHandler( return startSpan( { - name: `Handle Email ${emailMessage.to}`, + name: 'email', attributes: { [SENTRY_OP]: FUNCTION, - 'faas.trigger': 'email', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.email', + [CODE_FUNCTION_NAME]: 'email', + [FAAS_TRIGGER]: 'email', + [SENTRY_ORIGIN]: 'auto.faas.cloudflare.email', [SENTRY_SEGMENT_NAME_SOURCE]: 'task', }, }, diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts index f633770988f9..b0da3a279a39 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentScheduled.ts @@ -1,9 +1,18 @@ import type { ScheduledController } from '@cloudflare/workers-types'; import type { AnyExportedHandler } from '../../types'; import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + CODE_FUNCTION_NAME, + SENTRY_OP, + FAAS_CRON, + FAAS_TIME, + FAAS_TRIGGER, + SENTRY_DESCRIPTION, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; -import { captureException, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, withIsolationScope } from '@sentry/core'; +import { captureException, hasSpanStreamingEnabled, startSpan, withIsolationScope } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; import { flushAndDispose } from '../../flush'; import { ensureInstrumented } from '../../instrument'; @@ -30,15 +39,21 @@ function wrapScheduledHandler( addCloudResourceContext(isolationScope); + const description = `Scheduled Cron ${controller.cron}`; + return startSpan( { - name: `Scheduled Cron ${controller.cron}`, + name: client && hasSpanStreamingEnabled(client) ? 'scheduled' : description, attributes: { [SENTRY_OP]: FUNCTION, - 'faas.cron': controller.cron, - 'faas.time': new Date(controller.scheduledTime).toISOString(), - 'faas.trigger': 'timer', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', + // override description inference by Relay to preserve the original (transaction-based) description. + // sentry-conventions can't map the special case for the "Scheduled Cron" prefix and the chron string. + [SENTRY_DESCRIPTION]: description, + [CODE_FUNCTION_NAME]: 'scheduled', + [FAAS_CRON]: controller.cron, + [FAAS_TIME]: new Date(controller.scheduledTime).toISOString(), + [FAAS_TRIGGER]: 'timer', + [SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', [SENTRY_SEGMENT_NAME_SOURCE]: 'task', }, }, diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 95a69473f667..d20fd184d100 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -1,13 +1,13 @@ import type { DurableObjectStorage } from '@cloudflare/workers-types'; import type { SerializedTraceData } from '@sentry/core'; +import { CODE_FUNCTION_NAME, SENTRY_OP, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { FUNCTION } from '@sentry/conventions/op'; import { isObjectLike, captureException, continueTrace, isThenable, type Scope, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startNewTrace as startNewTraceCore, startSpan, } from '@sentry/core'; @@ -203,8 +203,10 @@ export function wrapMethodWithSentry( const attributes = wrapperOptions.spanOp ? { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [SENTRY_OP]: wrapperOptions.spanOp, + [SENTRY_ORIGIN]: origin, + // `function` spans are already named like their function name, so we just set `code.function.name` here. + ...(wrapperOptions.spanOp === FUNCTION && { [CODE_FUNCTION_NAME]: methodName }), } : {}; diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts index c1222a14c605..c69d41d74faa 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -265,6 +265,7 @@ describe('instrumentEmail', () => { data: { 'sentry.origin': 'auto.faas.cloudflare.email', 'sentry.op': 'function', + 'code.function.name': 'email', 'faas.trigger': 'email', 'sentry.sample_rate': 1, 'sentry.segment.name.source': 'task', diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index 6a2dd9e08dbe..f79f1b49e1b3 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -259,6 +259,7 @@ describe('instrumentScheduled', () => { data: { 'sentry.origin': 'auto.faas.cloudflare.scheduled', 'sentry.op': 'function', + 'code.function.name': 'scheduled', 'faas.cron': '0 0 0 * * *', 'faas.time': expect.any(String), 'faas.trigger': 'timer', From 20b23ed7d2c9e44b68d76118a466593b102de66d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 9 Sep 2026 18:16:59 +0200 Subject: [PATCH 2/3] fix tests --- .../suites/tracing/scheduled/test.ts | 2 + .../worker/instrumentEmail.test.ts | 4 +- .../worker/instrumentScheduled.test.ts | 27 ++++++++++++ .../test/wrapMethodWithSentry.test.ts | 44 +++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts index a09128d28fc0..7043172e6284 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled/test.ts @@ -31,6 +31,8 @@ it('Scheduled handler creates transaction with correct attributes', async ({ sig [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.scheduled', [SENTRY_SEGMENT_NAME_SOURCE]: 'task', [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + 'sentry.description': expect.stringMatching(/^Scheduled Cron/), + 'code.function.name': 'scheduled', 'faas.cron': expect.any(String), 'faas.time': expect.any(String), 'faas.trigger': 'timer', diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts index c69d41d74faa..2c15f5a39966 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentEmail.test.ts @@ -259,7 +259,9 @@ describe('instrumentEmail', () => { const emailMessage = createMockEmailMessage(); await wrappedHandler.email?.(emailMessage, MOCK_ENV, createMockExecutionContext()); - expect(sentryEvent.transaction).toEqual(`Handle Email ${emailMessage.to}`); + // The recipient is deliberately not carried over into a description: it is PII, and the span + // name must stay low cardinality. + expect(sentryEvent.transaction).toEqual('email'); expect(sentryEvent.spans).toHaveLength(0); expect(sentryEvent.contexts?.trace).toEqual({ data: { diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts index f79f1b49e1b3..46090684daea 100644 --- a/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts +++ b/packages/cloudflare/test/instrumentations/worker/instrumentScheduled.test.ts @@ -259,6 +259,7 @@ describe('instrumentScheduled', () => { data: { 'sentry.origin': 'auto.faas.cloudflare.scheduled', 'sentry.op': 'function', + 'sentry.description': 'Scheduled Cron 0 0 0 * * *', 'code.function.name': 'scheduled', 'faas.cron': '0 0 0 * * *', 'faas.time': expect.any(String), @@ -273,6 +274,32 @@ describe('instrumentScheduled', () => { trace_id: expect.stringMatching(/[a-f0-9]{32}/), }); }); + + async function spanNameFor(traceLifecycle: 'static' | 'stream'): Promise { + let spanName: string | undefined; + + const handler = { + scheduled(_controller, _env, _context) { + // Read the name while the handler is in flight: the gate applies at span start. + const activeSpan = SentryCore.getActiveSpan(); + spanName = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)).name : undefined; + }, + } satisfies ExportedHandler; + + const wrappedHandler = withSentry(env => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1, traceLifecycle }), handler); + + await wrappedHandler.scheduled?.(createMockScheduledController(), MOCK_ENV, createMockExecutionContext()); + + return spanName; + } + + test('keeps the cron out of the span name when span streaming is enabled', async () => { + expect(await spanNameFor('stream')).toBe('scheduled'); + }); + + test('keeps the descriptive span name when span streaming is disabled', async () => { + expect(await spanNameFor('static')).toBe('Scheduled Cron 0 0 0 * * *'); + }); }); test('flush must be called when all waitUntil are done', async () => { diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index 45652c6f26e5..53f30bde5950 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -251,6 +251,50 @@ describe('wrapMethodWithSentry', () => { ); }); + it('sets code.function.name on function spans', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const handler = vi.fn().mockResolvedValue('result'); + const options = { + origin: 'auto.faas.cloudflare.durable_object', + options: {}, + context: createMockContext(), + spanName: 'fetch', + spanOp: 'function', + }; + + const wrapped = wrapMethodWithSentry(options, handler, 'fetch'); + await wrapped(); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ 'code.function.name': 'fetch' }), + }), + expect.any(Function), + ); + }); + + it('does not set code.function.name on spans with a different op', async () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const handler = vi.fn().mockResolvedValue('result'); + const options = { + origin: 'auto.faas.cloudflare.durable_object', + options: {}, + context: createMockContext(), + spanName: 'fetch', + spanOp: 'test-op', + }; + + const wrapped = wrapMethodWithSentry(options, handler, 'fetch'); + await wrapped(); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ 'code.function.name': expect.anything() }), + }), + expect.any(Function), + ); + }); + it('does not create span when spanName is not provided', async () => { const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const handler = vi.fn().mockResolvedValue('result'); From 50ab906f4e7c88dc5851655f96bcb7a5008576b2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 11 Sep 2026 13:09:19 +0200 Subject: [PATCH 3/3] add tests --- .../tracing/scheduled-streamed/index.ts | 21 +++++++++++ .../suites/tracing/scheduled-streamed/test.ts | 36 +++++++++++++++++++ .../tracing/scheduled-streamed/wrangler.jsonc | 9 +++++ 3 files changed, 66 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts new file mode 100644 index 000000000000..eda2688ae645 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/index.ts @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'stream', + tracesSampleRate: 1.0, + }), + { + async fetch(_request, _env, _ctx) { + return new Response('OK'); + }, + async scheduled(_controller, _env, _ctx) { + await new Promise(resolve => setTimeout(resolve, 10)); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts new file mode 100644 index 000000000000..f19dd39fa430 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/test.ts @@ -0,0 +1,36 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} + +it('keeps the cron out of the scheduled span name when span streaming is enabled', async ({ signal }) => { + const runner = createRunner(__dirname) + .withWranglerArgs('--test-scheduled') + .expect(envelope => { + const segmentSpan = getSpanContainer(envelope).items.find(span => !!span.is_segment); + + expect(segmentSpan).toBeDefined(); + expect(segmentSpan!.name).toBe('scheduled'); + expect(segmentSpan!.attributes).toEqual( + expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'function' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.faas.cloudflare.scheduled' }, + 'code.function.name': { type: 'string', value: 'scheduled' }, + // Relay infers the description from the span name, so the original, cron-bearing name is + // preserved explicitly to keep it visible in the UI. + 'sentry.description': { type: 'string', value: expect.stringMatching(/^Scheduled Cron/) }, + 'faas.trigger': { type: 'string', value: 'timer' }, + }), + ); + }) + .start(signal); + + await runner.makeRequest('get', '/__scheduled'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc new file mode 100644 index 000000000000..76f688a57488 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/scheduled-streamed/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "scheduled-streamed-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_compat"], + "triggers": { + "crons": ["* * * * *"], + }, +}