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(react-router): Add build-time config #15406

Merged
merged 22 commits into from
Feb 27, 2025
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: just to confirm does this not throw a type error? or is defineConfig lenient enough to allow adding arbitrary keys to the config object?

Copy link
Member

@Lms24 Lms24 Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also something to explore in the future: Maybe we can add a plugin in sentryReactRouter that adds the sentryConfig in the config hook to the vite config, so that users don't have to do it explicitly. Not sure if you already tried this, or which instance of the vite config is passed into buildEnd but maybe it's worth a shot. we could even try to write it onto the global object to pass it over 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't throw a type error, but you are right the best solution for this would be to define this in a plugin that we add! Will do that in a follow up task

};
});
```

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,
Copy link
Member

@Lms24 Lms24 Feb 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l/Q: is there some kind of sequence() helper or so in case people have more than one buildEnd callback?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this case they could just

buildEnd: ({ viteConfig, reactRouterConfig, buildManifest }) => {
    // do their stuff
    sentryOnBuildEnd({ viteConfig, reactRouterConfig, buildManifest });
  },

} 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`];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m/something to follow up on: We only want to delete source maps by default if it was us who turned on source map generation in the first place. This was the reason why I had to pass the promise for filesToDeleteAfterUpload in SvelteKit, because I only knew that once makeEnableSourceMapsPlugin's config hook was invoked. But since we have the vite config here, and we don't rely on the original file deletion plugin, maybe we can solve this simpler 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah my issue was that I only get the final vite config here and not the initial one, BUT I guess we can maybe just write that into the config as well with a custom plugin (like the sentryConfig from your comment above)

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
Loading