@blamejs/blamejs-shop 0.0.61 → 0.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.errorLog
4
+ * @title Error log — operator-side record of HTTP 4xx/5xx events
5
+ *
6
+ * @intro
7
+ * Hot-path observability sink for the request lifecycle. The
8
+ * storefront / admin / API layers call `recordError` on every
9
+ * non-2xx response; this primitive captures the row, surfaces the
10
+ * top-404 dead-link aggregate, the upstream-5xx breakdown, the
11
+ * slow-render outliers, and the per-window p50 / p95 / p99
12
+ * response-time math the operator dashboard renders.
13
+ *
14
+ * **Drop-silent on bad input — by design.** `recordError` is wired
15
+ * directly into the request lifecycle. Throwing inside it would
16
+ * crash the response that triggered it, turning a recorded 404
17
+ * into an unrecorded 500. Every malformed input (bad status,
18
+ * unknown method, missing required field, oversized string)
19
+ * resolves to a silent drop. Read-side methods (`top404Paths`,
20
+ * `top5xxErrors`, `slowRenders`, `errorById`, `byStatus`,
21
+ * `metrics`) THROW on bad input — those are dashboard queries with
22
+ * an operator on the other end of the stack trace.
23
+ *
24
+ * Session identifiers are hashed via
25
+ * `b.crypto.namespaceHash("error-log-session", ...)` before the
26
+ * row reaches the database. The customer_id passes through plain
27
+ * because the customers table primary-keys on the same shape (an
28
+ * operator joining the two tables needs the unhashed value).
29
+ *
30
+ * v1 surface:
31
+ *
32
+ * errorLog.recordError({
33
+ * status, path, method,
34
+ * referrer?, user_agent_class,
35
+ * session_id?, customer_id?,
36
+ * error_id?, response_time_ms?,
37
+ * occurred_at?,
38
+ * })
39
+ * → { id, occurred_at } on success
40
+ * → { dropped: true, reason } on bad input (no throw)
41
+ *
42
+ * errorLog.top404Paths({ from, to, limit })
43
+ * → [{ path, count }] sorted by count DESC
44
+ *
45
+ * errorLog.top5xxErrors({ from, to, limit })
46
+ * → [{ path, status, count }] sorted by count DESC
47
+ *
48
+ * errorLog.slowRenders({ from, to, limit, p99_threshold_ms? })
49
+ * → [{ id, path, status, response_time_ms, occurred_at }]
50
+ * sorted by response_time_ms DESC
51
+ *
52
+ * errorLog.errorById(error_id)
53
+ * → row | null
54
+ *
55
+ * errorLog.byStatus({ status, from, to, limit, cursor? })
56
+ * → { rows, next_cursor } keyset-paginated on (occurred_at, id)
57
+ *
58
+ * errorLog.metrics({ from, to })
59
+ * → { total, by_status_class, p50_ms, p95_ms, p99_ms }
60
+ *
61
+ * Percentile math: response_time_ms is collected, sorted, and a
62
+ * simple index-into-sorted-array is read off (no streaming
63
+ * estimator — operators on a single-shard D1 dataset hit modest
64
+ * row counts and want exact answers). The `metrics` method
65
+ * defaults to a 24-hour window; the operator passes a wider range
66
+ * for daily/weekly rollups.
67
+ */
68
+
69
+ var bShop;
70
+ function _b() {
71
+ if (!bShop) bShop = require("./index");
72
+ return bShop.framework;
73
+ }
74
+
75
+ var ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
76
+ var DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h — the dashboard's default
77
+
78
+ var SESSION_NAMESPACE = "error-log-session";
79
+
80
+ var METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
81
+ var UA_CLASSES = ["desktop", "mobile", "bot", "other"];
82
+
83
+ // String length bounds for the persisted columns. A path that
84
+ // breaches the bound is almost always a misrouted query string or a
85
+ // caller-side bug — the drop-silent gate refuses the row rather
86
+ // than truncate (truncated paths aggregate to garbage buckets).
87
+ var MAX_PATH = 2048;
88
+ var MAX_REFERRER = 2048;
89
+ var MAX_SESSION_ID = 512;
90
+ var MAX_CUSTOMER_ID = 512;
91
+ var MAX_ERROR_ID = 128;
92
+
93
+ // ---- read-side validators (THROW — dashboard queries) ------------------
94
+
95
+ function _epochMs(n, label) {
96
+ if (!Number.isInteger(n) || n < 0) {
97
+ throw new TypeError("errorLog: " + label + " must be a non-negative integer (epoch ms)");
98
+ }
99
+ }
100
+
101
+ function _resolveWindow(opts) {
102
+ opts = opts || {};
103
+ var now = Date.now();
104
+ var from = opts.from == null ? (now - DEFAULT_WINDOW_MS) : opts.from;
105
+ var to = opts.to == null ? now : opts.to;
106
+ _epochMs(from, "from");
107
+ _epochMs(to, "to");
108
+ if (from >= to) {
109
+ throw new TypeError("errorLog: from must be strictly less than to");
110
+ }
111
+ if ((to - from) > ONE_YEAR_MS) {
112
+ throw new TypeError("errorLog: window (to - from) must be ≤ 1 year");
113
+ }
114
+ return { from: from, to: to };
115
+ }
116
+
117
+ function _limit(n, label, max) {
118
+ max = max || 100;
119
+ if (!Number.isInteger(n) || n < 1 || n > max) {
120
+ throw new TypeError("errorLog: " + label + " must be an integer in [1, " + max + "]");
121
+ }
122
+ }
123
+
124
+ // ---- write-side drop-silent helpers ------------------------------------
125
+
126
+ function _isInt(n) { return typeof n === "number" && Number.isInteger(n); }
127
+ function _isPositive(n) { return _isInt(n) && n > 0; }
128
+ function _isNonNegative(n) { return _isInt(n) && n >= 0; }
129
+
130
+ function _isBoundedString(v, max) {
131
+ return typeof v === "string" && v.length > 0 && v.length <= max;
132
+ }
133
+
134
+ function _isOptionalBoundedString(v, max) {
135
+ if (v == null) return true;
136
+ return _isBoundedString(v, max);
137
+ }
138
+
139
+ // ---- factory ------------------------------------------------------------
140
+
141
+ function create(opts) {
142
+ opts = opts || {};
143
+ var query = opts.query;
144
+ if (!query) {
145
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
146
+ }
147
+
148
+ return {
149
+ // Record a single error-log row. **Drop-silent on bad input** —
150
+ // this surface is wired into the request lifecycle and a thrown
151
+ // exception would crash the response that triggered it.
152
+ //
153
+ // Returns `{ id, occurred_at }` on success and
154
+ // `{ dropped: true, reason }` on every refusal so a caller that
155
+ // wants to log the drop (debug builds, smoke tests) can see why.
156
+ // Production callers ignore the return value; the drop is silent
157
+ // from the operator dashboard's perspective.
158
+ recordError: async function (input) {
159
+ try {
160
+ if (!input || typeof input !== "object") {
161
+ return { dropped: true, reason: "input must be an object" };
162
+ }
163
+
164
+ // status — required, integer in HTTP-error range.
165
+ var status = input.status;
166
+ if (!_isInt(status) || status < 400 || status > 599) {
167
+ return { dropped: true, reason: "status must be an integer in [400, 599]" };
168
+ }
169
+
170
+ // path — required, bounded string. Caller strips the query
171
+ // string before passing (the GROUP BY relies on this).
172
+ var path = input.path;
173
+ if (!_isBoundedString(path, MAX_PATH)) {
174
+ return { dropped: true, reason: "path must be a non-empty string ≤ " + MAX_PATH + " chars" };
175
+ }
176
+
177
+ // method — required, enum.
178
+ var method = input.method;
179
+ if (typeof method !== "string" || METHODS.indexOf(method) === -1) {
180
+ return { dropped: true, reason: "method must be one of " + METHODS.join(", ") };
181
+ }
182
+
183
+ // user_agent_class — required, enum. A request without one
184
+ // is a caller-side bug (the request lifecycle classifies
185
+ // every UA before recordError is invoked).
186
+ var uaClass = input.user_agent_class;
187
+ if (typeof uaClass !== "string" || UA_CLASSES.indexOf(uaClass) === -1) {
188
+ return { dropped: true, reason: "user_agent_class must be one of " + UA_CLASSES.join(", ") };
189
+ }
190
+
191
+ // referrer — optional, bounded string.
192
+ if (!_isOptionalBoundedString(input.referrer, MAX_REFERRER)) {
193
+ return { dropped: true, reason: "referrer must be a bounded non-empty string when provided" };
194
+ }
195
+ var referrer = input.referrer == null ? null : input.referrer;
196
+
197
+ // session_id — optional. Hashed before persist.
198
+ if (!_isOptionalBoundedString(input.session_id, MAX_SESSION_ID)) {
199
+ return { dropped: true, reason: "session_id must be a bounded non-empty string when provided" };
200
+ }
201
+
202
+ // customer_id — optional, plain.
203
+ if (!_isOptionalBoundedString(input.customer_id, MAX_CUSTOMER_ID)) {
204
+ return { dropped: true, reason: "customer_id must be a bounded non-empty string when provided" };
205
+ }
206
+ var customerId = input.customer_id == null ? null : input.customer_id;
207
+
208
+ // error_id — optional correlation id surfaced in the
209
+ // rendered error page.
210
+ if (!_isOptionalBoundedString(input.error_id, MAX_ERROR_ID)) {
211
+ return { dropped: true, reason: "error_id must be a bounded non-empty string when provided" };
212
+ }
213
+ var errorId = input.error_id == null ? null : input.error_id;
214
+
215
+ // response_time_ms — optional, non-negative integer.
216
+ var responseTimeMs;
217
+ if (input.response_time_ms == null) {
218
+ responseTimeMs = null;
219
+ } else if (_isNonNegative(input.response_time_ms)) {
220
+ responseTimeMs = input.response_time_ms;
221
+ } else {
222
+ return { dropped: true, reason: "response_time_ms must be a non-negative integer when provided" };
223
+ }
224
+
225
+ // occurred_at — optional, defaults to Date.now().
226
+ var occurredAt;
227
+ if (input.occurred_at == null) {
228
+ occurredAt = Date.now();
229
+ } else if (_isNonNegative(input.occurred_at)) {
230
+ occurredAt = input.occurred_at;
231
+ } else {
232
+ return { dropped: true, reason: "occurred_at must be a non-negative integer when provided" };
233
+ }
234
+
235
+ var b = _b();
236
+ var sessionHash = input.session_id == null
237
+ ? null
238
+ : b.crypto.namespaceHash(SESSION_NAMESPACE, input.session_id);
239
+
240
+ var id = b.uuid.v7();
241
+ await query(
242
+ "INSERT INTO error_log " +
243
+ "(id, status, path, method, referrer, user_agent_class, " +
244
+ " session_id_hash, customer_id, error_id, response_time_ms, occurred_at) " +
245
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
246
+ [id, status, path, method, referrer, uaClass,
247
+ sessionHash, customerId, errorId, responseTimeMs, occurredAt],
248
+ );
249
+ return { id: id, occurred_at: occurredAt };
250
+ } catch (_e) {
251
+ // Drop-silent on any unexpected failure — a database hiccup
252
+ // mid-request must not crash the response. The aggregate
253
+ // dashboards already render "no data" gracefully so an
254
+ // observed gap is the operator's signal to check the DB.
255
+ return { dropped: true, reason: "internal" };
256
+ }
257
+ },
258
+
259
+ // Top-N 404 paths within the window. GROUP BY path, ORDER BY
260
+ // count DESC. The aggregate strips query strings at the write
261
+ // site so a single dead link surfaces as one row regardless of
262
+ // tracking parameters.
263
+ top404Paths: async function (windowOpts) {
264
+ var w = _resolveWindow(windowOpts);
265
+ var limit = (windowOpts && windowOpts.limit) == null ? 10 : windowOpts.limit;
266
+ _limit(limit, "limit");
267
+ var r = await query(
268
+ "SELECT path AS path, COUNT(*) AS count " +
269
+ " FROM error_log " +
270
+ " WHERE status = 404 " +
271
+ " AND occurred_at >= ?1 AND occurred_at < ?2 " +
272
+ " GROUP BY path " +
273
+ " ORDER BY count DESC, path ASC " +
274
+ " LIMIT ?3",
275
+ [w.from, w.to, limit],
276
+ );
277
+ return r.rows.map(function (row) {
278
+ return { path: row.path, count: Number(row.count) || 0 };
279
+ });
280
+ },
281
+
282
+ // Top-N 5xx (path, status) tuples within the window. Operators
283
+ // use this to triage upstream failures — a sudden spike on a
284
+ // single (path, 502) tuple usually points at a misbehaving
285
+ // dependency.
286
+ top5xxErrors: async function (windowOpts) {
287
+ var w = _resolveWindow(windowOpts);
288
+ var limit = (windowOpts && windowOpts.limit) == null ? 10 : windowOpts.limit;
289
+ _limit(limit, "limit");
290
+ var r = await query(
291
+ "SELECT path AS path, status AS status, COUNT(*) AS count " +
292
+ " FROM error_log " +
293
+ " WHERE status >= 500 AND status <= 599 " +
294
+ " AND occurred_at >= ?1 AND occurred_at < ?2 " +
295
+ " GROUP BY path, status " +
296
+ " ORDER BY count DESC, path ASC, status ASC " +
297
+ " LIMIT ?3",
298
+ [w.from, w.to, limit],
299
+ );
300
+ return r.rows.map(function (row) {
301
+ return {
302
+ path: row.path,
303
+ status: Number(row.status) || 0,
304
+ count: Number(row.count) || 0,
305
+ };
306
+ });
307
+ },
308
+
309
+ // Slow-render outliers — rows whose response_time_ms exceeded
310
+ // the operator threshold within the window. Default threshold
311
+ // is 2000ms (a server-rendered page taking >2s is loud enough
312
+ // to surface on the dashboard without burying everything else).
313
+ slowRenders: async function (windowOpts) {
314
+ var w = _resolveWindow(windowOpts);
315
+ var limit = (windowOpts && windowOpts.limit) == null ? 20 : windowOpts.limit;
316
+ _limit(limit, "limit");
317
+ var threshold = (windowOpts && windowOpts.p99_threshold_ms) == null ? 2000 : windowOpts.p99_threshold_ms;
318
+ if (!_isNonNegative(threshold)) {
319
+ throw new TypeError("errorLog: p99_threshold_ms must be a non-negative integer");
320
+ }
321
+ var r = await query(
322
+ "SELECT id, path, status, response_time_ms, occurred_at " +
323
+ " FROM error_log " +
324
+ " WHERE response_time_ms IS NOT NULL " +
325
+ " AND response_time_ms >= ?1 " +
326
+ " AND occurred_at >= ?2 AND occurred_at < ?3 " +
327
+ " ORDER BY response_time_ms DESC, id ASC " +
328
+ " LIMIT ?4",
329
+ [threshold, w.from, w.to, limit],
330
+ );
331
+ return r.rows.map(function (row) {
332
+ return {
333
+ id: row.id,
334
+ path: row.path,
335
+ status: Number(row.status) || 0,
336
+ response_time_ms: Number(row.response_time_ms) || 0,
337
+ occurred_at: Number(row.occurred_at) || 0,
338
+ };
339
+ });
340
+ },
341
+
342
+ // Correlation-id lookup — operator pastes the error id from the
343
+ // rendered page and gets the single row back. Returns `null`
344
+ // when no row matches (the customer mistyped, or the row was
345
+ // already swept by retention).
346
+ errorById: async function (errorId) {
347
+ if (typeof errorId !== "string" || errorId.length === 0) {
348
+ throw new TypeError("errorLog.errorById: error_id required");
349
+ }
350
+ if (errorId.length > MAX_ERROR_ID) {
351
+ throw new TypeError("errorLog.errorById: error_id exceeds " + MAX_ERROR_ID + " chars");
352
+ }
353
+ var r = await query(
354
+ "SELECT id, status, path, method, referrer, user_agent_class, " +
355
+ " session_id_hash, customer_id, error_id, response_time_ms, occurred_at " +
356
+ " FROM error_log " +
357
+ " WHERE error_id = ?1 " +
358
+ " ORDER BY occurred_at DESC, id ASC " +
359
+ " LIMIT 1",
360
+ [errorId],
361
+ );
362
+ if (r.rows.length === 0) return null;
363
+ var row = r.rows[0];
364
+ return {
365
+ id: row.id,
366
+ status: Number(row.status) || 0,
367
+ path: row.path,
368
+ method: row.method,
369
+ referrer: row.referrer,
370
+ user_agent_class: row.user_agent_class,
371
+ session_id_hash: row.session_id_hash,
372
+ customer_id: row.customer_id,
373
+ error_id: row.error_id,
374
+ response_time_ms: row.response_time_ms == null ? null : Number(row.response_time_ms),
375
+ occurred_at: Number(row.occurred_at) || 0,
376
+ };
377
+ },
378
+
379
+ // Keyset-paginated scan over a single status code. The cursor
380
+ // is the (occurred_at, id) pair from the last row of the
381
+ // previous page — operators step through a long tail without
382
+ // OFFSET-induced scan cost.
383
+ byStatus: async function (opts) {
384
+ opts = opts || {};
385
+ if (!_isInt(opts.status) || opts.status < 400 || opts.status > 599) {
386
+ throw new TypeError("errorLog.byStatus: status must be an integer in [400, 599]");
387
+ }
388
+ var w = _resolveWindow(opts);
389
+ var limit = opts.limit == null ? 50 : opts.limit;
390
+ _limit(limit, "limit", 500);
391
+
392
+ var cursorAt = null;
393
+ var cursorId = null;
394
+ if (opts.cursor != null) {
395
+ if (typeof opts.cursor !== "string" || opts.cursor.indexOf(":") === -1) {
396
+ throw new TypeError("errorLog.byStatus: cursor must be a string of the form '<occurred_at>:<id>'");
397
+ }
398
+ var idx = opts.cursor.indexOf(":");
399
+ var at = parseInt(opts.cursor.slice(0, idx), 10);
400
+ var id = opts.cursor.slice(idx + 1);
401
+ if (!Number.isInteger(at) || at < 0 || id.length === 0) {
402
+ throw new TypeError("errorLog.byStatus: cursor parses to garbage");
403
+ }
404
+ cursorAt = at;
405
+ cursorId = id;
406
+ }
407
+
408
+ var sql;
409
+ var params;
410
+ if (cursorAt == null) {
411
+ sql =
412
+ "SELECT id, status, path, method, referrer, user_agent_class, " +
413
+ " session_id_hash, customer_id, error_id, response_time_ms, occurred_at " +
414
+ " FROM error_log " +
415
+ " WHERE status = ?1 " +
416
+ " AND occurred_at >= ?2 AND occurred_at < ?3 " +
417
+ " ORDER BY occurred_at DESC, id DESC " +
418
+ " LIMIT ?4";
419
+ params = [opts.status, w.from, w.to, limit + 1];
420
+ } else {
421
+ sql =
422
+ "SELECT id, status, path, method, referrer, user_agent_class, " +
423
+ " session_id_hash, customer_id, error_id, response_time_ms, occurred_at " +
424
+ " FROM error_log " +
425
+ " WHERE status = ?1 " +
426
+ " AND occurred_at >= ?2 AND occurred_at < ?3 " +
427
+ " AND (occurred_at < ?4 OR (occurred_at = ?4 AND id < ?5)) " +
428
+ " ORDER BY occurred_at DESC, id DESC " +
429
+ " LIMIT ?6";
430
+ params = [opts.status, w.from, w.to, cursorAt, cursorId, limit + 1];
431
+ }
432
+ var r = await query(sql, params);
433
+
434
+ var rows = r.rows.slice(0, limit).map(function (row) {
435
+ return {
436
+ id: row.id,
437
+ status: Number(row.status) || 0,
438
+ path: row.path,
439
+ method: row.method,
440
+ referrer: row.referrer,
441
+ user_agent_class: row.user_agent_class,
442
+ session_id_hash: row.session_id_hash,
443
+ customer_id: row.customer_id,
444
+ error_id: row.error_id,
445
+ response_time_ms: row.response_time_ms == null ? null : Number(row.response_time_ms),
446
+ occurred_at: Number(row.occurred_at) || 0,
447
+ };
448
+ });
449
+ var nextCursor = null;
450
+ if (r.rows.length > limit) {
451
+ var last = rows[rows.length - 1];
452
+ nextCursor = last.occurred_at + ":" + last.id;
453
+ }
454
+ return { rows: rows, next_cursor: nextCursor };
455
+ },
456
+
457
+ // Aggregate counts + response-time percentiles for the window.
458
+ // Percentile math is exact: pull every non-null response_time_ms
459
+ // into memory, sort, index. Operators on a single-shard D1
460
+ // dataset hit modest row counts and prefer exact answers over
461
+ // streaming-estimator approximations.
462
+ metrics: async function (windowOpts) {
463
+ var w = _resolveWindow(windowOpts);
464
+
465
+ var counts = await query(
466
+ "SELECT status, COUNT(*) AS count " +
467
+ " FROM error_log " +
468
+ " WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
469
+ " GROUP BY status",
470
+ [w.from, w.to],
471
+ );
472
+ var total = 0;
473
+ var byClass = { "4xx": 0, "5xx": 0 };
474
+ for (var i = 0; i < counts.rows.length; i += 1) {
475
+ var row = counts.rows[i];
476
+ var n = Number(row.count) || 0;
477
+ total += n;
478
+ var s = Number(row.status) || 0;
479
+ if (s >= 400 && s <= 499) byClass["4xx"] += n;
480
+ else if (s >= 500 && s <= 599) byClass["5xx"] += n;
481
+ }
482
+
483
+ var times = await query(
484
+ "SELECT response_time_ms " +
485
+ " FROM error_log " +
486
+ " WHERE response_time_ms IS NOT NULL " +
487
+ " AND occurred_at >= ?1 AND occurred_at < ?2 " +
488
+ " ORDER BY response_time_ms ASC",
489
+ [w.from, w.to],
490
+ );
491
+ var sorted = [];
492
+ for (var j = 0; j < times.rows.length; j += 1) {
493
+ var v = Number(times.rows[j].response_time_ms);
494
+ if (Number.isFinite(v)) sorted.push(v);
495
+ }
496
+
497
+ function _pct(p) {
498
+ if (sorted.length === 0) return 0;
499
+ // Index = ceil(p * N) - 1, clamped to [0, N-1]. Matches the
500
+ // "nearest-rank" definition operators expect on a discrete
501
+ // sample (a p99 over 100 rows points at the 99th, not at
502
+ // the interpolated 99.0th).
503
+ var idx = Math.ceil(p * sorted.length) - 1;
504
+ if (idx < 0) idx = 0;
505
+ if (idx >= sorted.length) idx = sorted.length - 1;
506
+ return sorted[idx];
507
+ }
508
+
509
+ return {
510
+ total: total,
511
+ by_status_class: byClass,
512
+ p50_ms: _pct(0.50),
513
+ p95_ms: _pct(0.95),
514
+ p99_ms: _pct(0.99),
515
+ };
516
+ },
517
+ };
518
+ }
519
+
520
+ module.exports = {
521
+ create: create,
522
+ ONE_YEAR_MS: ONE_YEAR_MS,
523
+ DEFAULT_WINDOW_MS: DEFAULT_WINDOW_MS,
524
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
525
+ };