Skip to content
10 changes: 7 additions & 3 deletions sentry.edge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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'],
}),
],
})
Expand Down
14 changes: 10 additions & 4 deletions sentry.server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}),
],

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,38 @@ 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(() => {
require(`../../../../${moduleName}.ts`)
})
} 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()
Expand Down
7 changes: 6 additions & 1 deletion src/services/perks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
}

Expand Down
29 changes: 26 additions & 3 deletions src/utils/__tests__/sentry-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<(typeof ENV_KEYS)[number], string | undefined>> = {}

const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions src/utils/__tests__/sentry.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 18 additions & 1 deletion src/utils/sentry-env.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'
}
13 changes: 11 additions & 2 deletions src/utils/sentry-init.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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'

Expand Down Expand Up @@ -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(),
],
Expand Down
25 changes: 17 additions & 8 deletions src/utils/sentry.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Loading