@bymax-one/nest-logger 1.2.1 → 1.2.3

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,81 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.3] - 2026-08-14
15
+
16
+ ### Fixed
17
+
18
+ - **A destination that fails to initialize no longer silences the entire application.** Reported
19
+ by a consumer while wiring pretty local output, and reproduced end to end: `destinations`
20
+ **replaces** the default stdout sink, so `destinations: [new PrettyDevDestination()]` without the
21
+ optional `pino-pretty` installed left the app booting, running, exiting `0` and writing **not one
22
+ byte to stdout or stderr** — including the `LOGGER_DESTINATION_INIT_FAILED` entry that exists to
23
+ explain exactly this, which was delivered into the dead sink. Not a crash and not a degraded
24
+ mode: silence that looks like a quiet service.
25
+
26
+ Four independently reasonable behaviours composed into it — `destinations` replacing rather than
27
+ adding, the failure being reported through `this.logger`, the multistream being built from the
28
+ full registered list (it is wired while NestJS builds the DI graph, before any `onInit` runs), and
29
+ a failed sink dropping its writes. Three changes break the chain:
30
+
31
+ - the init failure is written **directly to `process.stderr`**, matching what the shutdown path
32
+ and the write path already do, and for the same reason — reporting a broken sink through the
33
+ sink set is a feedback loop;
34
+ - a destination whose `onInit` rejected is **excluded from the write fan-out**;
35
+ - when **no** destination initializes, entries **fall back to raw NDJSON on stdout**, so a
36
+ misconfiguration degrades visibly instead of going quiet. The fallback is elected before the
37
+ bootstrap entries are announced, so `LOGGER_BOOTSTRAP_WARNING` — the signal that PII redaction
38
+ was disabled — survives the failure it would otherwise have been lost to.
39
+
40
+ The defect belonged to `destinations` as a general contract, not to `PrettyDevDestination`: any
41
+ consumer-written sink that failed `onInit` as the only registered destination did the same thing.
42
+ `pino-pretty` remains an optional peer and nothing was added to `dependencies`, which stays `{}`.
43
+
44
+ ### Breaking
45
+
46
+ - **`isPretty` is removed from `BymaxLoggerModuleOptions`.** It has been `@deprecated` and **inert**
47
+ — a `true` value never changed any output on its own. An option that does nothing is worse than
48
+ an absent one, because it is a knob a consumer turns and then reasons from.
49
+
50
+ **Migration:** delete the option. Nothing about your output changes, because nothing about your
51
+ output depended on it. For pretty local logs, add `new PrettyDevDestination()` to `destinations`
52
+ and install the optional `pino-pretty` peer — which is what the option's own documentation had
53
+ been telling you to do.
54
+
55
+ Type-level only: `applyDefaults` no longer emits the field, and no runtime behaviour is affected.
56
+
57
+ ### Documentation
58
+
59
+ - **`destinations` documents that it REPLACES the default stdout sink**, rather than claiming to add
60
+ "custom destinations beyond `DefaultStdoutDestination`". Replacement is the correct behaviour — a
61
+ file-only or socket-only deployment must be able to turn stdout off — but the doc described the
62
+ opposite, and it is the assumption that makes the failure above easy to arrive at.
63
+ - **`PrettyDevDestination` no longer implies it follows Pino's recommended transport form.** It runs
64
+ `pino-pretty` as a main-thread transform, a documented pino-pretty API; Pino's own docs recommend
65
+ `transport: { target: 'pino-pretty' }` in a worker, which is unreachable here because this library
66
+ owns the Pino instance and fans out through `pino.multistream`.
67
+
68
+ ## [1.2.2] - 2026-08-13
69
+
70
+ ### Added
71
+
72
+ - **`EmittedServiceResource` and `EmittedDeploymentResource` are exported.** `LogEntry['service']`
73
+ and `LogEntry['deployment']` have been typed by these interfaces since 1.2.1, but neither was
74
+ reexported from `@bymax-one/nest-logger/shared`, so a consumer could hold `entry.service` and had
75
+ no way to write its type — a function taking one had to inline the shape or reach for
76
+ `LogEntry['service']`. Reported by a consumer adopting 1.2.1.
77
+ - **Every shared type is now reexported from the server subpath, not a subset.** `LogEntry` was
78
+ already there while the types of its own properties were not, which is the same defect one level
79
+ up. `ResolvedServiceMetadata` and `ReservedLogKey` join them for the same reason.
80
+ - **`LogEntry` declares `'event.name'`.** The field has been emitted since 1.2.1 and documented as
81
+ part of the entry, but only the index signature covered it — so a consumer reading
82
+ `entry['event.name']` got `unknown` and needed a cast. A documented field that cannot be read
83
+ without a cast is not really published. The declaration describes the default key; a renamed
84
+ `eventNameField` still arrives through the index signature.
85
+
86
+ Type-only change: `dist/*.mjs` and `dist/*.cjs` are byte-identical to 1.2.1, since `export type`
87
+ erases at compile time. Only the `.d.ts` files differ.
88
+
14
89
  ## [1.2.1] - 2026-08-13
