@bymax-one/nest-logger 1.2.5 → 1.2.7

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,223 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.7] - 2026-08-15
15
+
16
+ ### Security
17
+
18
+ - **Serializer output is redacted by name under `redactStrategy: 'paths'` too.** Carrying an
19
+ error's own properties at every depth of the `cause` chain (below) opened a hole in that legacy
20
+ mode: the name walk is a pass-through there, so redaction fell entirely to `fast-redact`, and
21
+ `DEFAULT_REDACT_PATHS` reaches `*.*.*.*.password` — four wildcard levels. A serialized cause chain
22
+ goes deeper. Measured on the built artifact with a password on the deepest link of
23
+ `err.errors[0].cause.cause`:
24
+
25
+ ```json
26
+ { "cause": { "name": "Error", "message": "deep", "password": "LEAK-deep" } }
27
+ ```
28
+
29
+ Confirmed absent from `1.2.6`, where the field was not carried at all — so this was a regression
30
+ introduced inside this release rather than an inherited one, and it never reached a published
31
+ artifact.
32
+
33
+ `redactStrategy` is the consumer's choice about **their own payload**; what a serializer
34
+ synthesizes is this library's output, and the name walk — which has no depth ceiling — now covers
35
+ it either way. The caller's record is untouched by this: `'paths'` still means exactly what it
36
+ documented, four-level ceiling included, and the e2e case that pins that ceiling is what caught
37
+ the first attempt at this fix collapsing both hooks onto one redactor.
38
+
39
+ Reported by Copilot on the pull request that introduced the deeper copy.
40
+
41
+ - **An own `__proto__` key on an error is now stored as data instead of being applied as a
42
+ prototype.** The own-property copy assigned each key, and `__proto__` is an accessor inherited
43
+ from `Object.prototype` — so the assignment walked the chain, invoked that setter, and replaced
44
+ the sanitized node's prototype. An error-like object arriving from JSON (a worker boundary, a
45
+ queue, an HTTP body) carries `__proto__` as an ordinary own enumerable key, and `Object.entries`
46
+ hands it over like any other.
47
+
48
+ Measured rather than assumed. `Object.prototype` itself is never touched, so this is prototype
49
+ injection into a single log node rather than global pollution, and with a JSON-sourced payload the
50
+ emitted line was unchanged — `JSON.stringify` ignores inherited fields. The live hazard is the
51
+ node: given `{"__proto__": null}` it loses `hasOwnProperty` and `toString`, which then **throw**
52
+ for anything downstream that touches them, inside the one path whose contract is that logging
53
+ never crashes the application.
54
+
55
+ The copy now defines an own data property, which makes the key inert and keeps its value — an
56
+ error that genuinely carries a `__proto__` field gets it logged as the data it is. Reported by
57
+ Copilot on the pull request that introduced the deeper copy.
58
+
59
+ ### Documentation
60
+
61
+ - **`ignore` needs an escaped dot for `event.name`, and every example here taught the opposite.**
62
+ `pino-pretty` reads a dot as a nested path, and this library emits `event.name` as a literal
63
+ top-level key — so `ignore: '…,event.name'` looks for an `event` object with a `name` inside,
64
+ finds neither, and hides nothing without a word of complaint. Measured on one entry with only the
65
+ option changing:
66
+
67
+ ```
68
+ ignore: '…,event.name' → INFO: msg {"logKey":"X","event.name":"x"} ← still there
69
+ ignore: '…,event\\.name' → INFO: msg {"logKey":"X"} ← hidden
70
+ ```
71
+
72
+ Found by a consumer who copied the `1.2.6` example verbatim, configured it, and saw the field
73
+ still on screen. The escaped form is now in the `PrettyViewOptions` JSDoc — where an editor's
74
+ autocomplete shows it, which is where someone actually is when writing the option — in the README,
75
+ and in the `1.2.6` example itself, which is corrected rather than left to keep teaching it.
76
+
77
+ ### Fixed
78
+
79
+ - **An error's own properties now survive at every depth of the `cause` chain, not just at the top.**
80
+ `code`, `statusCode` and any domain field an application attaches were copied onto the error handed
81
+ to the log call and **dropped the moment that same error was wrapped as someone else's `cause`** —
82
+ which is what a `catch` in `main.ts` does routinely. Measured on the published `1.2.6`:
83
+
84
+ ```json
85
+ {
86
+ "type": "Error",
87
+ "message": "bootstrap failed",
88
+ "code": "EBOOT",
89
+ "cause": { "name": "Error", "message": "config invalid" }
90
+ }
91
+ ```
92
+
93
+ The inner error carried `code: "BYMAX_CONFIG_VALIDATION"` and an `issues` array holding one entry
94
+ per invalid variable. Both vanished. The reporting consumer framed the severity exactly right:
95
+ because `message` survives and their message is the full aggregated report, **the human stayed
96
+ served while the machine went blind** — the text an operator reads was intact, and the fields an
97
+ alert or a dashboard keys on were gone.
98
+
99
+ Nothing justified the asymmetry. The reason own properties are "part of the contract" at the top —
100
+ `pino.stdSerializers.err` copied them, so dropping them is a silent compatibility loss — is the
101
+ same reason one level down, and a wrapped error is the common case rather than the exotic one.
102
+ They still never shadow a field the serializer derives, and they still pass through the same
103
+ name-based redaction and size bound as any other serializer output.
104
+
105
+ Found by following up an asymmetry noticed while answering a consumer's question about how to pass
106
+ an error through the NestJS bridge — not by a bug report.
107
+
108
+ ### Changed
109
+
110
+ - **The own-property copy lives in one place instead of two.** The `err` serializer read the raw
111
+ thrown value and walked its properties itself, duplicating what the sanitizer now does at every
112
+ node; it copies from the sanitized node instead. Same fields, one walk — and no second copy to
113
+ drift away from the first.
114
+
115
+ ## [1.2.6] - 2026-08-15
116
+
117
+ ### Added
118
+
119
+ - **`PrettyDevDestination` takes a `view`, so the terminal rendering is the consumer's choice.**
120
+ Requested with the measurement that makes the case — one real entry rendered three ways through
121
+ `pino-pretty`, where only the options differ:
122
+
123
+ ```
124
+ A) the built-in view 7 lines for one entry
125
+ B) singleLine + an extended ignore list 1 line, fields kept
126
+ C) hideObject + messageFormat '[{context}] {msg}' 1 line, message only
127
+ ```
128
+
129
+ All of it was reachable in `pino-pretty` and none of it was reachable through this library, which
130
+ hard-coded five options and read only `minLevel` from its constructor.
131
+
132
+ ```ts
133
+ new PrettyDevDestination({
134
+ view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event\\.name' }
135
+ })
136
+ ```
137
+
138
+ `PrettyViewOptions` covers `singleLine`, `ignore`, `hideObject`, `messageFormat`, `translateTime`
139
+ and `colorize`. **Additive** — every field defaults to what it rendered before, so
140
+ `new PrettyDevDestination()` is unchanged.
141
+
142
+ **`destination` is deliberately not exposed.** The library owns where entries go; a redirected
143
+ stream would route around the multistream fan-out and the last-resort rescue. It is absent from
144
+ the type AND applied after the consumer's options are spread, so an untyped JavaScript caller
145
+ cannot smuggle one in either — omitting it from a type is a suggestion, ordering the spread is the
146
+ guarantee.
147
+
148
+ The options bag is this library's **own** interface rather than `pino-pretty`'s `PrettyOptions`,
149
+ and that is load-bearing rather than stylistic: a type import from the peer emits a hard
150
+ `import { PrettyOptions } from 'pino-pretty'` on line 4 of the published `.d.ts`, so every consumer
151
+ who type-checks without the optional peer installed would hit an unresolved module. That would make
152
+ an optional peer effectively required. Measured on the built artifact.
153
+
154
+ ### Fixed
155
+
156
+ - **Entries emitted before the pretty transform exists are now rendered, not dumped raw.** A real
157
+ boot put **43 raw NDJSON lines on screen before the first rendered one** — every entry NestJS emits
158
+ while instantiating providers, because the transform cannot be built until `onInit` (loading the
159
+ optional peer is async). Nothing was ever lost, but a developer who had just enabled pretty output
160
+ saw a screen of JSON and concluded it had not worked. That is what was reported.
161
+
162
+ Those entries are now held and flushed **through** the transform once it exists, in arrival order.
163
+ Nothing is lost on any path, which is the property the raw passthrough had and the buffer had to
164
+ keep:
165
+
166
+ - **init fails** → the held entries are drained raw, because the renderer they were waiting for is
167
+ never coming;
168
+ - **a bound is reached** (1000 entries or 4 MiB, whichever trips first — nothing guarantees
169
+ `onInit` ever runs) → the buffer drains raw _before_ the entry that tripped it, so output stays
170
+ in order rather than replaying old entries after newer ones;
171
+ - **shutdown before init** → drained raw rather than dying with the process.
172
+
173
+ The byte ceiling is there because the entry count alone does not bound memory: a payload has no
174
+ whole-record size limit (`maxEntrySizeBytes` bounds what a serializer emits for one field, not the
175
+ entry), so 1000 held entries carrying large metadata could retain far more than the count suggests
176
+ — where the raw passthrough this buffer replaced retained nothing at all.
177
+
178
+ - **A closed pipe can no longer crash the host, which the `try/catch` around
179
+ `process.stdout.write` never actually prevented.** Every fallback path in this library — the
180
+ last-resort NDJSON rescue, the pre-init drain, the destination failure reports — wrapped its raw
181
+ write in a `try/catch` and documented that as EPIPE protection. It is not, and this was measured
182
+ rather than argued:
183
+
184
+ ```
185
+ stdout error listeners at start: 0
186
+ sync result: no-throw ← the try/catch caught nothing
187
+ UNCAUGHT:EPIPE ← arrived later, as an uncaught exception
188
+ child exit code: 42 ← the process died
189
+ ```
190
+
191
+ A closed pipe (`node app | head`) reports EPIPE **asynchronously**, through the stream's `'error'`
192
+ event, after `write()` has already returned. Node attaches no default handler, so the emit becomes
193
+ an uncaught exception. The guarantee was asserted in prose and absent from the code, on exactly the
194
+ paths whose purpose is to survive a broken sink.
195
+
196
+ Every library-owned write to a process stream now goes through one `safe-stdio` helper that
197
+ installs a swallow-EPIPE handler on first use, covering the asynchronous half; the `try/catch`
198
+ remains for the synchronous half, since either alone leaves a way to die. The handler is found by
199
+ identity rather than remembered, so anything that strips listeners from a process stream cannot
200
+ silently disable the protection permanently.
201
+
202
+ **This includes `DefaultStdoutDestination`, and that is the half that actually mattered.** The
203
+ first version of the fix guarded only the fallback paths — the rescue, the drain, the failure
204
+ reports — none of which a healthy application ever takes. The default sink, which is what a
205
+ consumer gets when they configure nothing, still called `process.stdout.write` directly, so the
206
+ ordinary install was exactly as crashable as before. Caught in review and then reproduced against
207
+ the built artifact: `0` listeners, no synchronous throw, `UNCAUGHT:EPIPE`, exit 42. Re-measured
208
+ after the fix: survived, exit 0.
209
+
210
+ `PrettyDevDestination` is deliberately **not** guarded by this library. `pino-pretty` receives
211
+ `process.stdout` and attaches two `'error'` listeners of its own; a child piped to a closed reader
212
+ survived and exited 0 with nothing from us on the stream, so a guard there would be code whose
213
+ need is disproven. A test pins that, and goes red if `pino-pretty` ever stops attaching them.
214
+
215
+ One consequence worth stating rather than burying: after the first log line, `process.stdout` has a
216
+ swallowing `'error'` listener that belongs to this library, so an EPIPE raised by **your** writes
217
+ to stdout also stops being an uncaught exception. That is the trade — the alternative is the crash
218
+ above — and your own `'error'` listener is never removed or replaced.
219
+
220
+ Reported by Copilot against the new pre-init buffer, then a second time against the incomplete
221
+ fix; the same ineffective pattern was already shipped in `1.2.3` and `1.2.5`, so this corrects
222
+ those paths too.
223
+
224
+ - **A write after a failed init is verifiably dropped, not merely silent.** Adding the buffer made
225
+ "dropped" and "held" observationally identical — the existing case asserted only that nothing
226
+ reached stdout, and buffering produces the same silence. A **cold** mutation run caught it
227
+ (the incremental runs had been reporting 100% while the cold run read 99.62%, with all three
228
+ survivors in this file). The case now shuts down after the failed init, which drains anything
229
+ still held: a merely-buffered entry surfaces there, a dropped one does not.
230
+
14
231
  ## [1.2.5] - 2026-08-14
