@zerotal/monitor 1.0.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,415 @@
1
+ /**
2
+ * SQLite persistence for the monitoring panel (`bun:sqlite`).
3
+ *
4
+ * Every recorded event is appended with a millisecond timestamp, so the panel
5
+ * can trace history across the live / 1h / 24h / 7d ranges **and survive
6
+ * restarts** — not just show whatever happened since boot. A retention policy
7
+ * prunes (or archives) rows older than the configured window, and the store can
8
+ * be wiped on demand from the panel.
9
+ *
10
+ * Use `:memory:` for tests; a file path for real persistence.
11
+ */
12
+ import { Database } from "bun:sqlite";
13
+ import { mkdirSync } from "node:fs";
14
+
15
+ export type RetentionMode = "delete" | "archive";
16
+
17
+ export interface RequestRow {
18
+ t: number;
19
+ method: string;
20
+ path: string;
21
+ status: number;
22
+ ms: number;
23
+ nplus: number;
24
+ queries: string; // JSON-encoded RequestQuery[]
25
+ user: string | null; // authenticated user id/email, or null when unauthenticated
26
+ ip: string | null; // client IP
27
+ mem: number; // process heap (KB) at request completion — a per-request proxy
28
+ context: string; // JSON metadata from Monitor.context()
29
+ payload?: string | null; // JSON RequestPayload (headers+bodies), when capture is on
30
+ error?: string | null; // exception message when the request failed
31
+ }
32
+ export interface QueryRow {
33
+ t: number;
34
+ sql: string;
35
+ ms: number;
36
+ location: string;
37
+ }
38
+ export interface ExceptionRow {
39
+ t: number;
40
+ type: string;
41
+ message: string;
42
+ location: string;
43
+ frames: string; // JSON-encoded string[]
44
+ user: string | null; // authenticated user who hit it, or null
45
+ }
46
+ export interface HttpRow {
47
+ t: number;
48
+ host: string;
49
+ ms: number;
50
+ error: number;
51
+ }
52
+ export interface CacheRow {
53
+ t: number;
54
+ hit: number;
55
+ key: string;
56
+ }
57
+ export interface MailRow {
58
+ t: number;
59
+ subject: string;
60
+ recipient: string;
61
+ mailer: string;
62
+ status: string;
63
+ ms: number;
64
+ body: string;
65
+ }
66
+ export interface DeployRow {
67
+ t: number;
68
+ sha: string;
69
+ }
70
+ export interface JobRow {
71
+ t: number;
72
+ status: string;
73
+ className: string;
74
+ queue: string;
75
+ ms: number;
76
+ error: string | null;
77
+ }
78
+
79
+ /** Generic framework-event row — powers the security, transactions, migrations,
80
+ * N+1, cache-eviction, scheduler, and realtime/WS feeds from one table. */
81
+ export interface EventRow {
82
+ t: number;
83
+ kind: string; // "auth" | "tx" | "migration" | "nplus" | "cache_evict" | "task" | "ws"
84
+ label: string; // human label, e.g. "login.failed", "rollback", route/component name
85
+ status: string; // "ok" | "warn" | "bad" | "info"
86
+ route: string | null; // associated route/component, when known
87
+ data: string; // JSON detail
88
+ }
89
+
90
+ /** Row counts + oldest sample, for the System tab's storage panel. */
91
+ export interface StorageInfo {
92
+ requests: number;
93
+ queries: number;
94
+ exceptions: number;
95
+ httpCalls: number;
96
+ cacheEvents: number;
97
+ mail: number;
98
+ jobs: number;
99
+ deploys: number;
100
+ archived: number;
101
+ oldestMs: number | null;
102
+ }
103
+
104
+ // Column definitions per table (`t` first; archive mirrors share the schema).
105
+ const SCHEMA: Record<string, string> = {
106
+ mon_requests:
107
+ "t INTEGER NOT NULL, method TEXT, path TEXT, status INTEGER, ms INTEGER, nplus INTEGER, queries TEXT, user TEXT, ip TEXT, mem INTEGER, context TEXT, payload TEXT, error TEXT",
108
+ mon_queries: "t INTEGER NOT NULL, sql TEXT, ms INTEGER, location TEXT",
109
+ mon_exceptions:
110
+ "t INTEGER NOT NULL, type TEXT, message TEXT, location TEXT, frames TEXT, user TEXT",
111
+ mon_http: "t INTEGER NOT NULL, host TEXT, ms INTEGER, error INTEGER",
112
+ mon_cache: "t INTEGER NOT NULL, hit INTEGER, key TEXT",
113
+ mon_mail:
114
+ "t INTEGER NOT NULL, subject TEXT, recipient TEXT, mailer TEXT, status TEXT, ms INTEGER, body TEXT",
115
+ mon_deploys: "t INTEGER NOT NULL, sha TEXT",
116
+ mon_jobs: "t INTEGER NOT NULL, status TEXT, className TEXT, queue TEXT, ms INTEGER, error TEXT",
117
+ mon_events: "t INTEGER NOT NULL, kind TEXT, label TEXT, status TEXT, route TEXT, data TEXT",
118
+ };
119
+
120
+ // Prepared insert per table (column order matches the value arrays pushed by record*).
121
+ const INSERTS: Record<string, string> = {
122
+ mon_requests:
123
+ "INSERT INTO mon_requests (t,method,path,status,ms,nplus,queries,user,ip,mem,context,payload,error) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
124
+ mon_queries: "INSERT INTO mon_queries (t,sql,ms,location) VALUES (?,?,?,?)",
125
+ mon_exceptions:
126
+ "INSERT INTO mon_exceptions (t,type,message,location,frames,user) VALUES (?,?,?,?,?,?)",
127
+ mon_http: "INSERT INTO mon_http (t,host,ms,error) VALUES (?,?,?,?)",
128
+ mon_cache: "INSERT INTO mon_cache (t,hit,key) VALUES (?,?,?)",
129
+ mon_mail:
130
+ "INSERT INTO mon_mail (t,subject,recipient,mailer,status,ms,body) VALUES (?,?,?,?,?,?,?)",
131
+ mon_deploys: "INSERT INTO mon_deploys (t,sha) VALUES (?,?)",
132
+ mon_jobs: "INSERT INTO mon_jobs (t,status,className,queue,ms,error) VALUES (?,?,?,?,?,?)",
133
+ mon_events: "INSERT INTO mon_events (t,kind,label,status,route,data) VALUES (?,?,?,?,?,?)",
134
+ };
135
+
136
+ /** Flush the write buffer at least this often, and whenever it reaches the size cap. */
137
+ const FLUSH_MS = 1000;
138
+ const FLUSH_AT = 2000;
139
+
140
+ export class MonitorDb {
141
+ private readonly _db: Database;
142
+ // Write buffer: record* enqueues here (an array push, no I/O) and a timer flushes
143
+ // batched inserts off the request hot path. Keyed by table → rows of value arrays.
144
+ private readonly _pending = new Map<string, unknown[][]>();
145
+ private _pendingCount = 0;
146
+ private readonly _flushTimer: ReturnType<typeof setInterval>;
147
+
148
+ constructor(path = ":memory:") {
149
+ if (path !== ":memory:") {
150
+ const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
151
+ if (slash > 0) {
152
+ try {
153
+ mkdirSync(path.slice(0, slash), { recursive: true });
154
+ } catch {
155
+ /* directory may already exist */
156
+ }
157
+ }
158
+ }
159
+ this._db = new Database(path, { create: true });
160
+ this._db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
161
+ this._migrate();
162
+ this._flushTimer = setInterval(() => this.flush(), FLUSH_MS);
163
+ (this._flushTimer as { unref?: () => void }).unref?.();
164
+ }
165
+
166
+ /** Buffer one row for `table` (an array push). Flushes early if the buffer is full. */
167
+ private _enqueue(table: string, values: unknown[]): void {
168
+ let rows = this._pending.get(table);
169
+ if (!rows) {
170
+ rows = [];
171
+ this._pending.set(table, rows);
172
+ }
173
+ rows.push(values);
174
+ if (++this._pendingCount >= FLUSH_AT) this.flush();
175
+ }
176
+
177
+ /** Persist all buffered rows in one transaction. Called by the timer, before reads, on dispose. */
178
+ flush(): void {
179
+ if (this._pendingCount === 0) return;
180
+ try {
181
+ this._db.transaction(() => {
182
+ for (const [table, rows] of this._pending) {
183
+ if (rows.length === 0) continue;
184
+ const stmt = this._db.query(INSERTS[table]!);
185
+ for (const vals of rows) stmt.run(...(vals as never[]));
186
+ }
187
+ })();
188
+ } catch {
189
+ /* recording must never break the app; drop the batch rather than throw */
190
+ }
191
+ this._pending.clear();
192
+ this._pendingCount = 0;
193
+ }
194
+
195
+ private _migrate(): void {
196
+ for (const [name, cols] of Object.entries(SCHEMA)) {
197
+ this._db.exec(
198
+ `CREATE TABLE IF NOT EXISTS ${name} (${cols});` +
199
+ `CREATE INDEX IF NOT EXISTS idx_${name}_t ON ${name}(t);` +
200
+ `CREATE TABLE IF NOT EXISTS ${name}_archive (${cols});`,
201
+ );
202
+ }
203
+ // Columns added after the initial release — applied idempotently so an older
204
+ // on-disk database upgrades in place rather than erroring on the new SELECTs.
205
+ for (const t of ["mon_requests", "mon_requests_archive"]) {
206
+ this._addColumn(t, "user", "TEXT");
207
+ this._addColumn(t, "ip", "TEXT");
208
+ this._addColumn(t, "mem", "INTEGER");
209
+ this._addColumn(t, "context", "TEXT");
210
+ }
211
+ for (const t of ["mon_jobs", "mon_jobs_archive"]) {
212
+ this._addColumn(t, "className", "TEXT");
213
+ this._addColumn(t, "queue", "TEXT");
214
+ this._addColumn(t, "ms", "INTEGER");
215
+ this._addColumn(t, "error", "TEXT");
216
+ }
217
+ for (const t of ["mon_exceptions", "mon_exceptions_archive"]) {
218
+ this._addColumn(t, "user", "TEXT");
219
+ }
220
+ for (const t of ["mon_requests", "mon_requests_archive"]) {
221
+ this._addColumn(t, "payload", "TEXT");
222
+ this._addColumn(t, "error", "TEXT");
223
+ }
224
+ // Composite index for the per-route drill-in (method+path filtered over a window).
225
+ // The per-table idx_*_t already covers the plain time-range scans every feed uses.
226
+ this._db.exec(
227
+ "CREATE INDEX IF NOT EXISTS idx_mon_requests_route ON mon_requests(method, path, t)",
228
+ );
229
+ }
230
+
231
+ private _addColumn(table: string, col: string, type: string): void {
232
+ try {
233
+ this._db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`);
234
+ } catch {
235
+ /* column already exists */
236
+ }
237
+ }
238
+
239
+ // ── Inserts ──────────────────────────────────────────────────────────────────
240
+
241
+ recordRequest(r: RequestRow): void {
242
+ this._enqueue("mon_requests", [
243
+ r.t,
244
+ r.method,
245
+ r.path,
246
+ r.status,
247
+ r.ms,
248
+ r.nplus,
249
+ r.queries,
250
+ r.user,
251
+ r.ip,
252
+ r.mem,
253
+ r.context,
254
+ r.payload ?? null,
255
+ r.error ?? null,
256
+ ]);
257
+ }
258
+ recordQuery(r: QueryRow): void {
259
+ this._enqueue("mon_queries", [r.t, r.sql, r.ms, r.location]);
260
+ }
261
+ recordException(r: ExceptionRow): void {
262
+ this._enqueue("mon_exceptions", [r.t, r.type, r.message, r.location, r.frames, r.user]);
263
+ }
264
+ recordHttp(r: HttpRow): void {
265
+ this._enqueue("mon_http", [r.t, r.host, r.ms, r.error]);
266
+ }
267
+ recordCache(r: CacheRow): void {
268
+ this._enqueue("mon_cache", [r.t, r.hit, r.key]);
269
+ }
270
+ recordMail(r: MailRow): void {
271
+ this._enqueue("mon_mail", [r.t, r.subject, r.recipient, r.mailer, r.status, r.ms, r.body]);
272
+ }
273
+ recordDeploy(r: DeployRow): void {
274
+ this._enqueue("mon_deploys", [r.t, r.sha]);
275
+ }
276
+ recordJob(r: JobRow): void {
277
+ this._enqueue("mon_jobs", [r.t, r.status, r.className, r.queue, r.ms, r.error]);
278
+ }
279
+ recordEvent(r: EventRow): void {
280
+ this._enqueue("mon_events", [r.t, r.kind, r.label, r.status, r.route, r.data]);
281
+ }
282
+
283
+ // ── Windowed reads (t >= cutoff) ─────────────────────────────────────────────
284
+
285
+ requestsWithin(cutoff: number): RequestRow[] {
286
+ return this._db
287
+ .query(
288
+ "SELECT t,method,path,status,ms,nplus,queries,user,ip,mem,context,payload,error FROM mon_requests WHERE t >= ? ORDER BY t ASC",
289
+ )
290
+ .all(cutoff) as RequestRow[];
291
+ }
292
+ requestsBetween(from: number, to: number): RequestRow[] {
293
+ return this._db
294
+ .query(
295
+ "SELECT t,method,path,status,ms,nplus,queries,user,ip,mem,context FROM mon_requests WHERE t >= ? AND t < ?",
296
+ )
297
+ .all(from, to) as RequestRow[];
298
+ }
299
+ /** All requests to one method+path within the window (per-route drill-in). */
300
+ requestsForRouteWithin(method: string, path: string, cutoff: number): RequestRow[] {
301
+ return this._db
302
+ .query(
303
+ "SELECT t,method,path,status,ms,nplus,queries,user,ip,mem,context,payload,error FROM mon_requests WHERE t >= ? AND method = ? AND path = ? ORDER BY t ASC",
304
+ )
305
+ .all(cutoff, method, path) as RequestRow[];
306
+ }
307
+ queriesWithin(cutoff: number): QueryRow[] {
308
+ return this._db
309
+ .query("SELECT t,sql,ms,location FROM mon_queries WHERE t >= ?")
310
+ .all(cutoff) as QueryRow[];
311
+ }
312
+ exceptionsWithin(cutoff: number): ExceptionRow[] {
313
+ return this._db
314
+ .query(
315
+ "SELECT t,type,message,location,frames,user FROM mon_exceptions WHERE t >= ? ORDER BY t ASC",
316
+ )
317
+ .all(cutoff) as ExceptionRow[];
318
+ }
319
+ httpWithin(cutoff: number): HttpRow[] {
320
+ return this._db
321
+ .query("SELECT t,host,ms,error FROM mon_http WHERE t >= ?")
322
+ .all(cutoff) as HttpRow[];
323
+ }
324
+ cacheWithin(cutoff: number): CacheRow[] {
325
+ return this._db.query("SELECT t,hit,key FROM mon_cache WHERE t >= ?").all(cutoff) as CacheRow[];
326
+ }
327
+ mailWithin(cutoff: number): MailRow[] {
328
+ return this._db
329
+ .query(
330
+ "SELECT t,subject,recipient,mailer,status,ms,body FROM mon_mail WHERE t >= ? ORDER BY t DESC LIMIT 100",
331
+ )
332
+ .all(cutoff) as MailRow[];
333
+ }
334
+ jobsWithin(cutoff: number): JobRow[] {
335
+ return this._db
336
+ .query("SELECT t,status,className,queue,ms,error FROM mon_jobs WHERE t >= ?")
337
+ .all(cutoff) as JobRow[];
338
+ }
339
+ eventsWithin(cutoff: number): EventRow[] {
340
+ return this._db
341
+ .query("SELECT t,kind,label,status,route,data FROM mon_events WHERE t >= ? ORDER BY t DESC")
342
+ .all(cutoff) as EventRow[];
343
+ }
344
+ deploysWithin(cutoff: number): DeployRow[] {
345
+ return this._db
346
+ .query("SELECT t,sha FROM mon_deploys WHERE t >= ? ORDER BY t DESC LIMIT 8")
347
+ .all(cutoff) as DeployRow[];
348
+ }
349
+
350
+ // ── Retention ────────────────────────────────────────────────────────────────
351
+
352
+ /** Remove (or archive then remove) every row older than `cutoff`. Returns rows removed. */
353
+ prune(cutoff: number, mode: RetentionMode): number {
354
+ this.flush(); // persist buffered rows so they aren't lost/miscounted
355
+ let removed = 0;
356
+ const tx = this._db.transaction(() => {
357
+ for (const name of Object.keys(SCHEMA)) {
358
+ if (mode === "archive") {
359
+ this._db
360
+ .query(`INSERT INTO ${name}_archive SELECT * FROM ${name} WHERE t < ?`)
361
+ .run(cutoff);
362
+ }
363
+ const res = this._db.query(`DELETE FROM ${name} WHERE t < ?`).run(cutoff);
364
+ removed += Number(res.changes);
365
+ }
366
+ });
367
+ tx();
368
+ return removed;
369
+ }
370
+
371
+ /** Delete all recorded data (optionally including the archive). Returns rows removed. */
372
+ wipe(includeArchive = false): number {
373
+ this._pending.clear(); // discard buffered rows — we're deleting everything anyway
374
+ this._pendingCount = 0;
375
+ let removed = 0;
376
+ const tx = this._db.transaction(() => {
377
+ for (const name of Object.keys(SCHEMA)) {
378
+ removed += Number(this._db.query(`DELETE FROM ${name}`).run().changes);
379
+ if (includeArchive) this._db.query(`DELETE FROM ${name}_archive`).run();
380
+ }
381
+ });
382
+ tx();
383
+ return removed;
384
+ }
385
+
386
+ /** Row counts + oldest sample, for the storage panel. */
387
+ info(): StorageInfo {
388
+ this.flush(); // counts should include just-recorded rows
389
+ const count = (t: string): number =>
390
+ Number((this._db.query(`SELECT COUNT(*) AS c FROM ${t}`).get() as { c: number }).c);
391
+ const oldest = (
392
+ this._db.query("SELECT MIN(t) AS m FROM mon_requests").get() as { m: number | null }
393
+ ).m;
394
+ let archived = 0;
395
+ for (const name of Object.keys(SCHEMA)) archived += count(`${name}_archive`);
396
+ return {
397
+ requests: count("mon_requests"),
398
+ queries: count("mon_queries"),
399
+ exceptions: count("mon_exceptions"),
400
+ httpCalls: count("mon_http"),
401
+ cacheEvents: count("mon_cache"),
402
+ mail: count("mon_mail"),
403
+ jobs: count("mon_jobs"),
404
+ deploys: count("mon_deploys"),
405
+ archived,
406
+ oldestMs: oldest ?? null,
407
+ };
408
+ }
409
+
410
+ dispose(): void {
411
+ clearInterval(this._flushTimer);
412
+ this.flush();
413
+ this._db.close();
414
+ }
415
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A fixed-capacity rolling buffer of numeric samples, used for sparklines and
3
+ * throughput series. Pushing past capacity drops the oldest sample — so the
4
+ * buffer always represents the most recent `capacity` data points.
5
+ */
6
+ export class RingBuffer {
7
+ private readonly _values: number[];
8
+ private readonly _capacity: number;
9
+
10
+ constructor(capacity: number, fill = 0) {
11
+ this._capacity = Math.max(1, capacity);
12
+ this._values = Array.from({ length: this._capacity }, () => fill);
13
+ }
14
+
15
+ /** Append a sample, evicting the oldest if at capacity. */
16
+ push(value: number): void {
17
+ this._values.push(value);
18
+ if (this._values.length > this._capacity) this._values.shift();
19
+ }
20
+
21
+ /** A copy of the current series, oldest → newest. */
22
+ values(): number[] {
23
+ return [...this._values];
24
+ }
25
+
26
+ /** The most recent sample, or 0 if empty. */
27
+ last(): number {
28
+ return this._values[this._values.length - 1] ?? 0;
29
+ }
30
+
31
+ /** Arithmetic mean of the series. */
32
+ avg(): number {
33
+ if (this._values.length === 0) return 0;
34
+ return this._values.reduce((a, b) => a + b, 0) / this._values.length;
35
+ }
36
+
37
+ /** Sum of the series. */
38
+ sum(): number {
39
+ return this._values.reduce((a, b) => a + b, 0);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * A simple sliding-window timestamped event log. Used to compute rates and
45
+ * percentiles over a recent time window without unbounded memory growth.
46
+ */
47
+ export class TimeWindow<T extends { t: number }> {
48
+ private _items: T[] = [];
49
+ private readonly _maxAgeMs: number;
50
+ private readonly _maxItems: number;
51
+
52
+ constructor(maxAgeMs: number, maxItems = 5000) {
53
+ this._maxAgeMs = maxAgeMs;
54
+ this._maxItems = maxItems;
55
+ }
56
+
57
+ add(item: T): void {
58
+ this._items.push(item);
59
+ if (this._items.length > this._maxItems) {
60
+ this._items.splice(0, this._items.length - this._maxItems);
61
+ }
62
+ this._prune();
63
+ }
64
+
65
+ /** Items within `windowMs` of now (defaults to the full retained window). */
66
+ within(windowMs = this._maxAgeMs): T[] {
67
+ const cutoff = Date.now() - windowMs;
68
+ return this._items.filter((i) => i.t >= cutoff);
69
+ }
70
+
71
+ all(): T[] {
72
+ return [...this._items];
73
+ }
74
+
75
+ private _prune(): void {
76
+ const cutoff = Date.now() - this._maxAgeMs;
77
+ let i = 0;
78
+ while (i < this._items.length && (this._items[i] as T).t < cutoff) i++;
79
+ if (i > 0) this._items.splice(0, i);
80
+ }
81
+ }
82
+
83
+ /** Compute a percentile (0..100) from a numeric sample set. */
84
+ export function percentile(values: number[], p: number): number {
85
+ if (values.length === 0) return 0;
86
+ const sorted = [...values].sort((a, b) => a - b);
87
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
88
+ return Math.round(sorted[idx] as number);
89
+ }