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 new file mode 100644 index 000000000000..8ba441f7280a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts @@ -0,0 +1,39 @@ +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 }) => { + console.log('Global request middleware executed'); + return next(); +}); + +// Global function middleware - runs on every server function +const globalFunctionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Global function middleware executed'); + return next(); +}); + +// Server function middleware +const serverFnMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Server function middleware executed'); + return next(); +}); + +// Server route request middleware +const serverRouteRequestMiddleware = createMiddleware().server(async ({ next }) => { + console.log('Server route request middleware executed'); + return next(); +}); + +// Manually wrap middlewares with Sentry +export const [ + wrappedGlobalRequestMiddleware, + wrappedGlobalFunctionMiddleware, + wrappedServerFnMiddleware, + wrappedServerRouteRequestMiddleware, +] = wrapMiddlewaresWithSentry({ + globalRequestMiddleware, + globalFunctionMiddleware, + serverFnMiddleware, + serverRouteRequestMiddleware, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts new file mode 100644 index 000000000000..1bf3fdb1c5da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { wrappedServerRouteRequestMiddleware } from '../middleware'; + +export const Route = createFileRoute('/api/test-middleware')({ + server: { + middleware: [wrappedServerRouteRequestMiddleware], + handlers: { + GET: async () => { + return { message: 'Server route middleware test' }; + }, + }, + }, +}); 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 new file mode 100644 index 000000000000..bc061b5bb0c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; +import { wrappedServerFnMiddleware } from '../middleware'; + +// Server function with specific middleware (also gets global function middleware) +const serverFnWithMiddleware = createServerFn() + .middleware([wrappedServerFnMiddleware]) + .handler(async () => { + console.log('Server function with specific middleware executed'); + return { message: 'Server function middleware test' }; + }); + +// Server function without specific middleware (only gets global function middleware) +const serverFnWithoutMiddleware = createServerFn().handler(async () => { + console.log('Server function without specific middleware executed'); + return { message: 'Global middleware only test' }; +}); + +export const Route = createFileRoute('/test-middleware')({ + component: TestMiddleware, +}); + +function TestMiddleware() { + return ( +
+

Test Middleware Page

