deepdebug-local-agent 1.7.0 → 1.9.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.
Files changed (71) hide show
  1. package/.claude/context.md +2 -1
  2. package/CLAUDE.md +1 -1
  3. package/docs/la-protocol-v2.md +27 -4
  4. package/env.example +12 -0
  5. package/package.json +1 -1
  6. package/scripts/incident-parity/Gen.java +256 -0
  7. package/scripts/incident-parity/LogEntry.java +17 -0
  8. package/scripts/incident-parity/extract.py +51 -0
  9. package/scripts/incident-parity/gen.sh +32 -0
  10. package/src/incident-detector/README.md +52 -0
  11. package/src/incident-detector/aggregator.js +78 -0
  12. package/src/incident-detector/categorize.js +48 -0
  13. package/src/incident-detector/config.js +95 -0
  14. package/src/incident-detector/detector.js +93 -0
  15. package/src/incident-detector/emitter.js +164 -0
  16. package/src/incident-detector/index.js +44 -0
  17. package/src/incident-detector/java-compat.js +66 -0
  18. package/src/incident-detector/normalize.js +209 -0
  19. package/src/incident-detector/parse.js +114 -0
  20. package/src/incident-detector/sources/file-source.js +113 -0
  21. package/src/incident-detector/sources/loki-source.js +101 -0
  22. package/src/incident-detector/sources/stream-source.js +39 -0
  23. package/src/incident-detector/sources/webhook-source.js +109 -0
  24. package/src/incident-detector/store.js +119 -0
  25. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
  26. package/src/la-protocol-v2/contract/README.md +103 -3
  27. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  28. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  29. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  30. package/src/la-protocol-v2/contract/index.json +9 -2
  31. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  32. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  33. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  34. package/src/la-protocol-v2/contract.js +45 -7
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +173 -0
  39. package/src/la-protocol-v2/runtime.js +44 -1
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  50. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  51. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  59. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  60. package/test/incident-detector/parity.test.js +66 -0
  61. package/test/incident-detector/parity.vectors.json +2619 -0
  62. package/test/incident-detector/size-model.test.js +127 -0
  63. package/test/incident-detector/sources.test.js +179 -0
  64. package/test/incident-detector/units.test.js +430 -0
  65. package/test/incident-detector/wiring.test.js +165 -0
  66. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  67. package/test/la-protocol-v2/contract.test.js +6 -4
  68. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  69. package/test/la-protocol-v2/llm-key.test.js +509 -0
  70. package/test/la-protocol-v2/traces.test.js +3 -3
  71. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * categorize.js - the small mappings between a log entry and the metadata of `incident.candidate` (F3-11b).
