From d632fe8ab902ff77168ad00191831be2d715fd03 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:15:04 +0100 Subject: [PATCH 1/6] feat(tanstackstart-react): Auto-copy build file to correct folder --- .../tanstackstart-react/package.json | 2 +- .../src/vite/copyInstrumentationFile.ts | 78 ++++++ .../src/vite/sentryTanstackStart.ts | 4 + .../test/vite/copyInstrumentationFile.test.ts | 249 ++++++++++++++++++ .../test/vite/sentryTanstackStart.test.ts | 44 +++- 5 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts create mode 100644 packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts 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..154e294223f2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -0,0 +1,78 @@ +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 `instrument.server.mjs` file + * to the server build output directory after the build completes. + * + * Supports: + * - Nitro deployments (reads output dir from the Nitro Vite environment config) + * - Cloudflare/Netlify deployments (outputs to `dist/server`) + */ +export function makeCopyInstrumentationFilePlugin(): Plugin { + let serverOutputDir: string | undefined; + + return { + name: 'sentry-tanstackstart-copy-instrumentation-file', + apply: 'build', + enforce: 'post', + + configResolved(resolvedConfig: ResolvedConfig) { + // 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; + return; + } + } + + // Cloudflare/Netlify case: detect by plugin name + const plugins = resolvedConfig.plugins || []; + const hasCloudflareOrNetlify = plugins.some(p => /cloudflare|netlify/i.test(p.name)); + if (hasCloudflareOrNetlify) { + serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); + } + }, + + async closeBundle() { + if (!serverOutputDir) { + return; + } + + const instrumentationSource = path.resolve(process.cwd(), 'instrument.server.mjs'); + + try { + await fs.promises.access(instrumentationSource, fs.constants.F_OK); + } catch { + // No instrumentation file found — nothing to copy + return; + } + + const destination = path.resolve(serverOutputDir, 'instrument.server.mjs'); + + 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 instrument.server.mjs to ${destination}`); + }); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', error); + }); + } + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index d14033ff052d..a8b8d4e8f8bd 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'; /** @@ -53,6 +54,9 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + // copy instrumentation file to build output + plugins.push(makeCopyInstrumentationFilePlugin()); + // 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..ead32928621f --- /dev/null +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -0,0 +1,249 @@ +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: [], + 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: [], + 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: 'vite-plugin-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-plugin' }], + } 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('does not set output dir when neither Nitro nor Cloudflare/Netlify is detected', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).not.toHaveBeenCalled(); + }); + }); + + describe('closeBundle', () => { + it('copies instrumentation file when it exists and output dir is set', async () => { + const resolvedConfig = { + root: '/project', + plugins: [], + 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; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).not.toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + }); + + it('does nothing when instrumentation file does not exist', async () => { + const resolvedConfig = { + root: '/project', + plugins: [], + 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')); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + }); + + it('logs a warning when copy fails', async () => { + const resolvedConfig = { + root: '/project', + plugins: [], + 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), + ); + + 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', () => { From d6e225dd961bb0e80785120eeedf3a27dae3b1ee Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:32:57 +0100 Subject: [PATCH 2/6] detect by plugin name --- .../src/vite/copyInstrumentationFile.ts | 46 +++++++++++-------- .../test/vite/copyInstrumentationFile.test.ts | 41 ++++++++++++++--- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 154e294223f2..9a4c460051b8 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -20,27 +20,35 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { enforce: 'post', configResolved(resolvedConfig: ResolvedConfig) { - // 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; - return; - } - } - - // Cloudflare/Netlify case: detect by plugin name const plugins = resolvedConfig.plugins || []; - const hasCloudflareOrNetlify = plugins.some(p => /cloudflare|netlify/i.test(p.name)); - if (hasCloudflareOrNetlify) { + 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 determine server output directory. ' + + 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + }); } }, diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index ead32928621f..8778a4af7827 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -45,7 +45,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Nitro environment and reads output dir', () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -71,7 +71,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Nitro environment with array rollup output', () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -94,7 +94,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Cloudflare plugin and sets dist/server as output dir', () => { const resolvedConfig = { root: '/project', - plugins: [{ name: 'vite-plugin-cloudflare' }], + plugins: [{ name: 'cloudflare' }], } as unknown as ResolvedConfig; (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -108,7 +108,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Netlify plugin and sets dist/server as output dir', () => { const resolvedConfig = { root: '/project', - plugins: [{ name: 'netlify-plugin' }], + plugins: [{ name: 'netlify' }], } as unknown as ResolvedConfig; (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -125,11 +125,34 @@ describe('makeCopyInstrumentationFilePlugin()', () => { 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(fs.promises.access).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('logs a warning when no recognized deployment 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); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] Could not determine server output directory. ' + + 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + + warnSpy.mockRestore(); }); }); @@ -137,7 +160,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('copies instrumentation file when it exists and output dir is set', async () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -176,18 +199,22 @@ describe('makeCopyInstrumentationFilePlugin()', () => { 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('does nothing when instrumentation file does not exist', async () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -214,7 +241,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('logs a warning when copy fails', async () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { From 3a48e1b0692ade720b52a3947491c61379d35ee3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:43:00 +0100 Subject: [PATCH 3/6] =?UTF-8?q?deslo=C3=BCg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/vite/copyInstrumentationFile.ts | 3 +-- .../test/vite/copyInstrumentationFile.test.ts | 21 +++---------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 9a4c460051b8..88c6d9c4bb48 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -44,8 +44,7 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - '[Sentry TanStack Start] Could not determine server output directory. ' + - 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + '[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.', ); }); diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index 8778a4af7827..f127fa8d4a8e 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -119,7 +119,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { expect(fs.promises.access).toHaveBeenCalled(); }); - it('does not set output dir when neither Nitro nor Cloudflare/Netlify is detected', () => { + 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' }], @@ -131,26 +131,11 @@ describe('makeCopyInstrumentationFilePlugin()', () => { (plugin.closeBundle as AnyFunction)(); - expect(fs.promises.access).not.toHaveBeenCalled(); - - warnSpy.mockRestore(); - }); - - it('logs a warning when no recognized deployment 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); - expect(warnSpy).toHaveBeenCalledWith( - '[Sentry TanStack Start] Could not determine server output directory. ' + - 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + '[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(); }); From 379adc7d9db095960976a23963dc3f8b187758e4 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:54:31 +0100 Subject: [PATCH 4/6] Add warn message if no instrument file found --- .../src/vite/copyInstrumentationFile.ts | 9 ++++++++- .../test/vite/copyInstrumentationFile.test.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 88c6d9c4bb48..647265893813 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -49,6 +49,7 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { ); }); } + }, async closeBundle() { @@ -61,7 +62,13 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { try { await fs.promises.access(instrumentationSource, fs.constants.F_OK); } catch { - // No instrumentation file found — nothing to copy + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry TanStack Start] No instrument.server.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + }); return; } diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index f127fa8d4a8e..ac84ae38cd3e 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -196,7 +196,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { warnSpy.mockRestore(); }); - it('does nothing when instrumentation file does not exist', async () => { + it('warns and does not copy when instrumentation file does not exist', async () => { const resolvedConfig = { root: '/project', plugins: [{ name: 'nitro' }], @@ -217,10 +217,18 @@ describe('makeCopyInstrumentationFilePlugin()', () => { 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 () => { From 7eb02b344fd93dbbf037862c3a9629b86bf863b8 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 12:05:22 +0100 Subject: [PATCH 5/6] instrument file path configurable --- .../src/vite/copyInstrumentationFile.ts | 19 +++-- .../src/vite/sentryTanstackStart.ts | 11 ++- .../test/vite/copyInstrumentationFile.test.ts | 71 +++++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 647265893813..9a2b22c3bd55 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -4,14 +4,17 @@ import * as path from 'path'; import type { Plugin, ResolvedConfig } from 'vite'; /** - * Creates a Vite plugin that copies the user's `instrument.server.mjs` file + * 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(): Plugin { +export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: string): Plugin { let serverOutputDir: string | undefined; return { @@ -57,7 +60,8 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { return; } - const instrumentationSource = path.resolve(process.cwd(), 'instrument.server.mjs'); + const instrumentationFileName = instrumentationFilePath || 'instrument.server.mjs'; + const instrumentationSource = path.resolve(process.cwd(), instrumentationFileName); try { await fs.promises.access(instrumentationSource, fs.constants.F_OK); @@ -65,26 +69,27 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - '[Sentry TanStack Start] No instrument.server.mjs file found in project root. ' + + `[Sentry TanStack Start] No ${instrumentationFileName} file found in project root. ` + 'The Sentry instrumentation file will not be copied to the build output.', ); }); return; } - const destination = path.resolve(serverOutputDir, 'instrument.server.mjs'); + 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 instrument.server.mjs to ${destination}`); + 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 instrument.server.mjs to build output.', error); + 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 a8b8d4e8f8bd..651dff1d7ebb 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -20,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; } /** @@ -55,7 +64,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; // copy instrumentation file to build output - plugins.push(makeCopyInstrumentationFilePlugin()); + plugins.push(makeCopyInstrumentationFilePlugin(options.instrumentationFilePath)); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index ac84ae38cd3e..0ec1ce8b1d2a 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -262,6 +262,77 @@ describe('makeCopyInstrumentationFilePlugin()', () => { '[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(); }); From dcf888c9d86b1c9c62f37a7c8164ba54cf1d32ba Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 13:18:00 +0100 Subject: [PATCH 6/6] yf --- packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 9a2b22c3bd55..018e02d8663a 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -52,7 +52,6 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri ); }); } - }, async closeBundle() {