15
232
 
16
233
  ### Fixed
@@ -1014,7 +1231,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
1014
1231
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
1015
1232
  `release.yml`, Dependabot, and issue templates
1016
1233
 
1017
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.5...HEAD
1234
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.7...HEAD
1235
+ [1.2.7]: https://github.com/bymaxone/nest-logger/compare/v1.2.6...v1.2.7
1236
+ [1.2.6]: https://github.com/bymaxone/nest-logger/compare/v1.2.5...v1.2.6
1018
1237
  [1.2.5]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...v1.2.5
1019
1238
  [1.2.4]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...v1.2.4
1020
1239
  [1.2.3]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...v1.2.3
package/README.md CHANGED
@@ -60,7 +60,7 @@ pnpm add @bymax-one/nest-logger
60
60
  - ✅ **Structured JSON** — every entry has `level`, `time`, `service`, `logKey`, `msg`, and arbitrary metadata fields
61
61
  - ✅ **`MODULE_ACTION_RESULT` Log Keys** — a naming convention enforced by an exported regex for CI validation
62
62
  - ✅ **NestJS `LoggerService` Bridge** — drop-in replacement; all NestJS internal logs flow through Pino
63
- - ✅ **Pretty-Print in Dev** — opt-in `PrettyDevDestination` for readable local output (requires optional `pino-pretty`)
63
+ - ✅ **Pretty-Print in Dev** — opt-in `PrettyDevDestination` with a configurable `view` (single-line, hidden fields, message-only) for readable local output (requires optional `pino-pretty`)
64
64
  - ✅ **Field Size Guard** — a serialized field over the ceiling (default 64 KB) is replaced by a compact truncation envelope instead of flooding the sink
