@richardmcquiston01/error-log-adapter 0.5.0 → 0.6.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/README.md CHANGED
@@ -70,24 +70,25 @@ Constructor options are checked when the adapter is created and throw a descript
70
70
 
71
71
  Every adapter takes an optional `prefix` (default `[error-log-adapter]`) that is printed before its failure messages.
72
72
 
73
- | Adapter | Sink | Runs in | Main options |
74
- | ----------------------------------- | --------------------------------------------------------- | ------------- | ------------------------------------------------ |
75
- | `ConsoleErrorAdapter` | `console.error` | Anywhere | |
76
- | `WebhookErrorAdapter(url)` | HTTP `POST` of the entry as JSON | Anywhere | `headers`, `timeoutMs`, `allowInsecure` |
77
- | `MongoDbErrorAdapter(collection)` | Anything with `insertOne`, such as a MongoDB `Collection` | Node | `timeoutMs` |
78
- | `PostgresErrorAdapter(client)` | A Postgres table, through `pg` | Node | `tableName`, `createTable`, `timeoutMs` |
79
- | `MySqlErrorAdapter(client)` | A MySQL / MariaDB table, through `mysql2/promise` | Node | `tableName`, `createTable`, `timeoutMs` |
80
- | `SqliteErrorAdapter(client)` | A SQLite table, through any driver | Node, Bun | `tableName`, `createTable`, `timeoutMs` |
81
- | `SupabaseErrorAdapter(client)` | A Supabase table, through `supabase-js` | Anywhere | `tableName`, `timeoutMs` |
82
- | `RedisErrorAdapter(client)` | A Redis list or stream | Node | `mode`, `key`, `maxEntries`, `side`, `timeoutMs` |
83
- | `S3ErrorAdapter(client, bucket)` | S3, DigitalOcean Spaces, R2, MinIO objects | Node | `keyPrefix`, `batch`, `contentType`, `timeoutMs` |
84
- | `FirestoreErrorAdapter(collection)` | A Firestore collection | Node, browser | `timeoutMs` |
85
- | `RealtimeDatabaseErrorAdapter(ref)` | A Realtime Database reference | Node, browser | `timeoutMs` |
86
- | `FlatFileErrorAdapter(path)` | One text line per entry | Node, Bun | `formatLine`, `maxBytes`, `maxFiles` |
87
- | `JsonFileErrorAdapter(path)` | NDJSON lines or one JSON array | Node, Bun | `mode`, `maxEntries`, `indent` |
88
- | `IndexedDbErrorAdapter(database)` | An IndexedDB object store | Browser | `storeName`, `timeoutMs` |
89
- | `WebStorageErrorAdapter(storage)` | `sessionStorage` or `localStorage`, as one capped array | Browser | `key`, `maxEntries`, `locks` |
90
- | `CompositeErrorAdapter([...])` | Several adapters at once; succeeds only if all succeed | Anywhere | `timeoutMs` |
73
+ | Adapter | Sink | Runs in | Main options |
74
+ | ----------------------------------- | --------------------------------------------------------- | ------------- | ------------------------------------------------------------------- |
75
+ | `ConsoleErrorAdapter` | `console.error` | Anywhere | |
76
+ | `WebhookErrorAdapter(url)` | HTTP `POST` of the entry as JSON | Anywhere | `headers`, `timeoutMs`, `retries`, `signingSecret`, `allowInsecure` |
77
+ | `MongoDbErrorAdapter(collection)` | Anything with `insertOne`, such as a MongoDB `Collection` | Node | `timeoutMs` |
78
+ | `PostgresErrorAdapter(client)` | A Postgres table, through `pg` | Node | `tableName`, `createTable`, `timeoutMs` |
79
+ | `MySqlErrorAdapter(client)` | A MySQL / MariaDB table, through `mysql2/promise` | Node | `tableName`, `createTable`, `timeoutMs` |
80
+ | `SqliteErrorAdapter(client)` | A SQLite table, through any driver | Node, Bun | `tableName`, `createTable`, `timeoutMs` |
81
+ | `SupabaseErrorAdapter(client)` | A Supabase table, through `supabase-js` | Anywhere | `tableName`, `timeoutMs` |
82
+ | `RedisErrorAdapter(client)` | A Redis list or stream | Node | `mode`, `key`, `maxEntries`, `side`, `timeoutMs` |
83
+ | `S3ErrorAdapter(client, bucket)` | S3, DigitalOcean Spaces, R2, MinIO objects | Node | `keyPrefix`, `batch`, `contentType`, `timeoutMs` |
84
+ | `FirestoreErrorAdapter(collection)` | A Firestore collection | Node, browser | `timeoutMs` |
85
+ | `RealtimeDatabaseErrorAdapter(ref)` | A Realtime Database reference | Node, browser | `timeoutMs` |
86
+ | `FlatFileErrorAdapter(path)` | One text line per entry | Node, Bun | `formatLine`, `maxBytes`, `maxFiles` |
87
+ | `JsonFileErrorAdapter(path)` | NDJSON lines or one JSON array | Node, Bun | `mode`, `maxEntries`, `indent` |
88
+ | `IndexedDbErrorAdapter(database)` | An IndexedDB object store | Browser | `storeName`, `timeoutMs` |
89
+ | `WebStorageErrorAdapter(storage)` | `sessionStorage` or `localStorage`, as one capped array | Browser | `key`, `maxEntries`, `locks` |
90
+ | `EventEmitterErrorAdapter(emitter)` | An event on an emitter you own (in-process hooks) | Anywhere | `eventName`, `requireListener` |
91
+ | `CompositeErrorAdapter([...])` | Several adapters at once; succeeds only if all succeed | Anywhere | `timeoutMs` |
91
92
 