15
90
 
16
91
  ### Security
@@ -793,7 +868,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
793
868
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
794
869
  `release.yml`, Dependabot, and issue templates
795
870
 
796
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...HEAD
871
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...HEAD
872
+ [1.2.3]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...v1.2.3
873
+ [1.2.2]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...v1.2.2
797
874
  [1.2.1]: https://github.com/bymaxone/nest-logger/compare/v1.2.0...v1.2.1
798
875
  [1.2.0]: https://github.com/bymaxone/nest-logger/compare/v1.1.0...v1.2.0
799
876
  [1.1.0]: https://github.com/bymaxone/nest-logger/compare/v1.0.8...v1.1.0
package/README.md CHANGED
@@ -101,10 +101,10 @@ pnpm add @bymax-one/nest-logger
101
101
 
102
102
  One package, two entry points — import only what your app needs:
103
103
 
104
- | Subpath | Import | Purpose | Dependencies |
105
- | ---------- | ------------------------------- | ------------------------------------------------------------------------------- | :-------------------------------------: |
106
- | **Server** | `@bymax-one/nest-logger` | NestJS module, logger service, interceptor, filter, decorators, destinations | NestJS 11, pino, rxjs, reflect-metadata |
107
- | **Shared** | `@bymax-one/nest-logger/shared` | Types, constants, the log-key regex — `LogLevel`, `LogEntry`, `ServiceMetadata` | None |
104
+ | Subpath | Import | Purpose | Dependencies |
105
+ | ---------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------: |
106
+ | **Server** | `@bymax-one/nest-logger` | NestJS module, logger service, interceptor, filter, decorators, destinations | NestJS 11, pino, rxjs, reflect-metadata |
107
+ | **Shared** | `@bymax-one/nest-logger/shared` | Types, constants, the log-key regex — `LogLevel`, `LogEntry`, `ServiceMetadata`, `ResolvedServiceMetadata`, `EmittedServiceResource`, `EmittedDeploymentResource` | None |
108
108
 
109
109
  ```
110
110
  shared (zero deps)
@@ -546,24 +546,23 @@ Full options reference for `BymaxLoggerModule.forRoot(options)`:
546
546
 
547
547
  ### Top-level options
548
548
 
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 |
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
+ | `redactPaths` | `string[]` | `[]` | Additional `fast-redact` paths, applied on top of the default coverage |
561
+ | `redactStrategy` | `'names' \| 'paths'` | `'names'` | Engine behind the DEFAULT set. `'paths'` restores the pre-1.2 `fast-redact` expansion (four-level ceiling, ~100× slower) |
562
+ | `shouldDisableDefaultRedact` | `boolean` | `false` | Skip the default PII coverage entirely. ⚠️ Emits `LOGGER_BOOTSTRAP_WARNING` at startup — document why |
563
+ | `redactCensor` | `string` | `'[REDACTED]'` | Replacement value written in place of every redacted field |
564
+ | `maxEntrySizeBytes` | `number` | `65536` | UTF-8 byte ceiling per serialized field (`err` + any custom serializer); over it the value becomes a truncation envelope |
565
+ | `destinations` | `ILogDestination[]` | `[new DefaultStdoutDestination()]` | The sinks entries are written to. ⚠️ A non-empty list **replaces** that default — see [Destinations](#destinations-replace-stdout) |
567
566
 
568
567
  ### `http` options
569
568
 
@@ -713,6 +712,21 @@ BymaxLoggerModule.forRootAsync({
713
712
  })
714
713
  ```
