| 1 | /** |
| 2 | * Small readings shared by both doors: routes with their ids taken out, |
| 3 | * severities, ids, and text kept to a size. |
| 4 | */ |
| 5 | import type { LogSeverity } from "@g1t/contracts/telemetry"; |
| 6 | |
| 7 | /** A log body or a message is kept to this many characters. */ |
| 8 | export const MAX_TEXT = 16_384; |
| 9 | /** An attribute's string value, to this many. */ |
| 10 | export const MAX_VALUE = 4_096; |
| 11 | |
| 12 | export 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 | |
| 16 | const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; |
| 17 | const NUMBER = /^\d+$/; |
| 18 | const HEXISH = /^[0-9a-f]{12,}$/i; |
| 19 | /** Long tokens that mix letters and digits: slugs with ids, base64, ulids. */ |
| 20 | const 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 | */ |
| 26 | export 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. */ |
| 42 | export 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. */ |
| 52 | export 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. */ |
| 81 | export 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. */ |
| 91 | export 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. */ |
| 101 | export 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. */ |
| 106 | export 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. */ |
| 122 | export 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. */ |
| 133 | export 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 | |
| 139 | function safeJson(value: unknown): string { |
| 140 | try { |
| 141 | return JSON.stringify(value) ?? String(value); |
| 142 | } catch { |
| 143 | return String(value); |
| 144 | } |
| 145 | } |