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',
+ },
+ };
+}