65
65
 
66
66
  ### 🛡️ Security & Privacy
@@ -534,6 +534,32 @@ any other field. `AggregateError` members reach the entry as `err.errors`. There
534
534
  OpenTelemetry attribute for a cause chain, so these stay namespaced under `err` rather than
535
535
  inventing an `exception.cause` the spec does not define.
536
536
 
537
+ **An error's own enumerable properties travel with it at every depth** — `code` on a Node system
538
+ error, `statusCode` from an HTTP layer, whatever domain fields you attached — including on a nested
539
+ `cause` and on each `AggregateError` member:
540
+
541
+ ```json
542
+ {
543
+ "err": {
544
+ "type": "Error",
545
+ "message": "bootstrap failed",
546
+ "code": "EBOOT",
547
+ "cause": {
548
+ "name": "Error",
549
+ "message": "config invalid",
550
+ "code": "BYMAX_CONFIG_VALIDATION",
551
+ "issues": [{ "variable": "DATABASE_URL", "code": "invalid_url" }]
552
+ }
553
+ }
554
+ }
555
+ ```
556
+
557
+ This matters because `code` is usually the field you alert on. Until 1.2.7 those properties survived
558
+ only on the error handed to the log call and were dropped the moment the same error was wrapped as
559
+ someone else's `cause` — which is what a `catch` in `main.ts` does routinely. They never shadow a
560
+ field the serializer derives, and they pass through the same name-based redaction and size bound as
561
+ any other serializer output.
562
+
537
563
  An error's own enumerable properties (`code`, `statusCode`, domain fields) are carried through, as
