Skip to content

Commit

Permalink
feat(react-router): Add build-time config (#15406)
Browse files Browse the repository at this point in the history
- Adds a vite plugin for react router that handles:
  - Updating sourcemap settings
  - Release injection
  - Telemetry Data
- Adds a sentryOnBuildEnd hook that handles:
  - Creating releases
  - DebugId injection
  - Uploading sourcemaps
  - Deleting sourcemaps after upload

closes #15188

---------

Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com>
  • Loading branch information
chargome and s1gr1d authored Feb 27, 2025
1 parent 1163332 commit 9a55e17
Show file tree
Hide file tree
Showing 15 changed files with 1,630 additions and 16 deletions.
36 changes: 36 additions & 0 deletions packages/react-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,39 @@ Update the `start` and `dev` script to include the instrumentation file:
"start": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js",
}
```

## Build-time Config

Update your vite.config.ts file to include the `sentryReactRouter` plugin and also add your config options to the vite config (this is required for uploading sourcemaps at the end of the build):

```ts
import { reactRouter } from '@react-router/dev/vite';
import { sentryReactRouter } from '@sentry/react-router';
import { defineConfig } from 'vite';

const sentryConfig = {
authToken: '...',
org: '...',
project: '...',
// rest of your config
};

export default defineConfig(config => {
return {
plugins: [reactRouter(), sentryReactRouter(sentryConfig, config)],
sentryConfig,
};
});
```

Next, in your `react-router.config.ts` file, include the `sentryOnBuildEnd` hook:

```ts
import type { Config } from '@react-router/dev/config';
import { sentryOnBuildEnd } from '@sentry/react-router';

