@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 +207 -1
- package/README.md +129 -7
- package/dist/server/index.cjs +272 -15
- package/dist/server/index.d.cts +297 -3
- package/dist/server/index.d.ts +297 -3
- package/dist/server/index.mjs +268 -12
- package/dist/shared/index.cjs +1 -0
- package/dist/shared/index.d.cts +1 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.mjs +1 -0
- package/package.json +3 -3
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.
|
|
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
|
|
1231
|
-
|
|
|
1232
|
-
| `run`
|
|
1233
|
-
| `
|
|
1234
|
-
| `
|
|
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
|
|