| 1 | /** |
| 2 | * Durations kept as counts in fixed buckets, so a minute's latency (or |
| 3 | * CPU) is a short list of numbers that adds to any other minute's, and |
| 4 | * percentiles over any period come from the sum. Each bucket is read back |
| 5 | * as a point between its bounds, which keeps a percentile within the |
| 6 | * bucket's width of the true one: a few percent here, as bounds go up in |
| 7 | * steps of about 1.5×. |
| 8 | */ |
| 9 | import type { Percentiles } from "@g1t/contracts/telemetry"; |
| 10 | |
| 11 | /** Upper bounds, in milliseconds; the last bucket holds everything above the last bound. */ |
| 12 | export const BOUNDS_MS = [ |
| 13 | 1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 75, 100, 150, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000, 7500, 10000, 15000, 30000, 60000, |
| 14 | ]; |
| 15 | |
| 16 | export const BUCKETS = BOUNDS_MS.length + 1; |
| 17 | |
| 18 | export function bucketOf(ms: number): number { |
| 19 | let i = 0; |
| 20 | while (i < BOUNDS_MS.length && ms > BOUNDS_MS[i]) i++; |
| 21 | return i; |
| 22 | } |
| 23 | |
| 24 | export function emptyHistogram(): number[] { |
| 25 | return new Array(BUCKETS).fill(0); |
| 26 | } |
| 27 | |
| 28 | export function addInto(into: number[], from: readonly number[]): number[] { |
| 29 | for (let i = 0; i < BUCKETS; i++) into[i] = (into[i] ?? 0) + (from[i] ?? 0); |
| 30 | return into; |
| 31 | } |
| 32 | |
| 33 | /** A histogram as stored: counts joined by commas, trailing zeros dropped. */ |
| 34 | export function encode(counts: readonly number[]): string { |
| 35 | let end = counts.length; |
| 36 | while (end > 0 && !counts[end - 1]) end--; |
| 37 | return counts.slice(0, end).join(","); |
| 38 | } |
| 39 | |
| 40 | export function decode(text: string | null | undefined): number[] { |
| 41 | const out = emptyHistogram(); |
| 42 | if (!text) return out; |
| 43 | text.split(",").forEach((n, i) => { |
| 44 | if (i < BUCKETS) out[i] = Number(n) || 0; |
| 45 | }); |
| 46 | return out; |
| 47 | } |
| 48 | |
| 49 | /** The value at quantile `q` (0–1), interpolated within its bucket. */ |
| 50 | export function quantile(counts: readonly number[], q: number): number | null { |
| 51 | const total = counts.reduce((a, b) => a + b, 0); |
| 52 | if (!total) return null; |
| 53 | const rank = q * total; |
| 54 | let seen = 0; |
| 55 | for (let i = 0; i < BUCKETS; i++) { |
| 56 | const n = counts[i] ?? 0; |
| 57 | if (!n) continue; |
| 58 | if (seen + n >= rank) { |
| 59 | const low = i === 0 ? 0 : BOUNDS_MS[i - 1]; |
| 60 | const high = i < BOUNDS_MS.length ? BOUNDS_MS[i] : BOUNDS_MS[BOUNDS_MS.length - 1] * 2; |
| 61 | const within = n === 0 ? 0 : (rank - seen) / n; |
| 62 | return Math.round((low + (high - low) * within) * 100) / 100; |
| 63 | } |
| 64 | seen += n; |
| 65 | } |
| 66 | return BOUNDS_MS[BOUNDS_MS.length - 1]; |
| 67 | } |
| 68 | |
| 69 | export function percentiles(counts: readonly number[]): Percentiles { |
| 70 | return { p50: quantile(counts, 0.5), p75: quantile(counts, 0.75), p95: quantile(counts, 0.95), p99: quantile(counts, 0.99) }; |
| 71 | } |