@richardmcquiston01/error-log-adapter 0.8.0 → 0.10.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,27 +70,28 @@ 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`, `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
- | `WebSocketErrorAdapter(socket)` | One JSON message over a socket you own | Anywhere | (none) |
92
- | `CloudWatchLogsErrorAdapter(client, group, stream)` | AWS CloudWatch Logs events | Node | `batch`, `createMissing`, `timeoutMs` |
93
- | `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`, `expireAfterMs` |
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
+ | `WebSocketErrorAdapter(socket)` | One JSON message over a socket you own | Anywhere | (none) |
92
+ | `CloudWatchLogsErrorAdapter(client, group, stream)` | AWS CloudWatch Logs events | Node | `batch`, `createMissing`, `timeoutMs` |
93
+ | `BatchingErrorAdapter(adapter)` | A queue in front of any adapter: batches, retries with backoff | Anywhere | `batchSize`, `maxWaitMs`, `retries`, `maxBuffered`, `onDrop` |
94
+ | `CompositeErrorAdapter([...])` | Several adapters at once; succeeds only if all succeed | Anywhere | `timeoutMs` |
94
95
 
95
96
  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.
96
97
 
@@ -102,9 +103,11 @@ What changes durability or size, by adapter:
102
103
  - **Files** (`maxBytes` / `maxFiles`, `maxEntries`): the flat file adapter rotates by size; the JSON file adapter's `array` mode keeps the newest `maxEntries`.
103
104
  - **Redis and Web Storage** (`maxEntries`): the oldest entries are dropped past the cap.
104
105
  - **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.
106
+ - **Firestore expiry**: pass `expireAfterMs` (for example `30 * 24 * 60 * 60 * 1000`) and every document gets a Firestore timestamp field, `expire_at` (change the name with `expireField`), set to the entry's `timestamp` plus that time. This only writes the field: Firestore deletes nothing until you enable a TTL policy on it, in the console or with `gcloud firestore fields ttls update expire_at --collection-group=error_log --enable-ttl`. Documents are then removed some time after they expire (typically within a day), and the deletes are billed like any other. The Realtime Database has no TTL feature, so the option is Firestore only.
105
107
  - **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.
106
108
  - **WebSocket**: each entry is sent as one JSON text message (the serialized entry plus its UUID `id`) over a socket you own; the adapter never connects, reconnects or buffers. If the socket's `readyState` is not open (connecting, closing or closed), `log()` returns `ok: false` and says which. `ok: true` means the socket accepted the message, not that the server got it, since a WebSocket has no acknowledgement. Browsers, Node's global `WebSocket`, Deno, Bun and `ws` all fit. Pair it with a durable adapter in a `CompositeErrorAdapter` if entries must survive the socket being down.
107
109
  - **CloudWatch Logs**: each entry is sent through `PutLogEvents` as a JSON event (the serialized entry plus its UUID `id`) stamped with the entry's time, so Logs Insights can query its fields. Pass an AWS SDK v3 `CloudWatchLogs` client. AWS no longer needs sequence tokens, so none are tracked. If the stream (or group) does not exist, the adapter creates it and tries again once (`createMissing`, on by default; it needs `logs:CreateLogStream` and `logs:CreateLogGroup`, and a group created this way never expires its logs, so set a retention policy). CloudWatch can answer ok and still refuse events older than 14 days or more than 2 hours ahead; that is reported as `ok: false`. An event over 256 KiB is refused before sending. With `batch: { batchSize, maxWaitMs }`, events are buffered and sent together (up to 10,000 events and 1 MiB per request, oldest first), `log()` returns `ok` as soon as the event is buffered, and you should call `flush()` before the process exits. In Lambda and ECS, stdout already reaches CloudWatch; this is for apps running elsewhere.