92
93
  Every stored record has the same shape: a UUID `id`, `code`, `message`, `timestamp` (ISO 8601), and optional `source` and `cause`. An `Error` cause is stored as `{ name, message, stack }`. `serializeEntry` and `serializeCause` are exported if you build your own sink.
93
94
 
@@ -99,7 +100,8 @@ What changes durability or size, by adapter:
99
100
  - **Files** (`maxBytes` / `maxFiles`, `maxEntries`): the flat file adapter rotates by size; the JSON file adapter's `array` mode keeps the newest `maxEntries`.
100
101
  - **Redis and Web Storage** (`maxEntries`): the oldest entries are dropped past the cap.
101
102
  - **Browser storage**: `sessionStorage` belongs to one tab. `localStorage` is shared by every tab and has no atomic update, so two tabs logging at the same instant can overwrite each other's entry. `WebStorageErrorAdapter` takes a `navigator.locks` lock around each write, which reduces the loss but cannot remove it, since tabs in separate processes can still read a stale copy. Use `IndexedDbErrorAdapter` when every entry matters. Create its object store from your `onupgradeneeded` handler with `createErrorLogStore`. A stored value the adapter cannot read is copied to `<key>.corrupt` before it is replaced.
102
- - **Webhook**: the URL must be `https://` (or `http://localhost`, `127.0.0.1`, `[::1]`) unless you pass `allowInsecure: true`, because entries carry messages and stack traces. Redirects are not followed, since a redirect would turn the `POST` into a body-less `GET` or re-send the entry and your headers to another host; a redirect is reported as `ok: false`. Failure messages contain only the origin, never the path or query, because webhook URLs often hold a secret.
103
+ - **Event emitter**: each entry is emitted as an `error-log` event whose payload is the stored record, for in-process hooks such as a toast, a metrics counter or a test assertion. Listeners run synchronously, so one that throws makes `log()` return `ok: false` with its message. Node's `EventEmitter` and mitt work as they are; a browser `EventTarget` needs a one-line wrapper (shown in the example below). Don't name the event `error` on a Node `EventEmitter`: it throws when nobody listens. Node's `emit` returns `false` when there are no listeners; pass `requireListener: true` to report that as a failure instead of letting the entry vanish.
104
+ - **Webhook**: the URL must be `https://` (or `http://localhost`, `127.0.0.1`, `[::1]`) unless you pass `allowInsecure: true`, because entries carry messages and stack traces. Redirects are not followed, since a redirect would turn the `POST` into a body-less `GET` or re-send the entry and your headers to another host; a redirect is reported as `ok: false`. Failure messages contain only the origin, never the path or query, because webhook URLs often hold a secret. Set `retries` to try again after a network error, a timeout, HTTP 408, 425, 429 or 5xx (exponential backoff with jitter from `retryDelayMs`, capped by `maxRetryDelayMs`, honoring `Retry-After`; other 4xx answers are never retried). With `retries` set, every attempt sends the same body including an `id`, so the receiver can drop a repeat. Set `signingSecret` to add `X-Signature-Timestamp` and `X-Signature-256` (`sha256=` + hex HMAC-SHA256 of `<timestamp>.<body>`); the receiver recomputes it and rejects old timestamps.
103
105
 
104
106
  ### When a sink is slow or down
105
107
 
