diff --git a/CHANGELOG.md b/CHANGELOG.md index 80dafba422c2..d48ff1cfb8fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,23 @@ - "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** +- **feat(tanstackstart-react): Auto-instrument global middleware in `sentryTanstackStart` Vite plugin** + + The `sentryTanstackStart` Vite plugin now automatically instruments `requestMiddleware` and `functionMiddleware` arrays in `createStart()`. This captures performance data without requiring manual wrapping. + + Auto-instrumentation is enabled by default. To disable it: + + ```ts + // vite.config.ts + sentryTanstackStart({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'your-org', + project: 'your-project', + autoInstrumentMiddleware: false, + }); + ``` + +- **feat(tanstackstart-react): Add sentryTanstackStart vite plugin to manage automatic source map uploads** You can now configure source maps upload for TanStack Start using the `sentryTanstackStart` Vite plugin: diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts index daf81ea97e10..780d8a3a2a9d 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts @@ -2,13 +2,15 @@ import { createMiddleware } from '@tanstack/react-start'; import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; // Global request middleware - runs on every request -const globalRequestMiddleware = createMiddleware().server(async ({ next }) => { +// NOTE: This is exported unwrapped to test auto-instrumentation via the Vite plugin +export const globalRequestMiddleware = createMiddleware().server(async ({ next }) => { console.log('Global request middleware executed'); return next(); }); // Global function middleware - runs on every server function -const globalFunctionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { +// NOTE: This is exported unwrapped to test auto-instrumentation via the Vite plugin +export const globalFunctionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { console.log('Global function middleware executed'); return next(); }); @@ -37,17 +39,13 @@ const errorMiddleware = createMiddleware({ type: 'function' }).server(async () = throw new Error('Middleware Error Test'); }); -// Manually wrap middlewares with Sentry +// Manually wrap middlewares with Sentry (for middlewares that won't be auto-instrumented) export const [ - wrappedGlobalRequestMiddleware, - wrappedGlobalFunctionMiddleware, wrappedServerFnMiddleware, wrappedServerRouteRequestMiddleware, wrappedEarlyReturnMiddleware, wrappedErrorMiddleware, ] = wrapMiddlewaresWithSentry({ - globalRequestMiddleware, - globalFunctionMiddleware, serverFnMiddleware, serverRouteRequestMiddleware, earlyReturnMiddleware, diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts index eecd2816e492..0dc32ebd112f 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts @@ -1,9 +1,10 @@ import { createStart } from '@tanstack/react-start'; -import { wrappedGlobalRequestMiddleware, wrappedGlobalFunctionMiddleware } from './middleware'; +// NOTE: These are NOT wrapped - auto-instrumentation via the Vite plugin will wrap them +import { globalRequestMiddleware, globalFunctionMiddleware } from './middleware'; export const startInstance = createStart(() => { return { - requestMiddleware: [wrappedGlobalRequestMiddleware], - functionMiddleware: [wrappedGlobalFunctionMiddleware], + requestMiddleware: [globalRequestMiddleware], + functionMiddleware: [globalFunctionMiddleware], }; }); diff --git a/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts b/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts new file mode 100644 index 000000000000..351e8b27ad7b --- /dev/null +++ b/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts @@ -0,0 +1,88 @@ +import type { Plugin } from 'vite'; + +type AutoInstrumentMiddlewareOptions = { + enabled?: boolean; + debug?: boolean; +}; + +/** + * A Vite plugin that automatically instruments TanStack Start middlewares + * by wrapping `requestMiddleware` and `functionMiddleware` arrays in `createStart()`. + */ +export function makeAutoInstrumentMiddlewarePlugin(options: AutoInstrumentMiddlewareOptions = {}): Plugin { + const { enabled = true, debug = false } = options; + + return { + name: 'sentry-tanstack-middleware-auto-instrument', + enforce: 'pre', + + transform(code, id) { + if (!enabled) { + return null; + } + + // Skip if not a TS/JS file + if (!/\.(ts|tsx|js|jsx|mjs|mts)$/.test(id)) { + return null; + } + + // Only wrap requestMiddleware and functionMiddleware in createStart() + if (!code.includes('createStart')) { + return null; + } + + // Skip if the user already did some manual wrapping + if (code.includes('wrapMiddlewaresWithSentry')) { + return null; + } + + let transformed = code; + let needsImport = false; + + transformed = transformed.replace( + /(requestMiddleware|functionMiddleware)\s*:\s*\[([^\]]*)\]/g, + (match, key, contents) => { + const objContents = arrayToObjectShorthand(contents); + if (objContents) { + needsImport = true; + if (debug) { + // eslint-disable-next-line no-console + console.log(`[Sentry] Auto-wrapping ${key} in ${id}`); + } + return `${key}: wrapMiddlewaresWithSentry(${objContents})`; + } + return match; + }, + ); + + if (needsImport) { + transformed = `import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react';\n${transformed}`; + return { code: transformed, map: null }; + } + + return null; + }, + }; +} + +/** + * Convert array contents to object shorthand syntax. + * e.g., "foo, bar, baz" → "{ foo, bar, baz }" + * + * Returns null if contents contain non-identifier expressions (function calls, etc.) + * which cannot be converted to object shorthand. + */ +export function arrayToObjectShorthand(contents: string): string | null { + const items = contents + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + // Only convert if all items are valid identifiers (no complex expressions) + const allIdentifiers = items.every(item => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(item)); + if (!allIdentifiers || items.length === 0) { + return null; + } + + return `{ ${items.join(', ')} }`; +} diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts index 4af3423136fb..85143344028d 100644 --- a/packages/tanstackstart-react/src/vite/index.ts +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -1 +1,2 @@ export { sentryTanstackStart } from './sentryTanstackStart'; +export type { SentryTanstackStartOptions } from './sentryTanstackStart'; diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 00dc145117be..494159ea12d0 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,7 +1,26 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; +import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +/** + * Build-time options for the Sentry TanStack Start SDK. + */ +export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { + /** + * If this flag is `true`, the Sentry plugins will automatically instrument TanStack Start middlewares. + * + * This wraps global middlewares (`requestMiddleware` and `functionMiddleware`) in `createStart()` with Sentry + * instrumentation to capture performance data. + * + * Set to `false` to disable automatic middleware instrumentation if you prefer to wrap middlewares manually + * using `wrapMiddlewaresWithSentry`. + * + * @default true + */ + autoInstrumentMiddleware?: boolean; +} + /** * Vite plugins for the Sentry TanStack Start SDK. * @@ -26,14 +45,21 @@ import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourc * @param options - Options to configure the Sentry Vite plugins * @returns An array of Vite plugins */ -export function sentryTanstackStart(options: BuildTimeOptionsBase = {}): Plugin[] { - // Only add plugins in production builds +export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { + // only add plugins in production builds if (process.env.NODE_ENV === 'development') { return []; } const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + // middleware auto-instrumentation + const autoInstrumentMiddleware = options.autoInstrumentMiddleware !== false; + if (autoInstrumentMiddleware) { + plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); + } + + // source maps const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; if (!sourceMapsDisabled) { plugins.push(...makeEnableSourceMapsVitePlugin(options)); diff --git a/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts b/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts new file mode 100644 index 000000000000..e0fd35a488e1 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts @@ -0,0 +1,137 @@ +import type { Plugin } from 'vite'; +import { describe, expect, it } from 'vitest'; +import { arrayToObjectShorthand, makeAutoInstrumentMiddlewarePlugin } from '../../src/vite/autoInstrumentMiddleware'; + +type PluginWithTransform = Plugin & { + transform: (code: string, id: string) => { code: string; map: null } | null; +}; + +describe('makeAutoInstrumentMiddlewarePlugin', () => { + const createStartFile = ` +import { createStart } from '@tanstack/react-start'; +import { authMiddleware, loggingMiddleware } from './middleware'; + +export const startInstance = createStart(() => ({ + requestMiddleware: [authMiddleware], + functionMiddleware: [loggingMiddleware], +})); +`; + + it('instruments a file with createStart and middleware arrays', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const result = plugin.transform(createStartFile, '/app/start.ts'); + + expect(result).not.toBeNull(); + expect(result!.code).toContain("import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'"); + expect(result!.code).toContain('requestMiddleware: wrapMiddlewaresWithSentry({ authMiddleware })'); + expect(result!.code).toContain('functionMiddleware: wrapMiddlewaresWithSentry({ loggingMiddleware })'); + }); + + it('does not instrument files without createStart', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const code = "export const foo = 'bar';"; + const result = plugin.transform(code, '/app/other.ts'); + + expect(result).toBeNull(); + }); + + it('does not instrument non-TS/JS files', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const result = plugin.transform(createStartFile, '/app/start.css'); + + expect(result).toBeNull(); + }); + + it('does not instrument when enabled is false', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin({ enabled: false }) as PluginWithTransform; + const result = plugin.transform(createStartFile, '/app/start.ts'); + + expect(result).toBeNull(); + }); + + it('wraps single middleware entry correctly', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const code = ` +import { createStart } from '@tanstack/react-start'; +createStart(() => ({ requestMiddleware: [singleMiddleware] })); +`; + const result = plugin.transform(code, '/app/start.ts'); + + expect(result!.code).toContain('requestMiddleware: wrapMiddlewaresWithSentry({ singleMiddleware })'); + }); + + it('wraps multiple middleware entries correctly', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const code = ` +import { createStart } from '@tanstack/react-start'; +createStart(() => ({ requestMiddleware: [a, b, c] })); +`; + const result = plugin.transform(code, '/app/start.ts'); + + expect(result!.code).toContain('requestMiddleware: wrapMiddlewaresWithSentry({ a, b, c })'); + }); + + it('does not wrap empty middleware arrays', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const code = ` +import { createStart } from '@tanstack/react-start'; +createStart(() => ({ requestMiddleware: [] })); +`; + const result = plugin.transform(code, '/app/start.ts'); + + expect(result).toBeNull(); + }); + + it('does not wrap if middleware contains function calls', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const code = ` +import { createStart } from '@tanstack/react-start'; +createStart(() => ({ requestMiddleware: [getMiddleware()] })); +`; + const result = plugin.transform(code, '/app/start.ts'); + + expect(result).toBeNull(); + }); + + it('does not instrument files that already use wrapMiddlewaresWithSentry', () => { + const plugin = makeAutoInstrumentMiddlewarePlugin() as PluginWithTransform; + const code = ` +import { createStart } from '@tanstack/react-start'; +import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; +createStart(() => ({ requestMiddleware: wrapMiddlewaresWithSentry({ myMiddleware }) })); +`; + const result = plugin.transform(code, '/app/start.ts'); + + expect(result).toBeNull(); + }); +}); + +describe('arrayToObjectShorthand', () => { + it('converts single identifier', () => { + expect(arrayToObjectShorthand('foo')).toBe('{ foo }'); + }); + + it('converts multiple identifiers', () => { + expect(arrayToObjectShorthand('foo, bar, baz')).toBe('{ foo, bar, baz }'); + }); + + it('handles whitespace', () => { + expect(arrayToObjectShorthand(' foo , bar ')).toBe('{ foo, bar }'); + }); + + it('returns null for empty string', () => { + expect(arrayToObjectShorthand('')).toBeNull(); + }); + + it('returns null for function calls', () => { + expect(arrayToObjectShorthand('getMiddleware()')).toBeNull(); + }); + + it('returns null for spread syntax', () => { + expect(arrayToObjectShorthand('...middlewares')).toBeNull(); + }); + + it('returns null for mixed valid and invalid', () => { + expect(arrayToObjectShorthand('foo, bar(), baz')).toBeNull(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 390b601d8808..d97ad9e16807 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -21,11 +21,21 @@ const mockEnableSourceMapsPlugin: Plugin = { config: vi.fn(), }; +const mockMiddlewarePlugin: Plugin = { + name: 'sentry-tanstack-middleware-auto-instrument', + apply: 'build', + transform: vi.fn(), +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), })); +vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ + makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -36,47 +46,72 @@ describe('sentryTanstackStart()', () => { process.env.NODE_ENV = 'production'; }); - it('returns plugins in production mode', () => { - const plugins = sentryTanstackStart({ org: 'test-org' }); + describe('source maps', () => { + it('returns source maps plugins in production mode', () => { + const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); - }); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + }); - it('returns no plugins in development mode', () => { - process.env.NODE_ENV = 'development'; + it('returns no plugins in development mode', () => { + process.env.NODE_ENV = 'development'; - const plugins = sentryTanstackStart({ org: 'test-org' }); + const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([]); - }); + expect(plugins).toEqual([]); + }); - it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { - const plugins = sentryTanstackStart({ - sourcemaps: { disable: true }, + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + sourcemaps: { disable: true }, + }); + + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); - }); + it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + sourcemaps: { disable: 'disable-upload' }, + }); - 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).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + sourcemaps: { disable: false }, + }); + + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + }); }); - it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { - const plugins = sentryTanstackStart({ - sourcemaps: { disable: false }, + describe('middleware auto-instrumentation', () => { + it('includes middleware plugin by default', () => { + const plugins = sentryTanstackStart({ sourcemaps: { disable: true } }); + + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); - }); + it('includes middleware plugin when autoInstrumentMiddleware is true', () => { + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: true, + sourcemaps: { disable: true }, + }); + + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + }); - it('returns Sentry Vite Plugins and enable source maps plugin by default when sourcemaps is not specified', () => { - const plugins = sentryTanstackStart({}); + it('does not include middleware plugin when autoInstrumentMiddleware is false', () => { + const plugins = sentryTanstackStart({ + autoInstrumentMiddleware: false, + sourcemaps: { disable: true }, + }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + }); }); });