Skip to content

Commit b37ecb0

Browse files
chargomeclaude
andcommitted
feat: Emit low-cardinality http.server span names in framework SDKs
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) <noreply@anthropic.com>
1 parent 826d73b commit b37ecb0

19 files changed

Lines changed: 384 additions & 36 deletions

File tree

dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ const SEGMENT_SPAN = {
113113
},
114114
'sentry.segment.name': {
115115
type: 'string',
116-
value: 'GET /test-sentry-span',
116+
value: 'GET',
117117
},
118118
'sentry.segment.name.source': {
119119
type: 'string',
@@ -154,7 +154,7 @@ const SEGMENT_SPAN = {
154154
},
155155
end_timestamp: expect.any(Number),
156156
is_segment: true,
157-
name: 'GET /test-sentry-span',
157+
name: 'GET',
158158
span_id: expect.stringMatching(/^[\da-f]{16}$/),
159159
start_timestamp: expect.any(Number),
160160
status: 'ok',
@@ -200,7 +200,7 @@ test('Sends streamed spans (http.server and manual with Sentry.startSpan)', asyn
200200
},
201201
'sentry.segment.name': {
202202
type: 'string',
203-
value: 'GET /test-sentry-span',
203+
value: 'GET',
204204
},
205205
},
206206
end_timestamp: expect.any(Number),
@@ -230,10 +230,10 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL })
230230
const httpServerSpan = spans.find(span => getSpanOp(span) === 'http.server');
231231
expect(httpServerSpan).toEqual({
232232
...SEGMENT_SPAN,
233-
name: 'GET /test-interop',
233+
name: 'GET',
234234
attributes: {
235235
...SEGMENT_SPAN.attributes,
236-
'sentry.segment.name': { type: 'string', value: 'GET /test-interop' },
236+
'sentry.segment.name': { type: 'string', value: 'GET' },
237237
'url.full': { type: 'string', value: expect.stringMatching(/^http:\/\/localhost:\d+\/test-interop$/) },
238238
'url.path': { type: 'string', value: '/test-interop' },
239239
},
@@ -272,7 +272,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL })
272272
},
273273
'sentry.segment.name': {
274274
type: 'string',
275-
value: 'GET /test-interop',
275+
value: 'GET',
276276
},
277277
},
278278
end_timestamp: expect.any(Number),
@@ -313,7 +313,7 @@ test('OTel span appears as child of Sentry span (interop)', async ({ baseURL })
313313
},
314314
'sentry.segment.name': {
315315
type: 'string',
316-
value: 'GET /test-interop',
316+
value: 'GET',
317317
},
318318
'sentry.deno_tracer': {
319319
type: 'boolean',

packages/astro/src/server/middleware.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
getRootSpan,
1717
getUrlFragment,
1818
getUrlQuery,
19+
hasSpanStreamingEnabled,
20+
HTTP_SPAN_NAME_FALLBACK,
1921
objectify,
2022
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
2123
spanToJSON,
@@ -235,9 +237,16 @@ async function instrumentRequestStartHttpServerSpan(
235237
attributes[URL_QUERY] = filterCollectedUrlQuery(getUrlQuery(ctx.url.search));
236238
attributes[URL_FRAGMENT] = getUrlFragment(ctx.url.hash);
237239

238-
const name = `${method} ${parametrizedRoute || ctx.url.pathname}`;
240+
const transactionName = `${method} ${parametrizedRoute || ctx.url.pathname}`;
239241

240-
isolationScope.setTransactionName(name);
242+
// The scope's transaction name is what error events are grouped by, so it keeps the URL path.
243+
isolationScope.setTransactionName(transactionName);
244+
245+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL path.
246+
const name =
247+
parametrizedRoute || !hasSpanStreamingEnabled(client)
248+
? transactionName
249+
: method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;
241250

242251
const res = await startSpan(
243252
{

packages/astro/test/server/middleware.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Client, Span } from '@sentry/core';
33

44
import * as SentryCore from '@sentry/core';
55
import * as SentryNode from '@sentry/node';
6+
import type { APIContext } from 'astro';
67
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
78
import { handleRequest, interpolateRouteFromUrlAndParams } from '../../src/server/middleware';
89

@@ -133,6 +134,70 @@ describe('sentryMiddleware', () => {
133134
expect(resultFromNext).toStrictEqual(nextResult);
134135
});
135136

137+
describe('with span streaming enabled', () => {
138+
// A full `APIContext` is much larger than these tests need, so build a partial one behind a typed
139+
// helper rather than suppressing the type error at each call site.
140+
function mockApiContext(override: Record<string, unknown>): APIContext {
141+
return { ...DYNAMIC_REQUEST_CONTEXT, ...override } as unknown as APIContext;
142+
}
143+
144+
beforeEach(() => {
145+
vi.spyOn(SentryNode, 'getClient').mockImplementation(
146+
() =>
147+
({
148+
getOptions: () => ({ traceLifecycle: 'stream' }),
149+
getDataCollectionOptions: () => ({
150+
userInfo: false,
151+
cookies: true,
152+
httpHeaders: { request: true, response: true },
153+
httpBodies: [],
154+
urlQueryParams: true,
155+
graphQL: { document: true, variables: true },
156+
genAI: { inputs: true, outputs: true },
157+
databaseQueryData: true,
158+
stackFrameVariables: true,
159+
frameContextLines: 5,
160+
}),
161+
}) as unknown as Client,
162+
);
163+
});
164+
165+
it('names an unparameterized span after the request method', async () => {
166+
const middleware = handleRequest();
167+
const ctx = mockApiContext({
168+
request: { method: 'GET', url: '/a%xx', headers: new Headers() },
169+
url: { pathname: 'a%xx', href: 'http://localhost:1234/a%xx' },
170+
params: {},
171+
});
172+
173+
await middleware(
174+
ctx,
175+
vi.fn(() => nextResult),
176+
);
177+
178+
expect(startSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'GET' }), expect.any(Function));
179+
});
180+
181+
it('keeps the parameterized route as the span name', async () => {
182+
const middleware = handleRequest();
183+
const ctx = mockApiContext({
184+
request: { method: 'GET', url: '/users/123/details', headers: new Headers() },
185+
params: { id: '123' },
186+
url: new URL('https://myDomain.io/users/123/details'),
187+
});
188+
189+
await middleware(
190+
ctx,
191+
vi.fn(() => nextResult),
192+
);
193+
194+
expect(startSpanSpy).toHaveBeenCalledWith(
195+
expect.objectContaining({ name: 'GET /users/[id]/details' }),
196+
expect.any(Function),
197+
);
198+
});
199+
});
200+
136201
it("sets source route if the url couldn't be decoded correctly", async () => {
137202
const middleware = handleRequest();
138203
const ctx = {

packages/bun/src/integrations/bunserver.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
getClient,
77
getUrlFragment,
88
getUrlQuery,
9+
hasSpanStreamingEnabled,
910
httpHeadersToSpanAttributes,
11+
HTTP_SPAN_NAME_FALLBACK,
1012
isURLObjectRelative,
1113
parseStringToURLObject,
1214
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
@@ -246,7 +248,11 @@ function wrapRequestHandler<T extends RouteHandler = RouteHandler>(
246248
{
247249
attributes,
248250
op: 'http.server',
249-
name: `${request.method} ${routeName}`,
251+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL path.
252+
name:
253+
attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client)
254+
? `${request.method} ${routeName}`
255+
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK,
250256
},
251257
async span => {
252258
try {

packages/bun/test/integrations/bunserver.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ describe('Bun Serve Integration', () => {
7575
'http.request.header.user_agent': expect.stringContaining('Bun'),
7676
}),
7777
op: 'http.server',
78-
name: 'GET /users',
78+
name: 'GET',
7979
},
8080
expect.any(Function),
8181
);
@@ -119,7 +119,7 @@ describe('Bun Serve Integration', () => {
119119
'http.request.header.user_agent': expect.stringContaining('Bun'),
120120
}),
121121
op: 'http.server',
122-
name: 'POST /',
122+
name: 'POST',
123123
},
124124
expect.any(Function),
125125
);
@@ -148,7 +148,7 @@ describe('Bun Serve Integration', () => {
148148
'http.request.method': 'QUERY',
149149
}),
150150
op: 'http.server',
151-
name: 'QUERY /search',
151+
name: 'QUERY',
152152
}),
153153
expect.any(Function),
154154
);
@@ -243,7 +243,7 @@ describe('Bun Serve Integration', () => {
243243
'http.request.header.sentry_trace': expect.any(String),
244244
}),
245245
op: 'http.server',
246-
name: 'POST /api/test',
246+
name: 'POST',
247247
}),
248248
expect.any(Function),
249249
);

