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
1112export const config = { runtime : 'edge' } ;
@@ -18,18 +19,90 @@ import { redisPipeline } from './_upstash-json.js';
1819
1920const HEALTH_WINDOW_SECONDS = 60 ;
2021const 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+ */
2252const 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+
2379const MAX_BODY_BYTES = 1_024 ;
2480const MAX_COUNTER_DELTA = 10_000 ;
2581const RATE_LIMIT_SCOPE = 'analytics-health' ;
2682const RATE_LIMIT_PER_MINUTE = 60 ;
2783const ALLOWED_COHORTS = new Set ( [ 'event' , 'critical-event' , 'identify' ] ) ;
2884const 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
35108function 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
51160function 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+
57203export 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