@warlock.js/logger 5.29.0 → 5.31.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +48 -2
  2. package/README.md +14 -17
  3. package/cjs/index.cjs +569 -57
  4. package/cjs/index.cjs.map +1 -1
  5. package/esm/channels/console-log.d.mts +1 -1
  6. package/esm/channels/console-log.d.mts.map +1 -1
  7. package/esm/channels/console-log.mjs +1 -1
  8. package/esm/channels/console-log.mjs.map +1 -1
  9. package/esm/channels/file-log.d.mts +129 -13
  10. package/esm/channels/file-log.d.mts.map +1 -1
  11. package/esm/channels/file-log.mjs +204 -23
  12. package/esm/channels/file-log.mjs.map +1 -1
  13. package/esm/channels/file-name-pattern.d.mts +16 -0
  14. package/esm/channels/file-name-pattern.d.mts.map +1 -0
  15. package/esm/channels/file-name-pattern.mjs +41 -0
  16. package/esm/channels/file-name-pattern.mjs.map +1 -0
  17. package/esm/channels/index.mjs +1 -1
  18. package/esm/channels/json-file-log.d.mts +1 -1
  19. package/esm/channels/json-file-log.d.mts.map +1 -1
  20. package/esm/channels/json-file-log.mjs +25 -6
  21. package/esm/channels/json-file-log.mjs.map +1 -1
  22. package/esm/channels/sentry-log.d.mts +1 -1
  23. package/esm/channels/sentry-log.d.mts.map +1 -1
  24. package/esm/channels/sentry-log.mjs +1 -1
  25. package/esm/channels/sentry-log.mjs.map +1 -1
  26. package/esm/index.d.mts +8 -5
  27. package/esm/index.mjs +5 -3
  28. package/esm/log-channel.d.mts +24 -2
  29. package/esm/log-channel.d.mts.map +1 -1
  30. package/esm/log-channel.mjs +23 -1
  31. package/esm/log-channel.mjs.map +1 -1
  32. package/esm/logger.d.mts +78 -18
  33. package/esm/logger.d.mts.map +1 -1
  34. package/esm/logger.mjs +163 -22
  35. package/esm/logger.mjs.map +1 -1
  36. package/esm/redact/default-keys.d.mts +1 -1
  37. package/esm/redact/default-keys.d.mts.map +1 -1
  38. package/esm/redact/default-keys.mjs +1 -1
  39. package/esm/redact/default-keys.mjs.map +1 -1
  40. package/esm/redact/escape-log-value.d.mts +26 -0
  41. package/esm/redact/escape-log-value.d.mts.map +1 -0
  42. package/esm/redact/escape-log-value.mjs +37 -0
  43. package/esm/redact/escape-log-value.mjs.map +1 -0
  44. package/esm/redact/index.mjs +2 -0
  45. package/esm/redact/mask-email.d.mts +40 -0
  46. package/esm/redact/mask-email.d.mts.map +1 -0
  47. package/esm/redact/mask-email.mjs +70 -0
  48. package/esm/redact/mask-email.mjs.map +1 -0
  49. package/esm/redact/redact.d.mts +1 -1
  50. package/esm/redact/redact.d.mts.map +1 -1
  51. package/esm/redact/redact.mjs +1 -1
  52. package/esm/redact/redact.mjs.map +1 -1
  53. package/esm/types.d.mts +10 -2
  54. package/esm/types.d.mts.map +1 -1
  55. package/esm/utils/capture-unhandled-errors.d.mts +1 -1
  56. package/esm/utils/capture-unhandled-errors.d.mts.map +1 -1
  57. package/esm/utils/capture-unhandled-errors.mjs +1 -1
  58. package/esm/utils/capture-unhandled-errors.mjs.map +1 -1
  59. package/esm/utils/clear-message.d.mts +1 -1
  60. package/esm/utils/clear-message.d.mts.map +1 -1
  61. package/esm/utils/clear-message.mjs +1 -1
  62. package/esm/utils/clear-message.mjs.map +1 -1
  63. package/esm/utils/safe-json-stringify.d.mts +1 -1
  64. package/esm/utils/safe-json-stringify.d.mts.map +1 -1
  65. package/esm/utils/safe-json-stringify.mjs +1 -1
  66. package/esm/utils/safe-json-stringify.mjs.map +1 -1
  67. package/llms-full.txt +247 -147
  68. package/llms.txt +2 -2
  69. package/package.json +4 -3
  70. package/skills/capture-unhandled-errors/SKILL.md +9 -10
  71. package/skills/configure-logger/SKILL.md +7 -10
  72. package/skills/filter-log-entries/SKILL.md +2 -2
  73. package/skills/flush-logs-on-shutdown/SKILL.md +34 -15
  74. package/skills/index.md +4 -1
  75. package/skills/logger-basics/SKILL.md +21 -24
  76. package/skills/overview/SKILL.md +17 -6
  77. package/skills/pick-log-channel/SKILL.md +36 -24
  78. package/skills/redact-sensitive-log-fields/SKILL.md +48 -18
  79. package/skills/ship-logs-to-sentry/SKILL.md +18 -18
  80. package/skills/test-logging-code/SKILL.md +4 -2
  81. package/skills/write-custom-log-channel/SKILL.md +51 -18
package/cjs/index.cjs CHANGED
@@ -39,7 +39,7 @@ path = __toESM(path, 1);
39
39
  let safe_stable_stringify = require("safe-stable-stringify");
40
40
  let _mongez_reinforcements = require("@mongez/reinforcements");
41
41
 
