deepdebug-local-agent 1.7.0 → 1.8.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.
@@ -0,0 +1,179 @@
1
+ // F3-11b: the log sources (file tail, Loki, stream) with real files, a fake fetch and real streams.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { PassThrough, Readable } from "node:stream";
8
+ import { createFileSource } from "../../src/incident-detector/sources/file-source.js";
9
+ import { createLokiSource } from "../../src/incident-detector/sources/loki-source.js";
10
+ import { createStreamSource } from "../../src/incident-detector/sources/stream-source.js";
11
+
12
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-src-"));
13
+ const collect = () => { const got = []; return { got, handler: async (entries) => { got.push(...entries); } }; };
14
+
15
+ // ------------------------------------------------------------------------------------------ file
16
+
17
+ test("file: only NEW lines by default; fromStart reads what is there; a record spanning appends is one entry; the file name is the service", async () => {
18
+ const dir = tmp(); const f = path.join(dir, "billing-api.log");
19
+ fs.writeFileSync(f, "2026-10-29 10:00:00 ERROR old one\n");
20
+ const a = collect();
21
+ const src = createFileSource({ filePath: f, idleFlushMs: 60_000 });
22
+ src.start(a.handler); await src.poll();
23
+ assert.equal(a.got.length, 0, "history is not replayed");
24
+ fs.appendFileSync(f, "2026-10-29 10:00:01 ERROR c.a.Foo : failed 7\njava.lang.IllegalStateException: x\n");
25
+ await src.poll();
26
+ fs.appendFileSync(f, "\tat com.x.A.m(A.java:1)\n2026-10-29 10:00:02 INFO ok\n");
27
+ await src.poll();
28
+ src.stop();
29
+ assert.equal(a.got.length, 1, "the INFO line closed the ERROR record; the INFO record is still in progress");
30
+ assert.equal(a.got[0].service, "billing-api");
31
+ assert.match(a.got[0].stacktrace, /IllegalStateException[\s\S]*A\.java:1/);
32
+ const b = collect();
33
+ const s2 = createFileSource({ filePath: f, fromStart: true, idleFlushMs: 60_000 });
34
+ s2.start(b.handler); await s2.poll(); s2.stop();
35
+ assert.equal(b.got.length, 2);
36
+ });
37
+
38
+ test("file: rotation (new inode) and truncation restart at 0; a missing file is waited for; a partial last line waits for its newline; CRLF and multibyte are intact", async () => {
39
+ const dir = tmp(); const f = path.join(dir, "x.log");
40
+ const a = collect();
41
+ const src = createFileSource({ filePath: f, fromStart: true, idleFlushMs: 0 });
42
+ src.start(a.handler);
43
+ await src.poll(); // no file yet
44
+ fs.writeFileSync(f, "2026-10-29 10:00:00 ERROR one é💥\r\n2026-10-29 10:00:01 ERROR par");
45
+ await src.poll();
46
+ assert.deepEqual(a.got.map((e) => e.message), ["2026-10-29 10:00:00 ERROR one é💥"], "the partial line is not an entry yet");
47
+ fs.appendFileSync(f, "tial\n");
48
+ await src.poll();
49
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:01 ERROR partial");
50
+ fs.renameSync(f, f + ".1");
51
+ fs.writeFileSync(f, "2026-10-29 10:00:02 ERROR after rotation\n");
52
+ await src.poll();
53
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:02 ERROR after rotation");
54
+ fs.writeFileSync(f, "2026-10-29 10:00:03 ERROR t\n"); // truncate + rewrite (shorter than the offset)
55
+ await src.poll();
56
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:03 ERROR t");
57
+ src.stop();
58
+ });
59
+
60
+ test("file: an over-long line is cut at maxLineBytes (the rest to its newline is discarded and counted); reading is bounded per tick", async () => {
61
+ const dir = tmp(); const f = path.join(dir, "y.log");
62
+ fs.writeFileSync(f, "");
63
+ const a = collect(); const stats = {};
64
+ const src = createFileSource({ filePath: f, fromStart: true, idleFlushMs: 0, maxLineBytes: 100, maxReadBytes: 64 * 1024, stats });
65
+ src.start(a.handler); await src.poll();
66
+ fs.appendFileSync(f, "2026-10-29 10:00:00 ERROR " + "z".repeat(10_000) + "\n2026-10-29 10:00:01 ERROR next\n");
67
+ await src.poll(); await src.poll();
68
+ src.stop();
69
+ assert.equal(stats.longLines, 1);
70
+ assert.ok(a.got[0].message.length <= 100);
71
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:01 ERROR next");
72
+ // bounded per tick: 300 KiB are read over several ticks, never more than maxReadBytes (+ one chunk) at once
73
+ const g = path.join(dir, "big.log");
74
+ fs.writeFileSync(g, "");
75
+ const sizes = [];
76
+ const big = createFileSource({ filePath: g, fromStart: true, idleFlushMs: 0, maxReadBytes: 64 * 1024 });
77
+ big.start(async (entries) => { sizes.push(entries.length); });
78
+ await big.poll();
79
+ fs.appendFileSync(g, "2026-10-29 10:00:00 ERROR line\n".repeat(10_000));
80
+ await big.poll();
81
+ const afterOne = sizes.reduce((x, y) => x + y, 0);
82
+ assert.ok(afterOne > 0 && afterOne < 10_000, `one tick reads a bounded slice (${afterOne})`);
83
+ big.stop();
84
+ });
85
+
86
+ // ------------------------------------------------------------------------------------------ Loki
87
+
88
+ const nsOf = (ms) => (BigInt(ms) * 1_000_000n).toString();
89
+
90
+ test("loki: query_range with the cursor, bearer + org headers, service from the stream labels, Loki timestamp wins, cursor advances", async () => {
91
+ const calls = [];
92
+ const t0 = Date.parse("2026-10-29T10:00:00Z");
93
+ const fetchImpl = async (url, init) => {
94
+ calls.push({ url: new URL(url), init });
95
+ return { ok: true, status: 200, body: Readable.toWeb(Readable.from([Buffer.from(JSON.stringify({ data: { result: [
96
+ { stream: { service_name: "checkout", level: "error" }, values: [[nsOf(t0 + 5), "boom 2"], [nsOf(t0 + 1), '{"level":"error","msg":"json boom"}']] },
97
+ ] } }))])) };
98
+ };
99
+ const a = collect();
100
+ const src = createLokiSource({ url: "https://loki.internal:3100", query: '{app="x"}', token: "tok", org: "tenant-1", fetchImpl, now: () => t0 + 1000, lookbackMs: 60_000, limit: 500 });
101
+ src.start(a.handler); await src.poll();
102
+ const c = calls[0];
103
+ assert.equal(c.url.pathname, "/loki/api/v1/query_range");
104
+ assert.equal(c.url.searchParams.get("query"), '{app="x"}');
105
+ assert.equal(c.url.searchParams.get("direction"), "forward");
106
+ assert.equal(c.url.searchParams.get("start"), nsOf(t0 + 1000 - 60_000));
107
+ assert.equal(c.init.headers.Authorization, "Bearer tok");
108
+ assert.equal(c.init.headers["X-Scope-OrgID"], "tenant-1");
109
+ assert.equal(c.init.redirect, "error");
110
+ assert.deepEqual(a.got.map((e) => [e.message, e.service]), [["json boom", "checkout"], ["boom 2", "checkout"]], "sorted by time");
111
+ assert.equal(a.got[0].tsNs, BigInt(nsOf(t0 + 1)));
112
+ await src.poll();
113
+ src.stop();
114
+ assert.equal(calls[1].url.searchParams.get("start"), (BigInt(nsOf(t0 + 5)) + 1n).toString(), "cursor = last ns + 1");
115
+ });
116
+
117
+ test("loki: a full page continues at once (up to maxPages); an error is counted, never thrown, and carries nothing to the log", async () => {
118
+ let n = 0;
119
+ const t0 = Date.parse("2026-10-29T10:00:00Z");
120
+ const fetchImpl = async () => {
121
+ n++;
122
+ const values = [[nsOf(t0 + n * 10), `ERROR e${n}`], [nsOf(t0 + n * 10 + 1), `ERROR f${n}`]];
123
+ return { ok: true, status: 200, body: Readable.toWeb(Readable.from([Buffer.from(JSON.stringify({ data: { result: [{ stream: { app: "a" }, values }] } }))])) };
124
+ };
125
+ const a = collect(); const stats = {};
126
+ const src = createLokiSource({ url: "http://l", query: "q", fetchImpl, limit: 2, maxPages: 3, now: () => t0, stats });
127
+ src.start(a.handler); await src.poll(); src.stop();
128
+ assert.equal(n, 3, "maxPages");
129
+ assert.equal(a.got.length, 6);
130
+ const bad = createLokiSource({ url: "http://l", query: "q", fetchImpl: async () => { throw new Error("connect ECONNREFUSED http://secret-host:3100"); }, stats: {} });
131
+ bad.start(async () => {});
132
+ await new Promise((r) => setTimeout(r, 30));
133
+ assert.equal(bad.stats.errors, 1);
134
+ bad.stop();
135
+ });
136
+
137
+ test("loki: a response over maxResponseBytes, a non-200 and malformed rows are survived", async () => {
138
+ const mk = (body, status = 200) => async () => ({ ok: status === 200, status, body: Readable.toWeb(Readable.from([Buffer.from(body)])) });
139
+ const a = collect();
140
+ const big = createLokiSource({ url: "http://l", query: "q", fetchImpl: mk("x".repeat(5000)), maxResponseBytes: 1000 });
141
+ big.start(a.handler);
142
+ await assert.rejects(big.poll(), /response_too_large/);
143
+ big.stop();
144
+ const bad = createLokiSource({ url: "http://l", query: "q", fetchImpl: mk("{}", 500) });
145
+ bad.start(a.handler);
146
+ await assert.rejects(bad.poll(), /http_500/);
147
+ bad.stop();
148
+ const odd = createLokiSource({ url: "http://l", query: "q", fetchImpl: mk(JSON.stringify({ data: { result: [{ stream: null, values: [["x", "y"], [1, 2], ["5", "ERROR ok"], "z"] }, null] } })) });
149
+ odd.start(a.handler); await odd.poll(); odd.stop();
150
+ assert.deepEqual(a.got.map((e) => e.message), ["ERROR ok"]);
151
+ });
152
+
153
+ // ------------------------------------------------------------------------------------------ stream
154
+
155
+ test("stream: lines become entries, the service label is the default, the record in progress is flushed at the end", async () => {
156
+ const input = new PassThrough();
157
+ const a = collect();
158
+ const src = createStreamSource({ stream: input, service: "container-1", idleFlushMs: 20 });
159
+ src.start(a.handler);
160
+ input.write("2026-10-29 10:00:00 ERROR boom\n\tat a.B.c(B.java:1)\n");
161
+ await new Promise((r) => setTimeout(r, 80));
162
+ assert.equal(a.got.length, 1, "idle flush");
163
+ input.end("2026-10-29 10:00:01 ERROR last");
164
+ await new Promise((r) => setTimeout(r, 50));
165
+ assert.deepEqual(a.got.map((e) => e.service), ["container-1", "container-1"]);
166
+ src.stop();
167
+ });
168
+
169
+ test("stream: backpressure, a slow consumer pauses the stream instead of buffering the world", async () => {
170
+ const input = new PassThrough({ highWaterMark: 1024 });
171
+ let consumed = 0;
172
+ const src = createStreamSource({ stream: input, idleFlushMs: 5 });
173
+ src.start(async (entries) => { consumed += entries.length; await new Promise((r) => setTimeout(r, 30)); });
174
+ let written = 0; let backpressured = false;
175
+ for (let i = 0; i < 2000; i++) { const ok = input.write("2026-10-29 10:00:00 ERROR line " + i + "\n"); written++; if (!ok) { backpressured = true; break; } }
176
+ assert.equal(backpressured, true, "the writer is told to stop long before 2000 lines");
177
+ assert.ok(written < 2000);
178
+ src.stop(); input.destroy();
179
+ });
@@ -0,0 +1,430 @@
1
+ // F3-11b: unit tests of the pieces (config, parse, categorize, aggregator, store, emitter) with fake clocks and a fake session.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { EventEmitter } from "node:events";
8
+ import { resolveIncidentDetectorConfig } from "../../src/incident-detector/config.js";
9
+ import { parseRecord, EntryAssembler, parseTime } from "../../src/incident-detector/parse.js";
10
+ import { severityOf, maxSeverity, inferEnvironment, sanitizeService, sanitizeEnvironment, logCategoryOf } from "../../src/incident-detector/categorize.js";
11
+ import { createAggregator } from "../../src/incident-detector/aggregator.js";
12
+ import { createDetailStore } from "../../src/incident-detector/store.js";
13
+ import { createCandidateEmitter } from "../../src/incident-detector/emitter.js";
14
+ import { ProtocolError } from "../../src/la-protocol-v2/errors.js";
15
+
16
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-inc-"));
17
+ const H = (n) => n.toString(16).padStart(32, "0");
18
+
19
+ // ------------------------------------------------------------------------------------------ config
20
+
21
+ test("config: only true/1/yes/on enable it; unset, empty, false and unknown values keep it off (unknown warns)", () => {
22
+ for (const v of [undefined, "", "false", "0", "no", "off", " OFF ", "maybe", "2", "enabled"]) {
23
+ const warns = [];
24
+ assert.deepEqual(resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: v }, (m) => warns.push(m)), { enabled: false }, String(v));
25
+ assert.equal(warns.length, ["maybe", "2", "enabled"].includes(v) ? 1 : 0, String(v));
26
+ }
27
+ for (const v of ["true", "TRUE", "1", "yes", "on", " On "]) assert.equal(resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: v }, () => {}).enabled, true, v);
28
+ });
29
+
30
+ test("config: numbers are clamped, a bad value falls back to the default (never unlimited); sources and Loki validation", () => {
31
+ const warns = [];
32
+ const c = resolveIncidentDetectorConfig({
33
+ LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_INCIDENT_MAX_TRACKED: "99999999", LOCAL_AGENT_INCIDENT_MAX_PENDING: "0", LOCAL_AGENT_INCIDENT_FLUSH_MS: "abc",
34
+ LOCAL_AGENT_INCIDENT_MAX_EMITS_PER_MINUTE: "-1", LOCAL_AGENT_INCIDENT_LOG_FILES: " /a.log , ,/b.log", LOCAL_AGENT_INCIDENT_STDIN: "yes",
35
+ LOCAL_AGENT_INCIDENT_LOKI_URL: "ftp://x", LOCAL_AGENT_INCIDENT_MAX_ENTRY_BYTES: "1",
36
+ }, (m) => warns.push(m));
37
+ assert.equal(c.maxTracked, 5_000);
38
+ assert.equal(c.maxPending, 500);
39
+ assert.equal(c.flushMs, 30_000);
40
+ assert.equal(c.maxEmitsPerMinute, 60);
41
+ assert.equal(c.maxEntryBytes, 64 * 1024);
42
+ assert.deepEqual(c.files, ["/a.log", "/b.log"]);
43
+ assert.equal(c.stdin, true);
44
+ assert.equal(c.loki, null);
45
+ assert.equal(warns.length, 1);
46
+ const l = resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: "1", LOCAL_AGENT_INCIDENT_LOKI_URL: "https://loki.internal:3100/", LOCAL_AGENT_INCIDENT_LOKI_QUERY: '{app="x"} |= "error"', LOCAL_AGENT_INCIDENT_LOKI_TOKEN: "t" }, () => {});
47
+ assert.equal(l.loki.url, "https://loki.internal:3100");
48
+ assert.equal(l.loki.token, "t");
49
+ const noQuery = [];
50
+ assert.equal(resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: "1", LOCAL_AGENT_INCIDENT_LOKI_URL: "http://l" }, (m) => noQuery.push(m)).loki, null);
51
+ assert.equal(noQuery.length, 1);
52
+ });
53
+
54
+ // ------------------------------------------------------------------------------------------ parse
55
+
56
+ test("parse: JSON lines with aliases, separate stack field, docker json log, bad JSON as text", () => {
57
+ const a = parseRecord('{"level":"error","msg":"boom 1","stack_trace":"a.B: x\\n\\tat c.D.e(D.java:1)","service":"api","time":"2026-10-29T10:00:00Z"}');
58
+ assert.deepEqual([a.severity, a.message, a.service, a.tsMs], ["error", "boom 1", "api", Date.parse("2026-10-29T10:00:00Z")]);
59
+ assert.match(a.stacktrace, /^a\.B: x/);
60
+ const d = parseRecord('{"log":"ERROR something failed\\n","stream":"stderr","time":"2026-10-29T10:00:00.5Z"}');
61
+ assert.equal(d.severity, "ERROR");
62
+ assert.equal(d.message, "ERROR something failed");
63
+ const bad = parseRecord("{not json ERROR x");
64
+ assert.equal(bad.severity, "ERROR");
65
+ assert.equal(parseRecord(""), null);
66
+ assert.equal(parseRecord('{"nomsg":1}'), null);
67
+ const multi = parseRecord("java.lang.X: boom\n\tat a.B.c(B.java:1)");
68
+ assert.equal(multi.message, "java.lang.X: boom");
69
+ assert.equal(multi.stacktrace, "\tat a.B.c(B.java:1)");
70
+ });
71
+
72
+ test("parse: times from ISO, epoch s/ms/us/ns", () => {
73
+ const ms = Date.parse("2026-10-29T10:00:00Z");
74
+ assert.equal(parseTime("2026-10-29T10:00:00Z"), ms);
75
+ assert.equal(parseTime("2026-10-29 10:00:00,250"), ms + 250);
76
+ assert.equal(parseTime(ms), ms);
77
+ assert.equal(parseTime(ms / 1000), ms);
78
+ assert.equal(parseTime(ms * 1000), ms);
79
+ assert.equal(parseTime(ms * 1_000_000), ms);
80
+ assert.equal(parseTime("nope"), null);
81
+ assert.equal(parseTime(null), null);
82
+ });
83
+
84
+ test("assembler: a record owns the lines that do not start a new one; JSON lines stand alone; caps are enforced and counted", () => {
85
+ const a = new EntryAssembler({ maxEntryBytes: 200, maxStackLines: 3 });
86
+ const out = [];
87
+ for (const l of ["2026-10-29 10:00:00 ERROR c.a.Foo : failed 42", "java.lang.IllegalStateException: x", "\tat a.B.c(B.java:1)", "\tat a.B.d(B.java:2)", "\tat a.B.e(B.java:3)", "\tat a.B.f(B.java:4)",
88
+ "2026-10-29 10:00:01 INFO fine", '{"level":"error","msg":"json one"}', "2026-10-29 10:00:02 ERROR " + "z".repeat(500)]) out.push(...a.push(l));
89
+ out.push(...a.flush());
90
+ assert.equal(out.length, 4);
91
+ assert.equal(out[0].severity, "ERROR");
92
+ assert.equal(out[0].stacktrace.split("\n").length, 3, "maxStackLines");
93
+ assert.equal(out[1].severity, "INFO");
94
+ assert.equal(out[2].message, "json one");
95
+ assert.ok(out[3].message.length <= 200, "maxEntryBytes");
96
+ assert.equal(a.truncated, 2);
97
+ });
98
+
99
+ // ------------------------------------------------------------------------------------------ categorize
100
+
101
+ test("categorize: severity, environment, sanitised labels fit the contract patterns, categories", () => {
102
+ assert.deepEqual(["FATAL", "critical", "alert", "emerg", "ERROR", "severe", "WARN", null].map(severityOf), ["critical", "critical", "critical", "critical", "high", "high", "medium", "medium"]);
103
+ assert.equal(maxSeverity("high", "critical"), "critical");
104
+ assert.equal(maxSeverity("critical", "high"), "critical");
105
+ assert.deepEqual(["api-qa", "api-qa-2", "x-demo", "x-staging", "x-stg", "api", "", null].map(inferEnvironment), ["qa", "qa", "demo", "staging", "staging", "production", "production", "production"]);
106
+ for (const raw of ["my service!", "a".repeat(300), "", null, "ok.name:v1/x-y", "é💥"]) assert.match(sanitizeService(raw), /^[A-Za-z0-9._:/-]{1,128}$/, String(raw));
107
+ for (const raw of ["Prod!", "-x", "", null, "B".repeat(99), "staging-eu"]) assert.match(sanitizeEnvironment(raw), /^[a-z0-9][a-z0-9_-]{0,31}$/, String(raw));
108
+ const cat = (m, s = null, t = null) => logCategoryOf(m, s, t);
109
+ assert.equal(cat("java.lang.OutOfMemoryError: Java heap space timed out"), "oom");
110
+ assert.equal(cat("java.util.concurrent.TimeoutException: x"), "timeout");
111
+ assert.equal(cat("org.postgresql.util.PSQLException: boom"), "db");
112
+ assert.equal(cat("x", null, "PG_ERROR"), "db");
113
+ assert.equal(cat("AccessDeniedException: no"), "security");
114
+ assert.equal(cat("request failed HTTP 503"), "http_5xx");
115
+ assert.equal(cat("java.lang.IllegalStateException: x", null, "java.lang.IllegalStateException"), "exception");
116
+ assert.equal(cat("plain failure"), "other");
117
+ });
118
+
119
+ // ------------------------------------------------------------------------------------------ aggregator
120
+
121
+ function aggRig(opts = {}) {
122
+ const clock = { t: 1_000_000 };
123
+ const sent = [];
124
+ const agg = createAggregator({ windowMs: 10_000, flushMs: 2_000, maxTracked: 3, now: () => clock.t, onCandidate: (s) => sent.push(s), ...opts });
125
+ const obs = (hash, extra = {}) => agg.observe({ errorHash: hash, fingerprint: "f".repeat(64), severity: "high", service: "api", environment: "qa", logCategory: "exception", tsMs: clock.t, ...extra });
126
+ return { clock, sent, agg, obs };
127
+ }
128
+
129
+ test("aggregator: leading edge at once, then batched updates with growing count and a stable window identity", () => {
130
+ const { clock, sent, obs, agg } = aggRig();
131
+ assert.equal(obs(H(1)).opened, true);
132
+ assert.equal(sent.length, 1);
133
+ assert.equal(sent[0].count, 1);
134
+ clock.t += 100; obs(H(1)); clock.t += 100; obs(H(1));
135
+ assert.equal(sent.length, 1, "within flushMs nothing more is sent");
136
+ clock.t += 2_000; obs(H(1));
137
+ assert.equal(sent.length, 2);
138
+ assert.equal(sent[1].count, 4);
139
+ assert.equal(sent[1].firstSeen, sent[0].firstSeen, "same window: same firstSeen");
140
+ assert.equal(sent[1].windowStart, sent[0].windowStart);
141
+ clock.t += 500; obs(H(1));
142
+ clock.t += 2_000; agg.tick();
143
+ assert.equal(sent.length, 3, "tick sends the due update");
144
+ assert.equal(sent[2].count, 5);
145
+ clock.t += 2_000; agg.tick();
146
+ assert.equal(sent.length, 3, "nothing new: nothing sent");
147
+ });
148
+
149
+ test("aggregator: the window closes with a final update and the next occurrence opens a NEW window (new firstSeen, count restarts)", () => {
150
+ const { clock, sent, obs, agg } = aggRig();
151
+ obs(H(1)); clock.t += 500; obs(H(1)); // count 2, 1 unsent
152
+ clock.t += 10_000; agg.tick(); // window over: final update
153
+ assert.deepEqual(sent.map((s) => s.count), [1, 2]);
154
+ clock.t += 1; const r = obs(H(1));
155
+ assert.equal(r.opened, true);
156
+ assert.equal(sent.at(-1).count, 1);
157
+ assert.ok(sent.at(-1).windowStart > sent[0].windowStart);
158
+ assert.equal(agg.stats.windows, 2);
159
+ });
160
+
161
+ test("aggregator: an occurrence that arrives after the window ended (no tick yet) closes it itself: final update, then a new window", () => {
162
+ const { clock, sent, obs } = aggRig();
163
+ obs(H(1)); clock.t += 100; obs(H(1)); obs(H(1)); // count 3, 2 unsent
164
+ clock.t += 10_000;
165
+ obs(H(1));
166
+ assert.deepEqual(sent.map((s) => s.count), [1, 3, 1]);
167
+ assert.ok(sent[2].windowStart > sent[0].windowStart);
168
+ });
169
+
170
+ test("aggregator: highest severity of the window, first fingerprint/category win, firstSeen never in the future, an old line keeps the window identity", () => {
171
+ const { clock, sent, obs } = aggRig();
172
+ obs(H(1), { severity: "high", fingerprint: "a".repeat(64), logCategory: "db", tsMs: clock.t + 999_999 });
173
+ assert.equal(sent[0].firstSeen, clock.t, "clamped to now");
174
+ clock.t += 3_000;
175
+ obs(H(1), { severity: "critical", fingerprint: "b".repeat(64), logCategory: "oom", tsMs: 5 });
176
+ assert.equal(sent[1].severity, "critical");
177
+ assert.equal(sent[1].fingerprint, "a".repeat(64));
178
+ assert.equal(sent[1].logCategory, "db");
179
+ assert.equal(sent[1].firstSeen, sent[0].firstSeen);
180
+ });
181
+
182
+ test("aggregator: maxTracked evicts the least recently seen hash (with its pending update) and counts it", () => {
183
+ const { clock, sent, obs, agg } = aggRig();
184
+ obs(H(1)); obs(H(2)); obs(H(3));
185
+ clock.t += 100; obs(H(1)); // 1 is now the most recent; 2 is the oldest; 1 has an unsent update
186
+ obs(H(4)); // evicts 2 (nothing pending for it)
187
+ assert.equal(agg.tracked, 3);
188
+ assert.equal(agg.stats.evicted, 1);
189
+ clock.t += 100; obs(H(3)); obs(H(5)); // evicts 1 (it has count 2 unsent: a final update goes out)
190
+ const finalOf1 = sent.filter((s) => s.errorHash === H(1));
191
+ assert.deepEqual(finalOf1.map((s) => s.count), [1, 2]);
192
+ });
193
+
194
+ test("aggregator: drain() sends what is pending and forgets the windows", () => {
195
+ const { clock, sent, obs, agg } = aggRig();
196
+ obs(H(1)); clock.t += 10; obs(H(1)); obs(H(2));
197
+ agg.drain();
198
+ assert.equal(agg.tracked, 0);
199
+ assert.deepEqual(sent.map((s) => [s.errorHash, s.count]), [[H(1), 1], [H(2), 1], [H(1), 2]]);
200
+ });
201
+
202
+ // ------------------------------------------------------------------------------------------ store
203
+
204
+ function storeRig(extra = {}) {
205
+ const dir = path.join(tmp(), "inc");
206
+ const store = createDetailStore({ dir, maxBytes: 4096, maxQueue: 5, messageChars: 200, stackChars: 300, samplesPerWindow: 2, env: { LOCAL_AGENT_DLP_MODE: "enforce" }, ...extra });
207
+ return { dir, store, file: path.join(dir, "incident-detail.jsonl") };
208
+ }
209
+ const rec = (n, extra = {}) => ({ errorHash: H(n), fingerprint: "f".repeat(64), service: "api", severity: "high", windowStart: 1, message: `msg ${n}`, stack: `stack ${n}`, ...extra });
210
+
211
+ test("store: 0700 dir, 0600 file, one JSON per line, the detail is readable back (newest first), samples per window are capped", async () => {
212
+ const { store, dir, file } = storeRig();
213
+ for (let i = 0; i < 4; i++) await store.add(rec(1, { message: `first ${i}` }));
214
+ await store.add(rec(2, { windowStart: 2 }));
215
+ await store.flush();
216
+ assert.equal(store.stats.written, 3);
217
+ assert.equal(store.stats.sampledOut, 2);
218
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
219
+ assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
220
+ const got = await store.getDetail(H(1), { limit: 5 });
221
+ assert.deepEqual(got.map((r) => r.message), ["first 1", "first 0"]);
222
+ assert.equal(got[0].stackBytes, 7);
223
+ assert.deepEqual(await store.getDetail(H(9)), []);
224
+ });
225
+
226
+ test("store: the DLP masks secrets before they are written; text and stack are cut; a denied value is never written", async () => {
227
+ const { store, file } = storeRig();
228
+ const secret = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
229
+ await store.add(rec(1, { message: `token ${secret} leaked ${"x".repeat(500)}`, stack: `s ${secret}\n` + "y".repeat(900) }));
230
+ await store.flush();
231
+ const raw = fs.readFileSync(file, "utf8");
232
+ assert.ok(!raw.includes(secret), "the secret is not on disk");
233
+ const r = JSON.parse(raw);
234
+ assert.equal(r.truncated, true);
235
+ assert.ok(r.message.length <= 200 + 40 && r.stack.length <= 300 + 40);
236
+ assert.ok(store.stats.redactions >= 1);
237
+ });
238
+
239
+ test("store: rotates at maxBytes to .1 (one generation) and reads both", async () => {
240
+ const { store, file } = storeRig({ maxBytes: 1500 });
241
+ for (let i = 0; i < 12; i++) await store.add(rec(100 + i, { message: "m".repeat(100) }));
242
+ await store.flush();
243
+ assert.ok(store.stats.rotations >= 1);
244
+ assert.ok(fs.existsSync(`${file}.1`));
245
+ assert.ok(fs.statSync(file).size <= 1500 + 600);
246
+ assert.equal((await store.getDetail(H(100 + 11))).length, 1, "newest is in the current file");
247
+ });
248
+
249
+ test("store: a broken disk disables the store (counted, no throw) and add() resolves false; the queue is bounded", async () => {
250
+ const dir = tmp();
251
+ fs.writeFileSync(path.join(dir, "blocker"), "x");
252
+ const store = createDetailStore({ dir: path.join(dir, "blocker", "sub"), maxBytes: 4096, maxQueue: 2, messageChars: 100, stackChars: 100, samplesPerWindow: 5, env: {} });
253
+ assert.equal(await store.add(rec(1)), true, "queued before the failure is known");
254
+ await store.flush();
255
+ assert.equal(store.broken, true);
256
+ assert.equal(await store.add(rec(2)), false);
257
+ assert.ok(store.stats.errors >= 1);
258
+ assert.ok(store.stats.dropped >= 1);
259
+ });
260
+
261
+ test("store: add() waits (backpressure) while the queue is full instead of growing it", async () => {
262
+ const { store } = storeRig({ maxQueue: 2 });
263
+ let max = 0;
264
+ const ps = [];
265
+ for (let i = 0; i < 20; i++) { ps.push(store.add(rec(200 + i, { windowStart: i }))); max = Math.max(max, store.queued); }
266
+ await Promise.all(ps);
267
+ await store.flush();
268
+ assert.ok(max <= 2, `queue never above maxQueue (saw ${max})`);
269
+ assert.equal(store.stats.written, 20);
270
+ });
271
+
272
+ // ------------------------------------------------------------------------------------------ emitter (fake session)
273
+
274
+ class FakeSession extends EventEmitter {
275
+ constructor() { super(); this.state = "ready"; this.sent = []; this.script = []; }
276
+ async send(type, payload, opts) {
277
+ const next = this.script.shift();
278
+ if (next) throw new ProtocolError(next);
279
+ this.sent.push({ type, payload, opts });
280
+ }
281
+ }
282
+ const cfg = (extra = {}) => ({ maxPending: 3, maxEmitsPerMinute: 60, emitBurst: 10, ...extra });
283
+ const snap = (n, extra = {}) => ({ errorHash: H(n), fingerprint: "f".repeat(64), severity: "high", service: "api", environment: "qa", count: 1, firstSeen: 1_700_000_000_000, lastSeen: 1_700_000_001_000, logCategory: "exception", windowStart: 1, ...extra });
284
+ const quietLog = { warn() {}, info() {}, log() {} };
285
+
286
+ test("emitter: exactly the 9 contract fields, ISO times, the workspace of the session; nothing else", async () => {
287
+ const s = new FakeSession();
288
+ const e = createCandidateEmitter({ session: s, config: cfg(), workspaceId: "ws-1", env: {}, log: quietLog });
289
+ e.submit(snap(1));
290
+ await e.flush();
291
+ assert.equal(s.sent.length, 1);
292
+ assert.deepEqual(Object.keys(s.sent[0].payload).sort(), ["count", "environment", "errorHash", "fingerprint", "firstSeen", "lastSeen", "logCategory", "service", "severity"]);
293
+ assert.equal(s.sent[0].payload.firstSeen, "2023-11-14T22:13:20.000Z");
294
+ assert.equal(s.sent[0].opts.workspaceId, "ws-1");
295
+ assert.equal(s.sent[0].type, "incident.candidate");
296
+ e.close();
297
+ });
298
+
299
+ test("emitter: updates of the same window coalesce (the newest count wins); a full queue drops NEW candidates but still takes updates", async () => {
300
+ const s = new FakeSession(); s.state = "not_ready"; // nothing leaves
301
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog });
302
+ e.submit(snap(1)); e.submit(snap(2)); e.submit(snap(3));
303
+ assert.equal(e.submit(snap(4)), false, "full: a new candidate is dropped");
304
+ assert.equal(e.stats.droppedFull, 1);
305
+ e.submit(snap(1, { count: 9 }));
306
+ assert.equal(e.pending, 3);
307
+ assert.equal(e.stats.coalesced, 1);
308
+ s.state = "ready"; s.emit("ready", {});
309
+ await e.flush();
310
+ assert.equal(s.sent.length, 3);
311
+ assert.equal(s.sent[0].payload.count, 9);
312
+ e.close();
313
+ });
314
+
315
+ test("emitter: rate limit (token bucket) holds the rest back; the bucket refills with time", async () => {
316
+ const clock = { t: 0 };
317
+ const timers = { setTimeout: (fn, ms) => ({ fn, ms, unref() {} }), clearTimeout() {} };
318
+ const s = new FakeSession();
319
+ const e = createCandidateEmitter({ session: s, config: cfg({ maxPending: 50, maxEmitsPerMinute: 60, emitBurst: 2 }), env: {}, log: quietLog, now: () => clock.t, timers });
320
+ for (let i = 1; i <= 5; i++) e.submit(snap(i, { windowStart: i }));
321
+ await e.flush();
322
+ assert.equal(s.sent.length, 2, "burst of 2");
323
+ assert.equal(e.pending, 3);
324
+ clock.t += 3_000; // 3 s at 60/min = 3 tokens (capped at the burst of 2)
325
+ await e.flush();
326
+ assert.equal(s.sent.length, 4);
327
+ e.close();
328
+ });
329
+
330
+ test("emitter: rate_limited keeps everything and backs off; protocol_unsupported forgets the queue until the next ready; invalid is dropped", async () => {
331
+ const clock = { t: 0 };
332
+ const timers = { setTimeout: () => ({ unref() {} }), clearTimeout() {} };
333
+ const s = new FakeSession();
334
+ const e = createCandidateEmitter({ session: s, config: cfg({ maxPending: 10 }), env: {}, log: quietLog, now: () => clock.t, timers });
335
+ s.script.push("rate_limited");
336
+ e.submit(snap(1)); e.submit(snap(2));
337
+ await e.flush();
338
+ assert.equal(s.sent.length, 0);
339
+ assert.equal(e.pending, 2, "kept");
340
+ await e.flush();
341
+ assert.equal(s.sent.length, 0, "still inside the backoff window");
342
+ clock.t += 31_000;
343
+ await e.flush();
344
+ assert.equal(s.sent.length, 2);
345
+ s.script.push("protocol_unsupported");
346
+ e.submit(snap(3, { windowStart: 3 }));
347
+ await e.flush();
348
+ assert.equal(e.pending, 0, "dropped: this gateway takes none");
349
+ e.submit(snap(4, { windowStart: 4 }));
350
+ await e.flush();
351
+ assert.equal(s.sent.length, 2, "quiet");
352
+ s.emit("ready", {});
353
+ e.submit(snap(5, { windowStart: 5 }));
354
+ await e.flush();
355
+ assert.equal(s.sent.length, 3, "after ready it talks again");
356
+ s.script.push("schema_violation");
357
+ e.submit(snap(6, { windowStart: 6 }));
358
+ await e.flush();
359
+ assert.equal(e.stats.droppedInvalid, 1);
360
+ e.close();
361
+ });
362
+
363
+ test("emitter: internal/timeout retried, given up after 5 attempts in a row (counted)", async () => {
364
+ const timers = { setTimeout: () => ({ unref() {} }), clearTimeout() {} };
365
+ const s = new FakeSession();
366
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog, timers });
367
+ s.script.push("internal", "timeout", "internal", "timeout", "internal");
368
+ e.submit(snap(1));
369
+ for (let i = 0; i < 6; i++) await e.flush();
370
+ assert.equal(e.stats.droppedGaveUp, 1);
371
+ assert.equal(e.pending, 0);
372
+ e.close();
373
+ });
374
+
375
+ test("emitter: a label the DLP would change is not sent (the candidate is dropped, counted); with DLP off the label goes as is", async () => {
376
+ const secret = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
377
+ const s = new FakeSession();
378
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: { LOCAL_AGENT_DLP_MODE: "enforce" }, log: quietLog });
379
+ e.submit(snap(1, { service: `svc-${secret}` }));
380
+ await e.flush();
381
+ assert.equal(s.sent.length, 0);
382
+ assert.equal(e.stats.droppedDlp, 1);
383
+ e.submit(snap(2, { service: "billing-api" }));
384
+ await e.flush();
385
+ assert.equal(s.sent.length, 1);
386
+ e.close();
387
+ });
388
+
389
+ test("emitter: not ready keeps the candidates; the next ready sends them (one at a time, in order)", async () => {
390
+ const s = new FakeSession(); s.state = "detached";
391
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog });
392
+ e.submit(snap(1)); e.submit(snap(2));
393
+ await e.flush();
394
+ assert.equal(s.sent.length, 0);
395
+ s.state = "ready"; s.emit("ready", { resumed: true });
396
+ await e.flush();
397
+ assert.deepEqual(s.sent.map((x) => x.payload.errorHash), [H(1), H(2)]);
398
+ e.close();
399
+ });
400
+
401
+ // ------------------------------------------------------------------------------------------ untrusted timestamps (review of #65)
402
+
403
+ test("an absurd timestamp in a log line is not a time: parse gives null, the aggregator uses the clock, the emitter never blocks", async () => {
404
+ for (const v of [-1e30, 1e30, "-99999-01-01T00:00:00Z"]) {
405
+ const t = parseTime(v);
406
+ assert.ok(t === null || Math.abs(t) <= 8.64e15, `parseTime(${v}) = ${t}: it must be null or a valid Date value`);
407
+ }
408
+ assert.equal(parseTime(-1e30), null);
409
+ assert.equal(parseTime(1e300), null);
410
+ // the aggregator ignores a negative or non-finite time: firstSeen is the clock, never -1e33
411
+ const { clock, sent, obs } = aggRig();
412
+ obs(H(7), { tsMs: -1e33 });
413
+ assert.equal(sent[0].firstSeen, clock.t);
414
+ obs(H(8), { tsMs: -5 });
415
+ assert.equal(sent[1].firstSeen, clock.t);
416
+ // even if a bad snapshot reached the emitter, it is dropped (invalid) and the next candidate still goes out
417
+ const s = new FakeSession();
418
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog });
419
+ e.submit(snap(1, { firstSeen: -1e33, lastSeen: -1e33 }));
420
+ await e.flush();
421
+ assert.equal(s.sent.length, 0, "the bad candidate is not sent");
422
+ assert.equal(e.pending, 0, "and it no longer sits at the head of the queue (it used to block it for ever)");
423
+ e.submit(snap(2));
424
+ await e.flush();
425
+ assert.equal(s.sent.length, 1, "the good one goes out");
426
+ assert.equal(s.sent[0].payload.errorHash, H(2));
427
+ assert.equal(e.stats.droppedInvalid >= 1, true);
428
+ assert.equal(e.stats.droppedDlp, 0, "it is not a DLP drop");
429
+ e.close();
430
+ });