Skip to content

Commit 46c46b4

Browse files
committed
fix(analytics): judge collector health against its own baseline (#6026)
The aggregate alert compared a raw quotient to a fixed 0.5 on a denominator that could be as small as 5, so ordinary ad-blocker attrition and a dead collector produced the same verdict. On 2026-08-01 it pegged at the once-per-window ceiling through the day's busiest, outage-free hour. Three changes, none of which invent a calibration: - rate judgements now read the low end of a Wilson score interval, so a window's sample size is part of its claim rather than invisible; - MIN_WRITES is derived from the interval half-width at p = 0.5 instead of picked, which puts it at 31; - a window is compared against the previous day's observed rate for the same cohort, so the alert fires on a departure from normal rather than on normal, and three consecutive breached windows are required before it emits. The absolute MIN_FAILURE_RATE floor is left at 0.5 on purpose: raising it needs the measured ad-block baseline, which is an operator input. Also wires api/analytics-health.test.mjs into test:sidecar. The file existed but no script ran it.
1 parent 9d7b8fc commit 46c46b4

3 files changed

Lines changed: 465 additions & 66 deletions

File tree

api/analytics-health.js

Lines changed: 191 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
* A browser cannot tell whether a receiptless collector response came from a
55
* privacy layer or from a collector outage. It reports only bounded counter
66
* deltas here; Redis supplies the cross-user denominator and Sentry receives a
7-
* single warning when one request cohort crosses the existing sample/rate
8-
* floors. No event payload, user id, URL, or browser fingerprint is accepted.
7+
* single warning when one request cohort's failure rate separates from that
8+
* cohort's own observed baseline. No event payload, user id, URL, or browser
9+
* fingerprint is accepted.
910
*/
1011

1112
export const config = { runtime: 'edge' };
@@ -18,18 +19,90 @@ import { redisPipeline } from './_upstash-json.js';
1819

1920
const HEALTH_WINDOW_SECONDS = 60;
2021
const HEALTH_KEY_TTL_SECONDS = 120;
21-
const MIN_WRITES = 5;
22+
23+
/**
24+
* One-sided 95% z score. Every rate judgement below is made on a Wilson score
25+
* interval rather than on the raw quotient, because `failures / writes` carries
26+
* no information about how many samples produced it: 5/5 and 5000/5000 read
27+
* identically and are not remotely the same claim (#6026).
28+
*/
29+
const ALERT_CONFIDENCE_Z = 1.6448536269514722;
30+
31+
/**
32+
* Minimum writes in one window before its failure rate is allowed to decide
33+
* anything. Derived rather than picked: the half-width of the 95% Wilson
34+
* interval at the worst case p = 0.5 is `z * sqrt(0.25 / n)`, so n = 31 is the
35+
* smallest denominator that resolves the rate to within +/-0.15 (0.1477) — just
36+
* enough to separate an ordinary ad-blocker baseline from a collector that has
37+
* stopped accepting writes. The pre-#6026 floor of 5 resolved it to +/-0.368,
38+
* which is to say not at all.
39+
*/
40+
const MIN_WRITES = 31;
41+
42+
/**
43+
* Absolute backstop rate. The primary gate is the comparison against this
44+
* cohort's own observed baseline; this floor only stops an alert on a
45+
* deployment whose baseline is so low that a statistically real excursion is
46+
* still operationally uninteresting.
47+
*
48+
* Deliberately left at the pre-#6026 value. Raising it is the one part of this
49+
* that needs the measured ad-block baseline, which is an operator input rather
50+
* than something derivable from code.
51+
*/
2252
const MIN_FAILURE_RATE = 0.5;
53+
54+
/**
55+
* Consecutive breached windows required before Sentry hears about it. The
56+
* incident evidence in #6026 shows a real outage stays breached for tens of
57+
* consecutive windows, so this costs at most two extra minutes of detection
58+
* latency — the 2026-08-01 outage was surfaced in 9 — while removing any single
59+
* noisy window from the alert path.
60+
*/
61+
const MIN_CONSECUTIVE_BREACHED_WINDOWS = 3;
62+
const STREAK_KEY_TTL_SECONDS = HEALTH_WINDOW_SECONDS * 3;
63+
64+
/**
65+
* Rolling per-day baseline for a cohort's ordinary failure rate. Held for two
66+
* days so the previous — complete — day stays readable while the current one
67+
* accumulates. A day-scoped key means re-arming its TTL on every write does not
68+
* extend the window it measures.
69+
*/
70+
const BASELINE_KEY_TTL_SECONDS = 172_800;
71+
const WINDOWS_PER_DAY = 86_400 / HEALTH_WINDOW_SECONDS;
72+
73+
/**
74+
* A baseline may only license or veto an alert once it is resolved an order of
75+
* magnitude better than the single window it is judging.
76+
*/
77+
const MIN_BASELINE_WRITES = MIN_WRITES * 20;
78+
2379
const MAX_BODY_BYTES = 1_024;
2480
const MAX_COUNTER_DELTA = 10_000;
2581
const RATE_LIMIT_SCOPE = 'analytics-health';
2682
const RATE_LIMIT_PER_MINUTE = 60;
2783
const ALLOWED_COHORTS = new Set(['event', 'critical-event', 'identify']);
2884
const ALLOWED_FAILURE_KINDS = new Set(['network', 'timeout', 'missing-receipt']);
2985

30-
function redisKey(bucket, cohort, suffix) {
86+
function keyPrefix() {
3187
const environment = process.env.VERCEL_ENV || 'production';
32-
return `analytics:collector-health:v1:${environment}:${bucket}:${cohort}:${suffix}`;
88+
return `analytics:collector-health:v1:${environment}`;
89+
}
90+
91+
function redisKey(bucket, cohort, suffix) {
92+
return `${keyPrefix()}:${bucket}:${cohort}:${suffix}`;
93+
}
94+
95+
/** Spans windows, so it is deliberately not bucket-scoped. */
96+
function streakKeyFor(cohort) {
97+
return `${keyPrefix()}:${cohort}:streak`;
98+
}
99+
100+
function baselineKey(dayIndex, cohort, suffix) {
101+
return `${keyPrefix()}:day:${dayIndex}:${cohort}:${suffix}`;
102+
}
103+
104+
export function dayIndexForBucket(bucket) {
105+
return Math.floor(bucket / WINDOWS_PER_DAY);
33106
}
34107

35108
function finiteCounter(value) {
@@ -44,8 +117,44 @@ export function parseCollectorHealthReport(payload) {
44117
return { cohort, writes, failures, failureKind };
45118
}
46119

47-
export function shouldEmitAggregateAlert(writes, failures) {
48-
return writes >= MIN_WRITES && failures / writes >= MIN_FAILURE_RATE;
120+
/**
121+
* Wilson score interval for a binomial proportion. Preferred over the normal
122+
* approximation because it stays inside [0, 1] and stays honest at the small
123+
* denominators this endpoint actually sees.
124+
*/
125+
export function wilsonBounds(successes, total, z = ALERT_CONFIDENCE_Z) {
126+
if (!(total > 0)) return { lower: 0, upper: 1 };
127+
const p = successes / total;
128+
const z2 = z * z;
129+
const denominator = 1 + z2 / total;
130+
const centre = p + z2 / (2 * total);
131+
const margin = z * Math.sqrt((p * (1 - p)) / total + z2 / (4 * total * total));
132+
return {
133+
lower: Math.max(0, (centre - margin) / denominator),
134+
upper: Math.min(1, (centre + margin) / denominator),
135+
};
136+
}
137+
138+
/**
139+
* Decide whether one window's failure rate is worth an operator's attention.
140+
*
141+
* Three independent conditions, in cost order:
142+
* 1. the window carries enough samples to resolve a rate at all;
143+
* 2. the low end of its confidence interval still clears the absolute floor —
144+
* a point estimate would let 3-of-5 read as "50% failing";
145+
* 3. that low end sits above the high end of this cohort's own baseline, so
146+
* the alert fires on a *departure* from normal rather than on normal.
147+
*
148+
* (3) is skipped until a baseline day exists and is well resolved; until then
149+
* (1) and (2) carry the decision, which is the pre-#6026 behaviour with an
150+
* honest denominator.
151+
*/
152+
export function shouldEmitAggregateAlert(writes, failures, baseline = null) {
153+
if (!(writes >= MIN_WRITES)) return false;
154+
const observed = wilsonBounds(failures, writes).lower;
155+
if (observed < MIN_FAILURE_RATE) return false;
156+
if (!baseline) return true;
157+
return observed > wilsonBounds(baseline.failures, baseline.writes).upper;
49158
}
50159

51160
function counterResult(entry) {
@@ -54,6 +163,43 @@ function counterResult(entry) {
54163
return Number.isSafeInteger(value) && value >= 0 ? value : null;
55164
}
56165

166+
function stringResult(entry) {
167+
if (!entry || Object.prototype.hasOwnProperty.call(entry, 'error')) return null;
168+
return typeof entry.result === 'string' ? entry.result : null;
169+
}
170+
171+
export function readBaseline(writesEntry, failuresEntry) {
172+
const writes = counterResult(writesEntry);
173+
const failures = counterResult(failuresEntry);
174+
if (writes === null || failures === null) return null;
175+
if (writes < MIN_BASELINE_WRITES || failures > writes) return null;
176+
return { writes, failures };
177+
}
178+
179+
/**
180+
* Advance the consecutive-breach counter for this cohort.
181+
*
182+
* The stored value is `count:bucket`, so a gap of two or more windows resets
183+
* the run instead of letting alternating breached/healthy windows accumulate
184+
* into a false streak. Re-entering the same bucket is idempotent: every isolate
185+
* in a window reads the same prior value and computes the same successor.
186+
*/
187+
export function advanceBreachStreak(raw, bucket) {
188+
const [rawCount, rawBucket] = typeof raw === 'string' ? raw.split(':') : [];
189+
const previousCount = Number(rawCount);
190+
const previousBucket = Number(rawBucket);
191+
if (
192+
!Number.isSafeInteger(previousCount)
193+
|| previousCount < 1
194+
|| !Number.isSafeInteger(previousBucket)
195+
) {
196+
return 1;
197+
}
198+
if (previousBucket === bucket) return previousCount;
199+
if (previousBucket === bucket - 1) return previousCount + 1;
200+
return 1;
201+
}
202+
57203
export async function recordCollectorHealthAggregate(
58204
report,
59205
bucket,
@@ -63,31 +209,57 @@ export async function recordCollectorHealthAggregate(
63209
const { redisPipeline: pipeline, captureSilentError: capture } = dependencies;
64210
const writesKey = redisKey(bucket, report.cohort, 'writes');
65211
const failuresKey = redisKey(bucket, report.cohort, 'failures');
212+
const today = dayIndexForBucket(bucket);
213+
const todayWritesKey = baselineKey(today, report.cohort, 'writes');
214+
const todayFailuresKey = baselineKey(today, report.cohort, 'failures');
215+
const streakKey = streakKeyFor(report.cohort);
216+
217+
// One round trip: the window counters, this day's baseline accumulator, the
218+
// previous day's baseline, and the breach streak.
66219
const results = await pipeline([
67220
['INCRBY', writesKey, String(report.writes)],
68221
['INCRBY', failuresKey, String(report.failures)],
69222
['EXPIRE', writesKey, String(HEALTH_KEY_TTL_SECONDS)],
70223
['EXPIRE', failuresKey, String(HEALTH_KEY_TTL_SECONDS)],
71224
['GET', writesKey],
72225
['GET', failuresKey],
226+
['INCRBY', todayWritesKey, String(report.writes)],
227+
['INCRBY', todayFailuresKey, String(report.failures)],
228+
['EXPIRE', todayWritesKey, String(BASELINE_KEY_TTL_SECONDS)],
229+
['EXPIRE', todayFailuresKey, String(BASELINE_KEY_TTL_SECONDS)],
230+
['GET', baselineKey(today - 1, report.cohort, 'writes')],
231+
['GET', baselineKey(today - 1, report.cohort, 'failures')],
232+
['GET', streakKey],
73233
], 2_500);
74-
if (!Array.isArray(results) || results.length < 6) return false;
234+
if (!Array.isArray(results) || results.length < 13) return false;
75235

76236
const writes = counterResult(results[4]);
77237
const failures = counterResult(results[5]);
78-
if (writes === null || failures === null || !shouldEmitAggregateAlert(writes, failures)) return true;
238+
if (writes === null || failures === null) return true;
239+
240+
const baseline = readBaseline(results[10], results[11]);
241+
if (!shouldEmitAggregateAlert(writes, failures, baseline)) return true;
242+
243+
const consecutiveWindows = advanceBreachStreak(stringResult(results[12]), bucket);
79244

80245
// SET NX is the cross-isolate once-per-window latch. Two requests may both
81-
// observe the threshold, but only one wins this claim and emits Sentry.
246+
// observe the breach, but only one wins this claim and emits Sentry. The
247+
// streak write rides along in the same pipeline because it is idempotent
248+
// within a window, so it does not need to be serialized behind the latch.
82249
const claim = await pipeline([
250+
['SET', streakKey, `${consecutiveWindows}:${bucket}`, 'EX', String(STREAK_KEY_TTL_SECONDS)],
83251
['SET', redisKey(bucket, report.cohort, 'reported'), '1', 'NX', 'EX', String(HEALTH_KEY_TTL_SECONDS)],
84252
], 2_000);
85-
const claimEntry = claim?.[0];
253+
const claimEntry = claim?.[1];
86254
if (!claimEntry || Object.prototype.hasOwnProperty.call(claimEntry, 'error')) return false;
87255
if (!Object.prototype.hasOwnProperty.call(claimEntry, 'result')) return false;
88256
if (claimEntry.result !== 'OK') return true;
257+
if (consecutiveWindows < MIN_CONSECUTIVE_BREACHED_WINDOWS) return true;
258+
259+
const observed = wilsonBounds(failures, writes);
260+
const baselineBounds = baseline ? wilsonBounds(baseline.failures, baseline.writes) : null;
89261

90-
capture(new Error('Umami collector environment failures crossed aggregate floor'), {
262+
capture(new Error('Umami collector failure rate separated from its observed baseline'), {
91263
level: 'warning',
92264
tags: {
93265
component: 'analytics-collector',
@@ -99,7 +271,14 @@ export async function recordCollectorHealthAggregate(
99271
failureCount: failures,
100272
writeCount: writes,
101273
failureRate: failures / writes,
274+
failureRateLowerBound: observed.lower,
275+
baselineFailureRate: baseline ? baseline.failures / baseline.writes : null,
276+
baselineFailureRateUpperBound: baselineBounds ? baselineBounds.upper : null,
277+
baselineWriteCount: baseline ? baseline.writes : null,
278+
consecutiveBreachedWindows: consecutiveWindows,
102279
healthWindowSeconds: HEALTH_WINDOW_SECONDS,
280+
minWrites: MIN_WRITES,
281+
minFailureRate: MIN_FAILURE_RATE,
103282
},
104283
ctx,
105284
});

0 commit comments

Comments
 (0)