715
714
 
715
+ ### Destinations replace stdout
716
+
717
+ 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:
718
+
719
+ ```typescript
720
+ destinations: [new DefaultStdoutDestination(), new LokiDestination({ ... })]
721
+ ```
722
+
723
+ 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:
724
+
725
+ - **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.
726
+ - **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.
727
+
728
+ 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.
729
+
716
730
  ### Postgres destination (Prisma)
717
731
 
718
732
  ```typescript
@@ -1093,7 +1107,28 @@ PINO_LEVEL_NAMES[30] // 'info' — numeric code → label
1093
1107
  ```typescript
1094
1108
  type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'
1095
1109
 
1096
- type ServiceMetadata = { name: string; version: string }
1110
+ // What you CONFIGURE.
1111
+ type ServiceMetadata = {
1112
+ readonly name: string
1113
+ readonly version: string
1114
+ readonly namespace?: string
1115
+ readonly instanceId?: string
1116
+ readonly environment?: string
1117
+ }
1118
+
1119
+ // What is EMITTED. Deliberately a different type: `instanceId` nests as
1120
+ // `service.instance.id`, and the environment is not under `service` at all.
1121
+ // Reusing the configuration type here would advertise `entry.service.instanceId`,
1122
+ // which no entry carries.
1123
+ type EmittedServiceResource = {
1124
+ readonly name: string
1125
+ readonly version: string
1126
+ readonly namespace?: string
1127
+ readonly instance?: { readonly id: string }
1128
+ readonly [key: string]: unknown
1129
+ }
1130
+
1131
+ type EmittedDeploymentResource = { readonly environment: { readonly name: string } }
1097
1132
 
