Skip to content

Commit 43d9fa9

Browse files
authored
feat(sveltekit): Auto-wrap load functions with proxy module (#7994)
Introduce auto-wrapping of `load` functions in * `+(page|layout).(ts|js)` files (universal loads) * `+(page|layout).server.(ts|js)` files (server-only loads) See PR description for further details on API and methodology
1 parent 2395107 commit 43d9fa9

File tree

8 files changed

+448
-15
lines changed

8 files changed

+448
-15
lines changed

packages/sveltekit/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
"@sentry/types": "7.50.0",
2929
"@sentry/utils": "7.50.0",
3030
"@sentry/vite-plugin": "^0.6.0",
31-
"magic-string": "^0.30.0",
3231
"sorcery": "0.11.0"
3332
},
3433
"devDependencies": {

packages/sveltekit/src/server/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import type { DynamicSamplingContext, StackFrame, TraceparentData } from '@sentr
22
import { baggageHeaderToDynamicSamplingContext, basename, extractTraceparentData } from '@sentry/utils';
33
import type { RequestEvent } from '@sveltejs/kit';
44

5+
import { WRAPPED_MODULE_SUFFIX } from '../vite/autoInstrument';
6+
57
/**
68
* Takes a request event and extracts traceparent and DSC data
79
* from the `sentry-trace` and `baggage` DSC headers.
@@ -52,5 +54,11 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame {
5254

5355
delete frame.module;
5456

57+
// In dev-mode, the WRAPPED_MODULE_SUFFIX is still present in the frame's file name.
58+
// We need to remove it to make sure that the frame's filename matches the actual file
59+
if (frame.filename.endsWith(WRAPPED_MODULE_SUFFIX)) {
60+
frame.filename = frame.filename.slice(0, -WRAPPED_MODULE_SUFFIX.length);
61+
}
62+
5563
return frame;
5664
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/* eslint-disable @sentry-internal/sdk/no-optional-chaining */
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import type { Plugin } from 'vite';
5+
6+
export const WRAPPED_MODULE_SUFFIX = '?sentry-auto-wrap';
7+
8+
export type AutoInstrumentSelection = {
9+
/**
10+
* If this flag is `true`, the Sentry plugins will automatically instrument the `load` function of
11+
* your universal `load` functions declared in your `+page.(js|ts)` and `+layout.(js|ts)` files.
12+
*
13+
* @default true
14+
*/
15+
load?: boolean;
16+
17+
/**
18+
* If this flag is `true`, the Sentry plugins will automatically instrument the `load` function of
19+
* your server-only `load` functions declared in your `+page.server.(js|ts)`
20+
* and `+layout.server.(js|ts)` files.
21+
*
22+
* @default true
23+
*/
24+
serverLoad?: boolean;
25+
};
26+
27+
type AutoInstrumentPluginOptions = AutoInstrumentSelection & {
28+
debug: boolean;
29+
};
30+
31+
/**
32+
* Creates a Vite plugin that automatically instruments the parts of the app
33+
* specified in @param options. This includes
34+
*
35+
* - universal `load` functions from `+page.(js|ts)` and `+layout.(js|ts)` files
36+
* - server-only `load` functions from `+page.server.(js|ts)` and `+layout.server.(js|ts)` files
37+
*
38+
* @returns the plugin
39+
*/
40+
export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptions): Plugin {
41+
const { load: shouldWrapLoad, serverLoad: shouldWrapServerLoad, debug } = options;
42+
43+
return {
44+
name: 'sentry-auto-instrumentation',
45+
// This plugin needs to run as early as possible, before the SvelteKit plugin virtualizes all paths and ids
46+
enforce: 'pre',
47+
48+
async load(id) {
49+
const applyUniversalLoadWrapper =
50+
shouldWrapLoad &&
51+
/^\+(page|layout)\.(js|ts|mjs|mts)$/.test(path.basename(id)) &&
52+
(await canWrapLoad(id, debug));
53+
54+
if (applyUniversalLoadWrapper) {
55+
// eslint-disable-next-line no-console
56+
debug && console.log(`Wrapping ${id} with Sentry load wrapper`);
57+
return getWrapperCode('wrapLoadWithSentry', `${id}${WRAPPED_MODULE_SUFFIX}`);
58+
}
59+
60+
const applyServerLoadWrapper =
61+
shouldWrapServerLoad &&
62+
/^\+(page|layout)\.server\.(js|ts|mjs|mts)$/.test(path.basename(id)) &&
63+
(await canWrapLoad(id, debug));
64+
65+
if (applyServerLoadWrapper) {
66+
// eslint-disable-next-line no-console
67+
debug && console.log(`Wrapping ${id} with Sentry load wrapper`);
68+
return getWrapperCode('wrapServerLoadWithSentry', `${id}${WRAPPED_MODULE_SUFFIX}`);
69+
}
70+
71+
return null;
72+
},
73+
};
74+
}
75+
76+
/**
77+
* We only want to apply our wrapper to files that
78+
*
79+
* - Have no Sentry code yet in them. This is to avoid double-wrapping or interferance with custom
80+
* Sentry calls.
81+
* - Actually declare a `load` function. The second check of course is not 100% accurate, but it's good enough.
82+
* Injecting our wrapper into files that don't declare a `load` function would result in a build-time warning
83+
* because vite/rollup warns if we reference an export from the user's file in our wrapping code that
84+
* doesn't exist.
85+
*
86+
* Exported for testing
87+
*
88+
* @returns `true` if we can wrap the given file, `false` otherwise
89+
*/
90+
export async function canWrapLoad(id: string, debug: boolean): Promise<boolean> {
91+
const code = (await fs.promises.readFile(id, 'utf8')).toString();
92+
93+
const codeWithoutComments = code.replace(/(\/\/.*| ?\/\*[^]*?\*\/)(,?)$/gm, '');
94+
95+
const hasSentryContent = codeWithoutComments.includes('@sentry/sveltekit');
96+
if (hasSentryContent) {
97+
// eslint-disable-next-line no-console
98+
debug && console.log(`Skipping wrapping ${id} because it already contains Sentry code`);
99+
}
100+
101+
const hasLoadDeclaration = /((const|let|var|function)\s+load\s*(=|\())|as\s+load\s*(,|})/gm.test(codeWithoutComments);
102+
if (!hasLoadDeclaration) {
103+
// eslint-disable-next-line no-console
104+
debug && console.log(`Skipping wrapping ${id} because it doesn't declare a \`load\` function`);
105+
}
106+
107+
return !hasSentryContent && hasLoadDeclaration;
108+
}
109+
110+
/**
111+
* Return wrapper code fo the given module id and wrapping function
112+
*/
113+
function getWrapperCode(
114+
wrapperFunction: 'wrapLoadWithSentry' | 'wrapServerLoadWithSentry',
115+
idWithSuffix: string,
116+
): string {
117+
return (
118+
`import { ${wrapperFunction} } from "@sentry/sveltekit";` +
119+
`import * as userModule from ${JSON.stringify(idWithSuffix)};` +
120+
`export const load = userModule.load ? ${wrapperFunction}(userModule.load) : undefined;` +
121+
`export * from ${JSON.stringify(idWithSuffix)};`
122+
);
123+
}

