Skip to content

Commit 05cf332

Browse files
authored
feat(sveltekit): Add source maps support for Vercel (lambda) (#8256)
Adjust our automatic source maps upload setup in the SvelteKit SDK to support SvelteKit apps deployed to Vercel. This will only work for Lambda functions/Node runtime; not for the Vercel Edge runtime. This required a few changes in our custom vite plugin as well as on the server side of the SDK: * Based on the used adapter (manually set or detected via #8193) and the `svelte.config.js` we determine the output directory where the generated JS emitted to. * The determined output directory is injected into the global object on the server side * When an error occurs on the server side, we strip the absolute filename of each stack frame so that the relative path of the server-side code within the output directory is left. * We also use the determined output directory to build the correct `include` entries for the source map upload plugin. With this change, source maps upload should work for auto and Vercel adapters, as well as for the Node adapter. As for the Node adapter, the stackframe rewrite behaviour was also changed but it is now more in line with all supported adapters.
1 parent d5551aa commit 05cf332

11 files changed

+257
-26
lines changed

packages/sveltekit/.eslintrc.js

+6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ module.exports = {
1717
project: ['tsconfig.test.json'],
1818
},
1919
},
20+
{
21+
files: ['src/vite/**', 'src/server/**'],
22+
rules: {
23+
'@sentry-internal/sdk/no-optional-chaining': 'off',
24+
},
25+
},
2026
],
2127
extends: ['../../.eslintrc.js'],
2228
};

packages/sveltekit/src/server/utils.ts

+21-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
import type { DynamicSamplingContext, StackFrame, TraceparentData } from '@sentry/types';
2-
import { baggageHeaderToDynamicSamplingContext, basename, extractTraceparentData } from '@sentry/utils';
2+
import {
3+
baggageHeaderToDynamicSamplingContext,
4+
basename,
5+
escapeStringForRegex,
6+
extractTraceparentData,
7+
GLOBAL_OBJ,
8+
join,
9+
} from '@sentry/utils';
310
import type { RequestEvent } from '@sveltejs/kit';
411

512
import { WRAPPED_MODULE_SUFFIX } from '../vite/autoInstrument';
13+
import type { GlobalWithSentryValues } from '../vite/injectGlobalValues';
614

715
/**
816
* Takes a request event and extracts traceparent and DSC data
@@ -35,7 +43,8 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame {
3543
if (!frame.filename) {
3644
return frame;
3745
}
38-
46+
const globalWithSentryValues: GlobalWithSentryValues = GLOBAL_OBJ;
47+
const svelteKitBuildOutDir = globalWithSentryValues.__sentry_sveltekit_output_dir;
3948
const prefix = 'app:///';
4049

4150
// Check if the frame filename begins with `/` or a Windows-style prefix such as `C:\`
@@ -48,8 +57,16 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame {
4857
.replace(/\\/g, '/') // replace all `\\` instances with `/`
4958
: frame.filename;
5059

51-
const base = basename(filename);
52-
frame.filename = `${prefix}${base}`;
60+
let strippedFilename;
61+
if (svelteKitBuildOutDir) {
62+
strippedFilename = filename.replace(
63+
new RegExp(`^.*${escapeStringForRegex(join(svelteKitBuildOutDir, 'server'))}/`),
64+
'',
65+
);
66+
} else {
67+
strippedFilename = basename(filename);
68+
}
69+
frame.filename = `${prefix}${strippedFilename}`;
5370
}
5471

5572
delete frame.module;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { InternalGlobal } from '@sentry/utils';
2+
3+
export type GlobalSentryValues = {
4+
__sentry_sveltekit_output_dir?: string;
5+
};
6+
7+
/**
8+
* Extend the `global` type with custom properties that are
9+
* injected by the SvelteKit SDK at build time.
10+
* @see packages/sveltekit/src/vite/sourcemaps.ts
11+
*/
12+
export type GlobalWithSentryValues = InternalGlobal & GlobalSentryValues;
13+
14+
export const VIRTUAL_GLOBAL_VALUES_FILE = '\0sentry-inject-global-values-file';
15+
16+
/**
17+
* @returns code that injects @param globalSentryValues into the global object.
18+
*/
19+
export function getGlobalValueInjectionCode(globalSentryValues: GlobalSentryValues): string {
20+
if (Object.keys(globalSentryValues).length === 0) {
21+
return '';
22+
}
23+
24+
const sentryGlobal = '_global';
25+
26+
const globalCode = `var ${sentryGlobal} =
27+
typeof window !== 'undefined' ?
28+
window :
29+
typeof globalThis !== 'undefined' ?
30+
globalThis :
31+
typeof global !== 'undefined' ?
32+
global :
33+
typeof self !== 'undefined' ?
34+
self :
35+
{};`;
36+
const injectedValuesCode = Object.entries(globalSentryValues)
37+
.map(([key, value]) => `${sentryGlobal}["${key}"] = ${JSON.stringify(value)};`)
38+
.join('\n');
39+
40+
return `${globalCode}\n${injectedValuesCode}\n`;
41+
}

