Skip to content

Commit

Permalink
feat(sveltekit): Add source maps support for Vercel (lambda) (#8256)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Lms24 committed Jun 1, 2023
1 parent d5551aa commit 05cf332
Show file tree
Hide file tree
Showing 11 changed files with 257 additions and 26 deletions.
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) => {
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('');
});
});

0 comments on commit 05cf332

Please sign in to comment.