cachegate 1.1.1 → 1.3.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.
package/metrics.js CHANGED
@@ -1,556 +1,657 @@
1
- // model-router/metrics.js
2
- //
3
- // A shared record of what actually happened on every request: which
4
- // provider handled it, how long it took, what it cost, whether it was
5
- // a cache hit, and whether it errored. Two things depend on this data
6
- // existing, so it's built once, here, rather than twice:
7
- //
8
- // 1. Routing (router.js) needs rolling latency/error-rate per
9
- // provider to make a "cheapest CAPABLE provider" decision - a
10
- // static price table alone can't tell you a provider is currently
11
- // slow or failing.
12
- // 2. The cost dashboard needs historical data to show - there is
13
- // nothing to dashboard without a log.
14
- //
15
- // Storage is local, append-only JSONL, not a database - that keeps the
16
- // self-hosted/lightweight positioning honest (no new infrastructure to
17
- // run) while still being real persistence: every line is a complete,
18
- // independent JSON record, so a crash mid-write loses at most the one
19
- // in-flight line, and any tool that can read lines of JSON (jq, a
20
- // script) can consume it directly.
21
- //
22
- // ROTATION: one file per UTC calendar day (metrics-YYYY-MM-DD.jsonl),
23
- // not one file forever. This used to be a real, documented gap - a
24
- // single ever-growing file that every read (a dashboard load, a /stats
25
- // call, a routing-health check) re-read and re-parsed in FULL,
26
- // regardless of how much data the caller actually needed. Splitting by
27
- // day means readRecent() and rangeSummary() only open the files that
28
- // could actually contain what they're looking for - a 14-day dashboard
29
- // query reads at most 15 files, not the service's entire history.
30
- // Old files are NOT deleted automatically - see pruneOlderThan() for
31
- // the explicit, opt-in cleanup an operator can run; silently deleting
32
- // someone's cost history without being asked is a worse default than
33
- // disk slowly filling up, and this module doesn't get to make that
34
- // retention call on its own.
35
-
36
- const fs = require('fs');
37
- const path = require('path');
38
- const readline = require('readline');
39
- const { Pool } = require('pg');
40
-
41
- // Postgres-backed persistence - OPT-IN, not a replacement. The JSONL
42
- // file storage above/below stays the default for exactly the reason
43
- // its own original comment gives: self-hosted/lightweight, zero new
44
- // infrastructure required to run this router standalone in some other
45
- // app. But the EMBEDDED deployment inside MemoCode specifically already
46
- // has a real Postgres database (memocode-db, provisioned for its own
47
- // user-account/library data regardless of this router) - reusing that
48
- // costs nothing new (no extra service, no extra bill, no extra account)
49
- // and, unlike the router's own container filesystem, genuinely survives
50
- // a restart/redeploy. DATABASE_URL is Render's own standard convention
51
- // for injecting a database's connection string (matches how
52
- // 000_backend/db.mjs reads the exact same variable for the exact same
53
- // reason) - set it and every function below transparently reads/writes
54
- // Postgres instead of local files; leave it unset and nothing here
55
- // changes at all.
56
- function usingPostgres() {
57
- return Boolean(process.env.DATABASE_URL || process.env.MEMOCODE_ROUTER_DATABASE_URL);
58
- }
59
-
60
- let pgPool = null;
61
- function getPool() {
62
- if (!pgPool) {
63
- const connectionString = process.env.MEMOCODE_ROUTER_DATABASE_URL || process.env.DATABASE_URL;
64
- pgPool = new Pool({
65
- connectionString,
66
- // Same rule db.mjs already uses: a real hosted Postgres (Render's
67
- // managed instance) needs SSL; a local one (dev, this module's
68
- // own tests) doesn't and would just fail the handshake if asked.
69
- ssl: connectionString && !/localhost|127\.0\.0\.1/.test(connectionString) ? { rejectUnauthorized: false } : false
70
- });
71
- }
72
- return pgPool;
73
- }
74
-
75
- // Idempotent - safe to call on every getPool() use (CREATE TABLE/INDEX
76
- // IF NOT EXISTS), so a fresh deployment self-provisions its own schema
77
- // on first write with no separate migration step to remember to run.
78
- let schemaReady = null;
79
- async function ensureSchema() {
80
- if (!schemaReady) {
81
- schemaReady = getPool().query(`
82
- CREATE TABLE IF NOT EXISTS router_metrics (
83
- id BIGSERIAL PRIMARY KEY,
84
- ts TIMESTAMPTZ NOT NULL DEFAULT now(),
85
- provider TEXT,
86
- model TEXT,
87
- requested_model TEXT,
88
- cache_hit BOOLEAN,
89
- cache_type TEXT,
90
- latency_ms INTEGER,
91
- cost_usd DOUBLE PRECISION,
92
- error TEXT,
93
- error_type TEXT
94
- );
95
- CREATE INDEX IF NOT EXISTS router_metrics_ts_idx ON router_metrics (ts DESC);
96
- CREATE INDEX IF NOT EXISTS router_metrics_provider_ts_idx ON router_metrics (provider, ts DESC);
97
- `);
98
- }
99
- return schemaReady;
100
- }
101
-
102
- // Maps one Postgres row back to the exact same shape record() writes to
103
- // a JSONL line - so every function below this point (readRecent,
104
- // providerStats, rangeSummary) can share its existing row-aggregation
105
- // logic UNCHANGED regardless of which backend actually supplied the
106
- // rows. Only the row-fetching prelude differs between the two backends;
107
- // nothing downstream needs to know or care which one ran.
108
- function rowFromPg(dbRow) {
109
- return {
110
- timestamp: dbRow.ts.toISOString(),
111
- provider: dbRow.provider || undefined,
112
- model: dbRow.model || undefined,
113
- requested_model: dbRow.requested_model || undefined,
114
- cache_hit: dbRow.cache_hit === null ? undefined : dbRow.cache_hit,
115
- cache_type: dbRow.cache_type || undefined,
116
- latency_ms: dbRow.latency_ms === null ? undefined : dbRow.latency_ms,
117
- cost_usd: dbRow.cost_usd === null ? undefined : dbRow.cost_usd,
118
- error: dbRow.error || undefined,
119
- error_type: dbRow.error_type || undefined
120
- };
121
- }
122
-
123
- async function recordToPostgres(entry) {
124
- try {
125
- await ensureSchema();
126
- await getPool().query(
127
- `INSERT INTO router_metrics
128
- (provider, model, requested_model, cache_hit, cache_type, latency_ms, cost_usd, error, error_type)
129
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
130
- [
131
- entry.provider ?? null,
132
- entry.model ?? null,
133
- entry.requested_model ?? null,
134
- entry.cache_hit ?? null,
135
- entry.cache_type ?? null,
136
- entry.latency_ms ?? null,
137
- entry.cost_usd ?? null,
138
- entry.error ?? null,
139
- entry.error_type ?? null
140
- ]
141
- );
142
- } catch (err) {
143
- // Same fire-and-forget contract as the file backend's own record():
144
- // a metrics write must never be the reason a real request fails.
145
- console.warn('⚠️ Failed to record metric (Postgres):', err.message);
146
- }
147
- }
148
-
149
- async function readRecentFromPostgres(limit) {
150
- await ensureSchema();
151
- const result = await getPool().query(
152
- `SELECT * FROM router_metrics ORDER BY ts DESC LIMIT $1`,
153
- [limit]
154
- );
155
- return result.rows.reverse().map(rowFromPg); // oldest-first, matching readRecent()'s own file-backed order
156
- }
157
-
158
- async function rowsSinceFromPostgres(cutoffMs) {
159
- await ensureSchema();
160
- const result = await getPool().query(
161
- `SELECT * FROM router_metrics WHERE ts >= $1 ORDER BY ts ASC`,
162
- [new Date(cutoffMs)]
163
- );
164
- return result.rows.map(rowFromPg);
165
- }
166
-
167
- async function pruneOlderThanPostgres(days) {
168
- await ensureSchema();
169
- const result = await getPool().query(
170
- `DELETE FROM router_metrics WHERE ts < now() - ($1::double precision * interval '1 day') RETURNING id`,
171
- [days]
172
- );
173
- return result.rows.map((r) => r.id);
174
- }
175
-
176
- // Turns a raw provider error message into one of a handful of stable,
177
- // human-meaningful buckets - the difference between a dashboard that says
178
- // "openai: 100% error rate" (true, but not actionable without opening a
179
- // terminal and reading a stack trace) and one that says
180
- // "openai: authentication_error" (tells you exactly what to go fix).
181
- // Anthropic's and OpenAI's SDKs both format a thrown error's .message the
182
- // same way: "<http status> <json error body>" - so this tries that shape
183
- // first (checking BOTH `.error.type`/`.error.code` for OpenAI's nesting
184
- // and bare `.type` for Anthropic's), and falls back to keyword matching
185
- // on the raw text for anything that doesn't parse (a network error has no
186
- // JSON body at all, for instance - "unknown" is still more honest than
187
- // guessing). Exported so server.js's error handlers and this module's own
188
- // tests can both use the exact same classification, never two versions
189
- // that could drift apart.
190
- function classifyErrorType(message) {
191
- if (!message) return 'unknown';
192
- const jsonStart = message.indexOf('{');
193
- if (jsonStart !== -1) {
194
- try {
195
- const body = JSON.parse(message.slice(jsonStart));
196
- const type = body?.error?.type || body?.type;
197
- const code = body?.error?.code;
198
- if (type === 'authentication_error' || code === 'invalid_api_key') return 'authentication_error';
199
- if (type === 'insufficient_quota' || code === 'insufficient_quota') return 'insufficient_quota';
200
- if (type === 'rate_limit_error' || code === 'rate_limit_exceeded') return 'rate_limit_error';
201
- if (type) return type; // whatever the provider itself called it - still more useful than "unknown"
202
- } catch {
203
- // Not JSON (or not shaped as expected) - fall through to keywords.
204
- }
205
- }
206
- const lower = message.toLowerCase();
207
- if (lower.includes('invalid') && lower.includes('key')) return 'authentication_error';
208
- if (lower.includes('quota') || lower.includes('insufficient')) return 'insufficient_quota';
209
- if (lower.includes('rate limit') || lower.includes('429')) return 'rate_limit_error';
210
- return 'unknown';
211
- }
212
-
213
- // Historical note: METRICS_LOG_PATH used to name the one-and-only log
214
- // file directly. It's kept as the configuration knob for backward
215
- // compatibility, but now names the DIRECTORY those per-day files live
216
- // in (its dirname) - existing deployments/tests that set it to a file
217
- // path like ".../data/metrics.jsonl" keep working unchanged, since
218
- // that file's directory is exactly where rotation stores things now.
219
- const DATA_DIR = process.env.METRICS_LOG_PATH
220
- ? path.dirname(process.env.METRICS_LOG_PATH)
221
- : path.join(__dirname, 'data');
222
-
223
- fs.mkdirSync(DATA_DIR, { recursive: true });
224
-
225
- const FILE_NAME_PATTERN = /^metrics-(\d{4}-\d{2}-\d{2})\.jsonl$/;
226
-
227
- function dateStringFor(date) {
228
- return date.toISOString().slice(0, 10); // YYYY-MM-DD, UTC
229
- }
230
-
231
- function pathForDateString(dateStr) {
232
- return path.join(DATA_DIR, `metrics-${dateStr}.jsonl`);
233
- }
234
-
235
- /** Today's log file path, in UTC. Exposed for tests; not meant for app code to write to directly - use record(). */
236
- function currentLogPath() {
237
- return pathForDateString(dateStringFor(new Date()));
238
- }
239
-
240
- /**
241
- * Every rotated log file present, ascending by date. A file that
242
- * doesn't match the naming pattern (stray file, .gitkeep, whatever) is
243
- * silently ignored rather than treated as a parse error.
244
- */
245
- async function listLogFiles() {
246
- let names;
247
- try {
248
- names = await fs.promises.readdir(DATA_DIR);
249
- } catch {
250
- return [];
251
- }
252
- return names
253
- .map((name) => {
254
- const match = name.match(FILE_NAME_PATTERN);
255
- return match ? { date: match[1], path: path.join(DATA_DIR, name) } : null;
256
- })
257
- .filter(Boolean)
258
- .sort((a, b) => a.date.localeCompare(b.date));
259
- }
260
-
261
- async function readFileRows(filePath) {
262
- if (!fs.existsSync(filePath)) return [];
263
- const rows = [];
264
- const rl = readline.createInterface({
265
- input: fs.createReadStream(filePath),
266
- crlfDelay: Infinity
267
- });
268
- for await (const line of rl) {
269
- if (!line.trim()) continue;
270
- try {
271
- rows.push(JSON.parse(line));
272
- } catch {
273
- // Skip a malformed line rather than aborting the whole read.
274
- }
275
- }
276
- return rows;
277
- }
278
-
279
- // A plain per-call fs.appendFile was tried here first and was wrong:
280
- // concurrent calls to record() (real traffic under load, or even just a
281
- // tight test loop) fire multiple appendFile operations at once with no
282
- // guaranteed completion order, so lines could interleave or land out of
283
- // order - a real, reproducible flake this project's own tests caught
284
- // (roughly 1 run in 5). A single long-lived write stream serializes its
285
- // writes internally even when called back-to-back without awaiting
286
- // each one, which is what actually guarantees ordering. The only thing
287
- // a persistent stream needs extra is handling day rollover - resolved
288
- // by checking today's date on every write and swapping to a fresh
289
- // stream the moment it changes, so a long-running process still rotates
290
- // correctly without ever writing yesterday's line into today's file or
291
- // vice versa.
292
- let currentStream = null;
293
- let currentStreamDate = null;
294
-
295
- function ensureWriteStream() {
296
- const today = dateStringFor(new Date());
297
- if (currentStream && currentStreamDate === today) return currentStream;
298
- if (currentStream) currentStream.end();
299
- currentStreamDate = today;
300
- currentStream = fs.createWriteStream(pathForDateString(today), { flags: 'a' });
301
- currentStream.on('error', (err) => {
302
- console.warn('⚠️ Metrics log write error:', err.message);
303
- });
304
- return currentStream;
305
- }
306
-
307
- /**
308
- * Record one completed request, appended to TODAY's file. Fire-and-
309
- * forget by design - a metrics write must never be the reason a real
310
- * request fails or slows down.
311
- */
312
- function record(entry) {
313
- if (usingPostgres()) {
314
- recordToPostgres(entry); // fire-and-forget - see its own comment
315
- return;
316
- }
317
- try {
318
- const line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n';
319
- ensureWriteStream().write(line);
320
- } catch (err) {
321
- console.warn('⚠️ Failed to record metric:', err.message);
322
- }
323
- }
324
-
325
- /**
326
- * Read up to `limit` most recent records, scanning files newest-first
327
- * and stopping as soon as enough rows have been collected - bounded by
328
- * how many DAYS of data are needed to satisfy `limit`, not by the
329
- * service's entire lifetime.
330
- */
331
- async function readRecent(limit = 500) {
332
- if (usingPostgres()) return readRecentFromPostgres(limit);
333
- const files = await listLogFiles();
334
- const collected = [];
335
- for (let i = files.length - 1; i >= 0 && collected.length < limit; i--) {
336
- const rows = await readFileRows(files[i].path);
337
- collected.unshift(...rows);
338
- }
339
- return collected.slice(-limit);
340
- }
341
-
342
- /**
343
- * Rolling per-provider stats from the most recent `windowSize` requests
344
- * to that provider: average latency and error rate. This is the signal
345
- * router.js uses alongside the static cost table - a provider that is
346
- * currently slow or failing shouldn't be picked just because its list
347
- * price is lowest.
348
- */
349
- async function providerStats(windowSize = 50) {
350
- const rows = await readRecent(2000);
351
- const byProvider = {};
352
-
353
- for (const row of rows) {
354
- if (!row.provider) continue;
355
- if (!byProvider[row.provider]) byProvider[row.provider] = [];
356
- byProvider[row.provider].push(row);
357
- }
358
-
359
- const stats = {};
360
- for (const [provider, entries] of Object.entries(byProvider)) {
361
- const recent = entries.slice(-windowSize);
362
- const errorEntries = recent.filter((e) => e.error);
363
- const latencies = recent.filter((e) => !e.error && typeof e.latency_ms === 'number');
364
- const avgLatencyMs = latencies.length
365
- ? latencies.reduce((sum, e) => sum + e.latency_ms, 0) / latencies.length
366
- : null;
367
- // The MOST RECENT error only, not a tally of every type seen in the
368
- // window - an alert should reflect "what's wrong right now," not a
369
- // mix that might include something already fixed earlier in the
370
- // window. Falls back to classifying on the fly for an older record
371
- // written before error_type existed (see server.js) instead of
372
- // silently going blank.
373
- const lastError = errorEntries.length ? errorEntries[errorEntries.length - 1] : null;
374
-
375
- stats[provider] = {
376
- sampleSize: recent.length,
377
- errorRate: recent.length ? errorEntries.length / recent.length : 0,
378
- avgLatencyMs,
379
- lastErrorType: lastError ? lastError.error_type || classifyErrorType(lastError.error) : null,
380
- lastErrorAt: lastError ? lastError.timestamp : null
381
- };
382
- }
383
- return stats;
384
- }
385
-
386
- /**
387
- * Everything the cost dashboard needs for a calendar window, computed
388
- * in one pass so the KPI numbers and the daily chart data are
389
- * guaranteed to agree - they're two views of the exact same filtered
390
- * rows, never two separate queries that could drift apart.
391
- *
392
- * Only the files whose OWN date falls inside [cutoff, today] are read
393
- * at all - a 14-day query never opens a file from three months ago.
394
- * The per-row timestamp filter still runs afterward (a file's date is
395
- * an inclusion bound, not a correctness guarantee - see
396
- * listLogFiles()'s "only matches the naming pattern" note).
397
- *
398
- * A "miss" bucket is anything dispatched to a provider that WASN'T a
399
- * cache hit, successful or not - the error count is tracked alongside
400
- * it per day for the table view and tooltip, but deliberately isn't
401
- * its own stacked-chart series (see the dashboard page: three clean
402
- * outcome series read better than four, and error rate has its own,
403
- * more precise, KPI tile and per-provider breakdown instead).
404
- *
405
- * by_provider here is intentionally a different shape than
406
- * providerStats() above: that one is a ROLLING window for routing
407
- * health (router.js), this one is a CALENDAR window for reporting
408
- * (the dashboard) and also carries request count and cost. Same
409
- * underlying log, two different questions - not accidentally
410
- * duplicated logic.
411
- */
412
- async function rangeSummary(days = 14) {
413
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
414
-
415
- // Row-fetching prelude only - everything from here down (the actual
416
- // daily/provider/hit-rate aggregation) is identical regardless of
417
- // which backend supplied `rows`, so it's written once, below, shared
418
- // by both.
419
- let rows;
420
- if (usingPostgres()) {
421
- rows = await rowsSinceFromPostgres(cutoff); // already filtered server-side
422
- } else {
423
- const files = await listLogFiles();
424
- const relevantFiles = files.filter((f) => {
425
- // A file's own day spans [dayStart, dayStart + 24h) UTC; keep it if
426
- // any part of that day could be on or after the cutoff.
427
- const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
428
- return dayStart + 24 * 60 * 60 * 1000 > cutoff;
429
- });
430
- rows = [];
431
- for (const f of relevantFiles) {
432
- rows = rows.concat(await readFileRows(f.path));
433
- }
434
- }
435
- const inRange = rows.filter((r) => r.timestamp && Date.parse(r.timestamp) >= cutoff);
436
-
437
- const dailyByDate = new Map();
438
- const byProvider = {};
439
- let totalCostUsd = 0;
440
- let exactHits = 0;
441
- let semanticHits = 0;
442
- let misses = 0;
443
- let errors = 0;
444
-
445
- for (const row of inRange) {
446
- const date = row.timestamp.slice(0, 10); // YYYY-MM-DD (UTC, from toISOString())
447
- if (!dailyByDate.has(date)) {
448
- dailyByDate.set(date, { date, requests: 0, cost_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
449
- }
450
- const bucket = dailyByDate.get(date);
451
- bucket.requests += 1;
452
- bucket.cost_usd += row.cost_usd || 0;
453
- totalCostUsd += row.cost_usd || 0;
454
-
455
- if (row.provider) {
456
- if (!byProvider[row.provider]) {
457
- byProvider[row.provider] = { requests: 0, cost_usd: 0, errorCount: 0, latencies: [] };
458
- }
459
- const p = byProvider[row.provider];
460
- p.requests += 1;
461
- p.cost_usd += row.cost_usd || 0;
462
- if (row.error) p.errorCount += 1;
463
- else if (typeof row.latency_ms === 'number') p.latencies.push(row.latency_ms);
464
- }
465
-
466
- if (row.error) {
467
- errors += 1;
468
- bucket.errors += 1;
469
- } else if (row.cache_hit && row.cache_type === 'semantic') {
470
- semanticHits += 1;
471
- bucket.semantic_hits += 1;
472
- } else if (row.cache_hit) {
473
- exactHits += 1;
474
- bucket.exact_hits += 1;
475
- } else {
476
- misses += 1;
477
- bucket.misses += 1;
478
- }
479
- }
480
-
481
- const providerSummary = {};
482
- for (const [name, p] of Object.entries(byProvider)) {
483
- providerSummary[name] = {
484
- requests: p.requests,
485
- cost_usd: p.cost_usd,
486
- errorRate: p.requests ? p.errorCount / p.requests : 0,
487
- avgLatencyMs: p.latencies.length ? p.latencies.reduce((sum, v) => sum + v, 0) / p.latencies.length : null
488
- };
489
- }
490
-
491
- return {
492
- days,
493
- sample_size: inRange.length,
494
- total_cost_usd: totalCostUsd,
495
- cache_hit_rate: {
496
- exact: inRange.length ? exactHits / inRange.length : 0,
497
- semantic: inRange.length ? semanticHits / inRange.length : 0,
498
- combined: inRange.length ? (exactHits + semanticHits) / inRange.length : 0
499
- },
500
- error_rate: inRange.length ? errors / inRange.length : 0,
501
- by_provider: providerSummary,
502
- daily: [...dailyByDate.values()].sort((a, b) => a.date.localeCompare(b.date))
503
- };
504
- }
505
-
506
- /**
507
- * Explicit, opt-in cleanup: permanently deletes records older than
508
- * `days`. NOT called automatically anywhere in this module - deleting
509
- * cost/audit history is a retention-policy decision an operator makes
510
- * on purpose (a cron job, a manual run), never something this module
511
- * decides silently on their behalf. Returns the list of deleted file
512
- * paths (file backend) or deleted row ids (Postgres backend) - the two
513
- * backends' units of deletion genuinely differ, so the return value's
514
- * shape does too; nothing in this codebase inspects the contents today,
515
- * only that pruning happened and what it removed.
516
- */
517
- async function pruneOlderThan(days) {
518
- if (usingPostgres()) return pruneOlderThanPostgres(days);
519
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
520
- const files = await listLogFiles();
521
- const deleted = [];
522
- for (const f of files) {
523
- const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
524
- if (dayStart + 24 * 60 * 60 * 1000 <= cutoff) {
525
- await fs.promises.unlink(f.path);
526
- deleted.push(f.path);
527
- }
528
- }
529
- return deleted;
530
- }
531
-
532
- // Test-only: closes the cached pool (if one was ever opened) so a test
533
- // run doesn't hang on an open connection, and so the NEXT test that
534
- // re-requires this module with a different DATABASE_URL gets a fresh
535
- // pool/schema-ready state instead of reusing this one's.
536
- async function closePostgresPoolForTests() {
537
- if (pgPool) {
538
- await pgPool.end();
539
- pgPool = null;
540
- schemaReady = null;
541
- }
542
- }
543
-
544
- module.exports = {
545
- record,
546
- readRecent,
547
- providerStats,
548
- rangeSummary,
549
- pruneOlderThan,
550
- currentLogPath,
551
- listLogFiles,
552
- classifyErrorType,
553
- usingPostgres,
554
- closePostgresPoolForTests,
555
- DATA_DIR
556
- };
1
+ // model-router/metrics.js
2
+ //
3
+ // A shared record of what actually happened on every request: which
4
+ // provider handled it, how long it took, what it cost, whether it was
5
+ // a cache hit, and whether it errored. Two things depend on this data
6
+ // existing, so it's built once, here, rather than twice:
7
+ //
8
+ // 1. Routing (router.js) needs rolling latency/error-rate per
9
+ // provider to make a "cheapest CAPABLE provider" decision - a
10
+ // static price table alone can't tell you a provider is currently
11
+ // slow or failing.
12
+ // 2. The cost dashboard needs historical data to show - there is
13
+ // nothing to dashboard without a log.
14
+ //
15
+ // Storage is local, append-only JSONL, not a database - that keeps the
16
+ // self-hosted/lightweight positioning honest (no new infrastructure to
17
+ // run) while still being real persistence: every line is a complete,
18
+ // independent JSON record, so a crash mid-write loses at most the one
19
+ // in-flight line, and any tool that can read lines of JSON (jq, a
20
+ // script) can consume it directly.
21
+ //
22
+ // ROTATION: one file per UTC calendar day (metrics-YYYY-MM-DD.jsonl),
23
+ // not one file forever. This used to be a real, documented gap - a
24
+ // single ever-growing file that every read (a dashboard load, a /stats
25
+ // call, a routing-health check) re-read and re-parsed in FULL,
26
+ // regardless of how much data the caller actually needed. Splitting by
27
+ // day means readRecent() and rangeSummary() only open the files that
28
+ // could actually contain what they're looking for - a 14-day dashboard
29
+ // query reads at most 15 files, not the service's entire history.
30
+ // Old files are NOT deleted automatically - see pruneOlderThan() for
31
+ // the explicit, opt-in cleanup an operator can run; silently deleting
32
+ // someone's cost history without being asked is a worse default than
33
+ // disk slowly filling up, and this module doesn't get to make that
34
+ // retention call on its own.
35
+
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const readline = require('readline');
39
+ const { Pool } = require('pg');
40
+
41
+ // Postgres-backed persistence - OPT-IN, not a replacement. The JSONL
42
+ // file storage above/below stays the default for exactly the reason
43
+ // its own original comment gives: self-hosted/lightweight, zero new
44
+ // infrastructure required to run this router standalone in some other
45
+ // app. But the EMBEDDED deployment inside MemoCode specifically already
46
+ // has a real Postgres database (memocode-db, provisioned for its own
47
+ // user-account/library data regardless of this router) - reusing that
48
+ // costs nothing new (no extra service, no extra bill, no extra account)
49
+ // and, unlike the router's own container filesystem, genuinely survives
50
+ // a restart/redeploy. DATABASE_URL is Render's own standard convention
51
+ // for injecting a database's connection string (matches how
52
+ // 000_backend/db.mjs reads the exact same variable for the exact same
53
+ // reason) - set it and every function below transparently reads/writes
54
+ // Postgres instead of local files; leave it unset and nothing here
55
+ // changes at all.
56
+ function usingPostgres() {
57
+ return Boolean(process.env.DATABASE_URL || process.env.MEMOCODE_ROUTER_DATABASE_URL);
58
+ }
59
+
60
+ let pgPool = null;
61
+ function getPool() {
62
+ if (!pgPool) {
63
+ const connectionString = process.env.MEMOCODE_ROUTER_DATABASE_URL || process.env.DATABASE_URL;
64
+ pgPool = new Pool({
65
+ connectionString,
66
+ // Same rule db.mjs already uses: a real hosted Postgres (Render's
67
+ // managed instance) needs SSL; a local one (dev, this module's
68
+ // own tests) doesn't and would just fail the handshake if asked.
69
+ ssl: connectionString && !/localhost|127\.0\.0\.1/.test(connectionString) ? { rejectUnauthorized: false } : false
70
+ });
71
+ }
72
+ return pgPool;
73
+ }
74
+
75
+ // Idempotent - safe to call on every getPool() use (CREATE TABLE/INDEX
76
+ // IF NOT EXISTS), so a fresh deployment self-provisions its own schema
77
+ // on first write with no separate migration step to remember to run.
78
+ // `scope` (seams work, roadmap: engine/cloud "wrap it, don't fork it"):
79
+ // NULLABLE, not NOT NULL - deliberately different from how a fork
80
+ // starting fresh (CREATE TABLE with a required column) would do it.
81
+ // This module has live deployments already running against an existing
82
+ // table (MemoCode's own render wiring) where CREATE TABLE IF NOT EXISTS
83
+ // is a no-op on an already-created table - ADD COLUMN IF NOT EXISTS is
84
+ // what actually reaches an existing table's schema (same ALTER pattern
85
+ // the sibling backend's db.mjs already uses for exactly this reason).
86
+ // Existing rows get scope = NULL, which is exactly right: they were
87
+ // recorded before scope existed, under the one global/unscoped history,
88
+ // and null-scope reads (see rowFilterSql below) return precisely that
89
+ // history unfiltered.
90
+ let schemaReady = null;
91
+ async function ensureSchema() {
92
+ if (!schemaReady) {
93
+ schemaReady = getPool().query(`
94
+ CREATE TABLE IF NOT EXISTS router_metrics (
95
+ id BIGSERIAL PRIMARY KEY,
96
+ ts TIMESTAMPTZ NOT NULL DEFAULT now(),
97
+ provider TEXT,
98
+ model TEXT,
99
+ requested_model TEXT,
100
+ cache_hit BOOLEAN,
101
+ cache_type TEXT,
102
+ latency_ms INTEGER,
103
+ cost_usd DOUBLE PRECISION,
104
+ error TEXT,
105
+ error_type TEXT
106
+ );
107
+ ALTER TABLE router_metrics ADD COLUMN IF NOT EXISTS scope TEXT;
108
+ CREATE INDEX IF NOT EXISTS router_metrics_ts_idx ON router_metrics (ts DESC);
109
+ CREATE INDEX IF NOT EXISTS router_metrics_provider_ts_idx ON router_metrics (provider, ts DESC);
110
+ CREATE INDEX IF NOT EXISTS router_metrics_scope_ts_idx ON router_metrics (scope, ts DESC);
111
+ `);
112
+ }
113
+ return schemaReady;
114
+ }
115
+
116
+ // Maps one Postgres row back to the exact same shape record() writes to
117
+ // a JSONL line - so every function below this point (readRecent,
118
+ // providerStats, rangeSummary) can share its existing row-aggregation
119
+ // logic UNCHANGED regardless of which backend actually supplied the
120
+ // rows. Only the row-fetching prelude differs between the two backends;
121
+ // nothing downstream needs to know or care which one ran.
122
+ function rowFromPg(dbRow) {
123
+ return {
124
+ timestamp: dbRow.ts.toISOString(),
125
+ scope: dbRow.scope === null ? undefined : dbRow.scope,
126
+ provider: dbRow.provider || undefined,
127
+ model: dbRow.model || undefined,
128
+ requested_model: dbRow.requested_model || undefined,
129
+ cache_hit: dbRow.cache_hit === null ? undefined : dbRow.cache_hit,
130
+ cache_type: dbRow.cache_type || undefined,
131
+ latency_ms: dbRow.latency_ms === null ? undefined : dbRow.latency_ms,
132
+ cost_usd: dbRow.cost_usd === null ? undefined : dbRow.cost_usd,
133
+ error: dbRow.error || undefined,
134
+ error_type: dbRow.error_type || undefined
135
+ };
136
+ }
137
+
138
+ async function recordToPostgres(scope, entry) {
139
+ try {
140
+ await ensureSchema();
141
+ await getPool().query(
142
+ `INSERT INTO router_metrics
143
+ (scope, provider, model, requested_model, cache_hit, cache_type, latency_ms, cost_usd, error, error_type)
144
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
145
+ [
146
+ scope != null ? String(scope) : null,
147
+ entry.provider ?? null,
148
+ entry.model ?? null,
149
+ entry.requested_model ?? null,
150
+ entry.cache_hit ?? null,
151
+ entry.cache_type ?? null,
152
+ entry.latency_ms ?? null,
153
+ entry.cost_usd ?? null,
154
+ entry.error ?? null,
155
+ entry.error_type ?? null
156
+ ]
157
+ );
158
+ } catch (err) {
159
+ // Same fire-and-forget contract as the file backend's own record():
160
+ // a metrics write must never be the reason a real request fails.
161
+ console.warn('⚠️ Failed to record metric (Postgres):', err.message);
162
+ }
163
+ }
164
+
165
+ // scope === null/undefined means "global" - EVERY row, not rows whose
166
+ // own scope happens to be NULL. That's the seams contract this whole
167
+ // module holds to (see readRecent/providerStats/rangeSummary below): a
168
+ // deployment that never adopts scoping keeps reading its entire history
169
+ // exactly as it always has, whatever a future scoped caller happens to
170
+ // write alongside it. `scope = $1` is used only when a real scope value
171
+ // was actually asked for.
172
+ async function readRecentFromPostgres(scope, limit) {
173
+ await ensureSchema();
174
+ const result = scope != null
175
+ ? await getPool().query(
176
+ `SELECT * FROM router_metrics WHERE scope = $1 ORDER BY ts DESC LIMIT $2`,
177
+ [String(scope), limit]
178
+ )
179
+ : await getPool().query(
180
+ `SELECT * FROM router_metrics ORDER BY ts DESC LIMIT $1`,
181
+ [limit]
182
+ );
183
+ return result.rows.reverse().map(rowFromPg); // oldest-first, matching readRecent()'s own file-backed order
184
+ }
185
+
186
+ async function rowsSinceFromPostgres(scope, cutoffMs) {
187
+ await ensureSchema();
188
+ const result = scope != null
189
+ ? await getPool().query(
190
+ `SELECT * FROM router_metrics WHERE scope = $1 AND ts >= $2 ORDER BY ts ASC`,
191
+ [String(scope), new Date(cutoffMs)]
192
+ )
193
+ : await getPool().query(
194
+ `SELECT * FROM router_metrics WHERE ts >= $1 ORDER BY ts ASC`,
195
+ [new Date(cutoffMs)]
196
+ );
197
+ return result.rows.map(rowFromPg);
198
+ }
199
+
200
+ async function pruneOlderThanPostgres(days) {
201
+ await ensureSchema();
202
+ const result = await getPool().query(
203
+ `DELETE FROM router_metrics WHERE ts < now() - ($1::double precision * interval '1 day') RETURNING id`,
204
+ [days]
205
+ );
206
+ return result.rows.map((r) => r.id);
207
+ }
208
+
209
+ async function pruneScopedOlderThanPostgres(scope, days) {
210
+ await ensureSchema();
211
+ const result = await getPool().query(
212
+ `DELETE FROM router_metrics WHERE scope = $1 AND ts < now() - ($2::double precision * interval '1 day') RETURNING id`,
213
+ [String(scope), days]
214
+ );
215
+ return result.rows.map((r) => r.id);
216
+ }
217
+
218
+ // Turns a raw provider error message into one of a handful of stable,
219
+ // human-meaningful buckets - the difference between a dashboard that says
220
+ // "openai: 100% error rate" (true, but not actionable without opening a
221
+ // terminal and reading a stack trace) and one that says
222
+ // "openai: authentication_error" (tells you exactly what to go fix).
223
+ // Anthropic's and OpenAI's SDKs both format a thrown error's .message the
224
+ // same way: "<http status> <json error body>" - so this tries that shape
225
+ // first (checking BOTH `.error.type`/`.error.code` for OpenAI's nesting
226
+ // and bare `.type` for Anthropic's), and falls back to keyword matching
227
+ // on the raw text for anything that doesn't parse (a network error has no
228
+ // JSON body at all, for instance - "unknown" is still more honest than
229
+ // guessing). Exported so server.js's error handlers and this module's own
230
+ // tests can both use the exact same classification, never two versions
231
+ // that could drift apart.
232
+ function classifyErrorType(message) {
233
+ if (!message) return 'unknown';
234
+ const jsonStart = message.indexOf('{');
235
+ if (jsonStart !== -1) {
236
+ try {
237
+ const body = JSON.parse(message.slice(jsonStart));
238
+ const type = body?.error?.type || body?.type;
239
+ const code = body?.error?.code;
240
+ if (type === 'authentication_error' || code === 'invalid_api_key') return 'authentication_error';
241
+ if (type === 'insufficient_quota' || code === 'insufficient_quota') return 'insufficient_quota';
242
+ if (type === 'rate_limit_error' || code === 'rate_limit_exceeded') return 'rate_limit_error';
243
+ if (type) return type; // whatever the provider itself called it - still more useful than "unknown"
244
+ } catch {
245
+ // Not JSON (or not shaped as expected) - fall through to keywords.
246
+ }
247
+ }
248
+ const lower = message.toLowerCase();
249
+ if (lower.includes('invalid') && lower.includes('key')) return 'authentication_error';
250
+ if (lower.includes('quota') || lower.includes('insufficient')) return 'insufficient_quota';
251
+ if (lower.includes('rate limit') || lower.includes('429')) return 'rate_limit_error';
252
+ return 'unknown';
253
+ }
254
+
255
+ // Historical note: METRICS_LOG_PATH used to name the one-and-only log
256
+ // file directly. It's kept as the configuration knob for backward
257
+ // compatibility, but now names the DIRECTORY those per-day files live
258
+ // in (its dirname) - existing deployments/tests that set it to a file
259
+ // path like ".../data/metrics.jsonl" keep working unchanged, since
260
+ // that file's directory is exactly where rotation stores things now.
261
+ const DATA_DIR = process.env.METRICS_LOG_PATH
262
+ ? path.dirname(process.env.METRICS_LOG_PATH)
263
+ : path.join(__dirname, 'data');
264
+
265
+ fs.mkdirSync(DATA_DIR, { recursive: true });
266
+
267
+ const FILE_NAME_PATTERN = /^metrics-(\d{4}-\d{2}-\d{2})\.jsonl$/;
268
+
269
+ function dateStringFor(date) {
270
+ return date.toISOString().slice(0, 10); // YYYY-MM-DD, UTC
271
+ }
272
+
273
+ function pathForDateString(dateStr) {
274
+ return path.join(DATA_DIR, `metrics-${dateStr}.jsonl`);
275
+ }
276
+
277
+ /** Today's log file path, in UTC. Exposed for tests; not meant for app code to write to directly - use record(). */
278
+ function currentLogPath() {
279
+ return pathForDateString(dateStringFor(new Date()));
280
+ }
281
+
282
+ /**
283
+ * Every rotated log file present, ascending by date. A file that
284
+ * doesn't match the naming pattern (stray file, .gitkeep, whatever) is
285
+ * silently ignored rather than treated as a parse error.
286
+ */
287
+ async function listLogFiles() {
288
+ let names;
289
+ try {
290
+ names = await fs.promises.readdir(DATA_DIR);
291
+ } catch {
292
+ return [];
293
+ }
294
+ return names
295
+ .map((name) => {
296
+ const match = name.match(FILE_NAME_PATTERN);
297
+ return match ? { date: match[1], path: path.join(DATA_DIR, name) } : null;
298
+ })
299
+ .filter(Boolean)
300
+ .sort((a, b) => a.date.localeCompare(b.date));
301
+ }
302
+
303
+ async function readFileRows(filePath) {
304
+ if (!fs.existsSync(filePath)) return [];
305
+ const rows = [];
306
+ const rl = readline.createInterface({
307
+ input: fs.createReadStream(filePath),
308
+ crlfDelay: Infinity
309
+ });
310
+ for await (const line of rl) {
311
+ if (!line.trim()) continue;
312
+ try {
313
+ rows.push(JSON.parse(line));
314
+ } catch {
315
+ // Skip a malformed line rather than aborting the whole read.
316
+ }
317
+ }
318
+ return rows;
319
+ }
320
+
321
+ // A plain per-call fs.appendFile was tried here first and was wrong:
322
+ // concurrent calls to record() (real traffic under load, or even just a
323
+ // tight test loop) fire multiple appendFile operations at once with no
324
+ // guaranteed completion order, so lines could interleave or land out of
325
+ // order - a real, reproducible flake this project's own tests caught
326
+ // (roughly 1 run in 5). A single long-lived write stream serializes its
327
+ // writes internally even when called back-to-back without awaiting
328
+ // each one, which is what actually guarantees ordering. The only thing
329
+ // a persistent stream needs extra is handling day rollover - resolved
330
+ // by checking today's date on every write and swapping to a fresh
331
+ // stream the moment it changes, so a long-running process still rotates
332
+ // correctly without ever writing yesterday's line into today's file or
333
+ // vice versa.
334
+ let currentStream = null;
335
+ let currentStreamDate = null;
336
+
337
+ function ensureWriteStream() {
338
+ const today = dateStringFor(new Date());
339
+ if (currentStream && currentStreamDate === today) return currentStream;
340
+ if (currentStream) currentStream.end();
341
+ currentStreamDate = today;
342
+ currentStream = fs.createWriteStream(pathForDateString(today), { flags: 'a' });
343
+ currentStream.on('error', (err) => {
344
+ console.warn('⚠️ Metrics log write error:', err.message);
345
+ });
346
+ return currentStream;
347
+ }
348
+
349
+ /**
350
+ * Record one completed request, appended to TODAY's file. Fire-and-
351
+ * forget by design - a metrics write must never be the reason a real
352
+ * request fails or slows down.
353
+ *
354
+ * `scope` (seams work): an opaque isolation key, same contract as
355
+ * cache.js's - null/undefined (every call site in this codebase today)
356
+ * omits the field entirely, so an unscoped deployment's JSONL rows are
357
+ * byte-identical to before this parameter existed.
358
+ */
359
+ function record(scope, entry) {
360
+ if (usingPostgres()) {
361
+ recordToPostgres(scope, entry); // fire-and-forget - see its own comment
362
+ return;
363
+ }
364
+ try {
365
+ const line = JSON.stringify({
366
+ timestamp: new Date().toISOString(),
367
+ ...(scope != null ? { scope } : {}),
368
+ ...entry
369
+ }) + '\n';
370
+ ensureWriteStream().write(line);
371
+ } catch (err) {
372
+ console.warn('⚠️ Failed to record metric:', err.message);
373
+ }
374
+ }
375
+
376
+ // scope === null/undefined means "every row", not "rows whose own
377
+ // scope field happens to be absent" - see readRecentFromPostgres's own
378
+ // comment for why that distinction matters (it's what keeps an
379
+ // unscoped deployment's history complete once ANY caller starts
380
+ // passing a real scope).
381
+ function matchesScope(row, scope) {
382
+ return scope == null || row.scope === scope;
383
+ }
384
+
385
+ /**
386
+ * Read up to `limit` most recent records, scanning files newest-first
387
+ * and stopping as soon as enough rows have been collected - bounded by
388
+ * how many DAYS of data are needed to satisfy `limit`, not by the
389
+ * service's entire lifetime.
390
+ */
391
+ async function readRecent(scope, limit = 500) {
392
+ if (usingPostgres()) return readRecentFromPostgres(scope, limit);
393
+ const files = await listLogFiles();
394
+ const collected = [];
395
+ for (let i = files.length - 1; i >= 0 && collected.length < limit; i--) {
396
+ const rows = (await readFileRows(files[i].path)).filter((r) => matchesScope(r, scope));
397
+ collected.unshift(...rows);
398
+ if (collected.length > limit) collected.splice(0, collected.length - limit);
399
+ }
400
+ return collected.slice(-limit);
401
+ }
402
+
403
+ /**
404
+ * Rolling per-provider stats from the most recent `windowSize` requests
405
+ * to that provider: average latency and error rate. This is the signal
406
+ * router.js uses alongside the static cost table - a provider that is
407
+ * currently slow or failing shouldn't be picked just because its list
408
+ * price is lowest.
409
+ */
410
+ async function providerStats(scope, windowSize = 50) {
411
+ const rows = await readRecent(scope, 2000);
412
+ const byProvider = {};
413
+
414
+ for (const row of rows) {
415
+ if (!row.provider) continue;
416
+ if (!byProvider[row.provider]) byProvider[row.provider] = [];
417
+ byProvider[row.provider].push(row);
418
+ }
419
+
420
+ const stats = {};
421
+ for (const [provider, entries] of Object.entries(byProvider)) {
422
+ const recent = entries.slice(-windowSize);
423
+ const errorEntries = recent.filter((e) => e.error);
424
+ const latencies = recent.filter((e) => !e.error && typeof e.latency_ms === 'number');
425
+ const avgLatencyMs = latencies.length
426
+ ? latencies.reduce((sum, e) => sum + e.latency_ms, 0) / latencies.length
427
+ : null;
428
+ // The MOST RECENT error only, not a tally of every type seen in the
429
+ // window - an alert should reflect "what's wrong right now," not a
430
+ // mix that might include something already fixed earlier in the
431
+ // window. Falls back to classifying on the fly for an older record
432
+ // written before error_type existed (see server.js) instead of
433
+ // silently going blank.
434
+ const lastError = errorEntries.length ? errorEntries[errorEntries.length - 1] : null;
435
+
436
+ stats[provider] = {
437
+ sampleSize: recent.length,
438
+ errorRate: recent.length ? errorEntries.length / recent.length : 0,
439
+ avgLatencyMs,
440
+ lastErrorType: lastError ? lastError.error_type || classifyErrorType(lastError.error) : null,
441
+ lastErrorAt: lastError ? lastError.timestamp : null
442
+ };
443
+ }
444
+ return stats;
445
+ }
446
+
447
+ /**
448
+ * Everything the cost dashboard needs for a calendar window, computed
449
+ * in one pass so the KPI numbers and the daily chart data are
450
+ * guaranteed to agree - they're two views of the exact same filtered
451
+ * rows, never two separate queries that could drift apart.
452
+ *
453
+ * Only the files whose OWN date falls inside [cutoff, today] are read
454
+ * at all - a 14-day query never opens a file from three months ago.
455
+ * The per-row timestamp filter still runs afterward (a file's date is
456
+ * an inclusion bound, not a correctness guarantee - see
457
+ * listLogFiles()'s "only matches the naming pattern" note).
458
+ *
459
+ * A "miss" bucket is anything dispatched to a provider that WASN'T a
460
+ * cache hit, successful or not - the error count is tracked alongside
461
+ * it per day for the table view and tooltip, but deliberately isn't
462
+ * its own stacked-chart series (see the dashboard page: three clean
463
+ * outcome series read better than four, and error rate has its own,
464
+ * more precise, KPI tile and per-provider breakdown instead).
465
+ *
466
+ * by_provider here is intentionally a different shape than
467
+ * providerStats() above: that one is a ROLLING window for routing
468
+ * health (router.js), this one is a CALENDAR window for reporting
469
+ * (the dashboard) and also carries request count and cost. Same
470
+ * underlying log, two different questions - not accidentally
471
+ * duplicated logic.
472
+ */
473
+ async function rangeSummary(scope, days = 14) {
474
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
475
+
476
+ // Row-fetching prelude only - everything from here down (the actual
477
+ // daily/provider/hit-rate aggregation) is identical regardless of
478
+ // which backend supplied `rows`, so it's written once, below, shared
479
+ // by both.
480
+ let rows;
481
+ if (usingPostgres()) {
482
+ rows = await rowsSinceFromPostgres(scope, cutoff); // already filtered server-side
483
+ } else {
484
+ const files = await listLogFiles();
485
+ const relevantFiles = files.filter((f) => {
486
+ // A file's own day spans [dayStart, dayStart + 24h) UTC; keep it if
487
+ // any part of that day could be on or after the cutoff.
488
+ const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
489
+ return dayStart + 24 * 60 * 60 * 1000 > cutoff;
490
+ });
491
+ rows = [];
492
+ for (const f of relevantFiles) {
493
+ rows = rows.concat((await readFileRows(f.path)).filter((r) => matchesScope(r, scope)));
494
+ }
495
+ }
496
+ const inRange = rows.filter((r) => r.timestamp && Date.parse(r.timestamp) >= cutoff);
497
+
498
+ const dailyByDate = new Map();
499
+ const byProvider = {};
500
+ let totalCostUsd = 0;
501
+ let exactHits = 0;
502
+ let semanticHits = 0;
503
+ let misses = 0;
504
+ let errors = 0;
505
+
506
+ for (const row of inRange) {
507
+ const date = row.timestamp.slice(0, 10); // YYYY-MM-DD (UTC, from toISOString())
508
+ if (!dailyByDate.has(date)) {
509
+ dailyByDate.set(date, { date, requests: 0, cost_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
510
+ }
511
+ const bucket = dailyByDate.get(date);
512
+ bucket.requests += 1;
513
+ bucket.cost_usd += row.cost_usd || 0;
514
+ totalCostUsd += row.cost_usd || 0;
515
+
516
+ if (row.provider) {
517
+ if (!byProvider[row.provider]) {
518
+ byProvider[row.provider] = { requests: 0, cost_usd: 0, errorCount: 0, latencies: [] };
519
+ }
520
+ const p = byProvider[row.provider];
521
+ p.requests += 1;
522
+ p.cost_usd += row.cost_usd || 0;
523
+ if (row.error) p.errorCount += 1;
524
+ else if (typeof row.latency_ms === 'number') p.latencies.push(row.latency_ms);
525
+ }
526
+
527
+ if (row.error) {
528
+ errors += 1;
529
+ bucket.errors += 1;
530
+ } else if (row.cache_hit && row.cache_type === 'semantic') {
531
+ semanticHits += 1;
532
+ bucket.semantic_hits += 1;
533
+ } else if (row.cache_hit) {
534
+ exactHits += 1;
535
+ bucket.exact_hits += 1;
536
+ } else {
537
+ misses += 1;
538
+ bucket.misses += 1;
539
+ }
540
+ }
541
+
542
+ const providerSummary = {};
543
+ for (const [name, p] of Object.entries(byProvider)) {
544
+ providerSummary[name] = {
545
+ requests: p.requests,
546
+ cost_usd: p.cost_usd,
547
+ errorRate: p.requests ? p.errorCount / p.requests : 0,
548
+ avgLatencyMs: p.latencies.length ? p.latencies.reduce((sum, v) => sum + v, 0) / p.latencies.length : null
549
+ };
550
+ }
551
+
552
+ return {
553
+ days,
554
+ sample_size: inRange.length,
555
+ total_cost_usd: totalCostUsd,
556
+ cache_hit_rate: {
557
+ exact: inRange.length ? exactHits / inRange.length : 0,
558
+ semantic: inRange.length ? semanticHits / inRange.length : 0,
559
+ combined: inRange.length ? (exactHits + semanticHits) / inRange.length : 0
560
+ },
561
+ error_rate: inRange.length ? errors / inRange.length : 0,
562
+ by_provider: providerSummary,
563
+ daily: [...dailyByDate.values()].sort((a, b) => a.date.localeCompare(b.date))
564
+ };
565
+ }
566
+
567
+ /**
568
+ * Explicit, opt-in cleanup: permanently deletes records older than
569
+ * `days`. NOT called automatically anywhere in this module - deleting
570
+ * cost/audit history is a retention-policy decision an operator makes
571
+ * on purpose (a cron job, a manual run), never something this module
572
+ * decides silently on their behalf. Returns the list of deleted file
573
+ * paths (file backend) or deleted row ids (Postgres backend) - the two
574
+ * backends' units of deletion genuinely differ, so the return value's
575
+ * shape does too; nothing in this codebase inspects the contents today,
576
+ * only that pruning happened and what it removed.
577
+ */
578
+ async function pruneOlderThan(days) {
579
+ if (usingPostgres()) return pruneOlderThanPostgres(days);
580
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
581
+ const files = await listLogFiles();
582
+ const deleted = [];
583
+ for (const f of files) {
584
+ const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
585
+ if (dayStart + 24 * 60 * 60 * 1000 <= cutoff) {
586
+ await fs.promises.unlink(f.path);
587
+ deleted.push(f.path);
588
+ }
589
+ }
590
+ return deleted;
591
+ }
592
+
593
+ /**
594
+ * Scope-isolated cleanup (seams work): deletes only `scope`'s records
595
+ * older than `days`, leaving every other tenant's history untouched.
596
+ * This is the primitive a per-tenant retention policy needs - the DAYS
597
+ * live in the caller (a billing/tier decision, e.g. Free 7d / Starter
598
+ * 30d / Growth 90d), while the scope-filtered DELETE lives here, under
599
+ * the same `scope` contract as readRecent/providerStats/rangeSummary (a
600
+ * primitive; `scope = $1`). Postgres-only: the JSONL file backend keeps
601
+ * every scope in shared per-day append-only files, so excising one scope
602
+ * would mean rewriting those files mid-append - scoped prune is a
603
+ * multi-tenant (Postgres) concern, and on the file backend it is a
604
+ * documented no-op (warns, returns []). The global pruneOlderThan(days)
605
+ * above stays the unscoped, delete-everything form, for legacy/null-
606
+ * scope history and standalone single-tenant runs.
607
+ */
608
+ async function pruneScopedOlderThan(scope, days) {
609
+ // Fail closed on a null/undefined scope: this function is the SCOPED
610
+ // form, and passing null (which readRecent/providerStats/rangeSummary
611
+ // treat as "global") would otherwise either silently delete nothing
612
+ // (Postgres: scope = 'null' matches no real tenant) or warn-and-no-op
613
+ // (file backend) - both silent, both wrong for a caller who expected a
614
+ // global prune. Delegating to pruneOlderThan(days) instead would be
615
+ // the OPPOSITE hazard (silently deleting every tenant's history), so
616
+ // the safe answer is a loud error pointing at the right function.
617
+ if (scope == null) {
618
+ throw new Error(
619
+ 'pruneScopedOlderThan(scope, days) requires a non-null scope. ' +
620
+ 'Use pruneOlderThan(days) to prune globally.'
621
+ );
622
+ }
623
+ if (usingPostgres()) return pruneScopedOlderThanPostgres(scope, days);
624
+ console.warn(
625
+ '⚠️ pruneScopedOlderThan() is Postgres-only: the JSONL file backend ' +
626
+ 'cannot excise one scope from shared per-day files. Nothing deleted.'
627
+ );
628
+ return [];
629
+ }
630
+
631
+ // Test-only: closes the cached pool (if one was ever opened) so a test
632
+ // run doesn't hang on an open connection, and so the NEXT test that
633
+ // re-requires this module with a different DATABASE_URL gets a fresh
634
+ // pool/schema-ready state instead of reusing this one's.
635
+ async function closePostgresPoolForTests() {
636
+ if (pgPool) {
637
+ await pgPool.end();
638
+ pgPool = null;
639
+ schemaReady = null;
640
+ }
641
+ }
642
+
643
+ module.exports = {
644
+ record,
645
+ readRecent,
646
+ providerStats,
647
+ rangeSummary,
648
+ pruneOlderThan,
649
+ pruneScopedOlderThan,
650
+ currentLogPath,
651
+ listLogFiles,
652
+ classifyErrorType,
653
+ usingPostgres,
654
+ matchesScope,
655
+ closePostgresPoolForTests,
656
+ DATA_DIR
657
+ };