@bymax-one/nest-logger 1.2.2 → 1.2.4

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/CHANGELOG.md CHANGED
@@ -11,6 +11,114 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.4] - 2026-08-14
15
+
16
+ ### Documentation
17
+
18
+ - **The `1.2.3` notes below were amended after release: they were missing a `### Security` section.**
19
+ The terminal-escaping hardening described there shipped **in 1.2.3** — `toSingleLineMessage` is in
20
+ that bundle — but the release notes did not mention it, so a reader auditing the CHANGELOG for
21
+ security-relevant changes would have missed it. Found by a consumer reading the bundle diff rather
22
+ than the notes. The section was added to `[1.2.3]` where it belongs chronologically, and this entry
23
+ exists so anyone who already read those notes learns they changed. No code moved; the amendment is
24
+ the correction.
25
+
26
+ - **An optional peer dependency is NOT absent from a pruned production install.** Two consumers
27
+ independently reasoned that because `pino-pretty` is a `devDependency`, `pnpm prune --prod` strips
28
+ it and `PrettyDevDestination` therefore cannot run in production. That is false under pnpm, and one
29
+ of them disproved it inside a real production image: `prune --prod` removes the **top-level link**
30
+ while the package stays in the store, linked beside this library as the resolved optional peer, so
31
+ the lazy `import('pino-pretty')` inside `onInit` resolves relative to the library's own directory
32
+ and succeeds.
33
+
34
+ The practical consequence is documented in the README and the destinations guideline: do not treat
35
+ "it is a devDependency" as proof that a code path is unreachable in production. A `PrettyDevDestination`
36
+ left registered in a production deployment will not crash and will not warn — it will render ANSI
37
+ colour into a log pipeline that has silently failed to parse every line since the deploy.
38
+
39
+ ### Tests
40
+
41
+ - **The rescue path now asserts the audit signal it was written to protect.** The end-to-end case for
42
+ the total-init-failure fallback names `LOGGER_BOOTSTRAP_WARNING` — the signal that exists so a
43
+ security review can see PII redaction was disabled — as its justification, then asserted
44
+ `LOGGER_BOOTSTRAP_OK` instead. The guarantee was covered transitively (same path, and `warn` clears
45
+ any filter `info` clears), but the test did not pin the promise it cited, so it could have been
46
+ weakened without anyone noticing what was surrendered. A dedicated case now boots with
47
+ `shouldDisableDefaultRedact: true` and asserts the warning survives the rescue, with its flag and
48
+ level. Verified to fail when the rescuer election is moved after `announceBootstrap()`.
49
+
50
+ ## [1.2.3] - 2026-08-14
51
+
52
+ ### Security
53
+
54
+ - **Control characters in a destination failure report are escaped before reaching a terminal.**
55
+ The `LOGGER_DESTINATION_INIT_FAILED` and `LOGGER_DESTINATION_WRITE_FAILED` lines are written
56
+ straight to `process.stderr` and read by a human in a terminal, and `JSON.stringify` escapes C0 and
57
+ nothing else — DEL, the C1 range (U+0085 NEL included), U+2028 and U+2029 survive verbatim, which is
58
+ enough to drive a terminal or forge what looks like a separate log entry. The destination `name` is
59
+ consumer-chosen and the failure text is frequently remote-derived, so both are attacker-influenced
60
+ surfaces. `destination`, `err.type`, `err.message` and `msg` now pass through `toSingleLineMessage`,
61
+ the same per-sink escaping the rest of the library applies.
62
+
63
+ - **A rejected value that throws while being coerced can no longer abort application bootstrap.**
64
+ The failure reporter normalized the thrown value _outside_ its own guard, so `String(cause)` on a
65
+ value with a throwing `toString`/`Symbol.toPrimitive` — or the `name`/`message` reads on an `Error`
66
+ with hostile getters — escaped the reporter. It runs inside the registry catch that exists to keep a
67
+ bad sink from mattering, so the escape aborted boot: the failure handler became a harder failure
68
+ than the one it was handling. Coercion now happens inside the guard.
69
+
70
+ ### Fixed
71
+
72
+ - **A destination that fails to initialize no longer silences the entire application.** Reported
73
+ by a consumer while wiring pretty local output, and reproduced end to end: `destinations`
74
+ **replaces** the default stdout sink, so `destinations: [new PrettyDevDestination()]` without the
75
+ optional `pino-pretty` installed left the app booting, running, exiting `0` and writing **not one
76
+ byte to stdout or stderr** — including the `LOGGER_DESTINATION_INIT_FAILED` entry that exists to
77
+ explain exactly this, which was delivered into the dead sink. Not a crash and not a degraded
78
+ mode: silence that looks like a quiet service.
79
+
80
+ Four independently reasonable behaviours composed into it — `destinations` replacing rather than
81
+ adding, the failure being reported through `this.logger`, the multistream being built from the
82
+ full registered list (it is wired while NestJS builds the DI graph, before any `onInit` runs), and
83
+ a failed sink dropping its writes. Three changes break the chain:
84
+
85
+ - the init failure is written **directly to `process.stderr`**, matching what the shutdown path
86
+ and the write path already do, and for the same reason — reporting a broken sink through the
87
+ sink set is a feedback loop;
88
+ - a destination whose `onInit` rejected is **excluded from the write fan-out**;
89
+ - when **no** destination initializes, entries **fall back to raw NDJSON on stdout**, so a
90
+ misconfiguration degrades visibly instead of going quiet. The fallback is elected before the
91
+ bootstrap entries are announced, so `LOGGER_BOOTSTRAP_WARNING` — the signal that PII redaction
92
+ was disabled — survives the failure it would otherwise have been lost to.
93
+
94
+ The defect belonged to `destinations` as a general contract, not to `PrettyDevDestination`: any
95
+ consumer-written sink that failed `onInit` as the only registered destination did the same thing.
96
+ `pino-pretty` remains an optional peer and nothing was added to `dependencies`, which stays `{}`.
97
+
98
+ ### Breaking
99
+
100
+ - **`isPretty` is removed from `BymaxLoggerModuleOptions`.** It has been `@deprecated` and **inert**
101
+ — a `true` value never changed any output on its own. An option that does nothing is worse than
102
+ an absent one, because it is a knob a consumer turns and then reasons from.
103
+
104
+ **Migration:** delete the option. Nothing about your output changes, because nothing about your
105
+ output depended on it. For pretty local logs, add `new PrettyDevDestination()` to `destinations`
106
+ and install the optional `pino-pretty` peer — which is what the option's own documentation had
107
+ been telling you to do.
108
+
109
+ Type-level only: `applyDefaults` no longer emits the field, and no runtime behaviour is affected.
110
+
111
+ ### Documentation
112
+
113
+ - **`destinations` documents that it REPLACES the default stdout sink**, rather than claiming to add
114
+ "custom destinations beyond `DefaultStdoutDestination`". Replacement is the correct behaviour — a
115
+ file-only or socket-only deployment must be able to turn stdout off — but the doc described the
116
+ opposite, and it is the assumption that makes the failure above easy to arrive at.
117
+ - **`PrettyDevDestination` no longer implies it follows Pino's recommended transport form.** It runs
118
+ `pino-pretty` as a main-thread transform, a documented pino-pretty API; Pino's own docs recommend
119
+ `transport: { target: 'pino-pretty' }` in a worker, which is unreachable here because this library
120
+ owns the Pino instance and fans out through `pino.multistream`.
121
+
14
122
  ## [1.2.2] - 2026-08-13
