fix(sentry): gate non-actionable client noise before it leaves the device (#169)
opencode-mobile is now the org's #1 Sentry volume source (~4,500 events/mo against a 3,500/mo org quota, AGE-105). ~1,100 of those events are three non-defects: `connect timeout` (462), `connect server-unreachable` (157), and one device's `API Error: 401` token-refresh loop firing 498 times. Adds a pure, unit-tested noise gate (src/lib/sentry-noise.ts) wired into `beforeSend`, applying three layers cheapest-first: 1. Always-send allowlist — OOM/ANR/native/fatal crash classes bypass every limit. Quota is worthless if it silences real crashes. 2. Transport drop-list — hard drop for client-side network conditions. Hard, not sampled: the gate runs per-install, so "1 per device per day" would multiply by the install base straight back into thousands per month. 3. Dedup + rate cap — 6h per-fingerprint cooldown, ≤6 new fingerprints/h, ≤10 events/h, mirroring the openclaw-box-bot shim (AGE-55). Nothing is lost by the transport drop: those failures are already user-visible as connection UI and already trended, PII-free, as the PostHog `connection_failed{error_class}` event. captureDiagnostic() also short-circuits for those classifications so the event is never even built. Drops are auditable — the count since the last delivered event rides along as a `noise.dropped_since_last` tag. Replaying the observed 1,126-event hour through the gate yields 5 delivered events (1 auth report + 4 real OOMs). Tests: 18 new, 257 total passing; tsc --noEmit clean. Co-authored-by: engineer <engineer@macbookpro.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -75,6 +75,34 @@ Sentry and PostHog — there is no separate analytics toggle. Managed by `src/li
|
|||||||
- **Re-grant mid-session:** `optIn()` clears the persisted opt-out; the `app_opened`
|
- **Re-grant mid-session:** `optIn()` clears the persisted opt-out; the `app_opened`
|
||||||
session guard prevents double-counting.
|
session guard prevents double-counting.
|
||||||
|
|
||||||
|
## Sentry event budget — the noise gate (AGE-105)
|
||||||
|
|
||||||
|
Consent decides *whether* we report; the noise gate in `src/lib/sentry-noise.ts` decides *how
|
||||||
|
often*. It exists because this app became the org's #1 Sentry volume source (~4,500
|
||||||
|
events/month against a 3,500/month org quota) while ~1,100 of those events were three
|
||||||
|
non-defects: `connect timeout`, `connect server-unreachable`, and one device's
|
||||||
|
`API Error: 401` token-refresh loop firing 498 times.
|
||||||
|
|
||||||
|
`beforeSend` applies three layers, cheapest first:
|
||||||
|
|
||||||
|
| Layer | Rule | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| Always-send allowlist | OOM / ANR / native / `IllegalStateException` / `NullPointerException` / fatal level / unhandled mechanism | Bypasses every limit below — quota is worthless if it silences real crashes |
|
||||||
|
| Transport drop-list | `connect timeout\|server-unreachable\|no-internet\|malformed-url`, `Network request failed`, `Request timed out after`, `ECONN*`/`ETIMEDOUT`… | Hard drop. Not sampled: the gate is per-install, so even 1/device/day multiplies by the install base back into thousands/month |
|
||||||
|
| Dedup + rate cap | per-fingerprint cooldown 6h, ≤6 new fingerprints/h, ≤10 events/h (mirrors the `openclaw-box-bot` shim, AGE-55) | Turns a retry loop into one report and caps any future regression |
|
||||||
|
|
||||||
|
Nothing is lost by the transport drop: those failures are already shown to the user as
|
||||||
|
connection UI **and** already trended, PII-free, as the PostHog `connection_failed` event with
|
||||||
|
an `error_class` property (`src/lib/analytics-classify.ts`). Sentry was paying per event for a
|
||||||
|
graph we already have.
|
||||||
|
|
||||||
|
Dropped-event counts are not silent — the number dropped since the last delivered event rides
|
||||||
|
along as a `noise.dropped_since_last` tag, so the saving is auditable from Sentry itself.
|
||||||
|
|
||||||
|
Rules are pure and unit-tested in `src/lib/sentry-noise.test.ts` (18 tests, incl. a replay of
|
||||||
|
the observed 1,126-event hour → 5 delivered events). Widening the drop-list is a deliberate
|
||||||
|
act: add a test asserting the new pattern, and never add anything that could mask a crash.
|
||||||
|
|
||||||
## Disclosure surfaces (must stay in sync)
|
## Disclosure surfaces (must stay in sync)
|
||||||
|
|
||||||
| Surface | File |
|
| Surface | File |
|
||||||
|
|||||||
205
src/lib/sentry-noise.test.ts
Normal file
205
src/lib/sentry-noise.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { test } from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {
|
||||||
|
DEFAULT_GATE_LIMITS,
|
||||||
|
NoiseGate,
|
||||||
|
eventText,
|
||||||
|
fingerprint,
|
||||||
|
isAlwaysSend,
|
||||||
|
isFatalEvent,
|
||||||
|
isTransportNoise,
|
||||||
|
} from "./sentry-noise.ts"
|
||||||
|
|
||||||
|
const T0 = 1_786_700_000_000 // fixed clock; every test drives time explicitly
|
||||||
|
const MIN = 60_000
|
||||||
|
const HOUR = 60 * MIN
|
||||||
|
|
||||||
|
// --- pattern lists -------------------------------------------------------
|
||||||
|
|
||||||
|
test("isTransportNoise: the three top AGE-105 noise producers are noise", () => {
|
||||||
|
assert.equal(isTransportNoise("connect timeout"), true)
|
||||||
|
assert.equal(isTransportNoise("connect server-unreachable"), true)
|
||||||
|
assert.equal(isTransportNoise("Network request failed"), true)
|
||||||
|
assert.equal(isTransportNoise("connect no-internet"), true)
|
||||||
|
assert.equal(isTransportNoise("Request timed out after 10000ms"), true)
|
||||||
|
assert.equal(isTransportNoise("fetch error: ECONNREFUSED"), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isTransportNoise: server-side and app-side failures are NOT noise", () => {
|
||||||
|
// health-failed means the box answered but is unhealthy — that is actionable.
|
||||||
|
assert.equal(isTransportNoise("connect health-failed"), false)
|
||||||
|
assert.equal(isTransportNoise("connect tls-error"), false)
|
||||||
|
assert.equal(isTransportNoise("API Error: 500 - boom"), false)
|
||||||
|
assert.equal(isTransportNoise("TypeError: undefined is not a function"), false)
|
||||||
|
assert.equal(isTransportNoise(""), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isAlwaysSend: genuine crash classes bypass the gate", () => {
|
||||||
|
assert.equal(isAlwaysSend("OutOfMemoryError (okio.Buffer.readByteArray)"), true)
|
||||||
|
assert.equal(isAlwaysSend("IllegalStateException: no activity"), true)
|
||||||
|
assert.equal(isAlwaysSend("NullPointerException"), true)
|
||||||
|
assert.equal(isAlwaysSend("SIGSEGV"), true)
|
||||||
|
assert.equal(isAlwaysSend("connect timeout"), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- event flattening ----------------------------------------------------
|
||||||
|
|
||||||
|
test("eventText: prefers the exception type+value, falls back to message", () => {
|
||||||
|
assert.equal(
|
||||||
|
eventText({ exception: { values: [{ type: "Error", value: "connect timeout" }] } }),
|
||||||
|
"connect timeout",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
eventText({ exception: { values: [{ type: "OutOfMemoryError", value: "Failed to allocate" }] } }),
|
||||||
|
"OutOfMemoryError: Failed to allocate",
|
||||||
|
)
|
||||||
|
assert.equal(eventText({ message: "plain message" }), "plain message")
|
||||||
|
assert.equal(eventText({}), "")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isFatalEvent: fatal level or unhandled mechanism is fatal", () => {
|
||||||
|
assert.equal(isFatalEvent({ level: "fatal" }), true)
|
||||||
|
assert.equal(
|
||||||
|
isFatalEvent({ exception: { values: [{ value: "boom", mechanism: { handled: false } }] } }),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
isFatalEvent({ level: "error", exception: { values: [{ value: "boom", mechanism: { handled: true } }] } }),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- fingerprinting ------------------------------------------------------
|
||||||
|
|
||||||
|
test("fingerprint: the 401 retry loop collapses to a single key", () => {
|
||||||
|
const a = fingerprint('API Error: 401 - {"error":"token expired at 1786700000"}')
|
||||||
|
const b = fingerprint('API Error: 401 - {"error":"token expired at 1786700931"}')
|
||||||
|
const c = fingerprint("API Error: 401 - Unauthorized")
|
||||||
|
assert.equal(a, b)
|
||||||
|
assert.equal(a, c)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fingerprint: different statuses stay distinct", () => {
|
||||||
|
assert.notEqual(fingerprint("API Error: 401 - x"), fingerprint("API Error: 403 - x"))
|
||||||
|
assert.notEqual(fingerprint("API Error: 401 - x"), fingerprint("API Error: 500 - x"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fingerprint: ids/urls/paths do not fragment one error into many", () => {
|
||||||
|
const a = fingerprint("Failed to load session 0f8c7a2b-1111-4d3e-9aaa-1234567890ab from https://box.example/v1/x")
|
||||||
|
const b = fingerprint("Failed to load session 7bb1c0de-2222-4d3e-9aaa-abcdef123456 from https://other.example/v1/y")
|
||||||
|
assert.equal(a, b)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fingerprint: genuinely different errors stay different", () => {
|
||||||
|
assert.notEqual(fingerprint("TypeError: x is not a function"), fingerprint("connect timeout"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fingerprint: bounded length", () => {
|
||||||
|
assert.ok(fingerprint("z".repeat(500)).length <= 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- gate behaviour ------------------------------------------------------
|
||||||
|
|
||||||
|
test("gate: transport noise is dropped every time, forever", () => {
|
||||||
|
const gate = new NoiseGate()
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const d = gate.admit("connect timeout", {}, T0 + i * MIN)
|
||||||
|
assert.equal(d.send, false)
|
||||||
|
assert.equal(d.reason, "transport-noise")
|
||||||
|
}
|
||||||
|
assert.equal(gate.takeDroppedCount(), 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: a 401 retry loop reports once per cooldown, not 498 times", () => {
|
||||||
|
const gate = new NoiseGate()
|
||||||
|
let sent = 0
|
||||||
|
// 498 events over 3 hours — the exact shape of the AGE-105 single-user loop.
|
||||||
|
for (let i = 0; i < 498; i++) {
|
||||||
|
const at = T0 + Math.floor((i * 3 * HOUR) / 498)
|
||||||
|
if (gate.admit(`API Error: 401 - attempt ${i}`, {}, at).send) sent++
|
||||||
|
}
|
||||||
|
assert.equal(sent, 1)
|
||||||
|
// ...and it re-opens once the 6h cooldown has elapsed.
|
||||||
|
assert.equal(gate.admit("API Error: 401 - later", {}, T0 + 6 * HOUR + MIN).send, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: cooldown boundary is exclusive-then-inclusive", () => {
|
||||||
|
const gate = new NoiseGate()
|
||||||
|
assert.equal(gate.admit("TypeError: boom", {}, T0).send, true)
|
||||||
|
assert.equal(gate.admit("TypeError: boom", {}, T0 + DEFAULT_GATE_LIMITS.cooldownMs - 1).send, false)
|
||||||
|
assert.equal(gate.admit("TypeError: boom", {}, T0 + DEFAULT_GATE_LIMITS.cooldownMs).send, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: at most maxNewPerHour distinct new issues open per hour", () => {
|
||||||
|
const gate = new NoiseGate()
|
||||||
|
let sent = 0
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (gate.admit(`Distinct failure ${String.fromCharCode(97 + i)}`, {}, T0 + i * MIN).send) sent++
|
||||||
|
}
|
||||||
|
assert.equal(sent, DEFAULT_GATE_LIMITS.maxNewPerHour)
|
||||||
|
// The window is rolling, so an hour later new issues can open again.
|
||||||
|
assert.equal(gate.admit("Distinct failure zz", {}, T0 + HOUR + MIN).send, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: total per hour never exceeds maxPerHour even across cooldowns", () => {
|
||||||
|
const gate = new NoiseGate({ cooldownMs: 0, maxNewPerHour: 1000 })
|
||||||
|
let sent = 0
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
if (gate.admit(`Failure ${i % 30}`, {}, T0 + i * 30_000).send) sent++
|
||||||
|
}
|
||||||
|
// 100 events spread over 50 minutes — all inside one rolling hour.
|
||||||
|
assert.equal(sent, DEFAULT_GATE_LIMITS.maxPerHour)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: real crashes are never dropped, whatever the quota state", () => {
|
||||||
|
const gate = new NoiseGate()
|
||||||
|
// Exhaust every budget with ordinary errors first.
|
||||||
|
for (let i = 0; i < 50; i++) gate.admit(`Ordinary failure ${i}`, {}, T0 + i * MIN)
|
||||||
|
|
||||||
|
const oom = gate.admit("OutOfMemoryError (okio.Buffer.readByteArray)", {}, T0 + 51 * MIN)
|
||||||
|
assert.equal(oom.send, true)
|
||||||
|
assert.equal(oom.reason, "always-send")
|
||||||
|
|
||||||
|
// Repeats of a crash loop also survive — a relaunch-crash loop is a real signal.
|
||||||
|
const again = gate.admit("OutOfMemoryError (okio.Buffer.readByteArray)", {}, T0 + 52 * MIN)
|
||||||
|
assert.equal(again.send, true)
|
||||||
|
|
||||||
|
const fatal = gate.admit("Some unhandled native failure", { fatal: true }, T0 + 53 * MIN)
|
||||||
|
assert.equal(fatal.send, true)
|
||||||
|
assert.equal(fatal.reason, "always-send")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: fingerprint state stays bounded (oldest evicted)", () => {
|
||||||
|
// Distinct, digit-free labels: fingerprint() maps every digit to '#', so
|
||||||
|
// numbered labels would all collapse into one key.
|
||||||
|
const label = (i: number) =>
|
||||||
|
`failure ${i
|
||||||
|
.toString(26)
|
||||||
|
.split("")
|
||||||
|
.map((c) => String.fromCharCode(97 + parseInt(c, 26)))
|
||||||
|
.join("")}`
|
||||||
|
const gate = new NoiseGate({ maxNewPerHour: 1e6, maxPerHour: 1e6, maxTrackedFingerprints: 10 })
|
||||||
|
for (let i = 0; i < 50; i++) gate.admit(label(i), {}, T0 + i)
|
||||||
|
|
||||||
|
// The oldest fingerprints were evicted, so they are allowed to report again
|
||||||
|
// (bounded memory beats perfect dedup on a mobile client).
|
||||||
|
assert.equal(gate.admit(label(0), {}, T0 + MIN).send, true)
|
||||||
|
// A recently-seen one is still deduped.
|
||||||
|
assert.equal(gate.admit(label(49), {}, T0 + MIN).send, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gate: the AGE-105 mixed hour lands far under the old volume", () => {
|
||||||
|
const gate = new NoiseGate()
|
||||||
|
let sent = 0
|
||||||
|
const at = (i: number) => T0 + i * 1000
|
||||||
|
let i = 0
|
||||||
|
// The observed mix, compressed into one hour.
|
||||||
|
for (let n = 0; n < 462; n++) if (gate.admit("connect timeout", {}, at(i++)).send) sent++
|
||||||
|
for (let n = 0; n < 498; n++) if (gate.admit(`API Error: 401 - ${n}`, {}, at(i++)).send) sent++
|
||||||
|
for (let n = 0; n < 157; n++) if (gate.admit("connect server-unreachable", {}, at(i++)).send) sent++
|
||||||
|
for (let n = 0; n < 5; n++) if (gate.admit("Network request failed", {}, at(i++)).send) sent++
|
||||||
|
for (let n = 0; n < 4; n++) if (gate.admit("OutOfMemoryError (okio.Buffer)", {}, at(i++)).send) sent++
|
||||||
|
|
||||||
|
// 1126 raw events -> 1 auth report + 4 real crashes.
|
||||||
|
assert.equal(sent, 5)
|
||||||
|
})
|
||||||
242
src/lib/sentry-noise.ts
Normal file
242
src/lib/sentry-noise.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// Client-side Sentry noise gate (AGE-105).
|
||||||
|
//
|
||||||
|
// Why this exists: `opencode-mobile` became the #1 source of Sentry error
|
||||||
|
// volume in the org (~4,500 events/month against a 3,500/month org gate).
|
||||||
|
// Breaking that number down, the top three issues (~1,100 events) were NOT app
|
||||||
|
// defects:
|
||||||
|
//
|
||||||
|
// 462 events / 104 users Error: connect timeout
|
||||||
|
// 498 events / 1 user Error: API Error: 401 - …
|
||||||
|
// 157 events / 33 users Error: connect server-unreachable
|
||||||
|
//
|
||||||
|
// The `connect …` ones are `captureDiagnostic()` reports of client-side network
|
||||||
|
// conditions (user's LAN/VPN/self-hosted box is down). They are already shown to
|
||||||
|
// the user as UI state AND already counted, without PII, as the PostHog
|
||||||
|
// `connection_failed` event with an `error_class` property
|
||||||
|
// (see analytics-classify.ts + stores/connections.ts), so dropping them from
|
||||||
|
// Sentry loses no trend visibility — it just stops paying per-event for a graph
|
||||||
|
// we already have. The 401 storm is one device's token-refresh retry loop:
|
||||||
|
// 498 copies of one problem, not 498 problems.
|
||||||
|
//
|
||||||
|
// Three layers, cheapest first (`admit()` applies them in order):
|
||||||
|
// 1. ALWAYS-SEND allowlist — genuine crash classes (OOM/ANR/native/fatal)
|
||||||
|
// bypass every limit below. Quota is worthless if it silences real crashes.
|
||||||
|
// 2. TRANSPORT drop-list — hard drop for client-side network conditions.
|
||||||
|
// Hard, not sampled: this gate runs per-install, so even "1 per device per
|
||||||
|
// day" multiplies by the install base back into thousands per month.
|
||||||
|
// 3. Dedup + rate cap — per-fingerprint cooldown plus new-fingerprint/hour and
|
||||||
|
// total/hour ceilings, mirroring the `openclaw-box-bot` shim (AGE-55) that
|
||||||
|
// took that project from 50.7 events/h to 0. This is what turns a retry
|
||||||
|
// loop into one report, and caps the blast radius of any future regression.
|
||||||
|
//
|
||||||
|
// This module is pure and dependency-free (no @sentry/react-native, no RN) so it
|
||||||
|
// runs under plain `node --test`, same convention as analytics-classify.ts /
|
||||||
|
// diagnostics-classify.ts / api-error.ts.
|
||||||
|
|
||||||
|
/** Minimal structural shape of a Sentry event — avoids importing the SDK here
|
||||||
|
* so this module stays testable under plain node. */
|
||||||
|
export type NoiseEventLike = {
|
||||||
|
level?: string
|
||||||
|
message?: string
|
||||||
|
exception?: { values?: Array<{ type?: string; value?: string; mechanism?: { handled?: boolean } }> }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side network conditions. Unactionable server-side, already surfaced to
|
||||||
|
* the user as connection UI, and already trended in PostHog as
|
||||||
|
* `connection_failed{error_class}`. Dropped outright. */
|
||||||
|
export const TRANSPORT_NOISE_PATTERNS: RegExp[] = [
|
||||||
|
// captureDiagnostic() → new Error(`connect ${classification}`)
|
||||||
|
/^connect (?:timeout|server-unreachable|no-internet|malformed-url)$/i,
|
||||||
|
// RN fetch failures surfacing through the global handler / rejection hook.
|
||||||
|
/network request failed/i,
|
||||||
|
/request timed out after/i,
|
||||||
|
/\b(?:ECONNREFUSED|ECONNRESET|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH)\b/,
|
||||||
|
/aborted due to timeout/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Real crash classes that must never be filtered, whatever the quota says.
|
||||||
|
* Kept deliberately narrow — everything not listed here is still *reported*,
|
||||||
|
* just deduped/rate-capped rather than dropped. */
|
||||||
|
export const ALWAYS_SEND_PATTERNS: RegExp[] = [
|
||||||
|
/OutOfMemoryError/i,
|
||||||
|
/\bANR\b/,
|
||||||
|
/Application Not Responding/i,
|
||||||
|
/IllegalStateException/,
|
||||||
|
/NullPointerException/,
|
||||||
|
/SIGSEGV|SIGABRT|SIGBUS|EXC_BAD_ACCESS/,
|
||||||
|
/native crash/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
export type NoiseReason =
|
||||||
|
| "always-send"
|
||||||
|
| "ok"
|
||||||
|
| "transport-noise"
|
||||||
|
| "cooldown"
|
||||||
|
| "new-fingerprint-cap"
|
||||||
|
| "hourly-cap"
|
||||||
|
|
||||||
|
export type NoiseDecision = { send: boolean; reason: NoiseReason; fingerprint: string }
|
||||||
|
|
||||||
|
export type NoiseGateLimits = {
|
||||||
|
/** Per-fingerprint silence window after one report is sent. */
|
||||||
|
cooldownMs: number
|
||||||
|
/** Max *distinct* fingerprints allowed to open a new report per rolling hour. */
|
||||||
|
maxNewPerHour: number
|
||||||
|
/** Absolute ceiling of events sent per rolling hour, allowlist excluded. */
|
||||||
|
maxPerHour: number
|
||||||
|
/** Bound on retained fingerprint state (oldest evicted first). */
|
||||||
|
maxTrackedFingerprints: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** Mirrors the box-bot shim: cooldown=6h, max_new/h=6, max/h=10. */
|
||||||
|
export const DEFAULT_GATE_LIMITS: NoiseGateLimits = {
|
||||||
|
cooldownMs: 6 * HOUR_MS,
|
||||||
|
maxNewPerHour: 6,
|
||||||
|
maxPerHour: 10,
|
||||||
|
maxTrackedFingerprints: 200,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten an event to the text the pattern lists match against. */
|
||||||
|
export function eventText(event: NoiseEventLike): string {
|
||||||
|
const values = event.exception?.values ?? []
|
||||||
|
const parts: string[] = []
|
||||||
|
for (const ex of values) {
|
||||||
|
const type = ex.type && ex.type !== "Error" ? `${ex.type}: ` : ""
|
||||||
|
if (ex.value || ex.type) parts.push(`${type}${ex.value ?? ""}`.trim())
|
||||||
|
}
|
||||||
|
if (!parts.length && event.message) parts.push(event.message)
|
||||||
|
return parts.join(" | ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for events that must bypass every limit: allowlisted crash classes,
|
||||||
|
* fatal level, or an unhandled native mechanism. */
|
||||||
|
export function isFatalEvent(event: NoiseEventLike): boolean {
|
||||||
|
if (event.level === "fatal") return true
|
||||||
|
return (event.exception?.values ?? []).some((ex) => ex.mechanism?.handled === false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTransportNoise(text: string): boolean {
|
||||||
|
if (!text) return false
|
||||||
|
return TRANSPORT_NOISE_PATTERNS.some((p) => p.test(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAlwaysSend(text: string): boolean {
|
||||||
|
if (!text) return false
|
||||||
|
return ALWAYS_SEND_PATTERNS.some((p) => p.test(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse an error message to a stable dedup key.
|
||||||
|
*
|
||||||
|
* The point is that `API Error: 401 - {"error":"token expired at 17867…"}`
|
||||||
|
* fired 498 times by one token-refresh loop must map to ONE key. So: keep the
|
||||||
|
* first line, cut the variable tail off `API Error: <status> - <body>`, blank
|
||||||
|
* out URLs/paths/hex/uuids/numbers, then truncate. */
|
||||||
|
export function fingerprint(text: string): string {
|
||||||
|
let s = (text || "").split("\n")[0].trim()
|
||||||
|
// HTTP status is the one number worth keeping: 401 and 500 are different bugs.
|
||||||
|
const apiErr = s.match(/API Error:\s*(\d{3})\b/i)
|
||||||
|
if (apiErr) return `api error: ${apiErr[1]}`
|
||||||
|
s = s
|
||||||
|
.replace(/<redacted-url>|<redacted>/gi, "")
|
||||||
|
.replace(/https?:\/\/\S+/gi, "")
|
||||||
|
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "")
|
||||||
|
.replace(/\b[0-9a-f]{12,}\b/gi, "")
|
||||||
|
.replace(/\/[^\s"']*/g, "")
|
||||||
|
.replace(/\d+/g, "#")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
return s.slice(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedup + rate limiter. Deterministic: every method takes an explicit `now`
|
||||||
|
* (defaulting to `Date.now()`), so tests drive time instead of sleeping.
|
||||||
|
* State is in-memory and per app process — a restart legitimately re-opens the
|
||||||
|
* cooldown, since a crash-restart loop is itself worth one report per launch.
|
||||||
|
*/
|
||||||
|
export class NoiseGate {
|
||||||
|
private readonly limits: NoiseGateLimits
|
||||||
|
/** fingerprint -> last time it was SENT */
|
||||||
|
private readonly lastSent = new Map<string, number>()
|
||||||
|
/** timestamps of events sent in the current rolling hour */
|
||||||
|
private sentTimes: number[] = []
|
||||||
|
/** timestamps at which a *previously unseen* fingerprint was opened */
|
||||||
|
private newFingerprintTimes: number[] = []
|
||||||
|
private dropped = 0
|
||||||
|
|
||||||
|
constructor(limits: Partial<NoiseGateLimits> = {}) {
|
||||||
|
this.limits = { ...DEFAULT_GATE_LIMITS, ...limits }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of events dropped since the last `takeDroppedCount()`. */
|
||||||
|
takeDroppedCount(): number {
|
||||||
|
const n = this.dropped
|
||||||
|
this.dropped = 0
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
admit(text: string, opts: { fatal?: boolean } = {}, now: number = Date.now()): NoiseDecision {
|
||||||
|
const fp = fingerprint(text)
|
||||||
|
|
||||||
|
if (opts.fatal || isAlwaysSend(text)) {
|
||||||
|
// Still recorded so the hourly window reflects reality, but never blocked.
|
||||||
|
this.touch(fp, now)
|
||||||
|
this.sentTimes.push(now)
|
||||||
|
this.prune(now)
|
||||||
|
return { send: true, reason: "always-send", fingerprint: fp }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransportNoise(text)) return this.drop("transport-noise", fp)
|
||||||
|
|
||||||
|
this.prune(now)
|
||||||
|
|
||||||
|
const last = this.lastSent.get(fp)
|
||||||
|
if (last !== undefined && now - last < this.limits.cooldownMs) {
|
||||||
|
return this.drop("cooldown", fp)
|
||||||
|
}
|
||||||
|
if (this.sentTimes.length >= this.limits.maxPerHour) {
|
||||||
|
return this.drop("hourly-cap", fp)
|
||||||
|
}
|
||||||
|
if (last === undefined && this.newFingerprintTimes.length >= this.limits.maxNewPerHour) {
|
||||||
|
return this.drop("new-fingerprint-cap", fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last === undefined) this.newFingerprintTimes.push(now)
|
||||||
|
this.touch(fp, now)
|
||||||
|
this.sentTimes.push(now)
|
||||||
|
this.evictIfNeeded()
|
||||||
|
return { send: true, reason: "ok", fingerprint: fp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-insert so Map iteration order stays least-recently-sent first. */
|
||||||
|
private touch(fp: string, now: number) {
|
||||||
|
this.lastSent.delete(fp)
|
||||||
|
this.lastSent.set(fp, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
private drop(reason: NoiseReason, fp: string): NoiseDecision {
|
||||||
|
this.dropped++
|
||||||
|
return { send: false, reason, fingerprint: fp }
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(now: number) {
|
||||||
|
const hourAgo = now - HOUR_MS
|
||||||
|
this.sentTimes = this.sentTimes.filter((t) => t > hourAgo)
|
||||||
|
this.newFingerprintTimes = this.newFingerprintTimes.filter((t) => t > hourAgo)
|
||||||
|
const cutoff = now - this.limits.cooldownMs
|
||||||
|
for (const [fp, t] of this.lastSent) if (t <= cutoff) this.lastSent.delete(fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictIfNeeded() {
|
||||||
|
while (this.lastSent.size > this.limits.maxTrackedFingerprints) {
|
||||||
|
// Map preserves insertion order and we re-set on each send, so the first
|
||||||
|
// key is the least recently sent.
|
||||||
|
const oldest = this.lastSent.keys().next()
|
||||||
|
if (oldest.done) break
|
||||||
|
this.lastSent.delete(oldest.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,15 @@
|
|||||||
// server addresses or tokens never leak to Sentry.
|
// server addresses or tokens never leak to Sentry.
|
||||||
// 4. Provide small `addBreadcrumb` / `captureException` helpers so call sites
|
// 4. Provide small `addBreadcrumb` / `captureException` helpers so call sites
|
||||||
// get rich context without importing the Sentry SDK directly.
|
// get rich context without importing the Sentry SDK directly.
|
||||||
|
// 5. Keep event volume inside the org Sentry quota (AGE-105): a noise gate in
|
||||||
|
// `beforeSend` drops client-side network conditions and collapses retry
|
||||||
|
// loops, while genuine crashes pass through untouched. The decision logic
|
||||||
|
// lives in ./sentry-noise.ts so it is unit-testable without the RN SDK.
|
||||||
|
|
||||||
import * as Sentry from "@sentry/react-native"
|
import * as Sentry from "@sentry/react-native"
|
||||||
import appJson from "../../app.json"
|
import appJson from "../../app.json"
|
||||||
import { log } from "./logbuffer"
|
import { log } from "./logbuffer"
|
||||||
|
import { NoiseGate, eventText, isFatalEvent, isTransportNoise } from "./sentry-noise"
|
||||||
import type { DiagnosticReport } from "./diagnostics"
|
import type { DiagnosticReport } from "./diagnostics"
|
||||||
|
|
||||||
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
||||||
@@ -20,6 +25,10 @@ const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?
|
|||||||
let enabled = false
|
let enabled = false
|
||||||
let handlersInstalled = false
|
let handlersInstalled = false
|
||||||
|
|
||||||
|
// One gate per app process. Drops are counted and attached to the next event
|
||||||
|
// that does get through, so the quota saving stays visible in Sentry itself.
|
||||||
|
const noiseGate = new NoiseGate()
|
||||||
|
|
||||||
export function initSentry() {
|
export function initSentry() {
|
||||||
if (enabled) return
|
if (enabled) return
|
||||||
if (!DSN) {
|
if (!DSN) {
|
||||||
@@ -45,9 +54,11 @@ export function initSentry() {
|
|||||||
enableAutoPerformanceTracing: false,
|
enableAutoPerformanceTracing: false,
|
||||||
attachStacktrace: true,
|
attachStacktrace: true,
|
||||||
maxBreadcrumbs: 100,
|
maxBreadcrumbs: 100,
|
||||||
// Final pre-send scrub: strip URLs everywhere they could appear.
|
// Final pre-send stage: drop non-actionable noise (AGE-105), then strip
|
||||||
|
// URLs everywhere they could appear.
|
||||||
beforeSend(event) {
|
beforeSend(event) {
|
||||||
return scrubEvent(event)
|
const filtered = applyNoiseGate(event)
|
||||||
|
return filtered ? scrubEvent(filtered) : null
|
||||||
},
|
},
|
||||||
beforeBreadcrumb(crumb) {
|
beforeBreadcrumb(crumb) {
|
||||||
// Console output can contain malformed server payloads, prompts, or code.
|
// Console output can contain malformed server payloads, prompts, or code.
|
||||||
@@ -138,6 +149,24 @@ function toError(value: unknown): Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Noise gate (pure logic lives in ./sentry-noise for testability) ------
|
||||||
|
|
||||||
|
/** Returns the event to send, or null to drop it. Exported for the RN-side
|
||||||
|
* integration test; the pure rules are tested in sentry-noise.test.ts. */
|
||||||
|
export function applyNoiseGate<T extends Sentry.Event>(event: T): T | null {
|
||||||
|
const text = eventText(event)
|
||||||
|
const decision = noiseGate.admit(text, { fatal: isFatalEvent(event) })
|
||||||
|
if (!decision.send) {
|
||||||
|
log.info("sentry", "dropped event", decision.reason)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const dropped = noiseGate.takeDroppedCount()
|
||||||
|
if (dropped > 0) {
|
||||||
|
event.tags = { ...event.tags, "noise.dropped_since_last": String(dropped) }
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
// --- Scrubbing (pure functions live in ./scrub for testability) ----------
|
// --- Scrubbing (pure functions live in ./scrub for testability) ----------
|
||||||
|
|
||||||
export { scrubUrl } from "./scrub"
|
export { scrubUrl } from "./scrub"
|
||||||
@@ -238,6 +267,15 @@ export function captureException(
|
|||||||
export function captureDiagnostic(report: DiagnosticReport) {
|
export function captureDiagnostic(report: DiagnosticReport) {
|
||||||
log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)")
|
log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)")
|
||||||
if (!enabled) return
|
if (!enabled) return
|
||||||
|
// Client-side network conditions (timeout / unreachable / no internet) are
|
||||||
|
// already shown to the user and already trended in PostHog as
|
||||||
|
// `connection_failed{error_class}`; they were the single largest consumer of
|
||||||
|
// the org Sentry quota (AGE-105). Skip them here so we don't even build the
|
||||||
|
// event. `health-failed` / `tls-error` are genuinely actionable and still go.
|
||||||
|
if (isTransportNoise(`connect ${report.classification}`)) {
|
||||||
|
log.info("sentry", "skipped diagnostic upload (client-side network condition)", report.classification)
|
||||||
|
return
|
||||||
|
}
|
||||||
Sentry.withScope((scope) => {
|
Sentry.withScope((scope) => {
|
||||||
scope.setTag("connect.classification", report.classification)
|
scope.setTag("connect.classification", report.classification)
|
||||||
scope.setTag("connect.scheme", report.scheme ?? "n/a")
|
scope.setTag("connect.scheme", report.scheme ?? "n/a")
|
||||||
|
|||||||
Reference in New Issue
Block a user