diff --git a/docs/analytics.md b/docs/analytics.md index 02e04fb6..0dbc86d3 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -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 | diff --git a/src/lib/sentry-noise.test.ts b/src/lib/sentry-noise.test.ts new file mode 100644 index 00000000..e88b96f1 --- /dev/null +++ b/src/lib/sentry-noise.test.ts @@ -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) +}) diff --git a/src/lib/sentry-noise.ts b/src/lib/sentry-noise.ts new file mode 100644 index 00000000..85eb67ea --- /dev/null +++ b/src/lib/sentry-noise.ts @@ -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: - `, 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(/|/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() + /** 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 = {}) { + 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) + } + } +} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 6100162f..e27c39cc 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -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(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")