@@ -209,6 +211,40 @@ const errorLog = new S3ErrorAdapter(s3, 'my-space', {
209
211
  process.on('beforeExit', () => void errorLog.flush());
210
212
  ```
211
213
 
214
+ Show a toast, count errors, and keep a file copy, all from one `log` call:
215
+
216
+ ```ts
217
+ import { EventEmitter } from 'node:events';
218
+ import {
219
+ CompositeErrorAdapter,
220
+ EventEmitterErrorAdapter,
221
+ FlatFileErrorAdapter,
222
+ } from '@richardmcquiston01/error-log-adapter';
223
+
224
+ const bus = new EventEmitter();
225
+ bus.on('error-log', (record) => showToast(record.message));
226
+ bus.on('error-log', (record) => metrics.increment(`errors.${record.code}`));
227
+
228
+ const errorLog = new CompositeErrorAdapter([
229
+ new EventEmitterErrorAdapter(bus),
230
+ new FlatFileErrorAdapter('logs/errors.log'),
231
+ ]);
232
+ ```
233
+
234
+ In a browser, wrap an `EventTarget`:
235
+
236
+ ```ts
237
+ const target = new EventTarget();
238
+ const emitter = {
239
+ emit: (name: string, payload: unknown) =>
240
+ target.dispatchEvent(new CustomEvent(name, { detail: payload })),
241
+ };
242
+ target.addEventListener('error-log', (event) =>
243
+ console.log((event as CustomEvent).detail)
244
+ );
245
+ const errorLog = new EventEmitterErrorAdapter(emitter);
246
+ ```
247
+
212
248
  Write your own adapter by implementing the interface. Catch failures and return them rather than throwing:
213
249
 
214
250
  ```ts
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CompositeErrorAdapter: () => CompositeErrorAdapter,
24
24
  ConsoleErrorAdapter: () => ConsoleErrorAdapter,
25
+ EventEmitterErrorAdapter: () => EventEmitterErrorAdapter,
25
26
  FirestoreErrorAdapter: () => FirestoreErrorAdapter,
26
27
  FlatFileErrorAdapter: () => FlatFileErrorAdapter,
27
28
  IndexedDbErrorAdapter: () => IndexedDbErrorAdapter,
@@ -278,6 +279,78 @@ function withTimeout(start, timeoutMs, message) {
278
279
  }
279
280
 
280
281
  // src/adapters/webhook-error-adapter.ts
282
+ var DEFAULT_RETRY_DELAY_MS = 500;
283
+ var DEFAULT_MAX_RETRY_DELAY_MS = 3e4;
284
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 425, 429]);
285
+ var SigningUnavailableError = class extends Error {
286
+ };
287
+ async function hmacSha256Hex(secret, message) {
288
+ const subtle = globalThis.crypto?.subtle;
289
+ if (subtle === void 0) {
290
+ throw new SigningUnavailableError(
291
+ "signingSecret needs Web Crypto (crypto.subtle), which this runtime does not provide, so the entry was not sent unsigned"
292
+ );
293
+ }
294
+ const encoder = new TextEncoder();
295
+ const key = await subtle.importKey(
296
+ "raw",
297
+ encoder.encode(secret),
298
+ { name: "HMAC", hash: "SHA-256" },
299
+ false,
300
+ ["sign"]
301
+ );
302
+ const signature = await subtle.sign(
303
+ "HMAC",
304
+ key,
305
+ encoder.encode(message)
306
+ );
307
+ return Array.from(
308
+ new Uint8Array(signature),
309
+ (byte) => byte.toString(16).padStart(2, "0")
310
+ ).join("");
311
+ }
312
+ function parseRetryAfterMs(value) {
313
+ if (value === null || value === void 0 || value.trim() === "") {
314
+ return void 0;
315
+ }
316
+ const seconds = Number(value);
317
+ if (Number.isFinite(seconds) && seconds >= 0) {
318
+ return seconds * 1e3;
319
+ }
320
+ const date = Date.parse(value);
321
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
322
+ }
323
+ function validateRetryOptions(options) {
324
+ const retries = options.retries ?? 0;
325
+ if (!Number.isInteger(retries) || retries < 0) {
326
+ throw new RangeError(
327
+ `WebhookErrorAdapter: retries must be a whole number of 0 or more, received ${String(options.retries)}.`
328
+ );
329
+ }
330
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
331
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs <= 0) {
332
+ throw new RangeError(
333
+ `WebhookErrorAdapter: retryDelayMs must be a positive number, received ${String(options.retryDelayMs)}.`
334
+ );
335
+ }
336
+ const maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
337
+ if (!Number.isFinite(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
338
+ throw new RangeError(
339
+ `WebhookErrorAdapter: maxRetryDelayMs must be a number no smaller than retryDelayMs (${retryDelayMs}), received ${String(options.maxRetryDelayMs)}.`
340
+ );
341
+ }
342
+ if (options.signingSecret === "") {
343
+ throw new RangeError(
344
+ "WebhookErrorAdapter: signingSecret must not be empty; leave it out to send unsigned."
345
+ );
346
+ }
347
+ return { retries, retryDelayMs, maxRetryDelayMs };
348
+ }
349
+ function sleep(ms) {
350
+ return new Promise((resolve) => {
351
+ setTimeout(resolve, ms);
352
+ });
353
+ }
281
354
  var LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
