diff --git a/src/instrument.server.mjs b/src/instrument.server.mjs index 594affb50..c51339687 100644 --- a/src/instrument.server.mjs +++ b/src/instrument.server.mjs @@ -1,5 +1,8 @@ import * as Sentry from '@sentry/tanstackstart-react' -import { redactByokRequestHeaders } from './utils/sentry-redaction' +import { + filterSentryEvent, + redactByokRequestHeaders, +} from './utils/sentry-redaction' if (!Sentry.getClient()) Sentry.init({ @@ -25,6 +28,6 @@ if (!Sentry.getClient()) return null return breadcrumb }, - beforeSend: redactByokRequestHeaders, + beforeSend: filterSentryEvent, beforeSendTransaction: redactByokRequestHeaders, }) diff --git a/src/router.tsx b/src/router.tsx index 0362d4998..c82cb0697 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -9,7 +9,10 @@ import { installStaleAppReloadHandlers, consumeStaleAppRecovery, } from './utils/stale-app-reload' -import { redactByokRequestHeaders } from './utils/sentry-redaction' +import { + isRouterControlFlow, + redactByokRequestHeaders, +} from './utils/sentry-redaction' import type { DocsNavTabId } from './utils/docsNavTabs' import type { CustomToolTabId } from './libraries/custom-tool-tabs' @@ -25,6 +28,7 @@ if (typeof document !== 'undefined') { // Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled tracePropagationTargets: ['localhost', /^https:\/\/tanstack\.com\//], beforeSend(event, hint) { + if (isRouterControlFlow(hint.originalException)) return null redactByokRequestHeaders(event) // Only suppress the exact error that triggered a reload. Errors during // the reload cooldown still need reporting. diff --git a/src/utils/sentry-redaction.ts b/src/utils/sentry-redaction.ts index 872f2e8b4..50173472c 100644 --- a/src/utils/sentry-redaction.ts +++ b/src/utils/sentry-redaction.ts @@ -1,3 +1,5 @@ +import { isNotFound, isRedirect } from '@tanstack/react-router' + type EventWithRequestHeaders = { request?: { headers?: Record @@ -14,6 +16,10 @@ type EventWithRequestHeaders = { exception?: { values?: { value?: string }[] } } +type EventHintWithOriginalException = { + originalException?: unknown +} + export function redactByokRequestHeaders< TEvent extends EventWithRequestHeaders, >(event: TEvent) { @@ -59,3 +65,19 @@ export function redactByokRequestHeaders< return event } + +/** + * TanStack Router/Start throws `notFound()` and `redirect()` objects as + * control flow. They are not real errors and should never be reported. + */ +export function isRouterControlFlow(value: unknown): boolean { + return isNotFound(value) || isRedirect(value) +} + +export function filterSentryEvent( + event: TEvent, + hint?: EventHintWithOriginalException, +): TEvent | null { + if (isRouterControlFlow(hint?.originalException)) return null + return redactByokRequestHeaders(event) +} diff --git a/tests/sentry-redaction.test.ts b/tests/sentry-redaction.test.ts index eb1bd80dc..dc5dcbdca 100644 --- a/tests/sentry-redaction.test.ts +++ b/tests/sentry-redaction.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { redactByokRequestHeaders } from '../src/utils/sentry-redaction' +import { notFound, redirect } from '@tanstack/react-router' +import { + filterSentryEvent, + redactByokRequestHeaders, +} from '../src/utils/sentry-redaction' test('Sentry events never include BYOK request headers', () => { const event = { @@ -17,3 +21,23 @@ test('Sentry events never include BYOK request headers', () => { assert.equal(redactByokRequestHeaders(event), event) assert.deepEqual(event.request.headers, { Accept: 'application/json' }) }) + +test('Sentry filters actual Router control flow and keeps real failures redacted', () => { + for (const originalException of [notFound(), redirect({ href: '/' })]) { + assert.equal(filterSentryEvent({}, { originalException }), null) + } + for (const originalException of [ + new Error('failure'), + new Response(null, { status: 500 }), + { isRedirect: true }, + undefined, + ]) { + const event = { + request: { + headers: { 'x-byok-openai': 'secret', Accept: 'application/json' }, + }, + } + assert.equal(filterSentryEvent(event, { originalException }), event) + assert.deepEqual(event.request.headers, { Accept: 'application/json' }) + } +})