@bymax-one/nest-logger 1.3.1 → 1.4.1

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,210 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.4.1] - 2026-10-23
15
+
16
+ No breaking changes.
17
+
18
+ ### Added
19
+
20
+ - **`PinoUnexpectedErrorSink` — the cause of a nest-core `500`, logged.** When nest-core's exception
21
+ filter turns an unexpected error into `500 BYMAX_INTERNAL_ERROR`, the access-log line carries the
22
+ cause only when this package's HTTP interceptor saw the error. An error raised before it (in a
23
+ guard or a middleware), or any error with HTTP logging off, left a synthetic
24
+ `Error("GET /url → 500")` or no line at all. nest-core 1.6.2 adds an
25
+ optional `BYMAX_UNEXPECTED_ERROR_SINK` token. This class implements it: one `error` entry per
26
+ unexpected error, keyed `HTTP_UNEXPECTED_ERROR`, with the original error's type, message, stack
27
+ and cause chain, plus `method`, the route template (`route`, never the raw URL) and
28
+ `correlationId`. It adds nothing else from the request — no body, headers or query string — and
29
+ `record()` never throws. The error keeps its own enumerable fields, as on every `err` entry, so
30
+ request data an error object carries itself is logged under the usual key-name redaction.
31
+
32
+ This package still does not depend on nest-core. The class matches nest-core's contract by
33
+ shape, and the types it implements are exported as `IUnexpectedErrorSink` and
34
+ `UnexpectedErrorReport`.
35
+
36
+ `HTTP_UNEXPECTED_ERROR` joins `RESERVED_LOG_KEYS`. An application that already writes that key
37
+ for its own events should rename them, or its queries will mix the two.
38
+
39
+ #### Apply to a derived backend
40
+
41
+ Requires `@bymax-one/nest-core` 1.6.2 or later. Nothing changes until you bind the sink: the token
42
+ is optional and unbound by default. Bind it from a `@Global()` module imported next to
43
+ `BymaxLoggerModule`:
44
+
45
+ ```typescript
46
+ @Global()
47
+ @Module({
48
+ providers: [{ provide: BYMAX_UNEXPECTED_ERROR_SINK, useClass: PinoUnexpectedErrorSink }],
49
+ exports: [BYMAX_UNEXPECTED_ERROR_SINK]
50
+ })
51
+ export class UnexpectedErrorSinkModule {}
52
+ ```
53
+
54
+ `BYMAX_UNEXPECTED_ERROR_SINK` comes from `@bymax-one/nest-core` and `PinoUnexpectedErrorSink` from
55
+ `@bymax-one/nest-logger`. To verify, make a route throw a plain `Error`: the response stays
56
+ `500 BYMAX_INTERNAL_ERROR`, and the log gains one `HTTP_UNEXPECTED_ERROR` line next to the
57
+ access-log line.
58
+
59
+ ## [1.4.0] - 2026-08-30
60
+
61
+ No breaking changes. Nothing you have wired stops working, and no export was renamed.
62
+
63
+ ### Added
64
+
65
+ - **`applyAccessLog(app)` — the access log mounted ahead of the body parser.** A request the body
66
+ parser rejects never reaches module middleware, so until now it never reached the access log
67
+ either. NestJS registers the parser one line before it registers module middleware
68
+ (`@nestjs/core/nest-application.js`: `registerParserMiddleware()` then `registerModules()`), and
69
+ Express dispatches in registration order, so `next(err)` from the parser skips every remaining
70
+ non-error handler — module middleware, guards, interceptors and the route handler alike. A `POST`
71
+ carrying truncated JSON produced no access log at all, and the same hole covers a payload over the
72
+ body limit and an unsupported content type: the requests a client got wrong or an attacker shaped.
73
+
74
+ What you got before this release depended on your wiring, and neither answer was good. With
75
+ `forRoot` and the default `shouldCaptureExceptions`, the filter **did** catch the parser rejection
76
+ and emit one `HTTP_EXCEPTION_HANDLED` line — measured, not assumed — but with **no `requestId`**,
77
+ because the correlation scope is mounted behind the parser too and never opened. The client could
78
+ send `x-request-id` and the entry carried nothing to join it to. With `forRootAsync`, or with
79
+ `shouldCaptureExceptions: false`, there was nothing at all. A 400 you cannot trace to the caller
80
+ is the same defect as a missing 400, wearing a healthier face.
81
+
82
+ `INestApplication.use()` delegates straight to the HTTP adapter and `init()` — where the parser is
83
+ registered — does not run until `listen()`, so mounting from `main.ts` lands ahead of the parser,
84
+ the same reason `helmet` and `cookie-parser` are mounted that way. `HttpAccessLogMiddleware`
85
+ needed no new logic to work there: it already emits from the response's `'close'` event, reading
86
+ `statusCode` and `writableFinished`, so it never depended on a route matching, a handler running
87
+ or a filter catching anything. It was written correctly and mounted too late.
88
+
89
+ One line in and one line out now cover the parser rejection, the unmatched-route 404, the guard
90
+ rejection and the aborted connection alike. Call it after `NestFactory.create()` and **before the
91
+ application initializes** — that is, before `app.init()`. `listen()` only triggers `init()` when it
92
+ has not already run, so "before `listen()`" is the same deadline for the usual bootstrap and the
93
+ WRONG one for a serverless entry point or an integration test that does `await app.init()` and
94
+ wires up afterwards: that mount lands behind the parser and logs nothing extra, with no error to
95
+ say so.
96
+
97
+ - **`HttpAccessLogMiddleware` is exported.** Without the class, an early mount was impossible to
98
+ express — a consumer with its own bootstrap sequence had no way to reach it. It is the same class
99
+ `applyRequestIdMiddleware` has always wired; only its visibility changed.
100
+
101
+ - **`LogContextService.runMerged(context, callback)`** — a scope that EXTENDS the enclosing one
102
+ instead of replacing it, for the case where an inner scope is genuinely a refinement of the outer:
103
+ adding a `userId` once authentication resolved it, without restating the `requestId` the
104
+ middleware already set. Outside any scope it behaves exactly like `run()`. Writes inside it never
105
+ reach the enclosing context — the merge produces a fresh object.
106
+
107
+ ### Fixed
108
+
109
+ - **Two mounts of `RequestIdMiddleware` minted two different correlation ids.** `use()` sets the id
110
+ on the RESPONSE header and never writes it back onto `req.headers`, so a second mount with no
111
+ inbound header saw nothing and minted its own: the outer set the response header to one UUID and
112
+ the inner overwrote the log context with another, leaving the header and the entries disagreeing
113
+ about which id the request had. Each answer was internally consistent, which is why it survived —
114
+ nothing looked broken from either side alone.
115
+
116
+ The middleware is now idempotent, off two marks kept on the REQUEST rather than off a header
117
+ write-back. Writing the minted id back onto `req.headers` would have worked and was rejected
118
+ deliberately: it conflates "the client sent this id" with "we minted this id", and anything
119
+ downstream treating an inbound header as client-attested would start reading our own UUID as the
120
+ caller's.
121
+
122
+ **What is adopted is the id this library validated for this request — not whatever the log context
123
+ happens to hold**, and the difference decides how a gateway-provided id has to be passed. An id
124
+ that reaches the middleware only through an enclosing `LogContextService` scope is never adopted
125
+ and never echoed. With generation on, the request gets its own id: the inbound `x-request-id`, else
126
+ a fresh UUID. With `shouldGenerateRequestId: false` and no inbound header, an enclosing `requestId`
127
+ is inherited into the request's entries but no response header is set. **To have an upstream id
128
+ echoed, send it as `x-request-id`.**
129
+
130
+ The marks are two because they answer two questions and either can be true alone. One records that
131
+ a scope for this request was opened, and decides whether a second mount opens another. The other
132
+ records the validated id, and decides what may be echoed — read from the request rather than the
133
+ store, so a value middleware replaced between the mounts (`set()` takes `unknown`) cannot reach the
134
+ response header. The adopt path also **restores** the validated id to the active context, because
135
+ the emitted entry reads `requestId` from the store: echoing one value while leaving another in the
136
+ store would produce the same header/entry split this change exists to remove.
137
+
138
+ The request's own scope now starts from the enclosing scope's fields rather than from nothing,
139
+ which fixes a second thing in the same place: a consumer that opened its own scope upstream — a
140
+ tenant resolved at the edge — had it **discarded** for the whole request, because `run()` replaces
141
+ the context.
142
+
143
+ What it inherits is an ALLOWLIST — `requestId` and `tenantId`, the library's own correlation
144
+ fields — and that shape took three review rounds to reach. A denylist cannot be made safe here:
145
+ `LogContext` explicitly permits arbitrary consumer keys, so an application-defined `accountId` or
146
+ `sessionId` misattributes a request exactly the way `userId` does, and no list of forbidden names
147
+ is ever complete. Measured on the denylist version: a request opened inside a scope holding
148
+ `{ accountId, sessionId }` inherited both.
149
+
150
+ The identity cases the denylist did catch are worth recording, because they are what the allowlist
151
+ now covers by construction. `userId` is resolved by a guard that runs after this middleware, and
152
+ the mixin copies the store onto every entry while `PinoLoggerService` deliberately does not write
153
+ `userId: undefined` when the argument is omitted — so an anonymous request inside a scope holding
154
+ `userId: 'admin-u1'` was logged under `admin-u1`. Trace fields leak the same way, in every naming:
155
+ the mixin overwrites them from the active span but only when there IS one, and the field names are
156
+ configurable (`otel.fieldFormat: 'snake_case'` renames all three), so a literal exclusion was
157
+ wrong in both directions at once.
158
+
159
+ Dropping a field a consumer set upstream is not a regression — before this release `run()`
160
+ discarded all of it — and it fails in the safe direction: a missing field is visibly missing,
161
+ where an inherited identity is wrong while looking right.
162
+
163
+ - **A `g` or `y` flag on an `excludePaths` pattern excluded the path only every other time.**
164
+ `RegExp.prototype.test` on such a pattern advances its `lastIndex` and resumes from it, so the
165
+ same pattern tested twice against the same path returns `true`, then `false`. Measured on
166
+ `/^\/health$/g` against `/health`: `true, false, true, false`. The patterns live in module
167
+ options — one frozen array shared by the access-log middleware and the HTTP interceptor for the
168
+ lifetime of the process — so a consumer who carried a `g` flag in from a copied pattern saw
169
+ excluded health checks reappear at half rate, with no error and nothing to suggest the config was
170
+ not working.
171
+
172
+ This predates `1.4.0`; mounting both wiring helpers only made it sharper, because the two mounts
173
+ then test the same pattern twice within a single request and disagree outright. Both call sites now
174
+ go through one matcher that clears the stored position before testing, so the answer depends only
175
+ on the pattern and the path. Flags that say WHAT matches (`i`, `m`, `s`, `u`) are the consumer's
176
+ expressed intent and are untouched. Raised by Codex.
177
+
178
+ The clear happens only for a pattern that actually carries a position, and that guard is
179
+ load-bearing: `Object.freeze` on a `RegExp` — which a deep-freeze over a config module reaches —
180
+ makes `lastIndex` non-writable, and under ESM's strict mode assigning to it throws
181
+ `TypeError: Cannot assign to read only property 'lastIndex'` while `.test()` on the same pattern
182
+ works. Clearing unconditionally would have turned a working configuration into a throw on the
183
+ request path. A frozen pattern that IS global stays broken, and not by this: `test()` writes
184
+ `lastIndex` itself, so it throws before any of this runs.
185
+
186
+ - **A second mount of `HttpAccessLogMiddleware` doubled every access-log line.** It claims the
187
+ request's log lifecycle so the interceptor stays silent, but never checked whether the claim was
188
+ already made. It now stands down on an already-claimed request: one START and one terminal entry
189
+ per request no matter how many times it is mounted. The claim is per-request state, so a
190
+ different request is unaffected.
191
+
192
+ ### Documentation
193
+
194
+ - **`run()` now says that a nested scope DISCARDS the enclosing one.** The docblock said "a fresh
195
+ context scope", which does not tell a reader that an outer scope's fields are gone for the whole
196
+ inner one — nothing throws, `get()` just returns `undefined`, and the field is missing from every
197
+ entry the callback emits. Reported by a consumer that lost a `tenantId` resolved at the edge.
198
+
199
+ Replacement remains the default, and the reason is the direction the other choice leaks: a
200
+ background job started inside a request scope would silently inherit the caller's `userId` and
201
+ attribute its entries to a user who never triggered it. A missing field is visibly missing; an
202
+ inherited one is wrong while looking right. `runMerged()` is the explicit opt-in.
203
+
204
+ - **The README states what is not logged, and what a rejected request actually produces today.** The
205
+ absence read as a misconfiguration to whoever went looking for it, and cost a consumer real time
206
+ before it was traced to the parser. The new §5 subsection carries the ordering, the two-row table
207
+ of what each wiring emits, and the `main.ts` snippet.
208
+
209
+ - **`applyAccessLog` names `init()` as its deadline, not `listen()`.** `listen()` only triggers
210
+ `init()` when it has not run yet, so "before `listen()`" is true for the usual bootstrap and
211
+ false for a serverless entry point or an integration test that does `await app.init()` and wires
212
+ up afterwards — that mount lands behind the parser and logs nothing extra, with no error to say
213
+ so. A late mount is not rejected at runtime because NestJS exposes no supported signal for it
214
+ (`isInitialized` is private, and inspecting the adapter's router would break on a patch release
215
+ while claiming to be a safety net), so the deadline is documented at the call site instead. Raised
216
+ by Codex.
217
+
14
218
  ## [1.3.1] - 2026-08-18