+ + +
+ ); +} 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 new file mode 100644 index 000000000000..eecd2816e492 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts @@ -0,0 +1,9 @@ +import { createStart } from '@tanstack/react-start'; +import { wrappedGlobalRequestMiddleware, wrappedGlobalFunctionMiddleware } from './middleware'; + +export const startInstance = createStart(() => { + return { + requestMiddleware: [wrappedGlobalRequestMiddleware], + functionMiddleware: [wrappedGlobalFunctionMiddleware], + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts new file mode 100644 index 000000000000..b01f576bd4c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -0,0 +1,135 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ + page, +}) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-middleware-btn')).toBeVisible(); + await page.locator('#server-fn-middleware-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Find both middleware spans + const serverFnMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'serverFnMiddleware' && span.origin === 'manual.middleware.tanstackstart', + ); + const globalFunctionMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'globalFunctionMiddleware' && span.origin === 'manual.middleware.tanstackstart', + ); + + // Verify both middleware spans exist with expected properties + expect(serverFnMiddlewareSpan).toEqual( + expect.objectContaining({ + description: 'serverFnMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ); + expect(globalFunctionMiddlewareSpan).toEqual( + expect.objectContaining({ + description: 'globalFunctionMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ); + + // Both middleware spans should be siblings under the same parent + expect(serverFnMiddlewareSpan?.parent_span_id).toBe(globalFunctionMiddlewareSpan?.parent_span_id); +}); + +test('Sends spans for global function middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-global-only-btn')).toBeVisible(); + await page.locator('#server-fn-global-only-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the global function middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'globalFunctionMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends spans for global request middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-middleware' + ); + }); + + await page.goto('/test-middleware'); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the global request middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'globalRequestMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends spans for server route request middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/test-middleware' + ); + }); + + await page.goto('/api/test-middleware'); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the server route request middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'serverRouteRequestMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index d2ebbffb0ec0..3ef96e887bd2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -84,17 +84,19 @@ test('Sends a server function transaction for a nested server function only if i ]), ); - // Verify that the auto span is the parent of the nested span - const autoSpan = transactionEvent?.spans?.find( - (span: { op?: string; origin?: string }) => - span.op === 'function.tanstackstart' && span.origin === 'auto.function.tanstackstart.server', + // Verify that globalFunctionMiddleware and testNestedLog are sibling spans under the root + const functionMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'globalFunctionMiddleware' && span.origin === 'manual.middleware.tanstackstart', ); const nestedSpan = transactionEvent?.spans?.find( (span: { description?: string; origin?: string }) => span.description === 'testNestedLog' && span.origin === 'manual', ); - expect(autoSpan).toBeDefined(); + expect(functionMiddlewareSpan).toBeDefined(); expect(nestedSpan).toBeDefined(); - expect(nestedSpan?.parent_span_id).toBe(autoSpan?.span_id); + + // Both spans should be siblings under the same parent (root transaction) + expect(nestedSpan?.parent_span_id).toBe(functionMiddlewareSpan?.parent_span_id); }); diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 2299b46b7d64..b2b9add0d06b 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -1,6 +1,16 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ +import type { TanStackMiddlewareBase } from '../common/types'; + export * from '@sentry/react'; export { init } from './sdk'; + +/** + * No-op stub for client-side builds. + * The actual implementation is server-only, but this stub is needed to prevent build errors. + */ +export function wrapMiddlewaresWithSentry(middlewares: Record): T[] { + return Object.values(middlewares); +} diff --git a/packages/tanstackstart-react/src/common/index.ts b/packages/tanstackstart-react/src/common/index.ts index cb0ff5c3b541..0fbc5e41ca34 100644 --- a/packages/tanstackstart-react/src/common/index.ts +++ b/packages/tanstackstart-react/src/common/index.ts @@ -1 +1 @@ -export {}; +export type { TanStackMiddlewareBase, MiddlewareWrapperOptions } from './types'; diff --git a/packages/tanstackstart-react/src/common/types.ts b/packages/tanstackstart-react/src/common/types.ts new file mode 100644 index 000000000000..82e20754cb72 --- /dev/null +++ b/packages/tanstackstart-react/src/common/types.ts @@ -0,0 +1,7 @@ +export type TanStackMiddlewareBase = { + options?: { server?: (...args: unknown[]) => unknown }; +}; + +export type MiddlewareWrapperOptions = { + name: string; +}; diff --git a/packages/tanstackstart-react/src/index.client.ts b/packages/tanstackstart-react/src/index.client.ts index 96c65e2ad4b2..45c16d71b2ff 100644 --- a/packages/tanstackstart-react/src/index.client.ts +++ b/packages/tanstackstart-react/src/index.client.ts @@ -2,5 +2,5 @@ // so we keep this to be future proof export * from './client'; // nothing gets exported yet from there -// eslint-disable-next-line import/export + export * from './common'; diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index cf624f5a1a0b..1ad387ea6a6e 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -34,3 +34,5 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; + +export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewaresWithSentry; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 299f1cd85ea9..5765114cd28b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -5,6 +5,7 @@ export * from '@sentry/node'; export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; +export { wrapMiddlewaresWithSentry } from './middleware'; /** * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors diff --git a/packages/tanstackstart-react/src/server/middleware.ts b/packages/tanstackstart-react/src/server/middleware.ts new file mode 100644 index 000000000000..5a792c337ac7 --- /dev/null +++ b/packages/tanstackstart-react/src/server/middleware.ts @@ -0,0 +1,90 @@ +import { addNonEnumerableProperty } from '@sentry/core'; +import type { Span } from '@sentry/node'; +import { getActiveSpan, startSpanManual, withActiveSpan } from '@sentry/node'; +import type { MiddlewareWrapperOptions, TanStackMiddlewareBase } from '../common/types'; +import { getMiddlewareSpanOptions } from './utils'; + +const SENTRY_WRAPPED = '__SENTRY_WRAPPED__'; + +/** + * Creates a proxy for the next function that ends the current span and restores the parent span. + * This ensures that subsequent middleware spans are children of the root span, not nested children. + */ +function getNextProxy unknown>(next: T, span: Span, prevSpan: Span | undefined): T { + return new Proxy(next, { + apply: (originalNext, thisArgNext, argsNext) => { + span.end(); + + if (prevSpan) { + return withActiveSpan(prevSpan, () => { + return Reflect.apply(originalNext, thisArgNext, argsNext); + }); + } + + return Reflect.apply(originalNext, thisArgNext, argsNext); + }, + }); +} + +/** + * Wraps a TanStack Start middleware with Sentry instrumentation to create spans. + */ +function wrapMiddlewareWithSentry( + middleware: T, + options: MiddlewareWrapperOptions, +): T { + if ((middleware as TanStackMiddlewareBase & { [SENTRY_WRAPPED]?: boolean })[SENTRY_WRAPPED]) { + // already instrumented + return middleware; + } + + // instrument server middleware + if (middleware.options?.server) { + middleware.options.server = new Proxy(middleware.options.server, { + apply: (originalServer, thisArgServer, argsServer) => { + const prevSpan = getActiveSpan(); + + return startSpanManual(getMiddlewareSpanOptions(options.name), (span: Span) => { + // The server function receives { next, context, request } as first argument + // We need to proxy the `next` function inside that object + const middlewareArgs = argsServer[0] as { next?: (...args: unknown[]) => unknown } | undefined; + if (middlewareArgs && typeof middlewareArgs === 'object' && typeof middlewareArgs.next === 'function') { + middlewareArgs.next = getNextProxy(middlewareArgs.next, span, prevSpan); + } + + return originalServer.apply(thisArgServer, argsServer); + }); + }, + }); + + // mark as instrumented + addNonEnumerableProperty(middleware as unknown as Record, SENTRY_WRAPPED, true); + } + + return middleware; +} + +/** + * Wraps multiple TanStack Start middlewares with Sentry instrumentation. + * Object keys are used as span names to avoid users having to specify this manually. + * + * @example + * ```ts + * import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; + * + * const wrappedMiddlewares = wrapMiddlewaresWithSentry({ + * authMiddleware, + * loggingMiddleware, + * }); + * + * createServerFn().middleware(wrappedMiddlewares) + * ``` + * + * @param middlewares - An object containing middlewares + * @returns An array of wrapped middlewares + */ +export function wrapMiddlewaresWithSentry(middlewares: Record): T[] { + return Object.entries(middlewares).map(([name, middleware]) => { + return wrapMiddlewareWithSentry(middleware, { name }); + }); +} diff --git a/packages/tanstackstart-react/src/server/utils.ts b/packages/tanstackstart-react/src/server/utils.ts index a3ebbd118910..66cfec542dd3 100644 --- a/packages/tanstackstart-react/src/server/utils.ts +++ b/packages/tanstackstart-react/src/server/utils.ts @@ -1,3 +1,6 @@ +import type { StartSpanOptions } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/node'; + /** * Extracts the SHA-256 hash from a server function pathname. * Server function pathnames are structured as `/_serverFn/`. @@ -10,3 +13,17 @@ export function extractServerFunctionSha256(pathname: string): string { const serverFnMatch = pathname.match(/\/_serverFn\/([a-f0-9]{64})/i); return serverFnMatch?.[1] ?? 'unknown'; } + +/** + * Returns span options for TanStack Start middleware spans. + */ +export function getMiddlewareSpanOptions(name: string): StartSpanOptions { + return { + op: 'middleware.tanstackstart', + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual.middleware.tanstackstart', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.tanstackstart', + }, + }; +}