Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge status detection: first-byte speed probe, deploy windows, 4 of 5 with a re-check, check history, reminders | 1 | /** |
| 2 | * An incident's parts, check by check, as the status worker keeps them | |
| 3 | * (7 days, `AdminIncidentDetail.checks`): the layout of the latency chart | |
| 4 | * on the incident page, and the words over it. Drawn on the server as SVG; | |
| 5 | * nothing here runs in the browser. | |
| 6 | */ | |
| 7 | import type { CheckHistory, CheckSample } from "@g1t/contracts"; | |
| 8 | ||
| 9 | import { median } from "./incidents.ts"; | |
| 10 | ||
| 11 | /** Checks further apart than this are drawn as a gap, not a line across it. */ | |
| 12 | const GAP_MS = 3 * 60_000; | |
| 13 | ||
| 14 | export type LatencyPlot = { | |
| 15 | width: number; | |
| 16 | height: number; | |
| 17 | plot: { x: number; y: number; width: number; height: number }; | |
| 18 | /** The top of the scale, in milliseconds. */ | |
| 19 | max: number; | |
| 20 | /** The answered checks as one path; a check that failed, or a gap, breaks it. */ | |
| 21 | line: string; | |
| 22 | /** Where the slow line is; null when the part's limit is not known. */ | |
| 23 | slowY: number | null; | |
| 24 | /** Slow checks, as dots on the line. */ | |
| 25 | slow: { x: number; y: number }[]; | |
| 26 | /** Checks that failed, as marks along the top. */ | |
| 27 | down: { x: number }[]; | |
| 28 | /** Gridlines, with their label. */ | |
| 29 | ticks: { y: number; label: string }[]; | |
| 30 | }; | |
| 31 | ||
| 32 | /** "800 ms", "1.5 s". */ | |
| 33 | export function msWords(ms: number): string { | |
| 34 | return ms >= 1000 ? `${Number((ms / 1000).toFixed(1))} s` : `${Math.round(ms)} ms`; | |
| 35 | } | |
| 36 | ||
| 37 | /** A round top for the scale: the next 250 ms under a second, then the next half second. */ | |
| 38 | function niceMax(ms: number): number { | |
| 39 | const step = ms <= 1000 ? 250 : 500; | |
| 40 | return Math.max(step, Math.ceil(ms / step) * step); | |
| 41 | } | |
| 42 | ||
| 43 | export function latencyPlot(history: CheckHistory, { width = 640, height = 120 } = {}): LatencyPlot { | |
| 44 | const plot = { x: 44, y: 8, width: width - 52, height: height - 26 }; | |
| 45 | const answered = history.samples.filter((s) => s.outcome !== "down" && s.ms != null); | |
| 46 | const top = Math.max(history.slow_ms != null ? history.slow_ms * 1.5 : 0, ...answered.map((s) => s.ms!), 1); | |
| 47 | const max = niceMax(top); | |
| 48 | const from = Date.parse(history.from); | |
| 49 | const span = Math.max(1, Date.parse(history.to) - from); | |
| 50 | const xOf = (at: string) => plot.x + (Math.min(Math.max(Date.parse(at) - from, 0), span) / span) * plot.width; | |
| 51 | const yOf = (ms: number) => plot.y + plot.height - (Math.min(ms, max) / max) * plot.height; | |
| 52 | const round = (n: number) => Math.round(n * 10) / 10; | |
| 53 | ||
| 54 | let line = ""; | |
| 55 | let last: CheckSample | null = null; | |
| 56 | for (const s of history.samples) { | |
| 57 | if (s.outcome === "down" || s.ms == null) { | |
| 58 | last = null; | |
| 59 | continue; | |
| 60 | } | |
| 61 | const joined = last != null && Date.parse(s.at) - Date.parse(last.at) <= GAP_MS; | |
| 62 | line += `${joined ? "L" : "M"}${round(xOf(s.at))} ${round(yOf(s.ms))}`; | |
| 63 | last = s; | |
| 64 | } | |
| 65 | const ticks = [0, max / 2, max].map((v) => ({ y: round(yOf(v)), label: v === 0 ? "0" : msWords(v) })); | |
| 66 | return { | |
| 67 | width, | |
| 68 | height, | |
| 69 | plot, | |
| 70 | max, | |
| 71 | line, | |
| 72 | slowY: history.slow_ms != null ? round(yOf(history.slow_ms)) : null, | |
| 73 | slow: history.samples.filter((s) => s.outcome === "degraded" && s.ms != null).map((s) => ({ x: round(xOf(s.at)), y: round(yOf(s.ms!)) })), | |
| 74 | down: history.samples.filter((s) => s.outcome === "down").map((s) => ({ x: round(xOf(s.at)) })), | |
| 75 | ticks, | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | export type LatencySummary = { | |
| 80 | checks: number; | |
| 81 | slow: number; | |
| 82 | down: number; | |
| 83 | /** Of the checks that answered. */ | |
| 84 | median_ms: number | null; | |
| 85 | slowest_ms: number | null; | |
| 86 | /** Slow at first and asked again at once. */ | |
| 87 | asked_again: number; | |
| 88 | /** Where the checks ran from, most first. */ | |
| 89 | colos: string[]; | |
| 90 | }; | |
| 91 | ||
| 92 | export function latencySummary(samples: CheckSample[]): LatencySummary { | |
| 93 | const answered = samples.filter((s) => s.outcome !== "down" && s.ms != null).map((s) => s.ms!); | |
| 94 | const colos = new Map<string, number>(); | |
| 95 | for (const s of samples) if (s.colo) colos.set(s.colo, (colos.get(s.colo) ?? 0) + 1); | |
| 96 | return { | |
| 97 | checks: samples.length, | |
| 98 | slow: samples.filter((s) => s.outcome === "degraded").length, | |
| 99 | down: samples.filter((s) => s.outcome === "down").length, | |
| 100 | median_ms: median(answered), | |
| 101 | slowest_ms: answered.length ? Math.max(...answered) : null, | |
| 102 | asked_again: samples.filter((s) => s.first_ms != null).length, | |
| 103 | colos: [...colos].sort((a, b) => b[1] - a[1]).map(([c]) => c), | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | /** The summary in a line: "62 checks · 14 slow · 2 not answering · median 640 ms · slowest 2.4 s · from IAD". */ | |
| 108 | export function summaryWords(s: LatencySummary): string { | |
| 109 | const parts = [`${s.checks} check${s.checks === 1 ? "" : "s"}`, `${s.slow} slow`, `${s.down} not answering`]; | |
| 110 | if (s.median_ms != null) parts.push(`median ${msWords(s.median_ms)}`); | |
| 111 | if (s.slowest_ms != null) parts.push(`slowest ${msWords(s.slowest_ms)}`); | |
| 112 | if (s.asked_again) parts.push(`${s.asked_again} asked again`); | |
| 113 | if (s.colos.length) parts.push(`from ${s.colos.join(", ")}`); | |
| 114 | return parts.join(" · "); | |
| 115 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.