Skip to content
246 linesCodeBlameRaw
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 */
7import type { BadgeTone } from "../components/ui/badge";
8import type { LogSeverity, TelemetryFilter } from "@g1t/contracts";
9
10/** The periods the pages offer, newest first, with how long each is. */
11export 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
20export type RangeKey = (typeof RANGES)[number]["key"];
21
22export const DEFAULT_RANGE: RangeKey = "24h";
23
24/** What the address asks to see. */
25export type View = {
26 range: RangeKey;
27 environment: string | null;
28 service: string | null;
29 deployment: string | null;
30 q: string;
31};
32
33export 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`. */
43export 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). */
55export 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`. */
72export 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`. */
84export 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. */
92export 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
99export 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 */
115export 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. */
124export 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
136export 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
145export 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 */
159export 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. */
169export 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. */
177export 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
182export 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. */
187export 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 */
201export 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. */
224export 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}