diff --git a/CHANGELOG.md b/CHANGELOG.md index aeaf887eded2..6ea22542ec52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(tanstackstart-react): Add `sentryTanstackStart` Vite plugin for source maps upload** + + You can now configure source maps upload for TanStack Start using the `sentryTanstackStart` Vite plugin: + + ```ts + // vite.config.ts + import { defineConfig } from 'vite'; + import { sentryTanstackStart } from '@sentry/tanstackstart-react'; + import { tanstackStart } from '@tanstack/react-start/plugin/vite'; + + export default defineConfig({ + plugins: [ + sentryTanstackStart({ + org: 'your-org', + project: 'your-project', + }), + tanstackStart(), + ], + }); + ``` + ## 10.34.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 4df9fbb14208..0abe265332df 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -3,12 +3,19 @@ import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; +import { sentryTanstackStart } from '@sentry/tanstackstart-react'; export default defineConfig({ server: { port: 3000, }, plugins: [ + sentryTanstackStart({ + org: process.env.E2E_TEST_SENTRY_ORG_SLUG, + project: process.env.E2E_TEST_SENTRY_PROJECT, + authToken: process.env.E2E_TEST_AUTH_TOKEN, + debug: true, + }), tsConfigPaths(), tanstackStart(), nitro(), diff --git a/packages/solidstart/src/vite/sourceMaps.ts b/packages/solidstart/src/vite/sourceMaps.ts index 0cd44e6a61c7..8d10e6a16112 100644 --- a/packages/solidstart/src/vite/sourceMaps.ts +++ b/packages/solidstart/src/vite/sourceMaps.ts @@ -76,7 +76,7 @@ export function makeEnableSourceMapsVitePlugin(options: SentrySolidStartPluginOp ]; } -/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-j avascript/issues/13993) +/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-javascript/issues/13993) * * 1. User explicitly disabled source maps * - keep this setting (emit a warning that errors won't be unminified in Sentry) diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index eaaa19cff3e0..371bf8b1cef7 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -55,7 +55,11 @@ "@sentry-internal/browser-utils": "10.34.0", "@sentry/core": "10.34.0", "@sentry/node": "10.34.0", - "@sentry/react": "10.34.0" + "@sentry/react": "10.34.0", + "@sentry/vite-plugin": "^4.6.2" + }, + "devDependencies": { + "vite": "^5.4.11" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart-react/src/index.server.ts b/packages/tanstackstart-react/src/index.server.ts index 6b8974481131..775f27086d6a 100644 --- a/packages/tanstackstart-react/src/index.server.ts +++ b/packages/tanstackstart-react/src/index.server.ts @@ -3,3 +3,4 @@ export * from './config'; export * from './server'; export * from './common'; +export * from './vite'; diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index 1ad387ea6a6e..ca41d6ce05ee 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -12,6 +12,7 @@ export * from './config'; export * from './client'; export * from './server'; export * from './common'; +export * from './vite'; /** Initializes Sentry TanStack Start SDK */ export declare function init(options: Options | clientSdk.BrowserOptions | serverSdk.NodeOptions): Client | undefined; diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts new file mode 100644 index 000000000000..4af3423136fb --- /dev/null +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -0,0 +1 @@ +export { sentryTanstackStart } from './sentryTanstackStart'; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts new file mode 100644 index 000000000000..8f1742436f9f --- /dev/null +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -0,0 +1,43 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { Plugin } from 'vite'; +import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; + +/** + * Vite plugins for the Sentry TanStack Start SDK. + * + * @example + * ```typescript + * // vite.config.ts + * import { defineConfig } from 'vite'; + * import { sentryTanstackStart } from '@sentry/tanstackstart-react'; + * import { tanstackStart } from '@tanstack/react-start/plugin/vite'; + * + * export default defineConfig({ + * plugins: [ + * sentryTanstackStart({ + * org: 'your-org', + * project: 'your-project', + * }), + * tanstackStart(), + * ], + * }); + * ``` + * + * @param options - Options to configure the Sentry Vite plugins + * @returns An array of Vite plugins + */ +export function sentryTanstackStart(options: BuildTimeOptionsBase = {}): Plugin[] { + const plugins: Plugin[] = []; + + // Only add plugins in production builds + if (process.env.NODE_ENV !== 'development') { + plugins.push(...makeAddSentryVitePlugin(options)); + + const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; + if (!sourceMapsDisabled) { + plugins.push(...makeEnableSourceMapsVitePlugin(options)); + } + } + + return plugins; +} diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts new file mode 100644 index 000000000000..f31a5bc18679 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -0,0 +1,156 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import type { Plugin, UserConfig } from 'vite'; + +/** + * A Sentry plugin for adding the @sentry/vite-plugin to automatically upload source maps to Sentry. + */ +export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase): Plugin[] { + const { + authToken, + bundleSizeOptimizations, + debug, + errorHandler, + headers, + org, + project, + release, + sentryUrl, + silent, + sourcemaps, + telemetry, + } = options; + + const configPlugin: Plugin = { + name: 'sentry-tanstackstart-source-maps-config', + apply: 'build', + enforce: 'pre', + config(config) { + // Emit a warning if we're auto-deleting source maps + if ( + typeof sourcemaps?.filesToDeleteAfterUpload === 'undefined' && + typeof config.build?.sourcemap === 'undefined' && + debug + ) { + // eslint-disable-next-line no-console + console.log( + '[Sentry] Automatically setting `sourcemaps.filesToDeleteAfterUpload: [".*/**/*.map"]` to delete generated source maps after they were uploaded to Sentry.', + ); + } + }, + }; + + // Default to auto-deleting source maps from hidden directories after upload + // Users can override this by explicitly setting sourcemaps.filesToDeleteAfterUpload + const defaultFilesToDelete = ['.*/**/*.map']; + const filesToDeleteAfterUpload = sourcemaps?.filesToDeleteAfterUpload ?? defaultFilesToDelete; + + const sentryPlugins = sentryVitePlugin({ + authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, + bundleSizeOptimizations: bundleSizeOptimizations ?? undefined, + debug: debug ?? false, + errorHandler, + headers, + org: org ?? process.env.SENTRY_ORG, + project: project ?? process.env.SENTRY_PROJECT, + release, + silent, + sourcemaps: { + assets: sourcemaps?.assets, + disable: sourcemaps?.disable, + ignore: sourcemaps?.ignore, + filesToDeleteAfterUpload, + }, + telemetry: telemetry ?? true, + url: sentryUrl, + _metaOptions: { + telemetry: { + metaFramework: 'tanstackstart-react', + }, + }, + }); + + return [configPlugin, ...sentryPlugins]; +} + +/** + * A Sentry plugin for TanStack Start React to enable "hidden" source maps if they are unset. + */ +export function makeEnableSourceMapsVitePlugin(options: BuildTimeOptionsBase): Plugin[] { + return [ + { + name: 'sentry-tanstackstart-react-source-maps', + 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 (https://github.com/getsentry/sentry-javascript/issues/13993) + * + * 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?: BuildTimeOptionsBase, +): boolean | 'inline' | 'hidden' { + viteConfig.build = viteConfig.build || {}; + + const viteUserSourceMapSetting = viteConfig.build?.sourcemap; + const settingKey = 'vite.build.sourcemap'; + const debug = sentryPluginOptions?.debug; + + // Respect user source map setting if it is explicitly set + if (viteUserSourceMapSetting === false) { + if (debug) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] Source map generation is currently disabled in your TanStack Start configuration (\`${settingKey}: false\`). Sentry won't override this setting. Without source maps, code snippets on the Sentry Issues page will remain minified.`, + ); + } else { + // eslint-disable-next-line no-console + console.warn('[Sentry] Source map generation is disabled in your TanStack Start configuration.'); + } + + return viteUserSourceMapSetting; + } else if (viteUserSourceMapSetting && ['hidden', 'inline', true].includes(viteUserSourceMapSetting)) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We discovered \`${settingKey}\` is set to \`${viteUserSourceMapSetting.toString()}\`. Sentry will keep this source map setting.`, + ); + } + + return viteUserSourceMapSetting; + } + + // If the user did not specify a source map setting, we enable 'hidden' by default + if (debug) { + // 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 'hidden'; +} diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts new file mode 100644 index 000000000000..53b3657c5475 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -0,0 +1,94 @@ +import type { Plugin } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; + +const mockSourceMapsConfigPlugin: Plugin = { + name: 'sentry-tanstackstart-source-maps-config', + apply: 'build', + enforce: 'pre', + config: vi.fn(), +}; + +const mockSentryVitePlugin: Plugin = { + name: 'sentry-vite-debug-id-upload-plugin', + writeBundle: vi.fn(), +}; + +const mockEnableSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-react-source-maps', + apply: 'build', + enforce: 'post', + config: vi.fn(), +}; + +vi.mock('../../src/vite/sourceMaps', () => ({ + makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), + makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), +})); + +describe('sentryTanstackStart()', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NODE_ENV = 'production'; + }); + + afterEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('returns plugins in production mode', () => { + const plugins = sentryTanstackStart({ org: 'test-org' }); + + expect(plugins.length).toBeGreaterThan(0); + }); + + it('returns no plugins in development mode', () => { + process.env.NODE_ENV = 'development'; + + const plugins = sentryTanstackStart({ org: 'test-org' }); + + expect(plugins).toHaveLength(0); + }); + + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { + const plugins = sentryTanstackStart({ + sourcemaps: { disable: true }, + }); + + expect(plugins).toHaveLength(2); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-vite-debug-id-upload-plugin')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-react-source-maps')).toBeUndefined(); + }); + + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { + const plugins = sentryTanstackStart({ + sourcemaps: { disable: 'disable-upload' }, + }); + + expect(plugins).toHaveLength(2); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-vite-debug-id-upload-plugin')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-react-source-maps')).toBeUndefined(); + }); + + it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { + const plugins = sentryTanstackStart({ + sourcemaps: { disable: false }, + }); + + expect(plugins).toHaveLength(3); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-vite-debug-id-upload-plugin')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-react-source-maps')).toBeDefined(); + }); + + it('returns Sentry Vite Plugins and enable source maps plugin by default when sourcemaps is not specified', () => { + const plugins = sentryTanstackStart({}); + + expect(plugins).toHaveLength(3); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-vite-debug-id-upload-plugin')).toBeDefined(); + expect(plugins.find(p => p.name === 'sentry-tanstackstart-react-source-maps')).toBeDefined(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts new file mode 100644 index 000000000000..bc4f5ac64c93 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -0,0 +1,220 @@ +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; +import type { UserConfig } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getUpdatedSourceMapSettings, + makeAddSentryVitePlugin, + makeEnableSourceMapsVitePlugin, +} from '../../src/vite/sourceMaps'; + +const mockedSentryVitePlugin = { + name: 'sentry-vite-debug-id-upload-plugin', + writeBundle: vi.fn(), +}; + +const sentryVitePluginSpy = vi.fn((_options: SentryVitePluginOptions) => [mockedSentryVitePlugin]); + +vi.mock('@sentry/vite-plugin', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('makeEnableSourceMapsVitePlugin()', () => { + it('returns a plugin to enable source maps', () => { + const sourceMapsConfigPlugins = makeEnableSourceMapsVitePlugin({}); + const enableSourceMapPlugin = sourceMapsConfigPlugins[0]; + + expect(enableSourceMapPlugin?.name).toEqual('sentry-tanstackstart-react-source-maps'); + expect(enableSourceMapPlugin?.apply).toEqual('build'); + expect(enableSourceMapPlugin?.enforce).toEqual('post'); + expect(enableSourceMapPlugin?.config).toEqual(expect.any(Function)); + + expect(sourceMapsConfigPlugins).toHaveLength(1); + }); +}); + +describe('makeAddSentryVitePlugin()', () => { + it('passes user-specified vite plugin options to vite plugin', () => { + const errorHandler = vi.fn(); + makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + sentryUrl: 'https://custom.sentry.io', + headers: { 'X-Custom-Header': 'value' }, + silent: true, + errorHandler, + release: { + name: 'my-release', + inject: true, + create: true, + finalize: true, + dist: 'dist-1', + }, + sourcemaps: { + assets: ['dist/**/*.js'], + disable: false, + ignore: ['node_modules/**'], + filesToDeleteAfterUpload: ['baz/*.js'], + }, + bundleSizeOptimizations: { + excludeTracing: true, + }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'my-org', + authToken: 'my-token', + url: 'https://custom.sentry.io', + headers: { 'X-Custom-Header': 'value' }, + silent: true, + errorHandler, + release: { + name: 'my-release', + inject: true, + create: true, + finalize: true, + dist: 'dist-1', + }, + sourcemaps: { + assets: ['dist/**/*.js'], + disable: false, + ignore: ['node_modules/**'], + filesToDeleteAfterUpload: ['baz/*.js'], + }, + bundleSizeOptimizations: { + excludeTracing: true, + }, + }), + ); + }); + + it('returns Sentry Vite plugins and config plugin', () => { + const plugins = makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + }); + + expect(plugins.length).toBeGreaterThanOrEqual(2); + + const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config'); + expect(configPlugin).toBeDefined(); + expect(configPlugin?.apply).toBe('build'); + expect(configPlugin?.enforce).toBe('pre'); + expect(typeof configPlugin?.config).toBe('function'); + }); + + it('uses default filesToDeleteAfterUpload when not specified', () => { + makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: { + assets: undefined, + disable: undefined, + ignore: undefined, + filesToDeleteAfterUpload: ['.*/**/*.map'], + }, + }), + ); + }); + + it('logs auto-delete message when user did not configure sourcemap', () => { + const plugins = makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + debug: true, + }); + + const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config'); + expect(configPlugin).toBeDefined(); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Simulate config hook with no sourcemap configured + if (configPlugin?.config) { + (configPlugin.config as (config: UserConfig) => void)({}); + } + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + + consoleSpy.mockRestore(); + }); + + it('does not log auto-delete message when user configured sourcemap', () => { + const plugins = makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + debug: true, + }); + + const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-source-maps-config'); + expect(configPlugin).toBeDefined(); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Simulate config hook with sourcemap configured + if (configPlugin?.config) { + (configPlugin.config as (config: UserConfig) => void)({ build: { sourcemap: true } }); + } + + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + + consoleSpy.mockRestore(); + }); + + it('sets the correct metaFramework in telemetry options', () => { + makeAddSentryVitePlugin({ + org: 'my-org', + authToken: 'my-token', + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _metaOptions: { + telemetry: { + metaFramework: 'tanstackstart-react', + }, + }, + }), + ); + }); +}); + +describe('getUpdatedSourceMapSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should keep sourcemap as false and emit warning when explicitly disabled', () => { + const result = getUpdatedSourceMapSettings({ build: { sourcemap: false } }); + + expect(result).toBe(false); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalled(); + }); + + it.each([ + ['hidden', 'hidden'], + ['inline', 'inline'], + [true, true], + ] as const)('should keep sourcemap as %s when explicitly set', (input, expected) => { + const result = getUpdatedSourceMapSettings({ build: { sourcemap: input } }); + + expect(result).toBe(expected); + }); + + it('should set sourcemap to hidden when not configured', () => { + const result = getUpdatedSourceMapSettings({}); + + expect(result).toBe('hidden'); + }); +}); diff --git a/packages/tanstackstart-react/tsconfig.json b/packages/tanstackstart-react/tsconfig.json index 220ba3fa2b86..9399ef75ead6 100644 --- a/packages/tanstackstart-react/tsconfig.json +++ b/packages/tanstackstart-react/tsconfig.json @@ -1,9 +1,5 @@ { "extends": "../../tsconfig.json", "include": ["src/**/*"], - "compilerOptions": { - "lib": ["es2020"], - "module": "Node16", - "moduleResolution": "Node16" - } + "compilerOptions": {} }