-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Changes from all commits
c97c324
dca9d06
fc5bfe7
7cc99cd
367bffb
cf61939
5164de0
974f0cb
acbe928
0366d2d
3de3f57
73c80f5
63b504a
c52d220
07cf339
053a47b
6e5c009
006c45b
a30b3e2
066d506
ae60d70
a25b27b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. l/Q: is there some kind of There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from './server'; | ||
export * from './vite'; |
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`]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} | ||
}; |
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'; |
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); | ||
}), | ||
]; | ||
} |
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; | ||
} |
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; | ||
} |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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 thesentryConfig
in theconfig
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 intobuildEnd
but maybe it's worth a shot. we could even try to write it onto the global object to pass it over 😅There was a problem hiding this comment.
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