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); + }); +});