// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Parser and query helpers for dpsapi's log/slog text-handler output (stderr). * * Line format: `time= level= msg= key=value ...` where a value * is either bare (no spaces/quotes/=) or Go-quoted. */ import { goUnquote } from './gostrings'; export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const; export type LogLevel = (typeof LOG_LEVELS)[number]; /** Severity rank: TRACE 0 … ERROR 4. */ export function levelRank(level: string): number { const i = LOG_LEVELS.indexOf(level.toUpperCase() as LogLevel); if (i < 0) throw new Error(`unknown log level ${level}`); return i; } /** Levels that must be emitted when the minimum level is `min`. */ export function visibleLevels(min: LogLevel): LogLevel[] { return LOG_LEVELS.filter((l) => levelRank(l) >= levelRank(min)); } export interface LogRecord { /** The `time` attribute (UTC, millisecond precision). */ time: string; level: string; msg: string; /** All attributes except time/level/msg; the last value wins for duplicate keys. */ attrs: Record; /** Every key/value pair in order, including time/level/msg. */ pairs: Array<[string, string]>; /** The raw line. */ raw: string; /** 0-based line index in the stream. */ line: number; } export type AttrMatcher = string | number | boolean | RegExp | ((value: string | undefined) => boolean); export interface LogQuery { level?: string | string[]; msg?: string | RegExp; /** Every listed attribute must match. Use `undefined`-checking functions for absence. */ attrs?: Record; } /** Strict format of the slog `time` attribute as emitted by dpsapi (UTC, milliseconds). */ export const LOG_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; /** Parses one log line; returns undefined when the line is not a slog record. */ export function parseLogLine(raw: string, line = 0): LogRecord | undefined { if (!raw.startsWith('time=')) return undefined; const pairs: Array<[string, string]> = []; let i = 0; const n = raw.length; const readToken = (stopAtEq: boolean): string | undefined => { if (raw[i] === '"') { // quoted: find closing quote honoring backslash escapes let j = i + 1; while (j < n && raw[j] !== '"') j += raw[j] === '\\' ? 2 : 1; if (j >= n) return undefined; const q = raw.slice(i, j + 1); i = j + 1; try { return goUnquote(q); } catch { return undefined; } } let j = i; while (j < n && raw[j] !== ' ' && !(stopAtEq && raw[j] === '=')) j++; const t = raw.slice(i, j); i = j; return t; }; while (i < n) { while (raw[i] === ' ') i++; if (i >= n) break; const key = readToken(true); if (key === undefined || raw[i] !== '=') return undefined; i++; const value = readToken(false); if (value === undefined) return undefined; pairs.push([key, value]); } if (pairs.length < 3 || pairs[0][0] !== 'time' || pairs[1][0] !== 'level' || pairs[2][0] !== 'msg') return undefined; const attrs: Record = {}; for (const [k, v] of pairs.slice(3)) attrs[k] = v; return { time: pairs[0][1], level: pairs[1][1], msg: pairs[2][1], attrs, pairs, raw, line }; } export function matchesQuery(r: LogRecord, q: LogQuery): boolean { if (q.level !== undefined) { const levels = Array.isArray(q.level) ? q.level : [q.level]; if (!levels.map((l) => l.toUpperCase()).includes(r.level)) return false; } if (q.msg !== undefined) { if (typeof q.msg === 'string' ? r.msg !== q.msg : !q.msg.test(r.msg)) return false; } for (const [k, m] of Object.entries(q.attrs ?? {})) { const v = Object.prototype.hasOwnProperty.call(r.attrs, k) ? r.attrs[k] : undefined; if (typeof m === 'function') { if (!m(v)) return false; } else if (m instanceof RegExp) { if (v === undefined || !m.test(v)) return false; } else if (v !== String(m)) return false; } return true; } export class LogSet { readonly records: LogRecord[]; /** Lines that are not slog records (e.g. the final `dpsapi: ...` error line, usage text). */ readonly unparsed: Array<{ line: number; text: string }>; constructor(text: string) { this.records = []; this.unparsed = []; const lines = text.split('\n'); if (lines.length && lines[lines.length - 1] === '') lines.pop(); lines.forEach((l, idx) => { const r = parseLogLine(l, idx); if (r) this.records.push(r); else this.unparsed.push({ line: idx, text: l }); }); } filter(q: LogQuery = {}): LogRecord[] { return this.records.filter((r) => matchesQuery(r, q)); } find(q: LogQuery): LogRecord | undefined { return this.records.find((r) => matchesQuery(r, q)); } count(q: LogQuery = {}): number { return this.filter(q).length; } has(q: LogQuery): boolean { return this.find(q) !== undefined; } /** Distinct levels present. */ levels(): string[] { return [...new Set(this.records.map((r) => r.level))]; } /** Distinct messages present (optionally only at one level). */ messages(level?: string): string[] { return [...new Set(this.records.filter((r) => !level || r.level === level.toUpperCase()).map((r) => r.msg))]; } /** `this request has been made` records. */ requests(q: Omit = {}): LogRecord[] { return this.filter({ ...q, msg: 'this request has been made' }); } } export function parseLogs(text: string): LogSet { return new LogSet(text); }