Skip to content

Commit 65482d6

Browse files
authored
fix(v10/sveltekit): Upload source maps with SvelteKit 3 (#25172)
Backport of: #25139 ## Differences to the original PR - `packages/sveltekit/src/vite/sourceMaps.ts`: typed the `buildApp` hook locally. v10 builds against Vite 6 types, which don't have plugin `buildApp` hooks (added in Vite 7). Runtime behavior is the same. - `packages/sveltekit/test/vite/sourceMaps.test.ts`: added a `@ts-expect-error` for reading `buildApp` off a Vite 6 `Plugin`. - `sveltekit-3-sourcemaps/vite.config.js`: imports `sentrySvelteKit` from `@sentry/sveltekit`. v10 has no `@sentry/sveltekit/vite` subpath. - `sveltekit-3-sourcemaps/assert-build.ts`: inlined the debug ID, `.map` file and `sourceMappingURL` scans. v10's `@sentry-internal/test-utils` doesn't export `findInjectedDebugIds`, `findSourceMapFiles` or `findSourceMappingUrlComments`.
1 parent 485ebb3 commit 65482d6

18 files changed

Lines changed: 436 additions & 97 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.DS_Store
2+
node_modules
3+
/build
4+
/.svelte-kit
5+
6+
# Mock Sentry server artifacts
7+
.tmp_*
8+
/tmp_mock_chunks
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import * as assert from 'node:assert/strict';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import {
5+
getArtifactBundles,
6+
getAssembleRequests,
7+
getChunkUploadPosts,
8+
getDebugIdPairs,
9+
getSourcemaps,
10+
loadMockServerResults,
11+
} from '@sentry-internal/test-utils';
12+
13+
const BUILD_OUTPUT = 'build';
14+
const CLIENT_OUTPUT = path.join(BUILD_OUTPUT, 'client');
15+
const SERVER_OUTPUT = path.join(BUILD_OUTPUT, 'server');
16+
17+
/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
18+
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
19+
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
20+
21+
function listFiles(dir: string): string[] {
22+
return fs
23+
.readdirSync(dir, { recursive: true, withFileTypes: true })
24+
.filter(entry => entry.isFile())
25+
.map(entry => path.join(entry.parentPath, entry.name));
26+
}
27+
28+
const jsFiles = (dir: string): string[] => listFiles(dir).filter(file => file.endsWith('.js'));
29+
30+
/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.js`. */
31+
const debugIdsWithoutSourceMap = new Set(
32+
fs.existsSync('.tmp_debug_ids_without_source_map')
33+
? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean)
34+
: [],
35+
);
36+
37+
const requests = loadMockServerResults();
38+
39+
console.log(`Captured ${requests.length} requests to mock Sentry server:\n`);
40+
for (const request of requests) {
41+
console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`);
42+
}
43+
console.log('');
44+
45+
assert.ok(
46+
requests.some(r => r.authorization.includes('fake-auth-token')),
47+
'Expected requests with the configured auth token',
48+
);
49+
50+
assert.ok(
51+
requests.some(r => r.url?.includes('/releases') && r.method === 'POST'),
52+
'Expected a POST to create the release',
53+
);
54+
55+
assert.ok(
56+
getChunkUploadPosts(requests).some(r => r.bodySize > 0),
57+
'Expected at least one chunk upload POST with a non-empty body',
58+
);
59+
60+
const assembleRequests = getAssembleRequests(requests);
61+
assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request');
62+
for (const request of assembleRequests) {
63+
assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project');
64+
assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release');
65+
}
66+
67+
const bundles = getArtifactBundles(requests);
68+
assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest');
69+
70+
const sourcemaps = getSourcemaps(bundles);
71+
72+
const containsMarker = (marker: string): boolean =>
73+
sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker)));
74+
75+
assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source');
76+
assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source');
77+
78+
// The auto-instrument plugin loads wrapped modules under this suffix. It must be stripped before
79+
// upload, or the uploaded sources don't match the frames Sentry receives.
80+
const wrappedSources = sourcemaps
81+
.flatMap(map => map.sourcemap.sources ?? [])
82+
.filter(source => source.includes('?sentry-auto-wrap'));
83+
assert.deepEqual(wrappedSources, [], 'Expected no uploaded source to carry the auto-wrap query suffix');
84+
85+
const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase()));
86+
assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs');
87+
88+
for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) {
89+
const injectedDebugIds = [
90+
...new Set(
91+
jsFiles(outputDir).flatMap(file =>
92+
[...fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)].map(match =>
93+
(match[1] as string).toLowerCase(),
94+
),
95+
),
96+
),
97+
];
98+
assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`);
99+
100+
const unuploaded = injectedDebugIds.filter(
101+
debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId),
102+
);
103+
assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`);
104+
}
105+
106+
// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload.
107+
assert.deepEqual(
108+
listFiles(BUILD_OUTPUT).filter(file => file.endsWith('.map')),
109+
[],
110+
`Expected no source maps in ${BUILD_OUTPUT}`,
111+
);
112+
assert.deepEqual(
113+
jsFiles(CLIENT_OUTPUT).filter(file => /[#@]\s*sourceMappingURL=(?!data:)/.test(fs.readFileSync(file, 'utf8'))),
114+
[],
115+
`Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`,
116+
);
117+
118+
console.log('All sourcemap assertions passed!');
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "sveltekit-3-sourcemaps",
3+
"description": "E2E test app asserting what the SvelteKit SDK uploads to Sentry and what it leaves behind in `build`.",
4+
"version": "0.0.1",
5+
"private": true,
6+
"type": "module",
7+
"scripts": {
8+
"build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT",
9+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
10+
"test:build": "pnpm install && pnpm build",
11+
"test:assert": "node assert-build.ts"
12+
},
13+
"dependencies": {
14+
"@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz"
15+
},
16+
"devDependencies": {
17+
"@playwright/test": "~1.56.0",
18+
"@sentry-internal/test-utils": "link:../../../test-utils",
19+
"@sveltejs/adapter-node": "^6.0.0",
20+
"@sveltejs/kit": "^3.0.0",
21+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
22+
"@types/node": "^22.20.0",
23+
"svelte": "^5.57.1",
24+
"typescript": "^6.0.0",
25+
"vite": "^8.0.12"
26+
},
27+
"volta": {
28+
"node": "22.20.0",
29+
"extends": "../../package.json"
30+
}
31+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// See https://kit.svelte.dev/docs/types#app
2+
// for information about these interfaces
3+
declare global {
4+
namespace App {
5+
// interface Error {}
6+
// interface Locals {}
7+
// interface PageData {}
8+
// interface PageState {}
9+
// interface Platform {}
10+
}
11+
}
12+
13+
export {};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
6+
<meta name="viewport" content="width=device-width" />
7+
%sveltekit.head%
8+
</head>
9+
<body data-sveltekit-preload-data="off">
10+
<div style="display: contents">%sveltekit.body%</div>
11+
</body>
12+
</html>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as Sentry from '@sentry/sveltekit';
2+
3+
Sentry.init({
4+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
5+
});
6+
7+
export const handleError = Sentry.handleErrorWithSentry();
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import * as Sentry from '@sentry/sveltekit';
2+
3+
export const handleError = Sentry.handleErrorWithSentry();
4+
5+
export const handle = Sentry.sentryHandle();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as Sentry from '@sentry/sveltekit';
2+
3+
// SvelteKit 3 loads this file before the app, which is where `Sentry.init` belongs.
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script lang="ts">
2+
// SOURCEMAP_MARKER_CLIENT
3+
function throwError() {
4+
throw new Error('Client error');
5+
}
6+
</script>
7+
8+
<button on:click={throwError}>Throw error</button>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// SOURCEMAP_MARKER_SERVER
2+
export const load = async () => {
3+
throw new Error('Server load error');
4+
};

0 commit comments

Comments
 (0)