Skip to content

feat(sveltekit): Auto-instrument universal and server load functions #7969

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/sveltekit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@sentry/types": "7.49.0",
"@sentry/utils": "7.49.0",
"@sentry/vite-plugin": "^0.6.0",
"magic-string": "^0.30.0",
"magicast": "0.2.4",
"sorcery": "0.11.0"
},
"devDependencies": {
Expand Down
194 changes: 194 additions & 0 deletions packages/sveltekit/src/vite/autoInstrument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/* eslint-disable @sentry-internal/sdk/no-optional-chaining */
import type {
ExportNamedDeclaration,
FunctionDeclaration,
Program,
VariableDeclaration,
VariableDeclarator,
} from '@babel/types';
import type { ProxifiedModule } from 'magicast';
import { builders, generateCode, parseModule } from 'magicast';
import type { Plugin } from 'vite';

export type AutoInstrumentSelection = {
/**
* If this flag is `true`, the Sentry plugins will automatically instrument the `load` function of
* your universal `load` functions declared in your `+page.(js|ts)` and `+layout.(js|ts)` files.
*
* @default true
*/
load?: boolean;

/**
* If this flag is `true`, the Sentry plugins will automatically instrument the `load` function of
* your server-only `load` functions declared in your `+page.server.(js|ts)`
* and `+layout.server.(js|ts)` files.
*
* @default true
*/
serverLoad?: boolean;
};

type AutoInstrumentPluginOptions = AutoInstrumentSelection & {
debug: boolean;
};

/**
* Creates a Vite plugin that automatically instruments the parts of the app
* specified in @param options
*
* @returns the plugin
*/
export async function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptions): Promise<Plugin> {
const { load: shouldWrapLoad, serverLoad: shouldWrapServerLoad, debug } = options;

return {
name: 'sentry-auto-instrumentation',
enforce: 'post',
async transform(userCode, id) {
const shouldApplyUniversalLoadWrapper =
shouldWrapLoad &&
/\+(page|layout)\.(js|ts|mjs|mts)$/.test(id) &&
// Simple check to see if users already instrumented the file manually
!userCode.includes('@sentry/sveltekit');

if (shouldApplyUniversalLoadWrapper) {
// eslint-disable-next-line no-console
debug && console.log('[Sentry] Applying universal load wrapper to', id);
const wrappedCode = wrapLoad(userCode, 'wrapLoadWithSentry');
return { code: wrappedCode, map: null };
}

const shouldApplyServerLoadWrapper =
shouldWrapServerLoad &&
/\+(page|layout)\.server\.(js|ts|mjs|mts)$/.test(id) &&
!userCode.includes('@sentry/sveltekit');

if (shouldApplyServerLoadWrapper) {
// eslint-disable-next-line no-console
debug && console.log('[Sentry] Applying server load wrapper to', id);
const wrappedCode = wrapLoad(userCode, 'wrapServerLoadWithSentry');
return { code: wrappedCode, map: null };
}

return null;
},
};
}