3
+ * Nothing here returns text from the entry: only enum values and sanitised labels.
4
+ */
5
+
6
+ /** Gateway SeverityNormalizer for the levels ErrorDetector lets through (SEVERE is not in the gateway table; it counts as an error). */
7
+ export function severityOf(level) {
8
+ switch (String(level ?? "").trim().toUpperCase()) {
9
+ case "CRITICAL": case "ALERT": case "EMERG": case "EMERGENCY": case "FATAL": return "critical";
10
+ case "ERROR": case "ERR": case "SEVERE": return "high";
11
+ default: return "medium";
12
+ }
13
+ }
14
+ const RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
15
+ export const maxSeverity = (a, b) => (RANK[b] > RANK[a] ? b : a);
16
+
17
+ /** AutoIncidentCreator.inferEnvironment: the platform convention for the service name suffix. */
18
+ export function inferEnvironment(service) {
19
+ if (service == null || String(service).trim() === "") return "production";
20
+ const s = String(service).toLowerCase();
21
+ if (s.endsWith("-qa") || s.includes("-qa-")) return "qa";
22
+ if (s.endsWith("-demo") || s.includes("-demo-")) return "demo";
23
+ if (s.endsWith("-staging") || s.includes("-staging-") || s.endsWith("-stg")) return "staging";
24
+ return "production";
25
+ }
26
+
27
+ /** Schema: ^[A-Za-z0-9._:/-]{1,128}$ */
28
+ export function sanitizeService(raw) {
29
+ const s = String(raw ?? "").replace(/[^A-Za-z0-9._:/-]/g, "_").slice(0, 128);
30
+ return s === "" ? "unknown" : s;
31
+ }
32
+ /** Schema: ^[a-z0-9][a-z0-9_-]{0,31}$ */
33
+ export function sanitizeEnvironment(raw) {
34
+ const s = String(raw ?? "").toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^[^a-z0-9]+/, "").slice(0, 32);
35
+ return s === "" ? "production" : s;
36
+ }
37
+
38
+ /** logCategory enum of the contract. The order is the priority: an OOM that mentions a timeout is an oom. */
39
+ export function logCategoryOf(message, stacktrace, errorType) {
40
+ const t = `${message ?? ""}\n${stacktrace ?? ""}`;
41
+ if (/OutOfMemoryError|out of memory|Memory limit of|\bOOM\b/i.test(t)) return "oom";
42
+ if (/TimeoutException|timed? ?out|deadline exceeded/i.test(t)) return "timeout";
43
+ if (/SQLException|PSQLException|SQLSTATE|duplicate key|deadlock|MongoException|R2dbc|JDBC|relation "[^"]*" does not exist/i.test(t) || /^PG_/.test(errorType ?? "")) return "db";
44
+ if (/AccessDenied|Unauthorized|Forbidden|SecurityException|authentication failed|invalid token|\bCSRF\b/i.test(t)) return "security";
45
+ if (/\bHTTP\s+5\d\d\b|\bstatus[= :]+5\d\d\b/i.test(t)) return "http_5xx";
46
+ if (errorType || /Exception|Traceback|Error:/.test(t)) return "exception";
47
+ return "other";
48
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_INCIDENT_DETECTOR flag and limits (F3-11b).
3
+ *
4
+ * false (default) only this file is loaded (to read the flag): no source is opened, nothing is read, nothing is sent.
5
+ * true the agent watches the configured log sources, detects errors with the SAME rules as the Java monitor
6
+ * (parity vectors) and sends `incident.candidate` frames (metadata only) on the v2 session. Needs
7
+ * LOCAL_AGENT_PROTOCOL_V2=true as well (without a v2 session there is nobody to send to: the flag does nothing).
8
+ *
9
+ * Sources (at least one, or the detector does not start):
10
+ * LOCAL_AGENT_INCIDENT_LOG_FILES comma separated file paths, tailed (new lines only; rotation/truncation followed)
11
+ * LOCAL_AGENT_INCIDENT_STDIN true = read log lines from the agent's own stdin (pipe a container/process into it)
12
+ * LOCAL_AGENT_INCIDENT_LOKI_URL base URL of a Loki (http/https), polled with LOCAL_AGENT_INCIDENT_LOKI_QUERY (LogQL, required)
13
+ * optional: LOCAL_AGENT_INCIDENT_LOKI_TOKEN (Bearer), LOCAL_AGENT_INCIDENT_LOKI_ORG (X-Scope-OrgID)
14
+ * Alloy / OTLP / cloud providers: documented hooks (see README.md in this folder), not built here.
15
+ * Every number is clamped: a bad value falls back to the default, never to "unlimited".
16
+ */
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+
20
+ export const INCIDENT_DETECTOR_FLAG = "LOCAL_AGENT_INCIDENT_DETECTOR";
21
+
22
+ const TRUE = new Set(["true", "1", "yes", "on"]);
23
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
24
+ const KIB = 1024;
25
+ const MIB = 1024 * 1024;
26
+
27
+ function int(raw, def, min, max) {
28
+ const n = Number.parseInt(String(raw ?? ""), 10);
29
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
30
+ }
31
+ const bool = (raw) => TRUE.has(String(raw ?? "").trim().toLowerCase());
32
+ const list = (raw, max) => String(raw ?? "").split(",").map((s) => s.trim()).filter(Boolean).slice(0, max);
33
+
34
+ export function resolveIncidentDetectorConfig(env = process.env, warn = console.warn) {
35
+ const raw = String(env[INCIDENT_DETECTOR_FLAG] ?? "").trim().toLowerCase();
36
+ if (!TRUE.has(raw)) {
37
+ if (!FALSE.has(raw)) warn(`[la-incidents] unrecognised ${INCIDENT_DETECTOR_FLAG} value; the incident detector stays off`);
38
+ return { enabled: false };
39
+ }
40
+ let lokiUrl = null;
41
+ const lokiRaw = String(env.LOCAL_AGENT_INCIDENT_LOKI_URL ?? "").trim();
42
+ if (lokiRaw) {
43
+ try {
44
+ const u = new URL(lokiRaw);
45
+ if (u.protocol === "http:" || u.protocol === "https:") lokiUrl = u.origin + u.pathname.replace(/\/+$/, "");
46
+ else warn("[la-incidents] LOCAL_AGENT_INCIDENT_LOKI_URL must be http(s); the Loki source is off");
47
+ } catch { warn("[la-incidents] LOCAL_AGENT_INCIDENT_LOKI_URL is not a URL; the Loki source is off"); }
48
+ }
49
+ const lokiQuery = String(env.LOCAL_AGENT_INCIDENT_LOKI_QUERY ?? "").trim();
50
+ if (lokiUrl && !lokiQuery) { warn("[la-incidents] LOCAL_AGENT_INCIDENT_LOKI_URL without LOCAL_AGENT_INCIDENT_LOKI_QUERY; the Loki source is off"); lokiUrl = null; }
51
+ const maxEmitsPerMinute = int(env.LOCAL_AGENT_INCIDENT_MAX_EMITS_PER_MINUTE, 60, 1, 600);
52
+ const flushMs = int(env.LOCAL_AGENT_INCIDENT_FLUSH_MS, 30_000, 200, 600_000);
53
+ return {
54
+ enabled: true,
55
+ files: list(env.LOCAL_AGENT_INCIDENT_LOG_FILES, 32),
56
+ stdin: bool(env.LOCAL_AGENT_INCIDENT_STDIN),
57
+ loki: lokiUrl ? {
58
+ url: lokiUrl,
59
+ query: lokiQuery.slice(0, 2000),
60
+ token: String(env.LOCAL_AGENT_INCIDENT_LOKI_TOKEN ?? "").trim() || null,
61
+ org: String(env.LOCAL_AGENT_INCIDENT_LOKI_ORG ?? "").trim().slice(0, 128) || null,
62
+ pollMs: int(env.LOCAL_AGENT_INCIDENT_LOKI_POLL_MS, 15_000, 1_000, 600_000),
63
+ limit: int(env.LOCAL_AGENT_INCIDENT_LOKI_LIMIT, 500, 1, 5_000),
64
+ maxPages: int(env.LOCAL_AGENT_INCIDENT_LOKI_MAX_PAGES, 5, 1, 50),
65
+ timeoutMs: int(env.LOCAL_AGENT_INCIDENT_LOKI_TIMEOUT_MS, 10_000, 500, 60_000),
66
+ maxResponseBytes: int(env.LOCAL_AGENT_INCIDENT_LOKI_MAX_RESPONSE_BYTES, 4 * MIB, 64 * KIB, 32 * MIB),
67
+ lookbackMs: int(env.LOCAL_AGENT_INCIDENT_LOKI_LOOKBACK_MS, 60_000, 0, 24 * 3_600_000), // first poll looks this far back
68
+ } : null,
69
+ service: String(env.LOCAL_AGENT_INCIDENT_SERVICE ?? "").trim() || null,
70
+ environment: String(env.LOCAL_AGENT_INCIDENT_ENVIRONMENT ?? "").trim().toLowerCase() || null,
71
+ workspaceId: String(env.LOCAL_AGENT_INCIDENT_WORKSPACE ?? "").trim() || null,
72
+ filePollMs: int(env.LOCAL_AGENT_INCIDENT_FILE_POLL_MS, 1_000, 50, 60_000),
73
+ fromStart: bool(env.LOCAL_AGENT_INCIDENT_FROM_START), // files: read the existing content on the first open (default: only new lines)
74
+ // detection / dedup
75
+ windowMs: int(env.LOCAL_AGENT_INCIDENT_WINDOW_MS, 300_000, 1_000, 3_600_000), // one candidate window per errorHash
76
+ flushMs, // at most one update per errorHash per flushMs
77
+ maxTracked: int(env.LOCAL_AGENT_INCIDENT_MAX_TRACKED, 5_000, 10, 100_000), // errorHashes tracked at once (LRU)
78
+ maxSeenKeys: int(env.LOCAL_AGENT_INCIDENT_MAX_SEEN_KEYS, 20_000, 100, 500_000), // natural keys remembered (re-read protection)
79
+ // bounds
80
+ maxLineBytes: int(env.LOCAL_AGENT_INCIDENT_MAX_LINE_BYTES, 16 * KIB, 512, 256 * KIB),
81
+ maxEntryBytes: int(env.LOCAL_AGENT_INCIDENT_MAX_ENTRY_BYTES, 64 * KIB, 1 * KIB, 1 * MIB), // message + stack of one entry
82
+ maxStackLines: int(env.LOCAL_AGENT_INCIDENT_MAX_STACK_LINES, 200, 1, 5_000),
83
+ maxPending: int(env.LOCAL_AGENT_INCIDENT_MAX_PENDING, 500, 1, 50_000), // candidates waiting to be sent
84
+ maxEmitsPerMinute,
85
+ emitBurst: Math.min(maxEmitsPerMinute, 20),
86
+ // local detail store (JSONL, 0600): the text of the error stays HERE
87
+ storeDir: String(env.LOCAL_AGENT_INCIDENT_STORE_DIR ?? "").trim() || path.join(os.homedir(), ".deepdebug", "incidents"),
88
+ storeMaxBytes: int(env.LOCAL_AGENT_INCIDENT_STORE_MAX_BYTES, 8 * MIB, 64 * KIB, 512 * MIB), // per file; at the limit it rotates to .1 (so 2x on disk)
89
+ storeMaxQueue: int(env.LOCAL_AGENT_INCIDENT_STORE_MAX_QUEUE, 200, 1, 10_000),
90
+ samplesPerWindow: int(env.LOCAL_AGENT_INCIDENT_SAMPLES_PER_WINDOW, 3, 1, 50), // occurrences kept per errorHash and window
91
+ storeMessageChars: int(env.LOCAL_AGENT_INCIDENT_STORE_MESSAGE_CHARS, 8_000, 100, 64_000),
92
+ storeStackChars: int(env.LOCAL_AGENT_INCIDENT_STORE_STACK_CHARS, 16_000, 100, 128_000),
93
+ auditFile: String(env.LOCAL_AGENT_INCIDENT_AUDIT_FILE ?? "").trim() || null,
94
+ };
95
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * detector.js - the incident detector of the Local Agent (F3-11b): sources -> entries -> the Java rules -> local store +
3
+ * candidates (metadata only) -> v2 session.
4
+ *
5
+ * For each entry (one error record of a log source):
6
+ * 1. an entry already seen (same service|timestamp|md5(message), the Java LogEntryDedup key: a Loki overlap, a restart) is skipped
7
+ * 2. ErrorDetector.isSignificant (severity ERROR/CRITICAL/FATAL/EMERG/ALERT/SEVERE + noise + stack/HTTP patterns); LogNoiseClassifier
8
+ * infra/build entries are skipped (the Java monitor persists them but never promotes them)
9
+ * 3. errorHash = group hash of the first line, fingerprint = IncidentFingerprintGenerator; no fingerprint (empty message) = skipped
10
+ * 4. the detail (message, stack) goes to the local store (masked by the DLP); the aggregator decides what is sent and when
11
+ * 5. the candidate frame carries metadata only (emitter.js)
12
+ * The text of the entry never leaves this process except into the local store. Counters only in logs and audit.
13
+ */
14
+ import { classifyNoise, computeFingerprint, computeGroupHash, extractErrorType, isNoiseCategory, isSignificant, naturalKey } from "./normalize.js";
15
+ import { inferEnvironment, logCategoryOf, severityOf } from "./categorize.js";
16
+ import { createAggregator } from "./aggregator.js";
17
+
18
+ export function createIncidentDetector({ config, emitter, store, audit = null, now = Date.now, timers = { setInterval, clearInterval }, sources = [], log = console }) {
19
+ const stats = { seen: 0, duplicates: 0, insignificant: 0, noise: 0, noFingerprint: 0, detected: 0, stored: 0, truncated: 0 };
20
+ const seenKeys = new Set(); // natural keys (insertion order = oldest first)
21
+ let tickTimer = null;
22
+ let started = false;
23
+ const note = (event, extra = {}) => { try { audit?.record({ event, ...extra }); } catch { /* never breaks the data path */ } };
24
+
25
+ const aggregator = createAggregator({
26
+ windowMs: config.windowMs, flushMs: config.flushMs, maxTracked: config.maxTracked, now,
27
+ onCandidate: (snap) => emitter.submit(snap),
28
+ });
29
+
30
+ function remember(key) {
31
+ if (key == null) return false;
32
+ if (seenKeys.has(key)) return true;
33
+ seenKeys.add(key);
34
+ if (seenKeys.size > config.maxSeenKeys) seenKeys.delete(seenKeys.values().next().value);
35
+ return false;
36
+ }
37
+
38
+ /** One entry: {severity, message, stacktrace, service, tsMs, tsNs?}. Resolves when its detail is queued (backpressure). */
39
+ async function ingest(entry) {
40
+ stats.seen++;
41
+ if (!entry || typeof entry.message !== "string") { stats.insignificant++; return; }
42
+ let message = entry.message;
43
+ let stack = typeof entry.stacktrace === "string" ? entry.stacktrace : null;
44
+ if (message.length + (stack ? stack.length : 0) > config.maxEntryBytes) {
45
+ stats.truncated++;
46
+ message = message.slice(0, config.maxEntryBytes);
47
+ stack = stack ? stack.slice(0, Math.max(0, config.maxEntryBytes - message.length)) : null;
48
+ }
49
+ const service = entry.service || config.service || "unknown";
50
+ const nanos = entry.tsNs != null ? entry.tsNs : Number.isFinite(entry.tsMs) ? BigInt(Math.trunc(entry.tsMs)) * 1_000_000n : null;
51
+ if (remember(naturalKey(service, nanos, message))) { stats.duplicates++; return; }
52
+ if (!isSignificant({ severity: entry.severity, message, stacktrace: stack })) { stats.insignificant++; return; }
53
+ if (isNoiseCategory(classifyNoise(message))) { stats.noise++; return; }
54
+ const fingerprint = computeFingerprint(message, stack);
55
+ if (fingerprint == null) { stats.noFingerprint++; return; }
56
+ const errorHash = computeGroupHash(message);
57
+ const severity = severityOf(entry.severity);
58
+ const environment = config.environment || inferEnvironment(service);
59
+ const logCategory = logCategoryOf(message, stack, extractErrorType(message, stack));
60
+ stats.detected++;
61
+ const stackBytes = typeof entry.stacktrace === "string" ? Buffer.byteLength(entry.stacktrace) : 0; // as the source gave it, before the entry cap
62
+ const { windowStart } = aggregator.observe({ errorHash, fingerprint, severity, service, environment, logCategory, tsMs: entry.tsMs, stackBytes });
63
+ // the detail of the occurrence, tied to its window (the store keeps a few samples per window); resolves when queued
64
+ if (await store.add({ errorHash, fingerprint, service, severity, windowStart, message, stack })) stats.stored++;
65
+ }
66
+
67
+ const onEntries = async (entries) => { for (const e of entries) await ingest(e); };
68
+
69
+ return {
70
+ stats,
71
+ aggregator,
72
+ ingest,
73
+ onEntries,
74
+ start() {
75
+ if (started) return;
76
+ started = true;
77
+ tickTimer = timers.setInterval(() => aggregator.tick(), Math.min(config.flushMs, 5_000));
78
+ if (tickTimer && typeof tickTimer.unref === "function") tickTimer.unref();
79
+ for (const s of sources) s.start(onEntries);
80
+ note("start", { sources: sources.map((s) => s.name) });
81
+ },
82
+ async stop() {
83
+ if (!started) return;
84
+ started = false;
85
+ for (const s of sources) { try { s.stop(); } catch { /* ignore */ } }
86
+ if (tickTimer) timers.clearInterval(tickTimer);
87
+ tickTimer = null;
88
+ aggregator.drain();
89
+ note("stop", {});
90
+ },
91
+ getDetail: (errorHash, opts) => store.getDetail(errorHash, opts),
92
+ };
93
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * emitter.js - sends `incident.candidate` frames (F3-11b, contract F3-22: la->cp, metadata only).
3
+ *
4
+ * The payload has the 9 contract fields: errorHash, fingerprint, severity, service, environment, count, firstSeen,
5
+ * lastSeen, logCategory and, ONLY when the CP announced `incident.candidate.size-model` in `ready.capabilities` (contract 2.1 (c)), two more
6
+ * that carry no text: `stackBytes` (UTF-8 size of the biggest stack of the window) and `model` (detector version, e.g. stack-detector-1.8.0). There is no field for text, a stack, a path or a message, and the session validates every frame against
7
+ * the closed schema before it is written, so even a bug here cannot add one. (window = firstSeen..lastSeen.)
8
+ *
9
+ * DLP service and environment are labels of the customer's own logs; they go through the DLP and a label it would change
10
+ * (or deny) is not sent at all: the candidate is dropped and counted (`dlp`).
11
+ * bounds pending candidates are coalesced by (errorHash, window): a newer count replaces the older one; at most maxPending;
12
+ * when full a NEW candidate is dropped (counted), an update of a known one is still accepted.
13
+ * Rate: a token bucket of maxEmitsPerMinute (burst emitBurst). ONE frame in flight; the ack is the answer.
14
+ * the ack accepted = done. rate_limited: back off 30 s doubling to 5 min, keep everything. protocol_unsupported: this gateway
15
+ * does not take candidates: forget the queue and stay quiet until the next `ready`. internal/timeout: send again after a
16
+ * short pause, at most 5 attempts in a row (then that candidate is dropped, counted). Frames the session refuses before
17
+ * writing (schema, size): dropped, counted (`invalid`). Not ready / reset / offline: kept; the next `ready` sends them.
18
+ * never logs or audits a message, a path or a label: counters and codes only.
19
+ */
20
+ import { ProtocolError } from "../la-protocol-v2/errors.js";
21
+ import { protectEgress } from "../dlp/index.js";
22
+ import { sanitizeEnvironment, sanitizeService } from "./categorize.js";
23
+
24
+ const BACKOFF_MIN_MS = 30_000;
25
+ const BACKOFF_MAX_MS = 300_000;
26
+ const RETRY_MS = 5_000;
27
+ const MAX_ATTEMPTS = 5;
28
+ const NON_RETRYABLE_REFUSALS = new Set(["schema_violation", "frame_too_large", "payload_too_large", "unknown_type", "wrong_direction"]);
29
+
30
+ const SIZE_MODEL_CAPABILITY = "incident.candidate.size-model";
31
+ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$/;
32
+ const MAX_STACK_BYTES = 16 * 1024 * 1024;
33
+
34
+ export function createCandidateEmitter({ session, config, workspaceId = "", model = null, env = process.env, audit = null, log = console, now = Date.now, timers = { setTimeout, clearTimeout } }) {
35
+ const warn = (m) => (log.warn || log.log).call(log, m);
36
+ const pending = new Map(); // `${errorHash}|${windowStart}` -> snapshot
37
+ const stats = { submitted: 0, sent: 0, coalesced: 0, droppedFull: 0, droppedDlp: 0, droppedInvalid: 0, droppedGaveUp: 0, droppedStopped: 0, rejected: 0, byCode: {} };
38
+ let tokens = config.emitBurst;
39
+ let refillAt = now();
40
+ let timer = null;
41
+ let running = null;
42
+ let stopped = false; // protocol_unsupported: quiet until the next ready
43
+ let closed = false;
44
+ let attempts = 0;
45
+ let backoffMs = 0;
46
+ let notBefore = 0;
47
+ const unref = (t) => { if (t && typeof t.unref === "function") t.unref(); return t; };
48
+ const note = (event, extra = {}) => { try { audit?.record({ event, ...extra }); } catch { /* the audit never breaks the data path */ } };
49
+
50
+ function refill() {
51
+ const t = now();
52
+ const add = ((t - refillAt) * config.maxEmitsPerMinute) / 60_000;
53
+ if (add > 0) { tokens = Math.min(config.emitBurst, tokens + add); refillAt = t; }
54
+ }
55
+
56
+ const iso = (ms) => new Date(ms).toISOString();
57
+
58
+ /** The 9 fields, or null when a label does not survive the DLP unchanged. */
59
+ function payloadOf(s) {
60
+ const service = sanitizeService(s.service);
61
+ const environment = sanitizeEnvironment(s.environment);
62
+ for (const label of [service, environment]) {
63
+ const r = protectEgress(label, { env, source: "incident-candidate" });
64
+ if (r.denied || r.text !== label) return null;
65
+ }
66
+ const payload = { errorHash: s.errorHash, fingerprint: s.fingerprint, severity: s.severity, service, environment, count: s.count, firstSeen: iso(s.firstSeen), lastSeen: iso(s.lastSeen), logCategory: s.logCategory };
67
+ // contract 2.1 (c): only when the CP said (ready.capabilities) it takes them: a 2.0 CP answers the closed schema with schema_violation
68
+ if (typeof session.peerHas === "function" && session.peerHas(SIZE_MODEL_CAPABILITY)) {
69
+ if (Number.isFinite(s.stackBytes)) payload.stackBytes = Math.max(0, Math.min(Math.trunc(s.stackBytes), MAX_STACK_BYTES));
70
+ if (typeof model === "string" && MODEL.test(model)) payload.model = model;
71
+ }
72
+ return payload;
73
+ }
74
+
75
+ function schedule(ms) {
76
+ if (closed || timer) return;
77
+ timer = unref(timers.setTimeout(() => { timer = null; void pump(); }, ms));
78
+ }
79
+
80
+ function pump() {
81
+ if (running) return running;
82
+ if (closed || stopped || pending.size === 0 || session.state !== "ready") return Promise.resolve();
83
+ if (notBefore > now()) { schedule(notBefore - now()); return Promise.resolve(); }
84
+ running = drain().finally(() => { running = null; });
85
+ return running;
86
+ }
87
+
88
+ async function drain() {
89
+ try {
90
+ while (!closed && !stopped && pending.size > 0 && session.state === "ready") {
91
+ refill();
92
+ if (tokens < 1) { schedule(Math.ceil(((1 - tokens) * 60_000) / config.maxEmitsPerMinute)); return; }
93
+ const [key, snap] = pending.entries().next().value;
94
+ let payload = null;
95
+ let unbuildable = false;
96
+ try { payload = payloadOf(snap); } catch { unbuildable = true; } // e.g. an invalid date from an untrusted log line: dropped, it never blocks the queue
97
+ if (!payload) {
98
+ pending.delete(key);
99
+ if (unbuildable) { stats.droppedInvalid++; note("drop", { reason: "invalid", errorHash: snap.errorHash }); }
100
+ else { stats.droppedDlp++; note("drop", { reason: "dlp", errorHash: snap.errorHash }); }
101
+ continue;
102
+ }
103
+ tokens -= 1;
104
+ try {
105
+ await session.send("incident.candidate", payload, { workspaceId: workspaceId || undefined });
106
+ } catch (e) {
107
+ const code = e instanceof ProtocolError ? e.code : "internal";
108
+ stats.rejected++;
109
+ stats.byCode[code] = (stats.byCode[code] || 0) + 1;
110
+ tokens += 1; // nothing was spent on the wire for a refusal before writing; for the others the bucket stays honest enough
111
+ if (NON_RETRYABLE_REFUSALS.has(code)) {
112
+ pending.delete(key); stats.droppedInvalid++; note("drop", { reason: "invalid", code, errorHash: snap.errorHash });
113
+ continue;
114
+ }
115
+ if (code === "protocol_unsupported") {
116
+ stopped = true; pending.clear();
117
+ warn("[la-incidents] the gateway does not take incident candidates (protocol_unsupported); quiet until the next connection");
118
+ } else if (code === "rate_limited") {
119
+ backoffMs = Math.min(BACKOFF_MAX_MS, backoffMs ? backoffMs * 2 : BACKOFF_MIN_MS);
120
+ notBefore = now() + backoffMs;
121
+ warn(`[la-incidents] candidates rate limited; sending again in ${Math.round(backoffMs / 1000)} s`);
122
+ schedule(backoffMs);
123
+ } else if (code === "internal" || code === "timeout") {
124
+ if (++attempts >= MAX_ATTEMPTS) {
125
+ attempts = 0; pending.delete(key); stats.droppedGaveUp++; note("drop", { reason: "gave_up", code, errorHash: snap.errorHash });
126
+ warn(`[la-incidents] one candidate not accepted (${code}); gave up after ${MAX_ATTEMPTS} attempts`);
127
+ if (pending.size > 0) schedule(RETRY_MS);
128
+ } else {
129
+ warn(`[la-incidents] candidate not accepted (${code}); trying again shortly`);
130
+ schedule(RETRY_MS);
131
+ }
132
+ }
133
+ return; // anything else (not_ready, agent_offline, reset): kept; the next `ready` sends again
134
+ }
135
+ attempts = 0; backoffMs = 0; notBefore = 0;
136
+ stats.sent++;
137
+ note("candidate", { errorHash: snap.errorHash, severity: snap.severity, count: snap.count, logCategory: snap.logCategory });
138
+ if (pending.get(key) === snap) pending.delete(key); // a newer count that arrived meanwhile stays for the next round
139
+ }
140
+ } catch { /* nothing here may throw into the data path */ }
141
+ }
142
+
143
+ const onReady = () => { stopped = false; backoffMs = 0; notBefore = 0; attempts = 0; void pump(); };
144
+ session.on("ready", onReady);
145
+
146
+ return {
147
+ stats,
148
+ get pending() { return pending.size; },
149
+ submit(snap) {
150
+ if (closed) return false;
151
+ stats.submitted++;
152
+ if (stopped) { stats.droppedStopped++; return false; } // this gateway takes no candidates (until the next ready)
153
+ const key = `${snap.errorHash}|${snap.windowStart}`;
154
+ if (pending.has(key)) { pending.set(key, snap); stats.coalesced++; }
155
+ else if (pending.size >= config.maxPending) { stats.droppedFull++; note("drop", { reason: "queue_full", errorHash: snap.errorHash }); return false; }
156
+ else pending.set(key, snap);
157
+ void pump();
158
+ return true;
159
+ },
160
+ /** Waits until nothing is pending or in flight, or the session is not ready (tests, shutdown). */
161
+ async flush() { if (timer) { timers.clearTimeout(timer); timer = null; } await pump(); if (running) await running; },
162
+ close() { closed = true; if (timer) timers.clearTimeout(timer); timer = null; pending.clear(); session.off("ready", onReady); },
163
+ };
164
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * index.js - installs the incident detector on a v2 link (F3-11b). Called by la-protocol-v2/runtime.js only when
3
+ * LOCAL_AGENT_INCIDENT_DETECTOR=true (and v2 is on). Returns the detector (link.incidents), or null when there is nothing to start.
4
+ */
5
+ import { createAudit } from "../la-protocol-v2/content-handlers/audit.js";
6
+ import { createDetailStore } from "./store.js";
7
+ import { createCandidateEmitter } from "./emitter.js";
8
+ import { createIncidentDetector } from "./detector.js";
9
+ import { createFileSource } from "./sources/file-source.js";
10
+ import { createLokiSource } from "./sources/loki-source.js";
11
+ import { createStreamSource } from "./sources/stream-source.js";
12
+
13
+ export { INCIDENT_DETECTOR_FLAG, resolveIncidentDetectorConfig } from "./config.js";
14
+
15
+ const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
16
+
17
+ export function buildSources(config, { stdin = process.stdin } = {}) {
18
+ const common = { maxLineBytes: config.maxLineBytes, maxEntryBytes: config.maxEntryBytes, maxStackLines: config.maxStackLines };
19
+ const sources = [];
20
+ for (const filePath of config.files) sources.push(createFileSource({ filePath, service: config.service, fromStart: config.fromStart, pollMs: config.filePollMs, ...common }));
21
+ if (config.stdin) sources.push(createStreamSource({ stream: stdin, service: config.service || "stdin", ...common }));
22
+ if (config.loki) sources.push(createLokiSource({ ...config.loki, service: config.service }));
23
+ return sources;
24
+ }
25
+
26
+ export function installIncidentDetector({ link, workspaceId = "", config, env = process.env, log = console, sources = null, extraSources = [], model = null }) {
27
+ const list = sources || [...buildSources(config), ...extraSources]; // extraSources: push sources owned by someone else (the webhook receiver, F3-12 consumer)
28
+ if (list.length === 0) {
29
+ log.warn("[la-incidents] LOCAL_AGENT_INCIDENT_DETECTOR=true but no source is configured (LOCAL_AGENT_INCIDENT_LOG_FILES, LOCAL_AGENT_INCIDENT_STDIN or LOCAL_AGENT_INCIDENT_LOKI_URL): the detector does not start");
30
+ return null;
31
+ }
32
+ const ws = WORKSPACE_ID.test(config.workspaceId || "") && config.workspaceId ? config.workspaceId : workspaceId;
33
+ const audit = createAudit({ file: config.auditFile });
34
+ const store = createDetailStore({ dir: config.storeDir, maxBytes: config.storeMaxBytes, maxQueue: config.storeMaxQueue, messageChars: config.storeMessageChars,
35
+ stackChars: config.storeStackChars, samplesPerWindow: config.samplesPerWindow, env });
36
+ const emitter = createCandidateEmitter({ session: link.session, config, workspaceId: ws, model, env, audit, log });
37
+ const detector = createIncidentDetector({ config, emitter, store, audit, sources: list, log });
38
+ const closeLink = link.close.bind(link);
39
+ link.close = () => { void detector.stop().finally(() => { emitter.close(); }); closeLink(); };
40
+ link.incidents = { detector, emitter, store, audit, getDetail: (errorHash, opts) => store.getDetail(errorHash, opts) };
41
+ detector.start();
42
+ log.info(`[la-incidents] incident detector on (${list.map((s) => s.name).join(", ")}); candidates carry metadata only, the detail stays in the local store`);
43
+ return link.incidents;
44
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * java-compat.js - the few java.lang.String behaviours the parity needs (F3-11b).
3
+ *
4
+ * The detector must produce the SAME hashes as the Java code (monitor-service, shared/). JS and Java differ in small,
5
+ * silent ways that change a hash for exactly the messages nobody tests by hand:
6
+ * - `\s` / `\S` Java: [ \t\n\x0B\f\r] only. JS also matches NBSP, U+2003, U+2028... -> use SP_CLASS / NOT_SP_CLASS.
7
+ * - String.trim() Java removes every char <= U+0020. JS trim() removes Unicode whitespace (and not U+0001..U+001F).
8
+ * - String.isBlank() Java: Character.isWhitespace (no NBSP / U+2007 / U+202F). JS: Unicode White_Space.
9
+ * - stripLeading() same rule as isBlank.
10
+ * - getBytes(UTF_8) a lone surrogate becomes '?'. JS Buffer.from() writes U+FFFD (EF BF BD).
11
+ * Everything here is proven against the vectors generated from the Java code (test/incident-detector/parity.vectors.json).
12
+ */
13
+ import crypto from "node:crypto";
14
+
15
+ /** Java's `\s` as a character-class body (use inside [...]) and as a standalone class. */
16
+ export const SP = " \\t\\n\\x0B\\f\\r";
17
+ export const SP_CLASS = `[${SP}]`;
18
+ export const NOT_SP_CLASS = `[^${SP}]`;
19
+
20
+ /** String.trim(): strips every leading/trailing char <= U+0020. */
21
+ export function javaTrim(s) {
22
+ let a = 0;
23
+ let b = s.length;
24
+ while (a < b && s.charCodeAt(a) <= 0x20) a++;
25
+ while (b > a && s.charCodeAt(b - 1) <= 0x20) b--;
26
+ return a === 0 && b === s.length ? s : s.slice(a, b);
27
+ }
28
+
29
+ /** Character.isWhitespace(char) as used by String.isBlank()/stripLeading(). */
30
+ export function javaIsWhitespace(c) {
31
+ if (c === 0x20 || (c >= 0x09 && c <= 0x0d) || (c >= 0x1c && c <= 0x1f)) return true;
32
+ if (c < 0x80) return false;
33
+ if (c === 0x00a0 || c === 0x2007 || c === 0x202f) return false; // non-breaking spaces are excluded
34
+ return c === 0x1680 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x205f || c === 0x3000;
35
+ }
36
+
37
+ export function javaIsBlank(s) {
38
+ for (let i = 0; i < s.length; i++) if (!javaIsWhitespace(s.charCodeAt(i))) return false;
39
+ return true;
40
+ }
41
+
42
+ export function javaStripLeading(s) {
43
+ let i = 0;
44
+ while (i < s.length && javaIsWhitespace(s.charCodeAt(i))) i++;
45
+ return i === 0 ? s : s.slice(i);
46
+ }
47
+
48
+ /** s.getBytes(StandardCharsets.UTF_8): unpaired surrogates are replaced by '?' (0x3F), not by U+FFFD. */
49
+ export function javaUtf8(s) {
50
+ // fast path: no surrogate at all
51
+ if (!/[\ud800-\udfff]/.test(s)) return Buffer.from(s, "utf8");
52
+ let out = "";
53
+ for (let i = 0; i < s.length; i++) {
54
+ const c = s.charCodeAt(i);
55
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
56
+ const d = s.charCodeAt(i + 1);
57
+ if (d >= 0xdc00 && d <= 0xdfff) { out += s[i] + s[i + 1]; i++; continue; }
58
+ }
59
+ out += c >= 0xd800 && c <= 0xdfff ? "?" : s[i];
60
+ }
61
+ return Buffer.from(out, "utf8");
62
+ }
63
+
64
+ export const sha256Hex = (s) => crypto.createHash("sha256").update(javaUtf8(s)).digest("hex");
65
+ export const sha256Prefix16 = (s) => crypto.createHash("sha256").update(javaUtf8(s)).digest().subarray(0, 16).toString("hex");
66
+ export const md5Hex = (s) => crypto.createHash("md5").update(javaUtf8(s)).digest("hex");