@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/dist/index.mjs CHANGED
@@ -140,6 +140,12 @@ var ConsoleErrorAdapter = class {
140
140
  }
141
141
  };
142
142
 
143
+ // src/backoff.ts
144
+ function backoffDelayMs(retryNumber, baseMs, maxMs) {
145
+ const ceiling = Math.min(maxMs, baseMs * 2 ** (retryNumber - 1));
146
+ return ceiling / 2 + Math.random() * ceiling / 2;
147
+ }
148
+
143
149
  // src/uuid.ts
144
150
  function generateId() {
145
151
  const cryptoApi = globalThis.crypto;
@@ -351,6 +357,36 @@ var WebhookErrorAdapter = class {
351
357
  `could not prepare the entry: ${this.redact(describeError(error))}`
352
358
  );
353
359
  }
360
+ return this.deliver(body);
361
+ }
362
+ /**
363
+ * Sends several entries in one request, as a JSON array of the same records
364
+ * `log` sends one at a time (each with an `id`). `BatchingErrorAdapter` uses
365
+ * this when it wraps a webhook, so the receiver must accept an array. The
366
+ * whole request succeeds or fails together.
367
+ */
368
+ async logBatch(entries) {
369
+ if (entries.length === 0) {
370
+ return { ok: true };
371
+ }
372
+ let body;
373
+ try {
374
+ body = JSON.stringify(
375
+ entries.map((entry) => ({
376
+ id: resolveEntryId(entry),
377
+ ...serializeEntry(entry)
378
+ }))
379
+ );
380
+ } catch (error) {
381
+ console.error(`${this.prefix} WebhookErrorAdapter:`, error);
382
+ return this.fail(
383
+ `could not prepare the entries: ${this.redact(describeError(error))}`
384
+ );
385
+ }
386
+ return this.deliver(body);
387
+ }
388
+ /** Sends one prepared body, trying again as `retries` allows. */
389
+ async deliver(body) {
354
390
  let attempt = await this.send(body);
355
391
  let attempts = 1;
356
392
  while (!attempt.ok && attempt.retryable && attempts <= this.retries) {
@@ -370,11 +406,7 @@ var WebhookErrorAdapter = class {
370
406
  if (retryAfterMs !== void 0) {
371
407
  return Math.min(retryAfterMs, this.maxRetryDelayMs);
372
408
  }
373
- const ceiling = Math.min(
374
- this.maxRetryDelayMs,
375
- this.retryDelayMs * 2 ** (retryNumber - 1)
376
- );
377
- return ceiling / 2 + Math.random() * ceiling / 2;
409
+ return backoffDelayMs(retryNumber, this.retryDelayMs, this.maxRetryDelayMs);
378
410
  }
379
411
  async send(body) {
380
412
  const controller = new AbortController();
@@ -1542,6 +1574,15 @@ var S3ErrorAdapter = class {
1542
1574
  };
1543
1575
 
1544
1576
  // src/adapters/firebase-error-adapter.ts
1577
+ var DEFAULT_EXPIRE_FIELD = "expire_at";
1578
+ var RESERVED_FIELDS = /* @__PURE__ */ new Set([
1579
+ "id",
1580
+ "code",
1581
+ "message",
1582
+ "timestamp",
1583
+ "source",
1584
+ "cause"
1585
+ ]);
1545
1586
  function waitForWrite(start, timeoutMs) {
1546
1587
  return withTimeout(
1547
1588
  start,
@@ -1566,7 +1607,7 @@ function buildRecord(entry) {
1566
1607
  return record;
1567
1608
  }
1568
1609
  var FirestoreErrorAdapter = class {
1569
- /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`. */
1610
+ /** @throws {RangeError} if `options.timeoutMs` is not a positive number or `Infinity`, or the expiry options are invalid. */
1570
1611
  constructor(collection, options = {}) {
1571
1612
  this.collection = collection;
1572
1613
  this.prefix = options.prefix ?? DEFAULT_PREFIX;
@@ -1574,10 +1615,31 @@ var FirestoreErrorAdapter = class {
1574
1615
  options.timeoutMs,
1575
1616
  "FirestoreErrorAdapter"
1576
1617
  );
1618
+ this.expireAfterMs = options.expireAfterMs;
1619
+ this.expireField = options.expireField ?? DEFAULT_EXPIRE_FIELD;
1620
+ if (this.expireAfterMs !== void 0 && (!Number.isFinite(this.expireAfterMs) || this.expireAfterMs <= 0)) {
1621
+ throw new RangeError(
1622
+ `FirestoreErrorAdapter: expireAfterMs must be a positive number of milliseconds, received ${String(options.expireAfterMs)}.`
1623
+ );
1624
+ }
1625
+ if (this.expireField === "" || /[./]/.test(this.expireField) || RESERVED_FIELDS.has(this.expireField)) {
1626
+ throw new RangeError(
1627
+ `FirestoreErrorAdapter: expireField must be a name other than ${[...RESERVED_FIELDS].join(", ")}, without "." or "/", received ${JSON.stringify(this.expireField)}.`
1628
+ );
1629
+ }
1577
1630
  }
1578
1631
  async log(entry) {
1579
1632
  try {
1580
- const record = buildRecord(entry);
1633
+ const record = { ...buildRecord(entry) };
1634
+ if (this.expireAfterMs !== void 0) {
1635
+ const occurredAt = Date.parse(record.timestamp);
1636
+ if (Number.isNaN(occurredAt)) {
1637
+ throw new RangeError(
1638
+ `timestamp "${record.timestamp}" is not a valid ISO 8601 date, so the expiry time cannot be worked out`
1639
+ );
1640
+ }
1641
+ record[this.expireField] = new Date(occurredAt + this.expireAfterMs);
1642
+ }
1581
1643
  await waitForWrite(
1582
1644
  () => this.collection.doc(record.id).set(record),
1583
1645
  this.timeoutMs
@@ -2094,7 +2156,258 @@ function describeRejection(output, eventCount) {
2094
2156
  }
2095
2157
  return reasons.length === 0 ? void 0 : `CloudWatch Logs accepted the request but rejected events: ${reasons.join("; ")}`;
2096
2158
  }
2159
+
2160
+ // src/adapters/batching-error-adapter.ts
2161
+ var DEFAULT_BATCH_SIZE = 20;
2162
+ var DEFAULT_MAX_WAIT_MS = 5e3;
2163
+ var DEFAULT_RETRIES = 3;
2164
+ var DEFAULT_RETRY_DELAY_MS2 = 500;
2165
+ var DEFAULT_MAX_RETRY_DELAY_MS2 = 3e4;
2166
+ var DEFAULT_MAX_BUFFERED = 1e3;
2167
+ function hasLogBatch(adapter) {
2168
+ return typeof adapter.logBatch === "function";
2169
+ }
2170
+ var BatchingErrorAdapter = class {
2171
+ /** @throws {RangeError} if an option is out of range. */
2172
+ constructor(inner, options = {}) {
2173
+ this.inner = inner;
2174
+ this.queue = [];
2175
+ this.waitTimer = null;
2176
+ this.backoffTimer = null;
2177
+ this.draining = null;
2178
+ this.flushers = 0;
2179
+ this.droppedSinceFlush = 0;
2180
+ this.lastDropReason = "";
2181
+ this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
2182
+ this.maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
2183
+ this.retries = options.retries ?? DEFAULT_RETRIES;
2184
+ this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS2;
2185
+ this.maxRetryDelayMs = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS2;
2186
+ this.maxBuffered = options.maxBuffered ?? DEFAULT_MAX_BUFFERED;
2187
+ this.useBatchMethod = options.useBatchMethod ?? true;
2188
+ this.onDrop = options.onDrop;
2189
+ this.logPrefix = options.prefix ?? DEFAULT_PREFIX;
2190
+ if (!Number.isInteger(this.batchSize) || this.batchSize < 1) {
2191
+ throw new RangeError(
2192
+ `BatchingErrorAdapter: batchSize must be a positive integer, received ${String(options.batchSize)}.`
2193
+ );
2194
+ }
2195
+ if (Number.isNaN(this.maxWaitMs) || this.maxWaitMs <= 0) {
2196
+ throw new RangeError(
2197
+ `BatchingErrorAdapter: maxWaitMs must be a positive number or Infinity, received ${String(options.maxWaitMs)}.`
2198
+ );
2199
+ }
2200
+ if (!Number.isInteger(this.retries) || this.retries < 0) {
2201
+ throw new RangeError(
2202
+ `BatchingErrorAdapter: retries must be a whole number of 0 or more, received ${String(options.retries)}.`
2203
+ );
2204
+ }
2205
+ if (!Number.isFinite(this.retryDelayMs) || this.retryDelayMs <= 0) {
2206
+ throw new RangeError(
2207
+ `BatchingErrorAdapter: retryDelayMs must be a positive number, received ${String(options.retryDelayMs)}.`
2208
+ );
2209
+ }
2210
+ if (!Number.isFinite(this.maxRetryDelayMs) || this.maxRetryDelayMs < this.retryDelayMs) {
2211
+ throw new RangeError(
2212
+ `BatchingErrorAdapter: maxRetryDelayMs must be a number no smaller than retryDelayMs (${this.retryDelayMs}), received ${String(options.maxRetryDelayMs)}.`
2213
+ );
2214
+ }
2215
+ if (!Number.isInteger(this.maxBuffered) || this.maxBuffered < this.batchSize) {
2216
+ throw new RangeError(
2217
+ `BatchingErrorAdapter: maxBuffered must be a whole number no smaller than batchSize (${this.batchSize}), received ${String(options.maxBuffered)}.`
2218
+ );
2219
+ }
2220
+ }
2221
+ /** Queues the entry. Resolves ok straight away; delivery happens in the background. */
2222
+ log(entry) {
2223
+ this.queue.push({ entry, failures: 0 });
2224
+ if (this.queue.length > this.maxBuffered) {
2225
+ const overflow = this.queue.splice(
2226
+ 0,
2227
+ this.queue.length - this.maxBuffered
2228
+ );
2229
+ this.drop(
2230
+ overflow,
2231
+ `the buffer of ${this.maxBuffered} entries was full because the wrapped adapter keeps failing`
2232
+ );
2233
+ }
2234
+ if (this.queue.length >= this.batchSize) {
2235
+ this.startDraining();
2236
+ } else {
2237
+ this.scheduleDrain();
2238
+ }
2239
+ return Promise.resolve({ ok: true });
2240
+ }
2241
+ /**
2242
+ * Delivers everything queued, retrying as configured, and resolves once the
2243
+ * queue is empty. Resolves `ok: false` if any entries were given up on since
2244
+ * the last flush. With retries on and the sink down this can take a while
2245
+ * (the waits add up to well over a minute with the defaults).
2246
+ */
2247
+ async flush() {
2248
+ this.flushers += 1;
2249
+ this.backoffTimer?.ref?.();
2250
+ try {
2251
+ this.startDraining();
2252
+ while (this.draining !== null) {
2253
+ await this.draining;
2254
+ }
2255
+ } finally {
2256
+ this.flushers -= 1;
2257
+ }
2258
+ if (this.droppedSinceFlush > 0) {
2259
+ const count = this.droppedSinceFlush;
2260
+ const reason = this.lastDropReason;
2261
+ this.droppedSinceFlush = 0;
2262
+ this.lastDropReason = "";
2263
+ return {
2264
+ ok: false,
2265
+ error: `BatchingErrorAdapter gave up on ${count} entr${count === 1 ? "y" : "ies"}: ${reason}`
2266
+ };
2267
+ }
2268
+ return { ok: true };
2269
+ }
2270
+ scheduleDrain() {
2271
+ if (this.waitTimer !== null || this.maxWaitMs === Infinity) {
2272
+ return;
2273
+ }
2274
+ const timer = setTimeout(() => {
2275
+ this.waitTimer = null;
2276
+ this.startDraining();
2277
+ }, this.maxWaitMs);
2278
+ timer.unref?.();
2279
+ this.waitTimer = timer;
2280
+ }
2281
+ startDraining() {
2282
+ if (this.waitTimer !== null) {
2283
+ clearTimeout(this.waitTimer);
2284
+ this.waitTimer = null;
2285
+ }
2286
+ if (this.draining !== null || this.queue.length === 0) {
2287
+ return;
2288
+ }
2289
+ this.draining = this.drain().catch((error) => {
2290
+ console.error(`${this.logPrefix} BatchingErrorAdapter:`, error);
2291
+ }).finally(() => {
2292
+ this.draining = null;
2293
+ if (this.queue.length > 0) {
2294
+ this.scheduleDrain();
2295
+ }
2296
+ });
2297
+ }
2298
+ /** Delivers batches until the queue is empty, waiting out a backoff after each failure. */
2299
+ async drain() {
2300
+ let failedRounds = 0;
2301
+ while (this.queue.length > 0) {
2302
+ const batch = this.queue.slice(0, this.batchSize);
2303
+ const failure = await this.deliver(batch);
2304
+ if (failure === void 0) {
2305
+ failedRounds = 0;
2306
+ continue;
2307
+ }
2308
+ failedRounds += 1;
2309
+ const exhausted = this.queue.filter(
2310
+ (item) => item.failures > this.retries
2311
+ );
2312
+ if (exhausted.length > 0) {
2313
+ this.queue = this.queue.filter(
2314
+ (item) => item.failures <= this.retries
2315
+ );
2316
+ this.drop(
2317
+ exhausted,
2318
+ `delivery failed ${this.retries + 1} time${this.retries === 0 ? "" : "s"}; last error: ${failure}`
2319
+ );
2320
+ }
2321
+ if (this.queue.length > 0) {
2322
+ await this.backoff(failedRounds);
2323
+ }
2324
+ }
2325
+ }
2326
+ /**
2327
+ * Tries to deliver `batch` (the front of the queue). Delivered entries leave
2328
+ * the queue; an entry that failed has its failure count raised and stays.
2329
+ * Resolves to the failure message, or undefined when everything went through.
2330
+ */
2331
+ async deliver(batch) {
2332
+ const remove = (delivered) => {
2333
+ const gone = new Set(delivered);
2334
+ this.queue = this.queue.filter(
2335
+ (item) => !gone.has(item)
2336
+ );
2337
+ };
2338
+ if (this.useBatchMethod && hasLogBatch(this.inner)) {
2339
+ const result = await this.safely(
2340
+ () => this.inner.logBatch(
2341
+ batch.map(
2342
+ (item) => item.entry
2343
+ )
2344
+ )
2345
+ );
2346
+ if (result.ok) {
2347
+ remove(batch);
2348
+ return void 0;
2349
+ }
2350
+ for (const item of batch) {
2351
+ item.failures += 1;
2352
+ }
2353
+ return result.error ?? "the wrapped adapter reported a failure";
2354
+ }
2355
+ for (const item of batch) {
2356
+ const result = await this.safely(
2357
+ () => this.inner.log(item.entry)
2358
+ );
2359
+ if (result.ok) {
2360
+ remove([item]);
2361
+ continue;
2362
+ }
2363
+ item.failures += 1;
2364
+ return result.error ?? "the wrapped adapter reported a failure";
2365
+ }
2366
+ return void 0;
2367
+ }
2368
+ /** The wrapped adapter should not throw, but a bug in one must not stop the queue. */
2369
+ async safely(call) {
2370
+ try {
2371
+ return await call();
2372
+ } catch (error) {
2373
+ return { ok: false, error: describeError(error) };
2374
+ }
2375
+ }
2376
+ backoff(failedRounds) {
2377
+ return new Promise((resolve) => {
2378
+ const timer = setTimeout(
2379
+ () => {
2380
+ this.backoffTimer = null;
2381
+ resolve();
2382
+ },
2383
+ backoffDelayMs(failedRounds, this.retryDelayMs, this.maxRetryDelayMs)
2384
+ );
2385
+ if (this.flushers === 0) {
2386
+ timer.unref?.();
2387
+ }
2388
+ this.backoffTimer = timer;
2389
+ });
2390
+ }
2391
+ drop(items, reason) {
2392
+ if (items.length === 0) {
2393
+ return;
2394
+ }
2395
+ this.droppedSinceFlush += items.length;
2396
+ this.lastDropReason = reason;
2397
+ console.error(
2398
+ `${this.logPrefix} BatchingErrorAdapter: gave up on ${items.length} entr${items.length === 1 ? "y" : "ies"}: ${reason}`
2399
+ );
2400
+ try {
2401
+ this.onDrop?.(
2402
+ items.map((item) => item.entry),
2403
+ reason
2404
+ );
2405
+ } catch {
2406
+ }
2407
+ }
2408
+ };
2097
2409
  export {
2410
+ BatchingErrorAdapter,
2098
2411
  CloudWatchLogsErrorAdapter,
2099
2412
  CompositeErrorAdapter,
2100
2413
  ConsoleErrorAdapter,