/**
* Applies the wrapLoadWithSentry wrapper to the user's load functions
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function wrapLoad(
userCode: Readonly<string>,
wrapperFunction: 'wrapLoadWithSentry' | 'wrapServerLoadWithSentry',
): string {
const mod = parseModule(userCode);

const modAST = mod.exports.$ast as Program;
const namedExports = modAST.body.filter(
(node): node is ExportNamedDeclaration => node.type === 'ExportNamedDeclaration',
);

let wrappedSucessfully = false;
namedExports.forEach(modExport => {
const declaration = modExport.declaration;
if (!declaration) {
return;
}
if (declaration.type === 'FunctionDeclaration') {
if (!declaration.id || declaration.id.name !== 'load') {
return;
}
const declarationCode = generateCode(declaration).code;
mod.exports.load = builders.raw(`${wrapperFunction}(${declarationCode.replace('load', '_load')})`);
// because of an issue with magicast, we need to remove the original export
modAST.body = modAST.body.filter(node => node !== modExport);
wrappedSucessfully = true;
} else if (declaration.type === 'VariableDeclaration') {
declaration.declarations.forEach(declarator => {
wrappedSucessfully = wrapDeclarator(declarator, wrapperFunction);
});
}
});

if (wrappedSucessfully) {
return generateFinalCode(mod, wrapperFunction);
}

// If we're here, we know that we didn't find a directly exported `load` function yet.
// We need to look for it in the top level declarations in case it's declared and exported separately.
// First case: top level variable declaration
const topLevelVariableDeclarations = modAST.body.filter(
(statement): statement is VariableDeclaration => statement.type === 'VariableDeclaration',
);

topLevelVariableDeclarations.forEach(declaration => {
declaration.declarations.forEach(declarator => {
wrappedSucessfully = wrapDeclarator(declarator, wrapperFunction);
});
});

if (wrappedSucessfully) {
return generateFinalCode(mod, wrapperFunction);
}

// Second case: top level function declaration
// This is the most intrusive modification, as we need to replace a top level function declaration with a
// variable declaration and a function assignment. This changes the spacing formatting of the declarations
// but the line numbers should stay the same
const topLevelFunctionDeclarations = modAST.body.filter(
(statement): statement is FunctionDeclaration => statement.type === 'FunctionDeclaration',
);

topLevelFunctionDeclarations.forEach(declaration => {
if (!declaration.id || declaration.id.name !== 'load') {
return;
}

const stmtIndex = modAST.body.indexOf(declaration);
const declarationCode = generateCode(declaration).code;
const wrappedFunctionBody = builders.raw(`${wrapperFunction}(${declarationCode.replace('load', '_load')})`);
const stringifiedFunctionBody = generateCode(wrappedFunctionBody, {}).code;

const tmpMod = parseModule(`const load = ${stringifiedFunctionBody}`);
const newDeclarationNode = (tmpMod.$ast as Program).body[0];
const nodeWithAdjustedLoc = {
...newDeclarationNode,
loc: {
...declaration.loc,
},
};

// @ts-ignore - this works, magicast can handle this assignement although the types disagree
modAST.body[stmtIndex] = nodeWithAdjustedLoc;
wrappedSucessfully = true;
});

if (wrappedSucessfully) {
return generateFinalCode(mod, wrapperFunction);
}

// nothing found, so we just return the original code
return userCode;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function generateFinalCode(mod: ProxifiedModule<any>, wrapperFunction: string): string {
const { code } = generateCode(mod);
return `import { ${wrapperFunction} } from '@sentry/sveltekit'; ${code}`;
}

function wrapDeclarator(declarator: VariableDeclarator, wrapperFunction: string): boolean {
// @ts-ignore - id should always have a name in this case
if (!declarator.id || declarator.id.name !== 'load') {
return false;
}
const declarationInitCode = declarator.init;
// @ts-ignore - we can just place a string here, magicast will convert it to a node
const stringifiedCode = generateCode(declarationInitCode).code;
// @ts-ignore - we can just place a string here, magicast will convert it to a node
declarator.init = `${wrapperFunction}(${stringifiedCode})`;
return true;
}
41 changes: 37 additions & 4 deletions packages/sveltekit/src/vite/sentryVitePlugins.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { SentryVitePluginOptions } from '@sentry/vite-plugin';
import type { Plugin } from 'vite';

import type { AutoInstrumentSelection } from './autoInstrument';
import { makeAutoInstrumentationPlugin } from './autoInstrument';
import { makeCustomSentryVitePlugin } from './sourceMaps';

type SourceMapsUploadOptions = {
/**
* If this flag is `true`, the Sentry plugins will automatically upload source maps to Sentry.
* Defaults to `true`.
* @default true`.
*/
autoUploadSourceMaps?: boolean;

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

type AutoInstrumentOptions = {
/**
* The Sentry plugin will automatically instrument certain parts of your SvelteKit application at build time.
* Set this option to `false` to disable this behavior or what is instrumentated by passing an object.
*
* Auto instrumentation includes:
* - Universal `load` functions in `+page.(js|ts)` files
* - Server-only `load` functions in `+page.server.(js|ts)` files
*
* @default true (meaning, the plugin will instrument all of the above)
*/
autoInstrument?: boolean | AutoInstrumentSelection;
};

export type SentrySvelteKitPluginOptions = {
/**
* If this flag is `true`, the Sentry plugins will log some useful debug information.
* Defaults to `false`.
* @default false.
*/
debug?: boolean;
} & SourceMapsUploadOptions;
} & SourceMapsUploadOptions &
AutoInstrumentOptions;

const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = {
autoUploadSourceMaps: true,
autoInstrument: true,
debug: false,
};

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

const sentryPlugins = [];
const sentryPlugins: Plugin[] = [];

if (mergedOptions.autoInstrument) {
const pluginOptions: AutoInstrumentSelection = {
load: true,
serverLoad: true,
...(typeof mergedOptions.autoInstrument === 'object' ? mergedOptions.autoInstrument : {}),
};

sentryPlugins.push(
await makeAutoInstrumentationPlugin({
...pluginOptions,
debug: options.debug || false,
}),
);
}

if (mergedOptions.autoUploadSourceMaps) {
const pluginOptions = {
Expand Down
8 changes: 5 additions & 3 deletions packages/sveltekit/src/vite/sourceMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
let isSSRBuild = true;

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

// These hooks are copied from the original Sentry Vite plugin.
// They're mostly responsible for options parsing and release injection.
Expand All @@ -85,7 +85,7 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
renderChunk,
transform,

// Modify the config to generate source maps
// // Modify the config to generate source maps
config: config => {
// eslint-disable-next-line no-console
debug && console.log('[Source Maps Plugin] Enabeling source map generation');
Expand Down Expand Up @@ -117,6 +117,8 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
}

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

const jsFiles = getFiles(outDir).filter(file => file.endsWith('.js'));
// eslint-disable-next-line no-console
Expand Down
Loading