From cc5985e98175c2e883dcfb4e4e0ec39816b7808c Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 18 Sep 2026 17:32:26 +0200 Subject: [PATCH 1/7] chore(sentry): capture console errors only, and only from builds we read peanut-ui sends ~31k Sentry error events a month and every accepted event is billed regardless of level, so the cheapest wins are the events nobody reads. captureConsoleIntegration listened on `warn`, which bills a console.warn like an exception. Warn-level output here is handled conditions, not defects: the Radix DialogTitle notice (2,158 in 90 days), a missing icon name (1,420), the tokenPrice fallback (3,320). A message captured without an Error also arrives with a minified title ("d", "Module.d", "iE"), so ~3k a month were unreadable. Every build also reported into the production project. Ad-hoc PR previews (2,649 error + 1,374 warning in 30 days) and local builds were billed and mixed into production issues that nobody triages. Only production, staging and native now init at all. Server tracing ran at 100% while the client ran at 10%. Match them. --- sentry.edge.config.ts | 10 +++++++--- sentry.server.config.ts | 14 ++++++++++---- .../__tests__/sentryServerEdge.test.ts | 16 ++++++++++++++-- src/utils/__tests__/sentry-init.test.ts | 16 +++++++++++++--- src/utils/sentry-env.ts | 12 ++++++++++++ src/utils/sentry-init.ts | 13 +++++++++++-- 6 files changed, 67 insertions(+), 14 deletions(-) diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 39f96bb99c..3c53ee9a99 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') { +// Skipped outside production / staging / native: an ad-hoc PR preview reported +// into the same project as production, where nobody triaged it. +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..a4efbd26c2 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') { +// Skipped outside production / staging / native: an ad-hoc PR preview reported +// into the same project as production, where nobody triaged it. +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..a657e4cea3 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,19 @@ 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] + } + + // A PR preview reports into the production project, where nobody triages it. + it.each(['sentry.server.config', 'sentry.edge.config'])('does not init on a preview in %s', (moduleName) => { + expect(loadConfig(moduleName, 'preview')).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/utils/__tests__/sentry-init.test.ts b/src/utils/__tests__/sentry-init.test.ts index 7fc51713d3..157643120f 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,8 +53,18 @@ describe('initSentry', () => { expect(Sentry.init).not.toHaveBeenCalled() }) + it('never inits on a PR preview — those events are billed and nobody reads them', async () => { + const { initSentry, Sentry } = load({ NEXT_PUBLIC_VERCEL_ENV: 'preview' }) + 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({}) + const { initSentry, Sentry } = load({ NEXT_PUBLIC_VERCEL_ENV: 'production' }) Sentry.getClient.mockReturnValue(undefined) initSentry() @@ -68,7 +78,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/sentry-env.ts b/src/utils/sentry-env.ts index a8136ccb04..08b733ebd4 100644 --- a/src/utils/sentry-env.ts +++ b/src/utils/sentry-env.ts @@ -20,3 +20,15 @@ export function inferSentryEnvironment(): string { } return 'development' } + +/** + * The environments we pay Sentry for. Every build reports into the same + * project, so ad-hoc PR previews and local builds were billed alongside + * production and mixed into its issues — 2,649 error + 1,374 warning events + * from `preview` in 30 days, plus 336 from `development`, that nobody reads. + */ +const REPORTING_ENVIRONMENTS = new Set(['production', 'native', 'staging']) + +export function isSentryReportingEnvironment(): boolean { + return REPORTING_ENVIRONMENTS.has(inferSentryEnvironment()) +} 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(), ], From a03b8307d2eb037e1d166d1006c9f2be9093aba7 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 18 Sep 2026 17:32:40 +0200 Subject: [PATCH 2/7] chore(sentry): stop reporting three expected non-2xx responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these is an outcome the UI handles and shows the user, so an error in Sentry says only that the product worked. - /invites/validate 409: the code resolves to a campaign only, which validateInviteCode already reads as a success. - /bridge/exchange-rate 429: the upstream quota doing its job. Every mounted rate hook retries, so one fault arrived as many events. - /perks/pending 401: a stale session. The skip rule already covered it, but the call site's own console.error re-created the event under a minified title — that is the "Expected stale-session 401" known-noise row. 5xx on all three still reports, pinned by tests here and on /manteca/qr-payment/init. --- src/services/perks.ts | 7 +++- src/utils/__tests__/sentry.utils.test.ts | 50 ++++++++++++++++++++++++ src/utils/sentry.utils.ts | 11 +++++- 3 files changed, 65 insertions(+), 3 deletions(-) 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.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 2c901f13fa..269d253ea0 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -76,6 +76,56 @@ 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() + }) + + it('does NOT report /bridge/exchange-rate 429 (the upstream quota doing its job)', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(429, { error: 'RATE_LIMITED' })) + + const res = await fetchWithSentry('https://api.peanut.me/bridge/exchange-rate?accountType=iban') + + expect(res.status).toBe(429) + expect(Sentry.captureMessage).not.toHaveBeenCalled() + }) + + it('still reports /bridge/exchange-rate 500', async () => { + global.fetch = jest.fn().mockResolvedValue(mockResponse(500, { error: 'boom' })) + + 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.utils.ts b/src/utils/sentry.utils.ts index b5176c225d..16d78f944f 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -25,8 +25,15 @@ 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] }, + // Public exchange rates are fetched by every mounted rate hook and are + // deliberately rate-limited upstream. A 429 is the quota doing its job; the + // UI keeps the last rate and retries. peanut-api already reports the + // upstream cause, so reporting here just multiplies one fault by the hooks. + { pattern: /\/bridge\/exchange-rate/, statuses: [429] }, // /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 From ff7bde3f92a4bcee7785b34f8e1820310f6a283e Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 18 Sep 2026 17:32:41 +0200 Subject: [PATCH 3/7] chore(sentry): send three telemetry streams to PostHog instead of Sentry Sentry bills a captureMessage like a crash. None of these is a fault: - The native transport canary already wrote the same verdict to PostHog as native_transport_canary_failed; the Sentry message was a paid duplicate. The per-probe error text that used to ride only on the Sentry event now rides on the PostHog one, so nothing is lost and the daily sampling it needed is gone. - Passkey debug info (1,558 in 90 days at level info) is device capability state. In PostHog it is queryable against the passkey funnel. The captureException for a real failure stays. - The OneSignal subscription snapshot reported its own read failure (~410 a month) as a stackless message. It now rides the same PostHog event as the snapshot itself, under snapshot_error. --- src/constants/analytics.consts.ts | 5 ++ src/services/onesignal/native.adapter.ts | 13 ++-- src/utils/__tests__/native-canary.test.ts | 92 +++++++---------------- src/utils/native-canary.ts | 69 ++++------------- src/utils/passkeyDebug.ts | 11 +-- 5 files changed, 60 insertions(+), 130 deletions(-) diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index fd20b27bfe..a8aa221b03 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -331,6 +331,11 @@ export const ANALYTICS_EVENTS = { // refused/wedged, e.g. 1Password on iOS), `context` is the signing call site. PASSKEY_SIGN_FAILED: 'passkey_sign_failed', + // Device and WebAuthn capability snapshot, taken when a passkey step + // fails. A state fact, not a fault — it used to be a Sentry message at + // `info` level (1,558 in 90 days) that no triage ever opened. + PASSKEY_DEBUG_INFO: 'passkey_debug_info', + // One event per WebAuthn ceremony our code requests, tagged with the purpose // stack (`kernel_migration>user_op`, `admin_eip712`, …) and the flow it ran // in. `webauthn_ceremony_flow` closes a flow with the total count — that diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index 1d0c8bbf96..6af9cca57c 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -46,8 +46,10 @@ const snapshotTriggersFired = new Set() * and login are async) and again on the first subscription change (opt-in * often lands after the init snapshot). Deliberately omits the raw token. * - * This is a state fact, not a fault, so it goes to PostHog. Only a failure to - * read the state is an error worth Sentry. + * This is a state fact, not a fault, so it goes to PostHog — a failure to read + * it included, on the same event under `snapshot_error`. It used to be a + * separate stackless Sentry message (~410 a month) that said no more than the + * missing PostHog event already did. */ function captureSubscriptionSnapshot(trigger: string) { if (snapshotTriggersFired.has(trigger)) return @@ -72,10 +74,9 @@ function captureSubscriptionSnapshot(trigger: string) { onesignal_id: onesignalId, }) } catch (err) { - captureMessage('onesignal subscription snapshot failed', { - level: 'warning', - tags: { feature: 'onesignal', onesignal: 'subscription-snapshot', 'onesignal.trigger': trigger }, - extra: { error: String(err) }, + posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIPTION_SNAPSHOT, { + trigger, + snapshot_error: String(err), }) } })() diff --git a/src/utils/__tests__/native-canary.test.ts b/src/utils/__tests__/native-canary.test.ts index e69a288673..922fc0674f 100644 --- a/src/utils/__tests__/native-canary.test.ts +++ b/src/utils/__tests__/native-canary.test.ts @@ -1,8 +1,6 @@ -import * as Sentry from '@sentry/nextjs' import posthog from 'posthog-js' import { runCanary, scheduleTransportCanary } from '../native-canary' -jest.mock('@sentry/nextjs', () => ({ captureMessage: jest.fn() })) jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) jest.mock('../capacitor', () => ({ isNativeBridge: jest.fn(() => true), isCapacitor: jest.fn(() => true) })) jest.mock('../passkey-auth-capture', () => ({ getUnderlyingFetch: () => null })) @@ -14,7 +12,6 @@ jest.mock('../app-version', () => ({ getBinaryInfo: async () => ({ appVersion: ' const { nativeHttpRequest } = jest.requireMock('../native-http') as { nativeHttpRequest: jest.Mock } const { isNativeBridge } = jest.requireMock('../capacitor') as { isNativeBridge: jest.Mock } -const captureMessage = Sentry.captureMessage as jest.Mock const capturePosthog = posthog.capture as jest.Mock const ok = (status = 200) => ({ status }) as Response @@ -43,7 +40,6 @@ describe('transport canary', () => { it('stays silent and skips the control when every primary probe succeeds', async () => { await runCanary() - expect(captureMessage).not.toHaveBeenCalled() expect(capturePosthog).not.toHaveBeenCalled() expect(nativeHttpRequest).toHaveBeenCalledTimes(1) }) @@ -53,10 +49,10 @@ describe('transport canary', () => { await runCanary() - expect(captureMessage).not.toHaveBeenCalled() + expect(capturePosthog).not.toHaveBeenCalled() }) - it('reports and fingerprints the Android asymmetric transport shape', async () => { + it('reports the Android asymmetric transport shape', async () => { mockWebFetch(async (_url, init) => { if ((init?.method ?? 'GET') === 'GET') throw new TypeError('Failed to fetch: net::ERR_FAILED') return ok(405) @@ -64,40 +60,27 @@ describe('transport canary', () => { await runCanary() - expect(captureMessage).toHaveBeenCalledTimes(1) - const [message, options] = captureMessage.mock.calls[0] - expect(message).toBe('native canary: get:fail post:ok native:ok') - expect(options.level).toBe('warning') - expect(options.fingerprint).toEqual([ - 'native-canary-v7', - 'transport-asymmetry', - 'get:fail post:ok native:ok', - 'direct', - ]) - expect(options.tags).toMatchObject({ - canary: 'transport', - canaryVersion: '7', + expect(capturePosthog).toHaveBeenCalledTimes(1) + const [event, properties] = capturePosthog.mock.calls[0] + expect(event).toBe('native_transport_canary_failed') + expect(properties).toMatchObject({ + canary_version: '7', + canary_signature: 'get:fail post:ok native:ok', canary_classification: 'transport-asymmetry', canary_get: 'network-error', canary_post: 'http-405', canary_native: 'http-200', - canary_internet: 'not-run', - appVersion: '1.0.57', - appBuild: '412', + webview_transport: 'direct', + app_version: '1.0.57', + app_build: '412', }) + expect(properties.canary_internet).toBeUndefined() // the net:: code is the field most likely to name the root cause - expect(options.extra.get.errorMessage).toContain('net::ERR_FAILED') - expect(capturePosthog).toHaveBeenCalledWith( - 'native_transport_canary_failed', - expect.objectContaining({ - canary_classification: 'transport-asymmetry', - sentry_sampled: true, - }) - ) + expect(properties.canary_get_error).toContain('net::ERR_FAILED') expect(nativeHttpRequest).toHaveBeenCalledTimes(1) }) - it('does not let a PostHog failure suppress the actionable Sentry warning', async () => { + it('never lets a PostHog failure escape into app startup', async () => { mockWebFetch(async (_url, init) => { if ((init?.method ?? 'GET') === 'GET') throw new TypeError('Failed to fetch') return ok(405) @@ -106,10 +89,7 @@ describe('transport canary', () => { throw new Error('PostHog unavailable') }) - await runCanary() - - expect(captureMessage).toHaveBeenCalledTimes(1) - expect(captureMessage.mock.calls[0][1].tags.canary_classification).toBe('transport-asymmetry') + await expect(runCanary()).resolves.toBeUndefined() }) it('classifies an aborted probe as a timeout', async () => { @@ -121,9 +101,10 @@ describe('transport canary', () => { await runCanary() - const [message, options] = captureMessage.mock.calls[0] - expect(message).toBe('native canary: get:fail post:fail native:ok') - expect(options.tags.canary_get).toBe('timeout') + expect(capturePosthog.mock.calls[0][1]).toMatchObject({ + canary_signature: 'get:fail post:fail native:ok', + canary_get: 'timeout', + }) }) it('reports an API-host outage when the independent internet control succeeds', async () => { @@ -137,37 +118,30 @@ describe('transport canary', () => { await runCanary() - expect(captureMessage).toHaveBeenCalledTimes(1) - expect(captureMessage.mock.calls[0][0]).toBe('native canary: get:fail post:fail native:fail') - expect(captureMessage.mock.calls[0][1]).toMatchObject({ - level: 'warning', - fingerprint: ['native-canary-v7', 'api-unreachable', 'get:fail post:fail native:fail', 'direct'], - tags: { - canary_classification: 'api-unreachable', - canary_capgo: 'http-204', - canary_internet: 'http-204', - }, + expect(capturePosthog).toHaveBeenCalledTimes(1) + expect(capturePosthog.mock.calls[0][1]).toMatchObject({ + canary_signature: 'get:fail post:fail native:fail', + canary_classification: 'api-unreachable', + canary_capgo: 'http-204', + canary_internet: 'http-204', }) expect(nativeHttpRequest).toHaveBeenCalledTimes(3) }) - it('keeps whole-device connectivity in PostHog when the daily Sentry sample is not selected', async () => { + it('persists whole-device connectivity so an offline launch is not lost', async () => { mockWebFetch(async () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Unable to resolve host')) - jest.spyOn(Math, 'random').mockReturnValue(0.5) await runCanary() - expect(captureMessage).not.toHaveBeenCalled() expect(capturePosthog).toHaveBeenCalledWith( 'native_transport_canary_failed', expect.objectContaining({ canary_classification: 'device-connectivity', canary_capgo: 'network-error', canary_internet: 'network-error', - sentry_sampled: false, canary_replayed: false, }), expect.objectContaining({ uuid: expect.any(String) }) @@ -181,7 +155,6 @@ describe('transport canary', () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Unable to resolve host')) - jest.spyOn(Math, 'random').mockReturnValue(0.5) await runCanary() @@ -214,12 +187,11 @@ describe('transport canary', () => { ) }) - it('samples whole-device connectivity into Sentry at info level at most once per day', async () => { + it('keeps one durable event per failing launch', async () => { mockWebFetch(async () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Unable to resolve host')) - jest.spyOn(Math, 'random').mockReturnValue(0) await runCanary() await runCanary() @@ -228,11 +200,6 @@ describe('transport canary', () => { expect(capturePosthog.mock.calls[1][2].uuid).toBe(capturePosthog.mock.calls[0][2].uuid) expect(capturePosthog.mock.calls[2][2].uuid).not.toBe(capturePosthog.mock.calls[0][2].uuid) expect(JSON.parse(localStorage.getItem('nativeCanaryConnectivityOutboxV1') ?? '[]')).toHaveLength(2) - expect(captureMessage).toHaveBeenCalledTimes(1) - expect(captureMessage.mock.calls[0][1]).toMatchObject({ - level: 'info', - fingerprint: ['native-canary-v7', 'device-connectivity', 'get:fail post:fail native:fail', 'direct'], - }) }) it('uses a simple POST without an application/json preflight', async () => { @@ -251,8 +218,8 @@ describe('transport canary', () => { await jest.advanceTimersByTimeAsync(10_000) await run - expect(captureMessage).toHaveBeenCalledTimes(1) - expect(captureMessage.mock.calls[0][1].tags).toMatchObject({ + expect(capturePosthog).toHaveBeenCalledTimes(1) + expect(capturePosthog.mock.calls[0][1]).toMatchObject({ canary_get: 'timeout', canary_post: 'timeout', canary_native: 'http-200', @@ -264,7 +231,6 @@ describe('transport canary', () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Failed to fetch')) - jest.spyOn(Math, 'random').mockReturnValue(0.5) await runCanary() diff --git a/src/utils/native-canary.ts b/src/utils/native-canary.ts index 8d23870361..e694b96e17 100644 --- a/src/utils/native-canary.ts +++ b/src/utils/native-canary.ts @@ -18,8 +18,10 @@ * - Native Capgo and public-internet control probes run only after all three * primary probes fail. They separate an API-host incident from ordinary * device connectivity without adding round trips to healthy launches. - * - Asymmetric/API-host failures stay `warning`; whole-device connectivity is - * measured in PostHog and sampled into Sentry at `info` level. + * - Reported to PostHog only. The duplicate Sentry message this used to send + * alongside it carried the same verdict at a per-event price, and every + * consumer of it — the per-build split, the classification rates — is a + * PostHog query over `native_transport_canary_failed`. * * NO `no-cors` PROBE, which is what makes this safe to run on iOS. WKWebView * serves no opaque responses at all: the retired canary measured that probe @@ -36,12 +38,10 @@ * so per-build rates (the split that surfaced 3% on `8016c68` vs 21% on * `d4bd3ab`) can be reproduced by splitting on those. * - * Query: message starts `native canary:`. An explicit fingerprint includes the - * signature because the browser SDK attaches a synthetic stack to messages; - * without it, Sentry groups every shape at this file's captureMessage callsite. + * Query: event `native_transport_canary_failed`, split on + * `canary_classification` and `canary_signature`. */ -import * as Sentry from '@sentry/nextjs' import posthog from 'posthog-js' import { PEANUT_API_URL } from '@/constants/general.consts' import { isNativeBridge } from './capacitor' @@ -53,8 +53,6 @@ import { readStoredValue, removeStoredValue, writeStoredValue } from './safe-sto const CANARY_TIMEOUT_MS = 10_000 const CAPGO_CONTROL_URL = 'https://plugin.capgo.app/' const INTERNET_CONTROL_URL = 'https://www.gstatic.com/generate_204' -const CONNECTIVITY_SENTRY_SAMPLE_RATE = 0.1 -const CONNECTIVITY_SENTRY_DAY_KEY = 'nativeCanaryConnectivitySentryDay' const CONNECTIVITY_OUTBOX_KEY = 'nativeCanaryConnectivityOutboxV1' const CONNECTIVITY_OUTBOX_RETENTION_DAYS = 7 const CANARY_EVENT_NAME = 'native_transport_canary_failed' @@ -137,15 +135,6 @@ async function nativeProbe(url: string): Promise { } } -function shouldSampleConnectivityToSentry(): boolean { - const day = new Date().toISOString().slice(0, 10) - if (readStoredValue(CONNECTIVITY_SENTRY_DAY_KEY) === day) return false - // Persist the daily decision, including a decision not to sample. Otherwise - // repeated launches would turn 10% sampling into near-certain reporting. - writeStoredValue(CONNECTIVITY_SENTRY_DAY_KEY, day) - return Math.random() < CONNECTIVITY_SENTRY_SAMPLE_RATE -} - function utcDay(date: Date = new Date()): string { return date.toISOString().slice(0, 10) } @@ -295,7 +284,6 @@ export async function runCanary(): Promise { const baseFetch = getUnderlyingFetch() ?? window.fetch const webviewTransport = !!capWebFetch && baseFetch !== capWebFetch ? 'cap-http-proxy' : 'direct' const { appVersion, appBuild } = (await getBinaryInfo()) ?? { appVersion: 'unknown', appBuild: 'unknown' } - const sentrySampled = classification !== 'device-connectivity' || shouldSampleConnectivityToSentry() const eventProperties: CanaryEventProperties = { canary_version: '7', @@ -309,57 +297,26 @@ export async function runCanary(): Promise { canary_get_ms: results[0].durationMs, canary_post_ms: results[1].durationMs, canary_native_ms: results[2].durationMs, + // Android WebView TypeErrors carry net:: codes here — the one field + // most likely to name the root cause. It used to ride only on the + // Sentry event's `extra`. + canary_get_error: results[0].errorMessage, + canary_post_error: results[1].errorMessage, + canary_native_error: results[2].errorMessage, canary_capgo_ms: capgo?.durationMs, canary_internet_ms: internet?.durationMs, webview_transport: webviewTransport, app_version: appVersion, app_build: appBuild, online: navigator.onLine, - sentry_sampled: sentrySampled, } try { const pending = classification === 'device-connectivity' ? persistConnectivityEvent(eventProperties) : undefined capturePostHog(eventProperties, pending) } catch { - // Diagnostics must never affect app startup or the Sentry signal. + // Diagnostics must never affect app startup. } - - if (!sentrySampled) return - - Sentry.captureMessage(`native canary: ${signature}`, { - level: classification === 'device-connectivity' ? 'info' : 'warning', - fingerprint: ['native-canary-v7', classification, signature, webviewTransport], - tags: { - canary: 'transport', - canaryVersion: '7', - canary_signature: signature, - canary_classification: classification, - canary_get: outcomes.get, - canary_post: outcomes.post, - canary_native: outcomes.native, - canary_capgo: capgo?.outcome ?? 'not-run', - canary_internet: internet?.outcome ?? 'not-run', - webviewTransport, - appVersion, - appBuild, - online: String(navigator.onLine), - }, - extra: { - ...Object.fromEntries( - probes.map(({ name }, i) => [ - name, - { - durationMs: results[i].durationMs, - errorName: results[i].errorName, - errorMessage: results[i].errorMessage, - }, - ]) - ), - ...(capgo ? { capgo } : {}), - ...(internet ? { internet } : {}), - }, - }) } /* diff --git a/src/utils/passkeyDebug.ts b/src/utils/passkeyDebug.ts index 528d9c2f1c..803012142a 100644 --- a/src/utils/passkeyDebug.ts +++ b/src/utils/passkeyDebug.ts @@ -1,4 +1,6 @@ import * as Sentry from '@sentry/nextjs' +import posthog from 'posthog-js' +import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' /** * captures debug information about passkey and device capabilities @@ -55,11 +57,10 @@ export const capturePasskeyDebugInfo = async (context: string) => { } } - // log to sentry with all collected info - Sentry.captureMessage(`Passkey Debug Info: ${context}`, { - level: 'info', - extra: debugInfo, - }) + // PostHog, not Sentry: this is device capability state, queryable + // against the passkey funnel. As a Sentry message it was a billed + // event with a synthetic stack and no defect behind it. + posthog.capture(ANALYTICS_EVENTS.PASSKEY_DEBUG_INFO, debugInfo) console.log('[PasskeyDebug]', debugInfo) return debugInfo From 59c9bfef821ee6058005488182c4d0a40eca1505 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 18 Sep 2026 17:47:33 +0200 Subject: [PATCH 4/7] chore(sentry): narrow the telemetry move to the passkey debug message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope correction after checking the current volumes. The native transport canary and the OneSignal subscription snapshot are restored untouched: both already write to PostHog, and their remaining Sentry volume (14 and ~407 a month) comes from the retired canary in old native binaries, which no code change can stop — a Sentry inbound filter will. That leaves the passkey debug capture, which is live (SignTestTransaction calls it on a failed test transaction) but reported device capability state as a Sentry message at level info, 1,558 in 90 days. It is console-only now; the captureException for a real failure is unchanged. --- src/constants/analytics.consts.ts | 5 -- src/services/onesignal/native.adapter.ts | 13 ++-- src/utils/__tests__/native-canary.test.ts | 92 ++++++++++++++++------- src/utils/native-canary.ts | 69 +++++++++++++---- src/utils/passkeyDebug.ts | 11 +-- 5 files changed, 129 insertions(+), 61 deletions(-) diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index a8aa221b03..fd20b27bfe 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -331,11 +331,6 @@ export const ANALYTICS_EVENTS = { // refused/wedged, e.g. 1Password on iOS), `context` is the signing call site. PASSKEY_SIGN_FAILED: 'passkey_sign_failed', - // Device and WebAuthn capability snapshot, taken when a passkey step - // fails. A state fact, not a fault — it used to be a Sentry message at - // `info` level (1,558 in 90 days) that no triage ever opened. - PASSKEY_DEBUG_INFO: 'passkey_debug_info', - // One event per WebAuthn ceremony our code requests, tagged with the purpose // stack (`kernel_migration>user_op`, `admin_eip712`, …) and the flow it ran // in. `webauthn_ceremony_flow` closes a flow with the total count — that diff --git a/src/services/onesignal/native.adapter.ts b/src/services/onesignal/native.adapter.ts index 6af9cca57c..1d0c8bbf96 100644 --- a/src/services/onesignal/native.adapter.ts +++ b/src/services/onesignal/native.adapter.ts @@ -46,10 +46,8 @@ const snapshotTriggersFired = new Set() * and login are async) and again on the first subscription change (opt-in * often lands after the init snapshot). Deliberately omits the raw token. * - * This is a state fact, not a fault, so it goes to PostHog — a failure to read - * it included, on the same event under `snapshot_error`. It used to be a - * separate stackless Sentry message (~410 a month) that said no more than the - * missing PostHog event already did. + * This is a state fact, not a fault, so it goes to PostHog. Only a failure to + * read the state is an error worth Sentry. */ function captureSubscriptionSnapshot(trigger: string) { if (snapshotTriggersFired.has(trigger)) return @@ -74,9 +72,10 @@ function captureSubscriptionSnapshot(trigger: string) { onesignal_id: onesignalId, }) } catch (err) { - posthog.capture(ANALYTICS_EVENTS.NOTIFICATION_SUBSCRIPTION_SNAPSHOT, { - trigger, - snapshot_error: String(err), + captureMessage('onesignal subscription snapshot failed', { + level: 'warning', + tags: { feature: 'onesignal', onesignal: 'subscription-snapshot', 'onesignal.trigger': trigger }, + extra: { error: String(err) }, }) } })() diff --git a/src/utils/__tests__/native-canary.test.ts b/src/utils/__tests__/native-canary.test.ts index 922fc0674f..e69a288673 100644 --- a/src/utils/__tests__/native-canary.test.ts +++ b/src/utils/__tests__/native-canary.test.ts @@ -1,6 +1,8 @@ +import * as Sentry from '@sentry/nextjs' import posthog from 'posthog-js' import { runCanary, scheduleTransportCanary } from '../native-canary' +jest.mock('@sentry/nextjs', () => ({ captureMessage: jest.fn() })) jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) jest.mock('../capacitor', () => ({ isNativeBridge: jest.fn(() => true), isCapacitor: jest.fn(() => true) })) jest.mock('../passkey-auth-capture', () => ({ getUnderlyingFetch: () => null })) @@ -12,6 +14,7 @@ jest.mock('../app-version', () => ({ getBinaryInfo: async () => ({ appVersion: ' const { nativeHttpRequest } = jest.requireMock('../native-http') as { nativeHttpRequest: jest.Mock } const { isNativeBridge } = jest.requireMock('../capacitor') as { isNativeBridge: jest.Mock } +const captureMessage = Sentry.captureMessage as jest.Mock const capturePosthog = posthog.capture as jest.Mock const ok = (status = 200) => ({ status }) as Response @@ -40,6 +43,7 @@ describe('transport canary', () => { it('stays silent and skips the control when every primary probe succeeds', async () => { await runCanary() + expect(captureMessage).not.toHaveBeenCalled() expect(capturePosthog).not.toHaveBeenCalled() expect(nativeHttpRequest).toHaveBeenCalledTimes(1) }) @@ -49,10 +53,10 @@ describe('transport canary', () => { await runCanary() - expect(capturePosthog).not.toHaveBeenCalled() + expect(captureMessage).not.toHaveBeenCalled() }) - it('reports the Android asymmetric transport shape', async () => { + it('reports and fingerprints the Android asymmetric transport shape', async () => { mockWebFetch(async (_url, init) => { if ((init?.method ?? 'GET') === 'GET') throw new TypeError('Failed to fetch: net::ERR_FAILED') return ok(405) @@ -60,27 +64,40 @@ describe('transport canary', () => { await runCanary() - expect(capturePosthog).toHaveBeenCalledTimes(1) - const [event, properties] = capturePosthog.mock.calls[0] - expect(event).toBe('native_transport_canary_failed') - expect(properties).toMatchObject({ - canary_version: '7', - canary_signature: 'get:fail post:ok native:ok', + expect(captureMessage).toHaveBeenCalledTimes(1) + const [message, options] = captureMessage.mock.calls[0] + expect(message).toBe('native canary: get:fail post:ok native:ok') + expect(options.level).toBe('warning') + expect(options.fingerprint).toEqual([ + 'native-canary-v7', + 'transport-asymmetry', + 'get:fail post:ok native:ok', + 'direct', + ]) + expect(options.tags).toMatchObject({ + canary: 'transport', + canaryVersion: '7', canary_classification: 'transport-asymmetry', canary_get: 'network-error', canary_post: 'http-405', canary_native: 'http-200', - webview_transport: 'direct', - app_version: '1.0.57', - app_build: '412', + canary_internet: 'not-run', + appVersion: '1.0.57', + appBuild: '412', }) - expect(properties.canary_internet).toBeUndefined() // the net:: code is the field most likely to name the root cause - expect(properties.canary_get_error).toContain('net::ERR_FAILED') + expect(options.extra.get.errorMessage).toContain('net::ERR_FAILED') + expect(capturePosthog).toHaveBeenCalledWith( + 'native_transport_canary_failed', + expect.objectContaining({ + canary_classification: 'transport-asymmetry', + sentry_sampled: true, + }) + ) expect(nativeHttpRequest).toHaveBeenCalledTimes(1) }) - it('never lets a PostHog failure escape into app startup', async () => { + it('does not let a PostHog failure suppress the actionable Sentry warning', async () => { mockWebFetch(async (_url, init) => { if ((init?.method ?? 'GET') === 'GET') throw new TypeError('Failed to fetch') return ok(405) @@ -89,7 +106,10 @@ describe('transport canary', () => { throw new Error('PostHog unavailable') }) - await expect(runCanary()).resolves.toBeUndefined() + await runCanary() + + expect(captureMessage).toHaveBeenCalledTimes(1) + expect(captureMessage.mock.calls[0][1].tags.canary_classification).toBe('transport-asymmetry') }) it('classifies an aborted probe as a timeout', async () => { @@ -101,10 +121,9 @@ describe('transport canary', () => { await runCanary() - expect(capturePosthog.mock.calls[0][1]).toMatchObject({ - canary_signature: 'get:fail post:fail native:ok', - canary_get: 'timeout', - }) + const [message, options] = captureMessage.mock.calls[0] + expect(message).toBe('native canary: get:fail post:fail native:ok') + expect(options.tags.canary_get).toBe('timeout') }) it('reports an API-host outage when the independent internet control succeeds', async () => { @@ -118,30 +137,37 @@ describe('transport canary', () => { await runCanary() - expect(capturePosthog).toHaveBeenCalledTimes(1) - expect(capturePosthog.mock.calls[0][1]).toMatchObject({ - canary_signature: 'get:fail post:fail native:fail', - canary_classification: 'api-unreachable', - canary_capgo: 'http-204', - canary_internet: 'http-204', + expect(captureMessage).toHaveBeenCalledTimes(1) + expect(captureMessage.mock.calls[0][0]).toBe('native canary: get:fail post:fail native:fail') + expect(captureMessage.mock.calls[0][1]).toMatchObject({ + level: 'warning', + fingerprint: ['native-canary-v7', 'api-unreachable', 'get:fail post:fail native:fail', 'direct'], + tags: { + canary_classification: 'api-unreachable', + canary_capgo: 'http-204', + canary_internet: 'http-204', + }, }) expect(nativeHttpRequest).toHaveBeenCalledTimes(3) }) - it('persists whole-device connectivity so an offline launch is not lost', async () => { + it('keeps whole-device connectivity in PostHog when the daily Sentry sample is not selected', async () => { mockWebFetch(async () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Unable to resolve host')) + jest.spyOn(Math, 'random').mockReturnValue(0.5) await runCanary() + expect(captureMessage).not.toHaveBeenCalled() expect(capturePosthog).toHaveBeenCalledWith( 'native_transport_canary_failed', expect.objectContaining({ canary_classification: 'device-connectivity', canary_capgo: 'network-error', canary_internet: 'network-error', + sentry_sampled: false, canary_replayed: false, }), expect.objectContaining({ uuid: expect.any(String) }) @@ -155,6 +181,7 @@ describe('transport canary', () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Unable to resolve host')) + jest.spyOn(Math, 'random').mockReturnValue(0.5) await runCanary() @@ -187,11 +214,12 @@ describe('transport canary', () => { ) }) - it('keeps one durable event per failing launch', async () => { + it('samples whole-device connectivity into Sentry at info level at most once per day', async () => { mockWebFetch(async () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Unable to resolve host')) + jest.spyOn(Math, 'random').mockReturnValue(0) await runCanary() await runCanary() @@ -200,6 +228,11 @@ describe('transport canary', () => { expect(capturePosthog.mock.calls[1][2].uuid).toBe(capturePosthog.mock.calls[0][2].uuid) expect(capturePosthog.mock.calls[2][2].uuid).not.toBe(capturePosthog.mock.calls[0][2].uuid) expect(JSON.parse(localStorage.getItem('nativeCanaryConnectivityOutboxV1') ?? '[]')).toHaveLength(2) + expect(captureMessage).toHaveBeenCalledTimes(1) + expect(captureMessage.mock.calls[0][1]).toMatchObject({ + level: 'info', + fingerprint: ['native-canary-v7', 'device-connectivity', 'get:fail post:fail native:fail', 'direct'], + }) }) it('uses a simple POST without an application/json preflight', async () => { @@ -218,8 +251,8 @@ describe('transport canary', () => { await jest.advanceTimersByTimeAsync(10_000) await run - expect(capturePosthog).toHaveBeenCalledTimes(1) - expect(capturePosthog.mock.calls[0][1]).toMatchObject({ + expect(captureMessage).toHaveBeenCalledTimes(1) + expect(captureMessage.mock.calls[0][1].tags).toMatchObject({ canary_get: 'timeout', canary_post: 'timeout', canary_native: 'http-200', @@ -231,6 +264,7 @@ describe('transport canary', () => { throw new TypeError('Failed to fetch') }) nativeHttpRequest.mockRejectedValue(new TypeError('Failed to fetch')) + jest.spyOn(Math, 'random').mockReturnValue(0.5) await runCanary() diff --git a/src/utils/native-canary.ts b/src/utils/native-canary.ts index e694b96e17..8d23870361 100644 --- a/src/utils/native-canary.ts +++ b/src/utils/native-canary.ts @@ -18,10 +18,8 @@ * - Native Capgo and public-internet control probes run only after all three * primary probes fail. They separate an API-host incident from ordinary * device connectivity without adding round trips to healthy launches. - * - Reported to PostHog only. The duplicate Sentry message this used to send - * alongside it carried the same verdict at a per-event price, and every - * consumer of it — the per-build split, the classification rates — is a - * PostHog query over `native_transport_canary_failed`. + * - Asymmetric/API-host failures stay `warning`; whole-device connectivity is + * measured in PostHog and sampled into Sentry at `info` level. * * NO `no-cors` PROBE, which is what makes this safe to run on iOS. WKWebView * serves no opaque responses at all: the retired canary measured that probe @@ -38,10 +36,12 @@ * so per-build rates (the split that surfaced 3% on `8016c68` vs 21% on * `d4bd3ab`) can be reproduced by splitting on those. * - * Query: event `native_transport_canary_failed`, split on - * `canary_classification` and `canary_signature`. + * Query: message starts `native canary:`. An explicit fingerprint includes the + * signature because the browser SDK attaches a synthetic stack to messages; + * without it, Sentry groups every shape at this file's captureMessage callsite. */ +import * as Sentry from '@sentry/nextjs' import posthog from 'posthog-js' import { PEANUT_API_URL } from '@/constants/general.consts' import { isNativeBridge } from './capacitor' @@ -53,6 +53,8 @@ import { readStoredValue, removeStoredValue, writeStoredValue } from './safe-sto const CANARY_TIMEOUT_MS = 10_000 const CAPGO_CONTROL_URL = 'https://plugin.capgo.app/' const INTERNET_CONTROL_URL = 'https://www.gstatic.com/generate_204' +const CONNECTIVITY_SENTRY_SAMPLE_RATE = 0.1 +const CONNECTIVITY_SENTRY_DAY_KEY = 'nativeCanaryConnectivitySentryDay' const CONNECTIVITY_OUTBOX_KEY = 'nativeCanaryConnectivityOutboxV1' const CONNECTIVITY_OUTBOX_RETENTION_DAYS = 7 const CANARY_EVENT_NAME = 'native_transport_canary_failed' @@ -135,6 +137,15 @@ async function nativeProbe(url: string): Promise { } } +function shouldSampleConnectivityToSentry(): boolean { + const day = new Date().toISOString().slice(0, 10) + if (readStoredValue(CONNECTIVITY_SENTRY_DAY_KEY) === day) return false + // Persist the daily decision, including a decision not to sample. Otherwise + // repeated launches would turn 10% sampling into near-certain reporting. + writeStoredValue(CONNECTIVITY_SENTRY_DAY_KEY, day) + return Math.random() < CONNECTIVITY_SENTRY_SAMPLE_RATE +} + function utcDay(date: Date = new Date()): string { return date.toISOString().slice(0, 10) } @@ -284,6 +295,7 @@ export async function runCanary(): Promise { const baseFetch = getUnderlyingFetch() ?? window.fetch const webviewTransport = !!capWebFetch && baseFetch !== capWebFetch ? 'cap-http-proxy' : 'direct' const { appVersion, appBuild } = (await getBinaryInfo()) ?? { appVersion: 'unknown', appBuild: 'unknown' } + const sentrySampled = classification !== 'device-connectivity' || shouldSampleConnectivityToSentry() const eventProperties: CanaryEventProperties = { canary_version: '7', @@ -297,26 +309,57 @@ export async function runCanary(): Promise { canary_get_ms: results[0].durationMs, canary_post_ms: results[1].durationMs, canary_native_ms: results[2].durationMs, - // Android WebView TypeErrors carry net:: codes here — the one field - // most likely to name the root cause. It used to ride only on the - // Sentry event's `extra`. - canary_get_error: results[0].errorMessage, - canary_post_error: results[1].errorMessage, - canary_native_error: results[2].errorMessage, canary_capgo_ms: capgo?.durationMs, canary_internet_ms: internet?.durationMs, webview_transport: webviewTransport, app_version: appVersion, app_build: appBuild, online: navigator.onLine, + sentry_sampled: sentrySampled, } try { const pending = classification === 'device-connectivity' ? persistConnectivityEvent(eventProperties) : undefined capturePostHog(eventProperties, pending) } catch { - // Diagnostics must never affect app startup. + // Diagnostics must never affect app startup or the Sentry signal. } + + if (!sentrySampled) return + + Sentry.captureMessage(`native canary: ${signature}`, { + level: classification === 'device-connectivity' ? 'info' : 'warning', + fingerprint: ['native-canary-v7', classification, signature, webviewTransport], + tags: { + canary: 'transport', + canaryVersion: '7', + canary_signature: signature, + canary_classification: classification, + canary_get: outcomes.get, + canary_post: outcomes.post, + canary_native: outcomes.native, + canary_capgo: capgo?.outcome ?? 'not-run', + canary_internet: internet?.outcome ?? 'not-run', + webviewTransport, + appVersion, + appBuild, + online: String(navigator.onLine), + }, + extra: { + ...Object.fromEntries( + probes.map(({ name }, i) => [ + name, + { + durationMs: results[i].durationMs, + errorName: results[i].errorName, + errorMessage: results[i].errorMessage, + }, + ]) + ), + ...(capgo ? { capgo } : {}), + ...(internet ? { internet } : {}), + }, + }) } /* diff --git a/src/utils/passkeyDebug.ts b/src/utils/passkeyDebug.ts index 803012142a..46f46281b6 100644 --- a/src/utils/passkeyDebug.ts +++ b/src/utils/passkeyDebug.ts @@ -1,6 +1,4 @@ import * as Sentry from '@sentry/nextjs' -import posthog from 'posthog-js' -import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' /** * captures debug information about passkey and device capabilities @@ -57,11 +55,10 @@ export const capturePasskeyDebugInfo = async (context: string) => { } } - // PostHog, not Sentry: this is device capability state, queryable - // against the passkey funnel. As a Sentry message it was a billed - // event with a synthetic stack and no defect behind it. - posthog.capture(ANALYTICS_EVENTS.PASSKEY_DEBUG_INFO, debugInfo) - + // Console only. This is device capability state, not a fault: as a + // Sentry message at level info it was a billed event with a synthetic + // stack and nothing to act on (1,558 in 90 days). A real failure below + // still reports. console.log('[PasskeyDebug]', debugInfo) return debugInfo } catch (error) { From c8473e140971099ccb82ecc8a46c36bccc7d6bbc Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 18 Sep 2026 17:48:23 +0200 Subject: [PATCH 5/7] chore(sentry): correct the comments that named the old console levels Three comments justified a console.info by 'captureConsoleIntegration listens on warn'. That is no longer true, and a comment stating a false fact is worse than no comment. The decisions they explain are unchanged. --- src/utils/sentry-env.ts | 3 ++- src/utils/sentry.utils.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/utils/sentry-env.ts b/src/utils/sentry-env.ts index 08b733ebd4..4ee2c66241 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 diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 16d78f944f..32f084a895 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -541,10 +541,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' @@ -670,8 +670,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 From f96453b34c223313e0675846f8c984a59e6a6287 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Fri, 18 Sep 2026 17:57:38 +0200 Subject: [PATCH 6/7] chore(sentry): keep reporting /bridge/exchange-rate 429 after all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewers landed on the same rule independently, and the code agrees with them. useGetExchangeRate swallows the failure and returns a rate of '1' (retry:3 never fires, because resolving is not throwing), bankWithdrawMinUsd divides by it, and a Mexican bank withdrawal then shows a $50 minimum instead of about $3 with nothing gating submission. The 429 is the only alert for that wrong number, and for the open FX-stampede P2 whose runbook entry escalates on exactly this volume. The rule's justification was also wrong: the 429 comes from our own global rate limiter in peanut-api, which reports nothing server-side. Replaced with a comment saying why it must not be skipped and a test that fails if anyone adds the rule back. The invites/validate 409 and perks/pending 401 skips stand — neither has a money screen behind it. --- src/utils/__tests__/sentry.utils.test.ts | 17 ++++++----------- src/utils/sentry.utils.ts | 12 +++++++----- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/utils/__tests__/sentry.utils.test.ts b/src/utils/__tests__/sentry.utils.test.ts index 269d253ea0..cac5a8ef9d 100644 --- a/src/utils/__tests__/sentry.utils.test.ts +++ b/src/utils/__tests__/sentry.utils.test.ts @@ -87,17 +87,12 @@ describe('fetchWithSentry — expected-response suppression', () => { expect(Sentry.captureMessage).not.toHaveBeenCalled() }) - it('does NOT report /bridge/exchange-rate 429 (the upstream quota doing its job)', async () => { - global.fetch = jest.fn().mockResolvedValue(mockResponse(429, { error: 'RATE_LIMITED' })) - - const res = await fetchWithSentry('https://api.peanut.me/bridge/exchange-rate?accountType=iban') - - expect(res.status).toBe(429) - expect(Sentry.captureMessage).not.toHaveBeenCalled() - }) - - it('still reports /bridge/exchange-rate 500', async () => { - global.fetch = jest.fn().mockResolvedValue(mockResponse(500, { error: 'boom' })) + // 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') diff --git a/src/utils/sentry.utils.ts b/src/utils/sentry.utils.ts index 32f084a895..557d0786d8 100644 --- a/src/utils/sentry.utils.ts +++ b/src/utils/sentry.utils.ts @@ -29,11 +29,13 @@ const SKIP_REPORTING: Array<{ pattern: string | RegExp; statuses: number[]; erro // 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] }, - // Public exchange rates are fetched by every mounted rate hook and are - // deliberately rate-limited upstream. A 429 is the quota doing its job; the - // UI keeps the last rate and retries. peanut-api already reports the - // upstream cause, so reporting here just multiplies one fault by the hooks. - { pattern: /\/bridge\/exchange-rate/, statuses: [429] }, + // 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 From 147ef248609d2a41ff7149ba24f8c0e897552277 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Sat, 19 Sep 2026 15:57:04 +0200 Subject: [PATCH 7/7] chore(sentry): keep preview reporting, and keep the passkey debug message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reversions, both to avoid losing a diagnostic somebody depends on. Preview was going to stop initialising the SDK. It must not: the OTA liveness proof in ops/native-ota-envless-bundle-rca.md reads preview and canary events out of Sentry, so a dark preview takes that check with it. Only `development` is disabled now — which still beats the NODE_ENV guard it replaces, because a `next build` on a laptop has NODE_ENV=production and reported as production. passkeyDebug goes back to what `dev` has, captureMessage included. Support reads the collected navigator/credentials data on registration and test-transaction failures, and ~300 events a month is a fair price for it. Error sampling and the console-error-only setting are unchanged and apply everywhere; tracesSampleRate is a flat 0.1 on client and server. --- sentry.edge.config.ts | 4 ++-- sentry.server.config.ts | 4 ++-- .../__tests__/sentryServerEdge.test.ts | 13 +++++++++---- src/utils/__tests__/sentry-init.test.ts | 15 ++++++++++++++- src/utils/passkeyDebug.ts | 10 ++++++---- src/utils/sentry-env.ts | 18 +++++++++++------- 6 files changed, 44 insertions(+), 20 deletions(-) diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 3c53ee9a99..3c50fbb1a4 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -8,8 +8,8 @@ import * as Sentry from '@sentry/nextjs' import { beforeSendRouteAwareHandler, beforeSendRouteAwareTransaction } from './sentry.utils' import { inferSentryEnvironment, isSentryReportingEnvironment } from '@/utils/sentry-env' -// Skipped outside production / staging / native: an ad-hoc PR preview reported -// into the same project as production, where nobody triaged it. +// 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, diff --git a/sentry.server.config.ts b/sentry.server.config.ts index a4efbd26c2..2c03b66e03 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -7,8 +7,8 @@ import * as Sentry from '@sentry/nextjs' import { beforeSendRouteAwareHandler, beforeSendRouteAwareTransaction } from './sentry.utils' import { inferSentryEnvironment, isSentryReportingEnvironment } from '@/utils/sentry-env' -// Skipped outside production / staging / native: an ad-hoc PR preview reported -// into the same project as production, where nobody triaged it. +// 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, diff --git a/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts b/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts index a657e4cea3..41f58e751c 100644 --- a/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts +++ b/src/features/payment-network-explorer/__tests__/sentryServerEdge.test.ts @@ -69,10 +69,15 @@ describe('server and edge payment explorer Sentry guard', () => { return mockSentryInit.mock.calls[0]?.[0] } - // A PR preview reports into the production project, where nobody triages it. - it.each(['sentry.server.config', 'sentry.edge.config'])('does not init on a preview in %s', (moduleName) => { - expect(loadConfig(moduleName, 'preview')).toBeUndefined() - }) + // 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() + } + ) it.each(['sentry.server.config', 'sentry.edge.config'])('wires both route-aware hooks in %s', (moduleName) => { const options = loadConfig(moduleName, 'production') diff --git a/src/utils/__tests__/sentry-init.test.ts b/src/utils/__tests__/sentry-init.test.ts index 157643120f..62b06d9a50 100644 --- a/src/utils/__tests__/sentry-init.test.ts +++ b/src/utils/__tests__/sentry-init.test.ts @@ -53,13 +53,26 @@ describe('initSentry', () => { expect(Sentry.init).not.toHaveBeenCalled() }) - it('never inits on a PR preview — those events are billed and nobody reads them', 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() }) diff --git a/src/utils/passkeyDebug.ts b/src/utils/passkeyDebug.ts index 46f46281b6..528d9c2f1c 100644 --- a/src/utils/passkeyDebug.ts +++ b/src/utils/passkeyDebug.ts @@ -55,10 +55,12 @@ export const capturePasskeyDebugInfo = async (context: string) => { } } - // Console only. This is device capability state, not a fault: as a - // Sentry message at level info it was a billed event with a synthetic - // stack and nothing to act on (1,558 in 90 days). A real failure below - // still reports. + // log to sentry with all collected info + Sentry.captureMessage(`Passkey Debug Info: ${context}`, { + level: 'info', + extra: debugInfo, + }) + console.log('[PasskeyDebug]', debugInfo) return debugInfo } catch (error) { diff --git a/src/utils/sentry-env.ts b/src/utils/sentry-env.ts index 4ee2c66241..5b902b572f 100644 --- a/src/utils/sentry-env.ts +++ b/src/utils/sentry-env.ts @@ -23,13 +23,17 @@ export function inferSentryEnvironment(): string { } /** - * The environments we pay Sentry for. Every build reports into the same - * project, so ad-hoc PR previews and local builds were billed alongside - * production and mixed into its issues — 2,649 error + 1,374 warning events - * from `preview` in 30 days, plus 336 from `development`, that nobody reads. + * 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. */ -const REPORTING_ENVIRONMENTS = new Set(['production', 'native', 'staging']) - export function isSentryReportingEnvironment(): boolean { - return REPORTING_ENVIRONMENTS.has(inferSentryEnvironment()) + return inferSentryEnvironment() !== 'development' }