1098
1133
  type LogEntry = {
1099
1134
  /** Pino string label — `'info'`, not `30`. Convert with `PINO_LEVEL_NUMBERS`. */
@@ -1102,7 +1137,12 @@ type LogEntry = {
1102
1137
  time: string
1103
1138
  msg: string
1104
1139
  logKey?: string
1105
- service?: ServiceMetadata
1140
+ /** Present under `resourceFormat: 'nested'` (the default). */
1141
+ service?: EmittedServiceResource
1142
+ /** Present when an environment resolved — `deployment.environment.name`. */
1143
+ deployment?: EmittedDeploymentResource
1144
+ /** Derived from `logKey` unless `eventNameField: false`. */
1145
+ 'event.name'?: string
1106
1146
  context?: string
1107
1147
  requestId?: string
1108
1148
  tenantId?: string
@@ -1113,6 +1153,41 @@ type LogEntry = {
1113
1153
  }
1114
1154
  ```
1115
1155
 
1156
+ Under `resourceFormat: 'flat'` there is no `service` object at all — the values arrive as dotted
1157
+ top-level keys (`"service.instance.id"`), which the index signature already admits.
1158
+
1159
+ The emitted types are exported, so a helper that receives one can be typed without inlining its
1160
+ shape:
1161
+
1162
+ ```typescript
1163
+ import type {
1164
+ EmittedDeploymentResource,
1165
+ EmittedServiceResource,
1166
+ LogEntry
1167
+ } from '@bymax-one/nest-logger/shared'
1168
+
1169
+ /** Build the identity triplet a backend joins on, from a parsed entry. */
1170
+ function identityOf(entry: LogEntry): string {
1171
+ const service: EmittedServiceResource | undefined = entry.service
1172
+ const deployment: EmittedDeploymentResource | undefined = entry.deployment
1173
+ if (service === undefined) {
1174
+ return 'unknown'
1175
+ }
1176
+ // `service.namespace` is part of the identity, not decoration: OTel allows two
1177
+ // services to share a name when their namespaces differ, so a key built without
1178
+ // it collapses them into one.
1179
+ const namespace = service.namespace ?? 'no-namespace'
1180
+ const instance = service.instance?.id ?? 'no-instance'
1181
+ const environment = deployment?.environment.name ?? 'no-environment'
1182
+ return `${namespace}/${service.name}@${service.version}/${instance}/${environment}`
1183
+ }
1184
+
1185
+ /** `event.name` is declared, so it reads as a string without a cast. */
1186
+ function eventOf(entry: LogEntry): string | undefined {
1187
+ return entry['event.name']
1188
+ }
1189
+ ```
1190
+
1116
1191
  ### `ILogDestination`
1117
1192
 
1118
1193
  ```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
  *
@@ -125,6 +125,26 @@ interface ServiceMetadata {
125
125
  */
126
126
  readonly environment?: string;
127
127
  }
128
+ /**
129
+ * Service identity after precedence has been applied.
130
+ *
131
+ * Distinct from {@link ServiceMetadata} because the optional fields here mean
132
+ * "resolved to nothing" rather than "not configured": every source — options,
133
+ * `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, `NODE_ENV` — has already been
134
+ * consulted, and a field still absent will not appear on any entry.
135
+ */
136
+ interface ResolvedServiceMetadata {
137
+ /** `service.name`, always present. */
138
+ name: string;
139
+ /** `service.version`, always present. */
140
+ version: string;
141
+ /** `service.namespace`, when any source supplied one. */
142
+ namespace?: string;
143
+ /** `service.instance.id`, when any source supplied one. */
144
+ instanceId?: string;
145
+ /** `deployment.environment.name`, when any source supplied one. */
146
+ environment?: string;
147
+ }
128
148
  /**
129
149
  * The `service` block as it appears in an EMITTED record, under the default
130
150
  * `resourceFormat: 'nested'`.
@@ -314,15 +334,22 @@ interface BymaxLoggerModuleOptions {
314
334
  redactCensor?: string;
315
335
  /** Disable default redact paths (use with caution). Default: false. */
316
336
  shouldDisableDefaultRedact?: boolean;
317
- /** Custom destinations beyond `DefaultStdoutDestination`. */
318
- destinations?: readonly ILogDestination[];
319
337
  /**
320
- * @deprecated Currently inert — a `true` value does not change output on its
321
- * own. For pretty local logs add `new PrettyDevDestination()` to
322
- * `destinations` (and install the optional `pino-pretty` peer dep).
323
- * 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()]`.
324
351
  */
325
- isPretty?: boolean;
352
+ destinations?: readonly ILogDestination[];
326
353
  /** HTTP module configuration. */
327
354
  http?: HttpOptions;
328
355
  /** OpenTelemetry integration tuning. */
@@ -512,6 +539,11 @@ declare const RESERVED_LOG_KEYS_NOT_EMITTED: Readonly<{
512
539
  */
513
540
  readonly HTTP_REQUEST_COMPLETED: "reserved: superseded by the four status-specific terminal keys";
514
541
  }>;
542
+ /**
543
+ * Strongly-typed union of every reserved log key value, derived from
544
+ * {@link RESERVED_LOG_KEYS}. Use this type to constrain log-key arguments.
545
+ */
546
+ type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS];
515
547
 
516
548
  /**
517
549
  * Name-based recursive redaction.
@@ -885,8 +917,10 @@ declare class PrettyDevDestination implements ILogDestination {
885
917
  *
886
918
  * Three states:
887
919
  * - initialized → forward to the pino-pretty transform;
888
- * - init failed → DROP (the destination was removed from the active set, so
889
- * 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;
890
924
  * - pre-init (transient window before {@link onInit}, e.g. a bootstrap log)
891
925
  * → fall back to raw stdout so the entry is not lost.
892
926
  *
@@ -1287,6 +1321,17 @@ interface LogEntry {
1287
1321
  msg: string;
1288
1322
  /** Convention key — `MODULE_ACTION_RESULT` format. */
1289
1323
  logKey?: string;
1324
+ /**
1325
+ * OTel event name derived from {@link logKey} (`PAYMENT_FAILED` →
1326
+ * `payment.failed`), emitted on structured calls unless `eventNameField` is
1327
+ * `false`. Declared explicitly because the index signature below would type
1328
+ * it `unknown`: a documented field a consumer cannot read without a cast is
1329
+ * not really published.
1330
+ *
1331
+ * The key is configurable, so a renamed field arrives through the index
1332
+ * signature instead — this declaration describes the default only.
1333
+ */
1334
+ 'event.name'?: string;
1290
1335
  /**
1291
1336
  * Service identity as EMITTED — see {@link EmittedServiceResource}. Not the
1292
1337
  * configuration shape: `service.instance.id` nests, and the environment lives
@@ -1337,4 +1382,4 @@ interface LogEntry {
1337
1382
  */
1338
1383
  declare const LOG_KEYS_CONVENTION_REGEX: RegExp;
1339
1384
 
1340
- export { BymaxLoggerModule, type BymaxLoggerModuleAsyncOptions, type BymaxLoggerModuleOptions, type BymaxLoggerModuleOptionsFactory, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, HttpExceptionFilter, HttpLoggingInterceptor, type HttpOptions, type ILogDestination, type IncomingHeaders, InjectLogger, LOGGER_DESTINATIONS_TOKEN, LOGGER_OPTIONS_TOKEN, LOGGER_PINO_INSTANCE_TOKEN, LOG_CONTEXT_METADATA_KEY, LOG_CONTEXT_TOKEN, LOG_KEYS_CONVENTION_REGEX, LogContext, type LogContext$1 as LogContextBag, LogContextService, type LogEntry, type LogLevel, LogPerformance, type LoggableRequest, type LoggableResponse, type NextHandler, type OtelOptions, PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS, PinoLoggerService, PrettyDevDestination, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, RequestIdMiddleware, type ResolvedBymaxLoggerModuleOptions, type ServiceMetadata, applyRequestIdMiddleware };
1385
+ export { BymaxLoggerModule, type BymaxLoggerModuleAsyncOptions, type BymaxLoggerModuleOptions, type BymaxLoggerModuleOptionsFactory, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, type EmittedDeploymentResource, type EmittedServiceResource, HttpExceptionFilter, HttpLoggingInterceptor, type HttpOptions, type ILogDestination, type IncomingHeaders, InjectLogger, LOGGER_DESTINATIONS_TOKEN, LOGGER_OPTIONS_TOKEN, LOGGER_PINO_INSTANCE_TOKEN, LOG_CONTEXT_METADATA_KEY, LOG_CONTEXT_TOKEN, LOG_KEYS_CONVENTION_REGEX, LogContext, type LogContext$1 as LogContextBag, LogContextService, type LogEntry, type LogLevel, LogPerformance, type LoggableRequest, type LoggableResponse, type NextHandler, type OtelOptions, PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS, PinoLoggerService, PrettyDevDestination, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, RequestIdMiddleware, type ReservedLogKey, type ResolvedBymaxLoggerModuleOptions, type ResolvedServiceMetadata, type ServiceMetadata, applyRequestIdMiddleware };
@@ -125,6 +125,26 @@ interface ServiceMetadata {
125
125
  */
126
126
  readonly environment?: string;
127
127
  }
128
+ /**
129
+ * Service identity after precedence has been applied.
130
+ *
131
+ * Distinct from {@link ServiceMetadata} because the optional fields here mean
132
+ * "resolved to nothing" rather than "not configured": every source — options,
133
+ * `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, `NODE_ENV` — has already been
134
+ * consulted, and a field still absent will not appear on any entry.
135
+ */
136
+ interface ResolvedServiceMetadata {
137
+ /** `service.name`, always present. */
138
+ name: string;
139
+ /** `service.version`, always present. */
140
+ version: string;
141
+ /** `service.namespace`, when any source supplied one. */
142
+ namespace?: string;
143
+ /** `service.instance.id`, when any source supplied one. */
144
+ instanceId?: string;
145
+ /** `deployment.environment.name`, when any source supplied one. */
146
+ environment?: string;
147
+ }
128
148
  /**
129
149
  * The `service` block as it appears in an EMITTED record, under the default
130
150
  * `resourceFormat: 'nested'`.
@@ -314,15 +334,22 @@ interface BymaxLoggerModuleOptions {
314
334
  redactCensor?: string;
315
335
  /** Disable default redact paths (use with caution). Default: false. */
316
336
  shouldDisableDefaultRedact?: boolean;
317
- /** Custom destinations beyond `DefaultStdoutDestination`. */
318
- destinations?: readonly ILogDestination[];
319
337
  /**
320
- * @deprecated Currently inert — a `true` value does not change output on its
321
- * own. For pretty local logs add `new PrettyDevDestination()` to
322
- * `destinations` (and install the optional `pino-pretty` peer dep).
323
- * 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()]`.
324
351
  */
325
- isPretty?: boolean;
352
+ destinations?: readonly ILogDestination[];
326
353
  /** HTTP module configuration. */
327
354
  http?: HttpOptions;
328
355
  /** OpenTelemetry integration tuning. */
@@ -512,6 +539,11 @@ declare const RESERVED_LOG_KEYS_NOT_EMITTED: Readonly<{
512
539
  */
513
540
  readonly HTTP_REQUEST_COMPLETED: "reserved: superseded by the four status-specific terminal keys";
514
541
  }>;
542
+ /**
543
+ * Strongly-typed union of every reserved log key value, derived from
544
+ * {@link RESERVED_LOG_KEYS}. Use this type to constrain log-key arguments.
545
+ */
546
+ type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS];
515
547
 