15
123
 
16
124
  ### Added
@@ -814,7 +922,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
814
922
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
815
923
  `release.yml`, Dependabot, and issue templates
816
924
 
817
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...HEAD
925
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...HEAD
926
+ [1.2.4]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...v1.2.4
927
+ [1.2.3]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...v1.2.3
818
928
  [1.2.2]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...v1.2.2
819
929
  [1.2.1]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...v1.2.1
820
930
  [1.2.0]: https://github.com/bymaxone/nest-logger/compare/v1.1.0...v1.2.0
package/README.md CHANGED
@@ -149,6 +149,17 @@ pnpm add @opentelemetry/api @opentelemetry/sdk-node
149
149
 
150
150
  ```
151
151
 
152
+ > ⚠️ **`-D` does not make it absent in production.** Under pnpm, `pnpm prune --prod` removes the
153
+ > **top-level link** but leaves the package in the store, linked beside this library as the resolved
154
+ > optional peer — and `PrettyDevDestination` imports it lazily, relative to the library's own
155
+ > directory, so it resolves. Verified inside a real pruned production image by a consumer who had
156
+ > assumed the opposite.
157
+ >
158
+ > So "it is a devDependency, therefore this path cannot run in production" is **not** a safe premise.
159
+ > A `PrettyDevDestination` left registered in production will not crash and will not warn: it renders
160
+ > ANSI colour into a log pipeline that has silently failed to parse every line since the deploy. Gate
161
+ > it on an explicit configuration value you can see, not on the packaging.
162
+
152
163
  > [!NOTE]
153
164
  > `@opentelemetry/api` is resolved lazily when the Pino instance is built. When it is absent the trace mixin silently steps aside — the logger never fails to start, and never warns, over an optional peer.
154
165
 
@@ -546,24 +557,23 @@ Full options reference for `BymaxLoggerModule.forRoot(options)`:
546
557
 
547
558
  ### Top-level options
548
559
 
549
- | Option | Type | Default | Description |
550
- | ---------------------------- | ------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
551
- | `service.name` | `string` | **Required** | Service name emitted in every log entry |
552
- | `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
553
- | `service.namespace` | `string` | — | OTel `service.namespace` (**Stable**) — the group the service belongs to |
554
- | `service.instanceId` | `string` | — | OTel `service.instance.id` (**Stable**). **Never generated** — supply it from the platform. See [Resource identity](#6b-resource-identity) |
555
- | `service.environment` | `string` | `NODE_ENV` | OTel `deployment.environment.name` (**Stable**). The deprecated `deployment.environment` is never emitted |
556
- | `resourceFormat` | `'nested' \| 'flat'` | `'nested'` | Shape of the identity fields. `'flat'` emits the dotted attribute names verbatim |
557
- | `eventNameField` | `string \| false` | `'event.name'` | Field carrying the derived event name (`PAYMENT_FAILED` → `payment.failed`). `false` disables it |
558
- | `errorFormat` | `'pino' \| 'semconv' \| 'both'` | `'pino'` | `'both'` adds `exception.*` and `error.type` beside `err.*`; `'semconv'` replaces them |
559
- | `level` | `LogLevel` | `'info'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
560
- | `isPretty` | `boolean` | `!isProduction` | ⚠️ Reserved — not auto-wired. For pretty output add `new PrettyDevDestination()` to `destinations` (needs `pino-pretty`) |
561
- | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths, applied on top of the default coverage |
562
- | `redactStrategy` | `'names' \| 'paths'` | `'names'` | Engine behind the DEFAULT set. `'paths'` restores the pre-1.2 `fast-redact` expansion (four-level ceiling, ~100× slower) |
563
- | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the default PII coverage entirely. ⚠️ Emits `LOGGER_BOOTSTRAP_WARNING` at startup — document why |
564
- | `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
565
- | `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
566
- | `destinations` | `ILogDestination[]` | `[]` | Additional sinks (Loki, Postgres, rolling file, …) alongside default stdout |
560
+ | Option | Type | Default | Description |
561
+ | ---------------------------- | ------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
562
+ | `service.name` | `string` | **Required** | Service name emitted in every log entry |
563
+ | `service.version` | `string` | **Required** | Release version/SHA emitted in every log entry |
564
+ | `service.namespace` | `string` | — | OTel `service.namespace` (**Stable**) — the group the service belongs to |
565
+ | `service.instanceId` | `string` | — | OTel `service.instance.id` (**Stable**). **Never generated** — supply it from the platform. See [Resource identity](#6b-resource-identity) |
566
+ | `service.environment` | `string` | `NODE_ENV` | OTel `deployment.environment.name` (**Stable**). The deprecated `deployment.environment` is never emitted |
567
+ | `resourceFormat` | `'nested' \| 'flat'` | `'nested'` | Shape of the identity fields. `'flat'` emits the dotted attribute names verbatim |
568
+ | `eventNameField` | `string \| false` | `'event.name'` | Field carrying the derived event name (`PAYMENT_FAILED` → `payment.failed`). `false` disables it |
569
+ | `errorFormat` | `'pino' \| 'semconv' \| 'both'` | `'pino'` | `'both'` adds `exception.*` and `error.type` beside `err.*`; `'semconv'` replaces them |
570
+ | `level` | `LogLevel` | `'info'` | Minimum log level. One of `fatal \| error \| warn \| info \| debug \| trace` |
571
+ | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths, applied on top of the default coverage |
572
+ | `redactStrategy` | `'names' \| 'paths'` | `'names'` | Engine behind the DEFAULT set. `'paths'` restores the pre-1.2 `fast-redact` expansion (four-level ceiling, ~100× slower) |
573
+ | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the default PII coverage entirely. ⚠️ Emits `LOGGER_BOOTSTRAP_WARNING` at startup — document why |
574
+ | `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
575
+ | `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
576
+ | `destinations` | `ILogDestination[]` | `[new DefaultStdoutDestination()]` | The sinks entries are written to. ⚠️ A non-empty list **replaces** that default — see [Destinations](#destinations-replace-stdout) |
567
577
 
568
578
  ### `http` options
569
579
 
@@ -713,6 +723,21 @@ BymaxLoggerModule.forRootAsync({
713
723
  })
714
724
  ```
715
725
 
726
+ ### Destinations replace stdout
727
+
728
+ A non-empty `destinations` **replaces** `DefaultStdoutDestination` — it does not add to it. That is deliberate: a file-only or socket-only deployment has to be able to turn structured stdout off. To keep stdout alongside a custom sink, list it explicitly:
729
+
730
+ ```typescript
731
+ destinations: [new DefaultStdoutDestination(), new LokiDestination({ ... })]
732
+ ```
733
+
734
+ The consequence worth knowing: a sink you supply may be the **only** one the application has, so its failure is the application's silence. Two guarantees cover that, and neither requires anything from you:
735
+
736
+ - **A destination that fails `onInit` is reported as `LOGGER_DESTINATION_INIT_FAILED` on `stderr`** — not through the logger, whose sinks are the ones that just failed — and receives no entries.
737
+ - **If _every_ destination fails to initialize, entries fall back to raw NDJSON on `stdout`.** Degraded and ugly, but visible; nothing is lost, including the bootstrap entries.
738
+
739
+ So the common accident — adding `new PrettyDevDestination()` without installing the optional `pino-pretty` — costs you colours and a line on stderr telling you why, never your logs.
740
+
716
741
  ### Postgres destination (Prisma)
717
742
 
718
743
  ```typescript
@@ -76,7 +76,6 @@ function applyDefaults(options) {
76
76
  redactCensor: options.redactCensor ?? "[REDACTED]",
77
77
  shouldDisableDefaultRedact: options.shouldDisableDefaultRedact ?? false,
78
78
  destinations: Object.freeze([...options.destinations ?? []]),
79
- isPretty: options.isPretty ?? !isProduction,
80
79
  http: Object.freeze({
81
80
  ...http,
82
81
  excludePaths: Object.freeze([...http.excludePaths])
@@ -1021,22 +1020,43 @@ function compileRedactPaths(extraPaths, shouldSkipDefaults) {
1021
1020
  const sources = shouldSkipDefaults ? [extraPaths] : [DEFAULT_REDACT_PATHS, extraPaths];
1022
1021
  return Array.from(new Set(sources.flat()));
1023
1022
  }
1024
- function reportWriteFailure(name, cause) {
1025
- const err = cause instanceof Error ? { type: cause.name, message: cause.message } : { type: "UnknownError", message: String(cause) };
1023
+
1024
+ // src/server/utils/report-destination-failure.util.ts
1025
+ function reportDestinationFailure(logKey, name, cause, msg) {
1026
1026
  try {
1027
+ const err = cause instanceof Error ? {
1028
+ type: toSingleLineMessage(String(cause.name)),
1029
+ message: toSingleLineMessage(String(cause.message))
1030
+ } : { type: "UnknownError", message: toSingleLineMessage(String(cause)) };
1027
1031
  process.stderr.write(
1028
1032
  JSON.stringify({
1029
1033
  level: "error",
1030
- logKey: RESERVED_LOG_KEYS.LOGGER_DESTINATION_WRITE_FAILED,
1031
- destination: name,
1034
+ logKey,
1035
+ destination: toSingleLineMessage(name),
1032
1036
  err,
1033
- msg: `Log destination "${name}" failed to write; the entry was dropped`
1037
+ msg: toSingleLineMessage(msg)
1034
1038
  }) + "\n"
1035
1039
  );
1036
1040
  } catch {
1037
1041
  }
1038
1042
  }
1039
- function destinationToStream(destination) {
1043
+
1044
+ // src/server/utils/destination-to-stream.ts
1045
+ function reportWriteFailure(name, cause) {
1046
+ reportDestinationFailure(
1047
+ RESERVED_LOG_KEYS.LOGGER_DESTINATION_WRITE_FAILED,
1048
+ name,
1049
+ cause,
1050
+ `Log destination "${name}" failed to write; the entry was dropped`
1051
+ );
1052
+ }
1053
+ function rescueToStdout(payload) {
1054
+ try {
1055
+ process.stdout.write(payload);
1056
+ } catch {
1057
+ }
1058
+ }
1059
+ function destinationToStream(destination, health) {
1040
1060
  return new stream.Writable({
1041
1061
  // Keep string chunks as strings (Node's default re-encodes them to Buffers
1042
1062
  // before `_write`): the common Pino path emits NDJSON strings, so this skips
@@ -1050,6 +1070,13 @@ function destinationToStream(destination) {
1050
1070
  };
1051
1071
  try {
1052
1072
  const payload = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
1073
+ if (health.isFailed(destination)) {
1074
+ if (health.shouldRescue(destination)) {
1075
+ rescueToStdout(payload);
1076
+ }
1077
+ callback();
1078
+ return;
1079
+ }
1053
1080
  const result = destination.write(payload);
1054
1081
  if (result instanceof Promise) {
1055
1082
  result.then(() => callback(), onFailure);
@@ -1439,7 +1466,7 @@ function resolveRedactOption(options) {
1439
1466
  }
1440
1467
  return { paths, censor: options.redactCensor };
1441
1468
  }
1442
- function buildPinoInstance(options, logContext, destinations) {
1469
+ function buildPinoInstance(options, logContext, destinations, health) {
1443
1470
  const maxBytes = options.maxEntrySizeBytes;
1444
1471
  const redact = resolveNameRedactor(options);
1445
1472
  const bound = (serializer) => createSizeBoundedSerializer((input) => redact(serializer(input)), maxBytes);
@@ -1511,55 +1538,162 @@ function buildPinoInstance(options, logContext, destinations) {
1511
1538
  };
1512
1539
  const streams = destinations.map((destination) => ({
1513
1540
  level: destination.minLevel ?? options.level,
1514
- stream: destinationToStream(destination)
1541
+ stream: destinationToStream(destination, health)
1515
1542
  }));
1516
1543
  return pino__default.default(pinoOptions, pino__default.default.multistream(streams));
1517
1544
  }
1545
+ var DestinationHealth = class {
1546
+ constructor() {
1547
+ /** Destinations whose `onInit()` rejected. Identity-keyed, not name-keyed. */
1548
+ this.failed = /* @__PURE__ */ new Set();
1549
+ /**
1550
+ * Whether ANY destination initialized successfully.
1551
+ *
1552
+ * A boolean rather than a count, deliberately: the only question ever asked of
1553
+ * it is "is there somewhere to write?". Mutation testing made the point — a
1554
+ * counter incremented with `+=` survived being flipped to `-=`, because
1555
+ * `count === 0` means the same thing either way. A value whose arithmetic
1556
+ * cannot be observed was not a counter, it was a flag wearing a number.
1557
+ */
1558
+ this.hasHealthy = false;
1559
+ /**
1560
+ * The rescuer's severity index within {@link LOG_LEVEL_PRIORITY}. Starts above
1561
+ * every real level so the first `markFailed` always wins the comparison.
1562
+ */
1563
+ this.rescuerLevel = Number.POSITIVE_INFINITY;
1564
+ }
1565
+ /** Record that a destination initialized successfully. */
1566
+ markHealthy() {
1567
+ this.hasHealthy = true;
1568
+ }
1569
+ /**
1570
+ * Record that a destination failed to initialize, and re-elect the rescuer.
1571
+ *
1572
+ * The rescuer is the failed destination with the LOWEST effective level, ties
1573
+ * broken by registration order (the comparison is strict). Registration order
1574
+ * alone is deterministic but arbitrary: a `warn` sink registered before an
1575
+ * `info` one would rescue at `warn` and silently drop the `info` entries the
1576
+ * second was configured to receive, and swapping the array would change the
1577
+ * output for the same configuration. Electing by level rescues the UNION of
1578
+ * what the consumer asked to see.
1579
+ *
1580
+ * Inheriting a level filter at all is deliberate — `minLevel` is the consumer's
1581
+ * own instruction, so the rescue delivers what a working sink would have, never
1582
+ * more. The one entry that must never be filtered, the init failure itself, goes
1583
+ * straight to stderr, outside the multistream.
1584
+ *
1585
+ * @param destination - The destination whose `onInit()` rejected.
1586
+ * @param effectiveLevel - Its multistream level: `minLevel` when set, otherwise
1587
+ * the module-wide `level`.
1588
+ */
1589
+ markFailed(destination, effectiveLevel) {
1590
+ this.failed.add(destination);
1591
+ const level = LOG_LEVEL_PRIORITY.indexOf(effectiveLevel);
1592
+ if (level < this.rescuerLevel) {
1593
+ this.rescuer = destination;
1594
+ this.rescuerLevel = level;
1595
+ }
1596
+ }
1597
+ /**
1598
+ * Whether a destination failed to initialize and must not receive writes.
1599
+ *
1600
+ * @param destination - The destination backing a fan-out stream.
1601
+ * @returns `true` when its `onInit()` rejected.
1602
+ */
1603
+ isFailed(destination) {
1604
+ return this.failed.has(destination);
1605
+ }
1606
+ /**
1607
+ * Whether this destination is the one that must write the entry as raw NDJSON
1608
+ * to stdout because NOTHING initialized.
1609
+ *
1610
+ * Exactly one failed destination can satisfy this, so N failed sinks rescue an
1611
+ * entry once rather than N times.
1612
+ *
1613
+ * @param destination - The destination backing a fan-out stream.
1614
+ * @returns `true` when no destination initialized and this one is the elected
1615
+ * rescuer.
1616
+ */
1617
+ shouldRescue(destination) {
1618
+ return !this.hasHealthy && this.rescuer === destination;
1619
+ }
1620
+ };
1621
+ DestinationHealth = __decorateClass([
1622
+ common.Injectable()
1623
+ ], DestinationHealth);
1518
1624
  var DestinationRegistry = class {
1519
1625
  /**
1520
1626
  * @param registered - Every destination declared under
1521
1627
  * `LOGGER_DESTINATIONS_TOKEN`. Injected by token explicitly because the
1522
1628
  * package is bundled without `emitDecoratorMetadata`.
1523
- * @param logger - Structured logger used to report init failures. Injected by
1524
- * token explicitly for the same bundling reason.
1629
+ * @param logger - Structured logger used for the bootstrap entries. Injected by
1630
+ * token explicitly for the same bundling reason. Deliberately NOT used to
1631
+ * report init failures — see the file-level note.
1632
+ * @param options - Resolved module options, read for the bootstrap warnings and
1633
+ * for each destination's effective level.
1634
+ * @param health - Init-health record shared with the write fan-out.
1525
1635
  */
1526
- constructor(registered, logger, options) {
1636
+ constructor(registered, logger, options, health) {
1527
1637
  this.registered = registered;
1528
1638
  this.logger = logger;
1529
1639
  this.options = options;
1640
+ this.health = health;
1530
1641
  /**
1531
1642
  * Destinations that initialized successfully, in registration order.
1532
1643
  *
1533
- * Note: the Pino multistream is built from the full registered list, and the
1534
- * write path is independently fail-soft (see `destinationToStream`). This set
1535
- * is the authoritative record of init success — it drives reverse-order
1536
- * shutdown — but is not currently used to remove a failed-init sink from the
1537
- * write fan-out; a sink that failed `onInit` still receives writes, contained
1538
- * fail-soft by the wrapper.
1644
+ * Authoritative for BOTH shutdown (reverse order) and — via the shared
1645
+ * `DestinationHealth` record written alongside it — the write fan-out. The two
1646
+ * are kept in step here because the multistream itself is wired before any
1647
+ * `onInit` runs and cannot be rebuilt afterwards; see `DestinationHealth`.
1539
1648
  */
1540
1649
  this.active = [];
1541
1650
  }
1542
1651
  /**
1543
1652
  * Initialize every registered destination. A destination whose `onInit`
1544
- * rejects is skipped (not added to the active set) and reported — it must not
1545
- * block application bootstrap.
1653
+ * rejects is skipped (not added to the active set), marked failed so the
1654
+ * fan-out stops writing to it, and reported to stderr — it must not block
1655
+ * application bootstrap.
1656
+ *
1657
+ * The loop completes BEFORE {@link announceBootstrap}, and that ordering is
1658
+ * load-bearing rather than incidental: the health record is what lets a
1659
+ * last-resort sink carry the bootstrap entries when every destination failed.
1660
+ * Announcing first would emit them into a fan-out that has not yet been told
1661
+ * anything is wrong, and `LOGGER_BOOTSTRAP_WARNING` — the signal that exists so
1662
+ * a security review can see PII redaction was disabled — would be lost exactly
1663
+ * when the configuration is already known to be broken.
1546
1664
  */
1547
1665
  async onModuleInit() {
1548
1666
  for (const destination of this.registered) {
1549
1667
  try {
1550
1668
  await destination.onInit?.();
1551
1669
  this.active.push(destination);
1670
+ this.health.markHealthy();
1552
1671
  } catch (cause) {
1553
- this.logger.errorStructured(
1554
- RESERVED_LOG_KEYS.LOGGER_DESTINATION_INIT_FAILED,
1555
- cause instanceof Error ? cause : new Error(String(cause)),
1556
- void 0,
1557
- { destination: destination.name }
1558
- );
1672
+ this.health.markFailed(destination, destination.minLevel ?? this.options.level);
1673
+ this.reportInitFailure(destination.name, cause);
1559
1674
  }
1560
1675
  }
1561
1676
  this.announceBootstrap();
1562
1677
  }
1678
+ /**
1679
+ * Report one destination init failure as a structured
1680
+ * `LOGGER_DESTINATION_INIT_FAILED` line on `process.stderr`.
1681
+ *
1682
+ * Shares {@link reportDestinationFailure} with the write-failure path rather
1683
+ * than restating the shape, so an operator greps one `logKey` regardless of
1684
+ * which stage failed — and the two cannot drift apart.
1685
+ *
1686
+ * @param name - The failing destination's name.
1687
+ * @param cause - The thrown or rejected value.
1688
+ */
1689
+ reportInitFailure(name, cause) {
1690
+ reportDestinationFailure(
1691
+ RESERVED_LOG_KEYS.LOGGER_DESTINATION_INIT_FAILED,
1692
+ name,
1693
+ cause,
1694
+ `Log destination "${name}" failed to initialize and will receive no entries. If no destination initializes, entries fall back to raw NDJSON on stdout.`
1695
+ );
1696
+ }
1563
1697
  /**
1564
1698
  * Emit the one-shot bootstrap entries, AFTER every destination has been
1565
1699
  * initialized.
@@ -1637,7 +1771,8 @@ DestinationRegistry = __decorateClass([
1637
1771
  common.Injectable(),
1638
1772
  __decorateParam(0, common.Inject(LOGGER_DESTINATIONS_TOKEN)),
1639
1773
  __decorateParam(1, common.Inject(exports.PinoLoggerService)),
1640
- __decorateParam(2, common.Inject(LOGGER_OPTIONS_TOKEN))
1774
+ __decorateParam(2, common.Inject(LOGGER_OPTIONS_TOKEN)),
1775
+ __decorateParam(3, common.Inject(DestinationHealth))
1641
1776
  ], DestinationRegistry);
1642
1777
  var FORBIDDEN_CONTEXT_KEYS = PROTOTYPE_POLLUTING_KEYS;
1643
1778
  exports.LogContextService = class LogContextService {
@@ -1732,6 +1867,10 @@ function buildCommonProviders() {
1732
1867
  return [
1733
1868
  exports.LogContextService,
1734
1869
  { provide: LOG_CONTEXT_TOKEN, useExisting: exports.LogContextService },
1870
+ // Shared by the Pino factory (which closes over it in every fan-out stream)
1871
+ // and by DestinationRegistry (which writes to it during onModuleInit). Both
1872
+ // depend on this provider and it depends on nothing, so there is no cycle.
1873
+ DestinationHealth,
1735
1874
  {
1736
1875
  // The DEFAULT-coverage redactor, shared by the Pino factory's hooks and by
1737
1876
  // `PinoLoggerService.child()`. The latter needs it as a provider because
@@ -1742,8 +1881,13 @@ function buildCommonProviders() {
1742
1881
  },
1743
1882
  {
1744
1883
  provide: LOGGER_PINO_INSTANCE_TOKEN,
1745
- useFactory: (options, logContext, destinations) => buildPinoInstance(options, logContext, destinations),
1746
- inject: [LOGGER_OPTIONS_TOKEN, exports.LogContextService, LOGGER_DESTINATIONS_TOKEN]
1884
+ useFactory: (options, logContext, destinations, health) => buildPinoInstance(options, logContext, destinations, health),
1885
+ inject: [
1886
+ LOGGER_OPTIONS_TOKEN,
1887
+ exports.LogContextService,
1888
+ LOGGER_DESTINATIONS_TOKEN,
1889
+ DestinationHealth
1890
+ ]
1747
1891
  },
1748
1892
  {
1749
1893
  provide: LOGGER_DESTINATIONS_TOKEN,
@@ -1934,8 +2078,10 @@ var PrettyDevDestination = class {
1934
2078
  *
1935
2079
  * Three states:
1936
2080
  * - initialized → forward to the pino-pretty transform;
1937
- * - init failed → DROP (the destination was removed from the active set, so
1938
- * it must not duplicate raw NDJSON to stdout);
2081
+ * - init failed → DROP, so a co-registered stdout sink is not duplicated. The
2082
+ * fan-out already skips a failed destination before reaching this method
2083
+ * (and rescues the entry when nothing else initialized); this branch is the
2084
+ * belt to that, for any caller holding the destination directly;
1939
2085
  * - pre-init (transient window before {@link onInit}, e.g. a bootstrap log)
1940
2086
  * → fall back to raw stdout so the entry is not lost.
1941
2087
  *
@@ -334,15 +334,22 @@ interface BymaxLoggerModuleOptions {
334
334
  redactCensor?: string;
335
335
  /** Disable default redact paths (use with caution). Default: false. */
336
336
  shouldDisableDefaultRedact?: boolean;
337
- /** Custom destinations beyond `DefaultStdoutDestination`. */
338
- destinations?: readonly ILogDestination[];
339
337
  /**
340
- * @deprecated Currently inert — a `true` value does not change output on its
341
- * own. For pretty local logs add `new PrettyDevDestination()` to
342
- * `destinations` (and install the optional `pino-pretty` peer dep).
343
- * Default: `NODE_ENV !== 'production'`.
338
+ * The sinks every entry is written to.
339
+ *
340
+ * A non-empty list **REPLACES** `DefaultStdoutDestination` — it does not add to
341
+ * it. That is deliberate: a file-only or socket-only deployment has to be able
342
+ * to turn stdout off. It also means a sink supplied here may be the only one
343
+ * the application has, so a destination that fails `onInit` is reported on
344
+ * stderr and, if nothing else initialized, entries fall back to raw NDJSON on
345
+ * stdout rather than disappearing.
346
+ *
347
+ * Include `new DefaultStdoutDestination()` explicitly to keep structured stdout
348
+ * alongside a custom sink.
349
+ *
350
+ * Default: `[new DefaultStdoutDestination()]`.
344
351
  */
345
- isPretty?: boolean;
352
+ destinations?: readonly ILogDestination[];
346
353
  /** HTTP module configuration. */
347
354
  http?: HttpOptions;
348
355
  /** OpenTelemetry integration tuning. */
@@ -910,8 +917,10 @@ declare class PrettyDevDestination implements ILogDestination {
910
917
  *
911
918
  * Three states:
912
919
  * - initialized → forward to the pino-pretty transform;
913
- * - init failed → DROP (the destination was removed from the active set, so
914
- * it must not duplicate raw NDJSON to stdout);
920
+ * - init failed → DROP, so a co-registered stdout sink is not duplicated. The
921
+ * fan-out already skips a failed destination before reaching this method
922
+ * (and rescues the entry when nothing else initialized); this branch is the
923
+ * belt to that, for any caller holding the destination directly;
915
924
  * - pre-init (transient window before {@link onInit}, e.g. a bootstrap log)
916
925
  * → fall back to raw stdout so the entry is not lost.
917
926
  *
@@ -334,15 +334,22 @@ interface BymaxLoggerModuleOptions {
334
334
  redactCensor?: string;
335
335
  /** Disable default redact paths (use with caution). Default: false. */
336
336
  shouldDisableDefaultRedact?: boolean;
337
- /** Custom destinations beyond `DefaultStdoutDestination`. */
338
- destinations?: readonly ILogDestination[];
339
337
  /**
340
- * @deprecated Currently inert — a `true` value does not change output on its
341
- * own. For pretty local logs add `new PrettyDevDestination()` to
342
- * `destinations` (and install the optional `pino-pretty` peer dep).
343
- * Default: `NODE_ENV !== 'production'`.
338
+ * The sinks every entry is written to.
339
+ *
340
+ * A non-empty list **REPLACES** `DefaultStdoutDestination` — it does not add to
341
+ * it. That is deliberate: a file-only or socket-only deployment has to be able
342
+ * to turn stdout off. It also means a sink supplied here may be the only one
343
+ * the application has, so a destination that fails `onInit` is reported on
344
+ * stderr and, if nothing else initialized, entries fall back to raw NDJSON on
345
+ * stdout rather than disappearing.
346
+ *
347
+ * Include `new DefaultStdoutDestination()` explicitly to keep structured stdout
348
+ * alongside a custom sink.
349
+ *
350
+ * Default: `[new DefaultStdoutDestination()]`.
344
351
  */
345
- isPretty?: boolean;
352
+ destinations?: readonly ILogDestination[];
346
353
  /** HTTP module configuration. */
347
354
  http?: HttpOptions;
348
355
  /** OpenTelemetry integration tuning. */
@@ -910,8 +917,10 @@ declare class PrettyDevDestination implements ILogDestination {
910
917
  *
911
918
  * Three states:
912
919
  * - initialized → forward to the pino-pretty transform;
913
- * - init failed → DROP (the destination was removed from the active set, so
914
- * it must not duplicate raw NDJSON to stdout);
920
+ * - init failed → DROP, so a co-registered stdout sink is not duplicated. The
921
+ * fan-out already skips a failed destination before reaching this method
922
+ * (and rescues the entry when nothing else initialized); this branch is the
923
+ * belt to that, for any caller holding the destination directly;
915
924
  * - pre-init (transient window before {@link onInit}, e.g. a bootstrap log)
916
925
  * → fall back to raw stdout so the entry is not lost.
917
926
  *
@@ -73,7 +73,6 @@ function applyDefaults(options) {
73
73
  redactCensor: options.redactCensor ?? "[REDACTED]",
74
74
  shouldDisableDefaultRedact: options.shouldDisableDefaultRedact ?? false,
75
75
  destinations: Object.freeze([...options.destinations ?? []]),
76
- isPretty: options.isPretty ?? !isProduction,
77
76
  http: Object.freeze({
78
77
  ...http,
79
78
  excludePaths: Object.freeze([...http.excludePaths])
@@ -1018,22 +1017,43 @@ function compileRedactPaths(extraPaths, shouldSkipDefaults) {
1018
1017
  const sources = shouldSkipDefaults ? [extraPaths] : [DEFAULT_REDACT_PATHS, extraPaths];
1019
1018
  return Array.from(new Set(sources.flat()));
1020
1019
  }
1021
- function reportWriteFailure(name, cause) {
1022
- const err = cause instanceof Error ? { type: cause.name, message: cause.message } : { type: "UnknownError", message: String(cause) };
1020
+
1021
+ // src/server/utils/report-destination-failure.util.ts
1022
+ function reportDestinationFailure(logKey, name, cause, msg) {
1023
1023
  try {
1024
+ const err = cause instanceof Error ? {
1025
+ type: toSingleLineMessage(String(cause.name)),
1026
+ message: toSingleLineMessage(String(cause.message))
1027
+ } : { type: "UnknownError", message: toSingleLineMessage(String(cause)) };
1024
1028
  process.stderr.write(
1025
1029
  JSON.stringify({
1026
1030
  level: "error",
1027
- logKey: RESERVED_LOG_KEYS.LOGGER_DESTINATION_WRITE_FAILED,
1028
- destination: name,
1031
+ logKey,
1032
+ destination: toSingleLineMessage(name),
1029
1033
  err,
1030
- msg: `Log destination "${name}" failed to write; the entry was dropped`
1034
+ msg: toSingleLineMessage(msg)
1031
1035
  }) + "\n"
1032
1036
  );
1033
1037
  } catch {
1034
1038
  }
1035
1039
  }
1036
- function destinationToStream(destination) {
1040
+
1041
+ // src/server/utils/destination-to-stream.ts
1042
+ function reportWriteFailure(name, cause) {
1043
+ reportDestinationFailure(
1044
+ RESERVED_LOG_KEYS.LOGGER_DESTINATION_WRITE_FAILED,
1045
+ name,
1046
+ cause,
1047
+ `Log destination "${name}" failed to write; the entry was dropped`
1048
+ );
1049
+ }
1050
+ function rescueToStdout(payload) {
1051
+ try {
1052
+ process.stdout.write(payload);
1053
+ } catch {
1054
+ }
1055
+ }
1056
+ function destinationToStream(destination, health) {
1037
1057
  return new Writable({
1038
1058
  // Keep string chunks as strings (Node's default re-encodes them to Buffers
1039
1059
  // before `_write`): the common Pino path emits NDJSON strings, so this skips
@@ -1047,6 +1067,13 @@ function destinationToStream(destination) {
1047
1067
  };
1048
1068
  try {
1049
1069
  const payload = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
1070
+ if (health.isFailed(destination)) {
1071
+ if (health.shouldRescue(destination)) {
1072
+ rescueToStdout(payload);
1073
+ }
1074
+ callback();
1075
+ return;
1076
+ }
1050
1077
  const result = destination.write(payload);
1051
1078
  if (result instanceof Promise) {
1052
1079
  result.then(() => callback(), onFailure);
@@ -1436,7 +1463,7 @@ function resolveRedactOption(options) {
1436
1463
  }
1437
1464
  return { paths, censor: options.redactCensor };
1438
1465
  }
1439
- function buildPinoInstance(options, logContext, destinations) {
1466
+ function buildPinoInstance(options, logContext, destinations, health) {
1440
1467
  const maxBytes = options.maxEntrySizeBytes;
1441
1468
  const redact = resolveNameRedactor(options);
1442
1469
  const bound = (serializer) => createSizeBoundedSerializer((input) => redact(serializer(input)), maxBytes);
@@ -1508,55 +1535,162 @@ function buildPinoInstance(options, logContext, destinations) {
1508
1535
  };
1509
1536
  const streams = destinations.map((destination) => ({
1510
1537
  level: destination.minLevel ?? options.level,
1511
- stream: destinationToStream(destination)
1538
+ stream: destinationToStream(destination, health)
1512
1539
  }));
1513
1540
  return pino(pinoOptions, pino.multistream(streams));
1514
1541
  }
1542
+ var DestinationHealth = class {
1543
+ constructor() {
1544
+ /** Destinations whose `onInit()` rejected. Identity-keyed, not name-keyed. */
1545
+ this.failed = /* @__PURE__ */ new Set();
1546
+ /**
1547
+ * Whether ANY destination initialized successfully.
1548
+ *
1549
+ * A boolean rather than a count, deliberately: the only question ever asked of
1550
+ * it is "is there somewhere to write?". Mutation testing made the point — a
1551
+ * counter incremented with `+=` survived being flipped to `-=`, because
1552
+ * `count === 0` means the same thing either way. A value whose arithmetic
1553
+ * cannot be observed was not a counter, it was a flag wearing a number.
1554
+ */
1555
+ this.hasHealthy = false;
1556
+ /**
1557
+ * The rescuer's severity index within {@link LOG_LEVEL_PRIORITY}. Starts above
1558
+ * every real level so the first `markFailed` always wins the comparison.
1559
+ */
1560
+ this.rescuerLevel = Number.POSITIVE_INFINITY;
1561
+ }
1562
+ /** Record that a destination initialized successfully. */
1563
+ markHealthy() {
1564
+ this.hasHealthy = true;
1565
+ }
1566
+ /**
1567
+ * Record that a destination failed to initialize, and re-elect the rescuer.
1568
+ *
1569
+ * The rescuer is the failed destination with the LOWEST effective level, ties
1570
+ * broken by registration order (the comparison is strict). Registration order
1571
+ * alone is deterministic but arbitrary: a `warn` sink registered before an
1572
+ * `info` one would rescue at `warn` and silently drop the `info` entries the
1573
+ * second was configured to receive, and swapping the array would change the
1574
+ * output for the same configuration. Electing by level rescues the UNION of
1575
+ * what the consumer asked to see.
1576
+ *
1577
+ * Inheriting a level filter at all is deliberate — `minLevel` is the consumer's
1578
+ * own instruction, so the rescue delivers what a working sink would have, never
1579
+ * more. The one entry that must never be filtered, the init failure itself, goes
1580
+ * straight to stderr, outside the multistream.
1581
+ *
1582
+ * @param destination - The destination whose `onInit()` rejected.
1583
+ * @param effectiveLevel - Its multistream level: `minLevel` when set, otherwise
1584
+ * the module-wide `level`.
1585
+ */
1586
+ markFailed(destination, effectiveLevel) {
1587
+ this.failed.add(destination);
1588
+ const level = LOG_LEVEL_PRIORITY.indexOf(effectiveLevel);
1589
+ if (level < this.rescuerLevel) {
1590
+ this.rescuer = destination;
1591
+ this.rescuerLevel = level;
1592
+ }
1593
+ }
1594
+ /**
1595
+ * Whether a destination failed to initialize and must not receive writes.
1596
+ *
1597
+ * @param destination - The destination backing a fan-out stream.
1598
+ * @returns `true` when its `onInit()` rejected.
1599
+ */
1600
+ isFailed(destination) {
1601
+ return this.failed.has(destination);
1602
+ }
1603
+ /**
1604
+ * Whether this destination is the one that must write the entry as raw NDJSON
1605
+ * to stdout because NOTHING initialized.
1606
+ *
1607
+ * Exactly one failed destination can satisfy this, so N failed sinks rescue an
1608
+ * entry once rather than N times.
1609
+ *
1610
+ * @param destination - The destination backing a fan-out stream.
1611
+ * @returns `true` when no destination initialized and this one is the elected
1612
+ * rescuer.
1613
+ */
1614
+ shouldRescue(destination) {
1615
+ return !this.hasHealthy && this.rescuer === destination;
1616
+ }
1617
+ };
1618
+ DestinationHealth = __decorateClass([
1619
+ Injectable()
1620
+ ], DestinationHealth);
1515
1621
  var DestinationRegistry = class {
1516
1622
  /**
1517
1623
  * @param registered - Every destination declared under
1518
1624
  * `LOGGER_DESTINATIONS_TOKEN`. Injected by token explicitly because the
1519
1625
  * package is bundled without `emitDecoratorMetadata`.
1520
- * @param logger - Structured logger used to report init failures. Injected by
1521
- * token explicitly for the same bundling reason.
1626
+ * @param logger - Structured logger used for the bootstrap entries. Injected by
1627
+ * token explicitly for the same bundling reason. Deliberately NOT used to
1628
+ * report init failures — see the file-level note.
1629
+ * @param options - Resolved module options, read for the bootstrap warnings and
1630
+ * for each destination's effective level.
1631
+ * @param health - Init-health record shared with the write fan-out.
1522
1632
  */
1523
- constructor(registered, logger, options) {
1633
+ constructor(registered, logger, options, health) {
1524
1634
  this.registered = registered;
1525
1635
  this.logger = logger;
1526
1636
  this.options = options;
1637
+ this.health = health;
1527
1638
  /**
1528
1639
  * Destinations that initialized successfully, in registration order.
1529
1640
  *
1530
- * Note: the Pino multistream is built from the full registered list, and the
1531
- * write path is independently fail-soft (see `destinationToStream`). This set
1532
- * is the authoritative record of init success — it drives reverse-order
1533
- * shutdown — but is not currently used to remove a failed-init sink from the
1534
- * write fan-out; a sink that failed `onInit` still receives writes, contained
1535
- * fail-soft by the wrapper.
1641
+ * Authoritative for BOTH shutdown (reverse order) and — via the shared
1642
+ * `DestinationHealth` record written alongside it — the write fan-out. The two
1643
+ * are kept in step here because the multistream itself is wired before any
1644
+ * `onInit` runs and cannot be rebuilt afterwards; see `DestinationHealth`.
1536
1645
  */
1537
1646
  this.active = [];
1538
1647
  }
1539
1648
  /**
1540
1649
  * Initialize every registered destination. A destination whose `onInit`
1541
- * rejects is skipped (not added to the active set) and reported — it must not
1542
- * block application bootstrap.
1650
+ * rejects is skipped (not added to the active set), marked failed so the
1651
+ * fan-out stops writing to it, and reported to stderr — it must not block
1652
+ * application bootstrap.
1653
+ *
1654
+ * The loop completes BEFORE {@link announceBootstrap}, and that ordering is
1655
+ * load-bearing rather than incidental: the health record is what lets a
1656
+ * last-resort sink carry the bootstrap entries when every destination failed.
1657
+ * Announcing first would emit them into a fan-out that has not yet been told
1658
+ * anything is wrong, and `LOGGER_BOOTSTRAP_WARNING` — the signal that exists so
1659
+ * a security review can see PII redaction was disabled — would be lost exactly
1660
+ * when the configuration is already known to be broken.
1543
1661
  */
1544
1662
  async onModuleInit() {
1545
1663
  for (const destination of this.registered) {
1546
1664
  try {
1547
1665
  await destination.onInit?.();
1548
1666
  this.active.push(destination);
1667
+ this.health.markHealthy();
1549
1668
  } catch (cause) {
1550
- this.logger.errorStructured(
1551
- RESERVED_LOG_KEYS.LOGGER_DESTINATION_INIT_FAILED,
1552
- cause instanceof Error ? cause : new Error(String(cause)),
1553
- void 0,
1554
- { destination: destination.name }
1555
- );
1669
+ this.health.markFailed(destination, destination.minLevel ?? this.options.level);
1670
+ this.reportInitFailure(destination.name, cause);
1556
1671
  }
1557
1672
  }
1558
1673
  this.announceBootstrap();
1559
1674
  }
1675
+ /**
1676
+ * Report one destination init failure as a structured
1677
+ * `LOGGER_DESTINATION_INIT_FAILED` line on `process.stderr`.
1678
+ *
1679
+ * Shares {@link reportDestinationFailure} with the write-failure path rather
1680
+ * than restating the shape, so an operator greps one `logKey` regardless of
1681
+ * which stage failed — and the two cannot drift apart.
1682
+ *
1683
+ * @param name - The failing destination's name.
1684
+ * @param cause - The thrown or rejected value.
1685
+ */
1686
+ reportInitFailure(name, cause) {
1687
+ reportDestinationFailure(
1688
+ RESERVED_LOG_KEYS.LOGGER_DESTINATION_INIT_FAILED,
1689
+ name,
1690
+ cause,
1691
+ `Log destination "${name}" failed to initialize and will receive no entries. If no destination initializes, entries fall back to raw NDJSON on stdout.`
1692
+ );
1693
+ }
1560
1694
  /**
1561
1695
  * Emit the one-shot bootstrap entries, AFTER every destination has been
1562
1696
  * initialized.
@@ -1634,7 +1768,8 @@ DestinationRegistry = __decorateClass([
1634
1768
  Injectable(),
1635
1769
  __decorateParam(0, Inject(LOGGER_DESTINATIONS_TOKEN)),
1636
1770
  __decorateParam(1, Inject(PinoLoggerService)),
1637
- __decorateParam(2, Inject(LOGGER_OPTIONS_TOKEN))
1771
+ __decorateParam(2, Inject(LOGGER_OPTIONS_TOKEN)),
1772
+ __decorateParam(3, Inject(DestinationHealth))
1638
1773
  ], DestinationRegistry);
1639
1774
  var FORBIDDEN_CONTEXT_KEYS = PROTOTYPE_POLLUTING_KEYS;
1640
1775
  var LogContextService = class {
@@ -1729,6 +1864,10 @@ function buildCommonProviders() {
1729
1864
  return [
1730
1865
  LogContextService,
1731
1866
  { provide: LOG_CONTEXT_TOKEN, useExisting: LogContextService },
1867
+ // Shared by the Pino factory (which closes over it in every fan-out stream)
1868
+ // and by DestinationRegistry (which writes to it during onModuleInit). Both
1869
+ // depend on this provider and it depends on nothing, so there is no cycle.
1870
+ DestinationHealth,
1732
1871
  {
1733
1872
  // The DEFAULT-coverage redactor, shared by the Pino factory's hooks and by
1734
1873
  // `PinoLoggerService.child()`. The latter needs it as a provider because
@@ -1739,8 +1878,13 @@ function buildCommonProviders() {
1739
1878
  },
1740
1879
  {
1741
1880
  provide: LOGGER_PINO_INSTANCE_TOKEN,
1742
- useFactory: (options, logContext, destinations) => buildPinoInstance(options, logContext, destinations),
1743
- inject: [LOGGER_OPTIONS_TOKEN, LogContextService, LOGGER_DESTINATIONS_TOKEN]
1881
+ useFactory: (options, logContext, destinations, health) => buildPinoInstance(options, logContext, destinations, health),
1882
+ inject: [
1883
+ LOGGER_OPTIONS_TOKEN,
1884
+ LogContextService,
1885
+ LOGGER_DESTINATIONS_TOKEN,
1886
+ DestinationHealth
1887
+ ]
1744
1888
  },
1745
1889
  {
1746
1890
  provide: LOGGER_DESTINATIONS_TOKEN,
@@ -1931,8 +2075,10 @@ var PrettyDevDestination = class {
1931
2075
  *
1932
2076
  * Three states:
1933
2077
  * - initialized → forward to the pino-pretty transform;
1934
- * - init failed → DROP (the destination was removed from the active set, so
1935
- * it must not duplicate raw NDJSON to stdout);
2078
+ * - init failed → DROP, so a co-registered stdout sink is not duplicated. The
2079
+ * fan-out already skips a failed destination before reaching this method
2080
+ * (and rescues the entry when nothing else initialized); this branch is the
2081
+ * belt to that, for any caller holding the destination directly;
1936
2082
  * - pre-init (transient window before {@link onInit}, e.g. a bootstrap log)
1937
2083
  * → fall back to raw stdout so the entry is not lost.
1938
2084
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-logger",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Structured JSON logging for NestJS based on Pino 10, with optional OpenTelemetry correlation.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",
@@ -75,7 +75,7 @@
75
75
  "test:cov:all": "jest --config jest.coverage.config.ts --coverage",
76
76
  "test:e2e": "jest --config jest.e2e.config.ts",
77
77
  "test:watch": "jest --watch",
78
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json"
78
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.bench.json"
79
79
  },
80
80
  "lint-staged": {
81
81
  "*.{ts,tsx,js,mjs,cjs}": [
@@ -86,7 +86,6 @@
86
86
  "prettier --write"
87
87
  ]
88
88
  },
89
- "dependencies": {},
90
89
  "peerDependencies": {
91
90
  "@nestjs/common": "^11.0.16",
92
91
  "@nestjs/core": "^11.1.18",