538
564
  Pino's standard serializer did.
539
565
 
@@ -753,6 +779,48 @@ The consequence worth knowing: a sink you supply may be the **only** one the app
753
779
 
754
780
  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.
755
781
 
782
+ ### Choosing how the dev terminal renders
783
+
784
+ `PrettyDevDestination` takes a `view`. Every field defaults to what it rendered before, so `new PrettyDevDestination()` is unchanged — the options exist because the default view is deliberately verbose, and one entry can be seven lines.
785
+
786
+ ```typescript
787
+ // One line per entry, with the fields your project repeats on every line hidden.
788
+ new PrettyDevDestination({
789
+ view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event\\.name' }
790
+ })
791
+
792
+ // Message only, with the context pulled back into the line.
793
+ new PrettyDevDestination({
794
+ view: { hideObject: true, messageFormat: '[{context}] {msg}' }
795
+ })
796
+ ```
797
+
798
+ | Field | Default | Notes |
799
+ | --------------- | ------------------------ | ---------------------------------------------------------------------------- |
800
+ | `singleLine` | `false` | The single biggest change to how a terminal reads |
801
+ | `ignore` | `'pid,hostname,service'` | Display-only — what a real sink receives is untouched |
802
+ | `hideObject` | `false` | Hides the record entirely; see the caveat below |
803
+ | `messageFormat` | — | e.g. `'[{context}] {msg}'`; how to keep one field visible under `hideObject` |
804
+ | `translateTime` | `'SYS:HH:MM:ss.l'` | Or `false` for the raw timestamp |
805
+ | `colorize` | `true` | ANSI colour |
806
+
807
+ > **`hideObject` hides it everywhere.** In pretty mode there is no JSON copy behind the rendering, because `destinations` **replaces** stdout — so a field hidden here, `logKey` included, is not visible anywhere. That is what the option is for; `messageFormat` is how you pull a specific field back.
808
+
809
+ > **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
+ >
811
+ > ```
812
+ > ignore: '…,event.name' → INFO: msg {"logKey":"X","event.name":"x"} ← still there
813
+ > ignore: '…,event\\.name' → INFO: msg {"logKey":"X"} ← hidden
814
+ > ```
815
+ >
816
+ > In a TypeScript string literal that is `'event\\.name'` — one real backslash reaching `pino-pretty`. `service` and `deployment` need no escape; they really are nested objects. Reported by a consumer who copied the example above when it still had the unescaped form.
817
+
818
+ 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
+
820
+ > **`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
+
822
+ **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.
823
+
756
824
  ### Postgres destination (Prisma)
757
825
 
758
826
  ```typescript
