@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 +190 -1
- package/README.md +86 -6
- package/dist/server/index.cjs +214 -16
- package/dist/server/index.d.cts +233 -3
- package/dist/server/index.d.ts +233 -3
- package/dist/server/index.mjs +210 -13
- package/package.json +1 -1
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.
|
|
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
|
|
1231
|
-
|
|
|
1232
|
-
| `run`
|
|
1233
|
-
| `
|
|
1234
|
-
| `
|
|
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
|
|
package/dist/server/index.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
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;
|