packages/sveltekit/src/vite/sentryVitePlugins.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
22
import type { Plugin } from 'vite';
33

4+
import type { AutoInstrumentSelection } from './autoInstrument';
5+
import { makeAutoInstrumentationPlugin } from './autoInstrument';
46
import { makeCustomSentryVitePlugin } from './sourceMaps';
57

68
type SourceMapsUploadOptions = {
79
/**
810
* If this flag is `true`, the Sentry plugins will automatically upload source maps to Sentry.
9-
* Defaults to `true`.
11+
* @default true`.
1012
*/
1113
autoUploadSourceMaps?: boolean;
1214

@@ -17,16 +19,32 @@ type SourceMapsUploadOptions = {
1719
sourceMapsUploadOptions?: Partial<SentryVitePluginOptions>;
1820
};
1921

22+
type AutoInstrumentOptions = {
23+
/**
24+
* The Sentry plugin will automatically instrument certain parts of your SvelteKit application at build time.
25+
* Set this option to `false` to disable this behavior or what is instrumentated by passing an object.
26+
*
27+
* Auto instrumentation includes:
28+
* - Universal `load` functions in `+page.(js|ts)` files
29+
* - Server-only `load` functions in `+page.server.(js|ts)` files
30+
*
31+
* @default true (meaning, the plugin will instrument all of the above)
32+
*/
33+
autoInstrument?: boolean | AutoInstrumentSelection;
34+
};
35+
2036
export type SentrySvelteKitPluginOptions = {
2137
/**
2238
* If this flag is `true`, the Sentry plugins will log some useful debug information.
23-
* Defaults to `false`.
39+
* @default false.
2440
*/
2541
debug?: boolean;
26-
} & SourceMapsUploadOptions;
42+
} & SourceMapsUploadOptions &
43+
AutoInstrumentOptions;
2744