516
548
  /**
517
549
  * Name-based recursive redaction.
@@ -885,8 +917,10 @@ declare class PrettyDevDestination implements ILogDestination {
885
917
  *
886
918
  * Three states:
887
919
  * - initialized → forward to the pino-pretty transform;
888
- * - init failed → DROP (the destination was removed from the active set, so
889
- * 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;
890
924
  * - pre-init (transient window before {@link onInit}, e.g. a bootstrap log)
891
925
  * → fall back to raw stdout so the entry is not lost.
892
926
  *
@@ -1287,6 +1321,17 @@ interface LogEntry {
1287
1321
  msg: string;
1288
1322
  /** Convention key — `MODULE_ACTION_RESULT` format. */
1289
1323
  logKey?: string;
1324
+ /**
1325
+ * OTel event name derived from {@link logKey} (`PAYMENT_FAILED` →
1326
+ * `payment.failed`), emitted on structured calls unless `eventNameField` is
1327
+ * `false`. Declared explicitly because the index signature below would type
1328
+ * it `unknown`: a documented field a consumer cannot read without a cast is
1329
+ * not really published.
1330
+ *
1331
+ * The key is configurable, so a renamed field arrives through the index
1332
+ * signature instead — this declaration describes the default only.
1333
+ */
1334
+ 'event.name'?: string;
1290
1335
  /**
1291
1336
  * Service identity as EMITTED — see {@link EmittedServiceResource}. Not the
1292
1337
  * configuration shape: `service.instance.id` nests, and the environment lives
@@ -1337,4 +1382,4 @@ interface LogEntry {
1337
1382
  */