42
- //#region ../logger/src/log-channel.ts
42
+ //#region ../../packages/logger/src/log-channel.ts
43
43
  var LogChannel = class {
44
44
  /**
45
45
  * Constructor
@@ -56,6 +56,28 @@ var LogChannel = class {
56
56
  }, 0);
57
57
  }
58
58
  /**
59
+ * Route this channel's own failures (those outside a `log()` call) to the
60
+ * given reporter. `Logger` calls this when the channel is added, so the
61
+ * failures reach `log.onChannelError(...)`.
62
+ */
63
+ setErrorReporter(reporter) {
64
+ this.errorReporter = reporter;
65
+ }
66
+ /**
67
+ * Report a failure that happened outside a `log()` call. Never throws:
68
+ * without a reporter it writes one line to stderr.
69
+ */
70
+ reportError(error, entry) {
71
+ try {
72
+ if (this.errorReporter) {
73
+ this.errorReporter(error, entry);
74
+ return;
75
+ }
76
+ const reason = error instanceof Error ? error.message : String(error);
77
+ process.stderr.write(`[@warlock.js/logger] channel "${this.name}" failed: ${reason}\n`);
78
+ } catch {}
79
+ }
80
+ /**
59
81
  * Get config value
60
82
  */
61
83
  config(key) {
@@ -112,7 +134,7 @@ var LogChannel = class {
112
134
  };
113
135
 
114
136
  //#endregion
115
- //#region ../logger/src/channels/console-log.ts
137
+ //#region ../../packages/logger/src/channels/console-log.ts
116
138
  /**
117
139
  * The longest level name (`success`) sets the tag column width. Padding every
118
140
  * level name to this width keeps the timestamp / module / action columns
@@ -195,7 +217,53 @@ var ConsoleLog = class extends LogChannel {
195
217
  };
196
218
 
197
219
  //#endregion
198
- //#region ../logger/src/channels/file-log.ts
220
+ //#region ../../packages/logger/src/channels/file-name-pattern.ts
221
+ const TOKEN = /\{(YYYY|MM|DD|HH|mm|name)\}/g;
222
+ const pad = (value) => String(value).padStart(2, "0");
223
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
224
+ /**
225
+ * Render a file name option for the given date.
226
+ */
227
+ function renderFileName(option, date, name) {
228
+ if (typeof option === "function") return option(date);
229
+ return option.replace(TOKEN, (_match, token) => {
230
+ switch (token) {
231
+ case "YYYY": return String(date.getFullYear());
232
+ case "MM": return pad(date.getMonth() + 1);
233
+ case "DD": return pad(date.getDate());
234
+ case "HH": return pad(date.getHours());
235
+ case "mm": return pad(date.getMinutes());
236
+ default: return name;
237
+ }
238
+ });
239
+ }
240
+ /**
241
+ * Regular-expression source matching every name a pattern string can render:
242
+ * literal text is escaped, date tokens become digit runs and `{name}` the
243
+ * escaped channel name. The source is not anchored.
244
+ */
245
+ function fileNamePatternSource(pattern, name) {
246
+ let source = "";
247
+ let lastIndex = 0;
248
+ for (const match of pattern.matchAll(TOKEN)) {
249
+ const index = match.index ?? 0;
250
+ const token = match[1];
251
+ source += escapeRegExp(pattern.slice(lastIndex, index));
252
+ source += token === "YYYY" ? "\\d{4}" : token === "name" ? escapeRegExp(name) : "\\d{2}";
253
+ lastIndex = index + match[0].length;
254
+ }
255
+ return source + escapeRegExp(pattern.slice(lastIndex));
256
+ }
257
+
258
+ //#endregion
259
+ //#region ../../packages/logger/src/channels/file-log.ts
260
+ /** Default file name of a `daily` chunk: sortable ISO date. */
261
+ const DAILY_FILE_NAME = "{YYYY}-{MM}-{DD}";
262
+ /** Default file name of an `hourly` chunk: sortable ISO date and hour. */
263
+ const HOURLY_FILE_NAME = "{YYYY}-{MM}-{DD}-{HH}";
264
+ /** Default date part of a size-rotated file's name. */
265
+ const ROTATE_FILE_NAME = "{YYYY}-{MM}-{DD}";
266
+ const DAY_MS = 1440 * 60 * 1e3;
199
267
  var FileLog = class extends LogChannel {
200
268
  constructor(..._args) {
201
269
  super(..._args);
@@ -211,9 +279,7 @@ var FileLog = class extends LogChannel {
211
279
  maxMessagesToWrite: 100,
212
280
  filter: () => true,
213
281
  maxFileSize: 10 * 1024 * 1024,
214
- get rotateFileName() {
215
- return (0, dayjs.default)().format("DD-MM-YYYY");
216
- },
282
+ rotateFileName: ROTATE_FILE_NAME,
217
283
  dateFormat: {
218
284
  date: "DD-MM-YYYY",
219
285
  time: "HH:mm:ss"
@@ -221,6 +287,15 @@ var FileLog = class extends LogChannel {
221
287
  };
222
288
  this.lastWriteTime = Date.now();
223
289
  this.isWriting = false;
290
+ this.retentionDue = false;
291
+ this.missingMatchReported = false;
292
+ }
293
+ /**
294
+ * {@inheritdoc}
295
+ */
296
+ setConfigurations(configurations) {
297
+ assertRetention(configurations.retention);
298
+ return super.setConfigurations(configurations);
224
299
  }
225
300
  /**
226
301
  * Check file size for file rotation
@@ -228,19 +303,24 @@ var FileLog = class extends LogChannel {
228
303
  async checkAndRotateFile(filePath = this.filePath) {
229
304
  if (!this.config("rotate")) return;
230
305
  try {
231
- if ((await fs.default.promises.stat(filePath)).size >= this.config("maxFileSize")) await this.rotateLogFile();
306
+ if ((await fs.default.promises.stat(filePath)).size >= this.config("maxFileSize")) await this.rotateLogFile(filePath);
232
307
  } catch (error) {
233
308
  if (error.code !== "ENOENT") console.error("Error checking log file:", error);
234
309
  }
235
310
  }
236
311
  /**
237
- * Rotate log file
312
+ * Rotate the given log file (the active one by default) aside as
313
+ * `<file name>-<rotateFileName>-<epoch ms>.<extension>` in its directory.
238
314
  */
239
- async rotateLogFile() {
240
- const fileName = `${this.fileName}-${this.config("rotateFileName")}-${Date.now()}`;
315
+ async rotateLogFile(filePath = this.filePath) {
316
+ const now = /* @__PURE__ */ new Date();
241
317
  const extension = this.extension;
242
- const rotatedFilePath = path.default.join(this.storagePath, `${fileName}.${extension}`);
243
- await fs.default.promises.rename(this.filePath, rotatedFilePath).catch((error) => {
318
+ const baseName = path.default.basename(filePath, `.${extension}`);
319
+ const rotateName = renderFileName(this.config("rotateFileName") ?? "{YYYY}-{MM}-{DD}", now, this.baseName);
320
+ const rotatedFilePath = path.default.join(path.default.dirname(filePath), `${baseName}-${rotateName}-${now.getTime()}.${extension}`);
321
+ await fs.default.promises.rename(filePath, rotatedFilePath).then(() => {
322
+ this.retentionDue = true;
323
+ }, (error) => {
244
324
  console.error("Error rotating file:", error);
245
325
  });
246
326
  }
@@ -286,17 +366,32 @@ var FileLog = class extends LogChannel {
286
366
  return this.config("maxMessagesToWrite");
287
367
  }
288
368
  /**
289
- * Get file name
369
+ * The configured `name` option.
290
370
  */
291
- get fileName() {
371
+ get baseName() {
372
+ return this.config("name") ?? "app";
373
+ }
374
+ /**
375
+ * The `fileName` option, or the chunk's default. `undefined` means the
376
+ * plain `name` of a `single` chunk, used as is (braces included).
377
+ */
378
+ get fileNamePattern() {
379
+ const configured = this.config("fileName");
380
+ if (configured !== void 0) return configured;
292
381
  switch (this.config("chunk")) {
293
- case "single":
294
- default: return this.config("name");
295
- case "daily": return (0, dayjs.default)().format("DD-MM-YYYY");
296
- case "hourly": return (0, dayjs.default)().format("DD-MM-YYYY-HH-00-00-a");
382
+ case "daily": return DAILY_FILE_NAME;
383
+ case "hourly": return HOURLY_FILE_NAME;
384
+ default: return;
297
385
  }
298
386
  }
299
387
  /**
388
+ * Current file name, without extension
389
+ */
390
+ get fileName() {
391
+ const pattern = this.fileNamePattern;
392
+ return pattern === void 0 ? this.baseName : renderFileName(pattern, /* @__PURE__ */ new Date(), this.baseName);
393
+ }
394
+ /**
300
395
  * Get file extension
301
396
  */
302
397
  get extension() {
@@ -321,6 +416,138 @@ var FileLog = class extends LogChannel {
321
416
  const logsDirectory = this.storagePath;
322
417
  await (0, _warlock_js_fs.ensureDirectoryAsync)(logsDirectory);
323
418
  this.initMessageFlush();
419
+ await this.prune();
420
+ }
421
+ /**
422
+ * Remove old log files as configured by `retention`. Runs on boot and
423
+ * whenever a new file is started; call it yourself to prune on demand.
424
+ * Never rejects: failures go to the channel error handler.
425
+ */
426
+ prune() {
427
+ const run = (this.pruning ?? Promise.resolve()).then(() => this.pruneFiles());
428
+ this.pruning = run;
429
+ return run;
430
+ }
431
+ /**
432
+ * Prune once a write started a new file: after a size rotation, or when a
433
+ * dated file name rolled over. Never throws.
434
+ */
435
+ async afterWrite() {
436
+ let fileName;
437
+ try {
438
+ fileName = this.fileName;
439
+ } catch {
440
+ return;
441
+ }
442
+ const rolledOver = this.lastFileName !== void 0 && this.lastFileName !== fileName;
443
+ this.lastFileName = fileName;
444
+ if (!rolledOver && !this.retentionDue) return;
445
+ this.retentionDue = false;
446
+ await this.prune();
447
+ }
448
+ /**
449
+ * Apply `retention` to every directory this channel writes to.
450
+ */
451
+ async pruneFiles() {
452
+ const retention = this.config("retention");
453
+ if (!retention || retention.maxFiles === void 0 && retention.maxAgeDays === void 0) return;
454
+ try {
455
+ const matcher = this.retentionMatcher(retention);
456
+ if (!matcher) return;
457
+ const activeFile = `${this.fileName}.${this.extension}`;
458
+ for (const directory of await this.retentionDirectories()) await this.pruneDirectory(directory, matcher, retention, activeFile);
459
+ } catch (error) {
460
+ this.reportRetentionError(error, this.storagePath);
461
+ }
462
+ }
463
+ /**
464
+ * The expression a file name must match to be pruned, or `undefined` when
465
+ * none can be derived (a function `fileName` without `retention.match`).
466
+ */
467
+ retentionMatcher(retention) {
468
+ if (retention.match) return new RegExp(retention.match.source, retention.match.flags.replace(/[gy]/g, ""));
469
+ const pattern = this.fileNamePattern;
470
+ if (typeof pattern === "function") {
471
+ if (!this.missingMatchReported) {
472
+ this.missingMatchReported = true;
473
+ this.reportRetentionError(/* @__PURE__ */ new Error("FileLog retention needs `retention.match` when `fileName` is a function; no files were pruned"), this.storagePath);
474
+ }
475
+ return;
476
+ }
477
+ const name = this.baseName;
478
+ const fileSource = pattern === void 0 ? escapeRegExp(name) : fileNamePatternSource(pattern, name);
479
+ const rotateName = this.config("rotateFileName") ?? "{YYYY}-{MM}-{DD}";
480
+ const rotateSource = typeof rotateName === "function" ? ".+" : fileNamePatternSource(rotateName, name);
481
+ return new RegExp(`^${fileSource}(?:-${rotateSource}-\\d{13,})?\\.${escapeRegExp(this.extension)}$`);
482
+ }
483
+ /**
484
+ * The log directory, or each group directory when `groupBy` is set.
485
+ */
486
+ async retentionDirectories() {
487
+ let directories = [this.storagePath];
488
+ const depth = this.config("groupBy")?.length ?? 0;
489
+ for (let level = 0; level < depth; level++) {
490
+ const next = [];
491
+ for (const directory of directories) {
492
+ const entries = await fs.default.promises.readdir(directory, { withFileTypes: true }).catch((error) => {
493
+ if (error.code !== "ENOENT") this.reportRetentionError(error, directory);
494
+ return [];
495
+ });
496
+ for (const entry of entries) if (entry.isDirectory()) next.push(path.default.join(directory, entry.name));
497
+ }
498
+ directories = next;
499
+ }
500
+ return directories;
501
+ }
502
+ /**
503
+ * Remove the matching files of one directory that `retention` no longer
504
+ * keeps. Each failure is reported and the rest still run.
505
+ */
506
+ async pruneDirectory(directory, matcher, retention, activeFile) {
507
+ let names;
508
+ try {
509
+ names = await fs.default.promises.readdir(directory);
510
+ } catch (error) {
511
+ if (error.code !== "ENOENT") this.reportRetentionError(error, directory);
512
+ return;
513
+ }
514
+ const candidates = [];
515
+ for (const name of names) {
516
+ if (name === activeFile || !matcher.test(name)) continue;
517
+ const filePath = path.default.join(directory, name);
518
+ try {
519
+ const stats = await fs.default.promises.stat(filePath);
520
+ if (stats.isFile()) candidates.push({
521
+ filePath,
522
+ name,
523
+ modifiedAt: stats.mtimeMs
524
+ });
525
+ } catch (error) {
526
+ if (error.code !== "ENOENT") this.reportRetentionError(error, filePath);
527
+ }
528
+ }
529
+ candidates.sort((a, b) => b.modifiedAt - a.modifiedAt || (a.name < b.name ? 1 : -1));
530
+ const keep = retention.maxFiles === void 0 ? Infinity : retention.maxFiles - (names.includes(activeFile) ? 1 : 0);
531
+ const oldestKept = retention.maxAgeDays === void 0 ? -Infinity : Date.now() - retention.maxAgeDays * DAY_MS;
532
+ for (const [index, candidate] of candidates.entries()) {
533
+ if (index < keep && candidate.modifiedAt >= oldestKept) continue;
534
+ try {
535
+ await fs.default.promises.unlink(candidate.filePath);
536
+ } catch (error) {
537
+ if (error.code !== "ENOENT") this.reportRetentionError(error, candidate.filePath);
538
+ }
539
+ }
540
+ }
541
+ /**
542
+ * Send a retention failure to the channel error handler.
543
+ */
544
+ reportRetentionError(error, target) {
545
+ this.reportError(error, {
546
+ type: "error",
547
+ module: "logger",
548
+ action: "retention",
549
+ message: `Log retention failed for ${target}`
550
+ });
324
551
  }
325
552
  /**
326
553
  * Synchronously flush messages
@@ -412,14 +639,17 @@ var FileLog = class extends LogChannel {
412
639
  if (this.messages.length === 0 || this.isWriting || !this.isInitialized) return;
413
640
  this.isWriting = true;
414
641
  if (this.messagedShouldBeGrouped) return await this.writeGroupedMessagesToFile();
415
- await this.checkAndRotateFile();
416
642
  try {
417
- await this.write(this.filePath, this.content);
643
+ const filePath = this.filePath;
644
+ await this.checkAndRotateFile(filePath);
645
+ await this.write(filePath, this.content);
418
646
  this.onSave();
419
647
  } catch (error) {
420
648
  console.error("Failed to write log:", error);
421
649
  this.isWriting = false;
650
+ return;
422
651
  }
652
+ await this.afterWrite();
423
653
  }
424
654
  /**
425
655
  * Write grouped messages to the file
@@ -428,11 +658,11 @@ var FileLog = class extends LogChannel {
428
658
  this.prepareGroupedMessages();
429
659
  for (const [key, groupMessages] of Object.entries(this.groupedMessages)) {
430
660
  const directoryPath = path.default.join(this.storagePath, key);
431
- await (0, _warlock_js_fs.ensureDirectoryAsync)(directoryPath);
432
- const filePath = path.default.join(directoryPath, `${this.fileName}.${this.extension}`);
433
- await this.checkAndRotateFile(filePath);
434
- const content = groupMessages.map((message) => message.content).join(os.EOL) + os.EOL;
435
661
  try {
662
+ await (0, _warlock_js_fs.ensureDirectoryAsync)(directoryPath);
663
+ const filePath = path.default.join(directoryPath, `${this.fileName}.${this.extension}`);
664
+ await this.checkAndRotateFile(filePath);
665
+ const content = groupMessages.map((message) => message.content).join(os.EOL) + os.EOL;
436
666
  await this.write(filePath, content);
437
667
  } catch (error) {
438
668
  console.error("Failed to write log:", error);
@@ -440,6 +670,7 @@ var FileLog = class extends LogChannel {
440
670
  }
441
671
  this.onSave();
442
672
  this.isWriting = false;
673
+ await this.afterWrite();
443
674
  }
444
675
  /**
445
676
  * Prepare grouped messages
@@ -465,9 +696,19 @@ var FileLog = class extends LogChannel {
465
696
  });
466
697
  }
467
698
  };
699
+ /**
700
+ * Reject a `retention` that could never keep or remove anything sensibly.
701
+ */
702
+ function assertRetention(retention) {
703
+ if (!retention) return;
704
+ const { maxFiles, maxAgeDays, match } = retention;
705
+ if (maxFiles !== void 0 && (!Number.isInteger(maxFiles) || maxFiles < 1)) throw new TypeError("FileLog retention.maxFiles must be a positive integer");
706
+ if (maxAgeDays !== void 0 && (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0)) throw new TypeError("FileLog retention.maxAgeDays must be a positive number");
707
+ if (match !== void 0 && !(match instanceof RegExp)) throw new TypeError("FileLog retention.match must be a RegExp");
708
+ }
468
709
 
469
710
  //#endregion
470
- //#region ../logger/src/utils/safe-json-stringify.ts
711
+ //#region ../../packages/logger/src/utils/safe-json-stringify.ts
471
712
  /**
472
713
  * Replacer that surfaces Error data — `name`, `message`, and `stack` are
473
714
  * non-enumerable on `Error`, so neither default JSON serialization nor an
@@ -498,7 +739,7 @@ function safeJsonStringify(value, space) {
498
739
  }
499
740
 
500
741
  //#endregion
501
- //#region ../logger/src/channels/json-file-log.ts
742
+ //#region ../../packages/logger/src/channels/json-file-log.ts
502
743
  var JSONFileLog = class extends FileLog {
503
744
  constructor(..._args) {
504
745
  super(..._args);
@@ -577,10 +818,18 @@ var JSONFileLog = class extends FileLog {
577
818
  if (this.messages.length === 0 || this.isWriting || !this.isInitialized) return;
578
819
  this.isWriting = true;
579
820
  if (this.messagedShouldBeGrouped) return await this.writeGroupedMessagesToFile();
580
- await this.checkAndRotateFile();
821
+ let filePath;
822
+ try {
823
+ filePath = this.filePath;
824
+ } catch (error) {
825
+ console.error("Failed to write log:", error);
826
+ this.isWriting = false;
827
+ return;
828
+ }
829
+ await this.checkAndRotateFile(filePath);
581
830
  let fileContents = { messages: [] };
582
- if (await (0, _warlock_js_fs.fileExistsAsync)(this.filePath)) try {
583
- fileContents = await (0, _warlock_js_fs.getJsonFileAsync)(this.filePath);
831
+ if (await (0, _warlock_js_fs.fileExistsAsync)(filePath)) try {
832
+ fileContents = await (0, _warlock_js_fs.getJsonFileAsync)(filePath);
584
833
  } catch (error) {
585
834
  console.error("Error reading log file, reinitializing:", error);
586
835
  fileContents = { messages: [] };
@@ -588,22 +837,32 @@ var JSONFileLog = class extends FileLog {
588
837
  else fileContents = { messages: [] };
589
838
  fileContents.messages.push(...this.messages);
590
839
  try {
591
- await fs.default.promises.writeFile(this.filePath, safeJsonStringify(fileContents, 2));
840
+ await fs.default.promises.writeFile(filePath, safeJsonStringify(fileContents, 2));
592
841
  this.onSave();
593
842
  } catch (error) {
594
843
  console.error("Failed to write log:", error);
595
844
  this.isWriting = false;
845
+ return;
596
846
  }
847
+ await this.afterWrite();
597
848
  }
598
849
  /**
599
850
  * Write grouped messages to the file
600
851
  */
601
852
  async writeGroupedMessagesToFile() {
853
+ let fileName;
854
+ try {
855
+ fileName = this.fileName;
856
+ } catch (error) {
857
+ console.error("Failed to write log:", error);
858
+ this.isWriting = false;
859
+ return;
860
+ }
602
861
  this.prepareGroupedMessages();
603
862
  for (const [key, groupMessages] of Object.entries(this.groupedMessages)) {
604
863
  const directoryPath = path.default.join(this.storagePath, key);
605
864
  await (0, _warlock_js_fs.ensureDirectoryAsync)(directoryPath);
606
- const filePath = path.default.join(directoryPath, `${this.fileName}.${this.extension}`);
865
+ const filePath = path.default.join(directoryPath, `${fileName}.${this.extension}`);
607
866
  await this.checkAndRotateFile(filePath);
608
867
  let fileContents = { messages: [] };
609
868
  if (await (0, _warlock_js_fs.fileExistsAsync)(filePath)) try {
@@ -622,11 +881,12 @@ var JSONFileLog = class extends FileLog {
622
881
  }
623
882
  }
624
883
  this.onSave();
884
+ await this.afterWrite();
625
885
  }
626
886
  };
627
887
 
628
888
  //#endregion
629
- //#region ../logger/src/channels/sentry-log.ts
889
+ //#region ../../packages/logger/src/channels/sentry-log.ts
630
890
  let Sentry;
631
891
  let isModuleExists = null;
632
892
  let loadingPromise;
@@ -823,7 +1083,7 @@ var SentryLog = class extends LogChannel {
823
1083
  };
824
1084
 
825
1085
  //#endregion
826
- //#region ../logger/src/redact/default-keys.ts
1086
+ //#region ../../packages/logger/src/redact/default-keys.ts
827
1087
  /**
828
1088
  * Built-in secret-key denylist — the redaction floor that applies with no
829
1089
  * configuration at all.
@@ -937,7 +1197,7 @@ const DEFAULT_REDACT_KEYS = [
937
1197
  const DEFAULT_REDACT_KEY_SET = new Set(DEFAULT_REDACT_KEYS.map(normalizeRedactKey));
938
1198
 
939
1199
  //#endregion
940
- //#region ../logger/src/redact/redact.ts
1200
+ //#region ../../packages/logger/src/redact/redact.ts
941
1201
  const DEFAULT_CENSOR = "[REDACTED]";
942
1202
  /**
943
1203
  * Values we copy by reference rather than walking. Expanding these with
@@ -1201,7 +1461,110 @@ function mergeRedact(base, extra) {
1201
1461
  }
1202
1462
 
1203
1463
  //#endregion
1204
- //#region ../logger/src/utils/clear-message.ts
1464
+ //#region ../../packages/logger/src/redact/escape-log-value.ts
1465
+ /**
1466
+ * ANSI escape sequences: CSI (`ESC [ … final`) and the two-character
1467
+ * `ESC x` escapes (`ESC c` resets a terminal). Bounded so matching stays linear on long input.
1468
+ */
1469
+ const ANSI_ESCAPE = /\u001b(?:\[[0-?]{0,32}[ -/]{0,8}[@-~]|[0-~])/g;
1470
+ /** Control characters that are not already handled as `\r`, `\n` or `\t`. */
1471
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g;
1472
+ /**
1473
+ * Make client-controlled text safe to put inside a log line: line breaks and
1474
+ * tabs become the visible escapes `\r`, `\n`, `\t`, other control characters
1475
+ * (including NUL, DEL, C1 controls and the Unicode line/paragraph separators)
1476
+ * become `\uXXXX`, and ANSI terminal escape sequences are removed.
1477
+ *
1478
+ * Without it, a user agent or form field containing `\n2026-01-01 [info] …`
1479
+ * writes a forged entry into a text log, and an ANSI sequence can rewrite what
1480
+ * a terminal shows. Structured channels (JSON) are not affected, but console
1481
+ * and file channels are.
1482
+ *
1483
+ * @example
1484
+ * log.info("auth", "login", `Login from ${escapeLogValue(request.userAgent)}`);
1485
+ */
1486
+ function escapeLogValue(value, options = {}) {
1487
+ let text = typeof value === "string" ? value : String(value);
1488
+ const maxLength = options.maxLength;
1489
+ let truncated = false;
1490
+ if (maxLength !== void 0 && maxLength >= 0 && text.length > maxLength) {
1491
+ text = text.slice(0, maxLength);
1492
+ truncated = true;
1493
+ }
1494
+ const escaped = text.replace(ANSI_ESCAPE, "").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(CONTROL, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
1495
+ return truncated ? `${escaped}…[truncated]` : escaped;
1496
+ }
1497
+
1498
+ //#endregion
1499
+ //#region ../../packages/logger/src/redact/mask-email.ts
1500
+ /**
1501
+ * Email addresses inside free text: a local part and a dotted domain, letters
1502
+ * and digits from any script (`jöhn@exämple.de`). Kept deliberately simple (no
1503
+ * quoted local parts, no IP-literal domains, no percent-encoded `@`): it masks
1504
+ * the addresses that show up in log messages and cache keys, not every string
1505
+ * RFC 5322 allows.
1506
+ *
1507
+ * Every repetition is bounded (local part 64, label 63, at most 12 labels), so
1508
+ * matching stays linear in the input length. Cache keys and messages are often
1509
+ * built from request input; an unbounded pattern backtracks quadratically on a
1510
+ * long run of address-like characters with no domain.
1511
+ */
1512
+ const EMAIL_IN_TEXT = /[\p{L}\p{N}._%+-]{1,64}@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63}){0,10}\.\p{L}{2,63}/gu;
1513
+ /**
1514
+ * Longest text {@link maskEmailsInText} scans. Anything past it is cut off and
1515
+ * replaced by a marker, so a huge cache key or message cannot cost more than a
1516
+ * bounded amount of time per log line, and nothing past the cut can leak.
1517
+ */
1518
+ const MASK_EMAILS_MAX_LENGTH = 16384;
1519
+ /**
1520
+ * Mask one email address, keeping its first character and its domain:
1521
+ * `member@example.com` → `m***@example.com`. A value without an `@` is
1522
+ * returned as `***`.
1523
+ *
1524
+ * Key-based redaction cannot reach an address that sits inside a message
1525
+ * string or a cache key; mask it before it is logged.
1526
+ *
1527
+ * @example
1528
+ * log.info("mail", "send", `Sending mail to: ${maskEmail(to)}`);
1529
+ */
1530
+ function maskEmail(address) {
1531
+ const at = address.lastIndexOf("@");
1532
+ if (at <= 0) return "***";
1533
+ return `${String.fromCodePoint(address.codePointAt(0))}***${address.slice(at)}`;
1534
+ }
1535
+ /**
1536
+ * Mask every email address the pattern above recognises in a piece of text
1537
+ * with {@link maskEmail}. Text longer than {@link MASK_EMAILS_MAX_LENGTH}
1538
+ * characters is truncated first (the cut is marked).
1539
+ *
1540
+ * Dots are valid in the local part of an address, so a dotted prefix that
1541
+ * runs into the address is masked with it: in a cache key such as
1542
+ * `email.member@example.com` the whole `email.member` reads as the local part.
1543
+ *
1544
+ * Not recognised (left as they are): quoted local parts (`"john doe"@x.com`),
1545
+ * IP-literal domains (`user@[127.0.0.1]`) and percent-encoded addresses
1546
+ * (`user%40example.com`).
1547
+ *
1548
+ * @example
1549
+ * maskEmailsInText("sent to member@example.com") // "sent to m***@example.com"
1550
+ * maskEmailsInText("email.member@example.com") // "e***@example.com"
1551
+ */
1552
+ function maskEmailsInText(text) {
1553
+ let scanned = text;
1554
+ let marker = "";
1555
+ if (scanned.length > 16384) {
1556
+ let cut = MASK_EMAILS_MAX_LENGTH;
1557
+ const last = scanned.charCodeAt(cut - 1);
1558
+ if (last >= 55296 && last <= 56319) cut -= 1;
1559
+ marker = `…[${scanned.length - cut} chars truncated]`;
1560
+ scanned = scanned.slice(0, cut);
1561
+ }
1562
+ if (!scanned.includes("@")) return scanned + marker;
1563
+ return scanned.replace(EMAIL_IN_TEXT, (match) => maskEmail(match)) + marker;
1564
+ }
1565
+
1566
+ //#endregion
1567
+ //#region ../../packages/logger/src/utils/clear-message.ts
1205
1568
  /**
1206
1569
  * Clear message from any terminal codes
1207
1570
  */
@@ -1211,7 +1574,7 @@ function clearMessage(message) {
1211
1574
  }
1212
1575
 
1213
1576
  //#endregion
1214
- //#region ../logger/src/logger.ts
1577
+ //#region ../../packages/logger/src/logger.ts
1215
1578
  const SIGNAL_EVENTS = new Set([
1216
1579
  "SIGINT",
1217
1580
  "SIGTERM",
@@ -1239,22 +1602,39 @@ var Logger = class {
1239
1602
  this.channels = [];
1240
1603
  this.id = "logger-" + _mongez_reinforcements.Random.string(32);
1241
1604
  this.autoFlushHandlers = /* @__PURE__ */ new Map();
1605
+ this.pendingDeliveries = /* @__PURE__ */ new Map();
1606
+ this.channelErrorHandler = reportChannelError;
1242
1607
  }
1243
1608
  /**
1244
1609
  * Add a new channel
1245
1610
  */
1246
1611
  addChannel(channel) {
1247
- this.channels.push(channel);
1612
+ this.channels.push(this.attachChannel(channel));
1248
1613
  return this;
1249
1614
  }
1250
1615
  /**
1251
1616
  * Set base configurations
1252
1617
  */
1253
1618
  configure(config) {
1254
- if (config.channels) this.channels = config.channels;
1619
+ if (config.channels) this.setChannels(config.channels);
1255
1620
  if (config.autoFlushOn) this.enableAutoFlush(config.autoFlushOn);
1256
1621
  if (config.minLevel !== void 0) this.setMinLevel(config.minLevel);
1257
1622
  if (config.redact !== void 0) this.setRedact(config.redact);
1623
+ if (config.onChannelError) this.onChannelError(config.onChannelError);
1624
+ return this;
1625
+ }
1626
+ /**
1627
+ * Handle a channel's delivery failure. Logging is fire-and-forget, so a
1628
+ * channel that throws or rejects never reaches the call site and never
1629
+ * becomes an unhandled rejection; it goes to this handler instead. The
1630
+ * default writes one line to stderr. The handler must not log through this
1631
+ * logger (a failing channel would loop).
1632
+ *
1633
+ * @example
1634
+ * log.onChannelError((error, channel) => metrics.increment(`log.${channel.name}.failed`));
1635
+ */
1636
+ onChannelError(handler) {
1637
+ this.channelErrorHandler = handler;
1258
1638
  return this;
1259
1639
  }
1260
1640
  /**
@@ -1303,9 +1683,18 @@ var Logger = class {
1303
1683
  */
1304
1684
  setChannels(channels) {
1305
1685
  this.channels = channels;
1686
+ for (const channel of channels) this.attachChannel(channel);
1306
1687
  return this;
1307
1688
  }
1308
1689
  /**
1690
+ * Route a channel's own failures (outside `log()`, such as file retention)
1691
+ * to this logger's channel error handler.
1692
+ */
1693
+ attachChannel(channel) {
1694
+ if (typeof channel.setErrorReporter === "function") channel.setErrorReporter((error, entry) => this.reportChannelError(error, channel, entry));
1695
+ return channel;
1696
+ }
1697
+ /**
1309
1698
  * Normalize log data to a single object
1310
1699
  */
1311
1700
  normalizeLogData(dataOrModule, action, message = "", level, context) {
@@ -1332,21 +1721,62 @@ var Logger = class {
1332
1721
  * — each channel sees its own shallow clone so one channel cannot observe
1333
1722
  * another's mutations (e.g. a later terminal channel still sees the original
1334
1723
  * colored message).
1724
+ *
1725
+ * Synchronous and fire-and-forget: it returns the logger, not a promise.
1726
+ * Async channel deliveries are tracked and awaited by {@link flush}; a
1727
+ * channel that throws or rejects is isolated from the others and reported
1728
+ * to {@link onChannelError}.
1335
1729
  */
1336
- async log(data) {
1730
+ log(data) {
1337
1731
  if (this.minLevel && LEVEL_RANK[data.type] < LEVEL_RANK[this.minLevel]) return this;
1338
- const baseEntry = applyRedact(data, this.redactConfig);
1339
- for (const channel of this.channels) {
1732
+ let baseEntry;
1733
+ try {
1734
+ baseEntry = applyRedact(data, this.redactConfig);
1735
+ } catch (error) {
1736
+ for (const channel of this.channels) this.reportChannelError(error, channel, redactionFailedEntry(data));
1737
+ return this;
1738
+ }
1739
+ for (const channel of this.channels) this.deliver(channel, data, baseEntry);
1740
+ return this;
1741
+ }
1742
+ /**
1743
+ * Prepare and hand one entry to one channel, isolating its failure (a
1744
+ * channel redaction that throws included) and tracking an async delivery
1745
+ * until it settles.
1746
+ */
1747
+ deliver(channel, data, baseEntry) {
1748
+ let payload = baseEntry;
1749
+ let result;
1750
+ try {
1340
1751
  const channelRedact = channel.getRedactConfig?.();
1341
- const effectiveRedact = channelRedact ? mergeRedact(this.redactConfig, channelRedact) : void 0;
1342
- let payload = effectiveRedact ? applyRedact(data, effectiveRedact) : baseEntry;
1752
+ if (channelRedact) payload = applyRedact(data, mergeRedact(this.redactConfig, channelRedact));
1343
1753
  if (channel.terminal === false) payload = {
1344
1754
  ...payload,
1345
1755
  message: clearMessage(payload.message)
1346
1756
  };
1347
- channel.log(payload);
1757
+ result = channel.log(payload);
1758
+ } catch (error) {
1759
+ this.reportChannelError(error, channel, payload);
1760
+ return;
1348
1761
  }
1349
- return this;
1762
+ if (!result || typeof result.then !== "function") return;
1763
+ let pending = this.pendingDeliveries.get(channel);
1764
+ if (!pending) {
1765
+ pending = /* @__PURE__ */ new Set();
1766
+ this.pendingDeliveries.set(channel, pending);
1767
+ }
1768
+ const tracked = pending;
1769
+ const delivery = Promise.resolve(result).catch((error) => this.reportChannelError(error, channel, payload)).finally(() => {
1770
+ tracked.delete(delivery);
1771
+ if (tracked.size === 0 && this.pendingDeliveries.get(channel) === tracked) this.pendingDeliveries.delete(channel);
1772
+ });
1773
+ tracked.add(delivery);
1774
+ }
1775
+ reportChannelError(error, channel, entry) {
1776
+ try {
1777
+ const outcome = this.channelErrorHandler(error, channel, entry);
1778
+ if (outcome && typeof outcome.then === "function") outcome.then(void 0, () => {});
1779
+ } catch {}
1350
1780
  }
1351
1781
  /**
1352
1782
  * Make debug log
@@ -1442,7 +1872,9 @@ var Logger = class {
1442
1872
  for (const channel of this.channels) if (channel.flushSync) channel.flushSync();
1443
1873
  }
1444
1874
  /**
1445
- * Asynchronously drain every channel that implements `flush()`.
1875
+ * Wait for every in-flight async delivery, then drain every channel that
1876
+ * implements `flush()` — bounded by `timeout` (default
1877
+ * {@link DEFAULT_FLUSH_TIMEOUT_MS}).
1446
1878
  *
1447
1879
  * Unlike {@link flushSync}, this awaits each channel's async I/O — the
1448
1880
  * correct call for a graceful shutdown that can afford to wait
@@ -1450,24 +1882,68 @@ var Logger = class {
1450
1882
  * `process.exit`). A channel whose delivery is async (a network transport,
1451
1883
  * an async disk write) implements `flush()`, not `flushSync()`.
1452
1884
  *
1453
- * Channels are isolated: a channel whose flush rejects can neither prevent
1454
- * the others from draining nor escape as an unhandled rejection. Channels
1455
- * without `flush()` are skipped.
1885
+ * Channels drain concurrently and independently: each waits for its own
1886
+ * pending deliveries (including ones logged while the flush runs), then
1887
+ * runs its `flush()`. A channel whose delivery hangs therefore cannot hold
1888
+ * up another channel's drain, and a channel whose flush rejects can neither
1889
+ * stop the others nor escape as an unhandled rejection. When `timeout`
1890
+ * elapses, `flush()` resolves anyway and each channel still draining is
1891
+ * reported to {@link onChannelError}. Channels without `flush()` only wait
1892
+ * for their deliveries.
1456
1893
  *
1457
1894
  * @example
1458
1895
  * async function shutdown() {
1459
1896
  * await httpServer.close();
1460
- * await log.flush();
1897
+ * await log.flush({ timeout: 3000 });
1461
1898
  * process.exit(0);
1462
1899
  * }
1463
1900
  */
1464
- async flush() {
1465
- await Promise.allSettled(this.channels.map(async (channel) => {
1466
- if (!channel.flush) return;
1901
+ async flush(options = {}) {
1902
+ const timeout = options.timeout ?? 5e3;
1903
+ const draining = new Set(this.channels);
1904
+ for (const channel of this.pendingDeliveries.keys()) draining.add(channel);
1905
+ const unfinished = new Set(draining);
1906
+ const drains = [...draining].map(async (channel) => {
1467
1907
  try {
1468
- await channel.flush();
1469
- } catch {}
1470
- }));
1908
+ await this.drainChannel(channel);
1909
+ } catch {} finally {
1910
+ unfinished.delete(channel);
1911
+ }
1912
+ });
1913
+ const settled = Promise.allSettled(drains).then(() => false);
1914
+ if (!Number.isFinite(timeout)) {
1915
+ await settled;
1916
+ return;
1917
+ }
1918
+ let resolveTimeout;
1919
+ const timeoutReached = new Promise((resolve) => {
1920
+ resolveTimeout = resolve;
1921
+ });
1922
+ const timer = setTimeout(() => resolveTimeout(true), Math.max(0, timeout));
1923
+ timer.unref?.();
1924
+ const timedOut = await Promise.race([settled, timeoutReached]);
1925
+ clearTimeout(timer);
1926
+ if (!timedOut) return;
1927
+ for (const channel of unfinished) this.reportChannelError(/* @__PURE__ */ new Error(`flush timed out after ${timeout}ms; entries still in flight may be lost`), channel, {
1928
+ type: "warn",
1929
+ module: "logger",
1930
+ action: "flush",
1931
+ message: "flush timed out"
1932
+ });
1933
+ }
1934
+ /**
1935
+ * Wait until the channel has no delivery in flight, run its `flush()`, and
1936
+ * repeat while that flush let new deliveries in.
1937
+ */
1938
+ async drainChannel(channel) {
1939
+ do {
1940
+ let pending = this.pendingDeliveries.get(channel);
1941
+ while (pending && pending.size > 0) {
1942
+ await Promise.allSettled([...pending]);
1943
+ pending = this.pendingDeliveries.get(channel);
1944
+ }
1945
+ if (channel.flush) await channel.flush();
1946
+ } while ((this.pendingDeliveries.get(channel)?.size ?? 0) > 0);
1471
1947
  }
1472
1948
  /**
1473
1949
  * Register one process-level handler per event that calls `flushSync()`
@@ -1513,6 +1989,34 @@ var Logger = class {
1513
1989
  }
1514
1990
  };
1515
1991
  /**
1992
+ * Default `timeout` of {@link Logger.flush}: long enough for a healthy network
1993
+ * channel, short enough that a hung one cannot eat a shutdown budget.
1994
+ */
1995
+ const DEFAULT_FLUSH_TIMEOUT_MS = 5e3;
1996
+ /**
1997
+ * What the error handler receives when the logger-wide redaction itself
1998
+ * failed: the entry's identity only, never the unredacted message or context.
1999
+ */
2000
+ function redactionFailedEntry(data) {
2001
+ return {
2002
+ type: data.type,
2003
+ module: data.module,
2004
+ action: data.action,
2005
+ message: "[entry dropped: redaction failed]"
2006
+ };
2007
+ }
2008
+ /**
2009
+ * Default {@link ChannelErrorHandler}: one stderr line per failure. Written
2010
+ * directly to the stream, never through a logger, so a broken channel cannot
2011
+ * recurse.
2012
+ */
2013
+ function reportChannelError(error, channel) {
2014
+ const reason = error instanceof Error ? error.message : String(error);
2015
+ try {
2016
+ process.stderr.write(`[@warlock.js/logger] channel "${channel.name}" failed to log: ${reason}\n`);
2017
+ } catch {}
2018
+ }
2019
+ /**
1516
2020
  * The package singleton. Use this for everyday logging — `log.info(...)`,
1517
2021
  * `log.error(...)`, `log.configure(...)`. Custom logger instances can be
1518
2022
  * created by instantiating `Logger` directly.
@@ -1529,7 +2033,7 @@ var Logger = class {
1529
2033
  const log = new Logger();
1530
2034
 
1531
2035
  //#endregion
1532
- //#region ../logger/src/utils/capture-unhandled-errors.ts
2036
+ //#region ../../packages/logger/src/utils/capture-unhandled-errors.ts
1533
2037
  /**
1534
2038
  * Best-effort budget (ms) for draining async channels (file, Sentry, …) before
1535
2039
  * the forced exit. Bounded so a single stuck channel can't hang the process —
@@ -1579,17 +2083,25 @@ function captureAnyUnhandledRejection(options = {}) {
1579
2083
 
1580
2084
  //#endregion
1581
2085
  exports.ConsoleLog = ConsoleLog;
2086
+ exports.DAILY_FILE_NAME = DAILY_FILE_NAME;
2087
+ exports.DEFAULT_FLUSH_TIMEOUT_MS = DEFAULT_FLUSH_TIMEOUT_MS;
1582
2088
  exports.DEFAULT_REDACT_KEYS = DEFAULT_REDACT_KEYS;
1583
2089
  exports.DEFAULT_REDACT_KEY_SET = DEFAULT_REDACT_KEY_SET;
1584
2090
  exports.FileLog = FileLog;
2091
+ exports.HOURLY_FILE_NAME = HOURLY_FILE_NAME;
1585
2092
  exports.JSONFileLog = JSONFileLog;
1586
2093
  exports.LogChannel = LogChannel;
1587
2094
  exports.Logger = Logger;
2095
+ exports.MASK_EMAILS_MAX_LENGTH = MASK_EMAILS_MAX_LENGTH;
2096
+ exports.ROTATE_FILE_NAME = ROTATE_FILE_NAME;
1588
2097
  exports.SentryLog = SentryLog;
1589
2098
  exports.applyRedact = applyRedact;
1590
2099
  exports.captureAnyUnhandledRejection = captureAnyUnhandledRejection;
1591
2100
  exports.clearMessage = clearMessage;
2101
+ exports.escapeLogValue = escapeLogValue;
1592
2102
  exports.log = log;
2103
+ exports.maskEmail = maskEmail;
2104
+ exports.maskEmailsInText = maskEmailsInText;
1593
2105
  exports.mergeRedact = mergeRedact;
1594
2106
  exports.normalizeRedactKey = normalizeRedactKey;
1595
2107
  exports.resolveRedactKeys = resolveRedactKeys;