@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 +43 -21
- package/dist/index.cjs +321 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -4
- package/dist/index.d.ts +160 -4
- package/dist/index.mjs +320 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
74
|
-
| --------------------------------------------------- |
|
|
75
|
-
| `ConsoleErrorAdapter` | `console.error`
|
|
76
|
-
| `WebhookErrorAdapter(url)` | HTTP `POST` of the entry as JSON
|
|
77
|
-
| `MongoDbErrorAdapter(collection)` | Anything with `insertOne`, such as a MongoDB `Collection`
|
|
78
|
-
| `PostgresErrorAdapter(client)` | A Postgres table, through `pg`
|
|
79
|
-
| `MySqlErrorAdapter(client)` | A MySQL / MariaDB table, through `mysql2/promise`
|
|
80
|
-
| `SqliteErrorAdapter(client)` | A SQLite table, through any driver
|
|
81
|
-
| `SupabaseErrorAdapter(client)` | A Supabase table, through `supabase-js`
|
|
82
|
-
| `RedisErrorAdapter(client)` | A Redis list or stream
|
|
83
|
-
| `S3ErrorAdapter(client, bucket)` | S3, DigitalOcean Spaces, R2, MinIO objects
|
|
84
|
-
| `FirestoreErrorAdapter(collection)` | A Firestore collection
|
|
85
|
-
| `RealtimeDatabaseErrorAdapter(ref)` | A Realtime Database reference
|
|
86
|
-
| `FlatFileErrorAdapter(path)` | One text line per entry
|
|
87
|
-
| `JsonFileErrorAdapter(path)` | NDJSON lines or one JSON array
|
|
88
|
-
| `IndexedDbErrorAdapter(database)` | An IndexedDB object store
|
|
89
|
-
| `WebStorageErrorAdapter(storage)` | `sessionStorage` or `localStorage`, as one capped array
|
|
90
|
-
| `EventEmitterErrorAdapter(emitter)` | An event on an emitter you own (in-process hooks)
|
|
91
|
-
| `WebSocketErrorAdapter(socket)` | One JSON message over a socket you own
|
|
92
|
-
| `CloudWatchLogsErrorAdapter(client, group, stream)` | AWS CloudWatch Logs events
|
|
93
|
-
| `
|
|
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
|
-
|
|
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,
|