2845
const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = {
2946
autoUploadSourceMaps: true,
47+
autoInstrument: true,
3048
debug: false,
3149
};
3250

@@ -43,7 +61,22 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
4361
...options,
4462
};
4563

46-
const sentryPlugins = [];
64+
const sentryPlugins: Plugin[] = [];
65+
66+
if (mergedOptions.autoInstrument) {
67+
const pluginOptions: AutoInstrumentSelection = {
68+
load: true,
69+
serverLoad: true,
70+
...(typeof mergedOptions.autoInstrument === 'object' ? mergedOptions.autoInstrument : {}),
71+
};
72+
73+
sentryPlugins.push(
74+
makeAutoInstrumentationPlugin({
75+
...pluginOptions,
76+
debug: options.debug || false,
77+
}),
78+
);
79+
}
4780

4881
if (mergedOptions.autoUploadSourceMaps) {
4982
const pluginOptions = {

packages/sveltekit/src/vite/sourceMaps.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getSentryRelease } from '@sentry/node';
2-
import { uuid4 } from '@sentry/utils';
2+
import { escapeStringForRegex, uuid4 } from '@sentry/utils';
33
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
44
import { sentryVitePlugin } from '@sentry/vite-plugin';
55
import * as child_process from 'child_process';
@@ -10,6 +10,7 @@ import * as path from 'path';
1010
import * as sorcery from 'sorcery';
1111
import type { Plugin } from 'vite';
1212

13+
import { WRAPPED_MODULE_SUFFIX } from './autoInstrument';
1314
import { getAdapterOutputDir, loadSvelteConfig } from './svelteConfig';
1415

1516
// sorcery has no types, so these are some basic type definitions:
@@ -74,9 +75,9 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
7475
let isSSRBuild = true;
7576

7677
const customPlugin: Plugin = {
77-
name: 'sentry-vite-plugin-custom',
78+
name: 'sentry-upload-source-maps',
7879
apply: 'build', // only apply this plugin at build time
79-
enforce: 'post',
80+
enforce: 'post', // this needs to be set to post, otherwise we don't pick up the output from the SvelteKit adapter
8081

8182
// These hooks are copied from the original Sentry Vite plugin.
8283
// They're mostly responsible for options parsing and release injection.
@@ -117,6 +118,8 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
117118
}
118119

119120
const outDir = path.resolve(process.cwd(), outputDir);
121+
// eslint-disable-next-line no-console
122+
debug && console.log('[Source Maps Plugin] Looking up source maps in', outDir);
120123

121124
const jsFiles = getFiles(outDir).filter(file => file.endsWith('.js'));
122125
// eslint-disable-next-line no-console
@@ -145,6 +148,18 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
145148
console.error('[Source Maps Plugin] error while flattening', file, e);
146149
}
147150
}
151+
152+
// We need to remove the query string from the source map files that our auto-instrument plugin added
153+
// to proxy the load functions during building.
154+
const mapFile = `${file}.map`;
155+
if (fs.existsSync(mapFile)) {
156+
const mapContent = (await fs.promises.readFile(mapFile, 'utf-8')).toString();
157+
const cleanedMapContent = mapContent.replace(
158+
new RegExp(escapeStringForRegex(WRAPPED_MODULE_SUFFIX), 'gm'),
159+
'',
160+
);
161+
await fs.promises.writeFile(mapFile, cleanedMapContent);
162+
}
148163
});
149164

150165
try {

0 commit comments

Comments
 (0)