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