Skip to content

Commit 595e940

Browse files
chargomeclaude
andauthored
feat(sveltekit): Detect SvelteKit 3 native tracing from the Vite plugin (#21580)
As of SvelteKit 3, native server-side tracing is no longer configured in `svelte.config.js` — it's passed to the `sveltekit()` Vite plugin instead. Our auto-instrumentation plugin derived `onlyInstrumentClient` from `svelte.config.js`, so under Kit 3 it could no longer tell that native tracing was enabled and kept injecting its own server-side load instrumentation. The result was duplicate spans (the SDK's `function.sveltekit.server.load` stacked on top of Kit's native `sveltekit.load`). This PR now reads the resolved SvelteKit config that the `sveltekit()` plugin exposes via its plugin `api.options` and treats it as the source of truth for Kit 3. When native server tracing is enabled there, we skip our own server-load instrumentation. On older SvelteKit versions that don't expose the config this way, detection falls back to the existing `svelte.config.js`-derived value — so nothing changes for Kit 2. ref #21502 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 667cb71 commit 595e940

6 files changed

Lines changed: 137 additions & 25 deletions

File tree

‎dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { expect, test } from '@playwright/test';
22
import { waitForError } from '@sentry-internal/test-utils';
33

4-
// FIXME(sveltekit-3): server-side error capture works, but stack-frame function names are
5-
// `load$1` (not `load`) and the request URL scheme is `https` (not `http`). Root cause: the SDK's
6-
// Vite plugin reads native-tracing config from `svelte.config.js`, which Kit 3 removed, so it still
7-
// injects manual load instrumentation (which Rolldown renames to `load$1`). Unskip once the SDK
8-
// detects Kit 3 native tracing from the Vite plugin options. See repros + tracking notes.
9-
test.describe.skip('server-side errors', () => {
10-
test('captures universal load error', async ({ page }) => {
4+
test.describe('server-side errors', () => {
5+
// FIXME(sveltekit-3): the universal load function's frame is reported as `load$1` (not `load`)
6+
// because the SDK still wraps universal `+page.ts` load in the server build. Unlike server-only
7+
// load, this isn't suppressed by native-tracing detection: the wrapper is skipped via
8+
// `config.build.ssr`, which is unreliable under Vite 8's Environment API. Unskip once the SDK
9+
// detects the server environment via the Vite Environment API.
10+
test.skip('captures universal load error', async ({ page }) => {
1111
const errorEventPromise = waitForError('sveltekit-3', errorEvent => {
1212
return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (server)';
1313
});
@@ -31,8 +31,7 @@ test.describe.skip('server-side errors', () => {
3131
'user-agent': expect.any(String),
3232
}),
3333
method: 'GET',
34-
// SvelteKit's node adapter defaults to https in the protocol even if served on http
35-
url: 'http://localhost:3030/universal-load-error',
34+
url: 'https://localhost:3030/universal-load-error',
3635
});
3736
});
3837

@@ -60,7 +59,7 @@ test.describe.skip('server-side errors', () => {
6059
'user-agent': expect.any(String),
6160
}),
6261
method: 'GET',
63-
url: 'http://localhost:3030/server-load-error',
62+
url: 'https://localhost:3030/server-load-error',
6463
});
6564
});
6665

@@ -90,7 +89,7 @@ test.describe.skip('server-side errors', () => {
9089
accept: expect.any(String),
9190
}),
9291
method: 'GET',
93-
url: 'http://localhost:3030/server-route-error',
92+
url: 'https://localhost:3030/server-route-error',
9493
});
9594
});
9695
});

‎dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { expect, test } from '@playwright/test';
22
import { waitForTransaction } from '@sentry-internal/test-utils';
33
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/sveltekit';
44

