| 1 | /** |
| 2 | * A project's Observability pages: what the address says to show (the |
| 3 | * period, environment, service, deployment and search), and how numbers, |
| 4 | * times and statuses read. Kept free of server code, for the pages and |
| 5 | * their tests. |
| 6 | */ |
| 7 | import type { BadgeTone } from "../components/ui/badge"; |
| 8 | import type { LogSeverity, TelemetryFilter } from "@g1t/contracts"; |
| 9 | |
| 10 | /** The periods the pages offer, newest first, with how long each is. */ |
| 11 | export const RANGES = [ |
| 12 | { key: "15m", label: "15 minutes", ms: 15 * 60_000 }, |
| 13 | { key: "1h", label: "1 hour", ms: 3_600_000 }, |
| 14 | { key: "6h", label: "6 hours", ms: 6 * 3_600_000 }, |
| 15 | { key: "24h", label: "24 hours", ms: 86_400_000 }, |
| 16 | { key: "7d", label: "7 days", ms: 7 * 86_400_000 }, |
| 17 | { key: "30d", label: "30 days", ms: 30 * 86_400_000 }, |
| 18 | ] as const; |
| 19 | |
| 20 | export type RangeKey = (typeof RANGES)[number]["key"]; |
| 21 | |
| 22 | export const DEFAULT_RANGE: RangeKey = "24h"; |
| 23 | |
| 24 | /** What the address asks to see. */ |
| 25 | export type View = { |
| 26 | range: RangeKey; |
| 27 | environment: string | null; |
| 28 | service: string | null; |
| 29 | deployment: string | null; |
| 30 | q: string; |
| 31 | }; |
| 32 | |
| 33 | export function parseView(params: URLSearchParams): View { |
| 34 | const range = RANGES.find((r) => r.key === params.get("range"))?.key ?? DEFAULT_RANGE; |
| 35 | const text = (key: string) => { |
| 36 | const value = params.get(key)?.trim(); |
| 37 | return value ? value.slice(0, 200) : null; |
| 38 | }; |
| 39 | return { range, environment: text("env"), service: text("service"), deployment: text("deployment"), q: text("q") ?? "" }; |
| 40 | } |
| 41 | |
| 42 | /** The view as the service's filter, ending `now`. */ |
| 43 | export function filterOf(view: View, now = Date.now()): TelemetryFilter { |
| 44 | const ms = RANGES.find((r) => r.key === view.range)!.ms; |
| 45 | return { |
| 46 | from_us: (now - ms) * 1000, |
| 47 | to_us: now * 1000, |
| 48 | environments: view.environment ? [view.environment] : null, |
| 49 | services: view.service ? [view.service] : null, |
| 50 | deployment_id: view.deployment, |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | /** The address of `path` with the view, changed by `change` (null drops a parameter). */ |
| 55 | export function viewHref(path: string, view: View, change: Partial<Record<"range" | "env" | "service" | "deployment" | "q" | string, string | null>> = {}): string { |
| 56 | const params = new URLSearchParams(); |
| 57 | const base: Record<string, string | null> = { |
| 58 | range: view.range === DEFAULT_RANGE ? null : view.range, |
| 59 | env: view.environment, |
| 60 | service: view.service, |
| 61 | deployment: view.deployment, |
| 62 | q: view.q || null, |
| 63 | }; |
| 64 | for (const [key, value] of Object.entries({ ...base, ...change })) if (value) params.set(key, value); |
| 65 | const query = params.toString(); |
| 66 | return query ? `${path}?${query}` : path; |
| 67 | } |
| 68 | |
| 69 | // ---- Numbers --------------------------------------------------------------- |
| 70 | |
| 71 | /** A duration in milliseconds as people read one: `840 µs`, `12 ms`, `1.4 s`, `2m 5s`. */ |
| 72 | export function duration(ms: number | null | undefined): string { |
| 73 | if (ms === null || ms === undefined || !Number.isFinite(ms)) return "—"; |
| 74 | if (ms === 0) return "0"; |
| 75 | if (ms < 1) return `${Math.round(ms * 1000)} µs`; |
| 76 | if (ms < 10) return `${ms.toFixed(1).replace(/\.0$/, "")} ms`; |
| 77 | if (ms < 1000) return `${Math.round(ms)} ms`; |
| 78 | if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1).replace(/\.?0+$/, "")} s`; |
| 79 | const minutes = Math.floor(ms / 60_000); |
| 80 | return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`; |
| 81 | } |
| 82 | |
| 83 | /** A count, short: `940`, `12.4K`, `3.1M`. */ |
| 84 | export function count(n: number): string { |
| 85 | if (n >= 1e9) return `${(n / 1e9).toLocaleString("en-US", { maximumFractionDigits: 1 })}B`; |
| 86 | if (n >= 1e6) return `${(n / 1e6).toLocaleString("en-US", { maximumFractionDigits: 1 })}M`; |
| 87 | if (n >= 1e4) return `${(n / 1e3).toLocaleString("en-US", { maximumFractionDigits: 1 })}K`; |
| 88 | return Math.round(n).toLocaleString("en-US"); |
| 89 | } |
| 90 | |
| 91 | /** A share (0–1) as a percentage, with as many places as a small one needs. */ |
| 92 | export function percent(share: number): string { |
| 93 | if (!Number.isFinite(share) || share === 0) return "0%"; |
| 94 | const p = share * 100; |
| 95 | if (p < 0.01) return "<0.01%"; |
| 96 | return `${p.toLocaleString("en-US", { maximumFractionDigits: p < 1 ? 2 : 1 })}%`; |
| 97 | } |
| 98 | |
| 99 | export function bytes(n: number): string { |
| 100 | if (n < 1024) return `${n} B`; |
| 101 | const units = ["KB", "MB", "GB", "TB"]; |
| 102 | let v = n / 1024; |
| 103 | let i = 0; |
| 104 | while (v >= 1024 && i < units.length - 1) { |
| 105 | v /= 1024; |
| 106 | i++; |
| 107 | } |
| 108 | return `${v.toLocaleString("en-US", { maximumFractionDigits: v < 10 ? 1 : 0 })} ${units[i]}`; |
| 109 | } |
| 110 | |
| 111 | /** |
| 112 | * How a number moved from the period before: the change as a share, and |
| 113 | * whether that is good. For latency and errors, down is good. |
| 114 | */ |
| 115 | export function change(now: number | null, before: number | null, lowerIsBetter: boolean): { text: string; good: boolean | null } | null { |
| 116 | if (now === null || before === null || before === 0) return null; |
| 117 | const share = (now - before) / before; |
| 118 | if (Math.abs(share) < 0.005) return { text: "no change", good: null }; |
| 119 | const text = `${share > 0 ? "+" : "−"}${percent(Math.abs(share))}`; |
| 120 | return { text, good: lowerIsBetter ? share < 0 : share > 0 }; |
| 121 | } |
| 122 | |
| 123 | /** Round values for an axis from 0 to at least `max`, about `count` of them. */ |
| 124 | export function niceTicks(max: number, count = 4): number[] { |
| 125 | if (!(max > 0)) return [0, 1]; |
| 126 | const raw = max / count; |
| 127 | const power = 10 ** Math.floor(Math.log10(raw)); |
| 128 | const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw) ?? 10 * power; |
| 129 | const out: number[] = []; |
| 130 | for (let v = 0; out.length === 0 || out[out.length - 1]! < max; v += step) out.push(Number(v.toPrecision(12))); |
| 131 | return out; |
| 132 | } |
| 133 | |
| 134 | // ---- Statuses -------------------------------------------------------------- |
| 135 | |
| 136 | export function statusTone(status: number | null, outcome?: string | null): BadgeTone { |
| 137 | if (outcome && outcome !== "ok" && outcome !== "canceled" && outcome !== "responseStreamDisconnected") return "danger"; |
| 138 | if (status === null) return "neutral"; |
| 139 | if (status >= 500) return "danger"; |
| 140 | if (status >= 400) return "warn"; |
| 141 | if (status >= 300) return "info"; |
| 142 | return "success"; |
| 143 | } |
| 144 | |
| 145 | export const LEVEL_TONE: Record<LogSeverity, BadgeTone> = { |
| 146 | trace: "neutral", |
| 147 | debug: "neutral", |
| 148 | info: "info", |
| 149 | warn: "warn", |
| 150 | error: "danger", |
| 151 | fatal: "danger", |
| 152 | }; |
| 153 | |
| 154 | /** |
| 155 | * Chart colors. Latency percentiles are the site's first three categorical |
| 156 | * slots (lib/usage.ts, validated against both surfaces); requests by |
| 157 | * outcome use the status colors, each named in the legend. |
| 158 | */ |
| 159 | export const SERIES = { |
| 160 | p50: "#3987e5", |
| 161 | p95: "#d95926", |
| 162 | p99: "#199e70", |
| 163 | ok: "var(--color-accent)", |
| 164 | client: "var(--color-warn)", |
| 165 | server: "var(--color-danger)", |
| 166 | } as const; |
| 167 | |
| 168 | /** A time on an axis: the hour for a day or less, the date beyond. */ |
| 169 | export function axisTime(us: number, spanMs: number): string { |
| 170 | const at = new Date(us / 1000); |
| 171 | // A day and a bit (a 24-hour chart has a step more than a day) reads by the hour. |
| 172 | if (spanMs <= 2 * 86_400_000) return at.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: false, timeZone: "UTC" }); |
| 173 | return at.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }); |
| 174 | } |
| 175 | |
| 176 | /** A time to the second (or the millisecond, for logs), in UTC so the server and browser agree. */ |
| 177 | export function clock(us: number, millis = false): string { |
| 178 | const iso = new Date(us / 1000).toISOString(); |
| 179 | return `${iso.slice(5, 10)} ${iso.slice(11, millis ? 23 : 19)}`; |
| 180 | } |
| 181 | |
| 182 | export function shortId(id: string | null | undefined, n = 8): string { |
| 183 | return id ? id.slice(0, n) : ""; |
| 184 | } |
| 185 | |
| 186 | /** OpenTelemetry's environment variables for software sending here, in a shell's syntax. */ |
| 187 | export function otelEnv(endpoint: string, service: string, token: string): string { |
| 188 | return [ |
| 189 | `OTEL_EXPORTER_OTLP_ENDPOINT=${endpoint}`, |
| 190 | `OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer ${token}"`, |
| 191 | `OTEL_SERVICE_NAME=${service}`, |
| 192 | `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production`, |
| 193 | ].join("\n"); |
| 194 | } |
| 195 | |
| 196 | /** |
| 197 | * Whether a log line that arrived live belongs on the page: the same |
| 198 | * search the service runs (words in the line, `level:`, `key=value`), |
| 199 | * read here for lines that did not come through it. |
| 200 | */ |
| 201 | export function matchesLog( |
| 202 | log: { severity: LogSeverity; body: string; environment: string | null; service: string; deployment_id: string | null; attributes: Record<string, unknown> } & Record<string, unknown>, |
| 203 | view: View, |
| 204 | levels: LogSeverity[], |
| 205 | ): boolean { |
| 206 | if (view.environment && log.environment !== view.environment) return false; |
| 207 | if (view.service && log.service !== view.service) return false; |
| 208 | if (view.deployment && log.deployment_id !== view.deployment) return false; |
| 209 | const wanted = new Set<string>(levels); |
| 210 | const body = log.body.toLowerCase(); |
| 211 | for (const m of view.q.matchAll(/"([^"]+)"|(\S+)/g)) { |
| 212 | const token = m[1] ?? m[2]!; |
| 213 | const pair = m[1] ? null : /^([\w.@/-]+)[=:](.+)$/.exec(token); |
| 214 | if (pair && !/^https?$/.test(pair[1]!)) { |
| 215 | const [, key, value] = pair; |
| 216 | if (key === "level" || key === "severity") wanted.add(value!.toLowerCase()); |
| 217 | else if (String(log.attributes[key!] ?? log[key!] ?? "") !== value) return false; |
| 218 | } else if (!body.includes(token.toLowerCase())) return false; |
| 219 | } |
| 220 | return wanted.size === 0 || wanted.has(log.severity); |
| 221 | } |
| 222 | |
| 223 | /** The issue made for an error group, for a person or an agent to take: what happened, how often, since when, and where to look. */ |
| 224 | export function errorIssue( |
| 225 | group: { name: string; message: string; stack: string | null; count: number; first_us: number; last_us: number; service: string; environment: string | null; first_deployment_id: string | null; last_request_id: string | null; last_trace_id: string | null }, |
| 226 | pageUrl: string, |
| 227 | ): { title: string; body: string } { |
| 228 | const title = `${group.name}: ${group.message}`.replace(/\s+/g, " ").slice(0, 120); |
| 229 | const day = (us: number) => new Date(us / 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; |
| 230 | const lines = [ |
| 231 | `\`${group.name}\` in **${group.service || "the app"}**${group.environment ? ` (${group.environment})` : ""}: ${group.count.toLocaleString("en-US")} time${group.count === 1 ? "" : "s"}, first ${day(group.first_us)}, last ${day(group.last_us)}.`, |
| 232 | "", |
| 233 | "```", |
| 234 | group.message, |
| 235 | "```", |
| 236 | ]; |
| 237 | if (group.stack) lines.push("", "Stack:", "", "```", group.stack.slice(0, 6000), "```"); |
| 238 | const links = [ |
| 239 | group.first_deployment_id && `- First seen on deployment \`${group.first_deployment_id}\``, |
| 240 | group.last_request_id && `- The latest request: ${pageUrl}/requests/${group.last_request_id}`, |
| 241 | group.last_trace_id && `- Its trace: ${pageUrl}/traces/${group.last_trace_id}`, |
| 242 | `- Every occurrence: ${pageUrl}/errors`, |
| 243 | ].filter(Boolean); |
| 244 | lines.push("", ...(links as string[])); |
| 245 | return { title, body: lines.join("\n") }; |
| 246 | } |