282
355
  "localhost",
283
356
  "127.0.0.1",
@@ -303,15 +376,57 @@ function parseWebhookUrl(url, allowInsecure) {
303
376
  );
304
377
  }
305
378
  var WebhookErrorAdapter = class {
306
- /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or `timeoutMs` is invalid. */
379
+ /** @throws {RangeError} if `url` is not a valid `https://` URL (see `allowInsecure`) or a timeout, retry or signing option is invalid. */
307
380
  constructor(url, options = {}) {
308
381
  this.url = url;
309
382
  this.options = options;
310
383
  this.prefix = options.prefix ?? DEFAULT_PREFIX;
311
384
  this.timeoutMs = resolveTimeoutMs(options.timeoutMs, "WebhookErrorAdapter");
385
+ ({
386
+ retries: this.retries,
387
+ retryDelayMs: this.retryDelayMs,
388
+ maxRetryDelayMs: this.maxRetryDelayMs
389
+ } = validateRetryOptions(options));
312
390
  this.target = parseWebhookUrl(url, options.allowInsecure ?? false).origin;
313
391
  }
314
392
  async log(entry) {
393
+ let body;
394
+ try {
395
+ body = JSON.stringify(
396
+ hasCallerId(entry) || this.retries > 0 ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
397
+ );
398
+ } catch (error) {
399
+ console.error(`${this.prefix} WebhookErrorAdapter:`, error);
400
+ return this.fail(
401
+ `could not prepare the entry: ${this.redact(describeError(error))}`
402
+ );
403
+ }
404
+ let attempt = await this.send(body);
405
+ let attempts = 1;
406
+ while (!attempt.ok && attempt.retryable && attempts <= this.retries) {
407
+ await sleep(this.retryWaitMs(attempts, attempt.retryAfterMs));
408
+ attempt = await this.send(body);
409
+ attempts += 1;
410
+ }
411
+ if (attempt.ok) {
412
+ return { ok: true };
413
+ }
414
+ return this.fail(
415
+ attempts > 1 ? `${attempt.reason} (after ${attempts} attempts)` : attempt.reason
416
+ );
417
+ }
418
+ /** Milliseconds to wait before retry number `retryNumber` (1 for the first). */
419
+ retryWaitMs(retryNumber, retryAfterMs) {
420
+ if (retryAfterMs !== void 0) {
421
+ return Math.min(retryAfterMs, this.maxRetryDelayMs);
422
+ }
423
+ const ceiling = Math.min(
424
+ this.maxRetryDelayMs,
425
+ this.retryDelayMs * 2 ** (retryNumber - 1)
426
+ );
427
+ return ceiling / 2 + Math.random() * ceiling / 2;
428
+ }
429
+ async send(body) {
315
430
  const controller = new AbortController();
316
431
  let timedOut = false;
317
432
  const timer = this.timeoutMs === Infinity ? void 0 : setTimeout(() => {
@@ -323,43 +438,70 @@ var WebhookErrorAdapter = class {
323
438
  method: "POST",
324
439
  headers: {
325
440
  "Content-Type": "application/json",
326
- ...this.options.headers
441
+ ...this.options.headers,
442
+ ...await this.signatureHeaders(body)
327
443
  },
328
- // A caller-supplied id goes along so the receiver can drop a repeat.
329
- body: JSON.stringify(
330
- hasCallerId(entry) ? { id: resolveEntryId(entry), ...serializeEntry(entry) } : serializeEntry(entry)
331
- ),
444
+ body,
332
445
  // A redirect would turn the POST into a GET without a body (302, 303)
333
446
  // or re-send the entry and headers to another host (307, 308).
334
447
  redirect: "manual",
335
448
  signal: controller.signal
336
449
  });
337
450
  if (response.type === "opaqueredirect") {
338
- return this.fail(this.redirectMessage("a redirect"));
451
+ return this.rejected(this.redirectMessage("a redirect"));
339
452
  }
340
453
  if (!response.ok) {
341
454
  if (response.status >= 300 && response.status < 400) {
342
- return this.fail(this.redirectMessage(`HTTP ${response.status}`));
455
+ return this.rejected(this.redirectMessage(`HTTP ${response.status}`));
343
456
  }
344
- return this.fail(
345
- `webhook ${this.target} responded with HTTP ${response.status}`
346
- );
457
+ return {
458
+ ok: false,
459
+ reason: `webhook ${this.target} responded with HTTP ${response.status}`,
460
+ retryable: response.status >= 500 || RETRYABLE_STATUSES.has(response.status),
461
+ retryAfterMs: parseRetryAfterMs(response.headers?.get("retry-after"))
462
+ };
347
463
  }
348
464
  return { ok: true };
349
465
  } catch (error) {
350
466
  console.error(`${this.prefix} WebhookErrorAdapter:`, error);
467
+ if (error instanceof SigningUnavailableError) {
468
+ return this.rejected(error.message);
469
+ }
351
470
  if (timedOut) {
352
- return this.fail(
353
- timeoutMessage(`webhook ${this.target}`, this.timeoutMs)
354
- );
471
+ return {
472
+ ok: false,
473
+ reason: timeoutMessage(`webhook ${this.target}`, this.timeoutMs),
474
+ retryable: true
475
+ };
355
476
  }
356
- return this.fail(
357
- `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`
358
- );
477
+ return {
478
+ ok: false,
479
+ reason: `could not reach webhook ${this.target}: ${this.redact(describeError(error))}`,
480
+ retryable: true
481
+ };
359
482
  } finally {
360
483
  clearTimeout(timer);
361
484
  }
362
485
  }
486
+ /** Timestamp and signature headers, or none when no `signingSecret` is set. */
487
+ async signatureHeaders(body) {
488
+ const secret = this.options.signingSecret;
489
+ if (secret === void 0) {
490
+ return {};
491
+ }
492
+ const timestamp = String(Math.floor(Date.now() / 1e3));
493
+ const signature = await hmacSha256Hex(
494
+ secret,
495
+ `${timestamp}.${body}`
496
+ );
497
+ return {
498
+ "X-Signature-Timestamp": timestamp,
499
+ "X-Signature-256": `sha256=${signature}`
500
+ };
501
+ }
502
+ rejected(reason) {
503
+ return { ok: false, reason, retryable: false };
504
+ }
363
505
  redirectMessage(what) {
364
506
  return `webhook ${this.target} responded with ${what}, which is not followed because the entry would not arrive intact; use the final URL instead`;
365
507
  }
@@ -1639,10 +1781,50 @@ var SupabaseErrorAdapter = class {
1639
1781
  });