packages/cloudflare/src/wrapRequestHandlerWithInit.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import type { CfProperties, IncomingRequestCfProperties } from '@cloudflare/workers-types';
2-
import { NETWORK_PROTOCOL_NAME, NETWORK_PROTOCOL_VERSION } from '@sentry/conventions/attributes';
2+
import {
3+
NETWORK_PROTOCOL_NAME,
4+
NETWORK_PROTOCOL_VERSION,
5+
SENTRY_SEGMENT_NAME_SOURCE,
6+
} from '@sentry/conventions/attributes';
37
import {
48
captureException,
59
continueTrace,
610
getHttpSpanDetailsFromUrlObject,
11+
hasSpanStreamingEnabled,
712
httpHeadersToSpanAttributes,
13+
HTTP_SPAN_NAME_FALLBACK,
814
parseStringToURLObject,
915
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1016
setHttpStatus,
@@ -76,14 +82,20 @@ export function wrapRequestHandlerWithInit(
7682
isolationScope.setClient(client);
7783

7884
const urlObject = parseStringToURLObject(request.url);
79-
const [name, attributes] = getHttpSpanDetailsFromUrlObject(
85+
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
8086
urlObject,
8187
'server',
8288
'auto.http.cloudflare',
8389
request,
8490
undefined,
8591
client,
8692
);
93+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
94+
// A `route` source means the name already is (e.g. the `/` path), so it is kept as-is.
95+
const name =
96+
attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !client || !hasSpanStreamingEnabled(client)
97+
? rawName
98+
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;
8799

88100
const contentLength = request.headers.get('content-length');
89101
if (contentLength) {

packages/cloudflare/test/request.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,36 @@ describe('withSentry', () => {
3333
vi.clearAllMocks();
3434
});
3535

36+
describe('with span streaming enabled', () => {
37+
async function segmentSpanNameFor(url: string): Promise<string | undefined> {
38+
let spanName: string | undefined;
39+
40+
await wrapRequestHandler(
41+
{
42+
options: { ...MOCK_OPTIONS, traceLifecycle: 'stream', tracesSampleRate: 1 },
43+
request: new Request(url),
44+
context: createMockExecutionContext(),
45+
},
46+
() => {
47+
// Read the name while the request is in flight: the gate applies at span start.
48+
const activeSpan = SentryCore.getActiveSpan();
49+
spanName = activeSpan ? SentryCore.spanToJSON(SentryCore.getRootSpan(activeSpan)).name : undefined;
50+
return new Response('test');
51+
},
52+
);
53+
54+
return spanName;
55+
}
56+
57+
test('names a span without a resolvable route after the request method', async () => {
58+
expect(await segmentSpanNameFor('https://example.com/users/42')).toBe('GET');
59+
});
60+
61+
test('keeps the root path, which is already low cardinality', async () => {
62+
expect(await segmentSpanNameFor('https://example.com/')).toBe('GET /');
63+
});
64+
});
65+
3666
test('passes through the response from the handler', async () => {
3767
const response = new Response('test');
3868
const result = await wrapRequestHandler(

packages/deno/src/wrap-deno-request-handler.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { CLIENT_ADDRESS, CLIENT_PORT, NETWORK_PROTOCOL_NAME } from '@sentry/conventions/attributes';
1+
import {
2+
CLIENT_ADDRESS,
3+
CLIENT_PORT,
4+
NETWORK_PROTOCOL_NAME,
5+
SENTRY_SEGMENT_NAME_SOURCE,
6+
} from '@sentry/conventions/attributes';
27
import type { Integration, MaxRequestBodySize } from '@sentry/core';
38
import {
49
captureBodyFromWinterCGRequest,
510
captureException,
611
continueTrace,
712
getClient,
813
getHttpSpanDetailsFromUrlObject,
14+
hasSpanStreamingEnabled,
915
httpHeadersToSpanAttributes,
16+
HTTP_SPAN_NAME_FALLBACK,
1017
parseStringToURLObject,
1118
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1219
setHttpStatus,
@@ -60,14 +67,20 @@ export const wrapDenoRequestHandler = <Addr extends Deno.Addr = Deno.Addr>(
6067
}
6168

6269
const urlObject = parseStringToURLObject(request.url);
63-
const [name, attributes] = getHttpSpanDetailsFromUrlObject(
70+
const [rawName, attributes] = getHttpSpanDetailsFromUrlObject(
6471
urlObject,
6572
'server',
6673
'auto.http.deno',
6774
request,
6875
undefined,
6976
client,
7077
);
78+
// With span streaming, span names have to be low cardinality, so we can't fall back to the URL.
79+
// A `route` source means the name already is (e.g. the `/` path), so it is kept as-is.
80+
const name =
81+
attributes[SENTRY_SEGMENT_NAME_SOURCE] === 'route' || !hasSpanStreamingEnabled(client)
82+
? rawName
83+
: request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK;
7184

7285
const contentLength = request.headers.get('content-length');
7386
assignIfSet(attributes, 'http.request.body.size', contentLength && parseInt(contentLength, 10));

packages/elysia/src/withElysia.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
captureException,
66
continueTrace,
77
getActiveSpan,
8+
getClient,
89
getIsolationScope,
910
getRootSpan,
1011
getTraceData,
@@ -17,6 +18,8 @@ import {
1718
winterCGRequestToRequestData,
1819
withIsolationScope,
1920
filterCollectedUrl,
21+
hasSpanStreamingEnabled,
22+
HTTP_SPAN_NAME_FALLBACK,
2023
} from '@sentry/core';
2124
import type { AnyElysia, Elysia, ErrorContext, TraceHandler, TraceListener } from 'elysia';
2225

@@ -200,10 +203,16 @@ export function withElysia<T extends AnyElysia>(app: T, options: ElysiaHandlerOp
200203
baggage: request.headers.get('baggage'),
201204
},
202205
() => {
206+
const client = getClient();
203207
return startSpanManual(
204208
{
205209
op: 'http.server',
206-
name: `${request.method} ${new URL(request.url).pathname}`,
210+
// With span streaming, span names have to be low cardinality, so we can't fall back to the
211+
// URL path. `updateRouteTransactionName` renames the span once Elysia resolves the route.
212+
name:
213+
client && hasSpanStreamingEnabled(client)
214+
? request.method?.toUpperCase() || HTTP_SPAN_NAME_FALLBACK
215+
: `${request.method} ${new URL(request.url).pathname}`,
207216
attributes: {
208217
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ELYSIA_ORIGIN,
209218
[SENTRY_SEGMENT_NAME_SOURCE]: 'url',

0 commit comments

Comments
 (0)