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,209 @@
1
+ /**
2
+ * normalize.js - the Java detection rules, ported (F3-11b). The Java code is the source of truth; this file must give
3
+ * byte-identical results (parity.vectors.json is generated from it by scripts/incident-parity/gen.sh):
4
+ *
5
+ * isSignificant monitor-service ErrorDetector.isSignificant (severity gate + noise + stack/HTTP patterns)
6
+ * groupHash/Key monitor-service MonitorPollScheduler.computeGroupHash (sha256 of the normalised FIRST LINE, first 16 bytes = 32 hex)
7
+ * = the candidate's `errorHash` (it is log_entries.error_hash and incident_dedup_log.error_hash)
8
+ * fingerprint shared IncidentFingerprintGenerator.compute(extractErrorType, extractFirstUserFrame, message) (sha256, 64 hex)
9
+ * classifyNoise shared LogNoiseClassifier.classify (infra/build noise is persisted by Java but never promoted)
10
+ * naturalKey monitor-service LogEntryDedup.key (service|epoch micros|md5(message)): a re-read of the same entry
11
+ *
12
+ * NOT ported: ErrorDetector.hash (the legacy `service|message` hash). Java only uses it when computeGroupHash throws, which it
13
+ * cannot do on a String (parity vectors still carry it as `legacyHash`).
14
+ */
15
+ import { SP, SP_CLASS, NOT_SP_CLASS, javaTrim, javaIsBlank, javaStripLeading, sha256Hex, sha256Prefix16, md5Hex } from "./java-compat.js";
16
+
17
+ // ---------------------------------------------------------------------------------------------- group hash (errorHash)
18
+
19
+ const GROUP_URL = new RegExp(`https?://${NOT_SP_CLASS}+`, "g");
20
+ const GROUP_UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
21
+ const GROUP_ADDR = /0x[0-9a-f]+/g;
22
+ const GROUP_HEX = /\b[0-9a-f]{12,}\b/g;
23
+ const GROUP_VER = /\d+(\.\d+)+/g;
24
+ const GROUP_DIGITS = /\d+/g;
25
+ const GROUP_WS = new RegExp(`${SP_CLASS}+`, "g");
26
+
27
+ /** Order matters (as in Java): URL first (may contain uuid/digits), then uuid, 0x-addr, long hex, versions, digits. */
28
+ export function normalizeGroupKey(firstLine) {
29
+ return javaTrim(String(firstLine).toLowerCase()
30
+ .replace(GROUP_URL, "<url>")
31
+ .replace(GROUP_UUID, "<uuid>")
32
+ .replace(GROUP_ADDR, "<addr>")
33
+ .replace(GROUP_HEX, "<hex>")
34
+ .replace(GROUP_VER, "<ver>")
35
+ .replace(GROUP_DIGITS, "<n>")
36
+ .replace(GROUP_WS, " "));
37
+ }
38
+
39
+ /** errorHash: 32 hex. Only the FIRST line of the message counts (the stack trace never does). */
40
+ export function computeGroupHash(message) {
41
+ const msg = message == null ? "" : String(message);
42
+ const nl = msg.indexOf("\n");
43
+ return sha256Prefix16(normalizeGroupKey(nl >= 0 ? msg.slice(0, nl) : msg));
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------------------------- fingerprint
47
+
48
+ const PG_LOG_PREAMBLE = new RegExp(
49
+ `^${SP_CLASS}*\\d{4}-\\d{2}-\\d{2}${SP_CLASS}+\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?${SP_CLASS}+\\w+${SP_CLASS}+\\[\\d+\\]:${SP_CLASS}+\\[[^\\]]+\\]${SP_CLASS}+(?:db=[^,]+,user=${NOT_SP_CLASS}+${SP_CLASS}+)?(?:ERROR|WARNING|FATAL|LOG|NOTICE|INFO|DEBUG):?${SP_CLASS}*`,
50
+ "i");
51
+ const ISO_TIMESTAMP = new RegExp(`\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:${SP_CLASS}+[A-Z]{2,5}|[+-]\\d{2}:?\\d{2}|Z)?`, "g");
52
+ const UUID_PAT = /[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}/g;
53
+ const HEX_ADDR = /(?:0x|@)[0-9a-fA-F]{4,}/g;
54
+ const BRACKET_PID = /\[\d{2,}\]/g;
55
+ const LINE_NUM_TAIL = /(\.java|\.kt|\.swift|\.py|\.js|\.ts|\.rb|\.go):\d+/g;
56
+ const LONG_INT = /(?<![\w-])\d{5,}(?![\w-])/g;
57
+ const WS = new RegExp(`${SP_CLASS}+`, "g");
58
+
59
+ export function normaliseMessage(raw) {
60
+ if (raw == null) return null;
61
+ let s = String(raw);
62
+ s = s.replace(PG_LOG_PREAMBLE, ""); // anchored with ^ and no /g: at most one match, as in Java
63
+ s = s.replace(ISO_TIMESTAMP, "<ts>");
64
+ s = s.replace(UUID_PAT, "<uuid>");
65
+ s = s.replace(HEX_ADDR, "<addr>");
66
+ s = s.replace(BRACKET_PID, "[<pid>]");
67
+ s = s.replace(LONG_INT, "<n>");
68
+ s = javaTrim(s.replace(WS, " "));
69
+ return s.length > 500 ? s.slice(0, 500) : s;
70
+ }
71
+
72
+ export function normaliseFrame(raw) {
73
+ if (raw == null) return "";
74
+ let s = javaTrim(String(raw));
75
+ if (s.startsWith("at ")) s = s.slice(3);
76
+ s = s.replace(LINE_NUM_TAIL, "$1:<line>");
77
+ s = s.replace(HEX_ADDR, "<addr>");
78
+ return s.length > 200 ? s.slice(0, 200) : s;
79
+ }
80
+
81
+ const LINE_SPLIT = /\r?\n/;
82
+
83
+ /** IncidentFingerprintGenerator.extractErrorType (quirks included: "Caused by: X: y" gives "Caused by"). */
84
+ export function extractErrorType(message, stacktrace) {
85
+ if (stacktrace != null && !javaIsBlank(stacktrace)) {
86
+ const m = LINE_SPLIT.exec(stacktrace);
87
+ const first = javaTrim(m ? stacktrace.slice(0, m.index) : stacktrace);
88
+ const colon = first.indexOf(":");
89
+ const space = first.indexOf(" ");
90
+ if ((colon > 0 && space < 0) || (colon > 0 && colon < space)) return javaTrim(first.slice(0, colon));
91
+ if (first.startsWith("Caused by:")) {
92
+ const rest = javaTrim(first.slice("Caused by:".length));
93
+ const colon2 = rest.indexOf(":");
94
+ return colon2 > 0 ? javaTrim(rest.slice(0, colon2)) : rest;
95
+ }
96
+ }
97
+ if (message != null) {
98
+ const m = String(message);
99
+ if (PG_LOG_PREAMBLE.test(m)) {
100
+ if (m.includes("ERROR:")) return "PG_ERROR";
101
+ if (m.includes("FATAL:")) return "PG_FATAL";
102
+ if (m.includes("WARNING:")) return "PG_WARNING";
103
+ }
104
+ const colon = m.indexOf(":");
105
+ const space = m.indexOf(" ");
106
+ if (colon > 0 && (space < 0 || colon < space) && m.slice(0, colon).includes(".")) return javaTrim(m.slice(0, colon));
107
+ }
108
+ return null;
109
+ }
110
+
111
+ const FRAMEWORK_SKIP = ["at org.springframework.", "at reactor.", "at io.netty.", "at io.r2dbc.", "at org.apache.", "at com.fasterxml.", "at java.", "at javax.", "at jakarta."];
112
+ const FRAMEWORK_CONTAINS = ["java.lang.reflect", "sun.reflect", "jdk.internal", "$Proxy"];
113
+ const cut200 = (l) => (l.length > 200 ? l.slice(0, 200) : l);
114
+
115
+ export function extractFirstUserFrame(stacktrace) {
116
+ if (stacktrace == null) return "";
117
+ const lines = String(stacktrace).split(LINE_SPLIT).map(javaTrim);
118
+ for (const line of lines) {
119
+ if (!line.startsWith("at ")) continue;
120
+ if (FRAMEWORK_CONTAINS.some((c) => line.includes(c)) || FRAMEWORK_SKIP.some((p) => line.startsWith(p))) continue;
121
+ return cut200(line);
122
+ }
123
+ for (const line of lines) if (line.startsWith("at ")) return cut200(line);
124
+ return "";
125
+ }
126
+
127
+ /** IncidentFingerprintGenerator.compute: sha256 (64 hex) or null when the normalised message is empty/blank. */
128
+ export function computeFingerprintOf(errorType, firstFrame, message) {
129
+ const nm = normaliseMessage(message);
130
+ if (nm == null || javaIsBlank(nm)) return null;
131
+ return sha256Hex(`${javaTrim(errorType == null ? "" : String(errorType))}|${normaliseFrame(firstFrame)}|${nm}`);
132
+ }
133
+
134
+ /** What the gateway/orchestrator do with a (message, stack) pair. */
135
+ export function computeFingerprint(message, stacktrace) {
136
+ return computeFingerprintOf(extractErrorType(message, stacktrace), extractFirstUserFrame(stacktrace), message);
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------------------------- significance and noise
140
+
141
+ const ERROR_LEVELS = new Set(["ERROR", "CRITICAL", "FATAL", "EMERG", "ALERT", "SEVERE"]);
142
+ const NOISE_PATTERNS = [
143
+ /healthcheck/i, /\/actuator\/health/i, /broken pipe/i, /connection reset by peer/i, /client disconnected/i, /favicon\.ico/i,
144
+ new RegExp(`/ping${SP_CLASS}`, "i"),
145
+ ];
146
+ const STACKTRACE_PATTERNS = [
147
+ /\bat [\w$.]+\([\w.]+:\d+\)/,
148
+ /[\w]+\.swift:\d+/,
149
+ /\(\/[^)]*\.(?:js|ts|mjs|cjs):\d+:\d+\)/,
150
+ /File "[^"]+\.py", line \d+/,
151
+ new RegExp(`file:///[^${SP})]*\\.(?:js|ts|mjs|cjs|jsx|tsx):\\d+(?::\\d+)?`),
152
+ /Exception|Error: /i,
153
+ ];
154
+ const HTTP_ERROR_PATTERN = new RegExp(`\\bHTTP${SP_CLASS}+([45]\\d\\d)\\b`, "i");
155
+ const safeUpper = (s) => (s == null ? "" : String(s).toUpperCase());
156
+
157
+ /** ErrorDetector.isSignificant: `entry` = {severity, message, stacktrace}. */
158
+ export function isSignificant(entry) {
159
+ if (!entry || entry.message == null) return false;
160
+ const message = String(entry.message);
161
+ if (!ERROR_LEVELS.has(safeUpper(entry.severity))) return false;
162
+ for (const p of NOISE_PATTERNS) if (p.test(message)) return false;
163
+ const stack = entry.stacktrace == null ? "" : String(entry.stacktrace);
164
+ for (const p of STACKTRACE_PATTERNS) if (p.test(message) || p.test(stack)) return true;
165
+ if (HTTP_ERROR_PATTERN.test(message)) return true;
166
+ const sev = safeUpper(entry.severity);
167
+ return sev === "FATAL" || sev === "CRITICAL";
168
+ }
169
+
170
+ const PLATFORM_PROBE = /\b(STARTUP|LIVENESS|READINESS) (TCP|HTTP|GRPC) probe failed/;
171
+
172
+ /** LogNoiseClassifier.classify: "app_error" | "infra" | "build". */
173
+ export function classifyNoise(message) {
174
+ if (message == null || javaIsBlank(String(message))) return "app_error";
175
+ const m = javaStripLeading(String(message));
176
+ if (m.startsWith("{code=")) return "infra";
177
+ if (m.includes("Ready condition status changed")) return "infra";
178
+ if (m.startsWith("Memory limit of")) return "infra";
179
+ if (m.startsWith("Uncaught signal")) return "infra";
180
+ if (m.startsWith("The request failed because either the HTTP response was malformed")) return "infra";
181
+ if (PLATFORM_PROBE.test(m)) return "infra";
182
+ if (m.includes("AUDIT EVENT: method=")) return "infra";
183
+ if (m.startsWith("{@type=type.googleapis.com/google.cloud.audit.AuditLog")) return "infra";
184
+ if (m.includes("MongoSocketWriteException") || m.includes("MongoSocketReadException")) return "infra";
185
+ if (m.includes("LifecycleExecutionException")) return "build";
186
+ if (m.includes("Failed to execute goal")) return "build";
187
+ if (m.includes("Execution failed for task")) return "build";
188
+ if (m.startsWith("FAILURE: Build failed with an exception")) return "build";
189
+ return "app_error";
190
+ }
191
+
192
+ export const isNoiseCategory = (c) => c === "infra" || c === "build";
193
+
194
+ // ---------------------------------------------------------------------------------------------- natural key (re-read of the same entry)
195
+
196
+ export const MAX_MESSAGE_CHARS = 8000;
197
+
198
+ /** Epoch microseconds, truncated (floor) like Instant.truncatedTo(MICROS). `nanos` is a BigInt of epoch nanoseconds. */
199
+ export const epochMicros = (nanos) => {
200
+ const q = nanos / 1000n;
201
+ return nanos % 1000n < 0n ? q - 1n : q;
202
+ };
203
+
204
+ /** LogEntryDedup.key: null when the entry has no timestamp. `epochNanos` is a BigInt or null. */
205
+ export function naturalKey(service, epochNanos, message) {
206
+ if (epochNanos == null) return null;
207
+ const stored = message == null ? "" : String(message).length <= MAX_MESSAGE_CHARS ? String(message) : String(message).slice(0, MAX_MESSAGE_CHARS);
208
+ return `${service == null ? "unknown" : service}|${epochMicros(epochNanos)}|${md5Hex(stored)}`;
209
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * parse.js - from log lines to entries {severity, message, stacktrace, service, tsMs} (F3-11b).
3
+ *
4
+ * JSON line {"level":"error","message":"...","stack_trace":"..."} (and the usual aliases: msg/log/textPayload, severity/lvl,
5
+ * service/service_name/app, time/timestamp/@timestamp/ts, stack/stacktrace/exception.stacktrace/error.stack)
6
+ * plain text a record starts at a line with a timestamp or a level token; the lines that follow (indented, "Caused by:",
7
+ * "... n more", python Traceback/File lines, or any line that does not look like a new record) are its stack trace.
8
+ *
9
+ * message = the header line (for Java parity only its first line enters errorHash; the fingerprint also sees the stack's first
10
+ * line, as the Java code does). Entries are capped (maxEntryBytes, maxStackLines): the excess is dropped and counted.
11
+ */
12
+ const LEVEL = /\b(FATAL|CRITICAL|EMERG(?:ENCY)?|ALERT|SEVERE|ERROR|ERR|WARN(?:ING)?|NOTICE|INFO|DEBUG|TRACE)\b/i;
13
+ const RECORD_START = /^\s*(?:\[?\d{4}-\d{2}-\d{2}|\[?\d{2}:\d{2}:\d{2}|[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|\[?(?:FATAL|CRITICAL|SEVERE|ERROR|ERR|WARN|WARNING|INFO|DEBUG|TRACE)\b)/;
14
+ const ISO_PREFIX = /^\s*\[?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,9})?(?:Z|[+-]\d{2}:?\d{2})?)/;
15
+
16
+ const pick = (o, keys) => { for (const k of keys) { const v = o[k]; if (v !== undefined && v !== null && v !== "") return v; } return undefined; };
17
+ const str = (v) => (v == null ? undefined : typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : undefined);
18
+
19
+ /** @returns {number|null} epoch ms */
20
+ export function parseTime(v) {
21
+ if (v == null) return null;
22
+ if (typeof v === "number") {
23
+ const ms = v > 1e17 ? v / 1e6 : v > 1e14 ? v / 1e3 : v > 1e11 ? v : v * 1000; // ns, us, ms, s
24
+ return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? Math.round(ms) : null; // outside the Date range: not a timestamp
25
+ }
26
+ if (typeof v !== "string") return null;
27
+ const m = ISO_PREFIX.exec(v) || null;
28
+ const iso = m ? m[1].replace(" ", "T").replace(",", ".") : v;
29
+ const t = Date.parse(/(?:Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`);
30
+ return Number.isFinite(t) ? t : null;
31
+ }
32
+
33
+ const levelOf = (message) => { const m = LEVEL.exec(message.slice(0, 300)); return m ? m[1] : null; };
34
+
35
+ function splitMessage(text) {
36
+ const i = text.indexOf("\n");
37
+ return i < 0 ? { message: text, stack: null } : { message: text.slice(0, i).replace(/\r$/, ""), stack: text.slice(i + 1) || null };
38
+ }
39
+
40
+ /** One complete line or block (Loki entries, JSON lines). @returns {object|null} */
41
+ export function parseRecord(text) {
42
+ if (typeof text !== "string" || text === "") return null;
43
+ const t = text.trimStart();
44
+ if (t.startsWith("{")) {
45
+ let o = null;
46
+ try { o = JSON.parse(t); } catch { o = null; }
47
+ if (o && typeof o === "object" && !Array.isArray(o)) {
48
+ const msgRaw = str(pick(o, ["message", "msg", "log", "textPayload", "text", "event"]))
49
+ ?? str(o.jsonPayload && typeof o.jsonPayload === "object" ? o.jsonPayload.message : undefined);
50
+ if (msgRaw === undefined) return null;
51
+ const err = o.error && typeof o.error === "object" ? o.error : {};
52
+ const exc = o.exception && typeof o.exception === "object" ? o.exception : {};
53
+ const stack = str(pick(o, ["stack_trace", "stacktrace", "stackTrace", "stack"])) ?? str(pick(exc, ["stacktrace", "stack_trace"])) ?? str(pick(err, ["stack", "stack_trace"]));
54
+ const { message, stack: rest } = splitMessage(msgRaw);
55
+ return {
56
+ severity: str(pick(o, ["level", "severity", "lvl", "levelname"])) ?? levelOf(message),
57
+ message: stack ? msgRaw : message, // a separate stack field: the message is the whole text (its first line is what errorHash uses)
58
+ stacktrace: stack ?? rest,
59
+ service: str(pick(o, ["service", "service_name", "serviceName", "app", "application", "container_name"])) ?? null,
60
+ tsMs: parseTime(pick(o, ["timestamp", "time", "@timestamp", "ts", "t"])),
61
+ };
62
+ }
63
+ }
64
+ const { message, stack } = splitMessage(text);
65
+ const tm = ISO_PREFIX.exec(message);
66
+ return { severity: levelOf(message), message, stacktrace: stack, service: null, tsMs: tm ? parseTime(tm[1]) : null };
67
+ }
68
+
69
+ /**
70
+ * Joins the lines of a plain-text file/stream into entries. push(line) returns the entries completed by that line; flush() returns
71
+ * the one in progress (call it on a short idle timer and at the end). JSON lines are entries by themselves.
72
+ */
73
+ export class EntryAssembler {
74
+ constructor({ maxEntryBytes = 64 * 1024, maxStackLines = 200 } = {}) {
75
+ this.maxEntryBytes = maxEntryBytes;
76
+ this.maxStackLines = maxStackLines;
77
+ this.cur = null;
78
+ this.truncated = 0;
79
+ }
80
+ #finish() {
81
+ const c = this.cur;
82
+ this.cur = null;
83
+ if (!c) return null;
84
+ const entry = parseRecord(c.lines.join("\n"));
85
+ if (entry && c.truncated) this.truncated++;
86
+ return entry;
87
+ }
88
+ push(line) {
89
+ const out = [];
90
+ if (line === "") return out;
91
+ const isJson = line.trimStart().startsWith("{");
92
+ // anything that does not look like the start of a record belongs to the one in progress (its message continuation or its stack)
93
+ if (this.cur && !isJson && !RECORD_START.test(line)) {
94
+ const c = this.cur;
95
+ if (c.lines.length - 1 >= this.maxStackLines || c.bytes + line.length > this.maxEntryBytes) { c.truncated = true; return out; }
96
+ c.lines.push(line);
97
+ c.bytes += line.length + 1;
98
+ return out;
99
+ }
100
+ const done = this.#finish();
101
+ if (done) out.push(done);
102
+ if (isJson) {
103
+ const e = parseRecord(line);
104
+ if (e) out.push(e);
105
+ } else {
106
+ this.cur = { lines: [line.length > this.maxEntryBytes ? line.slice(0, this.maxEntryBytes) : line], bytes: Math.min(line.length, this.maxEntryBytes), truncated: line.length > this.maxEntryBytes };
107
+ }
108
+ return out;
109
+ }
110
+ flush() {
111
+ const e = this.#finish();
112
+ return e ? [e] : [];
113
+ }
114
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * file-source.js - tails log files (F3-11b).
3
+ *
4
+ * - by default only NEW lines are read (the agent starting must not turn a week of old errors into candidates);
5
+ * fromStart=true reads what is already there, once
6
+ * - rotation (new inode) and truncation (size < offset) restart at 0; a file that does not exist yet is waited for
7
+ * - pull based: at most `maxReadBytes` per file per tick and nothing is read while the previous batch is being processed
8
+ * (backpressure: a slow consumer slows the reading down, it never grows a queue)
9
+ * - one line is at most `maxLineBytes`: the rest of a longer line is discarded up to its newline (and counted)
10
+ * - the file name is used as the service label unless `service` is given; the PATH is never logged or sent
11
+ */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { StringDecoder } from "node:string_decoder";
15
+ import { EntryAssembler } from "../parse.js";
16
+
17
+ const READ_CHUNK = 64 * 1024;
18
+
19
+ export function createFileSource({ filePath, service = null, fromStart = false, pollMs = 1000, idleFlushMs = 300, maxReadBytes = 256 * 1024, maxLineBytes = 16 * 1024,
20
+ maxEntryBytes, maxStackLines, timers = { setTimeout, clearTimeout }, stats = {} }) {
21
+ const label = service || path.basename(filePath).replace(/\.(log|txt|out)$/i, "") || "unknown";
22
+ let timer = null;
23
+ let stopped = false;
24
+ let onEntries = null;
25
+ let inode = null;
26
+ let offset = 0;
27
+ let first = true;
28
+ let partial = "";
29
+ let discarding = false; // inside a line that exceeded maxLineBytes
30
+ let decoder = new StringDecoder("utf8");
31
+ let lastActivity = 0;
32
+ const asm = new EntryAssembler({ maxEntryBytes, maxStackLines });
33
+ stats.longLines = stats.longLines || 0;
34
+ stats.readErrors = stats.readErrors || 0;
35
+ const unref = (t) => { if (t && typeof t.unref === "function") t.unref(); return t; };
36
+
37
+ const emit = async (entries) => {
38
+ if (entries.length === 0) return;
39
+ for (const e of entries) if (!e.service) e.service = label;
40
+ await onEntries(entries);
41
+ };
42
+
43
+ function takeLines(text) {
44
+ const lines = [];
45
+ let buf = partial + text;
46
+ let i;
47
+ while ((i = buf.indexOf("\n")) >= 0) {
48
+ let line = buf.slice(0, i);
49
+ buf = buf.slice(i + 1);
50
+ if (discarding) { discarding = false; continue; } // the rest of an over-long line
51
+ if (line.endsWith("\r")) line = line.slice(0, -1);
52
+ if (line.length > maxLineBytes) { stats.longLines++; line = line.slice(0, maxLineBytes); }
53
+ lines.push(line);
54
+ }
55
+ if (buf.length > maxLineBytes) {
56
+ if (!discarding) { stats.longLines++; lines.push(buf.slice(0, maxLineBytes)); }
57
+ discarding = true;
58
+ buf = "";
59
+ }
60
+ partial = buf;
61
+ return lines;
62
+ }
63
+
64
+ async function tick() {
65
+ timer = null;
66
+ if (stopped) return;
67
+ let delay = pollMs;
68
+ try {
69
+ const fh = await fs.promises.open(filePath, "r").catch(() => null);
70
+ if (fh) {
71
+ try {
72
+ const st = await fh.stat();
73
+ if (st.isFile()) {
74
+ if (first) { first = false; inode = st.ino; offset = fromStart ? 0 : st.size; }
75
+ else if (st.ino !== inode || st.size < offset) { inode = st.ino; offset = 0; partial = ""; discarding = false; decoder = new StringDecoder("utf8"); }
76
+ let readNow = 0;
77
+ const buf = Buffer.allocUnsafe(READ_CHUNK);
78
+ while (!stopped && offset < st.size && readNow < maxReadBytes) {
79
+ const { bytesRead } = await fh.read(buf, 0, Math.min(READ_CHUNK, st.size - offset), offset);
80
+ if (bytesRead === 0) break;
81
+ offset += bytesRead;
82
+ readNow += bytesRead;
83
+ const lines = takeLines(decoder.write(buf.subarray(0, bytesRead)));
84
+ const entries = [];
85
+ for (const l of lines) entries.push(...asm.push(l));
86
+ await emit(entries);
87
+ lastActivity = Date.now();
88
+ }
89
+ if (offset < st.size) delay = 0; // more to read: come back at once (after yielding)
90
+ }
91
+ } finally { await fh.close().catch(() => {}); }
92
+ }
93
+ // a record in progress with nothing after it: the next line may never come
94
+ if (asm.cur && Date.now() - lastActivity >= idleFlushMs) await emit(asm.flush());
95
+ } catch {
96
+ stats.readErrors++; // the reason can carry a path: only counted
97
+ }
98
+ if (!stopped) timer = unref(timers.setTimeout(tick, asm.cur ? Math.min(delay, idleFlushMs) : delay));
99
+ }
100
+
101
+ return {
102
+ name: "file",
103
+ stats,
104
+ start(handler) {
105
+ onEntries = handler;
106
+ stopped = false;
107
+ timer = unref(timers.setTimeout(tick, 0));
108
+ },
109
+ /** Reads once, now (tests, shutdown). */
110
+ async poll() { if (timer) { timers.clearTimeout(timer); timer = null; } await tick(); },
111
+ stop() { stopped = true; if (timer) timers.clearTimeout(timer); timer = null; },
112
+ };
113
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * loki-source.js - polls a Loki (the customer's own) with a LogQL query (F3-11b).
3
+ *
4
+ * GET {url}/loki/api/v1/query_range?query=..&start=<ns>&end=<ns>&limit=<n>&direction=forward
5
+ * - cursor = the timestamp of the last entry seen (+1 ns); a poll that returns a full page continues at once, up to maxPages
6
+ * - the response body is read with a byte limit (maxResponseBytes) and a timeout; a failure is counted and retried next poll
7
+ * - the credentials (token, org id) go in headers only; the URL, the query and the response are never logged
8
+ * - the service label comes from the stream labels (service_name, service, app, job, container); the labels are not sent anywhere
9
+ * Re-reads (the cursor overlaps on purpose by 0; an instance restart looks back lookbackMs) are absorbed by the detector's natural-key dedup.
10
+ */
11
+ import { parseRecord } from "../parse.js";
12
+
13
+ const SERVICE_LABELS = ["service_name", "service", "app", "application", "container", "container_name", "job"];
14
+
15
+ async function readLimited(res, maxBytes) {
16
+ const reader = res.body.getReader();
17
+ const chunks = [];
18
+ let total = 0;
19
+ for (;;) {
20
+ const { done, value } = await reader.read();
21
+ if (done) break;
22
+ total += value.byteLength;
23
+ if (total > maxBytes) { try { await reader.cancel(); } catch { /* ignore */ } throw new Error("response_too_large"); }
24
+ chunks.push(value);
25
+ }
26
+ return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength))).toString("utf8");
27
+ }
28
+
29
+ export function createLokiSource({ url, query, token = null, org = null, pollMs = 15_000, limit = 500, maxPages = 5, timeoutMs = 10_000, maxResponseBytes = 4 * 1024 * 1024,
30
+ lookbackMs = 60_000, service = null, fetchImpl = globalThis.fetch, now = Date.now, timers = { setTimeout, clearTimeout }, stats = {} }) {
31
+ let timer = null;
32
+ let stopped = false;
33
+ let onEntries = null;
34
+ let cursorNs = null; // BigInt
35
+ stats.polls = stats.polls || 0;
36
+ stats.errors = stats.errors || 0;
37
+ stats.pages = stats.pages || 0;
38
+ const unref = (t) => { if (t && typeof t.unref === "function") t.unref(); return t; };
39
+
40
+ async function page(startNs, endNs) {
41
+ const q = new URLSearchParams({ query, start: startNs.toString(), end: endNs.toString(), limit: String(limit), direction: "forward" });
42
+ const headers = { Accept: "application/json" };
43
+ if (token) headers.Authorization = `Bearer ${token}`;
44
+ if (org) headers["X-Scope-OrgID"] = org;
45
+ const ctl = new AbortController();
46
+ const to = setTimeout(() => ctl.abort(), timeoutMs);
47
+ try {
48
+ const res = await fetchImpl(`${url}/loki/api/v1/query_range?${q}`, { headers, signal: ctl.signal, redirect: "error" });
49
+ if (!res.ok) throw new Error(`http_${res.status}`);
50
+ const body = JSON.parse(await readLimited(res, maxResponseBytes));
51
+ return Array.isArray(body?.data?.result) ? body.data.result : [];
52
+ } finally { clearTimeout(to); }
53
+ }
54
+
55
+ async function pollOnce() {
56
+ stats.polls++;
57
+ const endNs = BigInt(now()) * 1_000_000n;
58
+ if (cursorNs == null) cursorNs = endNs - BigInt(lookbackMs) * 1_000_000n;
59
+ for (let p = 0; p < maxPages && !stopped; p++) {
60
+ const result = await page(cursorNs, endNs);
61
+ stats.pages++;
62
+ const rows = [];
63
+ for (const s of result) {
64
+ const labels = s && typeof s.stream === "object" && s.stream ? s.stream : {};
65
+ const svc = service || SERVICE_LABELS.map((k) => labels[k]).find((v) => typeof v === "string" && v) || "unknown";
66
+ for (const v of Array.isArray(s?.values) ? s.values : []) {
67
+ if (!Array.isArray(v) || typeof v[0] !== "string" || typeof v[1] !== "string" || !/^\d{1,20}$/.test(v[0])) continue;
68
+ rows.push({ ns: BigInt(v[0]), line: v[1], svc });
69
+ }
70
+ }
71
+ rows.sort((a, b) => (a.ns < b.ns ? -1 : a.ns > b.ns ? 1 : 0));
72
+ const entries = [];
73
+ for (const r of rows) {
74
+ const e = parseRecord(r.line);
75
+ if (!e) continue;
76
+ e.service = e.service || r.svc;
77
+ e.tsNs = r.ns; // the Loki timestamp wins: it is what the natural key is made of
78
+ e.tsMs = Number(r.ns / 1_000_000n);
79
+ entries.push(e);
80
+ }
81
+ if (entries.length) await onEntries(entries);
82
+ if (rows.length) cursorNs = rows[rows.length - 1].ns + 1n;
83
+ if (rows.length < limit) break;
84
+ }
85
+ }
86
+
87
+ async function tick() {
88
+ timer = null;
89
+ if (stopped) return;
90
+ try { await pollOnce(); } catch { stats.errors++; } // the message of a fetch error can carry the URL: only counted
91
+ if (!stopped) timer = unref(timers.setTimeout(tick, pollMs));
92
+ }
93
+
94
+ return {
95
+ name: "loki",
96
+ stats,
97
+ start(handler) { onEntries = handler; stopped = false; timer = unref(timers.setTimeout(tick, 0)); },
98
+ async poll() { if (timer) { timers.clearTimeout(timer); timer = null; } await pollOnce(); },
99
+ stop() { stopped = true; if (timer) timers.clearTimeout(timer); timer = null; },
100
+ };
101
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * stream-source.js - reads log lines from any Readable: the agent's stdin (a container or process piped into it) or a test
3
+ * stream (F3-11b). Pull based: the loop awaits the consumer before taking the next line, so a slow consumer pauses the stream
4
+ * (Node stops reading from the pipe: backpressure reaches the writer instead of growing memory).
5
+ */
6
+ import readline from "node:readline";
7
+ import { EntryAssembler } from "../parse.js";
8
+
9
+ export function createStreamSource({ stream, service = "stdin", idleFlushMs = 300, maxLineBytes = 16 * 1024, maxEntryBytes, maxStackLines, stats = {} }) {
10
+ let rl = null;
11
+ let stopped = false;
12
+ stats.longLines = stats.longLines || 0;
13
+ return {
14
+ name: "stream",
15
+ stats,
16
+ start(onEntries) {
17
+ const asm = new EntryAssembler({ maxEntryBytes, maxStackLines });
18
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
19
+ const label = (entries) => { for (const e of entries) if (!e.service) e.service = service; return entries; };
20
+ (async () => {
21
+ try {
22
+ let idle = null;
23
+ for await (let line of rl) {
24
+ if (stopped) break;
25
+ if (idle) { clearTimeout(idle); idle = null; }
26
+ if (line.length > maxLineBytes) { stats.longLines++; line = line.slice(0, maxLineBytes); }
27
+ const out = label(asm.push(line));
28
+ if (out.length) await onEntries(out);
29
+ if (asm.cur) { idle = setTimeout(() => { const f = label(asm.flush()); if (f.length) void onEntries(f); }, idleFlushMs); if (idle.unref) idle.unref(); }
30
+ }
31
+ if (idle) clearTimeout(idle);
32
+ const rest = label(asm.flush());
33
+ if (rest.length) await onEntries(rest);
34
+ } catch { stats.readErrors = (stats.readErrors || 0) + 1; }
35
+ })();
36
+ },
37
+ stop() { stopped = true; try { rl?.close(); } catch { /* closed */ } },
38
+ };
39
+ }