packages/sveltekit/src/vite/sentryVitePlugins.ts

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
103103
const pluginOptions = {
104104
...mergedOptions.sourceMapsUploadOptions,
105105
debug: mergedOptions.debug, // override the plugin's debug flag with the one from the top-level options
106+
adapter: mergedOptions.adapter,
106107
};
107108
sentryPlugins.push(await makeCustomSentryVitePlugin(pluginOptions));
108109
}

packages/sveltekit/src/vite/sourceMaps.ts

+52-11
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import * as sorcery from 'sorcery';
1111
import type { Plugin } from 'vite';
1212

1313
import { WRAPPED_MODULE_SUFFIX } from './autoInstrument';
14-
import { getAdapterOutputDir, loadSvelteConfig } from './svelteConfig';
14+
import type { SupportedSvelteKitAdapters } from './detectAdapter';
15+
import type { GlobalSentryValues } from './injectGlobalValues';
16+
import { getGlobalValueInjectionCode, VIRTUAL_GLOBAL_VALUES_FILE } from './injectGlobalValues';
17+
import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from './svelteConfig';
1518

1619
// sorcery has no types, so these are some basic type definitions:
1720
type Chain = {
@@ -26,6 +29,10 @@ type SentryVitePluginOptionsOptionalInclude = Omit<SentryVitePluginOptions, 'inc
2629
include?: SentryVitePluginOptions['include'];
2730
};
2831

32+
type CustomSentryVitePluginOptions = SentryVitePluginOptionsOptionalInclude & {
33+
adapter: SupportedSvelteKitAdapters;
34+
};
35+
2936
// storing this in the module scope because `makeCustomSentryVitePlugin` is called multiple times
3037
// and we only want to generate a uuid once in case we have to fall back to it.
3138
const release = detectSentryRelease();
@@ -46,18 +53,15 @@ const release = detectSentryRelease();
4653
*
4754
* @returns the custom Sentry Vite plugin
4855
*/
49-
export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptionsOptionalInclude): Promise<Plugin> {
56+
export async function makeCustomSentryVitePlugin(options?: CustomSentryVitePluginOptions): Promise<Plugin> {
5057
const svelteConfig = await loadSvelteConfig();
5158

52-
const outputDir = await getAdapterOutputDir(svelteConfig);
59+
const usedAdapter = options?.adapter || 'other';
60+
const outputDir = await getAdapterOutputDir(svelteConfig, usedAdapter);
5361
const hasSentryProperties = fs.existsSync(path.resolve(process.cwd(), 'sentry.properties'));
5462

5563
const defaultPluginOptions: SentryVitePluginOptions = {
56-
include: [
57-
{ paths: [`${outputDir}/client`] },
58-
{ paths: [`${outputDir}/server/chunks`] },
59-
{ paths: [`${outputDir}/server`], ignore: ['chunks/**'] },
60-
],
64+
include: [`${outputDir}/client`, `${outputDir}/server`],
6165
configFile: hasSentryProperties ? 'sentry.properties' : undefined,
6266
release,
6367
};
@@ -70,10 +74,16 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
7074
const sentryPlugin: Plugin = sentryVitePlugin(mergedOptions);
7175

7276
const { debug } = mergedOptions;
73-
const { buildStart, resolveId, transform, renderChunk } = sentryPlugin;
77+
const { buildStart, renderChunk } = sentryPlugin;
7478

7579
let isSSRBuild = true;
7680

81+
const serverHooksFile = getHooksFileName(svelteConfig, 'server');
82+
83+
const globalSentryValues: GlobalSentryValues = {
84+
__sentry_sveltekit_output_dir: outputDir,
85+
};
86+
7787
const customPlugin: Plugin = {
7888
name: 'sentry-upload-source-maps',
7989
apply: 'build', // only apply this plugin at build time
@@ -82,9 +92,7 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
8292
// These hooks are copied from the original Sentry Vite plugin.
8393
// They're mostly responsible for options parsing and release injection.
8494
buildStart,
85-
resolveId,
8695
renderChunk,
87-
transform,
8896

8997
// Modify the config to generate source maps
9098
config: config => {
@@ -99,6 +107,27 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
99107
};
100108
},
101109

110+
resolveId: (id, _importer, _ref) => {
111+
if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
112+
return {
113+
id: VIRTUAL_GLOBAL_VALUES_FILE,
114+
external: false,
115+
moduleSideEffects: true,
116+
};
117+
}
118+
// @ts-ignore - this hook exists on the plugin!
119+
return sentryPlugin.resolveId(id, _importer, _ref);
120+
},
121+
122+
load: id => {
123+
if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
124+
return {
125+
code: getGlobalValueInjectionCode(globalSentryValues),
126+
};
127+
}
128+
return null;
129+
},
130+
102131
configResolved: config => {
103132
// The SvelteKit plugins trigger additional builds within the main (SSR) build.
104133
// We just need a mechanism to upload source maps only once.
@@ -109,6 +138,18 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
109138
}
110139
},
111140

