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:
Den
2026-08-14 06:35:27 -07:00
committed by GitHub
parent 4919164bca
commit 7c8bc7d317
4 changed files with 515 additions and 2 deletions

View File

@@ -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`
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)
| Surface | File |

View 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
View 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)
}
}
}

View File

@@ -8,10 +8,15 @@
// server addresses or tokens never leak to Sentry.
// 4. Provide small `addBreadcrumb` / `captureException` helpers so call sites
// 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 appJson from "../../app.json"
import { log } from "./logbuffer"
import { NoiseGate, eventText, isFatalEvent, isTransportNoise } from "./sentry-noise"
import type { DiagnosticReport } from "./diagnostics"
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 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() {
if (enabled) return
if (!DSN) {
@@ -45,9 +54,11 @@ export function initSentry() {
enableAutoPerformanceTracing: false,
attachStacktrace: true,
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) {
return scrubEvent(event)
const filtered = applyNoiseGate(event)
return filtered ? scrubEvent(filtered) : null
},
beforeBreadcrumb(crumb) {
// 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) ----------
export { scrubUrl } from "./scrub"
@@ -238,6 +267,15 @@ export function captureException(
export function captureDiagnostic(report: DiagnosticReport) {
log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)")
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) => {
scope.setTag("connect.classification", report.classification)
scope.setTag("connect.scheme", report.scheme ?? "n/a")