Skip to content

Commit 3ae2740

Browse files
committed
refactor: code review improvements — architecture, safety, tests, docs
Architecture: - Extract CrossSignalRuleEngine class (R.3–R.7 rules independently testable) - Export CROSS_SIGNAL_THRESHOLDS const - Add WindowedMonitorBase abstract class; GcMonitor + CacheMonitor extend it TypeScript / safety: - Move cross-signal event types to cross-signal-rule-engine.ts - Replace silent .catch() blocks with this.emit("warn", err) - Replace routeTracker non-null assertion with defensive check - Fix global-regex lastIndex pattern in index-hint-analyzer, migration-scanner, argus-agent Tests (601 → 627 passing): - Scenario tests: worker-only, cache-degradation, crash-recovery - OTLP edge cases: circular payload, ECONNREFUSED, timeout, dropped connection - Licensing boundary tests: JWT expiry ±1 s, clock-skew at 60 s boundary - CrossSignalRuleEngine: TTL expiry, stale-query eviction, R.7 warn path, dedup - Explicit { timeout: 10_000 } on all async tests that use setTimeout Repo: - Add .github/PULL_REQUEST_TEMPLATE.md - Update README: tagline, CI badge, Roadmap section, Used by placeholder - Add npm keywords: opentelemetry, postgres, mysql, n+1, node-js, sql
1 parent 72c7333 commit 3ae2740

42 files changed

Lines changed: 2363 additions & 1517 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Description
2+
3+
<!-- What does this PR do and why? Link to the issue it addresses if applicable. -->
4+
5+
Closes #
6+
7+
## Test plan
8+
9+
- [ ] `pnpm typecheck` passes
10+
- [ ] `pnpm lint` passes
11+
- [ ] `pnpm format:check` passes
12+
- [ ] `pnpm test` passes (all existing + new tests green)
13+
- [ ] New behaviour is covered by tests
14+
15+
## Breaking changes
16+
17+
<!-- List any breaking changes to the public API. If none, write "None." -->
18+
19+
None.
20+
21+
## Checklist
22+
23+
- [ ] No secrets, tokens, or PEM files committed
24+
- [ ] No new `console.log` left in production code
25+
- [ ] README / CHANGELOG updated if this changes the public API or project structure
26+
- [ ] Reviewed the diff for unintentional whitespace or debug code

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
# Argus
22

