@bymax-one/nest-logger 1.3.0 → 1.4.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 CHANGED
@@ -11,6 +11,193 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.4.0] - 2026-08-30
15
+
16
+ No breaking changes. Nothing you have wired stops working, and no export was renamed.
17
+
18
+ ### Added
19
+
20
+ - **`applyAccessLog(app)` — the access log mounted ahead of the body parser.** A request the body
21
+ parser rejects never reaches module middleware, so until now it never reached the access log
22
+ either. NestJS registers the parser one line before it registers module middleware
23
+ (`@nestjs/core/nest-application.js`: `registerParserMiddleware()` then `registerModules()`), and
24
+ Express dispatches in registration order, so `next(err)` from the parser skips every remaining
25
+ non-error handler — module middleware, guards, interceptors and the route handler alike. A `POST`
26
+ carrying truncated JSON produced no access log at all, and the same hole covers a payload over the
27
+ body limit and an unsupported content type: the requests a client got wrong or an attacker shaped.
28
+
29
+ What you got before this release depended on your wiring, and neither answer was good. With
30
+ `forRoot` and the default `shouldCaptureExceptions`, the filter **did** catch the parser rejection
31
+ and emit one `HTTP_EXCEPTION_HANDLED` line — measured, not assumed — but with **no `requestId`**,
32
+ because the correlation scope is mounted behind the parser too and never opened. The client could
33
+ send `x-request-id` and the entry carried nothing to join it to. With `forRootAsync`, or with
34
+ `shouldCaptureExceptions: false`, there was nothing at all. A 400 you cannot trace to the caller
35
+ is the same defect as a missing 400, wearing a healthier face.
36
+
37
+ `INestApplication.use()` delegates straight to the HTTP adapter and `init()` — where the parser is
38
+ registered — does not run until `listen()`, so mounting from `main.ts` lands ahead of the parser,
39
+ the same reason `helmet` and `cookie-parser` are mounted that way. `HttpAccessLogMiddleware`
40
+ needed no new logic to work there: it already emits from the response's `'close'` event, reading
41
+ `statusCode` and `writableFinished`, so it never depended on a route matching, a handler running
42
+ or a filter catching anything. It was written correctly and mounted too late.
43
+
44
+ One line in and one line out now cover the parser rejection, the unmatched-route 404, the guard
45
+ rejection and the aborted connection alike. Call it after `NestFactory.create()` and **before the
46
+ application initializes** — that is, before `app.init()`. `listen()` only triggers `init()` when it
47
+ has not already run, so "before `listen()`" is the same deadline for the usual bootstrap and the
48
+ WRONG one for a serverless entry point or an integration test that does `await app.init()` and
49
+ wires up afterwards: that mount lands behind the parser and logs nothing extra, with no error to
50
+ say so.
51
+
52
+ - **`HttpAccessLogMiddleware` is exported.** Without the class, an early mount was impossible to
53
+ express — a consumer with its own bootstrap sequence had no way to reach it. It is the same class
54
+ `applyRequestIdMiddleware` has always wired; only its visibility changed.
55
+
56
+ - **`LogContextService.runMerged(context, callback)`** — a scope that EXTENDS the enclosing one
57
+ instead of replacing it, for the case where an inner scope is genuinely a refinement of the outer:
58
+ adding a `userId` once authentication resolved it, without restating the `requestId` the
59
+ middleware already set. Outside any scope it behaves exactly like `run()`. Writes inside it never
60
+ reach the enclosing context — the merge produces a fresh object.
61
+
62
+ ### Fixed
63
+
64
+ - **Two mounts of `RequestIdMiddleware` minted two different correlation ids.** `use()` sets the id
65
+ on the RESPONSE header and never writes it back onto `req.headers`, so a second mount with no
66
+ inbound header saw nothing and minted its own: the outer set the response header to one UUID and
67
+ the inner overwrote the log context with another, leaving the header and the entries disagreeing
68
+ about which id the request had. Each answer was internally consistent, which is why it survived —
69
+ nothing looked broken from either side alone.
70
+
71
+ The middleware is now idempotent, off two marks kept on the REQUEST rather than off a header
72
+ write-back. Writing the minted id back onto `req.headers` would have worked and was rejected
73
+ deliberately: it conflates "the client sent this id" with "we minted this id", and anything
74
+ downstream treating an inbound header as client-attested would start reading our own UUID as the
75
+ caller's.
76
+
77
+ **What is adopted is the id this library validated for this request — not whatever the log context
78
+ happens to hold**, and the difference decides how a gateway-provided id has to be passed. An id
79
+ that reaches the middleware only through an enclosing `LogContextService` scope is never adopted
80
+ and never echoed. With generation on, the request gets its own id: the inbound `x-request-id`, else
81
+ a fresh UUID. With `shouldGenerateRequestId: false` and no inbound header, an enclosing `requestId`
82
+ is inherited into the request's entries but no response header is set. **To have an upstream id
83
+ echoed, send it as `x-request-id`.**
84
+
85
+ The marks are two because they answer two questions and either can be true alone. One records that
86
+ a scope for this request was opened, and decides whether a second mount opens another. The other
87
+ records the validated id, and decides what may be echoed — read from the request rather than the
88
+ store, so a value middleware replaced between the mounts (`set()` takes `unknown`) cannot reach the
89
+ response header. The adopt path also **restores** the validated id to the active context, because
90
+ the emitted entry reads `requestId` from the store: echoing one value while leaving another in the
91
+ store would produce the same header/entry split this change exists to remove.
92
+
93
+ The request's own scope now starts from the enclosing scope's fields rather than from nothing,
94
+ which fixes a second thing in the same place: a consumer that opened its own scope upstream — a
95
+ tenant resolved at the edge — had it **discarded** for the whole request, because `run()` replaces
96
+ the context.
97
+
98
+ What it inherits is an ALLOWLIST — `requestId` and `tenantId`, the library's own correlation
99
+ fields — and that shape took three review rounds to reach. A denylist cannot be made safe here:
100
+ `LogContext` explicitly permits arbitrary consumer keys, so an application-defined `accountId` or
101
+ `sessionId` misattributes a request exactly the way `userId` does, and no list of forbidden names
102
+ is ever complete. Measured on the denylist version: a request opened inside a scope holding
103
+ `{ accountId, sessionId }` inherited both.
104
+
105
+ The identity cases the denylist did catch are worth recording, because they are what the allowlist
106
+ now covers by construction. `userId` is resolved by a guard that runs after this middleware, and
107
+ the mixin copies the store onto every entry while `PinoLoggerService` deliberately does not write
108
+ `userId: undefined` when the argument is omitted — so an anonymous request inside a scope holding
109
+ `userId: 'admin-u1'` was logged under `admin-u1`. Trace fields leak the same way, in every naming:
110
+ the mixin overwrites them from the active span but only when there IS one, and the field names are
111
+ configurable (`otel.fieldFormat: 'snake_case'` renames all three), so a literal exclusion was
112
+ wrong in both directions at once.
113
+
114
+ Dropping a field a consumer set upstream is not a regression — before this release `run()`
115
+ discarded all of it — and it fails in the safe direction: a missing field is visibly missing,
116
+ where an inherited identity is wrong while looking right.
117
+
118
+ - **A `g` or `y` flag on an `excludePaths` pattern excluded the path only every other time.**
119
+ `RegExp.prototype.test` on such a pattern advances its `lastIndex` and resumes from it, so the
120
+ same pattern tested twice against the same path returns `true`, then `false`. Measured on
121
+ `/^\/health$/g` against `/health`: `true, false, true, false`. The patterns live in module
122
+ options — one frozen array shared by the access-log middleware and the HTTP interceptor for the
123
+ lifetime of the process — so a consumer who carried a `g` flag in from a copied pattern saw
124
+ excluded health checks reappear at half rate, with no error and nothing to suggest the config was
125
+ not working.
126
+
127
+ This predates `1.4.0`; mounting both wiring helpers only made it sharper, because the two mounts
128
+ then test the same pattern twice within a single request and disagree outright. Both call sites now
129
+ go through one matcher that clears the stored position before testing, so the answer depends only
130
+ on the pattern and the path. Flags that say WHAT matches (`i`, `m`, `s`, `u`) are the consumer's
131
+ expressed intent and are untouched. Raised by Codex.
132
+
133
+ The clear happens only for a pattern that actually carries a position, and that guard is
134
+ load-bearing: `Object.freeze` on a `RegExp` — which a deep-freeze over a config module reaches —
135
+ makes `lastIndex` non-writable, and under ESM's strict mode assigning to it throws
136
+ `TypeError: Cannot assign to read only property 'lastIndex'` while `.test()` on the same pattern
137
+ works. Clearing unconditionally would have turned a working configuration into a throw on the
138
+ request path. A frozen pattern that IS global stays broken, and not by this: `test()` writes
139
+ `lastIndex` itself, so it throws before any of this runs.
140
+
141
+ - **A second mount of `HttpAccessLogMiddleware` doubled every access-log line.** It claims the
142
+ request's log lifecycle so the interceptor stays silent, but never checked whether the claim was
143
+ already made. It now stands down on an already-claimed request: one START and one terminal entry
144
+ per request no matter how many times it is mounted. The claim is per-request state, so a
145
+ different request is unaffected.
146
+
147
+ ### Documentation
148
+
149
+ - **`run()` now says that a nested scope DISCARDS the enclosing one.** The docblock said "a fresh
150
+ context scope", which does not tell a reader that an outer scope's fields are gone for the whole
151
+ inner one — nothing throws, `get()` just returns `undefined`, and the field is missing from every
152
+ entry the callback emits. Reported by a consumer that lost a `tenantId` resolved at the edge.
153
+
154
+ Replacement remains the default, and the reason is the direction the other choice leaks: a
155
+ background job started inside a request scope would silently inherit the caller's `userId` and
156
+ attribute its entries to a user who never triggered it. A missing field is visibly missing; an
157
+ inherited one is wrong while looking right. `runMerged()` is the explicit opt-in.
158
+
159
+ - **The README states what is not logged, and what a rejected request actually produces today.** The
160
+ absence read as a misconfiguration to whoever went looking for it, and cost a consumer real time
161
+ before it was traced to the parser. The new §5 subsection carries the ordering, the two-row table
162
+ of what each wiring emits, and the `main.ts` snippet.
163
+
164
+ - **`applyAccessLog` names `init()` as its deadline, not `listen()`.** `listen()` only triggers
165
+ `init()` when it has not run yet, so "before `listen()`" is true for the usual bootstrap and
166
+ false for a serverless entry point or an integration test that does `await app.init()` and wires
167
+ up afterwards — that mount lands behind the parser and logs nothing extra, with no error to say
168
+ so. A late mount is not rejected at runtime because NestJS exposes no supported signal for it
169
+ (`isInitialized` is private, and inspecting the adapter's router would break on a patch release
170
+ while claiming to be a safety net), so the deadline is documented at the call site instead. Raised
171
+ by Codex.
172
+
173
+ ## [1.3.1] - 2026-08-18
174
+
175
+ ### Documentation
176
+
177
+ - **`excludePaths` says what its defaults do not reach.** The defaults are
178
+ `[/^\/health$/, /^\/metrics$/]`, and `@bymax-one/nest-core` serves liveness and readiness at
179
+ `/health/live` and `/health/ready` with no bare `/health` at all — so the default excludes a
180
+ route that does not exist while logging both probes. A consumer running both measured **8272 of
181
+ 8274** HTTP entries as the liveness probe, at one container healthcheck every ten seconds; at
182
+ orchestrator volume it is the same shape with more zeros. There is no error and no warning, and
183
+ the config reads as though it were handled.
184
+
185
+ The defaults are **not** widened, and the reason is checkable rather than a preference: both
186
+ prefixes are configurable there (`DEFAULT_HEALTH_PATH`, `DEFAULT_METRICS_PATH`). A default
187
+ naming specific subpaths would be less wrong rather than correct, and a prefix pattern would
188
+ silently swallow a consumer's own `/health/*` route they did want logged. Excluding a path
189
+ decides which requests vanish from the record, so it stays explicit — the docblock now carries
190
+ the one-line fix for the stock layout, and it ships in the `.d.ts` where someone wiring the two
191
+ libraries reads it.
192
+
193
+ ### Internal
194
+
195
+ - **The last surviving mutant is gone, by deletion rather than annotation.** A `catch` in
196
+ `report-destination-failure` re-assigned `undefined` to a variable already `undefined` from its
197
+ declaration, so emptying the block changed no output for any input and no test could kill it. A
198
+ Stryker directive was tried first and was not honoured at that position. The project scores
199
+ **100.00** on a cold run: 1515 mutants, 841 killed, zero survivors, 145 documented equivalents.
200
+
14
201
  ## [1.3.0] - 2026-08-18
