Skip to content
145 linesCodeBlameRaw
1/**
2 * Small readings shared by both doors: routes with their ids taken out,
3 * severities, ids, and text kept to a size.
4 */
5import type { LogSeverity } from "@g1t/contracts/telemetry";
6
7/** A log body or a message is kept to this many characters. */
8export const MAX_TEXT = 16_384;
9/** An attribute's string value, to this many. */
10export const MAX_VALUE = 4_096;
11
12export function clip(text: string, max = MAX_TEXT): string {
13 return text.length <= max ? text : `${text.slice(0, max)}… (${text.length - max} more characters)`;
14}
15
16const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17const NUMBER = /^\d+$/;
18const HEXISH = /^[0-9a-f]{12,}$/i;
19/** Long tokens that mix letters and digits: slugs with ids, base64, ulids. */
20const TOKENISH = /^(?=.*\d)(?=.*[a-z])[a-z0-9_-]{16,}$/i;
21
22/**
23 * The path with its ids taken out, so `/users/42` and `/users/43` count
24 * as one route: `/users/:id`. Files keep their names; the query goes.
25 */
26export function route(path: string): string {
27 const bare = path.split(/[?#]/)[0] || "/";
28 const parts = bare.split("/").map((part) => {
29 if (part === "") return part;
30 let decoded = part;
31 try {
32 decoded = decodeURIComponent(part);
33 } catch {}
34 if (NUMBER.test(decoded) || UUID.test(decoded) || HEXISH.test(decoded) || TOKENISH.test(decoded)) return ":id";
35 return part;
36 });
37 const joined = parts.join("/");
38 return joined.length > 200 ? `${joined.slice(0, 200)}…` : joined;
39}
40
41/** Host and path, without the query or the scheme. */
42export function hostPath(url: string): { url: string; path: string } | null {
43 try {
44 const u = new URL(url);
45 return { url: `${u.host}${u.pathname}`, path: u.pathname };
46 } catch {
47 return null;
48 }
49}
50
51/** OpenTelemetry's severity number (1–24) for a console level or a severity text. */
52export function severityNumber(level: string | undefined | null): number {
53 switch ((level ?? "").toLowerCase()) {
54 case "trace":
55 return 1;
56 case "debug":
57 return 5;
58 case "log":
59 case "info":
60 case "information":
61 case "notice":
62 return 9;
63 case "warn":
64 case "warning":
65 return 13;
66 case "error":
67 case "err":
68 return 17;
69 case "fatal":
70 case "critical":
71 case "crit":
72 case "emergency":
73 case "alert":
74 return 21;
75 default:
76 return 9;
77 }
78}
79
80/** The level a severity number falls in. 0 (unset) reads as info. */
81export function severityOf(n: number): LogSeverity {
82 if (n >= 21) return "fatal";
83 if (n >= 17) return "error";
84 if (n >= 13) return "warn";
85 if (n >= 9 || n <= 0) return "info";
86 if (n >= 5) return "debug";
87 return "trace";
88}
89
90/** Lowest severity number of each level, to filter by. */
91export const SEVERITY_FLOOR: Record<LogSeverity, [number, number]> = {
92 trace: [1, 4],
93 debug: [5, 8],
94 info: [9, 12],
95 warn: [13, 16],
96 error: [17, 20],
97 fatal: [21, 24],
98};
99
100/** A random id of `bytes` bytes, as hex: trace ids are 16, span and request ids 8. */
101export function randomId(bytes = 8): string {
102 return [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, "0")).join("");
103}
104
105/** A lowercase hex id, or null for one that is empty, all zeros, or not hex. */
106export function hexId(id: string | undefined | null, length: number): string | null {
107 if (!id) return null;
108 let hex = id.toLowerCase();
109 // OTLP/JSON writes ids as hex, but some exporters send base64.
110 if (!/^[0-9a-f]+$/.test(hex)) {
111 try {
112 hex = [...atob(id)].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
113 } catch {
114 return null;
115 }
116 }
117 if (hex.length !== length || /^0+$/.test(hex)) return null;
118 return hex;
119}
120
121/** Nanoseconds (as OTLP writes them, a decimal string or a number) to microseconds. */
122export function nanosToMicros(nanos: string | number | undefined | null): number | null {
123 if (nanos === undefined || nanos === null || nanos === "" || nanos === "0" || nanos === 0) return null;
124 try {
125 const n = typeof nanos === "number" ? BigInt(Math.trunc(nanos)) : BigInt(nanos);
126 return Number(n / 1000n);
127 } catch {
128 return null;
129 }
130}
131
132/** Anything a console call was given, as one line of text. */
133export function text(value: unknown): string {
134 if (typeof value === "string") return value;
135 if (Array.isArray(value)) return value.map((v) => (typeof v === "string" ? v : safeJson(v))).join(" ");
136 return safeJson(value);
137}
138
139function safeJson(value: unknown): string {
140 try {
141 return JSON.stringify(value) ?? String(value);
142 } catch {
143 return String(value);
144 }
145}