From 30260d0a404d8cfb580f9654027b69eadd1d164a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 25 Aug 2026 17:31:17 +0200 Subject: [PATCH] feat: Emit low-cardinality http.server span names in framework SDKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the same span-streaming gate to the runtime and framework SDKs so no integration keeps a raw URL in an http.server span name. Requests that resolve to a route are unchanged. Three sites differ from the rest and are worth a closer look: remix reads its own span name back, sveltekit also renames SvelteKit's native root span, and nextjs renames in a `spanStart` hook because Next.js — not the SDK — creates that span. Refs #23527 Co-Authored-By: Claude Opus 5 (1M context) --- .../deno-streamed/tests/spans.test.ts | 14 ++-- packages/astro/src/server/middleware.ts | 13 +++- packages/astro/test/server/middleware.test.ts | 65 +++++++++++++++++++ packages/bun/src/integrations/bunserver.ts | 8 ++- .../bun/test/integrations/bunserver.test.ts | 8 +-- .../src/wrapRequestHandlerWithInit.ts | 16 ++++- packages/cloudflare/test/request.test.ts | 30 +++++++++ .../deno/src/wrap-deno-request-handler.ts | 17 ++++- packages/elysia/src/withElysia.ts | 11 +++- packages/nextjs/src/edge/index.ts | 22 ++++++- .../nextjs/src/server/handleOnSpanStart.ts | 28 +++++++- packages/nextjs/src/server/index.ts | 2 +- .../src/runtime/hooks/captureTracingEvents.ts | 30 ++++++++- .../src/server/createServerInstrumentation.ts | 24 ++++++- .../createServerInstrumentation.test.ts | 43 ++++++++++++ packages/remix/src/server/instrumentServer.ts | 16 ++++- .../server/integrations/tracing-channel.ts | 15 ++++- .../sveltekit/src/server-common/handle.ts | 15 ++++- .../test/server-common/handle.test.ts | 43 ++++++++++++ 19 files changed, 384 insertions(+), 36 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index 8df9772e6654..9184084c85b8 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -113,7 +113,7 @@ const SEGMENT_SPAN = { }, 'sentry.segment.name': { type: 'string', - value: 'GET /test-sentry-span', + value: 'GET', }, 'sentry.segment.name.source': { type: 'string', @@ -154,7 +154,7 @@ const SEGMENT_SPAN = { }, end_timestamp: expect.any(Number), is_segment: true, - name: 'GET /test-sentry-span', + name: 'GET', span_id: expect.stringMatching(/^[\da-f]{16}$/), start_timestamp: expect.any(Number), status: 'ok', @@ -200,7 +200,7 @@ test('Sends streamed spans (http.server and manual with Sentry.startSpan)', asyn }, 'sentry.segment.name': { type: 'string', - value: 'GET /test-sentry-span', + value: 'GET', }, }, end_timestamp: expect.any(Number), @@ -230,10 +230,10 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server'); expect(httpServerSpan).toEqual({ ...SEGMENT_SPAN, - name: 'GET /test-interop', + name: 'GET', attributes: { ...SEGMENT_SPAN.attributes, - 'sentry.segment.name': { type: 'string', value: 'GET /test-interop' }, + 'sentry.segment.name': { type: 'string', value: 'GET' }, 'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) }, 'url.path': { type: 'string', value: '/test-interop' }, }, @@ -272,7 +272,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) }, 'sentry.segment.name': { type: 'string', - value: 'GET /test-interop', + value: 'GET', }, }, end_timestamp: expect.any(Number), @@ -313,7 +313,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) }, 'sentry.segment.name': { type: 'string', - value: 'GET /test-interop', + value: 'GET', }, 'sentry.deno_tracer': { type: 'boolean', diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index 31d42d8f3742..282da5f803ee 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -16,6 +16,8 @@ import { getRootSpan, getUrlFragment, getUrlQuery, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, objectify, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, spanToJSON, @@ -235,9 +237,16 @@ async function instrumentRequestStartHttpServerSpan( attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(ctx.url.search)); attributes[URL_FRAGMENT] = getUrlFragment(ctx.url.hash); - const name = `${method} ${parametrizedRoute || ctx.url.pathname}`; + const transactionName = `${method} ${parametrizedRoute || ctx.url.pathname}`; - isolationScope.setTransactionName(name); + // The scope's transaction name is what error events are grouped by, so it keeps the URL path. + isolationScope.setTransactionName(transactionName); + + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. + const name = + parametrizedRoute || !hasSpanStreamingEnabled(client) + ? transactionName + : method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK; const res = await startSpan( { diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 7568d855f468..9492491b8ddd 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -3,6 +3,7 @@ import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import * as SentryNode from '@sentry/node'; +import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware'; @@ -133,6 +134,70 @@ describe('sentryMiddleware', () => { expect(resultFromNext).toStrictEqual(nextResult); }); + describe('with span streaming enabled', () => { + // A full `APIContext` is much larger than these tests need, so build a partial one behind a typed + // helper rather than suppressing the type error at each call site. + function mockApiContext(override: Record): APIContext { + return { ...DYNAMIC_REQUEST_CONTEXT, ...override } as unknown as APIContext; + } + + beforeEach(() => { + vi.spyOn(SentryNode, 'getClient').mockImplementation( + () => + ({ + getOptions: () => ({ traceLifecycle: 'stream' }), + getDataCollectionOptions: () => ({ + userInfo: false, + cookies: true, + httpHeaders: { request: true, response: true }, + httpBodies: [], + urlQueryParams: true, + graphQL: { document: true, variables: true }, + genAI: { inputs: true, outputs: true }, + databaseQueryData: true, + stackFrameVariables: true, + frameContextLines: 5, + }), + }) as unknown as Client, + ); + }); + + it('names an unparameterized span after the request method', async () => { + const middleware = handleRequest(); + const ctx = mockApiContext({ + request: { method: 'GET', url: '/a%xx', headers: new Headers() }, + url: { pathname: 'a%xx', href: 'http://localhost:1234/a%xx' }, + params: {}, + }); + + await middleware( + ctx, + vi.fn(() => nextResult), + ); + + expect(startSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET' }), expect.any(Function)); + }); + + it('keeps the parameterized route as the span name', async () => { + const middleware = handleRequest(); + const ctx = mockApiContext({ + request: { method: 'GET', url: '/users/123/details', headers: new Headers() }, + params: { id: '123' }, + url: new URL('https://myDomain.io/users/123/details'), + }); + + await middleware( + ctx, + vi.fn(() => nextResult), + ); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'GET /users/[id]/details' }), + expect.any(Function), + ); + }); + }); + it("sets source route if the url couldn't be decoded correctly", async () => { const middleware = handleRequest(); const ctx = { diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index d13773b9b0bc..32e1a3c626f3 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -6,7 +6,9 @@ import { getClient, getUrlFragment, getUrlQuery, + hasSpanStreamingEnabled, httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, isURLObjectRelative, parseStringToURLObject, SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, @@ -246,7 +248,11 @@ function wrapRequestHandler( { attributes, op: 'http.server', - name: `${request.method} ${routeName}`, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. + name: + attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client) + ? `${request.method} ${routeName}` + : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, }, async span => { try { diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 92ce85e21e5f..49fd06d268dc 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -75,7 +75,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.user_agent': expect.stringContaining('Bun'), }), op: 'http.server', - name: 'GET /users', + name: 'GET', }, expect.any(Function), ); @@ -119,7 +119,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.user_agent': expect.stringContaining('Bun'), }), op: 'http.server', - name: 'POST /', + name: 'POST', }, expect.any(Function), ); @@ -148,7 +148,7 @@ describe('Bun Serve Integration', () => { 'http.request.method': 'QUERY', }), op: 'http.server', - name: 'QUERY /search', + name: 'QUERY', }), expect.any(Function), ); @@ -243,7 +243,7 @@ describe('Bun Serve Integration', () => { 'http.request.header.sentry_trace': expect.any(String), }), op: 'http.server', - name: 'POST /api/test', + name: 'POST', }), expect.any(Function), ); diff --git a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts index e7ba1b586105..7935d071f315 100644 --- a/packages/cloudflare/src/wrapRequestHandlerWithInit.ts +++ b/packages/cloudflare/src/wrapRequestHandlerWithInit.ts @@ -1,10 +1,16 @@ import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types'; -import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; +import { + NETWORK_PROTOCOL_NAME, + NETWORK_PROTOCOL_VERSION, + SENTRY_SEGMENT_NAME_SOURCE, +} from '@sentry/conventions/attributes'; import { captureException, continueTrace, getHttpSpanDetailsFromUrlObject, + hasSpanStreamingEnabled, httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, setHttpStatus, @@ -76,7 +82,7 @@ export function wrapRequestHandlerWithInit( isolationScope.setClient(client); const urlObject = parseStringToURLObject(request.url); - const [name, attributes] = getHttpSpanDetailsFromUrlObject( + const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( urlObject, 'server', 'auto.http.cloudflare', @@ -84,6 +90,12 @@ export function wrapRequestHandlerWithInit( undefined, client, ); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // A `route` source means the name already is (e.g. the `/` path), so it is kept as-is. + const name = + attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client) + ? rawName + : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK; const contentLength = request.headers.get('content-length'); if (contentLength) { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a23222fabc4f..adfd8c5f848b 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -33,6 +33,36 @@ describe('withSentry', () => { vi.clearAllMocks(); }); + describe('with span streaming enabled', () => { + async function segmentSpanNameFor(url: string): Promise { + let spanName: string | undefined; + + await wrapRequestHandler( + { + options: { ...MOCK_OPTIONS, traceLifecycle: 'stream', tracesSampleRate: 1 }, + request: new Request(url), + context: createMockExecutionContext(), + }, + () => { + // Read the name while the request is in flight: the gate applies at span start. + const activeSpan = SentryCore.getActiveSpan(); + spanName = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)).name : undefined; + return new Response('test'); + }, + ); + + return spanName; + } + + test('names a span without a resolvable route after the request method', async () => { + expect(await segmentSpanNameFor('https://example.com/users/42')).toBe('GET'); + }); + + test('keeps the root path, which is already low cardinality', async () => { + expect(await segmentSpanNameFor('https://example.com/')).toBe('GET /'); + }); + }); + test('passes through the response from the handler', async () => { const response = new Response('test'); const result = await wrapRequestHandler( diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 004a5f9fa26d..907748da1fda 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -1,4 +1,9 @@ -import { CLIENT_ADDRESS, CLIENT_PORT, NETWORK_PROTOCOL_NAME } from '@sentry/conventions/attributes'; +import { + CLIENT_ADDRESS, + CLIENT_PORT, + NETWORK_PROTOCOL_NAME, + SENTRY_SEGMENT_NAME_SOURCE, +} from '@sentry/conventions/attributes'; import type { Integration, MaxRequestBodySize } from '@sentry/core'; import { captureBodyFromWinterCGRequest, @@ -6,7 +11,9 @@ import { continueTrace, getClient, getHttpSpanDetailsFromUrlObject, + hasSpanStreamingEnabled, httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_OP, setHttpStatus, @@ -60,7 +67,7 @@ export const wrapDenoRequestHandler = ( } const urlObject = parseStringToURLObject(request.url); - const [name, attributes] = getHttpSpanDetailsFromUrlObject( + const [rawName, attributes] = getHttpSpanDetailsFromUrlObject( urlObject, 'server', 'auto.http.deno', @@ -68,6 +75,12 @@ export const wrapDenoRequestHandler = ( undefined, client, ); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // A `route` source means the name already is (e.g. the `/` path), so it is kept as-is. + const name = + attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !hasSpanStreamingEnabled(client) + ? rawName + : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK; const contentLength = request.headers.get('content-length'); assignIfSet(attributes, 'http.request.body.size', contentLength && parseInt(contentLength, 10)); diff --git a/packages/elysia/src/withElysia.ts b/packages/elysia/src/withElysia.ts index c58834cfa511..3591d114d5a4 100644 --- a/packages/elysia/src/withElysia.ts +++ b/packages/elysia/src/withElysia.ts @@ -5,6 +5,7 @@ import { captureException, continueTrace, getActiveSpan, + getClient, getIsolationScope, getRootSpan, getTraceData, @@ -17,6 +18,8 @@ import { winterCGRequestToRequestData, withIsolationScope, filterCollectedUrl, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, } from '@sentry/core'; import type { AnyElysia, Elysia, ErrorContext, TraceHandler, TraceListener } from 'elysia'; @@ -200,10 +203,16 @@ export function withElysia(app: T, options: ElysiaHandlerOp baggage: request.headers.get('baggage'), }, () => { + const client = getClient(); return startSpanManual( { op: 'http.server', - name: `${request.method} ${new URL(request.url).pathname}`, + // With span streaming, span names have to be low cardinality, so we can't fall back to the + // URL path. `updateRouteTransactionName` renames the span once Elysia resolves the route. + name: + client && hasSpanStreamingEnabled(client) + ? request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK + : `${request.method} ${new URL(request.url).pathname}`, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN, [SENTRY_SEGMENT_NAME_SOURCE]: 'url', diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 8a5eb7ca8cb8..801491223324 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -7,6 +7,8 @@ import { getIsolationScope, getRootSpan, GLOBAL_OBJ, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, registerSpanErrorInstrumentation, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -33,7 +35,12 @@ import { setUrlProcessingMetadata } from '../common/utils/setUrlProcessingMetada import { distDirRewriteFramesIntegration } from './distDirRewriteFramesIntegration'; import { enhanceMiddlewareRootSpan } from '../common/enhanceMiddlewareRootSpan'; import { enhanceRunHandlerRootSpan } from './enhanceRunHandlerRootSpan'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_KIND } from '@sentry/conventions/attributes'; +import { + HTTP_METHOD, + HTTP_REQUEST_METHOD, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_KIND, +} from '@sentry/conventions/attributes'; import { MIDDLEWARE } from '@sentry/conventions/op'; export * from '@sentry/vercel-edge'; @@ -134,6 +141,19 @@ export function init(options: VercelEdgeOptions = {}): void { dropMiddlewareTunnelRequests(span, spanAttributes); + // Next.js names the incoming-request span after the raw URL. With span streaming, span names have to + // be low cardinality, so we replace it here at span start; the `next.route` hoisting below renames it + // to `${method} ${route}` once Next.js reports a route. + if (isRootSpan && spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest') { + if (hasSpanStreamingEnabled(client)) { + // oxlint-disable-next-line typescript/no-deprecated + const method = spanAttributes[HTTP_REQUEST_METHOD] ?? spanAttributes[HTTP_METHOD]; + createLiveRootSpanAdapter(span).setName( + (typeof method === 'string' ? method.toUpperCase() : '') || HTTP_SPAN_NAME_FALLBACK, + ); + } + } + // Mark all spans generated by Next.js as 'auto' & server if (spanAttributes?.[ATTR_NEXT_SPAN_TYPE] !== undefined) { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto'); diff --git a/packages/nextjs/src/server/handleOnSpanStart.ts b/packages/nextjs/src/server/handleOnSpanStart.ts index df5bf1dc9362..c112bf119b06 100644 --- a/packages/nextjs/src/server/handleOnSpanStart.ts +++ b/packages/nextjs/src/server/handleOnSpanStart.ts @@ -4,11 +4,19 @@ import { HTTP_REQUEST_METHOD, HTTP_ROUTE, } from '@sentry/conventions/attributes'; -import type { Span } from '@sentry/core'; -import { getIsolationScope, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON } from '@sentry/core'; +import type { Client, Span } from '@sentry/core'; +import { + getIsolationScope, + getRootSpan, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, +} from '@sentry/core'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; import { dropMiddlewareTunnelRequests } from '../common/utils/dropMiddlewareTunnelRequests'; +import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { maybeEnhanceServerComponentSpanName } from '../common/utils/tracingUtils'; import { maybeStartCronCheckIn } from './vercelCronsMonitoring'; @@ -19,8 +27,9 @@ import { maybeEnrichQueueConsumerSpan, maybeEnrichQueueProducerSpan } from './ve * This function is used to enhance the span with additional information such as the route, the method, the headers, etc. * It is called for every span that is started by Next.js. * @param span The span that is starting. + * @param client The client the hook is registered on. */ -export function handleOnSpanStart(span: Span): void { +export function handleOnSpanStart(span: Span, client: Client): void { const spanAttributes = spanToJSON(span).attributes; const rootSpan = getRootSpan(span); const rootSpanAttributes = spanToJSON(rootSpan).attributes; @@ -28,6 +37,19 @@ export function handleOnSpanStart(span: Span): void { dropMiddlewareTunnelRequests(span, spanAttributes); + // Next.js names the incoming-request span after the raw URL. With span streaming, span names have to + // be low cardinality, so we replace it here at span start; the `next.route` hoisting below renames it + // to `${method} ${route}` once Next.js reports a route. + if (isRootSpan && spanAttributes?.[ATTR_NEXT_SPAN_TYPE] === 'BaseServer.handleRequest') { + if (hasSpanStreamingEnabled(client)) { + // eslint-disable-next-line typescript/no-deprecated + const method = spanAttributes[HTTP_REQUEST_METHOD] ?? spanAttributes[HTTP_METHOD]; + createLiveRootSpanAdapter(span).setName( + (typeof method === 'string' ? method.toUpperCase() : '') || HTTP_SPAN_NAME_FALLBACK, + ); + } + } + // What we do in this glorious piece of code, is hoist any information about parameterized routes from spans emitted // by Next.js via the `next.route` attribute, up to the transaction by setting the http.route attribute. if (typeof spanAttributes?.[ATTR_NEXT_ROUTE] === 'string') { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 302d9c2b1619..452887f0d8f4 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -201,7 +201,7 @@ export function init(options: NodeOptions): NodeClient | undefined { } }); - client?.on('spanStart', handleOnSpanStart); + client?.on('spanStart', span => handleOnSpanStart(span, client)); // Normalize name/op/source/status on the request root span at span end, before it is serialized into // a transaction event (legacy) or streamed span JSON. Running on the live span means both lifecycles diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index 57ab12fa6d5b..b34a5bf7225d 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -1,5 +1,5 @@ import * as dc from 'node:diagnostics_channel'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { HTTP_SERVER, MIDDLEWARE } from '@sentry/conventions/op'; import { isObjectLike, @@ -8,7 +8,9 @@ import { getHttpSpanDetailsFromUrlObject, getRootSpan, GLOBAL_OBJ, + hasSpanStreamingEnabled, httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, parseStringToURLObject, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setHttpStatus, @@ -103,8 +105,19 @@ function setupH3TracingChannels(): void { routePattern, ); + const client = getClient(); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // Only applies to the http.server span; middleware spans keep their own naming. + const isUnparameterizedStreamedServerSpan = + data?.type !== 'middleware' && + urlAttributes[SENTRY_SEGMENT_NAME_SOURCE] !== 'route' && + !!client && + hasSpanStreamingEnabled(client); + const span = startInactiveSpan({ - name: spanName, + name: isUnparameterizedStreamedServerSpan + ? data.event.req.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK + : spanName, attributes: { ...urlAttributes, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.nitro.h3', @@ -175,8 +188,19 @@ function setupSrvxTracingChannels(): void { ) : {}; + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. + // Only applies to the http.server span; middleware spans keep their own naming. + // h3 renames this span via `setHttpServerSpanRouteAttribute` once it resolves the route. + const isUnparameterizedStreamedServerSpan = + !data.middleware && + urlAttributes[SENTRY_SEGMENT_NAME_SOURCE] !== 'route' && + !!client && + hasSpanStreamingEnabled(client); + return startInactiveSpan({ - name: spanName, + name: isUnparameterizedStreamedServerSpan + ? data.request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK + : spanName, attributes: { ...urlAttributes, ...headerAttributes, diff --git a/packages/react-router/src/server/createServerInstrumentation.ts b/packages/react-router/src/server/createServerInstrumentation.ts index d00743799f10..b1649a0a10b1 100644 --- a/packages/react-router/src/server/createServerInstrumentation.ts +++ b/packages/react-router/src/server/createServerInstrumentation.ts @@ -12,8 +12,11 @@ import { debug, flushIfServerless, getActiveSpan, + getClient, getCurrentScope, getRootSpan, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -67,8 +70,16 @@ export function createSentryServerInstrumentation( const activeSpan = getActiveSpan(); const existingRootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + const client = getClient(); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL + // path. `updateRootSpanWithRoute` renames the span once React Router matches a route. + const unparameterizedName = + client && hasSpanStreamingEnabled(client) + ? info.request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK + : `${info.request.method} ${pathname}`; + if (existingRootSpan) { - updateSpanName(existingRootSpan, `${info.request.method} ${pathname}`); + updateSpanName(existingRootSpan, unparameterizedName); existingRootSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.instrumentation_api', @@ -92,7 +103,7 @@ export function createSentryServerInstrumentation( } else { await startSpan( { - name: `${info.request.method} ${pathname}`, + name: unparameterizedName, forceTransaction: true, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', @@ -273,7 +284,14 @@ function updateRootSpanWithRoute(method: string, pattern: string | undefined, ur const routeName = hasPattern ? normalizeRoutePath(pattern) || urlPath : urlPath; const transactionName = `${method} ${routeName}`; - updateSpanName(rootSpan, transactionName); + + const client = getClient(); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. + const isUnparameterizedStreamedSpan = !hasPattern && !!client && hasSpanStreamingEnabled(client); + updateSpanName( + rootSpan, + isUnparameterizedStreamedSpan ? method.toUpperCase() || HTTP_SPAN_NAME_FALLBACK : transactionName, + ); rootSpan.setAttributes({ [HTTP_ROUTE]: routeName, [SENTRY_SEGMENT_NAME_SOURCE]: hasPattern ? 'route' : 'url', diff --git a/packages/react-router/test/server/createServerInstrumentation.test.ts b/packages/react-router/test/server/createServerInstrumentation.test.ts index 2bc05251ea4a..011e2e79819e 100644 --- a/packages/react-router/test/server/createServerInstrumentation.test.ts +++ b/packages/react-router/test/server/createServerInstrumentation.test.ts @@ -16,6 +16,7 @@ vi.mock('@sentry/core', async () => { flushIfServerless: vi.fn(), getActiveSpan: vi.fn(), getRootSpan: vi.fn(), + getClient: vi.fn(), updateSpanName: vi.fn(), GLOBAL_OBJ: globalThis, SEMANTIC_ATTRIBUTE_SENTRY_OP: 'sentry.op', @@ -56,6 +57,48 @@ describe('createSentryServerInstrumentation', () => { expect((globalThis as any).__sentryReactRouterServerInstrumentationUsed).toBeUndefined(); }); + describe('with span streaming enabled', () => { + beforeEach(() => { + (core.getClient as any).mockReturnValue({ getOptions: () => ({ traceLifecycle: 'stream' }) }); + }); + + // `vi.clearAllMocks()` clears calls but not implementations, so this would leak into later tests. + afterEach(() => { + (core.getClient as any).mockReturnValue(undefined); + }); + + it('names an unparameterized root span after the request method', async () => { + const mockRequest = new Request('http://example.com/test-path'); + const mockInstrument = vi.fn(); + const mockRootSpan = { setAttributes: vi.fn() }; + + (core.getActiveSpan as any).mockReturnValue({}); + (core.getRootSpan as any).mockReturnValue(mockRootSpan); + + const instrumentation = createSentryServerInstrumentation(); + instrumentation.handler?.({ instrument: mockInstrument }); + const hooks = mockInstrument.mock.calls[0]![0]; + + await hooks.request(vi.fn().mockResolvedValue({ status: 'success', error: undefined }), { + request: mockRequest, + context: undefined, + }); + + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET'); + }); + + it('keeps the parameterized route once React Router matches one', async () => { + const { mockRootSpan } = await callMiddlewareHook({ + middlewareName: undefined, + routeId: 'test-route', + routePath: '/users/:id', + url: 'http://example.com/users/123', + }); + + expect(core.updateSpanName).toHaveBeenCalledWith(mockRootSpan, 'GET /users/:id'); + }); + }); + it('should set the global flag when React Router invokes the handler registration', () => { const instrumentation = createSentryServerInstrumentation(); expect((globalThis as any).__sentryReactRouterServerInstrumentationUsed).toBeUndefined(); diff --git a/packages/remix/src/server/instrumentServer.ts b/packages/remix/src/server/instrumentServer.ts index b10f208420e4..64427512dbb4 100644 --- a/packages/remix/src/server/instrumentServer.ts +++ b/packages/remix/src/server/instrumentServer.ts @@ -22,6 +22,8 @@ import { getRootSpan, getTraceData, hasSpansEnabled, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, httpHeadersToSpanAttributes, isNodeEnv, loadModule, @@ -313,6 +315,7 @@ function wrapRequestHandler ServerBuild | Promise ): RequestHandler { let resolvedBuild: ServerBuild | { build: ServerBuild }; let name: string; + let spanName: string; let source: TransactionSource; return async function (this: unknown, request: RemixRequest, loadContext?: AppLoadContext): Promise { @@ -354,13 +357,20 @@ function wrapRequestHandler ServerBuild | Promise if (options?.instrumentTracing && resolvedRoutes) { [name, source] = getTransactionName(resolvedRoutes, url); + // The scope's transaction name is what error events are grouped by, so it keeps the URL path. isolationScope.setTransactionName(name); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. + spanName = + source === 'route' || !client || !hasSpanStreamingEnabled(client) + ? name + : request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK; + // Update the span name if we're running inside an existing span const parentSpan = getActiveSpan(); if (parentSpan) { const rootSpan = getRootSpan(parentSpan); - rootSpan?.updateName(name); + rootSpan?.updateName(spanName); rootSpan?.setAttributes({ [SENTRY_SEGMENT_NAME_SOURCE]: source, ...(source === 'route' && { @@ -385,11 +395,11 @@ function wrapRequestHandler ServerBuild | Promise if (options?.instrumentTracing) { const parentSpan = getActiveSpan(); const rootSpan = parentSpan && getRootSpan(parentSpan); - rootSpan?.updateName(name); + rootSpan?.updateName(spanName); rootSpan?.setAttribute(SENTRY_SEGMENT_NAME_SOURCE, source); return startSpan( { - name, + name: spanName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.remix', [SENTRY_SEGMENT_NAME_SOURCE]: source, diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts index 714082a35ecb..a669f046761d 100644 --- a/packages/remix/src/server/integrations/tracing-channel.ts +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -2,7 +2,10 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { Span, SpanAttributes } from '@sentry/core'; import { getActiveSpan, + getClient, getSpanStatusFromHttpCode, + hasSpanStreamingEnabled, + HTTP_SPAN_NAME_FALLBACK, isObjectLike, isURLObjectRelative, parseStringToURLObject, @@ -142,8 +145,18 @@ function subscribeRequestHandler(): void { const method = requestAttributes[HTTP_REQUEST_METHOD]; const path = requestAttributes[URL_PATH]; const hasUrlName = typeof method === 'string' && typeof path === 'string'; + const client = getClient(); + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. + // The route is applied later, once Remix has matched it. + const isStreamed = !!client && hasSpanStreamingEnabled(client); return startInactiveSpan({ - name: hasUrlName ? `${method} ${path}` : 'remix.request', + name: isStreamed + ? typeof method === 'string' + ? method + : HTTP_SPAN_NAME_FALLBACK + : hasUrlName + ? `${method} ${path}` + : 'remix.request', attributes: { [SENTRY_KIND]: 'server', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, diff --git a/packages/sveltekit/src/server-common/handle.ts b/packages/sveltekit/src/server-common/handle.ts index 11da9c4b8a08..ca34a292797e 100644 --- a/packages/sveltekit/src/server-common/handle.ts +++ b/packages/sveltekit/src/server-common/handle.ts @@ -8,7 +8,9 @@ import { getDefaultIsolationScope, getIsolationScope, getTraceMetaTags, + hasSpanStreamingEnabled, httpHeadersToSpanAttributes, + HTTP_SPAN_NAME_FALLBACK, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setHttpStatus, @@ -154,7 +156,8 @@ async function instrumentHandle( // - Used Kit version doesn't yet support tracing // - Users didn't enable tracing const kitTracingEnabled = event.tracing?.enabled; - const dataCollectionOptions = getClient()?.getDataCollectionOptions(); + const client = getClient(); + const dataCollectionOptions = client?.getDataCollectionOptions(); try { const resolveWithSentry: (sentrySpan?: Span) => Promise = async (sentrySpan?: Span) => { @@ -180,6 +183,10 @@ async function instrumentHandle( const routeName = typeof kitRoute === 'string' ? kitRoute : routeId; if (routeName && typeof routeName === 'string') { updateSpanName(kitRootSpan, `${event.request.method ?? 'GET'} ${routeName}`); + } else if (client && hasSpanStreamingEnabled(client)) { + // Without a route, SvelteKit's own span name holds the raw URL, which is too high + // cardinality to stream. + updateSpanName(kitRootSpan, event.request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK); } kitRootSpan.setAttributes({ @@ -229,7 +236,11 @@ async function instrumentHandle( ? httpHeadersToSpanAttributes(winterCGHeadersToDict(event.request.headers), dataCollectionOptions) : {}), }, - name: routeName, + // With span streaming, span names have to be low cardinality, so we can't fall back to the URL path. + name: + routeId || !client || !hasSpanStreamingEnabled(client) + ? routeName + : event.request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK, }, resolveWithSentry, ); diff --git a/packages/sveltekit/test/server-common/handle.test.ts b/packages/sveltekit/test/server-common/handle.test.ts index 2e930f574d60..0298d4f5d788 100644 --- a/packages/sveltekit/test/server-common/handle.test.ts +++ b/packages/sveltekit/test/server-common/handle.test.ts @@ -98,6 +98,49 @@ beforeEach(() => { vi.clearAllMocks(); }); +describe('sentryHandle with span streaming', () => { + let streamingClient: NodeClient; + + beforeEach(() => { + streamingClient = new NodeClient(getDefaultNodeClientOptions({ tracesSampleRate: 1.0, traceLifecycle: 'stream' })); + setCurrentClient(streamingClient); + streamingClient.init(); + }); + + async function rootSpanNameFor(event: Parameters[0]['event']): Promise { + let rootSpan: Span | undefined; + streamingClient.on('spanEnd', span => { + if (span === getRootSpan(span)) { + rootSpan = span; + } + }); + + await sentryHandle({ handleUnknownRoutes: true })({ event, resolve: async () => mockResponse }); + + return rootSpan && spanToJSON(rootSpan).name; + } + + it('keeps the parameterized route as the span name', async () => { + expect(await rootSpanNameFor(mockEvent())).toEqual('GET /users/[id]'); + }); + + it('names a span without a resolved route after the request method', async () => { + expect(await rootSpanNameFor(mockEvent({ route: { id: null } }))).toEqual('GET'); + }); + + it("replaces SvelteKit's own root span name when no route resolves", async () => { + const kitRootSpan = SentryCore.startInactiveSpan({ name: 'GET http://localhost:3000/users/123' }); + + await sentryHandle({ handleUnknownRoutes: true })({ + event: mockEvent({ route: { id: null }, tracing: { enabled: true, root: kitRootSpan } }), + resolve: async () => mockResponse, + }); + + expect(spanToJSON(kitRootSpan).name).toEqual('GET'); + kitRootSpan.end(); + }); +}); + describe('sentryHandle', () => { describe.each([ // isSync, isError, expectedResponse