export default {
ssr: true,
buildEnd: sentryOnBuildEnd,
} satisfies Config;
```
7 changes: 6 additions & 1 deletion packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@
"access": "public"
},
"dependencies": {
"@sentry/vite-plugin": "^3.2.0",
"@sentry/cli": "^2.42.1",
"glob": "11.0.1",
"@sentry/browser": "9.2.0",
"@sentry/core": "9.2.0",
"@sentry/node": "9.2.0"
},
"devDependencies": {
"@react-router/node": "^7.1.5",
"react-router": "^7.1.5"
"@react-router/dev": "^7.1.5",
"react-router": "^7.1.5",
"vite": "^6.1.0"
},
"peerDependencies": {
"@react-router/node": "7.x",
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default [
makeBaseNPMConfig({
entrypoints: ['src/index.server.ts', 'src/index.client.ts'],
packageSpecificConfig: {
external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'],
external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime', 'vite'],
output: {
// make it so Rollup calms down about the fact that we're combining default and named exports
exports: 'named',
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/index.server.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './server';
export * from './vite';
1 change: 1 addition & 0 deletions packages/react-router/src/index.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

export * from './client';
export * from './server';
export * from './vite';

import type { Integration, Options, StackParser } from '@sentry/core';
import type * as clientSdk from './client';
Expand Down
113 changes: 113 additions & 0 deletions packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { rm } from 'node:fs/promises';
import type { Config } from '@react-router/dev/dist/config';
import SentryCli from '@sentry/cli';
import { glob } from 'glob';
import type { SentryReactRouterBuildOptions } from '../types';

type BuildEndHook = NonNullable<Config['buildEnd']>;

function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions {
if (!viteConfig || typeof viteConfig !== 'object' || !('sentryConfig' in viteConfig)) {
// eslint-disable-next-line no-console
console.error('[Sentry] sentryConfig not found - it needs to be passed to vite.config.ts');
}

return (viteConfig as { sentryConfig: SentryReactRouterBuildOptions }).sentryConfig;
}

/**
* A build end hook that handles Sentry release creation and source map uploads.
* It creates a new Sentry release if configured, uploads source maps to Sentry,
* and optionally deletes the source map files after upload.
*/
export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteConfig }) => {
const {
authToken,
org,
project,
release,
sourceMapsUploadOptions = { enabled: true },
debug = false,
} = getSentryConfig(viteConfig);

const cliInstance = new SentryCli(null, {
authToken,
org,
project,
});
// check if release should be created
if (release?.name) {
try {
await cliInstance.releases.new(release.name);
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Sentry] Could not create release', error);
}
}

if (sourceMapsUploadOptions?.enabled ?? (true && viteConfig.build.sourcemap !== false)) {
// inject debugIds
try {
await cliInstance.execute(['sourcemaps', 'inject', reactRouterConfig.buildDirectory], debug);
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Sentry] Could not inject debug ids', error);
}

// upload sourcemaps
try {
await cliInstance.releases.uploadSourceMaps(release?.name || 'undefined', {
include: [
{
paths: [reactRouterConfig.buildDirectory],
},
],
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Sentry] Could not upload sourcemaps', error);
}
}
// delete sourcemaps after upload
let updatedFilesToDeleteAfterUpload = sourceMapsUploadOptions?.filesToDeleteAfterUpload;
// set a default value no option was set
if (typeof sourceMapsUploadOptions?.filesToDeleteAfterUpload === 'undefined') {
updatedFilesToDeleteAfterUpload = [`${reactRouterConfig.buildDirectory}/**/*.map`];
if (debug) {
// eslint-disable-next-line no-console
console.info(
`[Sentry] Automatically setting \`sourceMapsUploadOptions.filesToDeleteAfterUpload: ${JSON.stringify(
updatedFilesToDeleteAfterUpload,
)}\` to delete generated source maps after they were uploaded to Sentry.`,
);
}
}
if (updatedFilesToDeleteAfterUpload) {
try {
const filePathsToDelete = await glob(updatedFilesToDeleteAfterUpload, {
absolute: true,
nodir: true,
});
if (debug) {
filePathsToDelete.forEach(filePathToDelete => {
// eslint-disable-next-line no-console
console.info(`Deleting asset after upload: ${filePathToDelete}`);
});
}
await Promise.all(
filePathsToDelete.map(filePathToDelete =>
rm(filePathToDelete, { force: true }).catch((e: unknown) => {
if (debug) {
// This is allowed to fail - we just don't do anything
// eslint-disable-next-line no-console
console.debug(`An error occurred while attempting to delete asset: ${filePathToDelete}`, e);
}
}),
),
);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error deleting files after sourcemap upload:', error);
}
}
};
3 changes: 3 additions & 0 deletions packages/react-router/src/vite/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { sentryReactRouter } from './plugin';
export { sentryOnBuildEnd } from './buildEnd/handleOnBuildEnd';
export type { SentryReactRouterBuildOptions } from './types';
37 changes: 37 additions & 0 deletions packages/react-router/src/vite/makeCustomSentryVitePlugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { type Plugin } from 'vite';
import type { SentryReactRouterBuildOptions } from './types';

/**
* Create a custom subset of sentry's vite plugins
*/
export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuildOptions): Promise<Plugin[]> {
const { debug, unstable_sentryVitePluginOptions, bundleSizeOptimizations, authToken, org, project, telemetry } =
options;

const sentryVitePlugins = sentryVitePlugin({
authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN,
bundleSizeOptimizations,
debug: debug ?? false,
org: org ?? process.env.SENTRY_ORG,
project: project ?? process.env.SENTRY_PROJECT,
telemetry: telemetry ?? true,
_metaOptions: {
telemetry: {
metaFramework: 'react-router',
},
},
// will be handled in buildEnd hook
sourcemaps: {
disable: true,
},
...unstable_sentryVitePluginOptions,
}) as Plugin[];

// only use a subset of the plugins as all upload and file deletion tasks will be handled in the buildEnd hook
return [
...sentryVitePlugins.filter(plugin => {
return ['sentry-telemetry-plugin', 'sentry-vite-release-injection-plugin'].includes(plugin.name);
}),
];
}
83 changes: 83 additions & 0 deletions packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { consoleSandbox } from '@sentry/core';
import type { Plugin, UserConfig } from 'vite';
import type { SentryReactRouterBuildOptions } from './types';