1338
1383
  declare const LOG_KEYS_CONVENTION_REGEX: RegExp;
1339
1384
 
1340
- export { BymaxLoggerModule, type BymaxLoggerModuleAsyncOptions, type BymaxLoggerModuleOptions, type BymaxLoggerModuleOptionsFactory, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, HttpExceptionFilter, HttpLoggingInterceptor, type HttpOptions, type ILogDestination, type IncomingHeaders, InjectLogger, LOGGER_DESTINATIONS_TOKEN, LOGGER_OPTIONS_TOKEN, LOGGER_PINO_INSTANCE_TOKEN, LOG_CONTEXT_METADATA_KEY, LOG_CONTEXT_TOKEN, LOG_KEYS_CONVENTION_REGEX, LogContext, type LogContext$1 as LogContextBag, LogContextService, type LogEntry, type LogLevel, LogPerformance, type LoggableRequest, type LoggableResponse, type NextHandler, type OtelOptions, PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS, PinoLoggerService, PrettyDevDestination, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, RequestIdMiddleware, type ResolvedBymaxLoggerModuleOptions, type ServiceMetadata, applyRequestIdMiddleware };
1385
+ export { BymaxLoggerModule, type BymaxLoggerModuleAsyncOptions, type BymaxLoggerModuleOptions, type BymaxLoggerModuleOptionsFactory, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, type EmittedDeploymentResource, type EmittedServiceResource, HttpExceptionFilter, HttpLoggingInterceptor, type HttpOptions, type ILogDestination, type IncomingHeaders, InjectLogger, LOGGER_DESTINATIONS_TOKEN, LOGGER_OPTIONS_TOKEN, LOGGER_PINO_INSTANCE_TOKEN, LOG_CONTEXT_METADATA_KEY, LOG_CONTEXT_TOKEN, LOG_KEYS_CONVENTION_REGEX, LogContext, type LogContext$1 as LogContextBag, LogContextService, type LogEntry, type LogLevel, LogPerformance, type LoggableRequest, type LoggableResponse, type NextHandler, type OtelOptions, PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS, PinoLoggerService, PrettyDevDestination, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, RequestIdMiddleware, type ReservedLogKey, type ResolvedBymaxLoggerModuleOptions, type ResolvedServiceMetadata, type ServiceMetadata, applyRequestIdMiddleware };
@@ -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
  *