@@ -975,6 +1043,14 @@ Trace context is never copied verbatim into a log entry. The OTel mixin reads th
975
1043
 
976
1044
  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.
977
1045
 
1046
+ ### A closed pipe does not kill the process
1047
+
1048
+ `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.
1049
+
1050
+ Every write this library makes to a process stream — the default stdout sink, the last-resort rescue, the pre-init drain, the `stderr` failure reports — installs a swallowing `'error'` listener on first use. **The side effect is worth knowing:** that listener is on the shared `process.stdout`, so after the first log line an EPIPE raised by _your_ writes stops being an uncaught exception too. The alternative is the crash above. Your own `'error'` listener is never removed or replaced — this only ever adds one.
1051
+
1052
+ If you write to `process.stdout` from a custom `ILogDestination`, do the same in your own sink; the [destinations guide](./docs/guidelines/DESTINATIONS-IMPLEMENTATION-GUIDELINES.md) has the exact pattern.
1053
+
978
1054
  ### Bounded field size
979
1055
 
980
1056
  Every serializer — the default `err` one and any you supply — is wrapped so a field whose serialized JSON exceeds `maxEntrySizeBytes` (64 KB by default) is replaced by a compact envelope: `_truncated`, `_logKey: 'LOGGER_ENTRY_TRUNCATED'`, `_originalSize`, and a 200-character `_preview`. An accidentally logged webhook payload costs a bounded number of bytes and leaves a record that truncation happened, instead of flooding the sink.