3-
> **Privacy-first performance diagnostics for Node.js — minimum Node 14.18 as a compiled package, Node 22.6 for source/dev mode**
3+
> **Privacy-first APM and performance diagnostics for Node.js — zero sidecar, zero raw data exported.**
44
5+
[![CI](https://github.com/sharon77242/Argus/actions/workflows/ci.yml/badge.svg)](https://github.com/sharon77242/Argus/actions/workflows/ci.yml)
56
[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github)](https://github.com/sponsors/sharon77242)
67
[![npm version](https://badge.fury.io/js/argus-apm.svg)](https://badge.fury.io/js/argus-apm)
78
[![npm downloads](https://img.shields.io/npm/dm/argus-apm.svg)](https://www.npmjs.com/package/argus-apm)
89
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
910

11+
> Minimum Node 14.18 as a compiled package · Node 22.6 for source/dev mode
12+
1013
![Argus demo](docs/demo.gif)
1114

1215
Named after **Argus Panoptes**, the hundred-eyed watchman of Greek mythology. A lightweight agent that embeds directly into your application — silently tracking runtime behaviour, isolating bottlenecks, and mathematically sanitizing all context before exporting OpenTelemetry (OTLP) telemetry to your observability stack.
@@ -842,13 +845,18 @@ Open `http://localhost:16686` to browse traces.
842845
843846
---
844847

845-
## SaaS Dashboard — Coming Soon
848+
## Roadmap
849+
850+
- **SaaS Dashboard** — hosted dashboard with 30-day query history, AI-powered fix suggestions, and cross-service correlation
851+
- **IDE plugin** — surface suggestions directly in VS Code as you write queries
852+
853+
[Watch the repo](https://github.com/sharon77242/Argus) or email [sharon10vp614@gmail.com](mailto:sharon10vp614@gmail.com) to be notified of major releases.
854+
855+
---
846856

847-
Local suggestions fire today with zero account required.
848-
A hosted dashboard with 30-day query history, AI-powered fix suggestions,
849-
and cross-service correlation is in development.
857+
## Used by
850858

851-
→ Subscribe via [this GitHub issue](https://github.com/sharon77242/Argus/issues) or email [sharon10vp614@gmail.com](mailto:sharon10vp614@gmail.com) to be notified at launch.
859+
Using Argus in production? [Open an issue](https://github.com/sharon77242/Argus/issues) or email [sharon10vp614@gmail.com](mailto:sharon10vp614@gmail.com) to be listed here.
852860

853861
---
854862

packages/agent/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,20 @@
4848
"keywords": [
4949
"argus",
5050
"node",
51+
"node-js",
5152
"diagnostics",
5253
"performance",
5354
"profiling",
5455
"apm",
5556
"observability",
5657
"privacy",
5758
"monitoring",
58-
"tracing"
59+
"tracing",
60+
"opentelemetry",
61+
"postgres",
62+
"mysql",
63+
"n+1",
64+
"sql"
5965
],
6066
"author": "Sharon Alhazov (https://github.com/sharon77242)",
6167
"license": "Apache-2.0",

packages/agent/src/analysis/cache-monitor.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { EventEmitter } from "node:events";
1+
import type { EventEmitter } from "node:events";
22
import type { TracedQuery } from "../instrumentation/engine.ts";
3+
import { WindowedMonitorBase } from "../profiling/windowed-monitor-base.ts";
34

45
interface Sample {
56
hit: boolean;
@@ -28,16 +29,13 @@ export interface CacheMonitorOptions {
2829
minHitRate?: number;
2930
}
3031

31-
export class CacheMonitor extends EventEmitter {
32-
private samples: Sample[] = [];
32+
export class CacheMonitor extends WindowedMonitorBase<Sample> {
3333
private sources = new Map<EventEmitter, (q: TracedQuery) => void>();
34-
private readonly windowMs: number;
3534
private readonly minSamples: number;
3635
private readonly minHitRate: number;
3736

3837
constructor(options: CacheMonitorOptions = {}) {
39-
super();
40-
this.windowMs = options.windowMs ?? 60_000;
38+
super(options.windowMs ?? 60_000);
4139
this.minSamples = options.minSamples ?? 10;
4240
this.minHitRate = options.minHitRate ?? 0.5;
4341
}
@@ -71,7 +69,9 @@ export class CacheMonitor extends EventEmitter {
7169

7270
/** Detach all sources. */
7371
stop(): void {
74-
for (const [src, fn] of this.sources) src.off("query", fn);
72+
for (const [src, fn] of this.sources) {
73+
src.off("query", fn);
74+
}
7575
this.sources.clear();
7676
}
7777

@@ -121,9 +121,6 @@ export class CacheMonitor extends EventEmitter {
121121
}
122122

123123
private _evict(now: number): void {
124-
const cutoff = now - this.windowMs;
125-
while (this.samples.length > 0 && this.samples[0].ts < cutoff) {
126-
this.samples.shift();
127-
}
124+
this.evictSamples(now, (s) => s.ts);
128125
}
129126
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import type { EventEmitter } from "node:events";
2+
import { ColumnUsageAnalyzer } from "./column-usage-analyzer.ts";
3+
4+
interface QueryLike {
5+
sanitizedQuery?: string;
6+
traceId?: string;
7+
correlationId?: string;
8+
suggestions?: { rule: string }[];
9+
}
10+
11+
interface HttpLike {
12+
method?: string;
13+
url?: string;
14+
durationMs?: number;
15+
traceId?: string;
16+
}
17+
18+
interface SlowQueryLike {
19+
sanitizedQuery?: string;
20+
durationMs?: number;
21+
driver?: string;
22+
}
23+
24+
interface PoolExhaustionLike {
25+
driver?: string;
26+
waitingCount?: number;
27+
}
28+
29+
export const CROSS_SIGNAL_THRESHOLDS = {
30+
SLOW_HTTP_MS: 1_000,
31+
POOL_STARVATION_WINDOW_MS: 10_000,
32+
N_PLUS_ONE_TTL_MS: 30_000,
33+
} as const;
34+
35+
export interface CrossSignalRuleEngineOptions {
36+
columnUsageDir?: string | null;
37+
columnUsageThreshold?: number;
38+
selectStarHits?: Map<string, number>;
39+
parseLineFromSourceLine?: (sourceLine: string) => number;
40+
}
41+
42+
/**
43+
* Wires cross-signal correlation rules (R.3–R.7) onto a shared EventEmitter.
44+
* Each rule listens for events emitted by individual monitors and combines them
45+
* into higher-signal compound anomalies.
46+
*/
47+
export class CrossSignalRuleEngine {
48+
private readonly emitter: EventEmitter;
49+
private readonly opts: CrossSignalRuleEngineOptions;
50+
private readonly listeners: [string, (...args: unknown[]) => void][] = [];
51+
52+
constructor(emitter: EventEmitter, opts: CrossSignalRuleEngineOptions = {}) {
53+
this.emitter = emitter;
54+
this.opts = opts;
55+
}
56+
57+
wire(): void {
58+
const SLOW_HTTP_MS = CROSS_SIGNAL_THRESHOLDS.SLOW_HTTP_MS;
59+
const POOL_STARVATION_WINDOW_MS = CROSS_SIGNAL_THRESHOLDS.POOL_STARVATION_WINDOW_MS;
60+
const N_PLUS_ONE_TTL_MS = CROSS_SIGNAL_THRESHOLDS.N_PLUS_ONE_TTL_MS;
61+
62+
// traceId → timestamp of last N+1 detection (R.3)
63+
const nPlusOneByTraceId = new Map<string, number>();
64+
// query key → { timestamp, sanitizedQuery, durationMs, driver } (R.4)
65+
const recentSlowQueries = new Map<
66+
string,
67+
{ timestamp: number; sanitizedQuery: string; durationMs: number; driver?: string }
68+
>();
69+
// traceId/correlationId of currently open transactions (R.5)
70+
const openTransactions = new Set<string>();
71+
72+
const onQuery = (event: QueryLike): void => {
73+
const sql = event.sanitizedQuery ?? "";
74+
const traceKey = event.traceId ?? event.correlationId;
75+
76+
// R.5: track open transactions
77+
if (traceKey) {
78+
if (/^\s*BEGIN\b/i.test(sql)) {
79+
openTransactions.add(traceKey);
80+
} else if (/^\s*(COMMIT|ROLLBACK)\b/i.test(sql)) {
81+
openTransactions.delete(traceKey);
82+
}
83+
}
84+
85+
const hasNPlusOne = event.suggestions?.some((s) => s.rule === "n-plus-one");
86+
87+
// R.3: record traceIds with active N+1
88+
if (hasNPlusOne && event.traceId) {
89+
nPlusOneByTraceId.set(event.traceId, Date.now());
90+
}
91+
92+
// R.5: N+1 inside open transaction
93+
if (hasNPlusOne && traceKey && openTransactions.has(traceKey)) {
94+
this.emitter.emit("anomaly", {
95+
type: "n-plus-one-in-transaction",
96+
traceId: event.traceId,
97+
correlationId: event.correlationId,
98+
suggestions: [
99+
{
100+
severity: "critical",
101+
rule: "n-plus-one-in-transaction",
102+
message:
103+
"N+1 query pattern detected inside an open transaction — each repeated query holds the database connection and delays COMMIT.",
104+
suggestedFix:
105+
"Batch the repeated queries before opening the transaction, or use a JOIN/IN clause to reduce round-trips.",
106+
},
107+
],
108+
});
109+
}
110+
};
111+
112+
const onHttp = (event: HttpLike): void => {
113+
// R.3: correlated-slow-endpoint
114+
if (!event.traceId || !event.durationMs || event.durationMs <= SLOW_HTTP_MS) return;
115+
const recordedAt = nPlusOneByTraceId.get(event.traceId);
116+
if (!recordedAt) return;
117+
if (Date.now() - recordedAt > N_PLUS_ONE_TTL_MS) {
118+
nPlusOneByTraceId.delete(event.traceId);
119+
return;
120+
}
121+
this.emitter.emit("anomaly", {
122+
type: "correlated-slow-endpoint",
123+
url: event.url,
124+
method: event.method,
125+
durationMs: event.durationMs,
126+
traceId: event.traceId,
127+
suggestions: [
128+
{
129+
severity: "critical",
130+
rule: "correlated-slow-endpoint",
131+
message: `${event.method ?? "HTTP"} ${event.url ?? "(unknown)"} took ${event.durationMs}ms — N+1 query pattern active within the same request trace.`,
132+
suggestedFix:
133+
"Batch the repeated queries with IN (...) or a JOIN before this endpoint can scale.",
134+
},
135+
],
136+
});
137+
};
138+
139+
const onSlowQuery = (event: SlowQueryLike): void => {
140+
// R.4: track recent slow queries keyed by a truncated query fingerprint
141+
if (!event.sanitizedQuery) return;
142+
const key = `${event.driver ?? ""}:${event.sanitizedQuery.slice(0, 120)}`;
143+
recentSlowQueries.set(key, {
144+
timestamp: Date.now(),
145+
sanitizedQuery: event.sanitizedQuery,
146+
durationMs: event.durationMs ?? 0,
147+
driver: event.driver,
148+
});
149+
};
150+
151+
const onPoolExhaustion = (event: PoolExhaustionLike): void => {
152+
// R.4: pool-starvation-by-slow-query
153+
const now = Date.now();
154+
const culprits: { sanitizedQuery: string; durationMs: number }[] = [];
155+
156+
for (const [key, sq] of recentSlowQueries) {
157+
if (now - sq.timestamp > POOL_STARVATION_WINDOW_MS) {
158+
recentSlowQueries.delete(key);
159+
continue;
160+
}
161+
if (!event.driver || !sq.driver || sq.driver === event.driver) {
162+
culprits.push({ sanitizedQuery: sq.sanitizedQuery, durationMs: sq.durationMs });
163+
}
164+
}
165+
166+
if (culprits.length === 0) return;
167+
168+
this.emitter.emit("anomaly", {
169+
type: "pool-starvation-by-slow-query",
170+
driver: event.driver,
171+
waitingCount: event.waitingCount,
172+
culprits,
173+
suggestions: [
174+
{
175+
severity: "critical",
176+
rule: "pool-starvation-by-slow-query",
177+
message: `Connection pool exhausted (${event.waitingCount ?? 0} waiting) — ${culprits.length} slow ${event.driver ?? ""} quer${culprits.length === 1 ? "y is" : "ies are"} holding connections.`,
178+
suggestedFix:
179+
"Optimize the slow queries or increase pool size. Use EXPLAIN to identify missing indexes.",
180+
},
181+
],
182+
});
183+
};
184+
185+
this.register("query", onQuery as (...args: unknown[]) => void);
186+
this.register("http", onHttp as (...args: unknown[]) => void);
187+
this.register("slow-query", onSlowQuery as (...args: unknown[]) => void);
188+
this.register("pool-exhaustion", onPoolExhaustion as (...args: unknown[]) => void);
189+
190+
// R.7 hot-path-select-star
191+
if (this.opts.columnUsageDir) {
192+
const colDir = this.opts.columnUsageDir;
193+
const threshold = this.opts.columnUsageThreshold ?? 5;
194+
const selectStarHits = this.opts.selectStarHits ?? new Map<string, number>();
195+
const parseLine =
196+
this.opts.parseLineFromSourceLine ??
197+
((s: string) => {
198+
const m = /:(\d+):\d+$/.exec(s);
199+
return m ? parseInt(m[1], 10) : 1;
200+
});
201+
const analyzed = new Set<string>();
202+
203+
const onQueryForColUsage = (event: Record<string, unknown>): void => {
204+
const sq = typeof event.sanitizedQuery === "string" ? event.sanitizedQuery : "";
205+
const sourceLine = typeof event.sourceLine === "string" ? event.sourceLine : undefined;
206+
if (!sourceLine || !sq.toUpperCase().includes("SELECT *")) return;
207+
if (analyzed.has(sourceLine)) return;
208+
209+
const hits = (selectStarHits.get(sourceLine) ?? 0) + 1;
210+
selectStarHits.set(sourceLine, hits);
211+
if (hits < threshold) return;
212+
213+
analyzed.add(sourceLine);
214+
ColumnUsageAnalyzer.analyzeFile(
215+
colDir,
216+
sourceLine.replace(/:\d+:\d+$/, ""),
217+
parseLine(sourceLine),
218+
)
219+
.then((fields) => {
220+
if (!fields) return;
221+
const suggestion = ColumnUsageAnalyzer.buildSuggestion(
222+
fields,
223+
sourceLine.replace(/:\d+:\d+$/, ""),
224+
parseLine(sourceLine),
225+
);
226+
this.emitter.emit("anomaly", {
227+
type: "hot-path-select-star",
228+
sourceLine,
229+
fields: [...fields],
230+
suggestions: [suggestion],
231+
});
232+
})
233+
.catch((err: unknown) => {
234+
this.emitter.emit("warn", err);
235+
});
236+
};
237+
238+
this.register("query", onQueryForColUsage);
239+
}
240+
}
241+
242+
unwire(): void {
243+
for (const [event, fn] of this.listeners) {
244+
this.emitter.off(event, fn);
245+
}
246+
this.listeners.length = 0;
247+
}
248+
249+
private register(event: string, handler: (...args: unknown[]) => void): void {
250+
this.emitter.on(event, handler);
251+
this.listeners.push([event, handler]);
252+
}
253+
}

0 commit comments

Comments
 (0)