141+
transform: async (code, id) => {
142+
let modifiedCode = code;
143+
const isServerHooksFile = new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`).test(id);
144+
145+
if (isServerHooksFile) {
146+
const globalValuesImport = `; import "${VIRTUAL_GLOBAL_VALUES_FILE}";`;
147+
modifiedCode = `${code}\n${globalValuesImport}\n`;
148+
}
149+
// @ts-ignore - this hook exists on the plugin!
150+
return sentryPlugin.transform(modifiedCode, id);
151+
},
152+
112153
// We need to start uploading source maps later than in the original plugin
113154
// because SvelteKit is invoking the adapter at closeBundle.
114155
// This means that we need to wait until the adapter is done before we start uploading.

packages/sveltekit/src/vite/svelteConfig.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import * as fs from 'fs';
55
import * as path from 'path';
66
import * as url from 'url';
77

8+
import type { SupportedSvelteKitAdapters } from './detectAdapter';
9+
810
/**
911
* Imports the svelte.config.js file and returns the config object.
1012
* The sveltekit plugins import the config in the same way.
@@ -35,28 +37,46 @@ export async function loadSvelteConfig(): Promise<Config> {
3537
}
3638
}
3739

40+
/**
41+
* Reads a custom hooks directory from the SvelteKit config. In case no custom hooks
42+
* directory is specified, the default directory is returned.
43+
*/
44+
export function getHooksFileName(svelteConfig: Config, hookType: 'client' | 'server'): string {
45+
return svelteConfig.kit?.files?.hooks?.[hookType] || `src/hooks.${hookType}`;
46+
}
47+
3848
/**
3949
* Attempts to read a custom output directory that can be specidied in the options
4050
* of a SvelteKit adapter. If no custom output directory is specified, the default
4151
* directory is returned.
42-
*
43-
* To get the directory, we have to apply a hack and call the adapter's adapt method
52+
*/
53+
export async function getAdapterOutputDir(svelteConfig: Config, adapter: SupportedSvelteKitAdapters): Promise<string> {
54+
if (adapter === 'node') {
55+
return await getNodeAdapterOutputDir(svelteConfig);
56+
}
57+
58+
// Auto and Vercel adapters simply use config.kit.outDir
59+
// Let's also use this directory for the 'other' case
60+
return path.join(svelteConfig.kit?.outDir || '.svelte-kit', 'output');
61+
}
62+
63+
/**
64+
* To get the Node adapter output directory, we have to apply a hack and call the adapter's adapt method
4465
* with a custom adapter `Builder` that only calls the `writeClient` method.
4566
* This method is the first method that is called with the output directory.
4667
* Once we obtained the output directory, we throw an error to exit the adapter.
4768
*
4869
* see: https://github.com/sveltejs/kit/blob/master/packages/adapter-node/index.js#L17
49-
*
5070
*/
51-
export async function getAdapterOutputDir(svelteConfig: Config): Promise<string> {
71+
async function getNodeAdapterOutputDir(svelteConfig: Config): Promise<string> {
5272
// 'build' is the default output dir for the node adapter
5373
let outputDir = 'build';
5474

5575
if (!svelteConfig.kit?.adapter) {
5676
return outputDir;
5777
}
5878

59-
const adapter = svelteConfig.kit.adapter;
79+
const nodeAdapter = svelteConfig.kit.adapter;
6080

6181
const adapterBuilder: Builder = {
6282
writeClient(dest: string) {
@@ -85,7 +105,7 @@ export async function getAdapterOutputDir(svelteConfig: Config): Promise<string>
85105
};
86106

87107
try {
88-
await adapter.adapt(adapterBuilder);
108+
await nodeAdapter.adapt(adapterBuilder);
89109
} catch (_) {
90110
// We expect the adapter to throw in writeClient!
91111
}

packages/sveltekit/test/server/utils.test.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { RewriteFrames } from '@sentry/integrations';
22
import type { StackFrame } from '@sentry/types';
3+
import { basename } from '@sentry/utils';
34

5+
import type { GlobalWithSentryValues } from '../../src/server/utils';
46
import { getTracePropagationData, rewriteFramesIteratee } from '../../src/server/utils';
57

68
const MOCK_REQUEST_EVENT: any = {
@@ -69,7 +71,7 @@ describe('rewriteFramesIteratee', () => {
6971
expect(result).not.toHaveProperty('module');
7072
});
7173

72-
it('does the same filename modification as the default RewriteFrames iteratee', () => {
74+
it('does the same filename modification as the default RewriteFrames iteratee if no output dir is available', () => {
7375
const frame: StackFrame = {
7476
filename: '/some/path/to/server/chunks/3-ab34d22f.js',
7577
lineno: 1,
@@ -94,4 +96,36 @@ describe('rewriteFramesIteratee', () => {
9496

9597
expect(result).toStrictEqual(defaultResult);
9698
});
99+
100+
it.each([
101+
['adapter-node', 'build', '/absolute/path/to/build/server/chunks/3-ab34d22f.js', 'app:///chunks/3-ab34d22f.js'],
102+
[
103+
'adapter-auto',
104+
'.svelte-kit/output',
105+
'/absolute/path/to/.svelte-kit/output/server/entries/pages/page.ts.js',
106+
'app:///entries/pages/page.ts.js',
107+
],
108+
])(
109+
'removes the absolut path to the server output dir, if the output dir is available (%s)',
110+
(_, outputDir, frameFilename, modifiedFilename) => {
111+
(globalThis as GlobalWithSentryValues).__sentry_sveltekit_output_dir = outputDir;
112+
113+
const frame: StackFrame = {
114+
filename: frameFilename,
115+
lineno: 1,
116+
colno: 1,
117+
module: basename(frameFilename),
118+
};
119+
120+
const result = rewriteFramesIteratee({ ...frame });
121+
122+
expect(result).toStrictEqual({
123+
filename: modifiedFilename,
124+
lineno: 1,
125+
colno: 1,
126+
});
127+
128+
delete (globalThis as GlobalWithSentryValues).__sentry_sveltekit_output_dir;
129+
},
130+
);
97131
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { getGlobalValueInjectionCode } from '../../src/vite/injectGlobalValues';
2+
3+
describe('getGlobalValueInjectionCode', () => {
4+
it('returns code that injects values into the global object', () => {
5+
const injectionCode = getGlobalValueInjectionCode({
6+
// @ts-ignore - just want to test this with multiple values
7+
something: 'else',
8+
__sentry_sveltekit_output_dir: '.svelte-kit/output',
9+
});
10+
expect(injectionCode).toEqual(`var _global =
11+
typeof window !== 'undefined' ?
12+
window :
13+
typeof globalThis !== 'undefined' ?
14+
globalThis :
15+
typeof global !== 'undefined' ?
16+
global :
17+
typeof self !== 'undefined' ?
18+
self :
19+
{};
20+
_global["something"] = "else";
21+
_global["__sentry_sveltekit_output_dir"] = ".svelte-kit/output";
22+
`);
23+
24+
// Check that the code above is in fact valid and works as expected
25+
// The return value of eval here is the value of the last expression in the code
26+
expect(eval(`${injectionCode}`)).toEqual('.svelte-kit/output');
27+
28+
delete globalThis.__sentry_sveltekit_output_dir;
29+
});
30+
31+
it('returns empty string if no values are passed', () => {
32+
expect(getGlobalValueInjectionCode({})).toEqual('');
33+
});
34+
});

0 commit comments

Comments
 (0)