5-
// FIXME(sveltekit-3): a duplicate server-load span appears (the SDK's `function.sveltekit.server.load`
6-
// on top of Kit 3's native `sveltekit.load`), because the SDK still injects manual load
7-
// instrumentation when it can't detect native tracing (config moved out of `svelte.config.js` in
8-
// Kit 3). Unskip once the SDK suppresses its manual load span under native tracing.
9-
test.skip('server pageload request span has nested request span for sub request', async ({ page }) => {
5+
test('server pageload request span has nested request span for sub request', async ({ page }) => {
106
const serverTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => {
117
return txnEvent?.transaction === 'GET /server-load-fetch';
128
});
@@ -85,14 +81,14 @@ test.skip('server pageload request span has nested request span for sub request'
8581
data: expect.objectContaining({
8682
'http.method': 'GET',
8783
'http.route': '/api/users',
88-
'http.url': 'http://localhost:3030/api/users',
84+
'http.url': 'https://localhost:3030/api/users',
8985
'sentry.op': 'http.server',
9086
'sentry.origin': 'auto.http.sveltekit',
9187
'sentry.source': 'route',
9288
'sveltekit.is_data_request': false,
9389
'sveltekit.is_sub_request': true,
9490
'sveltekit.tracing.original_name': 'sveltekit.handle.root',
95-
url: 'http://localhost:3030/api/users',
91+
url: 'https://localhost:3030/api/users',
9692
}),
9793
description: 'GET /api/users',
9894
op: 'http.server',

‎packages/sveltekit/src/vite/autoInstrument.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as fs from 'fs';
44
import * as path from 'path';
55
import type { Plugin } from 'vite';
66
import { WRAPPED_MODULE_SUFFIX } from '../common/utils';
7+
import type { BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig';
78

89
const AcornParser = acorn.Parser.extend(tsPlugin());
910

@@ -45,6 +46,11 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio
4546

4647
let isServerBuild: boolean | undefined = undefined;
4748

49+
// Whether we should skip server-side load instrumentation because SvelteKit's native server
50+
// tracing is enabled. Initialized from the option (derived from `svelte.config.js`), but may be
51+
// flipped to `true` in `configResolved` once we can read SvelteKit's resolved config (see below).
52+
let onlyInstrumentClient = options.onlyInstrumentClient;
53+
4854
return {
4955
name: 'sentry-auto-instrumentation',
5056
// This plugin needs to run as early as possible, before the SvelteKit plugin virtualizes all paths and ids
@@ -56,10 +62,20 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio
5662
// `config.build.ssr` is `true` for that first build and `false` in the other ones.
5763
// Hence we can use it as a switch to upload source maps only once in main build.
5864
isServerBuild = !!config.build.ssr;
65+
66+
// As of SvelteKit 3, the native server-tracing config is no longer read from
67+
// `svelte.config.js` (so the `onlyInstrumentClient` option, derived from it, is `false`).
68+
// It's passed to the `sveltekit()` Vite plugin instead, which exposes the resolved config
69+
// via its plugin `api.options`. Reading it here lets us reliably detect native tracing
70+
// regardless of SvelteKit version. When it's enabled, we must not add our own server-side
71+
// load instrumentation, otherwise we'd emit duplicate spans on top of SvelteKit's.
72+
if (!onlyInstrumentClient && isNativeServerTracingEnabled(config.plugins)) {
73+
onlyInstrumentClient = true;
74+
}
5975
},
6076

6177
async load(id) {
62-
if (options.onlyInstrumentClient && isServerBuild) {
78+
if (onlyInstrumentClient && isServerBuild) {
6379
return null;
6480
}
6581

@@ -74,7 +90,7 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio
7490
return getWrapperCode('wrapLoadWithSentry', `${id}${WRAPPED_MODULE_SUFFIX}`);
7591
}
7692

77-
if (options.onlyInstrumentClient) {
93+
if (onlyInstrumentClient) {
7894
// Now that we've checked universal files, we can early return and avoid further
7995
// regexp checks below for server-only files, in case `onlyInstrumentClient` is `true`.
8096
return null;
@@ -96,6 +112,30 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio
96112
};
97113
}
98114

115+
/**
116+
* Detects whether SvelteKit's native server-side tracing is enabled by reading the resolved
117+
* SvelteKit config that the `sveltekit()` Vite plugin exposes via its plugin `api.options`.
118+
*
119+
* This is the source of truth as of SvelteKit 3, where the config moved out of `svelte.config.js`
120+
* and into the `sveltekit()` plugin. On older SvelteKit versions that don't expose the config this
121+
* way, it simply returns `false` and we fall back to the `svelte.config.js`-derived value.
122+
*/
123+
function isNativeServerTracingEnabled(plugins: readonly Plugin[] | undefined): boolean {
124+
if (!plugins) {
125+
return false;
126+
}
127+
128+
for (const plugin of plugins) {
129+
const options = (plugin?.api as { options?: BackwardsForwardsCompatibleSvelteConfig } | undefined)?.options;
130+
// SvelteKit 3 (>= next.8) promoted `tracing` out of `experimental`; older versions nest it there.
131+
if (options?.kit?.tracing?.server || options?.kit?.experimental?.tracing?.server) {
132+
return true;
133+
}
134+
}
135+
136+
return false;
137+
}
138+
99139
/**
100140
* We only want to apply our wrapper to files that
101141
*

‎packages/sveltekit/src/vite/sentryVitePlugins.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
3232
const sentryPlugins: Plugin[] = [];
3333

3434
if (mergedOptions.autoInstrument) {
35-
// TODO: Once tracing is promoted stable, we need to adjust this check!
36-
const kitTracingEnabled = !!svelteConfig.kit?.experimental?.tracing?.server;
35+
// SvelteKit 3 (>= next.8) promoted `tracing` out of `experimental`; older versions nest it there.
36+
const kitTracingEnabled = !!(svelteConfig.kit?.tracing?.server || svelteConfig.kit?.experimental?.tracing?.server);
3737

3838
const pluginOptions: AutoInstrumentSelection = {
3939
load: true,

‎packages/sveltekit/src/vite/svelteConfig.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ export type SvelteKitTracingConfig = {
1515
};
1616

1717
/**
18-
* Experimental tracing and instrumentation config is available @since 2.31.0
19-
* // TODO: Once instrumentation and tracing is promoted stable, adjust this type!s
18+
* The location of SvelteKit's native tracing config differs by version:
19+
* - SvelteKit 2.31+ and early Kit 3 prereleases nest it under `kit.experimental.tracing`
20+
* - SvelteKit 3 (>= 3.0.0-next.8) promoted it to `kit.tracing`
21+
* We type (and read) both so detection works across the supported peer range.
2022
*/
21-
type BackwardsForwardsCompatibleKitConfig = Config['kit'] & { experimental?: SvelteKitTracingConfig };
23+
type BackwardsForwardsCompatibleKitConfig = Config['kit'] &
24+
Pick<SvelteKitTracingConfig, 'tracing'> & { experimental?: SvelteKitTracingConfig };
2225

2326
export interface BackwardsForwardsCompatibleSvelteConfig extends Config {
2427
kit?: BackwardsForwardsCompatibleKitConfig;

‎packages/sveltekit/test/vite/autoInstrument.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,80 @@ describe('makeAutoInstrumentationPlugin()', () => {
219219
},
220220
);
221221
});
222+
223+
describe('when SvelteKit native server tracing is detected via the Vite plugin `api`', () => {
224+
// SvelteKit 3 no longer reads native-tracing config from `svelte.config.js` (so the
225+
// `onlyInstrumentClient` option computed from it is `false`); the config is exposed on the
226+
// SvelteKit Vite plugin's `api.options` instead.
227+
// The tracing config location differs by SvelteKit version:
228+
// - SvelteKit 3 (>= 3.0.0-next.8): `kit.tracing.server`
229+
// - SvelteKit 2.31+ and early Kit 3 prereleases: `kit.experimental.tracing.server`
230+
function configWithKitTracing(
231+
ssr: boolean,
232+
serverTracing: boolean,
233+
location: 'tracing' | 'experimental' = 'tracing',
234+
): unknown {
235+
const kit =
236+
location === 'tracing'
237+
? { tracing: { server: serverTracing } }
238+
: { experimental: { tracing: { server: serverTracing } } };
239+
return {
240+
build: { ssr },
241+
plugins: [
242+
{ name: 'some-other-plugin' },
243+
{
244+
name: 'vite-plugin-sveltekit-setup',
245+
api: { options: { kit } },
246+
},
247+
],
248+
};
249+
}
250+
251+
describe.each(['tracing', 'experimental'] as const)('with the config under `kit.%s`', location => {
252+
it.each(['path/to/+page.server.ts', 'path/to/+layout.server.js', 'path/to/+page.ts', 'path/to/+layout.mjs'])(
253+
"doesn't wrap %s in the SSR build when native tracing is enabled, even if `onlyInstrumentClient` is `false`",
254+
async (path: string) => {
255+
const plugin = makeAutoInstrumentationPlugin({
256+
debug: false,
257+
load: true,
258+
serverLoad: true,
259+
onlyInstrumentClient: false,
260+
});
261+
262+
// @ts-expect-error this exists and is callable
263+
plugin.configResolved(configWithKitTracing(true, true, location));
264+
265+
// @ts-expect-error this exists and is callable
266+
const loadResult = await plugin.load(path);
267+
268+
expect(loadResult).toEqual(null);
269+
},
270+
);
271+
272+
it('still wraps server load in the SSR build when native tracing is not enabled', async () => {
273+
const plugin = makeAutoInstrumentationPlugin({
274+
debug: false,
275+
load: true,
276+
serverLoad: true,
277+
onlyInstrumentClient: false,
278+
});
279+
280+
// @ts-expect-error this exists and is callable
281+
plugin.configResolved(configWithKitTracing(true, false, location));
282+
283+
const path = 'path/to/+page.server.ts';
284+
// @ts-expect-error this exists and is callable
285+
const loadResult = await plugin.load(path);
286+
287+
expect(loadResult).toBe(
288+
'import { wrapServerLoadWithSentry } from "@sentry/sveltekit";' +
289+
`import * as userModule from "${path}?sentry-auto-wrap";` +
290+
'export const load = userModule.load ? wrapServerLoadWithSentry(userModule.load) : undefined;' +
291+
`export * from "${path}?sentry-auto-wrap";`,
292+
);
293+
});
294+
});
295+
});
222296
});
223297

224298
describe('canWrapLoad', () => {

0 commit comments

Comments
 (0)