@@ -152,6 +152,17 @@ interface LogEntry {
152
152
  msg: string;
153
153
  /** Convention key — `MODULE_ACTION_RESULT` format. */
154
154
  logKey?: string;
155
+ /**
156
+ * OTel event name derived from {@link logKey} (`PAYMENT_FAILED` →
157
+ * `payment.failed`), emitted on structured calls unless `eventNameField` is
158
+ * `false`. Declared explicitly because the index signature below would type
159
+ * it `unknown`: a documented field a consumer cannot read without a cast is
160
+ * not really published.
161
+ *
162
+ * The key is configurable, so a renamed field arrives through the index
163
+ * signature instead — this declaration describes the default only.
164
+ */
165
+ 'event.name'?: string;
155
166
  /**
156
167
  * Service identity as EMITTED — see {@link EmittedServiceResource}. Not the
157
168
  * configuration shape: `service.instance.id` nests, and the environment lives
@@ -258,4 +269,4 @@ declare const RESERVED_LOG_KEYS_NOT_EMITTED: Readonly<{
258
269
  */
259
270
  type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS];
260
271
 
261
- export { LOG_KEYS_CONVENTION_REGEX, type LogEntry, type LogLevel, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, type ReservedLogKey, type ResolvedServiceMetadata, type ServiceMetadata };
272
+ export { type EmittedDeploymentResource, type EmittedServiceResource, LOG_KEYS_CONVENTION_REGEX, type LogEntry, type LogLevel, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, type ReservedLogKey, type ResolvedServiceMetadata, type ServiceMetadata };
@@ -152,6 +152,17 @@ interface LogEntry {
152
152
  msg: string;
153
153
  /** Convention key — `MODULE_ACTION_RESULT` format. */
154
154
  logKey?: string;
155
+ /**
156
+ * OTel event name derived from {@link logKey} (`PAYMENT_FAILED` →
157
+ * `payment.failed`), emitted on structured calls unless `eventNameField` is
158
+ * `false`. Declared explicitly because the index signature below would type
159
+ * it `unknown`: a documented field a consumer cannot read without a cast is
160
+ * not really published.
161
+ *
162
+ * The key is configurable, so a renamed field arrives through the index
163
+ * signature instead — this declaration describes the default only.
164
+ */
165
+ 'event.name'?: string;
155
166
  /**
156
167
  * Service identity as EMITTED — see {@link EmittedServiceResource}. Not the
157
168
  * configuration shape: `service.instance.id` nests, and the environment lives
@@ -258,4 +269,4 @@ declare const RESERVED_LOG_KEYS_NOT_EMITTED: Readonly<{
258
269
  */
259
270
  type ReservedLogKey = (typeof RESERVED_LOG_KEYS)[keyof typeof RESERVED_LOG_KEYS];
260
271
 
261
- export { LOG_KEYS_CONVENTION_REGEX, type LogEntry, type LogLevel, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, type ReservedLogKey, type ResolvedServiceMetadata, type ServiceMetadata };
272
+ export { type EmittedDeploymentResource, type EmittedServiceResource, LOG_KEYS_CONVENTION_REGEX, type LogEntry, type LogLevel, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, type ReservedLogKey, type ResolvedServiceMetadata, type ServiceMetadata };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-logger",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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",