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..018e02d8663a --- /dev/null +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -0,0 +1,96 @@ +import { consoleSandbox } from '@sentry/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { Plugin, ResolvedConfig } from 'vite'; + +/** + * 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`. + * + * Supports: + * - Nitro deployments (reads output dir from the Nitro Vite environment config) + * - Cloudflare/Netlify deployments (outputs to `dist/server`) + */ +export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: string): Plugin { + let serverOutputDir: string | undefined; + + return { + name: 'sentry-tanstackstart-copy-instrumentation-file', + apply: 'build', + enforce: 'post', + + configResolved(resolvedConfig: ResolvedConfig) { + const plugins = resolvedConfig.plugins || []; + const hasPlugin = (name: string): boolean => plugins.some(p => p.name === name); + + if (hasPlugin('nitro')) { + // Nitro case: read server dir from the nitro environment config + // Vite 6 environment configs are not part of the public type definitions yet, + // so we need to access them via an index signature. + const environments = (resolvedConfig as Record)['environments'] as + | Record } } }> + | undefined; + const nitroEnv = environments?.nitro; + if (nitroEnv) { + const rollupOutput = nitroEnv.build?.rollupOptions?.output; + const dir = Array.isArray(rollupOutput) ? rollupOutput[0]?.dir : rollupOutput?.dir; + if (dir) { + serverOutputDir = dir; + } + } + } else if (hasPlugin('cloudflare') || hasPlugin('netlify')) { + serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); + } else { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry TanStack Start] 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() { + if (!serverOutputDir) { + return; + } + + const instrumentationFileName = instrumentationFilePath || 'instrument.server.mjs'; + const instrumentationSource = path.resolve(process.cwd(), instrumentationFileName); + + try { + await fs.promises.access(instrumentationSource, fs.constants.F_OK); + } catch { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + `[Sentry TanStack Start] No ${instrumentationFileName} file found in project root. ` + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + }); + return; + } + + 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); + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`[Sentry TanStack Start] Copied ${destinationFileName} to ${destination}`); + }); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn(`[Sentry TanStack Start] 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..651dff1d7ebb 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,15 @@ 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; } /** @@ -53,6 +63,9 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + // copy instrumentation file to build output + plugins.push(makeCopyInstrumentationFilePlugin(options.instrumentationFilePath)); + // 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..0ec1ce8b1d2a --- /dev/null +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -0,0 +1,340 @@ +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(), + }, + constants: { + F_OK: 0, + }, +})); + +type AnyFunction = (...args: unknown[]) => unknown; + +describe('makeCopyInstrumentationFilePlugin()', () => { + let plugin: Plugin; + + beforeEach(() => { + vi.clearAllMocks(); + plugin = makeCopyInstrumentationFilePlugin(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('has the correct plugin name', () => { + expect(plugin.name).toBe('sentry-tanstackstart-copy-instrumentation-file'); + }); + + it('applies only to build', () => { + expect(plugin.apply).toBe('build'); + }); + + it('enforces post', () => { + expect(plugin.enforce).toBe('post'); + }); + + describe('configResolved', () => { + it('detects Nitro environment and reads output dir', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + // Verify by calling closeBundle - it should attempt to access the file + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('detects Nitro environment with array rollup output', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: [{ dir: '/project/.output/server' }], + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('detects Cloudflare plugin and sets dist/server as output dir', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'cloudflare' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('detects Netlify plugin and sets dist/server as output dir', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'netlify' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('logs a warning and does not set output dir when no recognized plugin is detected', () => { + 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); + + (plugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] 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(); + + warnSpy.mockRestore(); + }); + }); + + describe('closeBundle', () => { + it('copies instrumentation file when it exists and output dir is set', async () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } 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.access).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + fs.constants.F_OK, + ); + 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('does nothing when no server output dir 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(fs.promises.access).not.toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('warns and does not copy when instrumentation file does not exist', async () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (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 TanStack Start] No instrument.server.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + + warnSpy.mockRestore(); + }); + + it('logs a warning when copy fails', async () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } 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).mockRejectedValueOnce(new Error('Permission denied')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await (plugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', + expect.any(Error), + ); + }); + + it('uses custom instrumentation file path when provided', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin('custom/path/my-instrument.mjs'); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (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'), + fs.constants.F_OK, + ); + 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 custom instrumentation file is not found', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin('custom/my-instrument.mjs'); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (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 TanStack Start] 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', () => {