diff --git a/CHANGELOG.md b/CHANGELOG.md index 069812f2dec0..12ab13cf82b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,17 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(tanstackstart-react): Auto-instrument server function middleware ([#19001](https://github.com/getsentry/sentry-javascript/pull/19001))** + + The `sentryTanstackStart` Vite plugin now automatically instruments middleware in `createServerFn().middleware([...])` calls. This captures performance data without requiring manual wrapping with `wrapMiddlewaresWithSentry()`. + ## 10.38.0 ### Important Changes - **feat(tanstackstart-react): Auto-instrument request middleware ([#18989](https://github.com/getsentry/sentry-javascript/pull/18989))** -The `sentryTanstackStart` Vite plugin now automatically instruments `middleware` arrays in `createFileRoute()`. This captures performance data without requiring manual wrapping with `wrapMiddlewaresWithSentry()`. + The `sentryTanstackStart` Vite plugin now automatically instruments `middleware` arrays in `createFileRoute()`. This captures performance data without requiring manual wrapping with `wrapMiddlewaresWithSentry()`. ### Other Changes 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 60374f8fda60..a15bd3744910 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 @@ -1,5 +1,4 @@ import { createMiddleware } from '@tanstack/react-start'; -import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; // Global request middleware - runs on every request // NOTE: This is exported unwrapped to test auto-instrumentation via the Vite plugin @@ -15,8 +14,8 @@ export const globalFunctionMiddleware = createMiddleware({ type: 'function' }).s return next(); }); -// Server function middleware -const serverFnMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { +// Server function middleware - exported unwrapped for auto-instrumentation via Vite plugin +export const serverFnMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { console.log('Server function middleware executed'); return next(); }); @@ -28,21 +27,15 @@ export const serverRouteRequestMiddleware = createMiddleware().server(async ({ n }); // Early return middleware - returns without calling next() -const earlyReturnMiddleware = createMiddleware({ type: 'function' }).server(async () => { +// Exported unwrapped for auto-instrumentation via Vite plugin +export const earlyReturnMiddleware = createMiddleware({ type: 'function' }).server(async () => { console.log('Early return middleware executed - not calling next()'); return { earlyReturn: true, message: 'Middleware returned early without calling next()' }; }); // Error middleware - throws an exception -const errorMiddleware = createMiddleware({ type: 'function' }).server(async () => { +// Exported unwrapped for auto-instrumentation via Vite plugin +export const errorMiddleware = createMiddleware({ type: 'function' }).server(async () => { console.log('Error middleware executed - throwing error'); throw new Error('Middleware Error Test'); }); - -// Manually wrap middlewares with Sentry (for middlewares that won't be auto-instrumented) -export const [wrappedServerFnMiddleware, wrappedEarlyReturnMiddleware, wrappedErrorMiddleware] = - wrapMiddlewaresWithSentry({ - serverFnMiddleware, - earlyReturnMiddleware, - errorMiddleware, - }); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx index 83ac81c75a62..81d621b92a46 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx @@ -1,10 +1,10 @@ import { createFileRoute } from '@tanstack/react-router'; import { createServerFn } from '@tanstack/react-start'; -import { wrappedServerFnMiddleware, wrappedEarlyReturnMiddleware, wrappedErrorMiddleware } from '../middleware'; +import { serverFnMiddleware, earlyReturnMiddleware, errorMiddleware } from '../middleware'; // Server function with specific middleware (also gets global function middleware) const serverFnWithMiddleware = createServerFn() - .middleware([wrappedServerFnMiddleware]) + .middleware([serverFnMiddleware]) .handler(async () => { console.log('Server function with specific middleware executed'); return { message: 'Server function middleware test' }; @@ -18,7 +18,7 @@ const serverFnWithoutMiddleware = createServerFn().handler(async () => { // Server function with early return middleware (middleware returns without calling next) const serverFnWithEarlyReturnMiddleware = createServerFn() - .middleware([wrappedEarlyReturnMiddleware]) + .middleware([earlyReturnMiddleware]) .handler(async () => { console.log('This should not be executed - middleware returned early'); return { message: 'This should not be returned' }; @@ -26,7 +26,7 @@ const serverFnWithEarlyReturnMiddleware = createServerFn() // Server function with error middleware (middleware throws an error) const serverFnWithErrorMiddleware = createServerFn() - .middleware([wrappedErrorMiddleware]) + .middleware([errorMiddleware]) .handler(async () => { console.log('This should not be executed - middleware threw error'); return { message: 'This should not be returned' }; diff --git a/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts b/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts index a39d1384e038..07cf2a29e832 100644 --- a/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts +++ b/packages/tanstackstart-react/src/vite/autoInstrumentMiddleware.ts @@ -11,6 +11,12 @@ type WrapResult = { skipped: string[]; }; +type FileTransformState = { + code: string; + needsImport: boolean; + skippedMiddlewares: string[]; +}; + /** * Core function that wraps middleware arrays matching the given regex. */ @@ -26,6 +32,10 @@ function wrapMiddlewareArrays(code: string, id: string, debug: boolean, regex: R // eslint-disable-next-line no-console console.log(`[Sentry] Auto-wrapping ${key} in ${id}`); } + // Handle method call syntax like `.middleware([...])` vs object property syntax like `middleware: [...]` + if (key.endsWith('(')) { + return `${key}wrapMiddlewaresWithSentry(${objContents}))`; + } return `${key}: wrapMiddlewaresWithSentry(${objContents})`; } // Track middlewares that couldn't be auto-wrapped @@ -53,6 +63,30 @@ export function wrapRouteMiddleware(code: string, id: string, debug: boolean): W return wrapMiddlewareArrays(code, id, debug, /(middleware)\s*:\s*\[([^\]]*)\]/g); } +/** + * Wraps middleware arrays in createServerFn().middleware([...]) calls. + */ +export function wrapServerFnMiddleware(code: string, id: string, debug: boolean): WrapResult { + return wrapMiddlewareArrays(code, id, debug, /(\.middleware\s*\()\s*\[([^\]]*)\]\s*\)/g); +} + +/** + * Applies a wrap function to the current state and returns the updated state. + */ +function applyWrap( + state: FileTransformState, + wrapFn: (code: string, id: string, debug: boolean) => WrapResult, + id: string, + debug: boolean, +): FileTransformState { + const result = wrapFn(state.code, id, debug); + return { + code: result.code, + needsImport: state.needsImport || result.didWrap, + skippedMiddlewares: [...state.skippedMiddlewares, ...result.skipped], + }; +} + /** * A Vite plugin that automatically instruments TanStack Start middlewares: * - `requestMiddleware` and `functionMiddleware` arrays in `createStart()` @@ -78,8 +112,9 @@ export function makeAutoInstrumentMiddlewarePlugin(options: AutoInstrumentMiddle // Detect file types that should be instrumented const isStartFile = id.includes('start') && code.includes('createStart('); const isRouteFile = code.includes('createFileRoute(') && /middleware\s*:\s*\[/.test(code); + const isServerFnFile = code.includes('createServerFn') && /\.middleware\s*\(\s*\[/.test(code); - if (!isStartFile && !isRouteFile) { + if (!isStartFile && !isRouteFile && !isServerFnFile) { return null; } @@ -88,48 +123,38 @@ export function makeAutoInstrumentMiddlewarePlugin(options: AutoInstrumentMiddle return null; } - let transformed = code; - let needsImport = false; - const skippedMiddlewares: string[] = []; - - switch (true) { - // global middleware - case isStartFile: { - const result = wrapGlobalMiddleware(transformed, id, debug); - transformed = result.code; - needsImport = needsImport || result.didWrap; - skippedMiddlewares.push(...result.skipped); - break; - } - // route middleware - case isRouteFile: { - const result = wrapRouteMiddleware(transformed, id, debug); - transformed = result.code; - needsImport = needsImport || result.didWrap; - skippedMiddlewares.push(...result.skipped); - break; - } - default: - break; + let fileTransformState: FileTransformState = { + code, + needsImport: false, + skippedMiddlewares: [], + }; + + // Wrap middlewares + if (isStartFile) { + fileTransformState = applyWrap(fileTransformState, wrapGlobalMiddleware, id, debug); + } + if (isRouteFile) { + fileTransformState = applyWrap(fileTransformState, wrapRouteMiddleware, id, debug); + } + if (isServerFnFile) { + fileTransformState = applyWrap(fileTransformState, wrapServerFnMiddleware, id, debug); } // Warn about middlewares that couldn't be auto-wrapped - if (skippedMiddlewares.length > 0) { + if (fileTransformState.skippedMiddlewares.length > 0) { // eslint-disable-next-line no-console console.warn( - `[Sentry] Could not auto-instrument ${skippedMiddlewares.join(' and ')} in ${id}. ` + + `[Sentry] Could not auto-instrument ${fileTransformState.skippedMiddlewares.join(' and ')} in ${id}. ` + 'To instrument these middlewares, use wrapMiddlewaresWithSentry() manually. ', ); } // We didn't wrap any middlewares, so we don't need to import the wrapMiddlewaresWithSentry function - if (!needsImport) { + if (!fileTransformState.needsImport) { return null; } - transformed = addSentryImport(transformed); - - return { code: transformed, map: null }; + return { code: addSentryImport(fileTransformState.code), map: null }; }, }; } diff --git a/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts b/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts index 4a4bfdf4fd29..7a58fa3237e4 100644 --- a/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts +++ b/packages/tanstackstart-react/test/vite/autoInstrumentMiddleware.test.ts @@ -6,6 +6,7 @@ import { makeAutoInstrumentMiddlewarePlugin, wrapGlobalMiddleware, wrapRouteMiddleware, + wrapServerFnMiddleware, } from '../../src/vite/autoInstrumentMiddleware'; type PluginWithTransform = Plugin & { @@ -329,6 +330,86 @@ export const Route = createFileRoute('/foo')({ }); }); +describe('wrapServerFnMiddleware', () => { + it('wraps single middleware in createServerFn().middleware()', () => { + const code = ` +const serverFn = createServerFn() + .middleware([authMiddleware]) + .handler(async () => ({})); +`; + const result = wrapServerFnMiddleware(code, '/app/routes/foo.ts', false); + + expect(result.didWrap).toBe(true); + expect(result.code).toContain('.middleware(wrapMiddlewaresWithSentry({ authMiddleware }))'); + expect(result.skipped).toHaveLength(0); + }); + + it('wraps multiple middlewares in createServerFn().middleware()', () => { + const code = ` +const serverFn = createServerFn() + .middleware([authMiddleware, loggingMiddleware]) + .handler(async () => ({})); +`; + const result = wrapServerFnMiddleware(code, '/app/routes/foo.ts', false); + + expect(result.didWrap).toBe(true); + expect(result.code).toContain('.middleware(wrapMiddlewaresWithSentry({ authMiddleware, loggingMiddleware }))'); + }); + + it('does not wrap empty middleware arrays', () => { + const code = ` +const serverFn = createServerFn() + .middleware([]) + .handler(async () => ({})); +`; + const result = wrapServerFnMiddleware(code, '/app/routes/foo.ts', false); + + expect(result.didWrap).toBe(false); + expect(result.skipped).toHaveLength(0); + }); + + it('does not wrap middleware containing function calls', () => { + const code = ` +const serverFn = createServerFn() + .middleware([createMiddleware()]) + .handler(async () => ({})); +`; + const result = wrapServerFnMiddleware(code, '/app/routes/foo.ts', false); + + expect(result.didWrap).toBe(false); + expect(result.skipped).toContain('.middleware('); + }); + + it('handles multiple server functions in same file', () => { + const code = ` +const serverFn1 = createServerFn() + .middleware([authMiddleware]) + .handler(async () => ({})); + +const serverFn2 = createServerFn() + .middleware([loggingMiddleware]) + .handler(async () => ({})); +`; + const result = wrapServerFnMiddleware(code, '/app/routes/foo.ts', false); + + expect(result.didWrap).toBe(true); + expect(result.code).toContain('.middleware(wrapMiddlewaresWithSentry({ authMiddleware }))'); + expect(result.code).toContain('.middleware(wrapMiddlewaresWithSentry({ loggingMiddleware }))'); + }); + + it('handles trailing commas in middleware arrays', () => { + const code = ` +const serverFn = createServerFn() + .middleware([authMiddleware,]) + .handler(async () => ({})); +`; + const result = wrapServerFnMiddleware(code, '/app/routes/foo.ts', false); + + expect(result.didWrap).toBe(true); + expect(result.code).toContain('.middleware(wrapMiddlewaresWithSentry({ authMiddleware }))'); + }); +}); + describe('addSentryImport', () => { it('prepends import to code without directives', () => { const code = 'const foo = 1;';