Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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