@bymax-one/nest-logger 1.2.4 → 1.2.6

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,214 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.2.6] - 2026-08-15
15
+
16
+ ### Added
17
+
18
+ - **`PrettyDevDestination` takes a `view`, so the terminal rendering is the consumer's choice.**
19
+ Requested with the measurement that makes the case — one real entry rendered three ways through
20
+ `pino-pretty`, where only the options differ:
21
+
22
+ ```
23
+ A) the built-in view 7 lines for one entry
24
+ B) singleLine + an extended ignore list 1 line, fields kept
25
+ C) hideObject + messageFormat '[{context}] {msg}' 1 line, message only
26
+ ```
27
+
28
+ All of it was reachable in `pino-pretty` and none of it was reachable through this library, which
29
+ hard-coded five options and read only `minLevel` from its constructor.
30
+
31
+ ```ts
32
+ new PrettyDevDestination({
33
+ view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event.name' }
34
+ })
35
+ ```
36
+
37
+ `PrettyViewOptions` covers `singleLine`, `ignore`, `hideObject`, `messageFormat`, `translateTime`
38
+ and `colorize`. **Additive** — every field defaults to what it rendered before, so
39
+ `new PrettyDevDestination()` is unchanged.
40
+
41
+ **`destination` is deliberately not exposed.** The library owns where entries go; a redirected
42
+ stream would route around the multistream fan-out and the last-resort rescue. It is absent from
43
+ the type AND applied after the consumer's options are spread, so an untyped JavaScript caller
44
+ cannot smuggle one in either — omitting it from a type is a suggestion, ordering the spread is the
45
+ guarantee.
46
+
47
+ The options bag is this library's **own** interface rather than `pino-pretty`'s `PrettyOptions`,
48
+ and that is load-bearing rather than stylistic: a type import from the peer emits a hard
49
+ `import { PrettyOptions } from 'pino-pretty'` on line 4 of the published `.d.ts`, so every consumer
50
+ who type-checks without the optional peer installed would hit an unresolved module. That would make
51
+ an optional peer effectively required. Measured on the built artifact.
52
+
53
+ ### Fixed
54
+
55
+ - **Entries emitted before the pretty transform exists are now rendered, not dumped raw.** A real
56
+ boot put **43 raw NDJSON lines on screen before the first rendered one** — every entry NestJS emits
57
+ while instantiating providers, because the transform cannot be built until `onInit` (loading the
58
+ optional peer is async). Nothing was ever lost, but a developer who had just enabled pretty output
59
+ saw a screen of JSON and concluded it had not worked. That is what was reported.
60
+
61
+ Those entries are now held and flushed **through** the transform once it exists, in arrival order.
62
+ Nothing is lost on any path, which is the property the raw passthrough had and the buffer had to
63
+ keep:
64
+
65
+ - **init fails** → the held entries are drained raw, because the renderer they were waiting for is
66
+ never coming;
67
+ - **a bound is reached** (1000 entries or 4 MiB, whichever trips first — nothing guarantees
68
+ `onInit` ever runs) → the buffer drains raw _before_ the entry that tripped it, so output stays
69
+ in order rather than replaying old entries after newer ones;
70
+ - **shutdown before init** → drained raw rather than dying with the process.
71
+
72
+ The byte ceiling is there because the entry count alone does not bound memory: a payload has no
73
+ whole-record size limit (`maxEntrySizeBytes` bounds what a serializer emits for one field, not the
74
+ entry), so 1000 held entries carrying large metadata could retain far more than the count suggests
75
+ — where the raw passthrough this buffer replaced retained nothing at all.
76
+
77
+ - **A closed pipe can no longer crash the host, which the `try/catch` around
78
+ `process.stdout.write` never actually prevented.** Every fallback path in this library — the
79
+ last-resort NDJSON rescue, the pre-init drain, the destination failure reports — wrapped its raw
80
+ write in a `try/catch` and documented that as EPIPE protection. It is not, and this was measured
81
+ rather than argued:
82
+
83
+ ```
84
+ stdout error listeners at start: 0
85
+ sync result: no-throw ← the try/catch caught nothing
86
+ UNCAUGHT:EPIPE ← arrived later, as an uncaught exception
87
+ child exit code: 42 ← the process died
88
+ ```
89
+
90
+ A closed pipe (`node app | head`) reports EPIPE **asynchronously**, through the stream's `'error'`
91
+ event, after `write()` has already returned. Node attaches no default handler, so the emit becomes
92
+ an uncaught exception. The guarantee was asserted in prose and absent from the code, on exactly the
93
+ paths whose purpose is to survive a broken sink.
94
+
95
+ Every library-owned write to a process stream now goes through one `safe-stdio` helper that
96
+ installs a swallow-EPIPE handler on first use, covering the asynchronous half; the `try/catch`
97
+ remains for the synchronous half, since either alone leaves a way to die. The handler is found by
98
+ identity rather than remembered, so anything that strips listeners from a process stream cannot
99
+ silently disable the protection permanently.
100
+
101
+ **This includes `DefaultStdoutDestination`, and that is the half that actually mattered.** The
102
+ first version of the fix guarded only the fallback paths — the rescue, the drain, the failure
103
+ reports — none of which a healthy application ever takes. The default sink, which is what a
104
+ consumer gets when they configure nothing, still called `process.stdout.write` directly, so the
105
+ ordinary install was exactly as crashable as before. Caught in review and then reproduced against
106
+ the built artifact: `0` listeners, no synchronous throw, `UNCAUGHT:EPIPE`, exit 42. Re-measured
107
+ after the fix: survived, exit 0.
108
+
109
+ `PrettyDevDestination` is deliberately **not** guarded by this library. `pino-pretty` receives
110
+ `process.stdout` and attaches two `'error'` listeners of its own; a child piped to a closed reader
111
+ survived and exited 0 with nothing from us on the stream, so a guard there would be code whose
112
+ need is disproven. A test pins that, and goes red if `pino-pretty` ever stops attaching them.
113
+
114
+ One consequence worth stating rather than burying: after the first log line, `process.stdout` has a
115
+ swallowing `'error'` listener that belongs to this library, so an EPIPE raised by **your** writes
116
+ to stdout also stops being an uncaught exception. That is the trade — the alternative is the crash
117
+ above — and your own `'error'` listener is never removed or replaced.
118
+
119
+ Reported by Copilot against the new pre-init buffer, then a second time against the incomplete
120
+ fix; the same ineffective pattern was already shipped in `1.2.3` and `1.2.5`, so this corrects
121
+ those paths too.
122
+
123
+ - **A write after a failed init is verifiably dropped, not merely silent.** Adding the buffer made
124
+ "dropped" and "held" observationally identical — the existing case asserted only that nothing
125
+ reached stdout, and buffering produces the same silence. A **cold** mutation run caught it
126
+ (the incremental runs had been reporting 100% while the cold run read 99.62%, with all three
127
+ survivors in this file). The case now shuts down after the failed init, which drains anything
128
+ still held: a merely-buffered entry surfaces there, a dropped one does not.
129
+
130
+ ## [1.2.5] - 2026-08-14
131
+
132
+ ### Fixed
133
+
134
+ - **An `Error` handed to the NestJS logger methods is no longer silently dropped.** Reported from a
135
+ real run rather than a unit test. `@bymax-one/nest-auth` logs the cause the way every caller does,
136
+ an SMTP `STARTTLS 502` failed, and this is what came out:
137
+
138
+ ```js
139
+ // the call site, nest-auth
140
+ this.logger.error(`delivery failed for "${subject}"`, error)
141
+ ```
142
+
143
+ ```json
144
+ {
145
+ "level": "error",
146
+ "context": "DefaultAuthEmailProvider",
147
+ "msg": "delivery failed for \"Verify your email address\""
148
+ }
149
+ ```
150
+
151
+ No `err`, no `stack`, no warning. The cause existed the whole time and was discarded. The dispatch
152
+ is fire-and-forget, so the HTTP response was `204` while nothing had been delivered: **the log was
153
+ the only surface where that failure could appear, and it appeared without its reason.** In CI you
154
+ can raise the level and re-run; in production nobody runs at `debug`, so the reason was never
155
+ written at all.
156
+
157
+ Neither side was obviously wrong, which is what made it survive. NestJS types the slot as
158
+ `error(message, stack?: string, context?: string)`, so this library read a string and discarded
159
+ anything else — implementing the contract faithfully. Passing the `Error` itself is what callers
160
+ actually do, because every other logger in the ecosystem accepts it.
161
+
162
+ **Measured before the fix: eleven of the twelve level/position combinations lost the `Error`.**
163
+ Only `error(err)` survived — every other level (`log`, `warn`, `debug`, `verbose`, `fatal`) routes
164
+ through a shared helper that handled no `Error` in either position. `fatal` was the worst of them,
165
+ being the level a caller reaches for when the process is about to die.
166
+
167
+ An `Error` anywhere in the variadic tail is now attached as `err`, the same field the leading-`Error`
168
+ branch already wrote, at every level. A trailing _string_ is still the `stack`, and `stack` is never
169
+ populated from an `Error` — the `err` serializer derives it, so writing both would emit the same
170
+ trace under two names.
171
+
172
+ **Detection is structural, not `instanceof`.** The first version of this fix used
173
+ `instanceof Error`, which is realm-local — so it would still have dropped a genuine `Error` built in
174
+ a `vm` context, and one already normalized into a plain `{ name, message, stack }`, which is what
175
+ `HttpExceptionFilter` produces and what any error crossing a process or worker boundary becomes.
176
+ That is the population most likely to be carrying the reason something failed, and this repository
177
+ had already recorded the same lesson in `sanitizeError` ("too narrow, and the gap was a live
178
+ defect"). The bridge now shares `isErrorLike` with `sanitizeError`, so the two cannot disagree about
179
+ what an error is. The bar stays deliberately narrow — string `name` AND string `message` — so an
180
+ ordinary metadata object is not mistaken for a cause.
181
+
182
+ **Two output changes to expect, since "additive" would overstate it.** Nothing is removed or
183
+ renamed, and a caller passing a string stack is untouched. But a caller who was passing an `Error`
184
+ necessarily sees a difference, because that is the defect:
185
+
186
+ - entries that silently dropped a cause now carry an `err` object — a **new field** on lines that
187
+ previously had none, which a strict log-pipeline schema will see;
188
+ - `warn(new Error('x'))` and the other non-`error` levels now emit `msg: "x"` instead of
189
+ `msg: "Error: x"`, matching what `error(new Error('x'))` already produced. A query grepping for
190
+ the `"Error: "` prefix on those levels will stop matching.
191
+
192
+ Both are the point of the change rather than side effects, but neither is invisible.
193
+
194
+ Scope, as measured by the reporting consumer across the ten `@bymax-one/*` packages that backend
195
+ installs at its pinned versions: **37 affected call sites, all of them in `nest-auth`**, none
196
+ elsewhere. Two are security-relevant — a breach check that fails open and logs that it admitted a
197
+ password it could not verify, and the `onRefreshTokenReuseDetected` hook, the tripwire for a stolen
198
+ token. Both said something went wrong and threw away what.
199
+
200
+ ### Documentation
201
+
202
+ - **The `pnpm prune --prod` note named the wrong mechanism.** It described the peer surviving as a
203
+ _residue_ of an incomplete prune. A second consumer whose Dockerfile never prunes — a clean
204
+ `pnpm install --prod --frozen-lockfile` in a fresh runtime stage — measured the peer present anyway.
205
+ In both images the peer was recorded in the lockfile and pnpm placed it in the store for a
206
+ production-only install. The distinction matters because "we do a clean prod install, not a prune"
207
+ reads like an exemption and is not one; it is what that consumer expected before measuring their
208
+ own image.
209
+
210
+ The note is now phrased as **two measurements rather than a law of pnpm** — whether a prod install
211
+ always carries an optional peer, however the lockfile was produced, has not been tested here. The
212
+ first version of this note over-generalized from the evidence, which is the same class of error as
213
+ the two corrections it documents.
214
+
215
+ The note now also says what is different **elsewhere**: under npm's or yarn's flat `node_modules` a
216
+ pruned devDependency really is gone, so the warning must not be read as universal. And it records
217
+ the probe that nearly produced the opposite conclusion — `require.resolve` from the library's
218
+ app-visible path returns `MODULE_NOT_FOUND` while the lazy import still succeeds, because the
219
+ library resolves from its real `.pnpm/` path. Verify by behaviour, not from a path the library
220
+ never uses.
221
+
14
222
  ## [1.2.4] - 2026-08-14
15
223
 
16
224
  ### Documentation
@@ -922,7 +1130,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
922
1130
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
923
1131
  `release.yml`, Dependabot, and issue templates
924
1132
 
925
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...HEAD
1133
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.2.6...HEAD
1134
+ [1.2.6]: https://github.com/bymaxone/nest-logger/compare/v1.2.5...v1.2.6
1135
+ [1.2.5]: https://github.com/bymaxone/nest-logger/compare/v1.2.4...v1.2.5
926
1136
  [1.2.4]: https://github.com/bymaxone/nest-logger/compare/v1.2.3...v1.2.4
927
1137
  [1.2.3]: https://github.com/bymaxone/nest-logger/compare/v1.2.2...v1.2.3
928
1138
  [1.2.2]: https://github.com/bymaxone/nest-logger/compare/v1.2.1...v1.2.2
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
@@ -149,16 +149,31 @@ pnpm add @opentelemetry/api @opentelemetry/sdk-node
149
149
 
150
150
  ```
151
151
 
152
- > ⚠️ **`-D` does not make it absent in production.** Under pnpm, `pnpm prune --prod` removes the
153
- > **top-level link** but leaves the package in the store, linked beside this library as the resolved
154
- > optional peer — and `PrettyDevDestination` imports it lazily, relative to the library's own
155
- > directory, so it resolves. Verified inside a real pruned production image by a consumer who had
156
- > assumed the opposite.
152
+ > ⚠️ **Under pnpm, `-D` may not make it absent in production — measure, do not assume.** Two
153
+ > consumers who had assumed otherwise measured the peer **present** in their real production images,
154
+ > by two different build routes: `pnpm prune --prod` keeps the store entry, and a clean
155
+ > `pnpm install --prod --frozen-lockfile` in a fresh stage installs it too. In both, the peer was
156
+ > recorded in the lockfile. `PrettyDevDestination` imports it lazily, relative to the library's own
157
+ > directory under `.pnpm/`, where it is a sibling — so it resolves.
157
158
  >
158
- > So "it is a devDependency, therefore this path cannot run in production" is **not** a safe premise.
159
- > A `PrettyDevDestination` left registered in production will not crash and will not warn: it renders
160
- > ANSI colour into a log pipeline that has silently failed to parse every line since the deploy. Gate
161
- > it on an explicit configuration value you can see, not on the packaging.
159
+ > That is two measurements, not a law of pnpm: whether a production install always carries an optional
160
+ > peer, however the lockfile was produced, has not been tested here. The advice below does not depend
161
+ > on it.
162
+ >
163
+ > The clean-install case is worth stating separately, because "we do a fresh prod install, not a
164
+ > prune" reads like an exemption and is not one. It is what one of those consumers concluded before
165
+ > measuring their own image.
166
+ >
167
+ > **On other package managers this is different**, and the difference cuts the other way: under npm's
168
+ > or yarn's flat `node_modules`, a pruned devDependency really is gone. If you install with those,
169
+ > do not read this warning as universal.
170
+ >
171
+ > Either way, the rule that survives both layouts: **"it is a devDependency, therefore this path
172
+ > cannot run in production" is not a safe premise, because the premise is about someone else's
173
+ > install command.** A `PrettyDevDestination` left registered in production will not crash and will
174
+ > not warn — it renders ANSI colour into a log pipeline that has silently failed to parse every line
175
+ > since the deploy, which is indistinguishable from a service that went quiet. Gate it on an explicit
176
+ > configuration value you can see, not on the packaging.
162
177
 
163
178
  > [!NOTE]
164
179
  > `@opentelemetry/api` is resolved lazily when the Pino instance is built. When it is absent the trace mixin silently steps aside — the logger never fails to start, and never warns, over an optional peer.
@@ -738,6 +753,39 @@ The consequence worth knowing: a sink you supply may be the **only** one the app
738
753
 
739
754
  So the common accident — adding `new PrettyDevDestination()` without installing the optional `pino-pretty` — costs you colours and a line on stderr telling you why, never your logs.
740
755
 
756
+ ### Choosing how the dev terminal renders
757
+
758
+ `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.
759
+
760
+ ```typescript
761
+ // One line per entry, with the fields your project repeats on every line hidden.
762
+ new PrettyDevDestination({
763
+ view: { singleLine: true, ignore: 'pid,hostname,service,deployment,event.name' }
764
+ })
765
+
766
+ // Message only, with the context pulled back into the line.
767
+ new PrettyDevDestination({
768
+ view: { hideObject: true, messageFormat: '[{context}] {msg}' }
769
+ })
770
+ ```
771
+
772
+ | Field | Default | Notes |
773
+ | --------------- | ------------------------ | ---------------------------------------------------------------------------- |
774
+ | `singleLine` | `false` | The single biggest change to how a terminal reads |
775
+ | `ignore` | `'pid,hostname,service'` | Display-only — what a real sink receives is untouched |
776
+ | `hideObject` | `false` | Hides the record entirely; see the caveat below |
777
+ | `messageFormat` | — | e.g. `'[{context}] {msg}'`; how to keep one field visible under `hideObject` |
778
+ | `translateTime` | `'SYS:HH:MM:ss.l'` | Or `false` for the raw timestamp |
779
+ | `colorize` | `true` | ANSI colour |
780
+
781
+ > **`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.
782
+
783
+ 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.
784
+
785
+ > **`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.
786
+
787
+ **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.
788
+
741
789
  ### Postgres destination (Prisma)
742
790
 
743
791
  ```typescript
@@ -960,6 +1008,14 @@ Trace context is never copied verbatim into a log entry. The OTel mixin reads th
960
1008
 
961
1009
  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.
962
1010
 
1011
+ ### A closed pipe does not kill the process
1012
+
1013
+ `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.
1014
+
1015
+ 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.
1016
+
1017
+ 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.
1018
+
963
1019
  ### Bounded field size
964
1020
 
965
1021
  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.