15
219
 
16
220
  ### Documentation
@@ -1822,7 +2026,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
1822
2026
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
1823
2027
  `release.yml`, Dependabot, and issue templates
1824
2028
 
1825
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.3.1...HEAD
2029
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.4.1...HEAD
2030
+ [1.4.1]: https://github.com/bymaxone/nest-logger/compare/v1.4.0...v1.4.1
2031
+ [1.4.0]: https://github.com/bymaxone/nest-logger/compare/v1.3.1...v1.4.0
1826
2032
  [1.3.1]: https://github.com/bymaxone/nest-logger/compare/v1.3.0...v1.3.1
1827
2033
  [1.3.0]: https://github.com/bymaxone/nest-logger/compare/v1.2.9...v1.3.0
1828
2034
  [1.2.9]: https://github.com/bymaxone/nest-logger/compare/v1.2.7...v1.2.9
package/README.md CHANGED
@@ -301,7 +301,7 @@ Output (pretty-print, development):
301
301
 
302
302
  ### 5. HTTP logging (automatic)
303
303
 
304
- Enable `http.isEnabled: true` in the module options and wire `applyRequestIdMiddleware(consumer)` (see [Context propagation](#7-context-propagation-with-logcontextservice)). The access log is recorded from **middleware**, which emits:
304
+ Enable `http.isEnabled: true` in the module options and wire the access log — `applyAccessLog(app)` in `main.ts` (recommended, see [Requests rejected before routing](#requests-rejected-before-routing)) or `applyRequestIdMiddleware(consumer)` (see [Context propagation](#7-context-propagation-with-logcontextservice)). The access log is recorded from **middleware**, which emits:
305
305
 
306
306
  | Log key | When |
307
307
  | --------------------------- | --------------------------------------------------- |
@@ -332,6 +332,73 @@ URLs are automatically normalized — `/users/550e8400-e29b-41d4-a716-4466554400
332
332
  > A consumer who does not wire the middleware keeps the previous interceptor-based behaviour rather
333
333
  > than losing HTTP logs — including its blind spot.
334
334
 
335
+ #### Requests rejected before routing
336
+
337
+ Middleware wired through `configure(consumer)` is not early enough for every request. NestJS
338
+ registers the body parser **one line before** it registers module middleware:
339
+
340
+ ```js
341
+ // @nestjs/core/nest-application.js
342
+ useBodyParser && this.registerParserMiddleware()
343
+ await this.registerModules() // everything from configure(consumer)
344
+ ```
345
+
346
+ Express dispatches in registration order, so when the parser rejects a body it calls `next(err)`
347
+ and every remaining non-error handler is skipped — module middleware, guards, interceptors and the
348
+ route handler all never run. **A `POST` carrying truncated JSON therefore produces no access log
349
+ at all**, and the same hole covers a payload over the body limit and an unsupported content type:
350
+ exactly the requests a client got wrong or an attacker shaped.
351
+
352
+ What you get today depends on your wiring, and neither answer is good:
353
+
354
+ | Wiring | A body the parser rejects produces |
355
+ | ------------------------------------------------------ | --------------------------------------------------------- |
356
+ | `forRoot` with `shouldCaptureExceptions` (the default) | **one** `HTTP_EXCEPTION_HANDLED` line, **no `requestId`** |
357
+ | `forRootAsync`, or `shouldCaptureExceptions: false` | **nothing at all** |
358
+
359
+ The first row is the one worth reading twice. The line exists, so a dashboard counting entries
360
+ looks healthy — but the correlation scope is mounted behind the parser too, so it never opened:
361
+ the client can send `x-request-id: measure-me` and the entry carries no `requestId` to join it to.
362
+ A 400 you cannot trace to the caller is the same defect as a missing 400 wearing a healthier face.
363
+
364
+ `applyAccessLog(app)` closes both rows. `INestApplication.use()` delegates straight to the HTTP
365
+ adapter and `init()` (where the parser is registered) does not run until `listen()`, so mounting
366
+ from `main.ts` lands **ahead** of the parser — the same reason `helmet` and `cookie-parser` are
367
+ mounted this way:
368
+
369
+ ```typescript
370
+ // main.ts
371
+ import { NestFactory } from '@nestjs/core'
372
+ import { BymaxLoggerModule, applyAccessLog } from '@bymax-one/nest-logger'
373
+ import { AppModule } from './app.module'
374
+
375
+ async function bootstrap() {
376
+ const app = await NestFactory.create(AppModule, { bufferLogs: true })
377
+ BymaxLoggerModule.useNestLogger(app)
378
+
379
+ // BEFORE the app initializes. init() is where the parser is mounted, and
380
+ // listen() only triggers init() when you have not called it yourself — so a
381
+ // serverless entry point or a test that does `await app.init()` first must
382
+ // mount before THAT, not merely before listen().
383
+ applyAccessLog(app)
384
+
385
+ await app.listen(3000)
386
+ }
387
+
388
+ void bootstrap()
389
+ ```
390
+
391
+ The access log needs nothing else to work there — it emits from the response's `'close'` event,
392
+ reading `statusCode` and `writableFinished`, so it never depends on a route matching, a handler
393
+ running or a filter catching anything. One line in, one line out, for the parser rejection, the
394
+ unmatched-route 404, the guard rejection and the aborted connection alike.
395
+
396
+ `applyRequestIdMiddleware(consumer)` remains supported and unchanged; it is the right choice when
397
+ correlation should be scoped to a route subtree rather than the whole application. **Wiring both is
398
+ safe** — the correlation middleware adopts an id already in scope instead of minting a second one,
399
+ and the access log stands down when it finds the request's lifecycle already claimed, so nothing is
400
+ minted twice and nothing is logged twice.
401
+
335
402
  **Delivery is reported separately from status.** `HTTP_REQUEST_ABORTED` is emitted when the
336
403
  connection closed before the response was flushed, and it **keeps the real status the server
337
404
  produced** rather than inventing one: nginx's `499` is not an HTTP status (IANA leaves 452–499
@@ -344,6 +411,48 @@ flushed** is reported as the success it was — the server completed and flushed
344
411
  peer read it is not knowable from the server side. What this catches is the case that matters
345
412
  operationally: the slow upstream, the load-balancer timeout, the cancelled request.
346
413
 
414
+ #### Unexpected errors behind `@bymax-one/nest-core`
415
+
416
+ When nest-core's exception filter answers, an unexpected error becomes a fixed
417
+ `500 BYMAX_INTERNAL_ERROR`. The access-log line for that response carries the cause only when this
418
+ package's HTTP interceptor saw the error, which it does not for an error raised before it (in a
419
+ guard or a middleware) or with HTTP logging off. nest-core 1.6.2 adds an optional sink token, `BYMAX_UNEXPECTED_ERROR_SINK`, and
420
+ `PinoUnexpectedErrorSink` is a ready implementation of it. Bind it from a global module:
421
+
422
+ ```typescript
423
+ import { Global, Module } from '@nestjs/common'
424
+ import { BYMAX_UNEXPECTED_ERROR_SINK } from '@bymax-one/nest-core'
425
+ import { PinoUnexpectedErrorSink } from '@bymax-one/nest-logger'
426
+
427
+ @Global()
428
+ @Module({
429
+ providers: [{ provide: BYMAX_UNEXPECTED_ERROR_SINK, useClass: PinoUnexpectedErrorSink }],
430
+ exports: [BYMAX_UNEXPECTED_ERROR_SINK]
431
+ })
432
+ export class UnexpectedErrorSinkModule {}
433
+ ```
434
+
435
+ Import it next to `BymaxLoggerModule`. The binding resolves `PinoLoggerService` because
436
+ `BymaxLoggerModule` is global by default; with `isGlobal: false`, add the same `forRoot`/`forRootAsync` result the
437
+ application imports to the sink module's `imports`. Keep it in a constant: calling `forRoot` a
438
+ second time builds a second logger. Each unexpected error then produces one `error` entry with the key
439
+ `HTTP_UNEXPECTED_ERROR`:
440
+
441
+ | Field | Content |
442
+ | --------------- | ------------------------------------------------------------------------------------------------------- |
443
+ | `err` | The original error through the `err` serializer: type, message, scrubbed stack, cause chain, own fields |
444
+ | `method` | HTTP method |
445
+ | `route` | Matched route template (`/invoices/:id`), or `<unmatched>` before routing — never the raw URL |
446
+ | `correlationId` | nest-core's correlation id, when one resolved |
447
+
448
+ The sink adds nothing from the request beyond `method`, `route` and `correlationId`: no body,
449
+ headers or query string. The error is serialized as on every `err` entry, own enumerable fields
450
+ included, so an error object that itself carries request data (a `url` with a query string, a copy
451
+ of the headers) brings that data along, under the same key-name redaction. The trace id comes from the trace mixin, as on every other entry. `record()`
452
+ never throws, so a failing destination cannot change the response. This package does not import
453
+ nest-core: the class matches nest-core's contract by shape, and `IUnexpectedErrorSink` /
454
+ `UnexpectedErrorReport` are exported for reference.
455
+
347
456
  ### 6. OpenTelemetry correlation
348
457
 
349
458
  Initialize the OTel SDK **before** importing NestJS — this is critical:
@@ -662,7 +771,7 @@ function assertValidLogKey(key: string) {
662
771
 
663
772
  The following keys are used internally by the library — do not reuse them in application code:
664
773
 
665
- `LOGGER_BOOTSTRAP_OK` · `LOGGER_BOOTSTRAP_WARNING` · `LOGGER_SHUTDOWN_OK` · `HTTP_REQUEST_START` · `HTTP_REQUEST_SUCCESS` · `HTTP_REQUEST_REDIRECT` · `HTTP_REQUEST_CLIENT_ERROR` · `HTTP_REQUEST_SERVER_ERROR` · `HTTP_REQUEST_ABORTED` · `HTTP_REQUEST_COMPLETED` · `HTTP_EXCEPTION_HANDLED` · `HTTP_EXCEPTION_UNHANDLED` · `METHOD_EXECUTION` · `METHOD_SLOW_EXECUTION` · `LOGGER_DESTINATION_INIT_FAILED` · `LOGGER_DESTINATION_WRITE_FAILED` · `LOGGER_ENTRY_TRUNCATED` · `LOGGER_REDACTION_FAILED`
774
+ `LOGGER_BOOTSTRAP_OK` · `LOGGER_BOOTSTRAP_WARNING` · `LOGGER_SHUTDOWN_OK` · `HTTP_REQUEST_START` · `HTTP_REQUEST_SUCCESS` · `HTTP_REQUEST_REDIRECT` · `HTTP_REQUEST_CLIENT_ERROR` · `HTTP_REQUEST_SERVER_ERROR` · `HTTP_REQUEST_ABORTED` · `HTTP_REQUEST_COMPLETED` · `HTTP_EXCEPTION_HANDLED` · `HTTP_EXCEPTION_UNHANDLED` · `HTTP_UNEXPECTED_ERROR` · `METHOD_EXECUTION` · `METHOD_SLOW_EXECUTION` · `LOGGER_DESTINATION_INIT_FAILED` · `LOGGER_DESTINATION_WRITE_FAILED` · `LOGGER_ENTRY_TRUNCATED` · `LOGGER_REDACTION_FAILED`
666
775
 
667
776
  All reserved keys are exported as the `RESERVED_LOG_KEYS` constant from `@bymax-one/nest-logger/shared`.
668
777
 
@@ -1227,11 +1336,24 @@ There are **two** APIs on this class, and they do not share a signature shape.
1227
1336
 
1228
1337
  ### `LogContextService`
1229
1338
 
1230
- | Method | Signature | Description |
1231
- | ---------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
1232
- | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields |
1233
- | `set` | `(key, value)` | Adds/updates a field in the current scope. Throws if called outside `run()`. Fields set here reach every subsequent log in the scope |
1234
- | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
1339
+ | Method | Signature | Description |
1340
+ | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
1341
+ | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields. **A nested `run()` REPLACES the enclosing context** — see below |
1342
+ | `runMerged` | `(store, callback)` | Opens a scope that EXTENDS the enclosing one: outer fields are carried in, `store` overrides them key by key |
1343
+ | `set` | `(key, value)` | Adds/updates a field in the current scope. Throws if called outside `run()`. Fields set here reach every subsequent log in the scope |
1344
+ | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
1345
+
1346
+ > [!IMPORTANT]
1347
+ > **A nested `run()` discards the enclosing context.** The inner scope starts from its own `store`
1348
+ > alone, so a field an outer scope had set — a `tenantId` resolved at the edge, say — is absent for
1349
+ > the whole inner scope. Nothing throws; `get()` simply returns `undefined` and the field is missing
1350
+ > from every entry the callback emits, which is the kind of thing found by losing a field in
1351
+ > production. Use `runMerged()` when the inner scope should extend the outer one.
1352
+ >
1353
+ > Replacement stays the default because the opposite leaks in a worse direction: a background job
1354
+ > started inside a request scope would silently inherit the caller's `userId` and attribute its
1355
+ > entries to a user who never triggered it. A missing field is visibly missing; an inherited one is
1356
+ > wrong while looking right.
1235
1357
 
1236
1358
  ### Decorators
1237
1359