Skip to content

Commit 95432c9

Browse files
committed
Merge branch 'main' of https://github.com/sharon77242/Argus
2 parents 689e1d5 + f0bce81 commit 95432c9

10 files changed

Lines changed: 168 additions & 8 deletions

File tree

packages/agent/src/analysis/fs-analyzer.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export class FsAnalyzer {
5050
if (entry) {
5151
if (now - entry.firstSeen <= this.READ_WINDOW_MS) {
5252
entry.count++;
53-
if (entry.count === this.READ_THRESHOLD) {
53+
if (entry.count >= this.READ_THRESHOLD) {
5454
suggestions.push({
5555
severity: "warning",
5656
rule: "missing-fs-cache",
@@ -67,8 +67,12 @@ export class FsAnalyzer {
6767
this.recentReads.set(filePath, { count: 1, firstSeen: now });
6868
}
6969

70-
// Cleanup
71-
if (this.recentReads.size > 100) this.recentReads.clear();
70+
// Evict only stale entries — never wipe the whole map
71+
if (this.recentReads.size > 100) {
72+
for (const [k, v] of this.recentReads) {
73+
if (now - v.firstSeen > this.READ_WINDOW_MS) this.recentReads.delete(k);
74+
}
75+
}
7276
}
7377

7478
return suggestions;

packages/agent/src/analysis/log-analyzer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class LogAnalyzer {
4646
const now = Date.now();
4747
if (now - this.recentErrors.firstSeen <= this.ERROR_WINDOW_MS) {
4848
this.recentErrors.count++;
49-
if (this.recentErrors.count === this.ERROR_THRESHOLD) {
49+
if (this.recentErrors.count >= this.ERROR_THRESHOLD) {
5050
suggestions.push({
5151
severity: "critical",
5252
rule: "log-error-storm",

packages/agent/src/analysis/query-analyzer.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,13 @@ export class QueryAnalyzer {
204204
private detectNPlusOne(query: string, suggestions: FixSuggestion[]): void {
205205
const now = Date.now();
206206

207-
// Normalize the query to detect repeated structural patterns
208-
const normalized = query.replace(/\?\s*/g, "?").trim();
207+
// Normalize all placeholder styles to '?' so structurally identical queries
208+
// from different drivers (pg $N, named :param, mysql ?) collapse to the same key.
209+
const normalized = query
210+
.replace(/\$\d+/g, "?")
211+
.replace(/:[a-zA-Z_]\w*/g, "?")
212+
.replace(/\?\s*/g, "?")
213+
.trim();
209214

210215
const entry = this.recentQueries.get(normalized);
211216

packages/agent/src/export/exporter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class OTLPExporter {
4949

5050
for (let attempt = 0; attempt <= maxRetries; attempt++) {
5151
if (attempt > 0) {
52-
await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
52+
await new Promise((r) => setTimeout(r, retryDelayMs * 2 ** (attempt - 1)));
5353
}
5454
try {
5555
await this.attempt(events);

packages/agent/src/instrumentation/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export class HttpInstrumentation extends EventEmitter {
9494
once: (event: string, cb: () => void) => void;
9595
statusCode?: number;
9696
};
97-
r.once("close", () => {
97+
r.once("end", () => {
9898
onEnd(r.statusCode);
9999
});
100100
});

packages/agent/tests/analysis/fs-analyzer.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,29 @@ describe("FsAnalyzer", () => {
4343
const suggestions = analyzer.analyze("readFile", "/var/log/app.log");
4444
assert.strictEqual(suggestions.length, 0);
4545
});
46+
47+
// Bug: threshold check used === so warning fired on exactly the 5th read and never again
48+
it("should keep warning on reads beyond the threshold (>= not ===)", () => {
49+
const fresh = new FsAnalyzer();
50+
const path = "/data/keeps-firing.json";
51+
for (let i = 0; i < 5; i++) fresh.analyze("readFile", path);
52+
// 6th read must also produce the warning
53+
const suggestions = fresh.analyze("readFile", path);
54+
const rule = suggestions.find((s) => s.rule === "missing-fs-cache");
55+
assert.ok(rule, "missing-fs-cache should still fire on the 6th read");
56+
});
57+
58+
// Bug: when recentReads.size > 100 the entire map was cleared, losing all tracking state
59+
it("should not lose tracking state when more than 100 unique paths are active", () => {
60+
const fresh = new FsAnalyzer();
61+
// Fill the map with 100 unique paths
62+
for (let i = 0; i < 100; i++) fresh.analyze("readFile", `/unique/path/${i}.json`);
63+
// Now analyse a path 4 times (below threshold) — state must survive the eviction check
64+
const target = "/data/tracked.json";
65+
for (let i = 0; i < 4; i++) fresh.analyze("readFile", target);
66+
// 5th read triggers the threshold — must still fire despite size > 100
67+
const suggestions = fresh.analyze("readFile", target);
68+
const rule = suggestions.find((s) => s.rule === "missing-fs-cache");
69+
assert.ok(rule, "tracking state must survive when map has >100 entries");
70+
});
4671
});

packages/agent/tests/analysis/log-analyzer.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,15 @@ describe("LogAnalyzer", () => {
4040
assert.strictEqual(s1.length, 0);
4141
assert.strictEqual(s2.length, 0);
4242
});
43+
44+
// Bug: threshold check used === so warning fired only on exactly the 5th error and never again
45+
it("should keep warning on every error beyond the threshold (>= not ===)", () => {
46+
const fresh = new LogAnalyzer();
47+
// Reach threshold
48+
for (let i = 0; i < 5; i++) fresh.analyze(["err"], "error");
49+
// 6th error must also trigger the storm warning
50+
const suggestions = fresh.analyze(["err"], "error");
51+
const rule = suggestions.find((s) => s.rule === "log-error-storm");
52+
assert.ok(rule, "log-error-storm should still fire on the 6th error in the window");
53+
});
4354
});

packages/agent/tests/analysis/query-analyzer.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,30 @@ describe("QueryAnalyzer", () => {
7979
const serious = suggestions.filter((s) => s.severity !== "info" && s.rule !== "n-plus-one");
8080
assert.strictEqual(serious.length, 0, "Well-formed query should have no serious issues");
8181
});
82+
83+
// Bug: normalization only collapsed '?' — PostgreSQL $N and :name params were not normalized,
84+
// so structurally identical pg queries were treated as distinct and N+1 was never detected.
85+
it("should detect N+1 for PostgreSQL $N placeholder style", () => {
86+
const pgAnalyzer = new QueryAnalyzer();
87+
// Each call uses a different parameter number but the same structural query
88+
for (let i = 1; i <= 4; i++) {
89+
const s = pgAnalyzer.analyze(`SELECT id, name FROM users WHERE id = $${i}`);
90+
assert.ok(!s.find((x) => x.rule === "n-plus-one"), `Should not trigger at call ${i}`);
91+
}
92+
const suggestions = pgAnalyzer.analyze("SELECT id, name FROM users WHERE id = $5");
93+
const rule = suggestions.find((s) => s.rule === "n-plus-one");
94+
assert.ok(rule, "Should detect N+1 for PostgreSQL $N style params");
95+
});
96+
97+
it("should detect N+1 for named :param placeholder style", () => {
98+
const namedAnalyzer = new QueryAnalyzer();
99+
for (let i = 1; i <= 4; i++) {
100+
namedAnalyzer.analyze(`SELECT * FROM orders WHERE user_id = :userId${i} LIMIT 10`);
101+
}
102+
const suggestions = namedAnalyzer.analyze(
103+
"SELECT * FROM orders WHERE user_id = :userId5 LIMIT 10",
104+
);
105+
const rule = suggestions.find((s) => s.rule === "n-plus-one");
106+
assert.ok(rule, "Should detect N+1 for named :param style params");
107+
});
82108
});

packages/agent/tests/export/exporter.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,56 @@ describe("OTLPExporter", () => {
3434
);
3535
});
3636

37+
// Bug: retry delay used retryDelayMs * attempt (linear) but JSDoc promised "doubles each attempt".
38+
// With maxRetries=3 the 3rd retry diverges: linear gives 300ms, exponential gives 400ms.
39+
it("should use exponential backoff — delay doubles each retry attempt", async () => {
40+
const capturedDelays: number[] = [];
41+
const origSetTimeout = globalThis.setTimeout;
42+
// Capture delay values; fire immediately so the test doesn't actually wait
43+
(globalThis as any).setTimeout = function (fn: () => void, ms?: number) {
44+
if (typeof ms === "number" && ms >= 100) capturedDelays.push(ms);
45+
return origSetTimeout(fn, 0);
46+
};
47+
48+
const requestMock = mock.method(https, "request", (_opts: unknown, callback?: unknown) => {
49+
const reqMock = new EventEmitter() as any;
50+
reqMock.write = () => {};
51+
reqMock.end = () => {};
52+
reqMock.destroy = () => {};
53+
const resMock = new EventEmitter() as any;
54+
resMock.statusCode = 503; // always retryable
55+
process.nextTick(() => {
56+
if (typeof callback === "function") callback(resMock);
57+
resMock.emit("end");
58+
});
59+
return reqMock;
60+
});
61+
62+
const exporter = new OTLPExporter({
63+
endpointUrl: "https://example.com/v1/traces",
64+
key: "k",
65+
cert: "c",
66+
ca: "ca",
67+
maxRetries: 3,
68+
retryDelayMs: 100,
69+
});
70+
71+
try {
72+
await exporter.export([
73+
{ id: "1", metricName: "lag", value: 50, payload: { timestamp: Date.now() } },
74+
]);
75+
} catch {
76+
// all 4 attempts failed — expected
77+
} finally {
78+
(globalThis as any).setTimeout = origSetTimeout;
79+
requestMock.mock.restore();
80+
}
81+
82+
// attempt 1: 100*2^0=100 attempt 2: 100*2^1=200 attempt 3: 100*2^2=400
83+
// (linear would give 100, 200, 300 — fails on the last value)
84+
assert.deepStrictEqual(capturedDelays, [100, 200, 400], "delays must be exponential");
85+
});
86+
3787
it("should construct mTLS options using node:https request correctly", async () => {
3888
const mockRequest = mock.method(https, "request", (options: any, callback?: any) => {
3989
// Assert mTLS options are populated exactly

packages/agent/tests/instrumentation/http.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,45 @@ describe("HttpInstrumentation", () => {
8686
);
8787
});
8888

89+
// Bug: instrumentation listened for 'close' on the response instead of 'end'.
90+
// On HTTP keep-alive connections the socket never closes promptly, so 'close' on
91+
// IncomingMessage fires only when the connection eventually times out — meaning
92+
// the "request" telemetry event could be delayed by seconds or never arrive.
93+
// The fix is to listen for 'end', which fires as soon as the response body is consumed.
94+
it("should emit the 'request' event before the socket closes (end semantics)", async () => {
95+
const instrumentation = new HttpInstrumentation(() => undefined);
96+
const events: TracedHttpRequest[] = [];
97+
instrumentation.on("request", (r) => events.push(r));
98+
instrumentation.enable();
99+
100+
// Server with keep-alive enabled (Node.js default) — it will NOT close the socket
101+
// after the response, so IncomingMessage 'close' would only fire after the timeout.
102+
const server = http.createServer((_req, res) => {
103+
res.setHeader("Connection", "keep-alive");
104+
res.writeHead(200);
105+
res.end("hello");
106+
});
107+
108+
await new Promise<void>((r) => server.listen(0, r));
109+
const { port } = server.address() as { port: number };
110+
111+
const req = http.request(`http://localhost:${port}/`, (res) => {
112+
res.on("data", () => {}); // consume so 'end' fires
113+
});
114+
req.end();
115+
116+
// Wait enough for 'end' to fire (few ms), but not for keep-alive timeout (5 000ms)
117+
await new Promise((r) => setTimeout(r, 50));
118+
119+
instrumentation.disable();
120+
// Force-close all connections so the port is released immediately
121+
(server as any).closeAllConnections?.();
122+
server.close();
123+
124+
assert.strictEqual(events.length, 1, "'request' event must fire before socket closes");
125+
assert.strictEqual(events[0].statusCode, 200);
126+
});
127+
89128
it("should not include correlationId when request is outside runWithContext", async () => {
90129
const instrumentation = new HttpInstrumentation(() => "test.ts:1");
91130
const requests: TracedHttpRequest[] = [];

0 commit comments

Comments
 (0)