1640
1782
  }
1641
1783
  };
1784
+
1785
+ // src/adapters/event-emitter-error-adapter.ts
1786
+ var DEFAULT_EVENT_NAME = "error-log";
1787
+ var EventEmitterErrorAdapter = class {
1788
+ /** @throws {RangeError} if `options.eventName` is empty. */
1789
+ constructor(emitter, options = {}) {
1790
+ this.emitter = emitter;
1791
+ this.eventName = options.eventName ?? DEFAULT_EVENT_NAME;
1792
+ if (this.eventName === "") {
1793
+ throw new RangeError(
1794
+ "EventEmitterErrorAdapter: eventName must not be empty."
1795
+ );
1796
+ }
1797
+ this.requireListener = options.requireListener ?? false;
1798
+ this.prefix = options.prefix ?? DEFAULT_PREFIX;
1799
+ }
1800
+ async log(entry) {
1801
+ try {
1802
+ const record = {
1803
+ id: resolveEntryId(entry),
1804
+ ...serializeEntry(entry)
1805
+ };
1806
+ const delivered = this.emitter.emit(this.eventName, record);
1807
+ if (this.requireListener && delivered === false) {
1808
+ throw new Error(
1809
+ `no listener is attached to "${this.eventName}", so nothing received the entry`
1810
+ );
1811
+ }
1812
+ return { ok: true };
1813
+ } catch (error) {
1814
+ return reportFailure({
1815
+ adapterName: "EventEmitterErrorAdapter",
1816
+ prefix: this.prefix,
1817
+ action: `to emit "${this.eventName}"`,
1818
+ error
1819
+ });
1820
+ }
1821
+ }
1822
+ };
1642
1823
  // Annotate the CommonJS export names for ESM import in node:
1643
1824
  0 && (module.exports = {
1644
1825
  CompositeErrorAdapter,
1645
1826
  ConsoleErrorAdapter,
1827
+ EventEmitterErrorAdapter,
1646
1828
  FirestoreErrorAdapter,
1647
1829
  FlatFileErrorAdapter,
1648
1830
  IndexedDbErrorAdapter,