diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17e420a5e004..23d01dd3117e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+- **feat(tanstackstart-react): Add global sentry exception middlewares ([#19330](https://github.com/getsentry/sentry-javascript/pull/19330))**
+
+ The `sentryGlobalRequestMiddleware` and `sentryGlobalFunctionMiddleware` global middlewares capture unhandled exceptions thrown in TanStack Start API routes and server functions. Add them as the first entries in the `requestMiddleware` and `functionMiddleware` arrays of `createStart()`:
+
+ ```ts
+ import { createStart } from '@tanstack/react-start/server';
+ import { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from '@sentry/tanstackstart-react/server';
+
+ export default createStart({
+ requestMiddleware: [sentryGlobalRequestMiddleware, myRequestMiddleware],
+ functionMiddleware: [sentryGlobalFunctionMiddleware, myFunctionMiddleware],
+ });
+ ```
+
### Important Changes
- fix(node-core): Reduce bundle size by removing apm-js-collab and requiring pino >= 9.10 ([#18631](https://github.com/getsentry/sentry-javascript/pull/18631))
diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.error.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.error.ts
index 470d53346ad7..041fb175c1f1 100644
--- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.error.ts
+++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.error.ts
@@ -1,16 +1,10 @@
-import * as Sentry from '@sentry/tanstackstart-react';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/error')({
server: {
handlers: {
GET: async () => {
- try {
- throw new Error('Sentry API Route Test Error');
- } catch (error) {
- Sentry.captureException(error);
- throw error;
- }
+ throw new Error('Sentry API Route Test Error');
},
},
},
diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.flush.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.flush.ts
new file mode 100644
index 000000000000..fb296b07d9b0
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.flush.ts
@@ -0,0 +1,13 @@
+import { createFileRoute } from '@tanstack/react-router';
+import { flush } from '@sentry/tanstackstart-react';
+
+export const Route = createFileRoute('/api/flush')({
+ server: {
+ handlers: {
+ GET: async () => {
+ await flush();
+ return new Response('ok');
+ },
+ },
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/ssr-error.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/ssr-error.tsx
new file mode 100644
index 000000000000..71ba7ce92d29
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/ssr-error.tsx
@@ -0,0 +1,8 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/ssr-error')({
+ loader: () => {
+ throw new Error('Sentry SSR Test Error');
+ },
+ component: () =>
SSR Error 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
index 0dc32ebd112f..110e3602b1c9 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,10 +1,11 @@
+import { sentryGlobalFunctionMiddleware, sentryGlobalRequestMiddleware } from '@sentry/tanstackstart-react';
import { createStart } from '@tanstack/react-start';
// NOTE: These are NOT wrapped - auto-instrumentation via the Vite plugin will wrap them
-import { globalRequestMiddleware, globalFunctionMiddleware } from './middleware';
+import { globalFunctionMiddleware, globalRequestMiddleware } from './middleware';
export const startInstance = createStart(() => {
return {
- requestMiddleware: [globalRequestMiddleware],
- functionMiddleware: [globalFunctionMiddleware],
+ requestMiddleware: [sentryGlobalRequestMiddleware, globalRequestMiddleware],
+ functionMiddleware: [sentryGlobalFunctionMiddleware, globalFunctionMiddleware],
};
});
diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts
index f5d25febb7a4..04d93e550824 100644
--- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts
+++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/errors.test.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
-import { waitForError } from '@sentry-internal/test-utils';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
test('Sends client-side error to Sentry with auto-instrumentation', async ({ page }) => {
const errorEventPromise = waitForError('tanstackstart-react', errorEvent => {
@@ -51,6 +51,7 @@ test('Sends server-side function error to Sentry with auto-instrumentation', asy
type: 'Error',
value: 'Sentry Server Function Test Error',
mechanism: {
+ type: 'auto.middleware.tanstackstart.server_function',
handled: false,
},
},
@@ -58,10 +59,10 @@ test('Sends server-side function error to Sentry with auto-instrumentation', asy
},
});
- expect(errorEvent.transaction).toBe('/');
+ expect(errorEvent.transaction).toEqual(expect.stringContaining('GET /_serverFn/'));
});
-test('Sends API route error to Sentry if manually instrumented', async ({ page }) => {
+test('Sends API route error to Sentry with auto-instrumentation', async ({ page }) => {
const errorEventPromise = waitForError('tanstackstart-react', errorEvent => {
return errorEvent?.exception?.values?.[0]?.value === 'Sentry API Route Test Error';
});
@@ -81,7 +82,8 @@ test('Sends API route error to Sentry if manually instrumented', async ({ page }
type: 'Error',
value: 'Sentry API Route Test Error',
mechanism: {
- handled: true,
+ type: 'auto.middleware.tanstackstart.request',
+ handled: false,
},
},
],
@@ -90,3 +92,28 @@ test('Sends API route error to Sentry if manually instrumented', async ({ page }
expect(errorEvent.transaction).toBe('GET /api/error');
});
+
+// the sentry global middleware does not capture errors from SSR loader errors since they are serialized before they reach the middleware layer
+// this test verifies that the error is in fact not sent to Sentry
+test('Does not send SSR loader error to Sentry', async ({ baseURL, page }) => {
+ let errorEventOccurred = false;
+
+ waitForError('tanstackstart-react', event => {
+ if (!event.type && event.exception?.values?.[0]?.value === 'Sentry SSR Test Error') {
+ errorEventOccurred = true;
+ }
+ return event?.transaction === 'GET /ssr-error';
+ });
+
+ const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => {
+ return transactionEvent?.transaction === 'GET /ssr-error';
+ });
+
+ await page.goto('/ssr-error');
+
+ await transactionEventPromise;
+
+ await (await fetch(`${baseURL}/api/flush`)).text();
+
+ expect(errorEventOccurred).toBe(false);
+});
diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts
index b2b9add0d06b..3e762580830c 100644
--- a/packages/tanstackstart-react/src/client/index.ts
+++ b/packages/tanstackstart-react/src/client/index.ts
@@ -14,3 +14,15 @@ export { init } from './sdk';
export function wrapMiddlewaresWithSentry(middlewares: Record): T[] {
return Object.values(middlewares);
}
+
+/**
+ * No-op stub for client-side builds.
+ * The actual implementation is server-only, but this stub is needed to prevent rendering errors.
+ */
+export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = { '~types': undefined, options: {} };
+
+/**
+ * No-op stub for client-side builds.
+ * The actual implementation is server-only, but this stub is needed to prevent rendering errors.
+ */
+export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = { '~types': undefined, options: {} };
diff --git a/packages/tanstackstart-react/src/common/types.ts b/packages/tanstackstart-react/src/common/types.ts
index 82e20754cb72..a2a029207036 100644
--- a/packages/tanstackstart-react/src/common/types.ts
+++ b/packages/tanstackstart-react/src/common/types.ts
@@ -1,5 +1,8 @@
export type TanStackMiddlewareBase = {
- options?: { server?: (...args: unknown[]) => unknown };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ '~types': any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ options: { server?: (...args: any[]) => any };
};
export type MiddlewareWrapperOptions = {
diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts
index ca41d6ce05ee..064a92ba1210 100644
--- a/packages/tanstackstart-react/src/index.types.ts
+++ b/packages/tanstackstart-react/src/index.types.ts
@@ -37,3 +37,6 @@ export declare const statsigIntegration: typeof clientSdk.statsigIntegration;
export declare const unleashIntegration: typeof clientSdk.unleashIntegration;
export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewaresWithSentry;
+
+export declare const sentryGlobalRequestMiddleware: typeof serverSdk.sentryGlobalRequestMiddleware;
+export declare const sentryGlobalFunctionMiddleware: typeof serverSdk.sentryGlobalFunctionMiddleware;
diff --git a/packages/tanstackstart-react/src/server/globalMiddleware.ts b/packages/tanstackstart-react/src/server/globalMiddleware.ts
new file mode 100644
index 000000000000..516535da41c5
--- /dev/null
+++ b/packages/tanstackstart-react/src/server/globalMiddleware.ts
@@ -0,0 +1,46 @@
+import { addNonEnumerableProperty, captureException } from '@sentry/core';
+import type { TanStackMiddlewareBase } from '../common/types';
+import { SENTRY_INTERNAL } from './middleware';
+
+function createSentryMiddlewareHandler(mechanismType: string) {
+ return async function sentryMiddlewareHandler({ next }: { next: () => Promise }): Promise {
+ try {
+ return await next();
+ } catch (e) {
+ captureException(e, {
+ mechanism: { type: mechanismType, handled: false },
+ });
+ throw e;
+ }
+ };
+}
+
+/**
+ * Global request middleware that captures errors from API route requests.
+ * Should be added as the first entry in the `requestMiddleware` array of `createStart()`.
+ */
+export const sentryGlobalRequestMiddleware: TanStackMiddlewareBase = {
+ '~types': undefined,
+
+ options: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ server: createSentryMiddlewareHandler('auto.middleware.tanstackstart.request') as (...args: any[]) => any,
+ },
+};
+
+/**
+ * Global function middleware that captures errors from server function invocations.
+ * Should be added as the first entry in the `functionMiddleware` array of `createStart()`.
+ */
+export const sentryGlobalFunctionMiddleware: TanStackMiddlewareBase = {
+ '~types': undefined,
+
+ options: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ server: createSentryMiddlewareHandler('auto.middleware.tanstackstart.server_function') as (...args: any[]) => any,
+ },
+};
+
+// Mark as internal so the Vite auto-instrumentation plugin skips these middleware
+addNonEnumerableProperty(sentryGlobalRequestMiddleware, SENTRY_INTERNAL, true);
+addNonEnumerableProperty(sentryGlobalFunctionMiddleware, SENTRY_INTERNAL, true);
diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts
index 5765114cd28b..0bf3238eab8a 100644
--- a/packages/tanstackstart-react/src/server/index.ts
+++ b/packages/tanstackstart-react/src/server/index.ts
@@ -6,6 +6,7 @@ export * from '@sentry/node';
export { init } from './sdk';
export { wrapFetchWithSentry } from './wrapFetchWithSentry';
export { wrapMiddlewaresWithSentry } from './middleware';
+export { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } from './globalMiddleware';
/**
* 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
index 4342af8e1c93..9347e6760742 100644
--- a/packages/tanstackstart-react/src/server/middleware.ts
+++ b/packages/tanstackstart-react/src/server/middleware.ts
@@ -5,6 +5,7 @@ import type { MiddlewareWrapperOptions, TanStackMiddlewareBase } from '../common
import { getMiddlewareSpanOptions } from './utils';
const SENTRY_WRAPPED = '__SENTRY_WRAPPED__';
+export const SENTRY_INTERNAL = '__SENTRY_INTERNAL__';
/**
* Creates a proxy for the next function that ends the current span and restores the parent span.
@@ -39,8 +40,11 @@ function wrapMiddlewareWithSentry(
middleware: T,
options: MiddlewareWrapperOptions,
): T {
- if ((middleware as TanStackMiddlewareBase & { [SENTRY_WRAPPED]?: boolean })[SENTRY_WRAPPED]) {
- // already instrumented
+ if (
+ (middleware as TanStackMiddlewareBase & { [SENTRY_WRAPPED]?: boolean })[SENTRY_WRAPPED] ||
+ (middleware as TanStackMiddlewareBase & { [SENTRY_INTERNAL]?: boolean })[SENTRY_INTERNAL]
+ ) {
+ // already instrumented or internal Sentry middleware
return middleware;
}
diff --git a/packages/tanstackstart-react/test/server/globalMiddleware.test.ts b/packages/tanstackstart-react/test/server/globalMiddleware.test.ts
new file mode 100644
index 000000000000..2be10f01bea9
--- /dev/null
+++ b/packages/tanstackstart-react/test/server/globalMiddleware.test.ts
@@ -0,0 +1,71 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const captureExceptionSpy = vi.fn();
+
+vi.mock('@sentry/core', async importOriginal => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ captureException: (...args: unknown[]) => captureExceptionSpy(...args),
+ };
+});
+
+// Import after mocks are set up
+const { sentryGlobalRequestMiddleware, sentryGlobalFunctionMiddleware } =
+ await import('../../src/server/globalMiddleware');
+
+describe('sentryGlobalRequestMiddleware', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('captures error with correct mechanism when next() throws', async () => {
+ const error = new Error('test error');
+ const next = vi.fn().mockRejectedValue(error);
+
+ const serverFn = sentryGlobalRequestMiddleware.options.server!;
+
+ await expect(serverFn({ next })).rejects.toThrow('test error');
+
+ expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
+ mechanism: { type: 'auto.middleware.tanstackstart.request', handled: false },
+ });
+ });
+
+ it('does not capture error when next() succeeds', async () => {
+ const next = vi.fn().mockResolvedValue('success');
+
+ const serverFn = sentryGlobalRequestMiddleware.options.server!;
+ const result = await serverFn({ next });
+
+ expect(result).toBe('success');
+ expect(captureExceptionSpy).not.toHaveBeenCalled();
+ });
+
+ it('has __SENTRY_INTERNAL__ flag set', () => {
+ expect((sentryGlobalRequestMiddleware as unknown as Record)['__SENTRY_INTERNAL__']).toBe(true);
+ });
+});
+
+describe('sentryGlobalFunctionMiddleware', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('captures error with correct mechanism when next() throws', async () => {
+ const error = new Error('test error');
+ const next = vi.fn().mockRejectedValue(error);
+
+ const serverFn = sentryGlobalFunctionMiddleware.options.server!;
+
+ await expect(serverFn({ next })).rejects.toThrow('test error');
+
+ expect(captureExceptionSpy).toHaveBeenCalledWith(error, {
+ mechanism: { type: 'auto.middleware.tanstackstart.server_function', handled: false },
+ });
+ });
+
+ it('has __SENTRY_INTERNAL__ flag set', () => {
+ expect((sentryGlobalFunctionMiddleware as unknown as Record)['__SENTRY_INTERNAL__']).toBe(true);
+ });
+});