From b9abc0698b7d5f31885a0b9ef360b676a008a655 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 11 Sep 2026 14:41:43 +0200 Subject: [PATCH 1/2] feat(sveltekit): Emit low cardinality `function` spans --- MIGRATION.md | 13 +++ .../tests/performance.server.test.ts | 9 +- .../tests/performance.server.test.ts | 9 +- .../sveltekit-3/tests/tracing.server.test.ts | 4 +- packages/sveltekit/src/client/load.ts | 22 ++++- .../integrations/svelteKitSpans.ts | 6 +- packages/sveltekit/src/server-common/load.ts | 32 ++++++- .../src/server-common/serverRoute.ts | 28 +++++- packages/sveltekit/test/client/load.test.ts | 67 ++++++++++++++- .../integrations/svelteKitSpans.test.ts | 13 +++ .../sveltekit/test/server-common/load.test.ts | 85 ++++++++++++++++++- .../test/server-common/serverRoute.test.ts | 59 ++++++++++++- 12 files changed, 329 insertions(+), 18 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index fd236135ae68..6a88bbb10e3c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1003,6 +1003,7 @@ The following span names were adjusted: | `router` | Framework-specific, sometimes containing the raw URL | `/users/123`, `SvelteKit Route Change` | The span's `http.route`, or `Router` if the SDK has none | `/users/:id`, `Router` | | `handler` | Framework-specific, often carrying the request method | `GET /users/:id`, `route-handler`, `getUser` | The span's `http.route`, or `Request handler` if the SDK has none | `/users/:id`, `Request handler` | | `function` (Angular `TraceMethod`) | The decorator's `name` option in angle brackets | ``, `` | The decorator's `name` option, or `Function execution` if it has none | `Login.ngOnInit`, `getUsers`, `Function execution` | +| `function` (SvelteKit) | The route the wrapped function ran for, or the raw URL path if the SDK couldn't resolve one | `/users/[id]`, `/users/123`, `GET /api/users/[id]` | The name of the wrapped function | `load`, `GET` | | `function.gcp` | The request method and path for HTTP functions, otherwise the trigger's event or trigger type | `POST /users`, `google.pubsub.topic.publish`, `firebase.function.http.request` | The function name, or `Serverless function execution` if the SDK cannot resolve one | `myFunction`, `Serverless function execution` | | `function.aws` | The Lambda function name | `my-function` | Unchanged, except that the SDK now falls back to `Serverless function execution` if it cannot resolve the function name | `my-function`, `Serverless function execution` | | `graphql` | The graphql phase and, for operations, the operation name | `query GetUser`, `graphql.parse`, `graphql.resolve user.0.name` | The operation type, or the processing type where there is none | `GraphQL query`, `GraphQL parse`, `GraphQL resolve` | @@ -1041,6 +1042,18 @@ function name on `faas.name`, the request URL on `url.full`, and the invocation rather than `custom`, matching the other FaaS spans: the name comes from the function, not from the user. This applies in both trace lifecycles. +#### SvelteKit function spans + +The spans around `wrapLoadWithSentry`, `wrapServerLoadWithSentry` and `wrapServerRouteWithSentry` are +named after the function they wrap (`load`, or the HTTP method a `+server.js` route handler is exported +as) rather than after the route it ran for. The route stays on `http.route` (`url.template` for the +client-side universal load span) and the request path on `url.path`, so `ignoreSpans` and `tracesSampler` +rules that matched these names have to match those attributes instead. + +Their span description is unchanged: each span carries a `sentry.description` attribute holding the +name it had before. The same applies to the spans SvelteKit's own tracing emits (`sveltekit.load`, +`sveltekit.resolve`, `sveltekit.form_action`, ...), which the SDK marks as `function` spans. + #### Filtering and sampling When span streaming is enabled (i.e. by default) `ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name: diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts index 2bf341ee709b..2fba83d177a4 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/tests/performance.server.test.ts @@ -23,9 +23,14 @@ test('server pageload request span has nested request span for sub request', asy expect.arrayContaining([ // load span where the server load function initiates the sub request: expect.objectContaining({ - name: '/server-load-fetch', + name: 'load', is_segment: false, - attributes: expect.objectContaining({ 'sentry.op': { value: 'function', type: 'string' } }), + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'load', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + 'sentry.description': { value: '/server-load-fetch', type: 'string' }, + }), }), // sub request span: expect.objectContaining({ diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts index 1818cb311dd7..5f91f781e888 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/tests/performance.server.test.ts @@ -19,9 +19,14 @@ test('server pageload request span has nested request span for sub request', asy expect.arrayContaining([ // load span where the server load function initiates the sub request: expect.objectContaining({ - name: '/server-load-fetch', + name: 'load', is_segment: false, - attributes: expect.objectContaining({ 'sentry.op': { value: 'function', type: 'string' } }), + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'code.function.name': { value: 'load', type: 'string' }, + 'http.route': { value: '/server-load-fetch', type: 'string' }, + 'sentry.description': { value: '/server-load-fetch', type: 'string' }, + }), }), // sub request span: expect.objectContaining({ diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts index 0ca667dda716..315111dd8734 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts @@ -190,11 +190,13 @@ test('server trace for a `QUERY` server route includes the wrapped route handler expect(getSegmentChildSpans(serverTraceSpans, serverSpan)).toEqual( expect.arrayContaining([ expect.objectContaining({ - name: 'QUERY /query-server-route', + name: 'QUERY', attributes: expect.objectContaining({ 'sentry.origin': { value: 'auto.function.sveltekit', type: 'string' }, 'code.function.name': { value: 'QUERY', type: 'string' }, 'http.request.method': { value: 'QUERY', type: 'string' }, + 'http.route': { value: '/query-server-route', type: 'string' }, + 'sentry.description': { value: 'QUERY /query-server-route', type: 'string' }, }), }), ]), diff --git a/packages/sveltekit/src/client/load.ts b/packages/sveltekit/src/client/load.ts index b7d9cf9a70b8..4ddeaad8ffef 100644 --- a/packages/sveltekit/src/client/load.ts +++ b/packages/sveltekit/src/client/load.ts @@ -1,11 +1,20 @@ import { addNonEnumerableProperty, + getClient, handleCallbackErrors, + hasSpanStreamingEnabled, objectify, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, } from '@sentry/core'; import { startSpan } from '@sentry/core/browser'; -import { SENTRY_SEGMENT_NAME_SOURCE, CODE_FUNCTION_NAME, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + SENTRY_SEGMENT_NAME_SOURCE, + CODE_FUNCTION_NAME, + SENTRY_DESCRIPTION, + SENTRY_OP, + URL_PATH, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; import { captureException } from '@sentry/svelte'; import type { LoadEvent } from '@sveltejs/kit'; @@ -74,16 +83,25 @@ export function wrapLoadWithSentry any>(origLoad: T) addNonEnumerableProperty(patchedEvent, '__sentry_wrapped__', true); const routeId = getRouteId(event); + const routeOrPathname = routeId ? routeId : event.url.pathname; + + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); return startSpan( { + // With span streaming, span names have to be low cardinality, so we use the function name. + name: hasSpanStreaming ? 'load' : routeOrPathname, attributes: { [SENTRY_OP]: FUNCTION, [CODE_FUNCTION_NAME]: 'load', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: routeId ? 'route' : 'url', + [URL_PATH]: event.url.pathname, + ...(routeId && { [URL_TEMPLATE]: routeId }), + // Relay infers the description from `code.function.name`, which would drop the route. + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: routeOrPathname }), }, - name: routeId ? routeId : event.url.pathname, }, () => handleCallbackErrors(() => wrappingTarget.apply(thisArg, [patchedEvent]), sendErrorToSentry), ); diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts index 050892a920c7..50e1701b5bef 100644 --- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts +++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts @@ -1,6 +1,6 @@ import type { Integration, SpanJSON, SpanOrigin, StreamedSpanJSON } from '@sentry/core'; import { safeSetSpanJSONAttributes, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { SENTRY_DESCRIPTION, SENTRY_OP } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; /** @@ -64,7 +64,9 @@ export function _enhanceKitSpanStreamed(span: StreamedSpanJSON): void { const previousOrigin = span.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined; - safeSetSpanJSONAttributes(span, { [SENTRY_OP]: FUNCTION }); + // Kit's span names carry no `code.function.name`, so without an explicit description, these spans + // would be described by the `function` op's static fallback instead of the operation they ran. + safeSetSpanJSONAttributes(span, { [SENTRY_OP]: FUNCTION, [SENTRY_DESCRIPTION]: span.name }); if (previousOrigin === 'manual') { // `safeSetSpanJSONAttributes` skips existing keys, so overwrite the 'manual' sentinel directly. diff --git a/packages/sveltekit/src/server-common/load.ts b/packages/sveltekit/src/server-common/load.ts index 64106c47d157..7a35f11618d3 100644 --- a/packages/sveltekit/src/server-common/load.ts +++ b/packages/sveltekit/src/server-common/load.ts @@ -1,10 +1,19 @@ -import { addNonEnumerableProperty, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { + addNonEnumerableProperty, + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, +} from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import { SENTRY_SEGMENT_NAME_SOURCE, CODE_FUNCTION_NAME, HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_DESCRIPTION, SENTRY_OP, + URL_PATH, } from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; import type { LoadEvent, ServerLoadEvent } from '@sveltejs/kit'; @@ -37,18 +46,26 @@ export function wrapLoadWithSentry any>(origLoad: T) addNonEnumerableProperty(event, '__sentry_wrapped__', true); const routeId = getRouteId(event); + const routeOrPathname = routeId ? routeId : event.url.pathname; + + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); try { // We need to await before returning, otherwise we won't catch any errors thrown by the load function return await startSpan( { + name: hasSpanStreaming ? 'load' : routeOrPathname, attributes: { [SENTRY_OP]: FUNCTION, [CODE_FUNCTION_NAME]: 'load', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: routeId ? 'route' : 'url', + [URL_PATH]: event.url.pathname, + ...(routeId && { [HTTP_ROUTE]: routeId }), + // Relay infers the description from `code.function.name`, which would drop the route. + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: routeOrPathname }), }, - name: routeId ? routeId : event.url.pathname, }, () => wrappingTarget.apply(thisArg, args), ); @@ -101,6 +118,10 @@ export function wrapServerLoadWithSentry any>(origSe // server `load` function's data on every route change. We use `getRouteId` which uses // SvelteKit 2's `untrack` when available, otherwise getOwnPropertyDescriptor for 1.x. const routeId = getRouteId(event); + const routeOrPathname = routeId ? routeId : event.url.pathname; + + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); try { // We need to await before returning, otherwise we won't catch any errors thrown by the load function @@ -112,8 +133,13 @@ export function wrapServerLoadWithSentry any>(origSe [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit.server', [SENTRY_SEGMENT_NAME_SOURCE]: routeId ? 'route' : 'url', [HTTP_REQUEST_METHOD]: event.request.method, + [URL_PATH]: event.url.pathname, + ...(routeId && { [HTTP_ROUTE]: routeId }), + // Relay infers the description from `code.function.name`, which would drop the route. + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: routeOrPathname }), }, - name: routeId ? routeId : event.url.pathname, + // With span streaming, span names have to be low cardinality, so we use the function name. + name: hasSpanStreaming ? 'load' : routeOrPathname, }, () => wrappingTarget.apply(thisArg, args), ); diff --git a/packages/sveltekit/src/server-common/serverRoute.ts b/packages/sveltekit/src/server-common/serverRoute.ts index 268556d0b96b..2d0c6ef12781 100644 --- a/packages/sveltekit/src/server-common/serverRoute.ts +++ b/packages/sveltekit/src/server-common/serverRoute.ts @@ -1,6 +1,19 @@ -import { addNonEnumerableProperty, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { + addNonEnumerableProperty, + FUNCTION_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startSpan, +} from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; -import { CODE_FUNCTION_NAME, HTTP_REQUEST_METHOD, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + CODE_FUNCTION_NAME, + HTTP_REQUEST_METHOD, + HTTP_ROUTE, + SENTRY_DESCRIPTION, + SENTRY_OP, +} from '@sentry/conventions/attributes'; import { FUNCTION } from '@sentry/conventions/op'; import type { RequestEvent } from '@sveltejs/kit'; import { sendErrorToSentry } from './utils'; @@ -42,18 +55,27 @@ export function wrapServerRouteWithSentry( const routeId = event.route?.id; const httpMethod = event.request.method; + const methodAndRoute = `${httpMethod} ${routeId || 'Server Route'}`; addNonEnumerableProperty(event, '__sentry_wrapped__', true); + const client = getClient(); + const hasSpanStreaming = !!client && hasSpanStreamingEnabled(client); + try { return await startSpan( { - name: `${httpMethod} ${routeId || 'Server Route'}`, + // With span streaming, span names have to be low cardinality, so we use the handler's + // function name, which for `+server.js` routes is the HTTP method it is exported as. + name: hasSpanStreaming ? httpMethod || FUNCTION_SPAN_NAME_FALLBACK : methodAndRoute, attributes: { [SENTRY_OP]: FUNCTION, [CODE_FUNCTION_NAME]: httpMethod, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [HTTP_REQUEST_METHOD]: httpMethod, + ...(routeId && { [HTTP_ROUTE]: routeId }), + // Relay infers the description from `code.function.name`, which would drop the route. + ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: methodAndRoute }), }, onlyIfParent: true, }, diff --git a/packages/sveltekit/test/client/load.test.ts b/packages/sveltekit/test/client/load.test.ts index d7b7b3a5f142..616326565f46 100644 --- a/packages/sveltekit/test/client/load.test.ts +++ b/packages/sveltekit/test/client/load.test.ts @@ -1,9 +1,11 @@ import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import type { Client } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; import * as SentrySvelte from '@sentry/svelte'; import type { Load } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { wrapLoadWithSentry } from '../../src/client/load'; const mockCaptureException = vi.spyOn(SentrySvelte, 'captureException').mockImplementation(() => 'xx'); @@ -108,6 +110,8 @@ describe('wrapLoadWithSentry', () => { 'code.function.name': 'load', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'url.path': '/users/123', + 'url.template': '/users/[id]', }, name: '/users/[id]', }, @@ -136,6 +140,7 @@ describe('wrapLoadWithSentry', () => { 'code.function.name': 'load', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + 'url.path': '/users/123', }, name: '/users/123', }, @@ -171,6 +176,66 @@ describe('wrapLoadWithSentry', () => { expect.any(Function), ); }); + + describe('with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockImplementation( + () => ({ getOptions: () => ({ traceLifecycle: 'stream' }) }) as unknown as Client, + ); + }); + + afterEach(() => { + vi.mocked(SentryCore.getClient).mockRestore(); + }); + + // `MOCK_LOAD_ARGS.route` is mutated by the tests above, so build a fresh event here. + const getLoadArgs = (): any => ({ + params: { id: '123' }, + route: { id: '/users/[id]' }, + url: new URL('http://localhost:3000/users/123'), + }); + + it('names the span after the load function and keeps the route in the description', async () => { + const wrappedLoad = wrapLoadWithSentry(async () => ({})); + await wrappedLoad(getLoadArgs()); + + expect(mockStartSpan).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'load', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'url.path': '/users/123', + 'url.template': '/users/[id]', + 'sentry.description': '/users/[id]', + }, + name: 'load', + }, + expect.any(Function), + ); + }); + + it("keeps the raw URL as description if `event.route.id` isn't available", async () => { + const wrappedLoad = wrapLoadWithSentry(async () => ({})); + await wrappedLoad({ ...getLoadArgs(), route: {} }); + + expect(mockStartSpan).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'load', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + 'url.path': '/users/123', + 'sentry.description': '/users/123', + }, + name: 'load', + }, + expect.any(Function), + ); + }); + }); }); it('adds an exception mechanism', async () => { diff --git a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts index 347c9f8fcf53..3412e9376b25 100644 --- a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts +++ b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts @@ -204,6 +204,7 @@ describe('svelteKitSpansIntegration', () => { expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBe(op); expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe(origin); + expect(span.attributes?.['sentry.description']).toBe(spanName); }); it("doesn't change spans from other origins", () => { @@ -213,6 +214,7 @@ describe('svelteKitSpansIntegration', () => { expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP]).toBeUndefined(); expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBeUndefined(); + expect(span.attributes?.['sentry.description']).toBeUndefined(); }); it("doesn't overwrite the sveltekit.handle.root span", () => { @@ -261,6 +263,17 @@ describe('svelteKitSpansIntegration', () => { expect(span.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]).toBe('auto.custom.origin'); }); + it("doesn't overwrite an already set description", () => { + const span = makeStreamedSpan({ + name: 'sveltekit.load', + attributes: { 'sentry.description': 'my custom description' }, + }); + + _enhanceKitSpanStreamed(span); + + expect(span.attributes?.['sentry.description']).toBe('my custom description'); + }); + it('overwrites previously set "manual" origins on sveltekit spans', () => { const span = makeStreamedSpan({ name: 'sveltekit.resolve', diff --git a/packages/sveltekit/test/server-common/load.test.ts b/packages/sveltekit/test/server-common/load.test.ts index 9fe29889226c..4dc513b778c7 100644 --- a/packages/sveltekit/test/server-common/load.test.ts +++ b/packages/sveltekit/test/server-common/load.test.ts @@ -1,5 +1,5 @@ import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; -import type { Event } from '@sentry/core'; +import type { Client, Event } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { NodeClient, setCurrentClient } from '@sentry/node'; @@ -170,6 +170,8 @@ describe('wrapLoadWithSentry calls `startSpan`', () => { 'code.function.name': 'load', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'url.path': '/users/123', + 'http.route': '/users/[id]', }, name: '/users/[id]', }, @@ -189,6 +191,7 @@ describe('wrapLoadWithSentry calls `startSpan`', () => { 'code.function.name': 'load', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + 'url.path': '/users/123', }, name: '/users/123', }, @@ -259,6 +262,7 @@ describe('wrapServerLoadWithSentry calls `startSpan`', () => { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', 'code.function.name': 'load', 'http.request.method': 'GET', + 'url.path': '/users/123', 'sentry.sample_rate': 1, }, op: 'function', @@ -329,3 +333,82 @@ describe('wrapServerLoadWithSentry calls `startSpan`', () => { ); }); }); + +describe('with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockImplementation( + () => ({ getOptions: () => ({ traceLifecycle: 'stream' }) }) as unknown as Client, + ); + }); + + afterEach(() => { + vi.mocked(SentryCore.getClient).mockRestore(); + }); + + async function load({ params }): Promise> { + return { post: params.id }; + } + + it('names the universal load span after the load function and keeps the route in the description', async () => { + const wrappedLoad = wrapLoadWithSentry(load); + await wrappedLoad(getLoadArgs()); + + expect(mockStartSpan).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'load', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'url.path': '/users/123', + 'http.route': '/users/[id]', + 'sentry.description': '/users/[id]', + }, + name: 'load', + }, + expect.any(Function), + ); + }); + + it('keeps the raw url as description if `event.route.id` is not available', async () => { + const wrappedLoad = wrapLoadWithSentry(load); + await wrappedLoad(getLoadArgsWithoutRoute()); + + expect(mockStartSpan).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'load', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + 'url.path': '/users/123', + 'sentry.description': '/users/123', + }, + name: 'load', + }, + expect.any(Function), + ); + }); + + it('names the server load span after the load function and keeps the route in the description', async () => { + const wrappedLoad = wrapServerLoadWithSentry(load); + await wrappedLoad(getServerOnlyArgs()); + + expect(mockStartSpan).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'load', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit.server', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'http.request.method': 'GET', + 'url.path': '/users/123', + 'http.route': '/users/[id]', + 'sentry.description': '/users/[id]', + }, + name: 'load', + }, + expect.any(Function), + ); + }); +}); diff --git a/packages/sveltekit/test/server-common/serverRoute.test.ts b/packages/sveltekit/test/server-common/serverRoute.test.ts index c2a9adf84846..72efa31c1a7e 100644 --- a/packages/sveltekit/test/server-common/serverRoute.test.ts +++ b/packages/sveltekit/test/server-common/serverRoute.test.ts @@ -1,7 +1,8 @@ +import type { Client } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import type { NumericRange, RequestEvent } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -40,6 +41,7 @@ describe('wrapServerRouteWithSentry', () => { 'code.function.name': 'GET', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', 'http.request.method': 'GET', + 'http.route': '/api/users/:id', }, name: 'GET /api/users/:id', onlyIfParent: true, @@ -71,6 +73,61 @@ describe('wrapServerRouteWithSentry', () => { expect(originalRouteHandler).toHaveBeenCalledTimes(1); }); + + describe('with span streaming enabled', () => { + beforeEach(() => { + vi.spyOn(SentryCore, 'getClient').mockImplementation( + () => ({ getOptions: () => ({ traceLifecycle: 'stream' }) }) as unknown as Client, + ); + }); + + afterEach(() => { + vi.mocked(SentryCore.getClient).mockRestore(); + }); + + it('names the span after the handler function and keeps the route in the description', () => { + const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler); + + wrappedRouteHandler(getRequestEventMock()); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'GET', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + 'http.request.method': 'GET', + 'http.route': '/api/users/:id', + 'sentry.description': 'GET /api/users/:id', + }, + name: 'GET', + onlyIfParent: true, + }, + expect.any(Function), + ); + }); + + it('keeps the generic description if the route id is not available', () => { + const wrappedRouteHandler = wrapServerRouteWithSentry(originalRouteHandler); + + wrappedRouteHandler({ ...getRequestEventMock(), route: undefined } as unknown as RequestEvent); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'function', + 'code.function.name': 'GET', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', + 'http.request.method': 'GET', + 'sentry.description': 'GET Server Route', + }, + name: 'GET', + onlyIfParent: true, + }, + expect.any(Function), + ); + }); + }); }); const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException'); From 5c67b3e85e14d1f679c7e0f9bc4bc99fade37c0f Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 11 Sep 2026 17:22:58 +0200 Subject: [PATCH 2/2] review suggestion --- packages/sveltekit/src/server-common/load.ts | 8 ++++---- packages/sveltekit/src/server-common/serverRoute.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sveltekit/src/server-common/load.ts b/packages/sveltekit/src/server-common/load.ts index 7a35f11618d3..63108d7c3ae6 100644 --- a/packages/sveltekit/src/server-common/load.ts +++ b/packages/sveltekit/src/server-common/load.ts @@ -45,7 +45,7 @@ export function wrapLoadWithSentry any>(origLoad: T) addNonEnumerableProperty(event, '__sentry_wrapped__', true); - const routeId = getRouteId(event); + const routeId = getRouteId(event) ?? undefined; const routeOrPathname = routeId ? routeId : event.url.pathname; const client = getClient(); @@ -62,7 +62,7 @@ export function wrapLoadWithSentry any>(origLoad: T) [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [SENTRY_SEGMENT_NAME_SOURCE]: routeId ? 'route' : 'url', [URL_PATH]: event.url.pathname, - ...(routeId && { [HTTP_ROUTE]: routeId }), + [HTTP_ROUTE]: routeId, // Relay infers the description from `code.function.name`, which would drop the route. ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: routeOrPathname }), }, @@ -117,7 +117,7 @@ export function wrapServerLoadWithSentry any>(origSe // Accessing any member of `event.route` causes SvelteKit to invalidate the // server `load` function's data on every route change. We use `getRouteId` which uses // SvelteKit 2's `untrack` when available, otherwise getOwnPropertyDescriptor for 1.x. - const routeId = getRouteId(event); + const routeId = getRouteId(event) ?? undefined; const routeOrPathname = routeId ? routeId : event.url.pathname; const client = getClient(); @@ -134,7 +134,7 @@ export function wrapServerLoadWithSentry any>(origSe [SENTRY_SEGMENT_NAME_SOURCE]: routeId ? 'route' : 'url', [HTTP_REQUEST_METHOD]: event.request.method, [URL_PATH]: event.url.pathname, - ...(routeId && { [HTTP_ROUTE]: routeId }), + [HTTP_ROUTE]: routeId, // Relay infers the description from `code.function.name`, which would drop the route. ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: routeOrPathname }), }, diff --git a/packages/sveltekit/src/server-common/serverRoute.ts b/packages/sveltekit/src/server-common/serverRoute.ts index 2d0c6ef12781..97f06bcbaf5c 100644 --- a/packages/sveltekit/src/server-common/serverRoute.ts +++ b/packages/sveltekit/src/server-common/serverRoute.ts @@ -53,7 +53,7 @@ export function wrapServerRouteWithSentry( return wrappingTarget.apply(thisArg, args); } - const routeId = event.route?.id; + const routeId = event.route?.id ?? undefined; const httpMethod = event.request.method; const methodAndRoute = `${httpMethod} ${routeId || 'Server Route'}`; @@ -73,7 +73,7 @@ export function wrapServerRouteWithSentry( [CODE_FUNCTION_NAME]: httpMethod, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.sveltekit', [HTTP_REQUEST_METHOD]: httpMethod, - ...(routeId && { [HTTP_ROUTE]: routeId }), + [HTTP_ROUTE]: routeId, // Relay infers the description from `code.function.name`, which would drop the route. ...(hasSpanStreaming && { [SENTRY_DESCRIPTION]: methodAndRoute }), },