Skip to content
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

feat(sveltekit): Add source maps support for Vercel (lambda) #8256

Merged
merged 9 commits into from Jun 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/sveltekit/.eslintrc.js
Expand Up @@ -17,6 +17,12 @@ module.exports = {
project: ['tsconfig.test.json'],
},
},
{
files: ['src/vite/**', 'src/server/**'],
rules: {
'@sentry-internal/sdk/no-optional-chaining': 'off',
},
},
],
extends: ['../../.eslintrc.js'],
};
25 changes: 21 additions & 4 deletions packages/sveltekit/src/server/utils.ts
@@ -1,8 +1,16 @@
import type { DynamicSamplingContext, StackFrame, TraceparentData } from '@sentry/types';
import { baggageHeaderToDynamicSamplingContext, basename, extractTraceparentData } from '@sentry/utils';
import {
baggageHeaderToDynamicSamplingContext,
basename,
escapeStringForRegex,
extractTraceparentData,
GLOBAL_OBJ,
join,
} from '@sentry/utils';
import type { RequestEvent } from '@sveltejs/kit';

import { WRAPPED_MODULE_SUFFIX } from '../vite/autoInstrument';
import type { GlobalWithSentryValues } from '../vite/injectGlobalValues';

/**
* Takes a request event and extracts traceparent and DSC data
Expand Down Expand Up @@ -35,7 +43,8 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame {
if (!frame.filename) {
return frame;
}

const globalWithSentryValues: GlobalWithSentryValues = GLOBAL_OBJ;
const svelteKitBuildOutDir = globalWithSentryValues.__sentry_sveltekit_output_dir;
const prefix = 'app:///';

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

const base = basename(filename);
frame.filename = `${prefix}${base}`;
let strippedFilename;
if (svelteKitBuildOutDir) {
strippedFilename = filename.replace(
new RegExp(`^.*${escapeStringForRegex(join(svelteKitBuildOutDir, 'server'))}/`),
'',
);
} else {
strippedFilename = basename(filename);
}
frame.filename = `${prefix}${strippedFilename}`;
}

delete frame.module;
Expand Down
41 changes: 41 additions & 0 deletions packages/sveltekit/src/vite/injectGlobalValues.ts
@@ -0,0 +1,41 @@
import type { InternalGlobal } from '@sentry/utils';

export type GlobalSentryValues = {
__sentry_sveltekit_output_dir?: string;
};

/**
* Extend the `global` type with custom properties that are
* injected by the SvelteKit SDK at build time.
* @see packages/sveltekit/src/vite/sourcemaps.ts
*/
export type GlobalWithSentryValues = InternalGlobal & GlobalSentryValues;

export const VIRTUAL_GLOBAL_VALUES_FILE = '\0sentry-inject-global-values-file';

/**
* @returns code that injects @param globalSentryValues into the global object.
*/
export function getGlobalValueInjectionCode(globalSentryValues: GlobalSentryValues): string {
if (Object.keys(globalSentryValues).length === 0) {
return '';
}

const sentryGlobal = '_global';

const globalCode = `var ${sentryGlobal} =
typeof window !== 'undefined' ?
window :
typeof globalThis !== 'undefined' ?
globalThis :
typeof global !== 'undefined' ?
global :
typeof self !== 'undefined' ?
self :
{};`;
const injectedValuesCode = Object.entries(globalSentryValues)
.map(([key, value]) => `${sentryGlobal}["${key}"] = ${JSON.stringify(value)};`)
.join('\n');

return `${globalCode}\n${injectedValuesCode}\n`;
}
1 change: 1 addition & 0 deletions packages/sveltekit/src/vite/sentryVitePlugins.ts
Expand Up @@ -103,6 +103,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}
const pluginOptions = {
...mergedOptions.sourceMapsUploadOptions,
debug: mergedOptions.debug, // override the plugin's debug flag with the one from the top-level options
adapter: mergedOptions.adapter,
};
sentryPlugins.push(await makeCustomSentryVitePlugin(pluginOptions));
}
Expand Down
63 changes: 52 additions & 11 deletions packages/sveltekit/src/vite/sourceMaps.ts
Expand Up @@ -11,7 +11,10 @@ import * as sorcery from 'sorcery';
import type { Plugin } from 'vite';

import { WRAPPED_MODULE_SUFFIX } from './autoInstrument';
import { getAdapterOutputDir, loadSvelteConfig } from './svelteConfig';
import type { SupportedSvelteKitAdapters } from './detectAdapter';
import type { GlobalSentryValues } from './injectGlobalValues';
import { getGlobalValueInjectionCode, VIRTUAL_GLOBAL_VALUES_FILE } from './injectGlobalValues';
import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from './svelteConfig';

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

