diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 39f96bb99c..3c50fbb1a4 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -6,9 +6,11 @@ import * as Sentry from '@sentry/nextjs' import { beforeSendRouteAwareHandler, beforeSendRouteAwareTransaction } from './sentry.utils' -import { inferSentryEnvironment } from '@/utils/sentry-env' +import { inferSentryEnvironment, isSentryReportingEnvironment } from '@/utils/sentry-env' -if (process.env.NODE_ENV !== 'development') { +// Everything but a local build. Stricter than the NODE_ENV check it replaces: +// a `next build` on a laptop has NODE_ENV=production and reported as one. +if (isSentryReportingEnvironment()) { Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: inferSentryEnvironment(), @@ -20,8 +22,10 @@ if (process.env.NODE_ENV !== 'development') { beforeSendTransaction: beforeSendRouteAwareTransaction, integrations: [ + // `error` only — a console.warn costs the same as an exception, and + // the warn-level output of this app is handled conditions, not defects. Sentry.captureConsoleIntegration({ - levels: ['error', 'warn'], + levels: ['error'], }), ], }) diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 888adec42a..2c03b66e03 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -5,22 +5,28 @@ import * as Sentry from '@sentry/nextjs' import { beforeSendRouteAwareHandler, beforeSendRouteAwareTransaction } from './sentry.utils' -import { inferSentryEnvironment } from '@/utils/sentry-env' +import { inferSentryEnvironment, isSentryReportingEnvironment } from '@/utils/sentry-env' -if (process.env.NODE_ENV !== 'development') { +// Everything but a local build. Stricter than the NODE_ENV check it replaces: +// a `next build` on a laptop has NODE_ENV=production and reported as one. +if (isSentryReportingEnvironment()) { Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: inferSentryEnvironment(), enabled: true, - tracesSampleRate: 1, + // Matches the client. Full server tracing bought no insight nobody + // could get from 10% of it, and every span is a billed event. + tracesSampleRate: 0.1, debug: false, beforeSend: beforeSendRouteAwareHandler, beforeSendTransaction: beforeSendRouteAwareTransaction, integrations: [ + // `error` only — a console.warn costs the same as an exception, and + // the warn-level output of this app is handled conditions, not defects. Sentry.captureConsoleIntegration({ - levels: ['error', 'warn'], + levels: ['error'], }), ], diff --git a/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts b/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts index 63dd69ba11..41f58e751c 100644 --- a/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts +++ b/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts @@ -49,11 +49,13 @@ describe('server and edge payment explorer Sentry guard', () => { } }) - it.each(['sentry.server.config', 'sentry.edge.config'])('wires both route-aware hooks in %s', (moduleName) => { + const loadConfig = (moduleName: string, vercelEnv: string) => { const previousNodeEnv = process.env.NODE_ENV + const previousVercelEnv = process.env.NEXT_PUBLIC_VERCEL_ENV const setNodeEnv = (value: string | undefined) => Object.defineProperty(process.env, 'NODE_ENV', { configurable: true, value, writable: true }) setNodeEnv('production') + process.env.NEXT_PUBLIC_VERCEL_ENV = vercelEnv mockSentryInit.mockClear() try { jest.isolateModules(() => { @@ -61,9 +63,24 @@ describe('server and edge payment explorer Sentry guard', () => { }) } finally { setNodeEnv(previousNodeEnv) + if (previousVercelEnv === undefined) delete process.env.NEXT_PUBLIC_VERCEL_ENV + else process.env.NEXT_PUBLIC_VERCEL_ENV = previousVercelEnv + } + return mockSentryInit.mock.calls[0]?.[0] + } + + // Preview keeps reporting; a local build (no VERCEL_ENV) infers 'development' + // and does not, even though NODE_ENV says production. + it.each(['sentry.server.config', 'sentry.edge.config'])( + 'inits on a preview but not on a local build in %s', + (moduleName) => { + expect(loadConfig(moduleName, 'preview')).toBeDefined() + expect(loadConfig(moduleName, '')).toBeUndefined() } + ) - const options = mockSentryInit.mock.calls[0]?.[0] + it.each(['sentry.server.config', 'sentry.edge.config'])('wires both route-aware hooks in %s', (moduleName) => { + const options = loadConfig(moduleName, 'production') expect(options?.beforeSend).toEqual(expect.any(Function)) expect(options?.beforeSendTransaction).toEqual(expect.any(Function)) expect(options?.beforeSend({ request: { url: '/dev/payment-graph?password=marker' } })).toBeNull() diff --git a/src/services/perks.ts b/src/services/perks.ts index db520dc69c..b00ec018d3 100644 --- a/src/services/perks.ts +++ b/src/services/perks.ts @@ -40,7 +40,12 @@ export const perksApi = { }) if (!response.ok) { - console.error('getPendingPerks: API request failed', response.status, response.statusText) + // info, not error: captureConsoleIntegration turns a console.error + // into a billed Sentry event, and the common failure here is a 401 + // from a stale session — which fetchWithSentry already suppresses + // as expected. Anything it does report (a 5xx) still arrives, with + // the request attached instead of this call site's minified name. + console.info('getPendingPerks: API request failed', response.status, response.statusText) return { success: false, perks: [], error: 'Failed to fetch pending perks' } } diff --git a/src/utils/__tests__/sentry-init.test.ts b/src/utils/__tests__/sentry-init.test.ts index 7fc51713d3..62b06d9a50 100644 --- a/src/utils/__tests__/sentry-init.test.ts +++ b/src/utils/__tests__/sentry-init.test.ts @@ -16,7 +16,7 @@ jest.mock('posthog-js', () => ({ type SentryMock = { init: jest.Mock; getClient: jest.Mock } -const ENV_KEYS = ['NEXT_PUBLIC_CAPACITOR_BUILD', 'NEXT_PUBLIC_PERF_BARE'] as const +const ENV_KEYS = ['NEXT_PUBLIC_CAPACITOR_BUILD', 'NEXT_PUBLIC_PERF_BARE', 'NEXT_PUBLIC_VERCEL_ENV'] as const const savedEnv: Partial> = {} const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -53,10 +53,33 @@ describe('initSentry', () => { expect(Sentry.init).not.toHaveBeenCalled() }) - it('inits exactly once on web, however many times it is called', async () => { + // Preview stays on: the OTA liveness proof reads preview events out of Sentry. + it('still inits on a PR preview', async () => { + const { initSentry, Sentry } = load({ NEXT_PUBLIC_VERCEL_ENV: 'preview' }) + Sentry.getClient.mockReturnValue(undefined) + + initSentry() + await flush() + + expect(Sentry.init).toHaveBeenCalledTimes(1) + }) + + // A `next build` on a laptop has NODE_ENV=production and no VERCEL_ENV, so it + // used to report as production. It infers 'development' now and reports nothing. + it('never inits on a local build', async () => { const { initSentry, Sentry } = load({}) Sentry.getClient.mockReturnValue(undefined) + initSentry() + await flush() + + expect(Sentry.init).not.toHaveBeenCalled() + }) + + it('inits exactly once on web, however many times it is called', async () => { + const { initSentry, Sentry } = load({ NEXT_PUBLIC_VERCEL_ENV: 'production' }) + Sentry.getClient.mockReturnValue(undefined) + initSentry() initSentry() await flush() @@ -68,7 +91,7 @@ describe('initSentry', () => { }) it('leaves an existing client alone', async () => { - const { initSentry, Sentry } = load({}) + const { initSentry, Sentry } = load({ NEXT_PUBLIC_VERCEL_ENV: 'production' }) Sentry.getClient.mockReturnValue({}) initSentry() diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 2c901f13fa..cac5a8ef9d 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -76,6 +76,51 @@ describe('fetchWithSentry — expected-response suppression', () => { ) }) + // A 409 means the code resolves to a campaign only — validateInviteCode + // treats it as a success (`typedCampaignOnly`), so it is never a failure. + it('does NOT report /invites/validate 409 (campaign-only code is a success here)', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(409, { error: 'CAMPAIGN_ONLY' })) + + const res = await fetchWithSentry('https://api.peanut.me/invites/validate', { method: 'POST', body: '{}' }) + + expect(res.status).toBe(409) + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + // Deliberately NOT skipped: useGetExchangeRate swallows the failure into a + // rate of '1', which bankWithdrawMinUsd turns into a wrong withdrawal + // minimum. This 429 is the alert for that, and for the FX stampede behind + // it. Do not add a skip rule without fixing the fallback first. + it.each([429, 500])('still reports /bridge/exchange-rate %i', async (status) => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(status, { error: 'RATE_LIMITED' })) + + await fetchWithSentry('https://api.peanut.me/bridge/exchange-rate?accountType=iban') + + expect(Sentry.captureMessage).toHaveBeenCalledTimes(1) + }) + + // A stale session is the normal way this endpoint 401s — the UI just shows + // no perks. Pins the "Expected stale-session 401 on /perks/pending" row. + it('does NOT report /perks/pending 401 (stale session)', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(401, { error: 'Unauthorized' })) + + const res = await fetchWithSentry('https://api.peanut.me/perks/pending') + + expect(res.status).toBe(401) + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('still reports /manteca/qr-payment/init 500 (a real payment failure)', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(500, { error: 'boom' })) + + await fetchWithSentry('https://api.peanut.me/manteca/qr-payment/init', { method: 'POST', body: '{}' }) + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + 'POST to https://api.peanut.me/manteca/qr-payment/init failed with status 500', + expect.objectContaining({ level: 'error' }) + ) + }) + it('does not report an expected exact-username quota response', async () => { global.fetch = jest .fn() diff --git a/src/utils/sentry-env.ts b/src/utils/sentry-env.ts index a8136ccb04..5b902b572f 100644 --- a/src/utils/sentry-env.ts +++ b/src/utils/sentry-env.ts @@ -1,6 +1,7 @@ /** * Returns the Sentry `environment` tag for the current build, so issues from - * staging / production / preview / native / local are filterable in Sentry. + * the environments we report from are filterable in Sentry. It also decides + * which those are — see `isSentryReportingEnvironment` below. * * Without this every Vercel build defaulted to NODE_ENV=production and all * events tagged "production" — `environment:staging` queries returned zero @@ -20,3 +21,19 @@ export function inferSentryEnvironment(): string { } return 'development' } + +/** + * Every environment we report from — that is, all of them but a local build. + * + * This is stricter than the `NODE_ENV !== 'development'` guard it replaces: a + * `next build` on a laptop runs with NODE_ENV=production and no VERCEL_ENV, so + * it used to report as `production` and be billed there (336 events in 30 + * days). It infers `development` here instead, and reports nothing. + * + * `preview` deliberately stays on. The OTA liveness proof in + * ops/native-ota-envless-bundle-rca.md reads preview and canary events out of + * Sentry, so a dark preview would take a diagnostic with it. + */ +export function isSentryReportingEnvironment(): boolean { + return inferSentryEnvironment() !== 'development' +} diff --git a/src/utils/sentry-init.ts b/src/utils/sentry-init.ts index 1cf41b367c..212e20ecd6 100644 --- a/src/utils/sentry-init.ts +++ b/src/utils/sentry-init.ts @@ -1,7 +1,7 @@ import { redactQrTelemetry } from './qr-telemetry-privacy' import { beforeSendHandler } from '../../sentry.utils' import { posthogErrorMirror, withoutNoise } from '@/utils/sentry-posthog-mirror' -import { inferSentryEnvironment } from '@/utils/sentry-env' +import { inferSentryEnvironment, isSentryReportingEnvironment } from '@/utils/sentry-env' import { loadSentry } from '@/utils/sentry-lazy' import { isPaymentNetworkExplorerPath } from '@/utils/private-routes' @@ -12,6 +12,7 @@ export { withoutNoise } // (offline transport, no BrowserTracing); a second init here would replace it. const ENABLED = process.env.NODE_ENV !== 'development' && + isSentryReportingEnvironment() && process.env.NEXT_PUBLIC_PERF_BARE !== 'true' && process.env.NEXT_PUBLIC_CAPACITOR_BUILD !== 'true' @@ -82,8 +83,16 @@ export function initSentry(): void { isPaymentNetworkExplorerPath(window.location.pathname) ? null : redactQrTelemetry(event), integrations: [ + /* + * `error` only. A `console.warn` is billed exactly like an + * exception, and warn-level console output is where the app is + * loudest about things it already handles — the Radix + * DialogTitle notice (2,158 in 90 days), a missing icon name + * (1,420), the tokenPrice fallback (3,320). None of them is a + * defect anybody acts on. + */ Sentry.captureConsoleIntegration({ - levels: ['error', 'warn'], + levels: ['error'], }), posthogErrorMirror(), ], diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index b5176c225d..557d0786d8 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -25,8 +25,17 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[]; erro { pattern: /users/, statuses: [400, 401, 403, 404] }, { pattern: /perks/, statuses: [400, 401, 403, 404] }, // /invites/validate 400 = "Invalid Invite": the user mistyped an invite code. - // Expected input validation, surfaced inline to the user — not a server bug. - { pattern: /\/invites\/validate/, statuses: [400] }, + // 409 = a code that resolves to a campaign only, which validateInviteCode + // reads as a success (`typedCampaignOnly`). Both are expected outcomes of a + // typed code, surfaced inline to the user — not server bugs. + { pattern: /\/invites\/validate/, statuses: [400, 409] }, + // NOT here on purpose: /bridge/exchange-rate 429. It looks like ordinary + // quota noise and is not. useGetExchangeRate swallows the failure and + // returns a rate of '1', which bankWithdrawMinUsd turns into a wrong + // withdrawal minimum (MX shows $50 instead of ~$3) with nothing gating + // submission — so this 429 is the only alert for a wrong number on a money + // screen, and for the open FX-stampede P2 behind it. It reports until the + // keyed single-flight fix in no-cache.ts lands. // /tokens/price 404 means the upstream price provider declined the lookup — // in practice a Mobula 429. The UI falls back to token denomination, so it is // a degraded display, never a wrong number. The backend already downgraded @@ -534,10 +543,10 @@ const reportNonOkResponse = async ( // the status falls through and is reported. if (skipRule?.errorCodes && bodyCarriesSkippedCode(skipRule.errorCodes, errorContent)) return - // console.info, not warn — captureConsoleIntegration listens on - // ['error','warn'], so a warn here became a SECOND Sentry event for every - // non-2xx in the app, grouped by this call site rather than by request. - // The explicit captureMessage below is the real report: it fingerprints on + // console.info, not error — captureConsoleIntegration listens on error, so + // an error here would be a SECOND Sentry event for every non-2xx in the + // app, grouped by this call site rather than by request. The explicit + // captureMessage below is the real report: it fingerprints on // [method, url, status] and carries headers, body and response. console.info(`Request to ${String(url).replace(/[\r\n]/g, '')} failed with status ${response.status}`) const method = options.method || 'GET' @@ -663,8 +672,8 @@ export const fetchWithSentry = async ( }) } catch (error) { if (attempt < maxAttempts && error instanceof Error && error.name === 'AbortError') { - // console.info, not warn: captureConsoleIntegration listens on - // warn, and the retry outcome is reported explicitly below. + // console.info: a retry that succeeds is not a failure, and + // the retry outcome is reported explicitly below. console.info(`Request to ${String(telemetryUrl).replace(/[\r\n]/g, '')} timed out — retrying`) await new Promise((resolve) => setTimeout(resolve, TRANSPORT_TIMEOUT_RETRY_DELAY_MS)) continue