@bli-cockpit/log-digest 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # @bli-cockpit/log-digest
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A token-cheap digest of log lines, shared by `cockpit logs` and the `logs_digest` MCP tool.
4
+ Node standard library only, no runtime dependencies.
5
+
6
+ Plain words: a busy log is tens of thousands of lines and almost all of it is routine. This keeps the
7
+ lines that can mean trouble, masks their variable parts (addresses, ids, times, numbers) into a
8
+ **template**, counts equal templates with first and last time and one example, turns routine runs into
9
+ counts, and **classifies** every template so harmless ones are counted and never alerted.
10
+
11
+ It is a TypeScript port of Kaiben's `log_digest.py` and `operator_classify.py` with the same output
12
+ contract: the masking order is fixed (memory addresses, URLs, UUIDs, timestamps, IPs, long hex, long ids,
13
+ paths, quoted strings, numbers except a status in a status position), a template id is the first 8 hex of
14
+ `sha1(unit group, event, template)`, output is deterministic and order-stable, and token-shaped strings are
15
+ redacted from every example. `src/python-contract.test.ts` pins it to the Python on Kaiben's real fixture.
16
+
17
+ ```ts
18
+ import { digestSource } from "@bli-cockpit/log-digest";
19
+
20
+ const outcome = await digestSource({ source: "railway:ladder", since: "1h" }, { stateDir });
21
+ if (outcome.ok && outcome.kind === "digest") console.log(outcome.text); // about 2 KB, our_bug and unknown first
22
+ else if (!outcome.ok) console.error(outcome.reason, outcome.detail); // a named refusal, never a stack trace
23
+ ```
24
+
25
+ Sources: `collector`, `dashboard` (Vercel), `railway`, `railway:<service>`, `-` (stdin text).
26
+ Every source is read-only. How to run it, the source inventory, what each source cannot see and how to
27
+ teach the class table a new pattern: `docs/runbooks/tower-logs.md` in the repository.
28
+
29
+ Known differences from the Python original: `\b`, `\w` and `\d` are ASCII only here (Python's are
30
+ Unicode); the circuit-breaker facts that read Kaiben's own deploy modules are not ported (`breaker` stays
31
+ `{}`); and `taggedLines` (on for Tower, off by default) judges `[tag] sentence {json}` lines by their parts.
@@ -0,0 +1,48 @@
1
+ export type Severity = "ERROR" | "hiccup" | "WARNING";
2
+ export interface ClassInfo {
3
+ severity: Severity;
4
+ about: string;
5
+ }
6
+ export interface TablePattern {
7
+ id: string;
8
+ class: string;
9
+ /** Regex source on the masked template. */
10
+ match: string;
11
+ /** Regex source on the unit group. */
12
+ unit?: string;
13
+ /** Regex source on the source name (`dashboard`, `railway:ladder`, `collector`...). */
14
+ source?: string;
15
+ /** Overrides the class's severity (a hiccup whose retries ran out is a WARNING). */
16
+ severity?: Severity;
17
+ note: string;
18
+ /** Where the verdict came from: a ticket, a commit, a measured window. */
19
+ basis: string;
20
+ }
21
+ export interface ClassTableData {
22
+ version: number;
23
+ classes: Record<string, ClassInfo>;
24
+ patterns: readonly TablePattern[];
25
+ /** Regexes on a unit name; a match drops the line and counts it as noise. */
26
+ noiseUnits?: readonly string[];
27
+ }
28
+ export interface Verdict {
29
+ class: string;
30
+ severity: Severity;
31
+ pattern: string | null;
32
+ note: string;
33
+ }
34
+ export type Classify = (unit: string, template: string, source?: string) => Verdict;
35
+ export declare class ClassTable {
36
+ readonly classes: Record<string, ClassInfo>;
37
+ readonly noiseUnits: readonly string[];
38
+ private readonly patterns;
39
+ private readonly cache;
40
+ constructor(data: ClassTableData);
41
+ /** A table read from its JSON text (the `--table` file a person writes to try a pattern). */
42
+ static fromJson(text: string): ClassTable;
43
+ classify: Classify;
44
+ private decide;
45
+ private verdict;
46
+ }
47
+ /** Display and sort order: what needs a person first, what is counted last. */
48
+ export declare const CLASS_ORDER: Record<string, number>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The error table, applied (port of Kaiben's `operator_classify.ClassTable`).
3
+ *
4
+ * A table maps a masked log template to a class, and a class to a severity word:
5
+ *
6
+ * ERROR our_bug, config_drift, capacity, unknown something we must fix
7
+ * hiccup hiccup, rate_pushback retried with back-off
8
+ * WARNING dead_source, benign the world changed, nothing to fix: counted, never alerted
9
+ *
10
+ * A template no pattern matches is class `unknown`, so a new kind of problem
11
+ * always reaches someone. First match wins: specific patterns sit above generic
12
+ * ones. A pattern's `match` is a JavaScript regex on the MASKED template; the
13
+ * optional `unit` regex narrows it to a unit group (a Vercel route family, a
14
+ * Railway service, a collector tag) and `source` to one log source.
15
+ */
16
+ import { DECLARED_WARNING } from "./judge.js";
17
+ export class ClassTable {
18
+ classes;
19
+ noiseUnits;
20
+ patterns;
21
+ cache = new Map();
22
+ constructor(data) {
23
+ if (!data.classes["unknown"])
24
+ throw new Error("a class table needs an `unknown` class: it is where a new problem lands");
25
+ this.classes = data.classes;
26
+ this.noiseUnits = data.noiseUnits ?? [];
27
+ this.patterns = data.patterns.map((spec) => {
28
+ if (!data.classes[spec.class])
29
+ throw new Error(`pattern ${spec.id} names the unknown class ${spec.class}`);
30
+ return {
31
+ spec,
32
+ match: new RegExp(spec.match),
33
+ unit: spec.unit ? new RegExp(spec.unit) : null,
34
+ source: spec.source ? new RegExp(spec.source) : null,
35
+ };
36
+ });
37
+ }
38
+ /** A table read from its JSON text (the `--table` file a person writes to try a pattern). */
39
+ static fromJson(text) {
40
+ return new ClassTable(JSON.parse(text));
41
+ }
42
+ classify = (unit, template, source) => {
43
+ const key = `${source ?? ""}\x1f${unit}\x1f${template}`;
44
+ let verdict = this.cache.get(key);
45
+ if (!verdict) {
46
+ verdict = this.decide(unit || "", template || "", source || "");
47
+ this.cache.set(key, verdict);
48
+ }
49
+ return verdict;
50
+ };
51
+ decide(unit, template, source) {
52
+ const declaredWarning = template.endsWith(DECLARED_WARNING);
53
+ const bare = declaredWarning ? template.slice(0, -DECLARED_WARNING.length) : template; // patterns see the template before the suffix
54
+ for (const pattern of this.patterns) {
55
+ if (pattern.unit && !pattern.unit.test(unit))
56
+ continue;
57
+ if (pattern.source && !pattern.source.test(source))
58
+ continue;
59
+ if (pattern.match.test(bare))
60
+ return this.verdict(pattern.spec.class, pattern.spec);
61
+ }
62
+ if (declaredWarning) {
63
+ // The unit logged this as WARNING on purpose and no pattern says otherwise: count it, never escalate.
64
+ return { class: "unknown", severity: "WARNING", pattern: "declared-warning", note: "the unit logged it with level WARNING; counted, not escalated" };
65
+ }
66
+ return this.verdict("unknown", null);
67
+ }
68
+ verdict(name, pattern) {
69
+ const info = this.classes[name] ?? this.classes["unknown"];
70
+ return {
71
+ class: name,
72
+ severity: pattern?.severity ?? info.severity,
73
+ pattern: pattern ? pattern.id : null,
74
+ note: pattern?.note ?? "",
75
+ };
76
+ }
77
+ }
78
+ /** Display and sort order: what needs a person first, what is counted last. */
79
+ export const CLASS_ORDER = {
80
+ unknown: 0, our_bug: 1, config_drift: 2, capacity: 3, rate_pushback: 4, hiccup: 5, transient: 5, dead_source: 6, benign: 7,
81
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One call that does the whole job for any source: read it, digest it, classify
3
+ * it, render it. The CLI (`cockpit logs`) and the MCP tool (`logs_digest`) are
4
+ * both thin callers of `digestSource`, so they print the same thing.
5
+ */
6
+ import { ClassTable } from "./classify.js";
7
+ import { type RoutineEvents } from "./digest.js";
8
+ import { type LogFn, type RunCli, type SourceContext } from "./sources/types.js";
9
+ export declare const SOURCE_NAMES: readonly ["collector", "dashboard", "railway", "railway:<service>", "-"];
10
+ /** A Railway service name: validated before anything is spawned, because a Windows run goes through cmd.exe. */
11
+ export declare const SERVICE_NAME: RegExp;
12
+ export declare const DEFAULT_MAX_BYTES = 2048;
13
+ export interface DigestRequest {
14
+ /** `collector`, `dashboard`, `railway`, `railway:<service>` or `-`. */
15
+ source: string;
16
+ /** `15m`, `1h`, `2d` or an ISO time. Default 15m. */
17
+ since?: string;
18
+ /** A template id: print the original lines behind it instead of the digest. */
19
+ raw?: string;
20
+ /** How many original lines `raw` prints (newest last). */
21
+ rawLimit?: number;
22
+ /** The class table; the Tower table by default. */
23
+ table?: ClassTable;
24
+ /** Ceiling on the printed text. About 2 KB by default. */
25
+ maxBytes?: number;
26
+ /** `dashboard` only: read info-only requests too (slow). */
27
+ allLevels?: boolean;
28
+ }
29
+ export interface DigestEnvironment {
30
+ /** Where the collector keeps its state on this machine. */
31
+ stateDir?: string;
32
+ stdinText?: string;
33
+ stdinIsTerminal?: boolean;
34
+ run?: RunCli;
35
+ log?: LogFn;
36
+ nowMs?: number;
37
+ limits?: SourceContext["limits"];
38
+ }
39
+ export interface DigestTemplate {
40
+ tid: string;
41
+ unit: string;
42
+ class: string;
43
+ severity: string;
44
+ pattern: string | null;
45
+ count: number;
46
+ first: number | null;
47
+ last: number | null;
48
+ template: string;
49
+ example: string;
50
+ instances: string[];
51
+ }
52
+ export interface DigestAnswer {
53
+ ok: true;
54
+ kind: "digest";
55
+ source: string;
56
+ window: {
57
+ from: string;
58
+ to: string;
59
+ label: string;
60
+ };
61
+ lines: number;
62
+ noise: number;
63
+ notes: string[];
64
+ templates: DigestTemplate[];
65
+ routine: Record<string, RoutineEvents>;
66
+ /** The capped human text, the same thing `cockpit logs` prints. */
67
+ text: string;
68
+ /** What the digest saved: characters of the log lines read vs characters printed (tokens are about chars / 4). */
69
+ measure: {
70
+ raw_chars: number;
71
+ digest_chars: number;
72
+ basis: "chars/4";
73
+ };
74
+ }
75
+ export interface RawAnswer {
76
+ ok: true;
77
+ kind: "raw";
78
+ source: string;
79
+ tid: string;
80
+ window: {
81
+ from: string;
82
+ to: string;
83
+ label: string;
84
+ };
85
+ matched: number;
86
+ lines: string[];
87
+ notes: string[];
88
+ text: string;
89
+ }
90
+ export interface Refusal {
91
+ ok: false;
92
+ reason: string;
93
+ detail: string;
94
+ }
95
+ export type DigestOutcome = DigestAnswer | RawAnswer | Refusal;
96
+ /** `railway:ladder` becomes `railway`: the source family a table pattern's `source` regex is matched against. */
97
+ export declare function sourceFamily(source: string): string;
98
+ /** Reads, digests, classifies and renders one source. Never throws for a refusal: it is returned, named. */
99
+ export declare function digestSource(request: DigestRequest, env?: DigestEnvironment): Promise<DigestOutcome>;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * One call that does the whole job for any source: read it, digest it, classify
3
+ * it, render it. The CLI (`cockpit logs`) and the MCP tool (`logs_digest`) are
4
+ * both thin callers of `digestSource`, so they print the same thing.
5
+ */
6
+ import { CLASS_ORDER } from "./classify.js";
7
+ import { Digest } from "./digest.js";
8
+ import { redact, squash, cut } from "./mask.js";
9
+ import { renderCapped } from "./render.js";
10
+ import { nodeRunCli } from "./sources/process.js";
11
+ import { readCollector } from "./sources/collector.js";
12
+ import { readRailway } from "./sources/railway.js";
13
+ import { readStdin } from "./sources/stdin.js";
14
+ import { readDashboard } from "./sources/vercel.js";
15
+ import { SourceRefusal } from "./sources/types.js";
16
+ import { towerClassTable } from "./tower-classes.js";
17
+ import { WindowError, parseWindow } from "./window.js";
18
+ export const SOURCE_NAMES = ["collector", "dashboard", "railway", "railway:<service>", "-"];
19
+ /** A Railway service name: validated before anything is spawned, because a Windows run goes through cmd.exe. */
20
+ export const SERVICE_NAME = /^railway:([A-Za-z0-9][A-Za-z0-9._-]{0,63})$/;
21
+ export const DEFAULT_MAX_BYTES = 2048;
22
+ const RAW_LINE_CHARS = 400;
23
+ const RAW_DEFAULT_LIMIT = 20;
24
+ const defaultLog = (message, fields) => console.error(message, JSON.stringify(fields));
25
+ /** `railway:ladder` becomes `railway`: the source family a table pattern's `source` regex is matched against. */
26
+ export function sourceFamily(source) {
27
+ return source.startsWith("railway:") ? "railway" : source;
28
+ }
29
+ function reader(source) {
30
+ if (source === "collector")
31
+ return readCollector;
32
+ if (source === "dashboard")
33
+ return readDashboard;
34
+ if (source === "-")
35
+ return readStdin;
36
+ if (source === "railway")
37
+ return (context) => readRailway(context);
38
+ const service = SERVICE_NAME.exec(source)?.[1];
39
+ if (service)
40
+ return (context) => readRailway(context, service);
41
+ return null;
42
+ }
43
+ const iso = (epoch) => new Date(epoch * 1000).toISOString();
44
+ function windowFacts(window) {
45
+ return { from: iso(window.fromEpoch), to: iso(window.toEpoch), label: window.label };
46
+ }
47
+ function templates(result, table, family) {
48
+ const rows = result.groups.map((record) => {
49
+ const verdict = table.classify(record.unit, record.template, family);
50
+ return {
51
+ tid: record.tid, unit: record.unit, class: verdict.class, severity: verdict.severity, pattern: verdict.pattern,
52
+ count: record.count, first: record.first, last: record.last, template: record.template, example: record.example, instances: record.instances,
53
+ };
54
+ });
55
+ // What needs a person first, then by count: the same order the text view uses.
56
+ return rows.sort((a, b) => (CLASS_ORDER[a.class] ?? 0) - (CLASS_ORDER[b.class] ?? 0) || b.count - a.count || (a.tid < b.tid ? -1 : 1));
57
+ }
58
+ /** Reads, digests, classifies and renders one source. Never throws for a refusal: it is returned, named. */
59
+ export async function digestSource(request, env = {}) {
60
+ const log = env.log ?? defaultLog;
61
+ const family = sourceFamily(request.source);
62
+ const read = reader(request.source);
63
+ if (!read && request.source.startsWith("railway:")) {
64
+ log("[logs digest] refused", { reason: "invalid_service_name", source_family: "railway" });
65
+ return { ok: false, reason: "invalid_service_name", detail: "a Railway service name is letters, digits, dot, dash and underscore, starting with a letter or digit, at most 64 characters" };
66
+ }
67
+ if (!read) {
68
+ log("[logs digest] refused", { reason: "unknown_source", source_family: family });
69
+ return { ok: false, reason: "unknown_source", detail: `source must be one of ${SOURCE_NAMES.join(", ")}; got ${JSON.stringify(request.source.slice(0, 40))}` };
70
+ }
71
+ let window;
72
+ try {
73
+ window = parseWindow(request.since, env.nowMs);
74
+ }
75
+ catch (error) {
76
+ if (!(error instanceof WindowError))
77
+ throw error;
78
+ log("[logs digest] refused", { reason: "invalid_since", source: family });
79
+ return { ok: false, reason: error.reason, detail: error.message };
80
+ }
81
+ if (request.raw !== undefined && !/^[0-9a-f]{8}$/.test(request.raw)) {
82
+ return { ok: false, reason: "invalid_template_id", detail: "--raw wants the 8 hex characters a digest line starts with" };
83
+ }
84
+ const table = request.table ?? towerClassTable();
85
+ const context = {
86
+ window, run: env.run ?? nodeRunCli, log, stateDir: env.stateDir, stdinText: env.stdinText, stdinIsTerminal: env.stdinIsTerminal, limits: env.limits, allLevels: request.allLevels,
87
+ };
88
+ let source;
89
+ try {
90
+ source = await read(context);
91
+ }
92
+ catch (error) {
93
+ if (error instanceof SourceRefusal) {
94
+ log("[logs digest] refused", { reason: error.reason, source: family });
95
+ return { ok: false, reason: error.reason, detail: error.detail };
96
+ }
97
+ throw error;
98
+ }
99
+ const digest = new Digest({ noise: table.noiseUnits, taggedLines: true });
100
+ const raw = [];
101
+ const wanted = request.raw;
102
+ let matched = 0;
103
+ for (const line of source.lines) {
104
+ const before = wanted ? digest.groups.get(wanted)?.count ?? 0 : 0;
105
+ digest.addLine(line.unit, line.message, line.ts, { level: line.level });
106
+ if (wanted && (digest.groups.get(wanted)?.count ?? 0) !== before) {
107
+ matched += 1;
108
+ const stamp = line.ts ? `${new Date(line.ts * 1000).toISOString().slice(11, 19)}Z ` : "";
109
+ raw.push(`${stamp}${line.unit}: ${cut(redact(squash(line.message)), RAW_LINE_CHARS)}`);
110
+ }
111
+ }
112
+ const result = digest.result();
113
+ const facts = windowFacts(window);
114
+ if (wanted) {
115
+ const limit = request.rawLimit ?? RAW_DEFAULT_LIMIT;
116
+ const lines = raw.slice(-limit);
117
+ log("[logs digest] raw lines", { source: family, tid: wanted, matched, printed: lines.length });
118
+ if (matched === 0) {
119
+ return { ok: false, reason: "template_not_found", detail: `no line in this ${request.source} window (${window.label}) has template id ${wanted}; use the same source and --since as the digest that printed it` };
120
+ }
121
+ const head = `RAW ${wanted} ${request.source} ${window.label}: ${matched} line${matched === 1 ? "" : "s"}${matched > lines.length ? `, newest ${lines.length}` : ""}`;
122
+ return { ok: true, kind: "raw", source: request.source, tid: wanted, window: facts, matched, lines, notes: source.notes, text: [head, ...lines].join("\n") };
123
+ }
124
+ const text = renderCapped(result, { source: request.source, window: window.label, classify: (unit, template) => table.classify(unit, template, family), maxBytes: request.maxBytes ?? DEFAULT_MAX_BYTES, notes: source.notes });
125
+ const all = templates(result, table, family);
126
+ const rawChars = source.rawChars ?? source.lines.reduce((sum, line) => sum + line.message.length + 1, 0);
127
+ const errors = all.filter((entry) => entry.severity === "ERROR").length;
128
+ log("[logs digest] digested", { source: family, window: window.label, lines: result.lines, noise: result.noise, templates: all.length, error_templates: errors, printed_bytes: Buffer.byteLength(text, "utf8"), raw_chars: rawChars });
129
+ return { ok: true, kind: "digest", source: request.source, window: facts, lines: result.lines, noise: result.noise, notes: source.notes, templates: all, routine: result.routine, text, measure: { raw_chars: rawChars, digest_chars: text.length, basis: "chars/4" } };
130
+ }
@@ -0,0 +1,81 @@
1
+ export declare const SUBJECTS_TRACKED = 6;
2
+ /** Units that only ever say "someone logged in" or "a package timer ran": dropped and counted as noise. */
3
+ export declare const DEFAULT_NOISE: readonly string[];
4
+ /** One `journalctl -o json` entry (the contract Kaiben's digest takes), or any source shaped like it. */
5
+ export interface JournalEntry {
6
+ MESSAGE?: unknown;
7
+ __CURSOR?: string;
8
+ __REALTIME_TIMESTAMP?: string | number;
9
+ SYSLOG_IDENTIFIER?: string;
10
+ UNIT?: string;
11
+ _SYSTEMD_UNIT?: string;
12
+ _TRANSPORT?: string;
13
+ }
14
+ export interface DigestGroup {
15
+ tid: string;
16
+ unit: string;
17
+ event: string;
18
+ template: string;
19
+ count: number;
20
+ first: number | null;
21
+ last: number | null;
22
+ example: string;
23
+ instances: string[];
24
+ }
25
+ /** event -> [count, subject -> count] */
26
+ export type RoutineEvents = Record<string, [number, Record<string, number>]>;
27
+ export interface DigestResult {
28
+ lines: number;
29
+ noise: number;
30
+ span: [number | null, number | null];
31
+ cursors: [string | null, string | null];
32
+ groups: DigestGroup[];
33
+ routine: Record<string, RoutineEvents>;
34
+ breaker: Record<string, never>;
35
+ }
36
+ export interface DigestOptions {
37
+ /** Regexes (source text) on a unit or identifier name; a match drops the line and counts it as noise. */
38
+ noise?: readonly string[];
39
+ /** Units whose FLAG/NOTE lines and JSON records are relays of another log: counted, never judged. */
40
+ routineUnits?: readonly string[];
41
+ /** Units whose own output is dropped while systemd's lines about them are kept. */
42
+ quietUnits?: readonly string[];
43
+ /**
44
+ * Judge `[tag] sentence {json}` lines (Tower's log shape) by their parts instead of by words anywhere in the line.
45
+ * Off by default so Kaiben's journals digest exactly as the Python does.
46
+ */
47
+ taggedLines?: boolean;
48
+ }
49
+ export interface LineFacts {
50
+ /** systemd's own words about a unit rather than the unit's output. */
51
+ manager?: boolean;
52
+ /** The source's own severity word (`error`, `warn`, `info`...). Only `warn` on a tagged line counts: see tagged.ts. */
53
+ level?: string;
54
+ }
55
+ export declare class Digest {
56
+ readonly groups: Map<string, DigestGroup>;
57
+ readonly routine: Map<string, Map<string, [number, Record<string, number>]>>;
58
+ lines: number;
59
+ noiseLines: number;
60
+ firstTs: number | null;
61
+ lastTs: number | null;
62
+ firstCursor: string | null;
63
+ lastCursor: string | null;
64
+ private readonly noise;
65
+ private readonly quiet;
66
+ private readonly routineUnits;
67
+ private readonly taggedLines;
68
+ private readonly openTracebacks;
69
+ constructor(options?: DigestOptions);
70
+ isNoise(unit: string | undefined, identifier: string | undefined): boolean;
71
+ /** One `journalctl -o json` entry. */
72
+ addEntry(entry: JournalEntry): void;
73
+ /** One log line from any source. `ts` is epoch seconds (0 when the source has none). */
74
+ addLine(unit: string, message: string, ts: number, facts?: LineFacts): void;
75
+ private routineCount;
76
+ private addGroup;
77
+ /** Python tracebacks span many lines: fold each into one group named by its exception and last frame. */
78
+ private foldTraceback;
79
+ /** The result, sorted so equal input renders byte-identically: groups by count then tid, routine by name. */
80
+ result(): DigestResult;
81
+ }