110
+ - **Batching wrapper**: `BatchingErrorAdapter` puts a queue in front of any other adapter. `log()` returns `ok: true` as soon as the entry is queued, so a slow or failing sink never holds up the app, and entries are delivered in order in batches (`batchSize`, default 20, or after `maxWaitMs`, default 5000). A failed delivery is retried with exponential backoff (`retries`, default 3) and then given up on: the entries go to `onDrop` and the console, and `flush()` returns `ok: false` once. Delivery stops at the first failure so a dead sink is not hammered. If the wrapped adapter has a `logBatch` method (the webhook does) the batch goes in one call, which for the webhook means one request carrying a JSON array, so the receiver must accept an array; pass `useBatchMethod: false` to keep one request per entry. `ok: true` means _queued_, not stored, and the queue lives in memory: call `flush()` before the process exits, and put a durable adapter beside it in a `CompositeErrorAdapter` if entries must survive a crash. Do not also enable the wrapped adapter's own retries (the webhook's `retries`); they multiply. The buffer holds `maxBuffered` entries (default 1000) and drops the oldest past that.
108
111
  - **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.
109
112
 
110
113
  ### When a sink is slow or down
@@ -275,6 +278,23 @@ const errorLog = new CloudWatchLogsErrorAdapter(
275
278
  process.on('beforeExit', () => void errorLog.flush());
276
279
  ```
277
280
 
281
+ Batch and retry in front of a webhook, so a slow endpoint never holds up the app:
282
+
283
+ ```ts
284
+ const errorLog = new BatchingErrorAdapter(
285
+ new WebhookErrorAdapter('https://example.com/errors', {
286
+ signingSecret: process.env.ERROR_WEBHOOK_SECRET,
287
+ }),
288
+ {
289
+ batchSize: 50,
290
+ maxWaitMs: 2000,
291
+ onDrop: (entries, reason) =>
292
+ console.warn(`lost ${entries.length}: ${reason}`),
293
+ }
294
+ );
295
+ process.on('beforeExit', () => void errorLog.flush());
296
+ ```
297
+
278
298
  Write your own adapter by implementing the interface. Catch failures and return them rather than throwing:
279
299
 
280
300
  ```ts
@@ -303,6 +323,8 @@ class SlackErrorAdapter implements ErrorLogAdapter {
303
323
  }
304
324
  ```
305
325
 
326
+ The full guide, [docs/custom-adapters.md](./docs/custom-adapters.md), covers the contract, timeouts, entry ids, buffering adapters, testing, and a checklist.
327
+
306
328
  See [ROADMAP.md](./ROADMAP.md) for planned adapters and [CHANGELOG.md](./CHANGELOG.md) for what changed in each release.
307
329
 
308
330
  ### Development
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ BatchingErrorAdapter: () => BatchingErrorAdapter,
23
24
  CloudWatchLogsErrorAdapter: () => CloudWatchLogsErrorAdapter,
24
25
  CompositeErrorAdapter: () => CompositeErrorAdapter,
25
26
  ConsoleErrorAdapter: () => ConsoleErrorAdapter,
@@ -192,6 +193,12 @@ var ConsoleErrorAdapter = class {
192
193
  }
193
194
  };
194
195
 
196
+ // src/backoff.ts
197
+ function backoffDelayMs(retryNumber, baseMs, maxMs) {
198
+ const ceiling = Math.min(maxMs, baseMs * 2 ** (retryNumber - 1));
199
+ return ceiling / 2 + Math.random() * ceiling / 2;
200
+ }
201
+
195
202
  // src/uuid.ts
196
203
  function generateId() {
197
204
  const cryptoApi = globalThis.crypto;
@@ -403,6 +410,36 @@ var WebhookErrorAdapter = class {
403
410
  `could not prepare the entry: ${this.redact(describeError(error))}`
404
411
  );
405
412
  }
413
+ return this.deliver(body);
414
+ }
415
+ /**
416
+ * Sends several entries in one request, as a JSON array of the same records
417
+ * `log` sends one at a time (each with an `id`). `BatchingErrorAdapter` uses
418
+ * this when it wraps a webhook, so the receiver must accept an array. The
419
+ * whole request succeeds or fails together.
420
+ */
421
+ async logBatch(entries) {
422
+ if (entries.length === 0) {
423
+ return { ok: true };
424
+ }
425
+ let body;
426
+ try {
427
+ body = JSON.stringify(
428
+ entries.map((entry) => ({
429
+ id: resolveEntryId(entry),
430
+ ...serializeEntry(entry)
431
+ }))
432
+ );
433
+ } catch (error) {
434
+ console.error(`${this.prefix} WebhookErrorAdapter:`, error);
435
+ return this.fail(
436
+ `could not prepare the entries: ${this.redact(describeError(error))}`
437
+ );
438
+ }
439
+ return this.deliver(body);
440
+ }
441
+ /** Sends one prepared body, trying again as `retries` allows. */
442
+ async deliver(body) {
406
443
  let attempt = await this.send(body);
407
444
  let attempts = 1;
408
445
  while (!attempt.ok && attempt.retryable && attempts <= this.retries) {
@@ -422,11 +459,7 @@ var WebhookErrorAdapter = class {
422
459
  if (retryAfterMs !== void 0) {
423
460
  return Math.min(retryAfterMs, this.maxRetryDelayMs);
424
461
  }
425
- const ceiling = Math.min(
426
- this.maxRetryDelayMs,
427
- this.retryDelayMs * 2 ** (retryNumber - 1)
428
- );
429
- return ceiling / 2 + Math.random() * ceiling / 2;
462
+ return backoffDelayMs(retryNumber, this.retryDelayMs, this.maxRetryDelayMs);
430
463
  }
431
464
  async send(body) {
432
465
  const controller = new AbortController();
@@ -1594,6 +1627,15 @@ var S3ErrorAdapter = class {
1594
1627
  };
1595
1628
 
1596
1629
  // src/adapters/firebase-error-adapter.ts
1630
+ var DEFAULT_EXPIRE_FIELD = "expire_at";
1631
+ var RESERVED_FIELDS = /* @__PURE__ */ new Set([
1632
+ "id",
1633
+ "code",
1634
+ "message",
1635
+ "timestamp",
1636
+ "source",
1637
+ "cause"
1638
+ ]);
1597
1639
  function waitForWrite(start, timeoutMs) {
1598
1640
  return withTimeout(
1599
1641
  start,
@@ -1618,7 +1660,7 @@ function buildRecord(entry) {
1618
1660
  return record;
1619
1661
  }
1620
1662
  var FirestoreErrorAdapter = class {
1621
- /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1663
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`, or the expiry options are invalid. */
1622
1664
  constructor(collection, options = {}) {
1623
1665
  this.collection = collection;
1624
1666
  this.prefix = options.prefix ?? DEFAULT_PREFIX;
@@ -1626,10 +1668,31 @@ var FirestoreErrorAdapter = class {
1626
1668
  options.timeoutMs,
1627
1669
  "FirestoreErrorAdapter"
1628
1670
  );
1671
+ this.expireAfterMs = options.expireAfterMs;
1672
+ this.expireField = options.expireField ?? DEFAULT_EXPIRE_FIELD;
1673
+ if (this.expireAfterMs !== void 0 && (!Number.isFinite(this.expireAfterMs) || this.expireAfterMs <= 0)) {
1674
+ throw new RangeError(
1675
+ `FirestoreErrorAdapter: expireAfterMs must be a positive number of milliseconds, received ${String(options.expireAfterMs)}.`
1676
+ );
1677
+ }
1678
+ if (this.expireField === "" || /[./]/.test(this.expireField) || RESERVED_FIELDS.has(this.expireField)) {
1679
+ throw new RangeError(
1680
+ `FirestoreErrorAdapter: expireField must be a name other than ${[...RESERVED_FIELDS].join(", ")}, without "." or "/", received ${JSON.stringify(this.expireField)}.`
1681
+ );
1682
+ }
1629
1683
  }
1630
1684
  async log(entry) {
1631
1685
  try {
1632
- const record = buildRecord(entry);
1686
+ const record = { ...buildRecord(entry) };
1687
+ if (this.expireAfterMs !== void 0) {
1688
+ const occurredAt = Date.parse(record.timestamp);
1689
+ if (Number.isNaN(occurredAt)) {
1690
+ throw new RangeError(
1691
+ `timestamp "${record.timestamp}" is not a valid ISO 8601 date, so the expiry time cannot be worked out`
1692
+ );
1693
+ }
1694
+ record[this.expireField] = new Date(occurredAt + this.expireAfterMs);
1695
+ }
1633
1696
  await waitForWrite(
1634
1697
  () => this.collection.doc(record.id).set(record),
1635
1698
  this.timeoutMs
@@ -2146,8 +2209,259 @@ function describeRejection(output, eventCount) {
2146
2209
  }
2147
2210
  return reasons.length === 0 ? void 0 : `CloudWatch Logs accepted the request but rejected events: ${reasons.join("; ")}`;
2148
2211
  }
2212
+
2213
+ // src/adapters/batching-error-adapter.ts
2214
+ var DEFAULT_BATCH_SIZE = 20;
2215
+ var DEFAULT_MAX_WAIT_MS = 5e3;
2216
+ var DEFAULT_RETRIES = 3;
2217
+ var DEFAULT_RETRY_DELAY_MS2 = 500;
2218
+ var DEFAULT_MAX_RETRY_DELAY_MS2 = 3e4;
2219
+ var DEFAULT_MAX_BUFFERED = 1e3;
2220
+ function hasLogBatch(adapter) {
2221
+ return typeof adapter.logBatch === "function";
2222
+ }
2223
+ var BatchingErrorAdapter = class {
2224
+ /** @throws {RangeError} if an option is out of range. */
2225
+ constructor(inner, options = {}) {
2226
+ this.inner = inner;
2227
+ this.queue = [];
2228
+ this.waitTimer = null;
2229
+ this.backoffTimer = null;
2230
+ this.draining = null;
2231
+ this.flushers = 0;
2232
+ this.droppedSinceFlush = 0;
2233
+ this.lastDropReason = "";
2234
+ this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2235
+ this.maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
2236
+ this.retries = options.retries ?? DEFAULT_RETRIES;
2237
+ this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS2;
2238
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS2;
2239
+ this.maxBuffered = options.maxBuffered ?? DEFAULT_MAX_BUFFERED;
2240
+ this.useBatchMethod = options.useBatchMethod ?? true;
2241
+ this.onDrop = options.onDrop;
2242
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
2243
+ if (!Number.isInteger(this.batchSize) || this.batchSize < 1) {
2244
+ throw new RangeError(
2245
+ `BatchingErrorAdapter: batchSize must be a positive integer, received ${String(options.batchSize)}.`
2246
+ );
2247
+ }
2248
+ if (Number.isNaN(this.maxWaitMs) || this.maxWaitMs <= 0) {
2249
+ throw new RangeError(
2250
+ `BatchingErrorAdapter: maxWaitMs must be a positive number or Infinity, received ${String(options.maxWaitMs)}.`
2251
+ );
2252
+ }
2253
+ if (!Number.isInteger(this.retries) || this.retries < 0) {
2254
+ throw new RangeError(
2255
+ `BatchingErrorAdapter: retries must be a whole number of 0 or more, received ${String(options.retries)}.`
2256
+ );
2257
+ }
2258
+ if (!Number.isFinite(this.retryDelayMs) || this.retryDelayMs <= 0) {
2259
+ throw new RangeError(
2260
+ `BatchingErrorAdapter: retryDelayMs must be a positive number, received ${String(options.retryDelayMs)}.`
2261
+ );
2262
+ }
2263
+ if (!Number.isFinite(this.maxRetryDelayMs) || this.maxRetryDelayMs < this.retryDelayMs) {
2264
+ throw new RangeError(
2265
+ `BatchingErrorAdapter: maxRetryDelayMs must be a number no smaller than retryDelayMs (${this.retryDelayMs}), received ${String(options.maxRetryDelayMs)}.`
2266
+ );
2267
+ }
2268
+ if (!Number.isInteger(this.maxBuffered) || this.maxBuffered < this.batchSize) {
2269
+ throw new RangeError(
2270
+ `BatchingErrorAdapter: maxBuffered must be a whole number no smaller than batchSize (${this.batchSize}), received ${String(options.maxBuffered)}.`
2271
+ );
2272
+ }
2273
+ }
2274
+ /** Queues the entry. Resolves ok straight away; delivery happens in the background. */
2275
+ log(entry) {
2276
+ this.queue.push({ entry, failures: 0 });
2277
+ if (this.queue.length > this.maxBuffered) {
2278
+ const overflow = this.queue.splice(
2279
+ 0,
2280
+ this.queue.length - this.maxBuffered
2281
+ );
2282
+ this.drop(
2283
+ overflow,
2284
+ `the buffer of ${this.maxBuffered} entries was full because the wrapped adapter keeps failing`
2285
+ );
2286
+ }
2287
+ if (this.queue.length >= this.batchSize) {
2288
+ this.startDraining();
2289
+ } else {
2290
+ this.scheduleDrain();
2291
+ }
2292
+ return Promise.resolve({ ok: true });
2293
+ }
2294
+ /**
2295
+ * Delivers everything queued, retrying as configured, and resolves once the
2296
+ * queue is empty. Resolves `ok: false` if any entries were given up on since
2297
+ * the last flush. With retries on and the sink down this can take a while
2298
+ * (the waits add up to well over a minute with the defaults).
2299
+ */
2300
+ async flush() {
2301
+ this.flushers += 1;
2302
+ this.backoffTimer?.ref?.();
2303
+ try {
2304
+ this.startDraining();
2305
+ while (this.draining !== null) {
2306
+ await this.draining;
2307
+ }
2308
+ } finally {
2309
+ this.flushers -= 1;
2310
+ }
2311
+ if (this.droppedSinceFlush > 0) {
2312
+ const count = this.droppedSinceFlush;
2313
+ const reason = this.lastDropReason;
2314
+ this.droppedSinceFlush = 0;
2315
+ this.lastDropReason = "";
2316
+ return {
2317
+ ok: false,
2318
+ error: `BatchingErrorAdapter gave up on ${count} entr${count === 1 ? "y" : "ies"}: ${reason}`
2319
+ };
2320
+ }
2321
+ return { ok: true };
2322
+ }
2323
+ scheduleDrain() {
2324
+ if (this.waitTimer !== null || this.maxWaitMs === Infinity) {
2325
+ return;
2326
+ }
2327
+ const timer = setTimeout(() => {
2328
+ this.waitTimer = null;
2329
+ this.startDraining();
2330
+ }, this.maxWaitMs);
2331
+ timer.unref?.();
2332
+ this.waitTimer = timer;
2333
+ }
2334
+ startDraining() {
2335
+ if (this.waitTimer !== null) {
2336
+ clearTimeout(this.waitTimer);
2337
+ this.waitTimer = null;
2338
+ }
2339
+ if (this.draining !== null || this.queue.length === 0) {
2340
+ return;
2341
+ }
2342
+ this.draining = this.drain().catch((error) => {
2343
+ console.error(`${this.logPrefix} BatchingErrorAdapter:`, error);
2344
+ }).finally(() => {
2345
+ this.draining = null;
2346
+ if (this.queue.length > 0) {
2347
+ this.scheduleDrain();
2348
+ }
2349
+ });
2350
+ }
2351
+ /** Delivers batches until the queue is empty, waiting out a backoff after each failure. */
2352
+ async drain() {
2353
+ let failedRounds = 0;
2354
+ while (this.queue.length > 0) {
2355
+ const batch = this.queue.slice(0, this.batchSize);
2356
+ const failure = await this.deliver(batch);
2357
+ if (failure === void 0) {
2358
+ failedRounds = 0;
2359
+ continue;
2360
+ }
2361
+ failedRounds += 1;
2362
+ const exhausted = this.queue.filter(
2363
+ (item) => item.failures > this.retries
2364
+ );
2365
+ if (exhausted.length > 0) {
2366
+ this.queue = this.queue.filter(
2367
+ (item) => item.failures <= this.retries
2368
+ );
2369
+ this.drop(
2370
+ exhausted,
2371
+ `delivery failed ${this.retries + 1} time${this.retries === 0 ? "" : "s"}; last error: ${failure}`
2372
+ );
2373
+ }
2374
+ if (this.queue.length > 0) {
2375
+ await this.backoff(failedRounds);
2376
+ }
2377
+ }
2378
+ }
2379
+ /**
2380
+ * Tries to deliver `batch` (the front of the queue). Delivered entries leave
2381
+ * the queue; an entry that failed has its failure count raised and stays.
2382
+ * Resolves to the failure message, or undefined when everything went through.
2383
+ */
2384
+ async deliver(batch) {
2385
+ const remove = (delivered) => {
2386
+ const gone = new Set(delivered);
2387
+ this.queue = this.queue.filter(
2388
+ (item) => !gone.has(item)
2389
+ );
2390
+ };
2391
+ if (this.useBatchMethod && hasLogBatch(this.inner)) {
2392
+ const result = await this.safely(
2393
+ () => this.inner.logBatch(
2394
+ batch.map(
2395
+ (item) => item.entry
2396
+ )
2397
+ )
2398
+ );
2399
+ if (result.ok) {
2400
+ remove(batch);
2401
+ return void 0;
2402
+ }
2403
+ for (const item of batch) {
2404
+ item.failures += 1;
2405
+ }
2406
+ return result.error ?? "the wrapped adapter reported a failure";
2407
+ }
2408
+ for (const item of batch) {
2409
+ const result = await this.safely(
2410
+ () => this.inner.log(item.entry)
2411
+ );
2412
+ if (result.ok) {
2413
+ remove([item]);
2414
+ continue;
2415
+ }
2416
+ item.failures += 1;
2417
+ return result.error ?? "the wrapped adapter reported a failure";
2418
+ }
2419
+ return void 0;
2420
+ }
2421
+ /** The wrapped adapter should not throw, but a bug in one must not stop the queue. */
2422
+ async safely(call) {
2423
+ try {
2424
+ return await call();
2425
+ } catch (error) {
2426
+ return { ok: false, error: describeError(error) };
2427
+ }
2428
+ }
2429
+ backoff(failedRounds) {
2430
+ return new Promise((resolve) => {
2431
+ const timer = setTimeout(
2432
+ () => {
2433
+ this.backoffTimer = null;
2434
+ resolve();
2435
+ },
2436
+ backoffDelayMs(failedRounds, this.retryDelayMs, this.maxRetryDelayMs)
2437
+ );
2438
+ if (this.flushers === 0) {
2439
+ timer.unref?.();
2440
+ }
2441
+ this.backoffTimer = timer;
2442
+ });
2443
+ }
2444
+ drop(items, reason) {
2445
+ if (items.length === 0) {
2446
+ return;
2447
+ }
2448
+ this.droppedSinceFlush += items.length;
2449
+ this.lastDropReason = reason;
2450
+ console.error(
2451
+ `${this.logPrefix} BatchingErrorAdapter: gave up on ${items.length} entr${items.length === 1 ? "y" : "ies"}: ${reason}`
2452
+ );
2453
+ try {
2454
+ this.onDrop?.(
2455
+ items.map((item) => item.entry),
2456
+ reason
2457
+ );
2458
+ } catch {
2459
+ }
2460
+ }
2461
+ };
2149
2462
  // Annotate the CommonJS export names for ESM import in node:
2150
2463
  0 && (module.exports = {
2464
+ BatchingErrorAdapter,
2151
2465
  CloudWatchLogsErrorAdapter,
2152
2466
  CompositeErrorAdapter,
2153
2467
  ConsoleErrorAdapter,