@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 +30 -2
- package/dist/classify.d.ts +48 -0
- package/dist/classify.js +81 -0
- package/dist/digest-source.d.ts +99 -0
- package/dist/digest-source.js +130 -0
- package/dist/digest.d.ts +81 -0
- package/dist/digest.js +259 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/judge.d.ts +33 -0
- package/dist/judge.js +198 -0
- package/dist/mask.d.ts +12 -0
- package/dist/mask.js +106 -0
- package/dist/merge.d.ts +12 -0
- package/dist/merge.js +65 -0
- package/dist/render.d.ts +51 -0
- package/dist/render.js +214 -0
- package/dist/sources/collector.d.ts +15 -0
- package/dist/sources/collector.js +166 -0
- package/dist/sources/process.d.ts +2 -0
- package/dist/sources/process.js +78 -0
- package/dist/sources/railway.d.ts +6 -0
- package/dist/sources/railway.js +156 -0
- package/dist/sources/stdin.d.ts +9 -0
- package/dist/sources/stdin.js +56 -0
- package/dist/sources/types.d.ts +67 -0
- package/dist/sources/types.js +13 -0
- package/dist/sources/vercel.d.ts +10 -0
- package/dist/sources/vercel.js +185 -0
- package/dist/tagged.d.ts +37 -0
- package/dist/tagged.js +114 -0
- package/dist/tower-classes.d.ts +28 -0
- package/dist/tower-classes.js +301 -0
- package/dist/window.d.ts +21 -0
- package/dist/window.js +39 -0
- package/package.json +40 -4
package/dist/digest.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The digest accumulator (port of Kaiben's `log_digest.Digest`).
|
|
3
|
+
*
|
|
4
|
+
* Keeps only the lines that can mean trouble, masks their variable parts into
|
|
5
|
+
* templates, groups equal templates with a count, first/last time and one
|
|
6
|
+
* example, and turns routine lines into counts per event type. Routine runs are
|
|
7
|
+
* collapsed into facts true of every collapsed line: a small value set is
|
|
8
|
+
* enumerated with exact counts, a large one becomes "N distinct" (Caveman).
|
|
9
|
+
*
|
|
10
|
+
* Output is deterministic and order-stable: the same input renders
|
|
11
|
+
* byte-identically.
|
|
12
|
+
*
|
|
13
|
+
* Not ported: the circuit-breaker facts (`breaker`) that read Kaiben's own
|
|
14
|
+
* deploy modules. The result still carries `breaker: {}` so the shape matches.
|
|
15
|
+
*/
|
|
16
|
+
import { DECLARED_WARNING, LONG_LINE_CHARS, judgeJson, jsonRecords, looksImportant, pythonJson, unitGroup } from "./judge.js";
|
|
17
|
+
import { EXAMPLE_CHARS, cut, mask, redact, squash, templateId } from "./mask.js";
|
|
18
|
+
import { TEMPLATE_CHARS } from "./mask.js";
|
|
19
|
+
import { judgeTagged, parseTagged, withoutZeroCounters } from "./tagged.js";
|
|
20
|
+
export const SUBJECTS_TRACKED = 6; // Caveman: enumerate a field only while its value set stays small
|
|
21
|
+
/** Units that only ever say "someone logged in" or "a package timer ran": dropped and counted as noise. */
|
|
22
|
+
export const DEFAULT_NOISE = [
|
|
23
|
+
"^ssh(d)?(\\.service)?$", "^sshd-session$", "^systemd-logind(\\.service)?$", "^session-\\d+\\.scope$",
|
|
24
|
+
"^user@\\d+\\.service$", "^user-runtime-dir@\\d+\\.service$", "^user-\\d+\\.slice$", "^run-user-\\d+\\.mount$",
|
|
25
|
+
"^(cron|CRON)(\\.service)?$", "^dbus(\\.service)?$", "^\\(sd-pam\\)$", "^packagekit(d)?(\\.service)?$",
|
|
26
|
+
"^(apt-news|esm-cache|apt-daily|apt-daily-upgrade|motd-news|man-db|logrotate|dpkg-db-backup|e2scrub_all|fstrim|"
|
|
27
|
+
+ "systemd-tmpfiles-clean|update-notifier-download|update-notifier-motd|fwupd-refresh|fwupd|snapd|"
|
|
28
|
+
+ "unattended-upgrades|systemd-timedated|sysstat-collect|sysstat-summary|polkit|systemd-hostnamed)(\\.service)?$",
|
|
29
|
+
];
|
|
30
|
+
// systemd's own routine words about a unit; the rest of such a line is the unit's description, which can say anything
|
|
31
|
+
const SYSTEMD_ROUTINE = /^(Starting|Started|Finished|Stopping|Stopped|Reloading|Reloaded|Created slice|Removed slice|Listening|Reached target|Closed|Queued|Activating|Deactivated|Consumed|\S+: (Deactivated|Consumed|Succeeded|Main process exited, code=exited, status=0\/SUCCESS))/;
|
|
32
|
+
const EXCEPTION_LINE = /^([A-Za-z_][\w.]*(?:Error|Exception|Interrupt|Exit|Warning|Failure))(?::\s?(.*))?$/;
|
|
33
|
+
const FRAME_LINE = /^\s+File "([^"]+)", line \d+, in (\S+)/;
|
|
34
|
+
function compileAll(patterns) {
|
|
35
|
+
return patterns.map((pattern) => new RegExp(pattern));
|
|
36
|
+
}
|
|
37
|
+
function emptySubjects() {
|
|
38
|
+
return Object.create(null);
|
|
39
|
+
}
|
|
40
|
+
export class Digest {
|
|
41
|
+
groups = new Map();
|
|
42
|
+
routine = new Map();
|
|
43
|
+
lines = 0;
|
|
44
|
+
noiseLines = 0;
|
|
45
|
+
firstTs = null;
|
|
46
|
+
lastTs = null;
|
|
47
|
+
firstCursor = null;
|
|
48
|
+
lastCursor = null;
|
|
49
|
+
noise;
|
|
50
|
+
quiet;
|
|
51
|
+
routineUnits;
|
|
52
|
+
taggedLines;
|
|
53
|
+
openTracebacks = new Map();
|
|
54
|
+
constructor(options = {}) {
|
|
55
|
+
this.taggedLines = options.taggedLines ?? false;
|
|
56
|
+
this.noise = compileAll(options.noise ?? DEFAULT_NOISE);
|
|
57
|
+
this.quiet = compileAll(options.quietUnits ?? []);
|
|
58
|
+
this.routineUnits = new Set(options.routineUnits ?? []);
|
|
59
|
+
}
|
|
60
|
+
// -- input
|
|
61
|
+
isNoise(unit, identifier) {
|
|
62
|
+
return [unit, identifier].some((name) => name && this.noise.some((pattern) => pattern.test(name)));
|
|
63
|
+
}
|
|
64
|
+
/** One `journalctl -o json` entry. */
|
|
65
|
+
addEntry(entry) {
|
|
66
|
+
let message = entry.MESSAGE;
|
|
67
|
+
if (Array.isArray(message)) {
|
|
68
|
+
// journald stores non-UTF-8 messages as a byte list
|
|
69
|
+
const bytes = message.filter((value) => Number.isInteger(value) && value >= 0 && value < 256);
|
|
70
|
+
message = Buffer.from(bytes).toString("utf8");
|
|
71
|
+
}
|
|
72
|
+
if (typeof message !== "string")
|
|
73
|
+
return;
|
|
74
|
+
if (entry.__CURSOR) {
|
|
75
|
+
this.firstCursor = this.firstCursor ?? entry.__CURSOR;
|
|
76
|
+
this.lastCursor = entry.__CURSOR;
|
|
77
|
+
}
|
|
78
|
+
const parsed = Number.parseInt(String(entry.__REALTIME_TIMESTAMP ?? 0), 10);
|
|
79
|
+
const ts = Number.isFinite(parsed) ? parsed / 1e6 : 0;
|
|
80
|
+
const identifier = entry.SYSLOG_IDENTIFIER;
|
|
81
|
+
let unit = entry.UNIT || entry._SYSTEMD_UNIT;
|
|
82
|
+
if (!unit)
|
|
83
|
+
unit = entry._TRANSPORT === "kernel" ? "kernel" : (identifier || "?");
|
|
84
|
+
const ownUnit = entry._SYSTEMD_UNIT ?? "";
|
|
85
|
+
if (this.isNoise(unit, identifier)
|
|
86
|
+
|| this.isNoise(ownUnit, undefined)
|
|
87
|
+
|| (!entry.UNIT && this.quiet.some((pattern) => pattern.test(ownUnit)))) {
|
|
88
|
+
this.noiseLines += 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.addLine(unit, message, ts, { manager: Boolean(entry.UNIT) });
|
|
92
|
+
}
|
|
93
|
+
/** One log line from any source. `ts` is epoch seconds (0 when the source has none). */
|
|
94
|
+
addLine(unit, message, ts, facts = {}) {
|
|
95
|
+
const manager = facts.manager ?? false;
|
|
96
|
+
this.lines += 1;
|
|
97
|
+
if (ts) {
|
|
98
|
+
this.firstTs = this.firstTs === null ? ts : Math.min(this.firstTs, ts);
|
|
99
|
+
this.lastTs = this.lastTs === null ? ts : Math.max(this.lastTs, ts);
|
|
100
|
+
}
|
|
101
|
+
const [group, instance] = unitGroup(unit);
|
|
102
|
+
const bare = unit.endsWith(".service") ? unit.slice(0, -".service".length) : unit;
|
|
103
|
+
if (!manager && this.routineUnits.has(bare) && (message.startsWith("FLAG ") || message.startsWith("NOTE "))) {
|
|
104
|
+
this.routineCount(group, "relay", null); // watchdog and breaker flags: ingested from their own log files
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (this.foldTraceback(unit, group, instance, message, ts))
|
|
108
|
+
return;
|
|
109
|
+
const stripped = message.trim();
|
|
110
|
+
if (stripped.startsWith("{") || Array.from(stripped).length > LONG_LINE_CHARS) {
|
|
111
|
+
const records = jsonRecords(stripped);
|
|
112
|
+
if (records === null) {
|
|
113
|
+
// journald splits a huge JSON line into pieces; a piece is not a sentence, and words like
|
|
114
|
+
// "archiveFailed" or "errors" inside it are key names, not failures.
|
|
115
|
+
this.routineCount(group, "json_fragment", null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
for (const data of records) {
|
|
119
|
+
if (this.routineUnits.has(bare)) {
|
|
120
|
+
this.routineCount(group, "relay", null); // watchdog and breaker records: ingested from their own files
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const judged = judgeJson(data);
|
|
124
|
+
if (judged.important && judged.template !== null) {
|
|
125
|
+
this.addGroup(group, instance, judged.event, judged.template, ts, cut(pythonJson(data), EXAMPLE_CHARS * 2));
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
this.routineCount(group, judged.event, judged.subject);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (FRAME_LINE.test(message) || message.startsWith(" ")) {
|
|
134
|
+
this.routineCount(group, "traceback_frame", null); // a frame or code line whose traceback header was interleaved away
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.taggedLines && !manager) {
|
|
138
|
+
const parsed = parseTagged(message);
|
|
139
|
+
if (parsed) {
|
|
140
|
+
const judged = judgeTagged(parsed, facts.level);
|
|
141
|
+
if (!judged.important) {
|
|
142
|
+
this.routineCount(group, judged.template, null);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const template = judged.declaredWarning ? cut(judged.template, TEMPLATE_CHARS - DECLARED_WARNING.length) + DECLARED_WARNING : judged.template;
|
|
146
|
+
this.addGroup(group, instance, "tagged", template, ts, message);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const event = manager ? "systemd" : unit === "kernel" ? "kernel" : "log";
|
|
152
|
+
// The source's own level is NOT a verdict on a plain line: Tower writes ordinary output to stderr, which Vercel
|
|
153
|
+
// and Railway both mark "error" (measured 2026-10-07). Words and fields decide; see tagged.ts.
|
|
154
|
+
const judgedText = this.taggedLines ? withoutZeroCounters(message) : message;
|
|
155
|
+
if (looksImportant(judgedText) && !(manager && SYSTEMD_ROUTINE.test(message))) {
|
|
156
|
+
this.addGroup(group, instance, event, mask(message), ts, message);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const word = message.split(" ");
|
|
160
|
+
const routineEvent = manager
|
|
161
|
+
? `systemd ${cut((word.length > 1 && word[0].endsWith(":") ? word[1] : word[0]).replace(/^[.:]+|[.:]+$/g, ""), 20)}`
|
|
162
|
+
: "text";
|
|
163
|
+
this.routineCount(group, routineEvent, null);
|
|
164
|
+
}
|
|
165
|
+
// -- internals
|
|
166
|
+
routineCount(group, event, subject) {
|
|
167
|
+
let events = this.routine.get(group);
|
|
168
|
+
if (!events)
|
|
169
|
+
this.routine.set(group, (events = new Map()));
|
|
170
|
+
let record = events.get(event);
|
|
171
|
+
if (!record)
|
|
172
|
+
events.set(event, (record = [0, emptySubjects()]));
|
|
173
|
+
record[0] += 1;
|
|
174
|
+
if (subject !== null) {
|
|
175
|
+
const subjects = record[1];
|
|
176
|
+
if (subject in subjects || Object.keys(subjects).length < SUBJECTS_TRACKED)
|
|
177
|
+
subjects[subject] = (subjects[subject] ?? 0) + 1;
|
|
178
|
+
else
|
|
179
|
+
subjects["…"] = (subjects["…"] ?? 0) + 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
addGroup(group, instance, event, template, ts, message) {
|
|
183
|
+
const tid = templateId(group, event, template);
|
|
184
|
+
let record = this.groups.get(tid);
|
|
185
|
+
if (!record) {
|
|
186
|
+
record = {
|
|
187
|
+
tid, unit: group, event, template, count: 0,
|
|
188
|
+
first: ts || null, last: ts || null,
|
|
189
|
+
example: cut(redact(squash(message)), EXAMPLE_CHARS),
|
|
190
|
+
instances: [],
|
|
191
|
+
};
|
|
192
|
+
this.groups.set(tid, record);
|
|
193
|
+
}
|
|
194
|
+
record.count += 1;
|
|
195
|
+
if (ts) {
|
|
196
|
+
record.first = Math.min(record.first ?? ts, ts);
|
|
197
|
+
record.last = Math.max(record.last ?? ts, ts);
|
|
198
|
+
}
|
|
199
|
+
if (instance && !record.instances.includes(instance) && record.instances.length < 64)
|
|
200
|
+
record.instances.push(instance);
|
|
201
|
+
}
|
|
202
|
+
/** Python tracebacks span many lines: fold each into one group named by its exception and last frame. */
|
|
203
|
+
foldTraceback(unit, group, instance, message, ts) {
|
|
204
|
+
const state = this.openTracebacks.get(unit);
|
|
205
|
+
if (message.startsWith("Traceback (most recent call last)")) {
|
|
206
|
+
this.openTracebacks.set(unit, { frame: null });
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
if (!state)
|
|
210
|
+
return false;
|
|
211
|
+
const frame = FRAME_LINE.exec(message);
|
|
212
|
+
if (frame) {
|
|
213
|
+
state.frame = `${frame[1].split("/").pop()}:${frame[2]}`;
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (/^[ \t]/.test(message) || message.startsWith("During handling") || message.startsWith("The above exception") || !message.trim()) {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
if (message.trimStart().startsWith("{"))
|
|
220
|
+
return false; // other threads keep logging JSON mid-traceback: judge it, keep it open
|
|
221
|
+
const exception = EXCEPTION_LINE.exec(message.trim());
|
|
222
|
+
if (exception) {
|
|
223
|
+
const where = state.frame ? ` @${state.frame}` : "";
|
|
224
|
+
const named = `${exception[1]}: ${exception[2] ?? ""}`.replace(/[: ]+$/, "");
|
|
225
|
+
const template = mask(named) + where;
|
|
226
|
+
this.addGroup(group, instance, "traceback", cut(template, TEMPLATE_CHARS), ts, message);
|
|
227
|
+
state.frame = null; // a chained exception may follow; its own frames come first
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
this.openTracebacks.delete(unit); // anything else ends the traceback; judge the line normally
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
// -- output
|
|
234
|
+
/** The result, sorted so equal input renders byte-identically: groups by count then tid, routine by name. */
|
|
235
|
+
result() {
|
|
236
|
+
const routine = Object.create(null);
|
|
237
|
+
for (const group of [...this.routine.keys()].sort()) {
|
|
238
|
+
const events = Object.create(null);
|
|
239
|
+
for (const event of [...this.routine.get(group).keys()].sort()) {
|
|
240
|
+
const [count, subjects] = this.routine.get(group).get(event);
|
|
241
|
+
const sorted = emptySubjects();
|
|
242
|
+
for (const name of Object.keys(subjects).sort())
|
|
243
|
+
sorted[name] = subjects[name];
|
|
244
|
+
events[event] = [count, sorted];
|
|
245
|
+
}
|
|
246
|
+
routine[group] = events;
|
|
247
|
+
}
|
|
248
|
+
const groups = [...this.groups.values()].sort((a, b) => b.count - a.count || (a.tid < b.tid ? -1 : a.tid > b.tid ? 1 : 0));
|
|
249
|
+
return {
|
|
250
|
+
lines: this.lines,
|
|
251
|
+
noise: this.noiseLines,
|
|
252
|
+
span: [this.firstTs, this.lastTs],
|
|
253
|
+
cursors: [this.firstCursor, this.lastCursor],
|
|
254
|
+
groups,
|
|
255
|
+
routine,
|
|
256
|
+
breaker: {},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { mask, redact, templateId, cut, squash, TEMPLATE_CHARS, EXAMPLE_CHARS } from "./mask.js";
|
|
2
|
+
export { DECLARED_WARNING, LONG_LINE_CHARS, judgeJson, jsonEvent, jsonRecords, looksImportant, pythonJson, unitGroup, type JsonJudgement, type JsonObject, } from "./judge.js";
|
|
3
|
+
export { DEFAULT_NOISE, Digest, SUBJECTS_TRACKED, type DigestGroup, type DigestOptions, type DigestResult, type JournalEntry, type LineFacts, type RoutineEvents, } from "./digest.js";
|
|
4
|
+
export { mergeResults, type MergeInput, type MergedResult } from "./merge.js";
|
|
5
|
+
export { CLASS_ORDER, ClassTable, type Classify, type ClassInfo, type ClassTableData, type Severity, type TablePattern, type Verdict, } from "./classify.js";
|
|
6
|
+
export { clock, fleetView, numberText, renderCapped, renderFleet, renderLocal, routineText, subjectsText, timeRange, type CappedOptions, type FleetView, } from "./render.js";
|
|
7
|
+
export { TOWER_CLASSES, TOWER_PATTERNS, TOWER_TABLE, towerClassTable } from "./tower-classes.js";
|
|
8
|
+
export { WindowError, isoOf, parseWindow, relativeSeconds, type LogWindow } from "./window.js";
|
|
9
|
+
export { DEFAULT_MAX_BYTES, SOURCE_NAMES, digestSource, sourceFamily, type DigestAnswer, type DigestEnvironment, type DigestOutcome, type DigestRequest, type DigestTemplate, type RawAnswer, type Refusal, } from "./digest-source.js";
|
|
10
|
+
export { SourceRefusal, type CliRun, type LogFn, type RunCli, type SourceContext, type SourceLine, type SourceRead } from "./sources/types.js";
|
|
11
|
+
export { nodeRunCli } from "./sources/process.js";
|
|
12
|
+
export { readCollector, receiptLine, streamLines, windowShare } from "./sources/collector.js";
|
|
13
|
+
export { RAILWAY_ENVIRONMENT, RAILWAY_PROJECT_ID, railwayRefusal, readRailway } from "./sources/railway.js";
|
|
14
|
+
export { VERCEL_PROJECT, VERCEL_SCOPE, readDashboard, routeTemplate, vercelRefusal } from "./sources/vercel.js";
|
|
15
|
+
export { readStdin } from "./sources/stdin.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { mask, redact, templateId, cut, squash, TEMPLATE_CHARS, EXAMPLE_CHARS } from "./mask.js";
|
|
2
|
+
export { DECLARED_WARNING, LONG_LINE_CHARS, judgeJson, jsonEvent, jsonRecords, looksImportant, pythonJson, unitGroup, } from "./judge.js";
|
|
3
|
+
export { DEFAULT_NOISE, Digest, SUBJECTS_TRACKED, } from "./digest.js";
|
|
4
|
+
export { mergeResults } from "./merge.js";
|
|
5
|
+
export { CLASS_ORDER, ClassTable, } from "./classify.js";
|
|
6
|
+
export { clock, fleetView, numberText, renderCapped, renderFleet, renderLocal, routineText, subjectsText, timeRange, } from "./render.js";
|
|
7
|
+
export { TOWER_CLASSES, TOWER_PATTERNS, TOWER_TABLE, towerClassTable } from "./tower-classes.js";
|
|
8
|
+
export { WindowError, isoOf, parseWindow, relativeSeconds } from "./window.js";
|
|
9
|
+
export { DEFAULT_MAX_BYTES, SOURCE_NAMES, digestSource, sourceFamily, } from "./digest-source.js";
|
|
10
|
+
export { SourceRefusal } from "./sources/types.js";
|
|
11
|
+
export { nodeRunCli } from "./sources/process.js";
|
|
12
|
+
export { readCollector, receiptLine, streamLines, windowShare } from "./sources/collector.js";
|
|
13
|
+
export { RAILWAY_ENVIRONMENT, RAILWAY_PROJECT_ID, railwayRefusal, readRailway } from "./sources/railway.js";
|
|
14
|
+
export { VERCEL_PROJECT, VERCEL_SCOPE, readDashboard, routeTemplate, vercelRefusal } from "./sources/vercel.js";
|
|
15
|
+
export { readStdin } from "./sources/stdin.js";
|
package/dist/judge.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare const LONG_LINE_CHARS = 4000;
|
|
2
|
+
/**
|
|
3
|
+
* Appended to a JSON line's template when the line says `"level": "WARNING"`.
|
|
4
|
+
* The classifier strips it before matching patterns, and an otherwise unknown
|
|
5
|
+
* template with it is counted (WARNING), not escalated.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DECLARED_WARNING = " level=WARNING";
|
|
8
|
+
export type JsonObject = Record<string, unknown>;
|
|
9
|
+
/** True when the line contains a trouble word (literal pre-filter, then the regex). */
|
|
10
|
+
export declare function looksImportant(text: string): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Every JSON object on one line: unbuffered writers sometimes glue two records
|
|
13
|
+
* together (`{...}{...}`). Null when the line is not entirely JSON objects.
|
|
14
|
+
*/
|
|
15
|
+
export declare function jsonRecords(text: string): JsonObject[] | null;
|
|
16
|
+
/** `rs-gap@caseys.service` becomes (`rs-gap@*`, `caseys`); `kaiben-shard.service` becomes (`kaiben-shard`, null). */
|
|
17
|
+
export declare function unitGroup(unit: string | null | undefined): [string, string | null];
|
|
18
|
+
/** The event name of one JSON log line: its "event" field, else its first key that is not a timestamp. */
|
|
19
|
+
export declare function jsonEvent(data: JsonObject): [string, boolean];
|
|
20
|
+
export interface JsonJudgement {
|
|
21
|
+
event: string;
|
|
22
|
+
important: boolean;
|
|
23
|
+
template: string | null;
|
|
24
|
+
subject: string | null;
|
|
25
|
+
}
|
|
26
|
+
/** (event, important, template, subject) for one JSON log line. */
|
|
27
|
+
export declare function judgeJson(data: JsonObject): JsonJudgement;
|
|
28
|
+
/**
|
|
29
|
+
* `json.dumps` as Python writes it (`", "` and `": "` separators, non-ASCII as
|
|
30
|
+
* `\uXXXX`), so a stored example matches the Python digest byte for byte. A
|
|
31
|
+
* float with a zero fraction prints as an integer here; JS cannot tell them apart.
|
|
32
|
+
*/
|
|
33
|
+
export declare function pythonJson(value: unknown): string;
|
package/dist/judge.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which lines can mean trouble, and what a JSON log line says (port of the
|
|
3
|
+
* important-line half of Kaiben's `log_digest.py`).
|
|
4
|
+
*
|
|
5
|
+
* Caveman's cheap literal pre-filter first (most lines contain none of the
|
|
6
|
+
* literals, so the regex runs on few lines), then the regex; JSON lines are
|
|
7
|
+
* judged by their event name and state fields, not by words inside key names.
|
|
8
|
+
*/
|
|
9
|
+
import { TEMPLATE_CHARS, mask } from "./mask.js";
|
|
10
|
+
export const LONG_LINE_CHARS = 4000;
|
|
11
|
+
const IMPORTANT_LITERALS = [
|
|
12
|
+
"error", "fail", "traceback", "exception", "refused", "denied", "timeout", "timed out", "warn",
|
|
13
|
+
"fatal", "panic", "crit", "killed", "out of memory", "oom", "no space", "unreachable", "captcha",
|
|
14
|
+
"forbidden", "too many requests", "403", "429", "50", "scheduled restart", "abort", "blocked",
|
|
15
|
+
];
|
|
16
|
+
const IMPORTANT_TEXT = /\b(error|errors|fatal|panic|exception|traceback|fail|failed|failure|warn|warning|crit|critical|alert|emerg|refused|denied|timeout|timed out|unreachable|killed|oom-kill|out of memory|no space left|captcha|forbidden|too many requests|aborted|blocked)\b|\bHTTP(?: Error)?[ /:](?:403|429|5\d\d)\b|\bhttp[=: ](?:403|429|5\d\d)\b|Scheduled restart job|status=\d+\/[A-Z]+/i;
|
|
17
|
+
// Event names are judged by whole words (camelCase and snake_case split):
|
|
18
|
+
// "newsroom" must not match "oom".
|
|
19
|
+
const IMPORTANT_EVENT_WORDS = new Set([
|
|
20
|
+
"fail", "failed", "failure", "error", "errors", "exception", "traceback", "refused", "denied",
|
|
21
|
+
"blocked", "captcha", "timeout", "timed", "exhausted", "fatal", "panic", "crash", "crashed", "oom",
|
|
22
|
+
"killed", "abort", "aborted", "limited", "throttled", "warn", "warning", "retry", "stalled",
|
|
23
|
+
"skipped",
|
|
24
|
+
]);
|
|
25
|
+
const IMPORTANT_COMPOUND_EVENTS = new Set(["circuit_open", "needs_human", "stop_marker"]);
|
|
26
|
+
const EVENT_WORD = /[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+/g;
|
|
27
|
+
const BAD_STATE = /^(fail|failed|failure|error|errored|fatal|blocked|refused|timeout|timed_out|crash|crashed|killed|aborted|needs_human|stopped)$/i;
|
|
28
|
+
const STATE_FIELDS = ["status", "outcome", "result", "level", "verdict", "state"];
|
|
29
|
+
/**
|
|
30
|
+
* Appended to a JSON line's template when the line says `"level": "WARNING"`.
|
|
31
|
+
* The classifier strips it before matching patterns, and an otherwise unknown
|
|
32
|
+
* template with it is counted (WARNING), not escalated.
|
|
33
|
+
*/
|
|
34
|
+
export const DECLARED_WARNING = " level=WARNING";
|
|
35
|
+
const EXPLAIN_FIELDS = [
|
|
36
|
+
"status", "outcome", "result", "http", "code", "step", "what", "reason", "error", "exception",
|
|
37
|
+
"message", "msg", "detail",
|
|
38
|
+
];
|
|
39
|
+
const META_KEYS = new Set(["ts", "at", "time", "timestamp", "level", "t"]);
|
|
40
|
+
/** True when the line contains a trouble word (literal pre-filter, then the regex). */
|
|
41
|
+
export function looksImportant(text) {
|
|
42
|
+
const lower = text.toLowerCase();
|
|
43
|
+
if (!IMPORTANT_LITERALS.some((literal) => lower.includes(literal)))
|
|
44
|
+
return false;
|
|
45
|
+
return IMPORTANT_TEXT.test(text);
|
|
46
|
+
}
|
|
47
|
+
/** The end index (exclusive) of the JSON object starting at `start`, or -1 when it never closes. */
|
|
48
|
+
function objectEnd(text, start) {
|
|
49
|
+
let depth = 0;
|
|
50
|
+
let inString = false;
|
|
51
|
+
for (let index = start; index < text.length; index += 1) {
|
|
52
|
+
const char = text[index];
|
|
53
|
+
if (inString) {
|
|
54
|
+
if (char === "\\")
|
|
55
|
+
index += 1;
|
|
56
|
+
else if (char === '"')
|
|
57
|
+
inString = false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (char === '"')
|
|
61
|
+
inString = true;
|
|
62
|
+
else if (char === "{")
|
|
63
|
+
depth += 1;
|
|
64
|
+
else if (char === "}") {
|
|
65
|
+
depth -= 1;
|
|
66
|
+
if (depth === 0)
|
|
67
|
+
return index + 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return -1;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Every JSON object on one line: unbuffered writers sometimes glue two records
|
|
74
|
+
* together (`{...}{...}`). Null when the line is not entirely JSON objects.
|
|
75
|
+
*/
|
|
76
|
+
export function jsonRecords(text) {
|
|
77
|
+
const records = [];
|
|
78
|
+
let position = 0;
|
|
79
|
+
while (position < text.length) {
|
|
80
|
+
if (text[position] !== "{")
|
|
81
|
+
return null;
|
|
82
|
+
const end = objectEnd(text, position);
|
|
83
|
+
if (end < 0)
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
const data = JSON.parse(text.slice(position, end));
|
|
87
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
88
|
+
return null;
|
|
89
|
+
records.push(data);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
position = end;
|
|
95
|
+
while (position < text.length && " \t,".includes(text[position]))
|
|
96
|
+
position += 1;
|
|
97
|
+
}
|
|
98
|
+
return records.length > 0 ? records : null;
|
|
99
|
+
}
|
|
100
|
+
/** `rs-gap@caseys.service` becomes (`rs-gap@*`, `caseys`); `kaiben-shard.service` becomes (`kaiben-shard`, null). */
|
|
101
|
+
export function unitGroup(unit) {
|
|
102
|
+
let name = unit || "?";
|
|
103
|
+
if (name.endsWith(".service"))
|
|
104
|
+
name = name.slice(0, -".service".length);
|
|
105
|
+
const at = name.indexOf("@");
|
|
106
|
+
if (at >= 0)
|
|
107
|
+
return [`${name.slice(0, at)}@*`, name.slice(at + 1)];
|
|
108
|
+
return [name, null];
|
|
109
|
+
}
|
|
110
|
+
/** The event name of one JSON log line: its "event" field, else its first key that is not a timestamp. */
|
|
111
|
+
export function jsonEvent(data) {
|
|
112
|
+
const event = data["event"];
|
|
113
|
+
if (typeof event === "string" && event)
|
|
114
|
+
return [event, false];
|
|
115
|
+
for (const key of Object.keys(data)) {
|
|
116
|
+
if (!META_KEYS.has(key))
|
|
117
|
+
return [key, true];
|
|
118
|
+
}
|
|
119
|
+
return ["json", false];
|
|
120
|
+
}
|
|
121
|
+
function shortValue(value) {
|
|
122
|
+
if (typeof value === "boolean" || value === null || value === undefined)
|
|
123
|
+
return JSON.stringify(value ?? null);
|
|
124
|
+
if (typeof value === "number") {
|
|
125
|
+
if (Number.isInteger(value) && value >= 100 && value <= 599)
|
|
126
|
+
return String(value); // an HTTP-like status
|
|
127
|
+
return Number.isInteger(value) && value >= -9 && value <= 9 ? String(value) : "#";
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(value))
|
|
130
|
+
return "<list>";
|
|
131
|
+
if (typeof value === "object")
|
|
132
|
+
return "<dict>";
|
|
133
|
+
return mask(value);
|
|
134
|
+
}
|
|
135
|
+
function isEmptyError(value) {
|
|
136
|
+
if (value === null || value === undefined || value === "" || value === 0 || value === false)
|
|
137
|
+
return true;
|
|
138
|
+
if (Array.isArray(value))
|
|
139
|
+
return value.length === 0;
|
|
140
|
+
if (typeof value === "object")
|
|
141
|
+
return Object.keys(value).length === 0;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
/** (event, important, template, subject) for one JSON log line. */
|
|
145
|
+
export function judgeJson(data) {
|
|
146
|
+
const [event, eventIsKey] = jsonEvent(data);
|
|
147
|
+
const subjectValue = eventIsKey ? data[event] : (data["kind"] || data["store"]);
|
|
148
|
+
const words = new Set((event.match(EVENT_WORD) ?? []).map((word) => word.toLowerCase()));
|
|
149
|
+
let important = [...words].some((word) => IMPORTANT_EVENT_WORDS.has(word)) || IMPORTANT_COMPOUND_EVENTS.has(event);
|
|
150
|
+
for (const field of STATE_FIELDS) {
|
|
151
|
+
const value = data[field];
|
|
152
|
+
if (typeof value === "string" && BAD_STATE.test(value))
|
|
153
|
+
important = true;
|
|
154
|
+
}
|
|
155
|
+
for (const field of ["http", "code", "status"]) {
|
|
156
|
+
const value = data[field];
|
|
157
|
+
if (typeof value === "number" && Number.isInteger(value) && (value === 403 || value === 429 || (value >= 500 && value <= 599))) {
|
|
158
|
+
important = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!isEmptyError(data["error"]))
|
|
162
|
+
important = true;
|
|
163
|
+
if (!important) {
|
|
164
|
+
const subject = typeof subjectValue === "string" && Array.from(subjectValue).length <= 24 ? subjectValue : null;
|
|
165
|
+
return { event, important: false, template: null, subject };
|
|
166
|
+
}
|
|
167
|
+
const parts = [event];
|
|
168
|
+
if (eventIsKey && typeof subjectValue === "string" && Array.from(subjectValue).length <= 40)
|
|
169
|
+
parts.push(mask(subjectValue));
|
|
170
|
+
for (const field of EXPLAIN_FIELDS) {
|
|
171
|
+
const value = data[field];
|
|
172
|
+
if (field in data && field !== event && value !== null && value !== undefined && value !== "") {
|
|
173
|
+
parts.push(`${field}=${shortValue(value)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let template = Array.from(parts.join(" ")).slice(0, TEMPLATE_CHARS - DECLARED_WARNING.length).join("");
|
|
177
|
+
const level = String(data["level"] ?? "").trim().toLowerCase();
|
|
178
|
+
if (level === "warning" || level === "warn")
|
|
179
|
+
template += DECLARED_WARNING;
|
|
180
|
+
return { event, important: true, template, subject: null };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* `json.dumps` as Python writes it (`", "` and `": "` separators, non-ASCII as
|
|
184
|
+
* `\uXXXX`), so a stored example matches the Python digest byte for byte. A
|
|
185
|
+
* float with a zero fraction prints as an integer here; JS cannot tell them apart.
|
|
186
|
+
*/
|
|
187
|
+
export function pythonJson(value) {
|
|
188
|
+
if (value === null || value === undefined)
|
|
189
|
+
return "null";
|
|
190
|
+
if (typeof value === "string")
|
|
191
|
+
return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
192
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
193
|
+
return String(value);
|
|
194
|
+
if (Array.isArray(value))
|
|
195
|
+
return `[${value.map(pythonJson).join(", ")}]`;
|
|
196
|
+
const entries = Object.entries(value).map(([key, item]) => `${pythonJson(key)}: ${pythonJson(item)}`);
|
|
197
|
+
return `{${entries.join(", ")}}`;
|
|
198
|
+
}
|
package/dist/mask.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const TEMPLATE_CHARS = 160;
|
|
2
|
+
export declare const EXAMPLE_CHARS = 200;
|
|
3
|
+
/** First `limit` code points, like Python's `text[:limit]`. */
|
|
4
|
+
export declare function cut(text: string, limit: number): string;
|
|
5
|
+
/** Python's `" ".join(text.split())`: whitespace runs become one space. */
|
|
6
|
+
export declare function squash(text: string): string;
|
|
7
|
+
/** Tokens and keys masked; the rest untouched. Applied to every example before it is stored or printed. */
|
|
8
|
+
export declare function redact(text: string): string;
|
|
9
|
+
/** The template of one message: every variable part replaced by a placeholder, then cut to 160 characters. */
|
|
10
|
+
export declare function mask(input: unknown): string;
|
|
11
|
+
/** Eight hex characters of sha1(unit group, event, template). Equal problems on two machines share a tid. */
|
|
12
|
+
export declare function templateId(unitGroup: string, event: string, template: string): string;
|
package/dist/mask.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masking: the template of one log message (port of Kaiben's `log_digest.mask`).
|
|
3
|
+
*
|
|
4
|
+
* Plain words: every part of a line that changes from one occurrence to the next
|
|
5
|
+
* (an address, an id, a time, a number) becomes a placeholder, so two lines that
|
|
6
|
+
* say the same thing become the same template and can be counted instead of read.
|
|
7
|
+
*
|
|
8
|
+
* Fixed masking order, so a memory address can never fake a status code
|
|
9
|
+
* (`0x7f...403...`): memory addresses, URLs, UUIDs, timestamps, IPs, long hex,
|
|
10
|
+
* long ids, absolute paths, long or numeric quoted strings, then every other
|
|
11
|
+
* number except a status code in a status position (`HTTP 403`, `status=500`,
|
|
12
|
+
* `[Errno 28]`).
|
|
13
|
+
*
|
|
14
|
+
* Known difference from the Python original: `\b`, `\w` and `\d` here are ASCII
|
|
15
|
+
* only, Python's are Unicode. A template next to a non-ASCII letter can differ.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
export const TEMPLATE_CHARS = 160;
|
|
19
|
+
export const EXAMPLE_CHARS = 200;
|
|
20
|
+
const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
21
|
+
const SECRET_PARAMETER = /\b(token|key|apikey|api_key|secret|signature|password|sig|x-amz-[a-z-]+)=([^&\s"']+)/gi;
|
|
22
|
+
const BEARER = /(bearer\s+)\S+/gi;
|
|
23
|
+
// A secret written as a JSON field, which the `name=value` rule cannot see.
|
|
24
|
+
const SECRET_JSON_FIELD = /("(?:token|secret|password|passwd|api_key|apikey|authorization|access_token|refresh_token|client_secret)"\s*:\s*")[^"]*/gi;
|
|
25
|
+
// Token-shaped strings by their well-known prefixes, wherever they appear.
|
|
26
|
+
const TOKEN_SHAPES = [
|
|
27
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, // JWT
|
|
28
|
+
/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, // OpenAI-style keys
|
|
29
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub tokens
|
|
30
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
|
|
31
|
+
/\bsb_(?:secret|publishable)_[A-Za-z0-9_-]{8,}/g, // Supabase keys
|
|
32
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens
|
|
33
|
+
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
|
|
34
|
+
/\bnpm_[A-Za-z0-9]{30,}/g,
|
|
35
|
+
];
|
|
36
|
+
const MEMORY_ADDRESS = /\b0x[0-9a-fA-F]+\b/g;
|
|
37
|
+
const URL_PATTERN = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/\s"'<>?#:]+)[^\s"'<>]*/g;
|
|
38
|
+
const UUID = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g;
|
|
39
|
+
const TIMESTAMP = /\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\b|\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b/g;
|
|
40
|
+
const IP_ADDRESS = /\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g;
|
|
41
|
+
const LONG_HEX = /\b(?=[0-9a-fA-F]*[0-9])(?=[0-9a-fA-F]*[a-fA-F])[0-9a-fA-F]{8,}\b/g;
|
|
42
|
+
// 4+ digits: an id, not a unit name.
|
|
43
|
+
const LONG_ID = /\b(?=(?:[A-Za-z_-]*\d){4})(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{20,}={0,2}/g;
|
|
44
|
+
// A ':' or ',' inside a segment, never a trailing one.
|
|
45
|
+
const PATH = /(?<![\w.<>/])(?:\/(?:[\w.@%+~-]|[:,](?=\w))+){2,}\/?/g;
|
|
46
|
+
const QUOTED = /'([^'\n]{0,300})'|"([^"\n]{0,300})"/g;
|
|
47
|
+
const NUMBER = /(?<![A-Za-z_\d#])\d+(?:[.,]\d+)*/g;
|
|
48
|
+
// A number right after one of these is a status, not a quantity: kept so
|
|
49
|
+
// "HTTP 403" and "HTTP 404" stay different templates.
|
|
50
|
+
const STATUS_BEFORE = /(?:\bHTTP(?: Error)?[ /:]|\bHTTP\/\d\.\d |\bhttp[=: ]|\bstatus[=: ]|\bcode[=: ]|\[Errno |\bexit(?: code| status)? |\bsignal |\bresponse |\breturned )$/i;
|
|
51
|
+
// The longest alternative above is 12 characters, so 17 characters of context
|
|
52
|
+
// (one more than Python's 16, so `\b` sees the real previous character) decide it.
|
|
53
|
+
const STATUS_WINDOW = 17;
|
|
54
|
+
/** First `limit` code points, like Python's `text[:limit]`. */
|
|
55
|
+
export function cut(text, limit) {
|
|
56
|
+
if (text.length <= limit)
|
|
57
|
+
return text;
|
|
58
|
+
return Array.from(text).slice(0, limit).join("");
|
|
59
|
+
}
|
|
60
|
+
/** Python's `" ".join(text.split())`: whitespace runs become one space. */
|
|
61
|
+
export function squash(text) {
|
|
62
|
+
return text.split(/\s+/).filter(Boolean).join(" ");
|
|
63
|
+
}
|
|
64
|
+
/** Tokens and keys masked; the rest untouched. Applied to every example before it is stored or printed. */
|
|
65
|
+
export function redact(text) {
|
|
66
|
+
let out = text.replace(BEARER, "$1<redacted>").replace(SECRET_PARAMETER, "$1=<redacted>");
|
|
67
|
+
out = out.replace(SECRET_JSON_FIELD, "$1<redacted>");
|
|
68
|
+
for (const shape of TOKEN_SHAPES)
|
|
69
|
+
out = out.replace(shape, "<redacted>");
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function keepStatusNumbers(match, offset, whole) {
|
|
73
|
+
const before = whole.slice(Math.max(0, offset - STATUS_WINDOW), offset);
|
|
74
|
+
return STATUS_BEFORE.test(before) ? match : "#";
|
|
75
|
+
}
|
|
76
|
+
function maskPath(path) {
|
|
77
|
+
const name = path.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
78
|
+
return name.endsWith(".py") ? `<path>/${name}` : "<path>";
|
|
79
|
+
}
|
|
80
|
+
function maskQuoted(match, single, double) {
|
|
81
|
+
const inner = single !== undefined ? single : (double ?? "");
|
|
82
|
+
const quote = single !== undefined ? "'" : '"';
|
|
83
|
+
if (Array.from(inner).length > 40 || /\d|\/|</.test(inner))
|
|
84
|
+
return `${quote}<q>${quote}`;
|
|
85
|
+
return match;
|
|
86
|
+
}
|
|
87
|
+
/** The template of one message: every variable part replaced by a placeholder, then cut to 160 characters. */
|
|
88
|
+
export function mask(input) {
|
|
89
|
+
let text = String(input).replace(ANSI, "");
|
|
90
|
+
text = redact(text);
|
|
91
|
+
text = text.replace(MEMORY_ADDRESS, "<addr>");
|
|
92
|
+
text = text.replace(URL_PATTERN, (_m, host) => `<url:${host.toLowerCase()}>`);
|
|
93
|
+
text = text.replace(UUID, "<uuid>");
|
|
94
|
+
text = text.replace(TIMESTAMP, "<ts>");
|
|
95
|
+
text = text.replace(IP_ADDRESS, "<ip>");
|
|
96
|
+
text = text.replace(LONG_HEX, "<hex>");
|
|
97
|
+
text = text.replace(LONG_ID, "<id>");
|
|
98
|
+
text = text.replace(PATH, maskPath);
|
|
99
|
+
text = text.replace(QUOTED, maskQuoted);
|
|
100
|
+
text = text.replace(NUMBER, keepStatusNumbers);
|
|
101
|
+
return cut(squash(text), TEMPLATE_CHARS);
|
|
102
|
+
}
|
|
103
|
+
/** Eight hex characters of sha1(unit group, event, template). Equal problems on two machines share a tid. */
|
|
104
|
+
export function templateId(unitGroup, event, template) {
|
|
105
|
+
return createHash("sha1").update(`${unitGroup}\x1f${event}\x1f${template}`, "utf8").digest("hex").slice(0, 8);
|
|
106
|
+
}
|
package/dist/merge.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several digest results as one (port of Kaiben's `merge_results`): counts
|
|
3
|
+
* summed, times widened, instance sets united. The next slice, a fleet view of
|
|
4
|
+
* every machine's collector digest, merges per-machine results with this.
|
|
5
|
+
*/
|
|
6
|
+
import { type DigestGroup, type DigestResult } from "./digest.js";
|
|
7
|
+
/** A result whose groups may be a list (probe output) or keyed by tid (a stored bucket). */
|
|
8
|
+
export type MergeInput = Omit<Partial<DigestResult>, "groups"> & {
|
|
9
|
+
groups?: DigestGroup[] | Record<string, DigestGroup>;
|
|
10
|
+
};
|
|
11
|
+
export type MergedResult = DigestResult;
|
|
12
|
+
export declare function mergeResults(results: readonly MergeInput[], registry?: Record<string, Partial<DigestGroup>>): MergedResult;
|