15
202
 
16
203
  ### Fixed
@@ -1794,7 +1981,9 @@ published `dist/` is identical — no runtime behaviour changes for consumers.
1794
1981
  - Professional CI suite: `ci.yml`, `bench.yml`, `codeql.yml`, `scorecard.yml`,
1795
1982
  `release.yml`, Dependabot, and issue templates
1796
1983
 
1797
- [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.3.0...HEAD
1984
+ [Unreleased]: https://github.com/bymaxone/nest-logger/compare/v1.4.0...HEAD
1985
+ [1.4.0]: https://github.com/bymaxone/nest-logger/compare/v1.3.1...v1.4.0
1986
+ [1.3.1]: https://github.com/bymaxone/nest-logger/compare/v1.3.0...v1.3.1
1798
1987
  [1.3.0]: https://github.com/bymaxone/nest-logger/compare/v1.2.9...v1.3.0
1799
1988
  [1.2.9]: https://github.com/bymaxone/nest-logger/compare/v1.2.7...v1.2.9
1800
1989
 
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
@@ -1227,11 +1294,24 @@ There are **two** APIs on this class, and they do not share a signature shape.
1227
1294
 
1228
1295
  ### `LogContextService`
1229
1296
 
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 |
1297
+ | Method | Signature | Description |
1298
+ | ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
1299
+ | `run` | `(store, callback)` | Opens an ALS scope. Any log inside `callback` carries `store` fields. **A nested `run()` REPLACES the enclosing context** — see below |
1300
+ | `runMerged` | `(store, callback)` | Opens a scope that EXTENDS the enclosing one: outer fields are carried in, `store` overrides them key by key |
1301
+ | `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 |
1302
+ | `getStore` | `()` | Returns current scope or `undefined` outside a `run()` call |
1303
+
1304
+ > [!IMPORTANT]
1305
+ > **A nested `run()` discards the enclosing context.** The inner scope starts from its own `store`
1306
+ > alone, so a field an outer scope had set — a `tenantId` resolved at the edge, say — is absent for
1307
+ > the whole inner scope. Nothing throws; `get()` simply returns `undefined` and the field is missing
1308
+ > from every entry the callback emits, which is the kind of thing found by losing a field in
1309
+ > production. Use `runMerged()` when the inner scope should extend the outer one.
1310
+ >
1311
+ > Replacement stays the default because the opposite leaks in a worse direction: a background job
1312
+ > started inside a request scope would silently inherit the caller's `userId` and attribute its
1313
+ > entries to a user who never triggered it. A missing field is visibly missing; an inherited one is
1314
+ > wrong while looking right.
1235
1315
 
1236
1316
  ### Decorators
1237
1317
 
@@ -768,12 +768,26 @@ var HTTP_SERVER_ERROR_MIN = 500;
768
768
  var UNKNOWN_USER_AGENT = "unknown";
769
769
  var RECORDER_ACTIVE = /* @__PURE__ */ Symbol("bymax-one/nest-logger:recorder");
770
770
  var RECORDED_ERROR = /* @__PURE__ */ Symbol("bymax-one/nest-logger:error");
771
+ var OWN_REQUEST_ID = /* @__PURE__ */ Symbol("bymax-one/nest-logger:request-id");
772
+ var CORRELATION_OPENED = /* @__PURE__ */ Symbol("bymax-one/nest-logger:correlation");
771
773
  function markRecorderActive(req) {
772
774
  Reflect.set(req, RECORDER_ACTIVE, true);
773
775
  }
774
776
  function isRecorderActive(req) {
775
777
  return Reflect.get(req, RECORDER_ACTIVE) === true;
776
778
  }
779
+ function markCorrelationOpened(req) {
780
+ Reflect.set(req, CORRELATION_OPENED, true);
781
+ }
782
+ function isCorrelationOpened(req) {
783
+ return Reflect.get(req, CORRELATION_OPENED) === true;
784
+ }
785
+ function markOwnRequestId(req, requestId) {
786
+ Reflect.set(req, OWN_REQUEST_ID, requestId);
787
+ }
788
+ function readOwnRequestId(req) {
789
+ return Reflect.get(req, OWN_REQUEST_ID);
790
+ }
777
791
  function recordError(req, error) {
778
792
  Reflect.set(req, RECORDED_ERROR, { error });
779
793
  }
@@ -791,6 +805,16 @@ function readUserAgent(req) {
791
805
  return typeof raw === "string" ? raw : UNKNOWN_USER_AGENT;
792
806
  }
793
807
 
808
+ // src/server/utils/matches-exclude-path.util.ts
809
+ function matchesExcludePath(patterns, path) {
810
+ return patterns.some((pattern) => {
811
+ if (pattern.global || pattern.sticky) {
812
+ pattern.lastIndex = 0;
813
+ }
814
+ return pattern.test(path);
815
+ });
816
+ }
817
+
794
818
  // src/server/interceptors/http-logging.interceptor.ts
795
819
  var HTTP_SUCCESS_MIN = 200;
796
820
  exports.HttpLoggingInterceptor = class HttpLoggingInterceptor {
@@ -812,7 +836,7 @@ exports.HttpLoggingInterceptor = class HttpLoggingInterceptor {
812
836
  * @returns `true` when the path matches a configured exclude pattern.
813
837
  */
814
838
  isExcluded(path) {
815
- return this.excludePaths.some((pattern) => pattern.test(path));
839
+ return matchesExcludePath(this.excludePaths, path);
816
840
  }
817
841
  /**
818
842
  * Emit the START entry, then attach success/redirect/error logging to the
@@ -1137,7 +1161,6 @@ function safeMinLevel(destination) {
1137
1161
  const read = destination.minLevel;
1138
1162
  resolved = isLogLevel(read) ? read : void 0;
1139
1163
  } catch {
1140
- resolved = void 0;
1141
1164
  }
1142
1165
  resolvedMinLevels.set(destination, resolved);
1143
1166
  return resolved;
@@ -2095,8 +2118,21 @@ exports.LogContextService = class LogContextService {
2095
2118
  * Run `callback` inside a fresh context scope. Every log emitted
2096
2119
  * synchronously or asynchronously within the callback inherits `context`.
2097
2120
  *
2121
+ * **A nested `run()` REPLACES the enclosing context, it does not extend it.**
2122
+ * The scope starts from `context` alone, so a field an outer scope had set —
2123
+ * a `tenantId` resolved at the edge, say — is absent for the whole inner
2124
+ * scope: nothing throws, `get()` simply returns `undefined`, and the field is
2125
+ * missing from every entry the callback emits. Reach for {@link runMerged}
2126
+ * when the inner scope should extend the outer one.
2127
+ *
2128
+ * Replacement is the deliberate default because the opposite leaks: a
2129
+ * background job started inside a request scope would silently inherit the
2130
+ * caller's `userId` and attribute its entries to a user who never triggered
2131
+ * it. A missing field is visibly missing; an inherited one is wrong while
2132
+ * looking right.
2133
+ *
2098
2134
  * @typeParam T - Return type of the callback.
2099
- * @param context - Initial context bag for the scope.
2135
+ * @param context - Initial context bag for the scope. Becomes the WHOLE scope.
2100
2136
  * @param callback - Function executed within the scope.
2101
2137
  * @returns Whatever `callback` returns.
2102
2138
  * @example
@@ -2107,6 +2143,35 @@ exports.LogContextService = class LogContextService {
2107
2143
  run(context, callback) {
2108
2144
  return this.als.run(this.sanitizeContext(context), callback);
2109
2145
  }
2146
+ /**
2147
+ * Run `callback` inside a scope that EXTENDS the enclosing one: the active
2148
+ * context's fields are carried in, and `context` overrides them key by key.
2149
+ *
2150
+ * The explicit counterpart to {@link run}'s replacement semantics, for the
2151
+ * case where an inner scope is genuinely a refinement of the outer — adding a
2152
+ * `userId` once authentication resolved it, without having to restate the
2153
+ * `requestId` the middleware already set.
2154
+ *
2155
+ * Outside any scope there is nothing to merge and this behaves exactly like
2156
+ * {@link run}. Writes inside the new scope never reach the enclosing one: the
2157
+ * merge produces a fresh object, so the outer context is intact after the
2158
+ * callback returns.
2159
+ *
2160
+ * @typeParam T - Return type of the callback.
2161
+ * @param context - Fields to add or override on top of the active context.
2162
+ * @param callback - Function executed within the merged scope.
2163
+ * @returns Whatever `callback` returns.
2164
+ * @example
2165
+ * logContext.run({ requestId: 'r_1' }, () => {
2166
+ * logContext.runMerged({ userId: 'u_1' }, () => {
2167
+ * logContext.get('requestId') // 'r_1' — carried in from the outer scope
2168
+ * })
2169
+ * })
2170
+ */
2171
+ runMerged(context, callback) {
2172
+ const merged = { ...this.als.getStore(), ...this.sanitizeContext(context) };
2173
+ return this.als.run(merged, callback);
2174
+ }
2110
2175
  /**
2111
2176
  * Read the active context, or `undefined` when called outside any scope.
2112
2177
  *
@@ -2538,7 +2603,7 @@ var PrettyDevDestination = class {
2538
2603
  });
2539
2604
  }
2540
2605
  };
2541
- var HttpAccessLogMiddleware = class {
2606
+ exports.HttpAccessLogMiddleware = class HttpAccessLogMiddleware {
2542
2607
  /**
2543
2608
  * @param logger - The structured logger the access entries are written to.
2544
2609
  * Injected by token explicitly: the package is bundled without
@@ -2559,13 +2624,17 @@ var HttpAccessLogMiddleware = class {
2559
2624
  * @param next - The next handler in the chain.
2560
2625
  */
2561
2626
  use(req, res, next) {
2627
+ if (isRecorderActive(req)) {
2628
+ next();
2629
+ return;
2630
+ }
2562
2631
  if (!this.isEnabled) {
2563
2632
  next();
2564
2633
  return;
2565
2634
  }
2566
2635
  const target = req.originalUrl ?? req.url;
2567
2636
  const path = stripQueryString(target);
2568
- if (this.excludePaths.some((pattern) => pattern.test(path))) {
2637
+ if (matchesExcludePath(this.excludePaths, path)) {
2569
2638
  next();
2570
2639
  return;
2571
2640
  }
@@ -2652,15 +2721,16 @@ var HttpAccessLogMiddleware = class {
2652
2721
  );
2653
2722
  }
2654
2723
  };
2655
- HttpAccessLogMiddleware = __decorateClass([
2724
+ exports.HttpAccessLogMiddleware = __decorateClass([
2656
2725
  common.Injectable(),
2657
2726
  __decorateParam(0, common.Inject(exports.PinoLoggerService)),
2658
2727
  __decorateParam(1, common.Inject(exports.LogContextService)),
2659
2728
  __decorateParam(2, common.Inject(LOGGER_OPTIONS_TOKEN))
2660
- ], HttpAccessLogMiddleware);
2729
+ ], exports.HttpAccessLogMiddleware);
2661
2730
  var REQUEST_ID_HEADER = "x-request-id";
2662
2731
  var MAX_CORRELATION_ID_LENGTH = 256;
2663
2732
  var CORRELATION_ID_CHARSET = /^[A-Za-z0-9._:/+=-]+$/;
2733
+ var INHERITABLE_CONTEXT_KEYS = ["requestId", "tenantId"];
2664
2734
  function isAcceptableHeaderValue(value) {
2665
2735
  return typeof value === "string" && value.length <= MAX_CORRELATION_ID_LENGTH && CORRELATION_ID_CHARSET.test(value);
2666
2736
  }
@@ -2679,27 +2749,128 @@ exports.RequestIdMiddleware = class RequestIdMiddleware {
2679
2749
  }
2680
2750
  /**
2681
2751
  * Echo or mint the `x-request-id`, expose it on the response, and run the
2682
- * remainder of the request inside a fresh log-context scope.
2752
+ * remainder of the request inside its own log-context scope.
2753
+ *
2754
+ * Idempotent: mounting this middleware twice on the same request mints one id,
2755
+ * not two. The request gets its OWN store, started from the enclosing scope's
2756
+ * correlation fields — see {@link INHERITABLE_CONTEXT_KEYS} — and nothing
2757
+ * else, because no other field can be known to belong to this request.
2758
+ *
2759
+ * An enclosing scope's `requestId` is never adopted and never echoed: adoption
2760
+ * reads a value this middleware recorded on the REQUEST, which a scope opened
2761
+ * elsewhere does not have. What the two generation modes do with one, measured
2762
+ * on two requests sharing a long-lived enclosing scope:
2763
+ *
2764
+ * - `shouldGenerateRequestId: true` (default) — each request mints its own
2765
+ * id, the two requests differ, and the enclosing value reaches neither the
2766
+ * header nor the entries.
2767
+ * - `shouldGenerateRequestId: false` with no inbound header — nothing is
2768
+ * minted, so the request scope inherits the enclosing `requestId` and every
2769
+ * request inside that scope logs under it. No header is exposed. That is
2770
+ * the consumer's own scope being inherited, not correlation being reused,
2771
+ * and it is the configuration that says the gateway owns the id.
2683
2772
  *
2684
2773
  * @param req - The incoming request (Express satisfies the contract).
2685
2774
  * @param res - The outgoing response (Express satisfies the contract).
2686
2775
  * @param next - The next handler in the chain.
2687
2776
  */
2688
2777
  use(req, res, next) {
2689
- const incoming = Reflect.get(req.headers, REQUEST_ID_HEADER);
2690
- const fromHeader = isAcceptableHeaderValue(incoming) ? incoming : void 0;
2691
- const requestId = fromHeader ?? (this.shouldGenerateRequestId ? crypto.randomUUID() : void 0);
2692
- const context = {};
2778
+ if (isCorrelationOpened(req)) {
2779
+ const ownRequestId = readOwnRequestId(req);
2780
+ if (ownRequestId !== void 0) {
2781
+ this.adoptExistingScope(ownRequestId, req, res);
2782
+ }
2783
+ next();
2784
+ return;
2785
+ }
2786
+ markCorrelationOpened(req);
2787
+ const context = this.inheritableContext();
2788
+ const requestId = this.resolveRequestId(req);
2693
2789
  if (requestId !== void 0) {
2694
2790
  res.setHeader(REQUEST_ID_HEADER, requestId);
2695
2791
  context.requestId = requestId;
2792
+ markOwnRequestId(req, requestId);
2696
2793
  }
2697
- const rawTenantId = Reflect.get(req.headers, this.tenantIdHeader);
2698
- if (isAcceptableHeaderValue(rawTenantId)) {
2699
- context.tenantId = rawTenantId;
2794
+ if (context.tenantId === void 0) {
2795
+ const tenantId = this.resolveTenantId(req);
2796
+ if (tenantId !== void 0) {
2797
+ context.tenantId = tenantId;
2798
+ }
2700
2799
  }
2701
2800
  this.logContext.run(context, () => next());
2702
2801
  }
2802
+ /**
2803
+ * The enclosing scope's fields that a request scope may start from.
2804
+ *
2805
+ * Plain `run()` would discard everything the caller had set and a plain merge
2806
+ * would carry identity across requests; {@link INHERITABLE_CONTEXT_KEYS} is
2807
+ * the middle a request scope actually needs.
2808
+ *
2809
+ * @returns A fresh bag, never the enclosing store itself, so writes inside the
2810
+ * request never reach the scope that opened it.
2811
+ */
2812
+ inheritableContext() {
2813
+ const store = { ...this.logContext.getStore() };
2814
+ const inherited = {};
2815
+ for (const key of INHERITABLE_CONTEXT_KEYS) {
2816
+ const value = Reflect.get(store, key);
2817
+ if (value !== void 0) {
2818
+ Reflect.set(inherited, key, value);
2819
+ }
2820
+ }
2821
+ return inherited;
2822
+ }
2823
+ /**
2824
+ * Second mount on a request that already carries a correlation id: echo the id
2825
+ * onto the response and fill in a tenant the scope does not have yet, without
2826
+ * opening another scope.
2827
+ *
2828
+ * Nothing is minted and nothing already set is overwritten, so a third mount —
2829
+ * or a thirtieth — changes nothing. The response header is written on every
2830
+ * pass because the enclosing scope may have been opened by consumer code that
2831
+ * never exposed it.
2832
+ *
2833
+ * @param requestId - The id already in scope, which wins.
2834
+ * @param req - The incoming request, read for the tenant header.
2835
+ * @param res - The outgoing response, where the effective id is exposed.
2836
+ */
2837
+ adoptExistingScope(requestId, req, res) {
2838
+ res.setHeader(REQUEST_ID_HEADER, requestId);
2839
+ this.logContext.set("requestId", requestId);
2840
+ if (this.logContext.get("tenantId") === void 0) {
2841
+ const tenantId = this.resolveTenantId(req);
2842
+ if (tenantId !== void 0) {
2843
+ this.logContext.set("tenantId", tenantId);
2844
+ }
2845
+ }
2846
+ }
2847
+ /**
2848
+ * The correlation id for this request: the inbound header when acceptable,
2849
+ * otherwise a fresh UUID — or nothing at all when `shouldGenerateRequestId` is
2850
+ * off and the caller sent none, which leaves correlation to the upstream
2851
+ * gateway.
2852
+ *
2853
+ * @param req - The incoming request.
2854
+ * @returns The id, or `undefined` when none is available and none is minted.
2855
+ */
2856
+ resolveRequestId(req) {
2857
+ const incoming = Reflect.get(req.headers, REQUEST_ID_HEADER);
2858
+ const fromHeader = isAcceptableHeaderValue(incoming) ? incoming : void 0;
2859
+ return fromHeader ?? (this.shouldGenerateRequestId ? crypto.randomUUID() : void 0);
2860
+ }
2861
+ /**
2862
+ * The tenant id carried by the configured header, when it is acceptable.
2863
+ *
2864
+ * Never minted: a tenant is an assertion about who the request belongs to, and
2865
+ * inventing one would be a fabricated claim rather than a correlation aid.
2866
+ *
2867
+ * @param req - The incoming request.
2868
+ * @returns The tenant id, or `undefined`.
2869
+ */
2870
+ resolveTenantId(req) {
2871
+ const raw = Reflect.get(req.headers, this.tenantIdHeader);
2872
+ return isAcceptableHeaderValue(raw) ? raw : void 0;
2873
+ }
2703
2874
  };
2704
2875
  exports.RequestIdMiddleware = __decorateClass([
2705
2876
  common.Injectable(),
@@ -2707,10 +2878,36 @@ exports.RequestIdMiddleware = __decorateClass([
2707
2878
  __decorateParam(1, common.Inject(LOGGER_OPTIONS_TOKEN))
2708
2879
  ], exports.RequestIdMiddleware);
2709
2880
 
2881
+ // src/server/middlewares/apply-access-log.ts
2882
+ function resolveDependencies(app) {
2883
+ try {
2884
+ return {
2885
+ logger: app.get(exports.PinoLoggerService),
2886
+ logContext: app.get(exports.LogContextService),
2887
+ options: app.get(LOGGER_OPTIONS_TOKEN)
2888
+ };
2889
+ } catch (cause) {
2890
+ throw new Error(
2891
+ "[BymaxLoggerModule] applyAccessLog(app) called but BymaxLoggerModule was not imported",
2892
+ { cause }
2893
+ );
2894
+ }
2895
+ }
2896
+ function applyAccessLog(app) {
2897
+ const { logger, logContext, options } = resolveDependencies(app);
2898
+ const requestId = new exports.RequestIdMiddleware(logContext, options);
2899
+ const accessLog = new exports.HttpAccessLogMiddleware(logger, logContext, options);
2900
+ app.use((req, res, next) => {
2901
+ requestId.use(req, res, () => {
2902
+ accessLog.use(req, res, next);
2903
+ });
2904
+ });
2905
+ }
2906
+
2710
2907
  // src/server/middlewares/apply-request-id-middleware.ts
2711
2908
  var ALL_ROUTES = "/";
2712
2909
  function applyRequestIdMiddleware(consumer, routes = ALL_ROUTES) {
2713
- consumer.apply(exports.RequestIdMiddleware, HttpAccessLogMiddleware).forRoutes(routes);
2910
+ consumer.apply(exports.RequestIdMiddleware, exports.HttpAccessLogMiddleware).forRoutes(routes);
2714
2911
  }
2715
2912
  var INJECT_LOGGER_CONTEXT_METADATA_KEY = "bymax_logger:context";
2716
2913
  function InjectLogger(context) {
@@ -2788,4 +2985,5 @@ exports.PINO_LEVEL_NUMBERS = PINO_LEVEL_NUMBERS;
2788
2985
  exports.PrettyDevDestination = PrettyDevDestination;
2789
2986
  exports.RESERVED_LOG_KEYS = RESERVED_LOG_KEYS;
2790
2987
  exports.RESERVED_LOG_KEYS_NOT_EMITTED = RESERVED_LOG_KEYS_NOT_EMITTED;
2988
+ exports.applyAccessLog = applyAccessLog;
2791
2989
  exports.applyRequestIdMiddleware = applyRequestIdMiddleware;