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.
- package/docs/la-protocol-v2.md +9 -3
- package/env.example +7 -0
- package/package.json +1 -1
- package/scripts/incident-parity/Gen.java +256 -0
- package/scripts/incident-parity/LogEntry.java +17 -0
- package/scripts/incident-parity/extract.py +51 -0
- package/scripts/incident-parity/gen.sh +32 -0
- package/src/incident-detector/README.md +51 -0
- package/src/incident-detector/aggregator.js +73 -0
- package/src/incident-detector/categorize.js +48 -0
- package/src/incident-detector/config.js +95 -0
- package/src/incident-detector/detector.js +92 -0
- package/src/incident-detector/emitter.js +154 -0
- package/src/incident-detector/index.js +44 -0
- package/src/incident-detector/java-compat.js +66 -0
- package/src/incident-detector/normalize.js +209 -0
- package/src/incident-detector/parse.js +114 -0
- package/src/incident-detector/sources/file-source.js +113 -0
- package/src/incident-detector/sources/loki-source.js +101 -0
- package/src/incident-detector/sources/stream-source.js +39 -0
- package/src/incident-detector/store.js +119 -0
- package/src/la-protocol-v2/runtime.js +10 -1
- package/test/incident-detector/parity.test.js +66 -0
- package/test/incident-detector/parity.vectors.json +2619 -0
- package/test/incident-detector/sources.test.js +179 -0
- package/test/incident-detector/units.test.js +430 -0
- package/test/incident-detector/wiring.test.js +165 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js - the local detail store of detected errors (F3-11b). The TEXT of an error (message, stack trace) lives here and
|
|
3
|
+
* nowhere else: the control plane only ever gets metadata.
|
|
4
|
+
*
|
|
5
|
+
* file <storeDir>/incident-detail.jsonl, one JSON object per line, dir 0700 / file 0600, append only
|
|
6
|
+
* rotation at storeMaxBytes the file moves to incident-detail.jsonl.1 (one generation): disk use <= 2 x storeMaxBytes
|
|
7
|
+
* record {v, ts, errorHash, fingerprint, service, severity, windowStart, message, stack, stackBytes, truncated}
|
|
8
|
+
* message/stack are masked by the DLP (when DLP is on) and cut to storeMessageChars/storeStackChars
|
|
9
|
+
* sampling samplesPerWindow records per errorHash and window (the count is in the candidate, not in this file)
|
|
10
|
+
* queue at most storeMaxQueue writes waiting; add() resolves when the record is queued OR dropped (never blocks the caller
|
|
11
|
+
* for more than the queue allows: when full it waits for room, which slows the reading of the sources = backpressure)
|
|
12
|
+
* getDetail (errorHash, {limit}) reads the newest records back: this is the hook for `content.request` kind `incident.detail`
|
|
13
|
+
* (F3-07 handler, NOT wired here: the contract/gateway kind and its ref scheme are not defined yet, see README.md)
|
|
14
|
+
* never logs a path, a message or a stack: counters only. A broken disk disables the store, not the detector.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { protectEgress } from "../dlp/index.js";
|
|
19
|
+
|
|
20
|
+
const FILE = "incident-detail.jsonl";
|
|
21
|
+
const cut = (s, n) => (typeof s === "string" && s.length > n ? s.slice(0, n) : s);
|
|
22
|
+
|
|
23
|
+
export function createDetailStore({ dir, maxBytes, maxQueue, messageChars, stackChars, samplesPerWindow, env = process.env, now = Date.now }) {
|
|
24
|
+
const file = path.join(dir, FILE);
|
|
25
|
+
const stats = { written: 0, sampledOut: 0, dropped: 0, errors: 0, rotations: 0, redactions: 0, dlpDenied: 0 };
|
|
26
|
+
const samples = new Map(); // `${errorHash}|${windowStart}` -> records written
|
|
27
|
+
let queue = [];
|
|
28
|
+
let size = null;
|
|
29
|
+
let broken = false;
|
|
30
|
+
let writing = null;
|
|
31
|
+
let waiters = [];
|
|
32
|
+
|
|
33
|
+
async function ensure() {
|
|
34
|
+
if (size !== null) return;
|
|
35
|
+
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
36
|
+
size = await fs.promises.stat(file).then((s) => s.size, () => 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function drain() {
|
|
40
|
+
try {
|
|
41
|
+
await ensure();
|
|
42
|
+
while (queue.length) {
|
|
43
|
+
const batch = queue;
|
|
44
|
+
queue = [];
|
|
45
|
+
const lines = batch.map((r) => JSON.stringify(r) + "\n");
|
|
46
|
+
const bytes = lines.reduce((n, l) => n + Buffer.byteLength(l), 0);
|
|
47
|
+
if (size > 0 && size + bytes > maxBytes) {
|
|
48
|
+
await fs.promises.rename(file, `${file}.1`).catch(() => {});
|
|
49
|
+
size = 0;
|
|
50
|
+
stats.rotations++;
|
|
51
|
+
}
|
|
52
|
+
await fs.promises.appendFile(file, lines.join(""), { mode: 0o600 });
|
|
53
|
+
size += bytes;
|
|
54
|
+
stats.written += batch.length;
|
|
55
|
+
const w = waiters; waiters = []; for (const f of w) f();
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
broken = true; // disk full, permissions...: the detector keeps going without a store
|
|
59
|
+
stats.errors++;
|
|
60
|
+
stats.dropped += queue.length;
|
|
61
|
+
queue = [];
|
|
62
|
+
} finally {
|
|
63
|
+
writing = null;
|
|
64
|
+
const w = waiters; waiters = []; for (const f of w) f();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mask = (text) => {
|
|
69
|
+
if (typeof text !== "string" || text === "") return text;
|
|
70
|
+
const r = protectEgress(text, { env, source: "incident-store" });
|
|
71
|
+
if (r.denied) { stats.dlpDenied++; return "[DLP_DENIED]"; }
|
|
72
|
+
if (r.total) stats.redactions += r.total;
|
|
73
|
+
return r.text;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
stats,
|
|
78
|
+
get broken() { return broken; },
|
|
79
|
+
get queued() { return queue.length; },
|
|
80
|
+
/** @returns {Promise<boolean>} true = queued. */
|
|
81
|
+
async add({ errorHash, fingerprint, service, severity, windowStart, message, stack }) {
|
|
82
|
+
if (broken) { stats.dropped++; return false; }
|
|
83
|
+
const key = `${errorHash}|${windowStart}`;
|
|
84
|
+
const n = samples.get(key) || 0;
|
|
85
|
+
if (n >= samplesPerWindow) { stats.sampledOut++; return false; }
|
|
86
|
+
if (samples.size > 50_000) samples.clear(); // bounded: a reset only lets a few extra samples through
|
|
87
|
+
samples.set(key, n + 1);
|
|
88
|
+
while (queue.length >= maxQueue && !broken) await new Promise((res) => { waiters.push(res); if (!writing) writing = drain(); });
|
|
89
|
+
if (broken) { stats.dropped++; return false; }
|
|
90
|
+
const m = typeof message === "string" ? message : "";
|
|
91
|
+
const st = typeof stack === "string" ? stack : "";
|
|
92
|
+
queue.push({
|
|
93
|
+
v: 1, ts: new Date(now()).toISOString(), errorHash, fingerprint, service, severity, windowStart,
|
|
94
|
+
message: mask(cut(m, messageChars)), stack: st ? mask(cut(st, stackChars)) : null,
|
|
95
|
+
stackBytes: Buffer.byteLength(st), truncated: m.length > messageChars || st.length > stackChars,
|
|
96
|
+
});
|
|
97
|
+
if (!writing) writing = drain();
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
/** Newest first. Reads both generations; a bad line is skipped. */
|
|
101
|
+
async getDetail(errorHash, { limit = 5 } = {}) {
|
|
102
|
+
if (writing) await writing;
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const f of [file, `${file}.1`]) {
|
|
105
|
+
let text;
|
|
106
|
+
try { text = await fs.promises.readFile(f, "utf8"); } catch { continue; }
|
|
107
|
+
const lines = text.split("\n");
|
|
108
|
+
for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
|
|
109
|
+
if (!lines[i].includes(errorHash)) continue;
|
|
110
|
+
try { const r = JSON.parse(lines[i]); if (r.errorHash === errorHash) out.push(r); } catch { /* skip */ }
|
|
111
|
+
}
|
|
112
|
+
if (out.length >= limit) break;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
},
|
|
116
|
+
/** Waits until everything queued is on disk (tests, shutdown). */
|
|
117
|
+
async flush() { if (!writing && queue.length) writing = drain(); if (writing) await writing; },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -56,6 +56,11 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
const contentCaps = contentCfg.enabled ? ["content.diff", "content.file", "content.file.list"] : []; // content.file.list: F3-07c, how the front finds out what exists (the gateway only sends file.list to agents that announce it)
|
|
59
|
+
// F3-11b: incident detector (logs -> incident.candidate, metadata only), behind LOCAL_AGENT_INCIDENT_DETECTOR (default off: nothing below changes anything)
|
|
60
|
+
const { resolveIncidentDetectorConfig } = await import("../incident-detector/config.js");
|
|
61
|
+
const incidentCfg = resolveIncidentDetectorConfig(env, (m) => log.warn(m));
|
|
62
|
+
const hasIncidentSource = incidentCfg.enabled && (incidentCfg.files.length > 0 || incidentCfg.stdin || !!incidentCfg.loki);
|
|
63
|
+
const incidentCaps = hasIncidentSource ? ["incident.candidate"] : [];
|
|
59
64
|
const link = createV2Link({
|
|
60
65
|
log,
|
|
61
66
|
sessionOptions: {
|
|
@@ -65,7 +70,7 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
65
70
|
signing,
|
|
66
71
|
laVersion: agentVersion(),
|
|
67
72
|
// only what this build implements: the transport, the handshake and the v1 commands on the same connection
|
|
68
|
-
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps],
|
|
73
|
+
capabilities: ["protocol.v2", "v1.commands", "chunk.base64", ...contentCaps, ...incidentCaps],
|
|
69
74
|
getWorkspaces: () => {
|
|
70
75
|
const open = (wsManager?.list?.() || []).map((w) => ({
|
|
71
76
|
workspaceId: String(w.id || ""),
|
|
@@ -87,5 +92,9 @@ export async function buildV2Link({ tenantId, agentId, signing, wsManager, getWo
|
|
|
87
92
|
const { installContentHandlers } = await import("./content-handlers/install.js");
|
|
88
93
|
installContentHandlers({ link, tenantId: tenant, wsManager, getWorkspaceRoot, config: contentCfg, env, log });
|
|
89
94
|
}
|
|
95
|
+
if (incidentCfg.enabled) {
|
|
96
|
+
const { installIncidentDetector } = await import("../incident-detector/index.js");
|
|
97
|
+
installIncidentDetector({ link, workspaceId: WORKSPACE_ID.test(identityWs) ? identityWs : "", config: incidentCfg, env, log });
|
|
98
|
+
}
|
|
90
99
|
return link;
|
|
91
100
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3-11b: the Node detector against vectors generated from the Java code (scripts/incident-parity/gen.sh).
|
|
3
|
+
* Every field of every case must match byte for byte: errorHash (group hash), group key, fingerprint, errorType, first user
|
|
4
|
+
* frame, normalised message/frame, significance, noise class; and the natural-key dedup (LogEntryDedup).
|
|
5
|
+
*/
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import {
|
|
12
|
+
computeGroupHash, normalizeGroupKey, computeFingerprint, extractErrorType, extractFirstUserFrame, normaliseMessage, normaliseFrame,
|
|
13
|
+
isSignificant, classifyNoise, naturalKey,
|
|
14
|
+
} from "../../src/incident-detector/normalize.js";
|
|
15
|
+
|
|
16
|
+
const VECTORS = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "parity.vectors.json"), "utf8"));
|
|
17
|
+
const nl = (v) => (v === undefined ? null : v);
|
|
18
|
+
|
|
19
|
+
test("the vector file says where it came from and is big enough to mean something", () => {
|
|
20
|
+
assert.equal(VECTORS.schema, "incident-parity-vectors/1");
|
|
21
|
+
assert.match(VECTORS.source.commit, /^[0-9a-f]{40}$/);
|
|
22
|
+
assert.ok(Object.keys(VECTORS.source.files).length >= 5);
|
|
23
|
+
assert.ok(VECTORS.cases.length >= 100, `${VECTORS.cases.length} cases`);
|
|
24
|
+
assert.ok(VECTORS.dedup.length >= 5);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
for (const c of VECTORS.cases) {
|
|
28
|
+
test(`parity: ${c.name}`, () => {
|
|
29
|
+
const e = c.expect;
|
|
30
|
+
const message = nl(c.message);
|
|
31
|
+
const stack = nl(c.stack);
|
|
32
|
+
assert.equal(isSignificant({ severity: c.severity, message, stacktrace: stack }), e.significant, "significant");
|
|
33
|
+
assert.equal(normalizeGroupKey(message == null ? "" : message.split("\n")[0]), e.groupKey, "groupKey");
|
|
34
|
+
assert.equal(computeGroupHash(message), e.groupHash, "errorHash");
|
|
35
|
+
assert.equal(extractErrorType(message, stack), e.errorType, "errorType");
|
|
36
|
+
assert.equal(extractFirstUserFrame(stack), e.firstFrame, "firstFrame");
|
|
37
|
+
assert.equal(normaliseMessage(message), e.normalisedMessage, "normalisedMessage");
|
|
38
|
+
assert.equal(normaliseFrame(e.firstFrame), e.normalisedFrame, "normalisedFrame");
|
|
39
|
+
assert.equal(computeFingerprint(message, stack), e.fingerprint, "fingerprint");
|
|
40
|
+
assert.equal(classifyNoise(message), e.noiseClass, "noiseClass");
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const d of VECTORS.dedup) {
|
|
45
|
+
test(`parity dedup: ${d.name}`, () => {
|
|
46
|
+
const nanos = (iso) => {
|
|
47
|
+
if (iso == null) return null;
|
|
48
|
+
const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/.exec(iso);
|
|
49
|
+
assert.ok(m, iso);
|
|
50
|
+
return BigInt(Date.parse(`${m[1]}Z`)) * 1_000_000n + BigInt((m[2] || "").padEnd(9, "0"));
|
|
51
|
+
};
|
|
52
|
+
const keys = d.entries.map((x) => naturalKey(x.service, nanos(x.ts), x.message));
|
|
53
|
+
assert.deepEqual(keys, d.keys, "natural keys");
|
|
54
|
+
// filterNew: drop what is known or repeated inside the batch; an entry without a key is always kept
|
|
55
|
+
const known = new Set(d.known);
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const kept = [];
|
|
58
|
+
keys.forEach((k, i) => {
|
|
59
|
+
if (k == null) { kept.push(i); return; }
|
|
60
|
+
if (known.has(k) || seen.has(k)) return;
|
|
61
|
+
seen.add(k);
|
|
62
|
+
kept.push(i);
|
|
63
|
+
});
|
|
64
|
+
assert.deepEqual(kept, d.kept, "kept entries");
|
|
65
|
+
});
|
|
66
|
+
}
|