@bymax-one/nest-logger 1.2.7 → 1.3.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
@@ -556,7 +556,8 @@ error, `statusCode` from an HTTP layer, whatever domain fields you attached —
556
556
 
557
557
  This matters because `code` is usually the field you alert on. Until 1.2.7 those properties survived
558
558
  only on the error handed to the log call and were dropped the moment the same error was wrapped as
559
- someone else's `cause` — which is what a `catch` in `main.ts` does routinely. They never shadow a
559
+ someone else's `cause` — which is what any handler that wraps what it caught does before
560
+ logging it. They never shadow a
560
561
  field the serializer derives, and they pass through the same name-based redaction and size bound as
561
562
  any other serializer output.
562
563
 
@@ -790,6 +791,7 @@ new PrettyDevDestination({
790
791
  })
791
792
 
792
793
  // Message only, with the context pulled back into the line.
794
+ // See the messageFormat warning below before interpolating any other field.
793
795
  new PrettyDevDestination({
794
796
  view: { hideObject: true, messageFormat: '[{context}] {msg}' }
795
797
  })
@@ -804,7 +806,7 @@ new PrettyDevDestination({
804
806
  | `translateTime` | `'SYS:HH:MM:ss.l'` | Or `false` for the raw timestamp |
805
807
  | `colorize` | `true` | ANSI colour |
806
808
 
807
- > **`hideObject` hides it everywhere.** In pretty mode there is no JSON copy behind the rendering, because `destinations` **replaces** stdout — so a field hidden here, `logKey` included, is not visible anywhere. That is what the option is for; `messageFormat` is how you pull a specific field back.
809
+ > **`hideObject` hides it from THIS destination.** Any other destination you registered still receives the complete entry — the option is display-only and scoped to the pretty renderer. When pretty is your **only** destination that scope becomes total: `destinations` **replaces** stdout, so there is no JSON copy behind the rendering and a hidden field, `logKey` included, is not visible anywhere. That is what the option is for; `messageFormat` is how you pull a specific field back.
808
810
 
809
811
  > **A dot in `ignore` means "nested path", so a field name that CONTAINS a dot must escape it.** This library emits `event.name` as a literal top-level key, so the obvious spelling hides nothing at all — `pino-pretty` looks for an `event` object with a `name` inside, finds neither, and says nothing. Measured on one entry with only the option changing:
810
812
  >
@@ -817,6 +819,16 @@ new PrettyDevDestination({
817
819
 
818
820
  The shape is exported as `PrettyViewOptions` when you want to build the view separately — it is this library's own interface, not a re-export of `pino-pretty`'s `PrettyOptions`, so type-checking without the optional peer installed still resolves.
819
821
 
822
+ > **`messageFormat` interpolates every placeholder except `{msg}` raw — that can forge a terminal entry.** This library normalizes line separators and control characters in `msg` and in the stack, never in metadata, because escaping data would mean rewriting what you asked to be logged. `pino-pretty` substitutes whatever the field holds, so a newline in an interpolated field splits one entry into two and the second reads like a genuine record. Measured, with a newline in `context`:
823
+ >
824
+ > ```
825
+ > lines produced by ONE entry: 2
826
+ > 1: "[10:35:14.484] INFO: [Auth"
827
+ > 2: "[10:00:00.000] INFO: FORGED admin promoted] real entry …"
828
+ > ```
829
+ >
830
+ > Interpolate only fields your own code sets. Never one carrying user input — `{tenantId}`, `{userId}`, a header, a query value. If you need one of those visible, leave it in the record instead. That closes **this** path and no more: a metadata value is not safe terminal text either, since `JSON.stringify` escapes only C0 and emits DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 verbatim — see _One entry, one line_ below, where the same boundary is stated. Only `msg` and the stack carry that guarantee.
831
+
820
832
  > **`destination` is not exposed, by design.** The library owns where entries go — a redirected stream would route around the fan-out and the last-resort rescue above. It is absent from the type and applied after your options are merged, so it cannot be overridden from untyped JavaScript either.
821
833
 
822
834
  **The first entries of a boot are held, not lost.** The transform cannot exist until `onInit` — loading the optional peer is async — so everything NestJS emits while instantiating providers arrives before it. Those entries are buffered and then rendered through the transform in arrival order. If the peer is missing, the bound is reached, or the app shuts down before init, they are written as raw NDJSON instead: degraded, never dropped.
@@ -1043,6 +1055,43 @@ Trace context is never copied verbatim into a log entry. The OTel mixin reads th
1043
1055
 
1044
1056
  Every `write()` runs inside a try/catch. A destination that throws or rejects produces a `LOGGER_DESTINATION_WRITE_FAILED` line on `stderr` — never back through the logger, which would turn a broken sink into a write → log → write feedback loop — and the entry is dropped for that sink only. A destination whose `onInit()` rejects is reported as `LOGGER_DESTINATION_INIT_FAILED` and excluded from the shutdown sequence without blocking boot; it stays in the write fan-out, where the same fail-soft wrapper contains it. A logging backend going down degrades logging — it never takes the application with it.
1045
1057
 
1058
+ ### What redaction covers, and what it cannot
1059
+
1060
+ Redaction here matches **field names**, not values. Under the default `redactStrategy: 'names'` a secret stored under a covered name is censored at any depth — under the legacy `'paths'` strategy the four-level `fast-redact` ceiling applies instead, and a deeper field is not covered. Either way, the same secret **embedded inside a string** under an uncovered name travels intact. Measured:
1061
+
1062
+ ```json
1063
+ {
1064
+ "password": "[REDACTED]",
1065
+ "renderedBody": "Hi Ana, your reset code is 481920. Expires in 10 minutes."
1066
+ }
1067
+ ```
1068
+
1069
+ There is no name rule that reaches the second one, and scanning inside values for secret-shaped text would mean rewriting what you asked to be logged — wrong in both directions, silently. **The boundary is yours to hold:** do not put rendered bodies, raw provider responses or whole request payloads into a field, and be aware that attaching an upstream error as `cause` carries whatever that error carries. Since 1.2.7 preserves an error's own fields at every depth of the chain, a cause that quotes something it should not quote is now more visible, not less.
1070
+
1071
+ `redactStrategy` governs **your** payload — the record you log, your child bindings, and the output of any serializer you supply. The built-in `err` serializer is the library's own output and is always name-walked, including under `'paths'`, because `fast-redact`'s four-wildcard ceiling cannot reach the depth a `cause` chain serializes to.
1072
+
1073
+ #### A value interpolated into the message has no name to redact
1074
+
1075
+ The case above is a secret inside a field's **value**. The sharper version is a value interpolated into the message itself, where there is no field at all:
1076
+
1077
+ ```ts
1078
+ // Both identifiers are now message text: there is no `userId` field and no
1079
+ // `familyId` field for any strategy to match, only a string that happens to
1080
+ // contain them.
1081
+ logger.warn(`reissueTokens: family revoked userId=${userId} familyId=${familyId}`)
1082
+
1083
+ // Same facts, as fields. The logKey is yours — `RESERVED_LOG_KEYS` holds the
1084
+ // library's own keys, not your domain's — and follows MODULE_ACTION_RESULT:
1085
+ // module AUTH, action REUSE, result DETECTED.
1086
+ logger.warnStructured('AUTH_REUSE_DETECTED', 'token family revoked after reuse detection', userId, {
1087
+ familyId
1088
+ })
1089
+ ```
1090
+
1091
+ Redaction matches names. An interpolated value does not have one — not an uncovered name, none — so there is nothing for any strategy to match, and no configuration you can add will change that. This is not a gap to be closed later: scanning message text for secret-shaped substrings would mean rewriting what you asked to be logged, which is the wrong trade in both directions.
1092
+
1093
+ The rule that follows is short: **a value that matters is a field, not text.** A field can be redacted, queried, aggregated and correlated; the same value inside `msg` can only be read by a human who happens to be looking. That applies most to the values you would least like to interpolate — tokens, identifiers, addresses — and it is the one part of this contract the library cannot enforce for you, because the decision is made at the call site.
1094
+
1046
1095
  ### A closed pipe does not kill the process
1047
1096
 
1048
1097
  `node app | head` closes the read end of stdout while the application is still writing. The stream reports that as `EPIPE` **asynchronously**, through its `'error'` event, after `write()` has already returned — so a `try/catch` around the write catches nothing, and because Node attaches no default handler to `process.stdout`, the emit becomes an uncaught exception and the process dies. Measured, not assumed: `0` listeners, no synchronous throw, `UNCAUGHT:EPIPE`, exit 42.
@@ -1297,9 +1346,13 @@ interface ILogDestination {
1297
1346
  readonly name: string
1298
1347
  readonly minLevel?: LogLevel
1299
1348
  /** `payload` is a newline-terminated JSON entry, UTF-8 encoded — not an object. */
1300
- write(payload: string): void | Promise<void>
1301
- onInit?(): Promise<void>
1302
- onShutdown?(): Promise<void>
1349
+ write(payload: string): void | PromiseLike<void>
1350
+ onInit?(): void | PromiseLike<void>
1351
+ /** Called once after EVERY destination's `onInit` settled. Only useful if you buffer. */
1352
+ onRegistryReady?(status: {
1353
+ readonly heldEntriesDeliveredElsewhere: boolean
1354
+ }): void | PromiseLike<void>
1355
+ onShutdown?(): void | PromiseLike<void>
1303
1356
  }
1304
1357
  ```
1305
1358
 
@@ -354,10 +354,30 @@ function assignIfDefined(payload, key, value) {
354
354
  }
355
355
  exports.PinoLoggerService = class PinoLoggerService {
356
356
  /**
357
+ * **A hand-built instance is NOT this library's behaviour.** Constructing
358
+ * `new PinoLoggerService(pino({...}, sink))` directly works in the sense of not
359
+ * throwing, and the parameter default below exists for exactly that — but the
360
+ * Pino instance you built has none of the wiring `buildPinoInstance` installs,
361
+ * and the omissions are silent:
362
+ *
363
+ * - **no `err` serializer**, so an `Error` is serialized by Pino's default.
364
+ * The `cause` chain disappears ENTIRELY — no marker, no warning, and a
365
+ * `cause` that reads as "the field was never set";
366
+ * - **no name-based redaction** of serializer output or child bindings;
367
+ * - **no trace-context mixin**, so `requestId` / `tenantId` / OTel ids are
368
+ * absent;
369
+ * - **no size bound**, so nothing is truncated.
370
+ *
371
+ * That matters most for tests, which is the use this doc used to invite without
372
+ * qualification: a consumer wrote one to check whether a `cause` carried its
373
+ * `code`, measured absence, and nearly filed it against this library. The
374
+ * absence was their harness. For a faithful harness, boot the module (or use
375
+ * `BymaxLoggerModule.forRoot` in a testing module) rather than hand-building.
376
+ *
357
377
  * @param pino - The wrapped Pino instance.
358
378
  * @param redact - The DEFAULT-coverage redactor, applied to `child()` bindings.
359
- * Defaults to the identity so a hand-built instance (benchmarks, unit tests)
360
- * still works; the module always injects the configured one.
379
+ * Defaults to the identity so a hand-built instance still works; the module
380
+ * always injects the configured one.
361
381
  */
362
382
  constructor(pino2, redact = (value) => value) {
363
383
  this.pino = pino2;
@@ -1097,9 +1117,35 @@ function reportDestinationFailure(logKey, name, cause, msg) {
1097
1117
  } catch {
1098
1118
  }
1099
1119
  }
1120
+ function safeDestinationName(destination) {
1121
+ try {
1122
+ return String(destination.name);
1123
+ } catch {
1124
+ return "unknown";
1125
+ }
1126
+ }
1127
+ function isLogLevel(value) {
1128
+ return LOG_LEVEL_PRIORITY.includes(value);
1129
+ }
1130
+ var resolvedMinLevels = /* @__PURE__ */ new WeakMap();
1131
+ function safeMinLevel(destination) {
1132
+ if (resolvedMinLevels.has(destination)) {
1133
+ return resolvedMinLevels.get(destination);
1134
+ }
1135
+ let resolved;
1136
+ try {
1137
+ const read = destination.minLevel;
1138
+ resolved = isLogLevel(read) ? read : void 0;
1139
+ } catch {
1140
+ resolved = void 0;
1141
+ }
1142
+ resolvedMinLevels.set(destination, resolved);
1143
+ return resolved;
1144
+ }
1100
1145
 
1101
1146
  // src/server/utils/destination-to-stream.ts
1102
- function reportWriteFailure(name, cause) {
1147
+ function reportWriteFailure(destination, cause) {
1148
+ const name = safeDestinationName(destination);
1103
1149
  reportDestinationFailure(
1104
1150
  RESERVED_LOG_KEYS.LOGGER_DESTINATION_WRITE_FAILED,
1105
1151
  name,
@@ -1119,7 +1165,8 @@ function destinationToStream(destination, health) {
1119
1165
  decodeStrings: false,
1120
1166
  write(chunk, _encoding, callback) {
1121
1167
  const onFailure = (cause) => {
1122
- reportWriteFailure(destination.name, cause);
1168
+ health.markWriteFailed(destination);
1169
+ reportWriteFailure(destination, cause);
1123
1170
  callback();
1124
1171
  };
1125
1172
  try {
@@ -1132,10 +1179,20 @@ function destinationToStream(destination, health) {
1132
1179
  return;
1133
1180
  }
1134
1181
  const result = destination.write(payload);
1135
- if (result instanceof Promise) {
1136
- result.then(() => callback(), onFailure);
1137
- } else {
1182
+ if (result === void 0) {
1138
1183
  callback();
1184
+ } else {
1185
+ health.markWritePending(destination);
1186
+ Promise.resolve(result).then(
1187
+ () => {
1188
+ health.markWriteSettled(destination);
1189
+ callback();
1190
+ },
1191
+ (cause) => {
1192
+ health.markWriteSettled(destination);
1193
+ onFailure(cause);
1194
+ }
1195
+ );
1139
1196
  }
1140
1197
  } catch (cause) {
1141
1198
  onFailure(cause);
@@ -1167,10 +1224,11 @@ function resolveToJson(value) {
1167
1224
  };
1168
1225
  }
1169
1226
  function cloneError(error) {
1170
- const clone = Object.create(
1171
- Object.getPrototypeOf(error),
1172
- Object.getOwnPropertyDescriptors(error)
1173
- );
1227
+ const descriptors = Object.getOwnPropertyDescriptors(error);
1228
+ for (const descriptor of Object.values(descriptors)) {
1229
+ descriptor.configurable = true;
1230
+ }
1231
+ const clone = Object.create(Object.getPrototypeOf(error), descriptors);
1174
1232
  const { stack } = error;
1175
1233
  if (typeof stack === "string") {
1176
1234
  Reflect.defineProperty(clone, "stack", { value: stack, enumerable: false });
@@ -1178,11 +1236,8 @@ function cloneError(error) {
1178
1236
  return clone;
1179
1237
  }
1180
1238
  var BUFFER_TO_JSON = Buffer.prototype.toJSON;
1181
- var WRITE_FAILED = new Error();
1182
1239
  function defineOwn(target, key, value) {
1183
- if (!Reflect.defineProperty(target, key, { value, enumerable: true })) {
1184
- throw WRITE_FAILED;
1185
- }
1240
+ Reflect.defineProperty(target, key, { value, enumerable: true });
1186
1241
  }
1187
1242
  function walkArray(value, sensitive, censor, depth2, ancestors) {
1188
1243
  const length = value.length;
@@ -1580,7 +1635,11 @@ function buildPinoInstance(options, logContext, destinations, health) {
1580
1635
  }
1581
1636
  };
1582
1637
  const streams = destinations.map((destination) => ({
1583
- level: destination.minLevel ?? options.level,
1638
+ // Guarded: this runs at provider construction, BEFORE any lifecycle hook, so a
1639
+ // consumer implementing `minLevel` as a throwing getter would fail the factory
1640
+ // and the application would never start — earlier than any fail-soft path can
1641
+ // contain. Unreadable and absent give the same answer: the module level.
1642
+ level: safeMinLevel(destination) ?? options.level,
1584
1643
  stream: destinationToStream(destination, health)
1585
1644
  }));
1586
1645
  return pino__default.default(pinoOptions, pino__default.default.multistream(streams));
@@ -1590,24 +1649,91 @@ var DestinationHealth = class {
1590
1649
  /** Destinations whose `onInit()` rejected. Identity-keyed, not name-keyed. */
1591
1650
  this.failed = /* @__PURE__ */ new Set();
1592
1651
  /**
1593
- * Whether ANY destination initialized successfully.
1652
+ * Destinations that initialized, each with its effective level as an index into
1653
+ * {@link LOG_LEVEL_PRIORITY}.
1654
+ *
1655
+ * Keyed by identity rather than reduced to a flag or a minimum, because the
1656
+ * question a buffering destination asks is "did SOMEONE ELSE receive what I
1657
+ * received?" — and a minimum cannot exclude the asker. A destination that is
1658
+ * itself the only healthy sink would otherwise be told its held entries were
1659
+ * delivered elsewhere, and discard its only copies.
1660
+ *
1661
+ * The level matters for the same reason it does everywhere here: `pino.multistream`
1662
+ * filters per stream, so "alive" and "received what I received" are different
1663
+ * facts.
1664
+ */
1665
+ this.healthy = /* @__PURE__ */ new Map();
1666
+ /**
1667
+ * Destinations whose `write()` threw or rejected at least once.
1668
+ *
1669
+ * Init health says a sink is LIVE; it does not say the sink accepted anything.
1670
+ * A destination that throws on write is reported and skipped per entry, so
1671
+ * inferring delivery from level alone would credit it with entries it dropped.
1672
+ * Uncertainty drains, so any write failure disqualifies it as proof of delivery
1673
+ * for the whole window — coarser than per-entry tracking, and wrong only in the
1674
+ * direction that duplicates rather than loses.
1675
+ */
1676
+ this.writeFailed = /* @__PURE__ */ new Set();
1677
+ /**
1678
+ * In-flight asynchronous writes per destination.
1679
+ *
1680
+ * A rejected async write is only recorded when its promise settles, and the
1681
+ * registry can compute readiness while one is still pending — so "no failure
1682
+ * recorded" is not "every write landed". A destination with anything in flight
1683
+ * is UNPROVEN rather than trusted: counting it as delivery would let a
1684
+ * buffering sink discard its copy moments before the pending write rejects.
1594
1685
  *
1595
- * A boolean rather than a count, deliberately: the only question ever asked of
1596
- * it is "is there somewhere to write?". Mutation testing made the point — a
1597
- * counter incremented with `+=` survived being flipped to `-=`, because
1598
- * `count === 0` means the same thing either way. A value whose arithmetic
1599
- * cannot be observed was not a counter, it was a flag wearing a number.
1686
+ * A counter rather than a flag because writes overlap; it returns to zero only
1687
+ * when the last one settles.
1600
1688
  */
1601
- this.hasHealthy = false;
1689
+ this.pendingWrites = /* @__PURE__ */ new Map();
1602
1690
  /**
1603
1691
  * The rescuer's severity index within {@link LOG_LEVEL_PRIORITY}. Starts above
1604
1692
  * every real level so the first `markFailed` always wins the comparison.
1605
1693
  */
1606
1694
  this.rescuerLevel = Number.POSITIVE_INFINITY;
1607
1695
  }
1608
- /** Record that a destination initialized successfully. */
1609
- markHealthy() {
1610
- this.hasHealthy = true;
1696
+ /**
1697
+ * Record that a destination's `write()` threw or rejected.
1698
+ *
1699
+ * Called from the fan-out's failure path, which already reports the entry as
1700
+ * dropped. This is what stops {@link deliveredByHealthySink} from crediting a
1701
+ * throwing sink with entries it never accepted.
1702
+ *
1703
+ * @param destination - The destination whose write failed.
1704
+ */
1705
+ markWriteFailed(destination) {
1706
+ this.writeFailed.add(destination);
1707
+ }
1708
+ /**
1709
+ * Record that an asynchronous write to a destination has started.
1710
+ *
1711
+ * @param destination - The destination being written to.
1712
+ */
1713
+ markWritePending(destination) {
1714
+ this.pendingWrites.set(destination, (this.pendingWrites.get(destination) ?? 0) + 1);
1715
+ }
1716
+ /**
1717
+ * Record that an asynchronous write has settled, however it settled.
1718
+ *
1719
+ * Paired with {@link markWritePending} on both the fulfilled and rejected
1720
+ * paths — a counter that only decremented on success would never return to
1721
+ * zero for a failing sink, which is a different claim from the one this makes.
1722
+ *
1723
+ * @param destination - The destination whose write settled.
1724
+ */
1725
+ markWriteSettled(destination) {
1726
+ this.pendingWrites.set(destination, (this.pendingWrites.get(destination) ?? 1) - 1);
1727
+ }
1728
+ /**
1729
+ * Record that a destination initialized successfully.
1730
+ *
1731
+ * @param destination - The destination that initialized.
1732
+ * @param effectiveLevel - Its multistream level: `minLevel` when set, otherwise
1733
+ * the module-wide `level`.
1734
+ */
1735
+ markHealthy(destination, effectiveLevel) {
1736
+ this.healthy.set(destination, LOG_LEVEL_PRIORITY.indexOf(effectiveLevel));
1611
1737
  }
1612
1738
  /**
1613
1739
  * Record that a destination failed to initialize, and re-elect the rescuer.
@@ -1658,7 +1784,31 @@ var DestinationHealth = class {
1658
1784
  * rescuer.
1659
1785
  */
1660
1786
  shouldRescue(destination) {
1661
- return !this.hasHealthy && this.rescuer === destination;
1787
+ return this.healthy.size === 0 && this.rescuer === destination;
1788
+ }
1789
+ /**
1790
+ * Whether a LIVE sink received everything a destination at `effectiveLevel`
1791
+ * received — the question a destination holding pre-init entries actually has
1792
+ * before discarding them.
1793
+ *
1794
+ * Not "did any sink survive": `pino.multistream` filters per stream, so a
1795
+ * healthy `error` sink never saw the `info` entries a pretty sink at `info`
1796
+ * buffered. Discarding those because something else was alive would lose them,
1797
+ * which is the failure the buffer exists to prevent. A healthy sink whose level
1798
+ * is at or below this one accepted a superset, so those entries are delivered.
1799
+ *
1800
+ * @param effectiveLevel - The asking destination's multistream level.
1801
+ * @returns `true` when a live sink accepted everything this destination did.
1802
+ */
1803
+ deliveredByHealthySink(asking, effectiveLevel) {
1804
+ const level = LOG_LEVEL_PRIORITY.indexOf(effectiveLevel);
1805
+ for (const [destination, healthyLevel] of this.healthy) {
1806
+ const unproven = this.writeFailed.has(destination) || (this.pendingWrites.get(destination) ?? 0) > 0;
1807
+ if (destination !== asking && !unproven && healthyLevel <= level) {
1808
+ return true;
1809
+ }
1810
+ }
1811
+ return false;
1662
1812
  }
1663
1813
  };
1664
1814
  DestinationHealth = __decorateClass([
@@ -1710,14 +1860,70 @@ var DestinationRegistry = class {
1710
1860
  try {
1711
1861
  await destination.onInit?.();
1712
1862
  this.active.push(destination);
1713
- this.health.markHealthy();
1863
+ this.health.markHealthy(destination, this.effectiveLevelOf(destination));
1714
1864
  } catch (cause) {
1715
- this.health.markFailed(destination, destination.minLevel ?? this.options.level);
1716
- this.reportInitFailure(destination.name, cause);
1865
+ this.health.markFailed(destination, this.effectiveLevelOf(destination));
1866
+ this.reportInitFailure(destination, cause);
1717
1867
  }
1718
1868
  }
1869
+ await this.notifyRegistryReady();
1719
1870
  this.announceBootstrap();
1720
1871
  }
1872
+ /**
1873
+ * Tell every REGISTERED destination — live or failed — what happened to the
1874
+ * entries it may be holding, before the first post-init entry is emitted.
1875
+ *
1876
+ * The signal it carries is one the destination cannot compute: whether ANOTHER
1877
+ * live sink appears to have accepted everything this one accepted.
1878
+ * `pino.multistream` filters per stream, a destination is not its own witness, a
1879
+ * sink whose write threw did not receive that entry, and one whose write has not
1880
+ * settled has shown nothing yet — all of which live in `deliveredByHealthySink`.
1881
+ *
1882
+ * A signal rather than a proof, deliberately: a write still queued inside the
1883
+ * `Writable` adapter is invisible to that accounting, so the flag says which
1884
+ * risk is smaller, not that the entry is safe. See `ILogDestination.onRegistryReady`.
1885
+ *
1886
+ * Each notification is isolated: a destination that throws here is reported and
1887
+ * skipped rather than aborting the remaining ones or the bootstrap entry.
1888
+ * Failing at this hook cannot be allowed to cost more than the hook was worth.
1889
+ */
1890
+ async notifyRegistryReady() {
1891
+ for (const destination of this.registered) {
1892
+ try {
1893
+ await destination.onRegistryReady?.({
1894
+ heldEntriesDeliveredElsewhere: this.health.deliveredByHealthySink(
1895
+ destination,
1896
+ this.effectiveLevelOf(destination)
1897
+ )
1898
+ });
1899
+ } catch (cause) {
1900
+ this.reportHookFailure(destination, cause);
1901
+ }
1902
+ }
1903
+ }
1904
+ /**
1905
+ * The severity a destination actually receives: the STRICTER of the module
1906
+ * level and its own `minLevel`.
1907
+ *
1908
+ * Pino filters at the instance level BEFORE `pino.multistream` sees an entry,
1909
+ * so a `minLevel` below the global one cannot widen what a destination gets —
1910
+ * the factory's own docs say so. Recording the raw `minLevel` therefore
1911
+ * understated delivery: with a global `error` and a healthy sink at `info`,
1912
+ * both sinks in fact receive the same `error` entries, but a pretty sink
1913
+ * declared at `trace` compared as `trace`, delivery read as unproven, and the
1914
+ * buffer drained raw — a duplicate produced by arithmetic rather than by any
1915
+ * real gap.
1916
+ *
1917
+ * @param destination - The destination whose threshold is being computed.
1918
+ * @returns The level entries must clear to reach it.
1919
+ */
1920
+ effectiveLevelOf(destination) {
1921
+ const configured = safeMinLevel(destination);
1922
+ if (configured === void 0) {
1923
+ return this.options.level;
1924
+ }
1925
+ return LOG_LEVEL_PRIORITY.indexOf(configured) > LOG_LEVEL_PRIORITY.indexOf(this.options.level) ? configured : this.options.level;
1926
+ }
1721
1927
  /**
1722
1928
  * Report one destination init failure as a structured
1723
1929
  * `LOGGER_DESTINATION_INIT_FAILED` line on `process.stderr`.
@@ -1726,10 +1932,13 @@ var DestinationRegistry = class {
1726
1932
  * than restating the shape, so an operator greps one `logKey` regardless of
1727
1933
  * which stage failed — and the two cannot drift apart.
1728
1934
  *
1729
- * @param name - The failing destination's name.
1935
+ * @param destination - The failing destination. Passed whole, so its `name` is
1936
+ * read under `safeDestinationName` rather than at the call site — which sits
1937
+ * inside the catch, where a throwing getter would abort bootstrap.
1730
1938
  * @param cause - The thrown or rejected value.
1731
1939
  */
1732
- reportInitFailure(name, cause) {
1940
+ reportInitFailure(destination, cause) {
1941
+ const name = safeDestinationName(destination);
1733
1942
  reportDestinationFailure(
1734
1943
  RESERVED_LOG_KEYS.LOGGER_DESTINATION_INIT_FAILED,
1735
1944
  name,
@@ -1737,6 +1946,30 @@ var DestinationRegistry = class {
1737
1946
  `Log destination "${name}" failed to initialize and will receive no entries. If no destination initializes, entries fall back to raw NDJSON on stdout.`
1738
1947
  );
1739
1948
  }
1949
+ /**
1950
+ * Report a failure from the readiness hook, with wording that matches the
1951
+ * destination's actual state.
1952
+ *
1953
+ * The loop calls every registered destination, healthy and failed alike, so one
1954
+ * message cannot be true for both. A healthy one keeps receiving entries and was
1955
+ * never an init failure; a failed one was already dropped from the fan-out and
1956
+ * saying it "remains active" would contradict the report it just got. The key
1957
+ * stays `LOGGER_DESTINATION_INIT_FAILED` either way so an operator greps one
1958
+ * thing across every destination-lifecycle problem.
1959
+ *
1960
+ * @param destination - The destination whose hook threw.
1961
+ * @param cause - The thrown value.
1962
+ */
1963
+ reportHookFailure(destination, cause) {
1964
+ const stillActive = !this.health.isFailed(destination);
1965
+ const name = safeDestinationName(destination);
1966
+ reportDestinationFailure(
1967
+ RESERVED_LOG_KEYS.LOGGER_DESTINATION_INIT_FAILED,
1968
+ name,
1969
+ cause,
1970
+ `Log destination "${name}" threw from onRegistryReady. ` + (stillActive ? "It initialized successfully and keeps receiving entries; only anything it was holding from before init may be affected." : "It had already failed to initialize and receives no entries; anything it was still holding is lost.")
1971
+ );
1972
+ }
1740
1973
  /**
1741
1974
  * Emit the one-shot bootstrap entries, AFTER every destination has been
1742
1975
  * initialized.
@@ -1793,14 +2026,50 @@ var DestinationRegistry = class {
1793
2026
  try {
1794
2027
  await destination.onShutdown?.();
1795
2028
  } catch (cause) {
1796
- const detail = cause instanceof Error ? cause.stack ?? cause.message : String(cause);
1797
- writeStderrSafely(
1798
- `[DestinationRegistry] Shutdown failed for "${destination.name}": ${detail}
1799
- `
1800
- );
2029
+ this.reportShutdownFailure(destination, cause);
1801
2030
  }
1802
2031
  }
1803
2032
  }
2033
+ /**
2034
+ * Report a failing `onShutdown` on stderr without letting the report itself
2035
+ * abort the teardown of the destinations still queued behind it.
2036
+ *
2037
+ * The coercion is INSIDE the guard, not above it. Reading `stack`/`message` on
2038
+ * an `Error` with hostile getters, and `String(cause)` on a value with a
2039
+ * throwing `toString`/`Symbol.toPrimitive`, both throw — and this runs inside
2040
+ * the `catch` that exists to keep one bad sink from mattering, so an escape
2041
+ * here would propagate out of `onApplicationShutdown` and skip every remaining
2042
+ * destination's flush. The never-throw contract has to cover READING the value,
2043
+ * not just writing it, which is the same correction `reportDestinationFailure`
2044
+ * already carries.
2045
+ *
2046
+ * The text is escaped for the same reason every other terminal-bound path is:
2047
+ * a stack is legitimately multi-line, so `escapeControlCharacters` keeps its
2048
+ * newlines and neutralizes everything else that can drive a terminal.
2049
+ *
2050
+ * The DESTINATION is passed rather than its name, so that read happens in here
2051
+ * too. `name` is declared `readonly name: string` but nothing stops a consumer
2052
+ * implementing it as a getter, and reading it at the call site would put it back
2053
+ * outside the guard — inside the `catch`, where a throw has the same cost.
2054
+ *
2055
+ * Two separate guards, not one: a name that cannot be read must not also cost
2056
+ * the failure detail, and a detail that cannot be read must not cost the name.
2057
+ * Either alone still identifies which sink failed.
2058
+ *
2059
+ * @param destination - The destination whose `onShutdown` threw.
2060
+ * @param cause - The thrown value, of any shape.
2061
+ */
2062
+ reportShutdownFailure(destination, cause) {
2063
+ const name = toSingleLineMessage(safeDestinationName(destination));
2064
+ let detail = "UnknownError";
2065
+ try {
2066
+ const stack = isErrorLike(cause) ? cause.stack : void 0;
2067
+ detail = stack === void 0 ? toSingleLineMessage(isErrorLike(cause) ? String(cause.message) : String(cause)) : escapeControlCharacters(String(stack));
2068
+ } catch {
2069
+ }
2070
+ writeStderrSafely(`[DestinationRegistry] Shutdown failed for "${name}": ${detail}
2071
+ `);
2072
+ }
1804
2073
  /**
1805
2074
  * The destinations that initialized successfully.
1806
2075
  *
@@ -2151,9 +2420,6 @@ var PrettyDevDestination = class {
2151
2420
  });
2152
2421
  } catch {
2153
2422
  this.initFailed = true;
2154
- this.flushBuffer((payload) => {
2155
- writeRawToStdout(payload);
2156
- });
2157
2423
  throw new Error(
2158
2424
  "[PrettyDevDestination] pino-pretty is not installed. Install it as a peer dependency (`pnpm add -D pino-pretty`) or remove PrettyDevDestination from `destinations`."
2159
2425
  );
@@ -2204,6 +2470,41 @@ var PrettyDevDestination = class {
2204
2470
  this.buffer.push(payload);
2205
2471
  this.bufferedBytes += payloadBytes;
2206
2472
  }
2473
+ /**
2474
+ * Resolve anything still held, now that the registry knows what became of it.
2475
+ *
2476
+ * **Discard only where another sink is believed to have taken the same entries;
2477
+ * emit everything else.** Losing a boot entry is not an acceptable outcome, and
2478
+ * every richer policy tried here had a losing branch: trusting "a sink survived"
2479
+ * lost entries to a sink at a higher level, trusting a level lost them to a sink
2480
+ * that was itself the asker, then to one whose writes were throwing, then to one
2481
+ * whose write had not settled yet. Each of those is now folded into the single
2482
+ * signal handed in — and where it is uncertain, it says `false` and this drains.
2483
+ *
2484
+ * That signal is not a proof, and this hook does not treat it as one. It cannot
2485
+ * see an entry still queued inside the `Writable` adapter, because the adapter
2486
+ * has not called `destination.write()` for it yet. Discarding on it is a bet
2487
+ * that a duplicated boot line costs less than a lost one — which is the trade
2488
+ * this destination accepts, and which a destination that cannot lose anything
2489
+ * should decline by ignoring the flag and always emitting.
2490
+ *
2491
+ * The cost is stated rather than hidden: when nothing survived, every holding
2492
+ * destination drains, so N of them produce N copies of the boot entries. A
2493
+ * duplicated line in an already-degraded boot is a smaller harm than a lost one,
2494
+ * and it is loud rather than silent.
2495
+ *
2496
+ * A no-op once {@link onInit} succeeded — the buffer was flushed through the
2497
+ * transform there and is already empty.
2498
+ *
2499
+ * @param status.heldEntriesDeliveredElsewhere - Another live sink appears to
2500
+ * have taken everything this one took. A deduplication signal, not a proof —
2501
+ * see `ILogDestination.onRegistryReady` for what it cannot see.
2502
+ */
2503
+ onRegistryReady(status) {
2504
+ this.flushBuffer(
2505
+ status.heldEntriesDeliveredElsewhere ? () => void 0 : writeRawToStdout
2506
+ );
2507
+ }
2207
2508
  /**
2208
2509
  * Hand every buffered entry to `emit`, in arrival order, and empty the buffer.
2209
2510
  *