/**
* A Sentry plugin for React Router to enable "hidden" source maps if they are unset.
*/
export function makeEnableSourceMapsPlugin(options: SentryReactRouterBuildOptions): Plugin {
return {
name: 'sentry-react-router-update-source-map-setting',
apply: 'build',
enforce: 'post',
config(viteConfig) {
return {
...viteConfig,
build: {
...viteConfig.build,
sourcemap: getUpdatedSourceMapSettings(viteConfig, options),
},
};
},
};
}

/** There are 3 ways to set up source map generation
*
* 1. User explicitly disabled source maps
* - keep this setting (emit a warning that errors won't be unminified in Sentry)
* - we won't upload anything
*
* 2. Users enabled source map generation (true, 'hidden', 'inline').
* - keep this setting (don't do anything - like deletion - besides uploading)
*
* 3. Users didn't set source maps generation
* - we enable 'hidden' source maps generation
* - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this)
*
* --> only exported for testing
*/
export function getUpdatedSourceMapSettings(
viteConfig: UserConfig,
sentryPluginOptions?: SentryReactRouterBuildOptions,
): boolean | 'inline' | 'hidden' {
viteConfig.build = viteConfig.build || {};

const viteSourceMap = viteConfig?.build?.sourcemap;
let updatedSourceMapSetting = viteSourceMap;

const settingKey = 'vite.build.sourcemap';

if (viteSourceMap === false) {
updatedSourceMapSetting = viteSourceMap;

consoleSandbox(() => {
// eslint-disable-next-line no-console
console.warn(
`[Sentry] Source map generation is currently disabled in your Vite configuration (\`${settingKey}: false \`). This setting is either a default setting or was explicitly set in your configuration. Sentry won't override this setting. Without source maps, code snippets on the Sentry Issues page will remain minified. To show unminified code, enable source maps in \`${settingKey}\` (e.g. by setting them to \`hidden\`).`,
);
});
} else if (viteSourceMap && ['hidden', 'inline', true].includes(viteSourceMap)) {
updatedSourceMapSetting = viteSourceMap;

if (sentryPluginOptions?.debug) {
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.log(
`[Sentry] We discovered \`${settingKey}\` is set to \`${viteSourceMap.toString()}\`. Sentry will keep this source map setting. This will un-minify the code snippet on the Sentry Issue page.`,
);
});
}
} else {
updatedSourceMapSetting = 'hidden';

consoleSandbox(() => {
// eslint-disable-next-line no-console
console.log(
`[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be deleted after they were uploaded to Sentry.`,
);
});
}

return updatedSourceMapSetting;
}
26 changes: 26 additions & 0 deletions packages/react-router/src/vite/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ConfigEnv } from 'vite';
import { type Plugin } from 'vite';
import { makeCustomSentryVitePlugins } from './makeCustomSentryVitePlugins';
import { makeEnableSourceMapsPlugin } from './makeEnableSourceMapsPlugin';
import type { SentryReactRouterBuildOptions } from './types';

/**
* A Vite plugin for Sentry that handles source map uploads and bundle size optimizations.
*
* @param options - Configuration options for the Sentry Vite plugin
* @param viteConfig - The Vite user config object
* @returns An array of Vite plugins
*/
export async function sentryReactRouter(
options: SentryReactRouterBuildOptions = {},
config: ConfigEnv,
): Promise<Plugin[]> {
const plugins: Plugin[] = [];

if (process.env.NODE_ENV !== 'development' && config.command === 'build' && config.mode !== 'development') {
plugins.push(makeEnableSourceMapsPlugin(options));
plugins.push(...(await makeCustomSentryVitePlugins(options)));
}

return plugins;
}
Loading

0 comments on commit 9a55e17

Please sign in to comment.