diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index d75ebb148639..f5ba627d5c2c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "build": "vite build && cp instrument.server.mjs .output/server", + "build": "vite build", "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts new file mode 100644 index 000000000000..215238042e48 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -0,0 +1,104 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { Plugin, ResolvedConfig } from 'vite'; + +interface CopyInstrumentationFilePluginOptions { + instrumentationFilePath?: string; + serverOutputDir?: string; +} + +/** + * Creates a Vite plugin that copies the user's instrumentation file + * to the server build output directory after the build completes. + * + * By default, copies `instrument.server.mjs` from the project root. + * A custom file path can be provided via `instrumentationFilePath`. + * + * The server output directory can be configured via `serverOutputDir`. + * By default, it will be auto-detected based on the vite plugin being used. + * + * For nitro deployments, we use the Nitro Vite environment config to get the server output directory. + * For cloudflare and netlify deployments, we assume the server output directory is `dist/server`, which is the default output directory for these plugins. + */ +export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationFilePluginOptions): Plugin { + let serverOutputDir: string | undefined; + type RollupOutputDir = { dir?: string }; + type ViteEnvironments = Record; + + return { + name: 'sentry-tanstackstart-copy-instrumentation-file', + apply: 'build', + enforce: 'post', + + configResolved(resolvedConfig: ResolvedConfig) { + // If user provided serverOutputDir, use it directly and skip auto-detection + if (options?.serverOutputDir) { + serverOutputDir = path.resolve(resolvedConfig.root, options.serverOutputDir); + return; + } + + const plugins = resolvedConfig.plugins || []; + const hasPlugin = (name: string): boolean => plugins.some(p => p.name?.includes(name)); + + if (hasPlugin('nitro')) { + // There seems to be no way to access the nitro instance directly to get the server dir, so we need to access it via the vite environment config. + // This works because Nitro's Vite bundler sets the rollup output dir to the resolved serverDir: + // https://github.com/nitrojs/nitro/blob/1954b824597f6ac52fb8b064415cb85d0feda078/src/build/vite/bundler.ts#L35 + const environments = (resolvedConfig as { environments?: ViteEnvironments }).environments; + const nitroEnv = environments?.nitro; + if (nitroEnv) { + const rollupOutput = nitroEnv.build?.rollupOptions?.output; + const dir = rollupOutput?.dir; + if (dir) { + serverOutputDir = dir; + } + } + } else if (hasPlugin('cloudflare') || hasPlugin('netlify')) { + // There seems to be no way for users to configure the server output dir for these plugins, so we just assume it's `dist/server`, which is the default output dir. + serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); + } else { + // eslint-disable-next-line no-console + console.warn( + '[Sentry] Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + } + }, + + async closeBundle() { + // Auto-detection failed, so we don't copy the instrumentation file. + if (!serverOutputDir) { + return; + } + + const instrumentationFileName = options?.instrumentationFilePath || 'instrument.server.mjs'; + const instrumentationSource = path.resolve(process.cwd(), instrumentationFileName); + + // Check if the instrumentation file exists. + try { + await fs.promises.access(instrumentationSource); + } catch { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] No ${instrumentationFileName} file found in project root. ` + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + return; + } + + // Copy the instrumentation file to the server output directory. + const destinationFileName = path.basename(instrumentationFileName); + const destination = path.resolve(serverOutputDir, destinationFileName); + + try { + await fs.promises.mkdir(serverOutputDir, { recursive: true }); + await fs.promises.copyFile(instrumentationSource, destination); + // eslint-disable-next-line no-console + console.log(`[Sentry] Copied ${destinationFileName} to ${destination}`); + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`[Sentry] Failed to copy ${destinationFileName} to build output.`, error); + } + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index d14033ff052d..d2d8051f0fed 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,6 +1,7 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeCopyInstrumentationFilePlugin } from './copyInstrumentationFile'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; /** @@ -19,6 +20,29 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default true */ autoInstrumentMiddleware?: boolean; + + /** + * Path to the instrumentation file to be copied to the server build output directory. + * + * Relative paths are resolved from the current working directory. + * + * @default 'instrument.server.mjs' + */ + instrumentationFilePath?: string; + + /** + * Custom server output directory path for the instrumentation file. + * + * By default, the plugin auto-detects the output directory: + * - For Nitro: reads from Vite environment config + * - For Cloudflare/Netlify: uses `dist/server` + * + * Use this option to override the default when your deployment target + * uses a non-standard output directory. + * + * @example 'build/server' + */ + serverOutputDir?: string; } /** @@ -53,6 +77,14 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + // copy instrumentation file to build output + plugins.push( + makeCopyInstrumentationFilePlugin({ + instrumentationFilePath: options.instrumentationFilePath, + serverOutputDir: options.serverOutputDir, + }), + ); + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts new file mode 100644 index 000000000000..4bd30dd433a0 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -0,0 +1,220 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { Plugin, ResolvedConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeCopyInstrumentationFilePlugin } from '../../src/vite/copyInstrumentationFile'; + +vi.mock('fs', () => ({ + promises: { + access: vi.fn(), + mkdir: vi.fn(), + copyFile: vi.fn(), + }, +})); + +type AnyFunction = (...args: unknown[]) => unknown; + +function createNitroConfig(overrides?: Partial): ResolvedConfig { + return { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { dir: '/project/.output/server' }, + }, + }, + }, + }, + ...overrides, + } as unknown as ResolvedConfig; +} + +describe('makeCopyInstrumentationFilePlugin()', () => { + let plugin: Plugin; + + beforeEach(() => { + vi.clearAllMocks(); + plugin = makeCopyInstrumentationFilePlugin(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('copies instrumentation file with Nitro config', async () => { + const resolvedConfig = createNitroConfig(); + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'instrument.server.mjs')); + expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + path.resolve('/project/.output/server', 'instrument.server.mjs'), + ); + }); + + it.each(['cloudflare', 'netlify'])('copies instrumentation file with %s config', async pluginName => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: pluginName }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'dist', 'server'), { recursive: true }); + }); + + it('warns and does nothing when no recognized plugin is detected', async () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + await (plugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + expect(fs.promises.access).not.toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('uses serverOutputDir option bypassing auto-detection', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'build/custom-server' }); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + // No warning should be logged since serverOutputDir is provided + expect(warnSpy).not.toHaveBeenCalled(); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (customPlugin.closeBundle as AnyFunction)(); + + // Verify the custom serverOutputDir is used + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'build/custom-server'), { + recursive: true, + }); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + path.resolve('/project', 'build/custom-server', 'instrument.server.mjs'), + ); + + warnSpy.mockRestore(); + }); + + it('warns when instrumentation file does not exist', async () => { + const resolvedConfig = createNitroConfig(); + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] No instrument.server.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + + warnSpy.mockRestore(); + }); + + it('warns when copy operation fails', async () => { + const resolvedConfig = createNitroConfig(); + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockRejectedValueOnce(new Error('Permission denied')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await (plugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] Failed to copy instrument.server.mjs to build output.', + expect.any(Error), + ); + }); + + it('uses custom instrumentation file path', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ + instrumentationFilePath: 'custom/path/my-instrument.mjs', + }); + + const resolvedConfig = createNitroConfig(); + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (customPlugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'custom/path/my-instrument.mjs')); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), + path.resolve('/project/.output/server', 'my-instrument.mjs'), + ); + }); + + it('warns with custom file name when file not found', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/my-instrument.mjs' }); + + const resolvedConfig = createNitroConfig(); + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await (customPlugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] No custom/my-instrument.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index ef18da74d03a..400464e204aa 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -28,6 +28,12 @@ const mockMiddlewarePlugin: Plugin = { transform: vi.fn(), }; +const mockCopyInstrumentationPlugin: Plugin = { + name: 'sentry-tanstackstart-copy-instrumentation-file', + apply: 'build', + enforce: 'post', +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -37,6 +43,10 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); +vi.mock('../../src/vite/copyInstrumentationFile', () => ({ + makeCopyInstrumentationFilePlugin: vi.fn(() => mockCopyInstrumentationPlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -51,7 +61,12 @@ describe('sentryTanstackStart()', () => { it('returns source maps plugins in production mode', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockEnableSourceMapsPlugin, + ]); }); it('returns no plugins in development mode', () => { @@ -68,7 +83,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockCopyInstrumentationPlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { @@ -77,7 +92,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: 'disable-upload' }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockCopyInstrumentationPlugin]); }); it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { @@ -86,7 +101,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: false }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockEnableSourceMapsPlugin, + ]); }); }); @@ -94,7 +114,12 @@ describe('sentryTanstackStart()', () => { it('includes middleware plugin by default', () => { const plugins = sentryTanstackStart({ sourcemaps: { disable: true } }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockMiddlewarePlugin, + ]); }); it('includes middleware plugin when autoInstrumentMiddleware is true', () => { @@ -103,7 +128,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockMiddlewarePlugin, + ]); }); it('does not include middleware plugin when autoInstrumentMiddleware is false', () => { @@ -112,7 +142,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockCopyInstrumentationPlugin]); }); it('passes correct options to makeAutoInstrumentMiddlewarePlugin', () => {