type CustomSentryVitePluginOptions = SentryVitePluginOptionsOptionalInclude & {
adapter: SupportedSvelteKitAdapters;
};

// storing this in the module scope because `makeCustomSentryVitePlugin` is called multiple times
// and we only want to generate a uuid once in case we have to fall back to it.
const release = detectSentryRelease();
Expand All @@ -46,18 +53,15 @@ const release = detectSentryRelease();
*
* @returns the custom Sentry Vite plugin
*/
export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptionsOptionalInclude): Promise<Plugin> {
export async function makeCustomSentryVitePlugin(options?: CustomSentryVitePluginOptions): Promise<Plugin> {
const svelteConfig = await loadSvelteConfig();

const outputDir = await getAdapterOutputDir(svelteConfig);
const usedAdapter = options?.adapter || 'other';
const outputDir = await getAdapterOutputDir(svelteConfig, usedAdapter);
const hasSentryProperties = fs.existsSync(path.resolve(process.cwd(), 'sentry.properties'));

const defaultPluginOptions: SentryVitePluginOptions = {
include: [
{ paths: [`${outputDir}/client`] },
{ paths: [`${outputDir}/server/chunks`] },
{ paths: [`${outputDir}/server`], ignore: ['chunks/**'] },
],
include: [`${outputDir}/client`, `${outputDir}/server`],
configFile: hasSentryProperties ? 'sentry.properties' : undefined,
release,
};
Expand All @@ -70,10 +74,16 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
const sentryPlugin: Plugin = sentryVitePlugin(mergedOptions);

const { debug } = mergedOptions;
const { buildStart, resolveId, transform, renderChunk } = sentryPlugin;
const { buildStart, renderChunk } = sentryPlugin;

let isSSRBuild = true;

const serverHooksFile = getHooksFileName(svelteConfig, 'server');

const globalSentryValues: GlobalSentryValues = {
__sentry_sveltekit_output_dir: outputDir,
};

const customPlugin: Plugin = {
name: 'sentry-upload-source-maps',
apply: 'build', // only apply this plugin at build time
Expand All @@ -82,9 +92,7 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
// These hooks are copied from the original Sentry Vite plugin.
// They're mostly responsible for options parsing and release injection.
buildStart,
resolveId,
renderChunk,
transform,

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

resolveId: (id, _importer, _ref) => {
if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
return {
id: VIRTUAL_GLOBAL_VALUES_FILE,
external: false,
moduleSideEffects: true,
};
}
// @ts-ignore - this hook exists on the plugin!
return sentryPlugin.resolveId(id, _importer, _ref);
},

load: id => {
if (id === VIRTUAL_GLOBAL_VALUES_FILE) {
return {
code: getGlobalValueInjectionCode(globalSentryValues),
};
}
return null;
},

configResolved: config => {
// The SvelteKit plugins trigger additional builds within the main (SSR) build.
// We just need a mechanism to upload source maps only once.
Expand All @@ -109,6 +138,18 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio
}
},

transform: async (code, id) => {
Lms24 marked this conversation as resolved.
Show resolved Hide resolved
let modifiedCode = code;
const isServerHooksFile = new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`).test(id);

if (isServerHooksFile) {
const globalValuesImport = `; import "${VIRTUAL_GLOBAL_VALUES_FILE}";`;
modifiedCode = `${code}\n${globalValuesImport}\n`;
}
// @ts-ignore - this hook exists on the plugin!
return sentryPlugin.transform(modifiedCode, id);
},

// We need to start uploading source maps later than in the original plugin
// because SvelteKit is invoking the adapter at closeBundle.
// This means that we need to wait until the adapter is done before we start uploading.
Expand Down
32 changes: 26 additions & 6 deletions packages/sveltekit/src/vite/svelteConfig.ts
Expand Up @@ -5,6 +5,8 @@ import * as fs from 'fs';
import * as path from 'path';
import * as url from 'url';

import type { SupportedSvelteKitAdapters } from './detectAdapter';

/**
* Imports the svelte.config.js file and returns the config object.
* The sveltekit plugins import the config in the same way.
Expand Down Expand Up @@ -35,28 +37,46 @@ export async function loadSvelteConfig(): Promise<Config> {
}
}

/**
* Reads a custom hooks directory from the SvelteKit config. In case no custom hooks
* directory is specified, the default directory is returned.
*/
export function getHooksFileName(svelteConfig: Config, hookType: 'client' | 'server'): string {
return svelteConfig.kit?.files?.hooks?.[hookType] || `src/hooks.${hookType}`;
}

/**
* Attempts to read a custom output directory that can be specidied in the options
* of a SvelteKit adapter. If no custom output directory is specified, the default
* directory is returned.
*
* To get the directory, we have to apply a hack and call the adapter's adapt method
*/
export async function getAdapterOutputDir(svelteConfig: Config, adapter: SupportedSvelteKitAdapters): Promise<string> {
if (adapter === 'node') {
return await getNodeAdapterOutputDir(svelteConfig);
}

// Auto and Vercel adapters simply use config.kit.outDir
// Let's also use this directory for the 'other' case
return path.join(svelteConfig.kit?.outDir || '.svelte-kit', 'output');
}

/**
* To get the Node adapter output directory, we have to apply a hack and call the adapter's adapt method
* with a custom adapter `Builder` that only calls the `writeClient` method.
* This method is the first method that is called with the output directory.
* Once we obtained the output directory, we throw an error to exit the adapter.
*
* see: https://github.com/sveltejs/kit/blob/master/packages/adapter-node/index.js#L17
*
*/
export async function getAdapterOutputDir(svelteConfig: Config): Promise<string> {
async function getNodeAdapterOutputDir(svelteConfig: Config): Promise<string> {
// 'build' is the default output dir for the node adapter
let outputDir = 'build';

if (!svelteConfig.kit?.adapter) {
return outputDir;
}

const adapter = svelteConfig.kit.adapter;
const nodeAdapter = svelteConfig.kit.adapter;

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

try {
await adapter.adapt(adapterBuilder);
await nodeAdapter.adapt(adapterBuilder);
} catch (_) {
// We expect the adapter to throw in writeClient!
}
Expand Down
36 changes: 35 additions & 1 deletion packages/sveltekit/test/server/utils.test.ts
@@ -1,6 +1,8 @@
import { RewriteFrames } from '@sentry/integrations';
import type { StackFrame } from '@sentry/types';
import { basename } from '@sentry/utils';

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

const MOCK_REQUEST_EVENT: any = {
Expand Down Expand Up @@ -69,7 +71,7 @@ describe('rewriteFramesIteratee', () => {
expect(result).not.toHaveProperty('module');
});

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

expect(result).toStrictEqual(defaultResult);
});

it.each([
['adapter-node', 'build', '/absolute/path/to/build/server/chunks/3-ab34d22f.js', 'app:///chunks/3-ab34d22f.js'],
[
'adapter-auto',
'.svelte-kit/output',
'/absolute/path/to/.svelte-kit/output/server/entries/pages/page.ts.js',
'app:///entries/pages/page.ts.js',
],
])(
'removes the absolut path to the server output dir, if the output dir is available (%s)',
(_, outputDir, frameFilename, modifiedFilename) => {
(globalThis as GlobalWithSentryValues).__sentry_sveltekit_output_dir = outputDir;

const frame: StackFrame = {
filename: frameFilename,
lineno: 1,
colno: 1,
module: basename(frameFilename),
};

const result = rewriteFramesIteratee({ ...frame });

expect(result).toStrictEqual({
filename: modifiedFilename,
lineno: 1,
colno: 1,
});

delete (globalThis as GlobalWithSentryValues).__sentry_sveltekit_output_dir;
},
);
});
34 changes: 34 additions & 0 deletions packages/sveltekit/test/vite/injectGlobalValues.test.ts
@@ -0,0 +1,34 @@
import { getGlobalValueInjectionCode } from '../../src/vite/injectGlobalValues';

describe('getGlobalValueInjectionCode', () => {
it('returns code that injects values into the global object', () => {
const injectionCode = getGlobalValueInjectionCode({
// @ts-ignore - just want to test this with multiple values
something: 'else',
__sentry_sveltekit_output_dir: '.svelte-kit/output',
});
expect(injectionCode).toEqual(`var _global =
typeof window !== 'undefined' ?
window :
typeof globalThis !== 'undefined' ?
globalThis :
typeof global !== 'undefined' ?
global :
typeof self !== 'undefined' ?
self :
{};
_global["something"] = "else";
_global["__sentry_sveltekit_output_dir"] = ".svelte-kit/output";
`);

// Check that the code above is in fact valid and works as expected
// The return value of eval here is the value of the last expression in the code
expect(eval(`${injectionCode}`)).toEqual('.svelte-kit/output');

delete globalThis.__sentry_sveltekit_output_dir;
});

it('returns empty string if no values are passed', () => {
expect(getGlobalValueInjectionCode({})).toEqual('');
});
});