cachegate 1.1.0 → 1.2.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/.env.example +127 -112
- package/README.md +31 -13
- package/cache.js +72 -51
- package/embeddings.js +42 -32
- package/metrics.js +609 -556
- package/package.json +15 -1
- package/redisClient.js +55 -45
- package/router.js +254 -218
- package/semanticCache.js +159 -154
- package/server.js +282 -113
- package/.dockerignore +0 -11
- package/.gitattributes +0 -12
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -33
- package/.github/ISSUE_TEMPLATE/config.yml +0 -5
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -29
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -25
- package/.github/workflows/test.yml +0 -63
- package/CODE_OF_CONDUCT.md +0 -66
- package/CONTRIBUTING.md +0 -94
- package/Dockerfile +0 -24
- package/OPEN_SOURCE_ROADMAP.md +0 -855
- package/ROADMAP.md +0 -281
- package/SECURITY.md +0 -39
- package/sync-oss-release.sh +0 -160
- package/test/auth-config.test.js +0 -27
- package/test/cache.test.js +0 -33
- package/test/embeddings.test.js +0 -24
- package/test/env-path.test.js +0 -41
- package/test/failover.test.js +0 -99
- package/test/metrics-postgres.test.js +0 -183
- package/test/metrics.test.js +0 -282
- package/test/router.test.js +0 -195
- package/test/semanticCache.test.js +0 -167
- package/test/server.test.js +0 -357
- package/test/streaming.test.js +0 -248
package/metrics.js
CHANGED
|
@@ -1,556 +1,609 @@
|
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
// that
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
+
// Turns a raw provider error message into one of a handful of stable,
|
|
210
|
+
// human-meaningful buckets - the difference between a dashboard that says
|
|
211
|
+
// "openai: 100% error rate" (true, but not actionable without opening a
|
|
212
|
+
// terminal and reading a stack trace) and one that says
|
|
213
|
+
// "openai: authentication_error" (tells you exactly what to go fix).
|
|
214
|
+
// Anthropic's and OpenAI's SDKs both format a thrown error's .message the
|
|
215
|
+
// same way: "<http status> <json error body>" - so this tries that shape
|
|
216
|
+
// first (checking BOTH `.error.type`/`.error.code` for OpenAI's nesting
|
|
217
|
+
// and bare `.type` for Anthropic's), and falls back to keyword matching
|
|
218
|
+
// on the raw text for anything that doesn't parse (a network error has no
|
|
219
|
+
// JSON body at all, for instance - "unknown" is still more honest than
|
|
220
|
+
// guessing). Exported so server.js's error handlers and this module's own
|
|
221
|
+
// tests can both use the exact same classification, never two versions
|
|
222
|
+
// that could drift apart.
|
|
223
|
+
function classifyErrorType(message) {
|
|
224
|
+
if (!message) return 'unknown';
|
|
225
|
+
const jsonStart = message.indexOf('{');
|
|
226
|
+
if (jsonStart !== -1) {
|
|
227
|
+
try {
|
|
228
|
+
const body = JSON.parse(message.slice(jsonStart));
|
|
229
|
+
const type = body?.error?.type || body?.type;
|
|
230
|
+
const code = body?.error?.code;
|
|
231
|
+
if (type === 'authentication_error' || code === 'invalid_api_key') return 'authentication_error';
|
|
232
|
+
if (type === 'insufficient_quota' || code === 'insufficient_quota') return 'insufficient_quota';
|
|
233
|
+
if (type === 'rate_limit_error' || code === 'rate_limit_exceeded') return 'rate_limit_error';
|
|
234
|
+
if (type) return type; // whatever the provider itself called it - still more useful than "unknown"
|
|
235
|
+
} catch {
|
|
236
|
+
// Not JSON (or not shaped as expected) - fall through to keywords.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const lower = message.toLowerCase();
|
|
240
|
+
if (lower.includes('invalid') && lower.includes('key')) return 'authentication_error';
|
|
241
|
+
if (lower.includes('quota') || lower.includes('insufficient')) return 'insufficient_quota';
|
|
242
|
+
if (lower.includes('rate limit') || lower.includes('429')) return 'rate_limit_error';
|
|
243
|
+
return 'unknown';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Historical note: METRICS_LOG_PATH used to name the one-and-only log
|
|
247
|
+
// file directly. It's kept as the configuration knob for backward
|
|
248
|
+
// compatibility, but now names the DIRECTORY those per-day files live
|
|
249
|
+
// in (its dirname) - existing deployments/tests that set it to a file
|
|
250
|
+
// path like ".../data/metrics.jsonl" keep working unchanged, since
|
|
251
|
+
// that file's directory is exactly where rotation stores things now.
|
|
252
|
+
const DATA_DIR = process.env.METRICS_LOG_PATH
|
|
253
|
+
? path.dirname(process.env.METRICS_LOG_PATH)
|
|
254
|
+
: path.join(__dirname, 'data');
|
|
255
|
+
|
|
256
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
257
|
+
|
|
258
|
+
const FILE_NAME_PATTERN = /^metrics-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
|
259
|
+
|
|
260
|
+
function dateStringFor(date) {
|
|
261
|
+
return date.toISOString().slice(0, 10); // YYYY-MM-DD, UTC
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function pathForDateString(dateStr) {
|
|
265
|
+
return path.join(DATA_DIR, `metrics-${dateStr}.jsonl`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Today's log file path, in UTC. Exposed for tests; not meant for app code to write to directly - use record(). */
|
|
269
|
+
function currentLogPath() {
|
|
270
|
+
return pathForDateString(dateStringFor(new Date()));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Every rotated log file present, ascending by date. A file that
|
|
275
|
+
* doesn't match the naming pattern (stray file, .gitkeep, whatever) is
|
|
276
|
+
* silently ignored rather than treated as a parse error.
|
|
277
|
+
*/
|
|
278
|
+
async function listLogFiles() {
|
|
279
|
+
let names;
|
|
280
|
+
try {
|
|
281
|
+
names = await fs.promises.readdir(DATA_DIR);
|
|
282
|
+
} catch {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
return names
|
|
286
|
+
.map((name) => {
|
|
287
|
+
const match = name.match(FILE_NAME_PATTERN);
|
|
288
|
+
return match ? { date: match[1], path: path.join(DATA_DIR, name) } : null;
|
|
289
|
+
})
|
|
290
|
+
.filter(Boolean)
|
|
291
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function readFileRows(filePath) {
|
|
295
|
+
if (!fs.existsSync(filePath)) return [];
|
|
296
|
+
const rows = [];
|
|
297
|
+
const rl = readline.createInterface({
|
|
298
|
+
input: fs.createReadStream(filePath),
|
|
299
|
+
crlfDelay: Infinity
|
|
300
|
+
});
|
|
301
|
+
for await (const line of rl) {
|
|
302
|
+
if (!line.trim()) continue;
|
|
303
|
+
try {
|
|
304
|
+
rows.push(JSON.parse(line));
|
|
305
|
+
} catch {
|
|
306
|
+
// Skip a malformed line rather than aborting the whole read.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return rows;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A plain per-call fs.appendFile was tried here first and was wrong:
|
|
313
|
+
// concurrent calls to record() (real traffic under load, or even just a
|
|
314
|
+
// tight test loop) fire multiple appendFile operations at once with no
|
|
315
|
+
// guaranteed completion order, so lines could interleave or land out of
|
|
316
|
+
// order - a real, reproducible flake this project's own tests caught
|
|
317
|
+
// (roughly 1 run in 5). A single long-lived write stream serializes its
|
|
318
|
+
// writes internally even when called back-to-back without awaiting
|
|
319
|
+
// each one, which is what actually guarantees ordering. The only thing
|
|
320
|
+
// a persistent stream needs extra is handling day rollover - resolved
|
|
321
|
+
// by checking today's date on every write and swapping to a fresh
|
|
322
|
+
// stream the moment it changes, so a long-running process still rotates
|
|
323
|
+
// correctly without ever writing yesterday's line into today's file or
|
|
324
|
+
// vice versa.
|
|
325
|
+
let currentStream = null;
|
|
326
|
+
let currentStreamDate = null;
|
|
327
|
+
|
|
328
|
+
function ensureWriteStream() {
|
|
329
|
+
const today = dateStringFor(new Date());
|
|
330
|
+
if (currentStream && currentStreamDate === today) return currentStream;
|
|
331
|
+
if (currentStream) currentStream.end();
|
|
332
|
+
currentStreamDate = today;
|
|
333
|
+
currentStream = fs.createWriteStream(pathForDateString(today), { flags: 'a' });
|
|
334
|
+
currentStream.on('error', (err) => {
|
|
335
|
+
console.warn('⚠️ Metrics log write error:', err.message);
|
|
336
|
+
});
|
|
337
|
+
return currentStream;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Record one completed request, appended to TODAY's file. Fire-and-
|
|
342
|
+
* forget by design - a metrics write must never be the reason a real
|
|
343
|
+
* request fails or slows down.
|
|
344
|
+
*
|
|
345
|
+
* `scope` (seams work): an opaque isolation key, same contract as
|
|
346
|
+
* cache.js's - null/undefined (every call site in this codebase today)
|
|
347
|
+
* omits the field entirely, so an unscoped deployment's JSONL rows are
|
|
348
|
+
* byte-identical to before this parameter existed.
|
|
349
|
+
*/
|
|
350
|
+
function record(scope, entry) {
|
|
351
|
+
if (usingPostgres()) {
|
|
352
|
+
recordToPostgres(scope, entry); // fire-and-forget - see its own comment
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const line = JSON.stringify({
|
|
357
|
+
timestamp: new Date().toISOString(),
|
|
358
|
+
...(scope != null ? { scope } : {}),
|
|
359
|
+
...entry
|
|
360
|
+
}) + '\n';
|
|
361
|
+
ensureWriteStream().write(line);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.warn('⚠️ Failed to record metric:', err.message);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// scope === null/undefined means "every row", not "rows whose own
|
|
368
|
+
// scope field happens to be absent" - see readRecentFromPostgres's own
|
|
369
|
+
// comment for why that distinction matters (it's what keeps an
|
|
370
|
+
// unscoped deployment's history complete once ANY caller starts
|
|
371
|
+
// passing a real scope).
|
|
372
|
+
function matchesScope(row, scope) {
|
|
373
|
+
return scope == null || row.scope === scope;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Read up to `limit` most recent records, scanning files newest-first
|
|
378
|
+
* and stopping as soon as enough rows have been collected - bounded by
|
|
379
|
+
* how many DAYS of data are needed to satisfy `limit`, not by the
|
|
380
|
+
* service's entire lifetime.
|
|
381
|
+
*/
|
|
382
|
+
async function readRecent(scope, limit = 500) {
|
|
383
|
+
if (usingPostgres()) return readRecentFromPostgres(scope, limit);
|
|
384
|
+
const files = await listLogFiles();
|
|
385
|
+
const collected = [];
|
|
386
|
+
for (let i = files.length - 1; i >= 0 && collected.length < limit; i--) {
|
|
387
|
+
const rows = (await readFileRows(files[i].path)).filter((r) => matchesScope(r, scope));
|
|
388
|
+
collected.unshift(...rows);
|
|
389
|
+
if (collected.length > limit) collected.splice(0, collected.length - limit);
|
|
390
|
+
}
|
|
391
|
+
return collected.slice(-limit);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Rolling per-provider stats from the most recent `windowSize` requests
|
|
396
|
+
* to that provider: average latency and error rate. This is the signal
|
|
397
|
+
* router.js uses alongside the static cost table - a provider that is
|
|
398
|
+
* currently slow or failing shouldn't be picked just because its list
|
|
399
|
+
* price is lowest.
|
|
400
|
+
*/
|
|
401
|
+
async function providerStats(scope, windowSize = 50) {
|
|
402
|
+
const rows = await readRecent(scope, 2000);
|
|
403
|
+
const byProvider = {};
|
|
404
|
+
|
|
405
|
+
for (const row of rows) {
|
|
406
|
+
if (!row.provider) continue;
|
|
407
|
+
if (!byProvider[row.provider]) byProvider[row.provider] = [];
|
|
408
|
+
byProvider[row.provider].push(row);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const stats = {};
|
|
412
|
+
for (const [provider, entries] of Object.entries(byProvider)) {
|
|
413
|
+
const recent = entries.slice(-windowSize);
|
|
414
|
+
const errorEntries = recent.filter((e) => e.error);
|
|
415
|
+
const latencies = recent.filter((e) => !e.error && typeof e.latency_ms === 'number');
|
|
416
|
+
const avgLatencyMs = latencies.length
|
|
417
|
+
? latencies.reduce((sum, e) => sum + e.latency_ms, 0) / latencies.length
|
|
418
|
+
: null;
|
|
419
|
+
// The MOST RECENT error only, not a tally of every type seen in the
|
|
420
|
+
// window - an alert should reflect "what's wrong right now," not a
|
|
421
|
+
// mix that might include something already fixed earlier in the
|
|
422
|
+
// window. Falls back to classifying on the fly for an older record
|
|
423
|
+
// written before error_type existed (see server.js) instead of
|
|
424
|
+
// silently going blank.
|
|
425
|
+
const lastError = errorEntries.length ? errorEntries[errorEntries.length - 1] : null;
|
|
426
|
+
|
|
427
|
+
stats[provider] = {
|
|
428
|
+
sampleSize: recent.length,
|
|
429
|
+
errorRate: recent.length ? errorEntries.length / recent.length : 0,
|
|
430
|
+
avgLatencyMs,
|
|
431
|
+
lastErrorType: lastError ? lastError.error_type || classifyErrorType(lastError.error) : null,
|
|
432
|
+
lastErrorAt: lastError ? lastError.timestamp : null
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
return stats;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Everything the cost dashboard needs for a calendar window, computed
|
|
440
|
+
* in one pass so the KPI numbers and the daily chart data are
|
|
441
|
+
* guaranteed to agree - they're two views of the exact same filtered
|
|
442
|
+
* rows, never two separate queries that could drift apart.
|
|
443
|
+
*
|
|
444
|
+
* Only the files whose OWN date falls inside [cutoff, today] are read
|
|
445
|
+
* at all - a 14-day query never opens a file from three months ago.
|
|
446
|
+
* The per-row timestamp filter still runs afterward (a file's date is
|
|
447
|
+
* an inclusion bound, not a correctness guarantee - see
|
|
448
|
+
* listLogFiles()'s "only matches the naming pattern" note).
|
|
449
|
+
*
|
|
450
|
+
* A "miss" bucket is anything dispatched to a provider that WASN'T a
|
|
451
|
+
* cache hit, successful or not - the error count is tracked alongside
|
|
452
|
+
* it per day for the table view and tooltip, but deliberately isn't
|
|
453
|
+
* its own stacked-chart series (see the dashboard page: three clean
|
|
454
|
+
* outcome series read better than four, and error rate has its own,
|
|
455
|
+
* more precise, KPI tile and per-provider breakdown instead).
|
|
456
|
+
*
|
|
457
|
+
* by_provider here is intentionally a different shape than
|
|
458
|
+
* providerStats() above: that one is a ROLLING window for routing
|
|
459
|
+
* health (router.js), this one is a CALENDAR window for reporting
|
|
460
|
+
* (the dashboard) and also carries request count and cost. Same
|
|
461
|
+
* underlying log, two different questions - not accidentally
|
|
462
|
+
* duplicated logic.
|
|
463
|
+
*/
|
|
464
|
+
async function rangeSummary(scope, days = 14) {
|
|
465
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
466
|
+
|
|
467
|
+
// Row-fetching prelude only - everything from here down (the actual
|
|
468
|
+
// daily/provider/hit-rate aggregation) is identical regardless of
|
|
469
|
+
// which backend supplied `rows`, so it's written once, below, shared
|
|
470
|
+
// by both.
|
|
471
|
+
let rows;
|
|
472
|
+
if (usingPostgres()) {
|
|
473
|
+
rows = await rowsSinceFromPostgres(scope, cutoff); // already filtered server-side
|
|
474
|
+
} else {
|
|
475
|
+
const files = await listLogFiles();
|
|
476
|
+
const relevantFiles = files.filter((f) => {
|
|
477
|
+
// A file's own day spans [dayStart, dayStart + 24h) UTC; keep it if
|
|
478
|
+
// any part of that day could be on or after the cutoff.
|
|
479
|
+
const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
|
|
480
|
+
return dayStart + 24 * 60 * 60 * 1000 > cutoff;
|
|
481
|
+
});
|
|
482
|
+
rows = [];
|
|
483
|
+
for (const f of relevantFiles) {
|
|
484
|
+
rows = rows.concat((await readFileRows(f.path)).filter((r) => matchesScope(r, scope)));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const inRange = rows.filter((r) => r.timestamp && Date.parse(r.timestamp) >= cutoff);
|
|
488
|
+
|
|
489
|
+
const dailyByDate = new Map();
|
|
490
|
+
const byProvider = {};
|
|
491
|
+
let totalCostUsd = 0;
|
|
492
|
+
let exactHits = 0;
|
|
493
|
+
let semanticHits = 0;
|
|
494
|
+
let misses = 0;
|
|
495
|
+
let errors = 0;
|
|
496
|
+
|
|
497
|
+
for (const row of inRange) {
|
|
498
|
+
const date = row.timestamp.slice(0, 10); // YYYY-MM-DD (UTC, from toISOString())
|
|
499
|
+
if (!dailyByDate.has(date)) {
|
|
500
|
+
dailyByDate.set(date, { date, requests: 0, cost_usd: 0, exact_hits: 0, semantic_hits: 0, misses: 0, errors: 0 });
|
|
501
|
+
}
|
|
502
|
+
const bucket = dailyByDate.get(date);
|
|
503
|
+
bucket.requests += 1;
|
|
504
|
+
bucket.cost_usd += row.cost_usd || 0;
|
|
505
|
+
totalCostUsd += row.cost_usd || 0;
|
|
506
|
+
|
|
507
|
+
if (row.provider) {
|
|
508
|
+
if (!byProvider[row.provider]) {
|
|
509
|
+
byProvider[row.provider] = { requests: 0, cost_usd: 0, errorCount: 0, latencies: [] };
|
|
510
|
+
}
|
|
511
|
+
const p = byProvider[row.provider];
|
|
512
|
+
p.requests += 1;
|
|
513
|
+
p.cost_usd += row.cost_usd || 0;
|
|
514
|
+
if (row.error) p.errorCount += 1;
|
|
515
|
+
else if (typeof row.latency_ms === 'number') p.latencies.push(row.latency_ms);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (row.error) {
|
|
519
|
+
errors += 1;
|
|
520
|
+
bucket.errors += 1;
|
|
521
|
+
} else if (row.cache_hit && row.cache_type === 'semantic') {
|
|
522
|
+
semanticHits += 1;
|
|
523
|
+
bucket.semantic_hits += 1;
|
|
524
|
+
} else if (row.cache_hit) {
|
|
525
|
+
exactHits += 1;
|
|
526
|
+
bucket.exact_hits += 1;
|
|
527
|
+
} else {
|
|
528
|
+
misses += 1;
|
|
529
|
+
bucket.misses += 1;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const providerSummary = {};
|
|
534
|
+
for (const [name, p] of Object.entries(byProvider)) {
|
|
535
|
+
providerSummary[name] = {
|
|
536
|
+
requests: p.requests,
|
|
537
|
+
cost_usd: p.cost_usd,
|
|
538
|
+
errorRate: p.requests ? p.errorCount / p.requests : 0,
|
|
539
|
+
avgLatencyMs: p.latencies.length ? p.latencies.reduce((sum, v) => sum + v, 0) / p.latencies.length : null
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
days,
|
|
545
|
+
sample_size: inRange.length,
|
|
546
|
+
total_cost_usd: totalCostUsd,
|
|
547
|
+
cache_hit_rate: {
|
|
548
|
+
exact: inRange.length ? exactHits / inRange.length : 0,
|
|
549
|
+
semantic: inRange.length ? semanticHits / inRange.length : 0,
|
|
550
|
+
combined: inRange.length ? (exactHits + semanticHits) / inRange.length : 0
|
|
551
|
+
},
|
|
552
|
+
error_rate: inRange.length ? errors / inRange.length : 0,
|
|
553
|
+
by_provider: providerSummary,
|
|
554
|
+
daily: [...dailyByDate.values()].sort((a, b) => a.date.localeCompare(b.date))
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Explicit, opt-in cleanup: permanently deletes records older than
|
|
560
|
+
* `days`. NOT called automatically anywhere in this module - deleting
|
|
561
|
+
* cost/audit history is a retention-policy decision an operator makes
|
|
562
|
+
* on purpose (a cron job, a manual run), never something this module
|
|
563
|
+
* decides silently on their behalf. Returns the list of deleted file
|
|
564
|
+
* paths (file backend) or deleted row ids (Postgres backend) - the two
|
|
565
|
+
* backends' units of deletion genuinely differ, so the return value's
|
|
566
|
+
* shape does too; nothing in this codebase inspects the contents today,
|
|
567
|
+
* only that pruning happened and what it removed.
|
|
568
|
+
*/
|
|
569
|
+
async function pruneOlderThan(days) {
|
|
570
|
+
if (usingPostgres()) return pruneOlderThanPostgres(days);
|
|
571
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
|
572
|
+
const files = await listLogFiles();
|
|
573
|
+
const deleted = [];
|
|
574
|
+
for (const f of files) {
|
|
575
|
+
const dayStart = Date.parse(`${f.date}T00:00:00.000Z`);
|
|
576
|
+
if (dayStart + 24 * 60 * 60 * 1000 <= cutoff) {
|
|
577
|
+
await fs.promises.unlink(f.path);
|
|
578
|
+
deleted.push(f.path);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return deleted;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Test-only: closes the cached pool (if one was ever opened) so a test
|
|
585
|
+
// run doesn't hang on an open connection, and so the NEXT test that
|
|
586
|
+
// re-requires this module with a different DATABASE_URL gets a fresh
|
|
587
|
+
// pool/schema-ready state instead of reusing this one's.
|
|
588
|
+
async function closePostgresPoolForTests() {
|
|
589
|
+
if (pgPool) {
|
|
590
|
+
await pgPool.end();
|
|
591
|
+
pgPool = null;
|
|
592
|
+
schemaReady = null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
module.exports = {
|
|
597
|
+
record,
|
|
598
|
+
readRecent,
|
|
599
|
+
providerStats,
|
|
600
|
+
rangeSummary,
|
|
601
|
+
pruneOlderThan,
|
|
602
|
+
currentLogPath,
|
|
603
|
+
listLogFiles,
|
|
604
|
+
classifyErrorType,
|
|
605
|
+
usingPostgres,
|
|
606
|
+
matchesScope,
|
|
607
|
+
closePostgresPoolForTests,
|
|
608
|
+
DATA_DIR
|
|
609
|
+
};
|