@bymax-one/nest-logger 1.1.0 → 1.2.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.
@@ -1,11 +1,12 @@
1
+ import { join } from 'path';
2
+ import { fileURLToPath } from 'url';
1
3
  import { Injectable, Inject, HttpStatus, Catch, ConfigurableModuleBuilder, Module, HttpException, SetMetadata } from '@nestjs/common';
2
4
  import { APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
3
5
  import { tap, catchError, throwError } from 'rxjs';
4
6
  import pino from 'pino';
5
7
  import { createRequire } from 'module';
6
- import { join } from 'path';
7
8
  import { Writable } from 'stream';
8
- import { AsyncLocalStorage } from 'async_hooks';
9
+ import { AsyncLocalStorage, AsyncResource } from 'async_hooks';
9
10
  import { randomUUID } from 'crypto';
10
11
 
11
12
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17,6 +18,8 @@ var __decorateClass = (decorators, target, key, kind) => {
17
18
  return result;
18
19
  };
19
20
  var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
21
+ var getFilename = () => fileURLToPath(import.meta.url);
22
+ var __filename$1 = /* @__PURE__ */ getFilename();
20
23
 
21
24
  // src/server/config/default-options.ts
22
25
  var DEFAULT_HTTP = {
@@ -58,6 +61,15 @@ function applyDefaults(options) {
58
61
  isGlobal: options.isGlobal ?? true,
59
62
  shouldUseAsNestLogger: options.shouldUseAsNestLogger ?? true,
60
63
  redactPaths: Object.freeze([...options.redactPaths ?? []]),
64
+ redactStrategy: options.redactStrategy ?? "names",
65
+ // `'nested'` keeps the shape every existing consumer already queries.
66
+ resourceFormat: options.resourceFormat ?? "nested",
67
+ // `??` rather than `||`: `false` is a meaningful value here (emit nothing)
68
+ // and `||` would silently replace it with the default.
69
+ eventNameField: options.eventNameField ?? "event.name",
70
+ // `'pino'` by default: the semconv attributes are additive value, but the
71
+ // legacy shape is what existing dashboards and alerts already query.
72
+ errorFormat: options.errorFormat ?? "pino",
61
73
  redactCensor: options.redactCensor ?? "[REDACTED]",
62
74
  shouldDisableDefaultRedact: options.shouldDisableDefaultRedact ?? false,
63
75
  destinations: Object.freeze([...options.destinations ?? []]),
@@ -83,7 +95,7 @@ var PINO_LEVEL_NUMBERS = Object.freeze({
83
95
  debug: 20,
84
96
  trace: 10
85
97
  });
86
- Object.freeze(
98
+ var PINO_LEVEL_NAMES = Object.freeze(
87
99
  Object.fromEntries(
88
100
  Object.entries(PINO_LEVEL_NUMBERS).map(([name, num]) => [num, name])
89
101
  // keys of PINO_LEVEL_NUMBERS are always LogLevel
@@ -120,16 +132,143 @@ function validateOptions(options) {
120
132
  if (options.maxEntrySizeBytes !== void 0 && options.maxEntrySizeBytes <= 0) {
121
133
  throw new Error("[BymaxLoggerModule] options.maxEntrySizeBytes must be > 0");
122
134
  }
135
+ if (options.redactStrategy !== void 0 && options.redactStrategy !== "names" && options.redactStrategy !== "paths") {
136
+ throw new Error(
137
+ `[BymaxLoggerModule] options.redactStrategy must be 'names' or 'paths'. Got: ${String(
138
+ options.redactStrategy
139
+ )}`
140
+ );
141
+ }
142
+ if (options.resourceFormat !== void 0 && options.resourceFormat !== "nested" && options.resourceFormat !== "flat") {
143
+ throw new Error(
144
+ `[BymaxLoggerModule] options.resourceFormat must be 'nested' or 'flat'. Got: ${String(
145
+ options.resourceFormat
146
+ )}`
147
+ );
148
+ }
149
+ if (options.errorFormat !== void 0 && options.errorFormat !== "pino" && options.errorFormat !== "semconv" && options.errorFormat !== "both") {
150
+ throw new Error(
151
+ `[BymaxLoggerModule] options.errorFormat must be 'pino', 'semconv' or 'both'. Got: ${String(
152
+ options.errorFormat
153
+ )}`
154
+ );
155
+ }
156
+ if (options.eventNameField !== void 0 && options.eventNameField !== false && (typeof options.eventNameField !== "string" || options.eventNameField.length === 0)) {
157
+ throw new Error(
158
+ `[BymaxLoggerModule] options.eventNameField must be a non-empty string or false. Got: ${String(
159
+ options.eventNameField
160
+ )}`
161
+ );
162
+ }
123
163
  }
124
164
 
125
165
  // src/server/constants/injection-tokens.constants.ts
126
166
  var LOGGER_OPTIONS_TOKEN = /* @__PURE__ */ Symbol("BYMAX_LOGGER_OPTIONS");
127
167
  var LOGGER_PINO_INSTANCE_TOKEN = /* @__PURE__ */ Symbol("BYMAX_LOGGER_PINO_INSTANCE");
128
168
  var LOGGER_DESTINATIONS_TOKEN = /* @__PURE__ */ Symbol("BYMAX_LOGGER_DESTINATIONS");
169
+ var LOGGER_REDACTOR_TOKEN = /* @__PURE__ */ Symbol("BYMAX_LOGGER_REDACTOR");
129
170
  var LOG_CONTEXT_TOKEN = /* @__PURE__ */ Symbol("BYMAX_LOGGER_LOG_CONTEXT");
171
+
172
+ // src/shared/constants/reserved-log-keys.constants.ts
173
+ var RESERVED_LOG_KEYS = Object.freeze({
174
+ LOGGER_BOOTSTRAP_OK: "LOGGER_BOOTSTRAP_OK",
175
+ LOGGER_BOOTSTRAP_WARNING: "LOGGER_BOOTSTRAP_WARNING",
176
+ LOGGER_SHUTDOWN_OK: "LOGGER_SHUTDOWN_OK",
177
+ HTTP_REQUEST_START: "HTTP_REQUEST_START",
178
+ HTTP_REQUEST_SUCCESS: "HTTP_REQUEST_SUCCESS",
179
+ HTTP_REQUEST_REDIRECT: "HTTP_REQUEST_REDIRECT",
180
+ HTTP_REQUEST_CLIENT_ERROR: "HTTP_REQUEST_CLIENT_ERROR",
181
+ HTTP_REQUEST_SERVER_ERROR: "HTTP_REQUEST_SERVER_ERROR",
182
+ HTTP_REQUEST_COMPLETED: "HTTP_REQUEST_COMPLETED",
183
+ HTTP_REQUEST_ABORTED: "HTTP_REQUEST_ABORTED",
184
+ HTTP_EXCEPTION_HANDLED: "HTTP_EXCEPTION_HANDLED",
185
+ HTTP_EXCEPTION_UNHANDLED: "HTTP_EXCEPTION_UNHANDLED",
186
+ METHOD_EXECUTION: "METHOD_EXECUTION",
187
+ METHOD_SLOW_EXECUTION: "METHOD_SLOW_EXECUTION",
188
+ LOGGER_DESTINATION_INIT_FAILED: "LOGGER_DESTINATION_INIT_FAILED",
189
+ LOGGER_DESTINATION_WRITE_FAILED: "LOGGER_DESTINATION_WRITE_FAILED",
190
+ LOGGER_ENTRY_TRUNCATED: "LOGGER_ENTRY_TRUNCATED",
191
+ LOGGER_REDACTION_FAILED: "LOGGER_REDACTION_FAILED"
192
+ });
193
+ var RESERVED_LOG_KEYS_NOT_EMITTED = Object.freeze({
194
+ /**
195
+ * Superseded before it was ever used. The request lifecycle terminates in one
196
+ * of `HTTP_REQUEST_SUCCESS` / `_REDIRECT` / `_CLIENT_ERROR` / `_SERVER_ERROR`,
197
+ * each carrying the same `duration` a generic "completed" entry would. Emitting
198
+ * it as well would double the terminal access-log volume to say nothing new.
199
+ * It stays claimed so an application cannot repurpose a name that reads like a
200
+ * library event.
201
+ */
202
+ HTTP_REQUEST_COMPLETED: "reserved: superseded by the four status-specific terminal keys"
203
+ });
204
+
205
+ // src/server/constants/prototype-polluting-keys.constants.ts
206
+ var PROTOTYPE_POLLUTING_KEYS = /* @__PURE__ */ new Set([
207
+ "__proto__",
208
+ "constructor",
209
+ "prototype"
210
+ ]);
211
+
212
+ // src/server/utils/escape-log-text.util.ts
213
+ var LINE_TERMINATORS = /* @__PURE__ */ new Set(["\r", "\n", "\x85", "\u2028", "\u2029"]);
214
+ var UNSAFE_IN_SINGLE_LINE = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u2028\u2029]/g;
215
+ var UNSAFE_IN_MULTILINE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u2028\u2029]/g;
216
+ function toEscapeSequence(char) {
217
+ return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
218
+ }
219
+ function toSingleLineMessage(text) {
220
+ return text.replace(
221
+ UNSAFE_IN_SINGLE_LINE,
222
+ (char) => LINE_TERMINATORS.has(char) ? "\\n" : toEscapeSequence(char)
223
+ );
224
+ }
225
+ function escapeControlCharacters(text) {
226
+ return text.replace(UNSAFE_IN_MULTILINE, toEscapeSequence);
227
+ }
228
+
229
+ // src/server/services/pino-logger.service.ts
230
+ var OWNED_PAYLOAD_KEYS = ["logKey", "userId", "context"];
231
+ function withoutOwnedKeys(metadata) {
232
+ if (metadata === void 0) {
233
+ return {};
234
+ }
235
+ try {
236
+ const safe = {};
237
+ for (const key of Object.keys(metadata)) {
238
+ if (!OWNED_PAYLOAD_KEYS.includes(key) && !PROTOTYPE_POLLUTING_KEYS.has(key)) {
239
+ Reflect.set(safe, key, Reflect.get(metadata, key));
240
+ }
241
+ }
242
+ return safe;
243
+ } catch {
244
+ return {
245
+ _redactionFailed: true,
246
+ _logKey: RESERVED_LOG_KEYS.LOGGER_REDACTION_FAILED
247
+ };
248
+ }
249
+ }
250
+ function safeErrorMessage(error) {
251
+ try {
252
+ return toSingleLineMessage(String(error.message));
253
+ } catch {
254
+ return "Unreadable error message";
255
+ }
256
+ }
257
+ function assignIfDefined(payload, key, value) {
258
+ if (value !== void 0) {
259
+ Reflect.defineProperty(payload, key, { value, enumerable: true });
260
+ }
261
+ }
130
262
  var PinoLoggerService = class {
131
- constructor(pino2) {
263
+ /**
264
+ * @param pino - The wrapped Pino instance.
265
+ * @param redact - The DEFAULT-coverage redactor, applied to `child()` bindings.
266
+ * Defaults to the identity so a hand-built instance (benchmarks, unit tests)
267
+ * still works; the module always injects the configured one.
268
+ */
269
+ constructor(pino2, redact = (value) => value) {
132
270
  this.pino = pino2;
271
+ this.redact = redact;
133
272
  }
134
273
  // ─── NestJS LoggerService variadic interface ──────────────────────────────
135
274
  /**
@@ -152,19 +291,20 @@ var PinoLoggerService = class {
152
291
  */
153
292
  error(message, ...optionalParams) {
154
293
  if (message instanceof Error) {
155
- this.pino.error(
156
- { context: this.resolveContext(optionalParams), err: this.serializeError(message) },
157
- message.message
158
- );
294
+ const payload2 = { err: message };
295
+ assignIfDefined(payload2, "context", this.resolveContext(optionalParams));
296
+ this.pino.error(payload2, safeErrorMessage(message));
159
297
  return;
160
298
  }
161
299
  const stack = typeof optionalParams[0] === "string" ? optionalParams[0] : void 0;
162
300
  const context = typeof optionalParams[1] === "string" ? optionalParams[1] : this.context;
163
- const payload = { context };
164
- if (stack !== void 0) {
165
- payload["stack"] = stack;
166
- }
167
- this.pino.error(payload, typeof message === "string" ? message : String(message));
301
+ const payload = {};
302
+ assignIfDefined(payload, "context", context);
303
+ assignIfDefined(payload, "stack", stack);
304
+ this.pino.error(
305
+ payload,
306
+ toSingleLineMessage(typeof message === "string" ? message : String(message))
307
+ );
168
308
  }
169
309
  /**
170
310
  * NestJS `warn` (warn level).
@@ -238,10 +378,19 @@ var PinoLoggerService = class {
238
378
  * @param metadata - Optional extra structured fields.
239
379
  */
240
380
  errorStructured(logKey, error, userId, metadata) {
241
- this.pino.error(
242
- { ...metadata, logKey, userId, context: this.context, err: this.serializeError(error) },
243
- error.message
244
- );
381
+ const payload = {
382
+ ...withoutOwnedKeys(metadata),
383
+ logKey,
384
+ // The RAW value, not a pre-serialized copy. Pre-serializing was what let
385
+ // Pino's standard `err` serializer re-derive `type` from the resulting
386
+ // plain object's constructor and emit `"Object"` for every typed
387
+ // exception — and it discarded the cause chain on the way. The configured
388
+ // serializer owns the shape now.
389
+ err: error
390
+ };
391
+ assignIfDefined(payload, "userId", userId);
392
+ assignIfDefined(payload, "context", this.context);
393
+ this.pino.error(payload, safeErrorMessage(error));
245
394
  }
246
395
  // ─── Helpers / escape hatches ─────────────────────────────────────────────
247
396
  /**
@@ -274,7 +423,8 @@ var PinoLoggerService = class {
274
423
  * @returns A new `PinoLoggerService` wrapping the Pino child.
275
424
  */
276
425
  child(bindings) {
277
- const childService = new PinoLoggerService(this.pino.child(bindings));
426
+ const safeBindings = this.redact(bindings, true);
427
+ const childService = new PinoLoggerService(this.pino.child(safeBindings), this.redact);
278
428
  if (this.context !== void 0) {
279
429
  childService.setContext(this.context);
280
430
  }
@@ -288,19 +438,6 @@ var PinoLoggerService = class {
288
438
  onApplicationShutdown() {
289
439
  }
290
440
  // ─── Private ──────────────────────────────────────────────────────────────
291
- /**
292
- * Serialize an Error into a plain, log-safe object matching Pino's built-in
293
- * `err` serializer shape (`type`, `message`, `stack`).
294
- *
295
- * Reads `error.name` (NOT `error.constructor.name`) for `type`: native
296
- * subclasses set `name` to their class name, and a sanitized plain-object error
297
- * (from `sanitizeError`) carries the real class name on `name` — whereas its
298
- * `constructor.name` would be the unhelpful `'Object'`, breaking `err.type`
299
- * dashboards for every exception logged through `HttpExceptionFilter`.
300
- */
301
- serializeError(error) {
302
- return { type: error.name, message: error.message, stack: error.stack };
303
- }
304
441
  /** Resolve the context: last string param wins, else the instance context. */
305
442
  resolveContext(optionalParams) {
306
443
  const last = optionalParams[optionalParams.length - 1];
@@ -308,22 +445,21 @@ var PinoLoggerService = class {
308
445
  }
309
446
  /** Emit a structured payload at info or warn level. */
310
447
  emitStructured(level, logKey, message, userId, metadata) {
311
- const payload = {
312
- ...metadata,
313
- logKey,
314
- userId,
315
- context: this.context
316
- };
448
+ const payload = { ...withoutOwnedKeys(metadata), logKey };
449
+ assignIfDefined(payload, "userId", userId);
450
+ assignIfDefined(payload, "context", this.context);
451
+ const line = toSingleLineMessage(message);
317
452
  if (level === "info") {
318
- this.pino.info(payload, message);
453
+ this.pino.info(payload, line);
319
454
  } else {
320
- this.pino.warn(payload, message);
455
+ this.pino.warn(payload, line);
321
456
  }
322
457
  }
323
458
  /** Emit a NestJS-style variadic log, dispatching by level without computed indexing. */
324
459
  emitNestStyle(level, message, optionalParams) {
325
- const payload = { context: this.resolveContext(optionalParams) };
326
- const msg = typeof message === "string" ? message : String(message);
460
+ const payload = {};
461
+ assignIfDefined(payload, "context", this.resolveContext(optionalParams));
462
+ const msg = toSingleLineMessage(typeof message === "string" ? message : String(message));
327
463
  switch (level) {
328
464
  case "info":
329
465
  this.pino.info(payload, msg);
@@ -345,7 +481,8 @@ var PinoLoggerService = class {
345
481
  };
346
482
  PinoLoggerService = __decorateClass([
347
483
  Injectable(),
348
- __decorateParam(0, Inject(LOGGER_PINO_INSTANCE_TOKEN))
484
+ __decorateParam(0, Inject(LOGGER_PINO_INSTANCE_TOKEN)),
485
+ __decorateParam(1, Inject(LOGGER_REDACTOR_TOKEN))
349
486
  ], PinoLoggerService);
350
487
 
351
488
  // src/server/decorators/inject-logger.provider.ts
@@ -395,26 +532,6 @@ DefaultStdoutDestination = __decorateClass([
395
532
  Injectable()
396
533
  ], DefaultStdoutDestination);
397
534
 
398
- // src/shared/constants/reserved-log-keys.constants.ts
399
- var RESERVED_LOG_KEYS = Object.freeze({
400
- LOGGER_BOOTSTRAP_OK: "LOGGER_BOOTSTRAP_OK",
401
- LOGGER_BOOTSTRAP_WARNING: "LOGGER_BOOTSTRAP_WARNING",
402
- LOGGER_SHUTDOWN_OK: "LOGGER_SHUTDOWN_OK",
403
- HTTP_REQUEST_START: "HTTP_REQUEST_START",
404
- HTTP_REQUEST_SUCCESS: "HTTP_REQUEST_SUCCESS",
405
- HTTP_REQUEST_REDIRECT: "HTTP_REQUEST_REDIRECT",
406
- HTTP_REQUEST_CLIENT_ERROR: "HTTP_REQUEST_CLIENT_ERROR",
407
- HTTP_REQUEST_SERVER_ERROR: "HTTP_REQUEST_SERVER_ERROR",
408
- HTTP_REQUEST_COMPLETED: "HTTP_REQUEST_COMPLETED",
409
- HTTP_EXCEPTION_HANDLED: "HTTP_EXCEPTION_HANDLED",
410
- HTTP_EXCEPTION_UNHANDLED: "HTTP_EXCEPTION_UNHANDLED",
411
- METHOD_EXECUTION: "METHOD_EXECUTION",
412
- METHOD_SLOW_EXECUTION: "METHOD_SLOW_EXECUTION",
413
- LOGGER_DESTINATION_INIT_FAILED: "LOGGER_DESTINATION_INIT_FAILED",
414
- LOGGER_DESTINATION_WRITE_FAILED: "LOGGER_DESTINATION_WRITE_FAILED",
415
- LOGGER_ENTRY_TRUNCATED: "LOGGER_ENTRY_TRUNCATED"
416
- });
417
-
418
535
  // src/server/utils/normalize-url.util.ts
419
536
  var UUID_REGEX = /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?![0-9a-f])/gi;
420
537
  var ULID_REGEX = /\/[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-HJKMNP-TV-Z])/g;
@@ -437,9 +554,16 @@ function sanitizeError(err, options) {
437
554
  const maxCauseDepth = DEFAULT_MAX_CAUSE_DEPTH;
438
555
  return toSanitizedError(err, /* @__PURE__ */ new WeakSet(), 0, maxCauseDepth);
439
556
  }
557
+ function isErrorLike(value) {
558
+ if (typeof value !== "object" || value === null) {
559
+ return false;
560
+ }
561
+ const candidate = value;
562
+ return typeof candidate.name === "string" && typeof candidate.message === "string";
563
+ }
440
564
  function toSanitizedError(value, seen, depth2, maxDepth) {
441
565
  try {
442
- if (!(value instanceof Error)) {
566
+ if (!isErrorLike(value)) {
443
567
  return { name: "UnknownError", message: String(value) };
444
568
  }
445
569
  seen.add(value);
@@ -477,7 +601,9 @@ function readAggregatedErrors(value) {
477
601
  return Array.isArray(candidate) ? candidate : void 0;
478
602
  }
479
603
  function scrubStack(stack) {
480
- return stack.split("\n").filter((line) => !line.includes("node_modules")).join("\n");
604
+ return escapeControlCharacters(
605
+ stack.split("\n").filter((line) => !line.includes("node_modules")).join("\n")
606
+ );
481
607
  }
482
608
  function isObject(value) {
483
609
  return typeof value === "object" && value !== null;
@@ -555,11 +681,39 @@ HttpExceptionFilter = __decorateClass([
555
681
  Catch(),
556
682
  __decorateParam(0, Inject(PinoLoggerService))
557
683
  ], HttpExceptionFilter);
558
- var HTTP_SUCCESS_MIN = 200;
684
+
685
+ // src/server/utils/http-log-state.util.ts
559
686
  var HTTP_REDIRECT_MIN = 300;
560
687
  var HTTP_CLIENT_ERROR_MIN = 400;
561
688
  var HTTP_SERVER_ERROR_MIN = 500;
562
689
  var UNKNOWN_USER_AGENT = "unknown";
690
+ var RECORDER_ACTIVE = /* @__PURE__ */ Symbol("bymax-one/nest-logger:recorder");
691
+ var RECORDED_ERROR = /* @__PURE__ */ Symbol("bymax-one/nest-logger:error");
692
+ function markRecorderActive(req) {
693
+ Reflect.set(req, RECORDER_ACTIVE, true);
694
+ }
695
+ function isRecorderActive(req) {
696
+ return Reflect.get(req, RECORDER_ACTIVE) === true;
697
+ }
698
+ function recordError(req, error) {
699
+ Reflect.set(req, RECORDED_ERROR, { error });
700
+ }
701
+ function readRecordedError(req) {
702
+ return Reflect.get(req, RECORDED_ERROR);
703
+ }
704
+ function toError(thrown) {
705
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
706
+ }
707
+ function readUserId(req) {
708
+ return req.user?.sub ?? req.user?.id;
709
+ }
710
+ function readUserAgent(req) {
711
+ const raw = req.headers["user-agent"];
712
+ return typeof raw === "string" ? raw : UNKNOWN_USER_AGENT;
713
+ }
714
+
715
+ // src/server/interceptors/http-logging.interceptor.ts
716
+ var HTTP_SUCCESS_MIN = 200;
563
717
  var HttpLoggingInterceptor = class {
564
718
  /**
565
719
  * @param logger - The structured logger the lifecycle entries are written to.
@@ -578,8 +732,8 @@ var HttpLoggingInterceptor = class {
578
732
  * @param path - The query-stripped request path.
579
733
  * @returns `true` when the path matches a configured exclude pattern.
580
734
  */
581
- isExcluded(path) {
582
- return this.excludePaths.some((pattern) => pattern.test(path));
735
+ isExcluded(path2) {
736
+ return this.excludePaths.some((pattern) => pattern.test(path2));
583
737
  }
584
738
  /**
585
739
  * Emit the START entry, then attach success/redirect/error logging to the
@@ -593,13 +747,15 @@ var HttpLoggingInterceptor = class {
593
747
  const http = context.switchToHttp();
594
748
  const req = http.getRequest();
595
749
  const res = http.getResponse();
750
+ if (isRecorderActive(req)) {
751
+ return this.recordOnly(req, next);
752
+ }
596
753
  const { method, url, ip } = req;
597
754
  if (this.isExcluded(stripQueryString(url))) {
598
755
  return next.handle();
599
756
  }
600
- const rawUserAgent = req.headers["user-agent"];
601
- const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : UNKNOWN_USER_AGENT;
602
- const userId = req.user?.sub ?? req.user?.id;
757
+ const userAgent = readUserAgent(req);
758
+ const userId = readUserId(req);
603
759
  const normalizedUrl = normalizeUrl(url);
604
760
  const start = Date.now();
605
761
  this.logger.info(RESERVED_LOG_KEYS.HTTP_REQUEST_START, `${method} ${normalizedUrl}`, userId, {
@@ -622,6 +778,28 @@ var HttpLoggingInterceptor = class {
622
778
  })
623
779
  );
624
780
  }
781
+ /**
782
+ * Hand the lifecycle to the access-log middleware, recording only the thrown
783
+ * error for its terminal entry.
784
+ *
785
+ * The interceptor cannot be the recorder: it never runs for a request a guard
786
+ * rejected or one that matched no route. It also cannot judge DELIVERY — at the
787
+ * moment it completes, the response has not been flushed yet, which is why an
788
+ * aborted request used to be logged as the 200 the handler intended. What it
789
+ * uniquely has is the thrown value, so that is all it contributes.
790
+ *
791
+ * @param req - The request whose lifecycle the middleware owns.
792
+ * @param next - The downstream call handler.
793
+ * @returns The response stream, unmodified and with exceptions re-thrown.
794
+ */
795
+ recordOnly(req, next) {
796
+ return next.handle().pipe(
797
+ catchError((err) => {
798
+ recordError(req, toError(err));
799
+ return throwError(() => err);
800
+ })
801
+ );
802
+ }
625
803
  /**
626
804
  * Log a non-throwing completion: SUCCESS for 2xx, REDIRECT for 3xx, nothing
627
805
  * for any other status (those are surfaced via the error path instead).
@@ -680,12 +858,12 @@ var HttpLoggingInterceptor = class {
680
858
  }
681
859
  return;
682
860
  }
683
- this.logger.errorStructured(
684
- RESERVED_LOG_KEYS.HTTP_REQUEST_SERVER_ERROR,
685
- err instanceof Error ? err : new Error(String(err)),
686
- userId,
687
- { method, url, statusCode: HttpStatus.INTERNAL_SERVER_ERROR, duration }
688
- );
861
+ this.logger.errorStructured(RESERVED_LOG_KEYS.HTTP_REQUEST_SERVER_ERROR, toError(err), userId, {
862
+ method,
863
+ url,
864
+ statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
865
+ duration
866
+ });
689
867
  }
690
868
  };
691
869
  HttpLoggingInterceptor = __decorateClass([
@@ -720,48 +898,6 @@ var {
720
898
  // disabled" without a dead nullish branch.
721
899
  global: extras.isGlobal !== false
722
900
  })).build();
723
- function detectOtelTraceApi() {
724
- try {
725
- const requireFromCwd = createRequire(join(process.cwd(), "noop.cjs"));
726
- const mod = requireFromCwd("@opentelemetry/api");
727
- return mod.trace;
728
- } catch {
729
- return void 0;
730
- }
731
- }
732
- function isValidTraceId(traceId) {
733
- return /^[0-9a-f]{32}$/.test(traceId) && !/^0+$/.test(traceId);
734
- }
735
- function isValidSpanId(spanId) {
736
- return /^[0-9a-f]{16}$/.test(spanId) && !/^0+$/.test(spanId);
737
- }
738
-
739
- // src/server/mixins/trace-context.mixin.ts
740
- function createTraceContextMixin(logContext, opts) {
741
- const traceApi = opts.shouldAutoInjectTraceContext ? detectOtelTraceApi() : void 0;
742
- return function mixin(_mergeObject, _level, _logger) {
743
- const merged = {};
744
- const store = logContext.getStore();
745
- if (store) {
746
- Object.assign(merged, store);
747
- }
748
- if (traceApi) {
749
- const span = traceApi.getActiveSpan();
750
- if (span) {
751
- const ctx = span.spanContext();
752
- if (isValidTraceId(ctx.traceId) && isValidSpanId(ctx.spanId)) {
753
- Reflect.set(merged, opts.traceIdField, ctx.traceId);
754
- Reflect.set(merged, opts.spanIdField, ctx.spanId);
755
- if (typeof ctx.traceFlags === "number") {
756
- const flagsByte = ctx.traceFlags & 255;
757
- Reflect.set(merged, opts.traceFlagsField, flagsByte.toString(16).padStart(2, "0"));
758
- }
759
- }
760
- }
761
- }
762
- return merged;
763
- };
764
- }
765
901
 
766
902
  // src/server/constants/default-redact-paths.constants.ts
767
903
  var REDACT_MAX_DEPTH = 4;
@@ -804,7 +940,20 @@ var REDACT_COMMON_FIELDS = [
804
940
  "signingSecret",
805
941
  "privateKey",
806
942
  // Conservative PII (1) — consumer can disable if logging email is justified
807
- "email"
943
+ "email",
944
+ // Credential-bearing HTTP header names (5). These also appear in
945
+ // {@link REDACT_ABSOLUTE_PATHS}, pinned to the `req.headers` / `res.headers`
946
+ // shapes — which covered ONLY those two shapes. A headers bag logged under any
947
+ // other key (`logger.info(k, m, u, { headers: req.headers })`, one of the most
948
+ // common things written while debugging an integration) wrote the bearer token
949
+ // in clear. Listed here as bare names, they are caught wherever they appear.
950
+ // Node lower-cases inbound header names, so the lower-case spelling is the one
951
+ // that can actually reach a log.
952
+ "authorization",
953
+ "cookie",
954
+ "set-cookie",
955
+ "x-api-key",
956
+ "x-auth-token"
808
957
  ];
809
958
  var REDACT_ABSOLUTE_PATHS = [
810
959
  "req.headers.authorization",
@@ -818,6 +967,51 @@ var DEFAULT_REDACT_PATHS = [
818
967
  ...REDACT_COMMON_FIELDS.flatMap((field) => depth(field)),
819
968
  ...REDACT_ABSOLUTE_PATHS
820
969
  ];
970
+ function loadTraceApiFrom(anchor) {
971
+ try {
972
+ const requireFrom = createRequire(anchor());
973
+ const mod = requireFrom("@opentelemetry/api");
974
+ return mod.trace;
975
+ } catch {
976
+ return void 0;
977
+ }
978
+ }
979
+ function detectOtelTraceApi() {
980
+ return loadTraceApiFrom(() => __filename$1) ?? loadTraceApiFrom(() => join(process.cwd(), "noop.cjs"));
981
+ }
982
+ function isValidTraceId(traceId) {
983
+ return /^[0-9a-f]{32}$/.test(traceId) && !/^0+$/.test(traceId);
984
+ }
985
+ function isValidSpanId(spanId) {
986
+ return /^[0-9a-f]{16}$/.test(spanId) && !/^0+$/.test(spanId);
987
+ }
988
+
989
+ // src/server/mixins/trace-context.mixin.ts
990
+ function createTraceContextMixin(logContext, opts) {
991
+ const traceApi = opts.shouldAutoInjectTraceContext ? detectOtelTraceApi() : void 0;
992
+ return function mixin(_mergeObject, _level, _logger) {
993
+ const merged = {};
994
+ const store = logContext.getStore();
995
+ if (store) {
996
+ Object.assign(merged, store);
997
+ }
998
+ if (traceApi) {
999
+ const span = traceApi.getActiveSpan();
1000
+ if (span) {
1001
+ const ctx = span.spanContext();
1002
+ if (isValidTraceId(ctx.traceId) && isValidSpanId(ctx.spanId)) {
1003
+ Reflect.set(merged, opts.traceIdField, ctx.traceId);
1004
+ Reflect.set(merged, opts.spanIdField, ctx.spanId);
1005
+ if (typeof ctx.traceFlags === "number") {
1006
+ const flagsByte = ctx.traceFlags & 255;
1007
+ Reflect.set(merged, opts.traceFlagsField, flagsByte.toString(16).padStart(2, "0"));
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ return merged;
1013
+ };
1014
+ }
821
1015
 
822
1016
  // src/server/utils/compile-redact-paths.util.ts
823
1017
  function compileRedactPaths(extraPaths, shouldSkipDefaults) {
@@ -866,6 +1060,241 @@ function destinationToStream(destination) {
866
1060
  });
867
1061
  }
868
1062
 
1063
+ // src/server/utils/redact-by-name.util.ts
1064
+ var REDACT_MAX_TRAVERSAL_DEPTH = 100;
1065
+ var REDACT_DEPTH_EXCEEDED = "[REDACTION_DEPTH_EXCEEDED]";
1066
+ var REDACT_CIRCULAR = "[Circular]";
1067
+ function resolveToJson(value) {
1068
+ let getter;
1069
+ let candidate;
1070
+ let current = value;
1071
+ while (current !== null) {
1072
+ const descriptor = Object.getOwnPropertyDescriptor(current, "toJSON");
1073
+ if (descriptor !== void 0) {
1074
+ getter = descriptor.get;
1075
+ candidate = getter === void 0 ? descriptor.value : getter.call(value);
1076
+ break;
1077
+ }
1078
+ current = Object.getPrototypeOf(current);
1079
+ }
1080
+ return {
1081
+ method: typeof candidate === "function" ? candidate : void 0,
1082
+ fromAccessor: getter !== void 0
1083
+ };
1084
+ }
1085
+ function cloneError(error) {
1086
+ const clone = Object.create(
1087
+ Object.getPrototypeOf(error),
1088
+ Object.getOwnPropertyDescriptors(error)
1089
+ );
1090
+ const { stack } = error;
1091
+ if (typeof stack === "string") {
1092
+ Reflect.defineProperty(clone, "stack", { value: stack, enumerable: false });
1093
+ }
1094
+ return clone;
1095
+ }
1096
+ var BUFFER_TO_JSON = Buffer.prototype.toJSON;
1097
+ var WRITE_FAILED = new Error();
1098
+ function defineOwn(target, key, value) {
1099
+ if (!Reflect.defineProperty(target, key, { value, enumerable: true })) {
1100
+ throw WRITE_FAILED;
1101
+ }
1102
+ }
1103
+ function walkArray(value, sensitive, censor, depth2, ancestors) {
1104
+ const length = value.length;
1105
+ const copy = [];
1106
+ for (let index = 0; index < length; index++) {
1107
+ Reflect.set(
1108
+ copy,
1109
+ index,
1110
+ walk(Reflect.get(value, index), sensitive, censor, depth2 + 1, ancestors)
1111
+ );
1112
+ }
1113
+ return copy;
1114
+ }
1115
+ function walk(value, sensitive, censor, depth2, ancestors, honorToJson = true) {
1116
+ if (value === null) {
1117
+ return value;
1118
+ }
1119
+ if (typeof value !== "object" && typeof value !== "function") {
1120
+ return value;
1121
+ }
1122
+ if (depth2 > REDACT_MAX_TRAVERSAL_DEPTH) {
1123
+ return REDACT_DEPTH_EXCEEDED;
1124
+ }
1125
+ if (ancestors.has(value)) {
1126
+ return REDACT_CIRCULAR;
1127
+ }
1128
+ const resolvedToJson = resolveToJson(value);
1129
+ if (!resolvedToJson.fromAccessor && resolvedToJson.method === BUFFER_TO_JSON) {
1130
+ return value;
1131
+ }
1132
+ ancestors.add(value);
1133
+ try {
1134
+ const toJson = honorToJson ? resolvedToJson.method : void 0;
1135
+ if (toJson !== void 0) {
1136
+ if (Object.keys(value).some((key) => sensitive.has(key.toLowerCase()))) {
1137
+ return censor;
1138
+ }
1139
+ return walk(toJson.call(value), sensitive, censor, depth2 + 1, ancestors);
1140
+ }
1141
+ if (typeof value === "function") {
1142
+ return value;
1143
+ }
1144
+ if (Array.isArray(value)) {
1145
+ return walkArray(value, sensitive, censor, depth2, ancestors);
1146
+ }
1147
+ const source = value;
1148
+ const keys = Object.keys(source);
1149
+ const copy = value instanceof Error ? cloneError(value) : {};
1150
+ for (const key of keys) {
1151
+ const current = Reflect.get(source, key);
1152
+ defineOwn(
1153
+ copy,
1154
+ key,
1155
+ sensitive.has(key.toLowerCase()) ? censor : walk(current, sensitive, censor, depth2 + 1, ancestors)
1156
+ );
1157
+ }
1158
+ return copy;
1159
+ } finally {
1160
+ ancestors.delete(value);
1161
+ }
1162
+ }
1163
+ function createNameRedactor(fieldNames, censor) {
1164
+ const sensitive = new Set(fieldNames.map((name) => name.toLowerCase()));
1165
+ return (value, isRecordRoot = false) => {
1166
+ try {
1167
+ return walk(value, sensitive, censor, 0, /* @__PURE__ */ new Set(), !isRecordRoot);
1168
+ } catch {
1169
+ return {
1170
+ _redactionFailed: true,
1171
+ _logKey: RESERVED_LOG_KEYS.LOGGER_REDACTION_FAILED
1172
+ };
1173
+ }
1174
+ };
1175
+ }
1176
+
1177
+ // src/server/utils/resolve-resource.util.ts
1178
+ var ATTR_NAMESPACE = "service.namespace";
1179
+ var ATTR_VERSION = "service.version";
1180
+ var ATTR_NAME = "service.name";
1181
+ var ATTR_INSTANCE_ID = "service.instance.id";
1182
+ var ATTR_ENVIRONMENT = "deployment.environment.name";
1183
+ function parseResourceAttributes(raw) {
1184
+ const parsed = {};
1185
+ if (raw === void 0) {
1186
+ return parsed;
1187
+ }
1188
+ for (const pair of raw.split(",")) {
1189
+ const separator = pair.indexOf("=");
1190
+ if (separator <= 0) {
1191
+ continue;
1192
+ }
1193
+ const key = pair.slice(0, separator).trim();
1194
+ const rawValue = pair.slice(separator + 1).trim();
1195
+ if (rawValue.length === 0) {
1196
+ continue;
1197
+ }
1198
+ let value;
1199
+ try {
1200
+ value = decodeURIComponent(rawValue);
1201
+ } catch {
1202
+ value = rawValue;
1203
+ }
1204
+ Object.defineProperty(parsed, key, { value, enumerable: true, configurable: true });
1205
+ }
1206
+ return parsed;
1207
+ }
1208
+ function attribute(attributes, key) {
1209
+ const value = Object.hasOwn(attributes, key) ? Reflect.get(attributes, key) : void 0;
1210
+ return typeof value === "string" ? value : void 0;
1211
+ }
1212
+ function firstNonEmpty(...candidates) {
1213
+ const found = candidates.find(
1214
+ (candidate) => typeof candidate === "string" && candidate.length > 0
1215
+ );
1216
+ return found;
1217
+ }
1218
+ function resolveServiceMetadata(service, env = process.env) {
1219
+ const attributes = parseResourceAttributes(env["OTEL_RESOURCE_ATTRIBUTES"]);
1220
+ const resolved = {
1221
+ // The spec's precedence: OTEL_SERVICE_NAME beats service.name in the
1222
+ // attribute list. Explicit configuration still beats both.
1223
+ name: firstNonEmpty(service.name, env["OTEL_SERVICE_NAME"], attribute(attributes, ATTR_NAME)) ?? "",
1224
+ version: firstNonEmpty(service.version, attribute(attributes, ATTR_VERSION)) ?? ""
1225
+ };
1226
+ const namespace = firstNonEmpty(service.namespace, attribute(attributes, ATTR_NAMESPACE));
1227
+ if (namespace !== void 0) {
1228
+ resolved.namespace = namespace;
1229
+ }
1230
+ const instanceId = firstNonEmpty(service.instanceId, attribute(attributes, ATTR_INSTANCE_ID));
1231
+ if (instanceId !== void 0) {
1232
+ resolved.instanceId = instanceId;
1233
+ }
1234
+ const environment = firstNonEmpty(
1235
+ service.environment,
1236
+ attribute(attributes, ATTR_ENVIRONMENT),
1237
+ env["NODE_ENV"]
1238
+ );
1239
+ if (environment !== void 0) {
1240
+ resolved.environment = environment;
1241
+ }
1242
+ return resolved;
1243
+ }
1244
+ function buildResourceBindings(resolved, format, extras = {}) {
1245
+ if (format === "flat") {
1246
+ const flat = {
1247
+ [ATTR_NAME]: resolved.name,
1248
+ [ATTR_VERSION]: resolved.version
1249
+ };
1250
+ for (const [key, value] of Object.entries(extras)) {
1251
+ Reflect.set(flat, `service.${key}`, value);
1252
+ }
1253
+ if (resolved.namespace !== void 0) {
1254
+ Reflect.set(flat, ATTR_NAMESPACE, resolved.namespace);
1255
+ }
1256
+ if (resolved.instanceId !== void 0) {
1257
+ Reflect.set(flat, ATTR_INSTANCE_ID, resolved.instanceId);
1258
+ }
1259
+ if (resolved.environment !== void 0) {
1260
+ Reflect.set(flat, ATTR_ENVIRONMENT, resolved.environment);
1261
+ }
1262
+ return flat;
1263
+ }
1264
+ const service = {
1265
+ ...extras,
1266
+ name: resolved.name,
1267
+ version: resolved.version
1268
+ };
1269
+ if (resolved.namespace !== void 0) {
1270
+ service["namespace"] = resolved.namespace;
1271
+ }
1272
+ if (resolved.instanceId !== void 0) {
1273
+ service["instance"] = { id: resolved.instanceId };
1274
+ }
1275
+ const bindings = { service };
1276
+ if (resolved.environment !== void 0) {
1277
+ bindings["deployment"] = { environment: { name: resolved.environment } };
1278
+ }
1279
+ return bindings;
1280
+ }
1281
+ var OWNED_SERVICE_KEYS = /* @__PURE__ */ new Set([
1282
+ "name",
1283
+ "version",
1284
+ "namespace",
1285
+ "instanceId",
1286
+ "environment"
1287
+ ]);
1288
+ function extraServiceFields(service) {
1289
+ const extras = {};
1290
+ for (const [key, value] of Object.entries({ ...service })) {
1291
+ if (!OWNED_SERVICE_KEYS.has(key)) {
1292
+ Reflect.set(extras, key, value);
1293
+ }
1294
+ }
1295
+ return extras;
1296
+ }
1297
+
869
1298
  // src/server/utils/truncate-large-entries.ts
870
1299
  var PREVIEW_LENGTH = 200;
871
1300
  function createSizeBoundedSerializer(baseSerializer, maxBytes) {
@@ -894,36 +1323,188 @@ function createSizeBoundedSerializer(baseSerializer, maxBytes) {
894
1323
  }
895
1324
 
896
1325
  // src/server/pino-factory.ts
1326
+ var PATH_SEGMENT = /\[\s*['"]([^'"]+)['"]\s*\]|([^.[\]]+)/g;
1327
+ var INDEX_SEGMENT = /^\d+$/;
1328
+ function leafNameOf(path2) {
1329
+ let leaf;
1330
+ for (const match of path2.matchAll(PATH_SEGMENT)) {
1331
+ const quoted = match[1];
1332
+ const name = quoted ?? (match[2] !== void 0 && INDEX_SEGMENT.test(match[2]) ? void 0 : match[2]);
1333
+ if (name !== void 0 && name !== "*") {
1334
+ leaf = name;
1335
+ }
1336
+ }
1337
+ return leaf;
1338
+ }
1339
+ var DERIVED_ERROR_FIELDS = /* @__PURE__ */ new Set([
1340
+ "name",
1341
+ "message",
1342
+ "stack",
1343
+ "cause",
1344
+ "errors"
1345
+ ]);
1346
+ function serializeErrorValue(value_) {
1347
+ const sanitized = sanitizeError(value_);
1348
+ const serialized = {
1349
+ // `type` rather than `name`: the field consumers already query.
1350
+ type: sanitized.name,
1351
+ message: sanitized.message
1352
+ };
1353
+ if (sanitized.stack !== void 0) {
1354
+ serialized["stack"] = sanitized.stack;
1355
+ }
1356
+ if (sanitized.cause !== void 0) {
1357
+ serialized["cause"] = sanitized.cause;
1358
+ }
1359
+ if (sanitized.errors !== void 0) {
1360
+ serialized["errors"] = sanitized.errors;
1361
+ }
1362
+ try {
1363
+ const source = (
1364
+ // Stryker disable next-line ConditionalExpression: equivalent BY CONSTRUCTION, not untested — the never-throw catch below makes the mutant observationally identical: with the null check flipped to true, `Object.entries(null)` throws, the catch swallows it, and the emitted envelope is byte-for-byte the same. The guard stays because routing the ordinary null case through exception control flow would trade an explicit branch for a hidden one.
1365
+ typeof value_ === "object" && value_ !== null && !Array.isArray(value_) ? value_ : {}
1366
+ );
1367
+ for (const [key, value] of Object.entries(source)) {
1368
+ if (!Object.hasOwn(serialized, key) && !DERIVED_ERROR_FIELDS.has(key)) {
1369
+ Reflect.set(serialized, key, value);
1370
+ }
1371
+ }
1372
+ } catch {
1373
+ }
1374
+ return serialized;
1375
+ }
1376
+ function withSemconvException(record, format) {
1377
+ if (format === "pino") {
1378
+ return record;
1379
+ }
1380
+ try {
1381
+ const candidate = record["err"];
1382
+ if (typeof candidate?.name === "string") {
1383
+ record["exception.type"] = candidate.name;
1384
+ record["error.type"] = candidate.name;
1385
+ }
1386
+ if (typeof candidate?.message === "string") {
1387
+ record["exception.message"] = candidate.message;
1388
+ }
1389
+ if (typeof candidate?.stack === "string") {
1390
+ record["exception.stacktrace"] = scrubStack(candidate.stack);
1391
+ }
1392
+ if (format === "semconv") {
1393
+ const withoutErr = {};
1394
+ for (const [key, value] of Object.entries(record)) {
1395
+ if (key !== "err") {
1396
+ Reflect.set(withoutErr, key, value);
1397
+ }
1398
+ }
1399
+ return withoutErr;
1400
+ }
1401
+ } catch {
1402
+ }
1403
+ return record;
1404
+ }
1405
+ function toEventName(logKey) {
1406
+ return logKey.toLowerCase().replaceAll("_", ".");
1407
+ }
1408
+ function withEventName(record, field) {
1409
+ if (field === false) {
1410
+ return record;
1411
+ }
1412
+ const logKey = record["logKey"];
1413
+ if (typeof logKey === "string" && logKey.length > 0) {
1414
+ Reflect.set(record, field, toEventName(logKey));
1415
+ }
1416
+ return record;
1417
+ }
1418
+ var PASS_THROUGH = (value) => value;
1419
+ function resolveNameRedactor(options) {
1420
+ if (options.redactStrategy !== "names") {
1421
+ return PASS_THROUGH;
1422
+ }
1423
+ const names = [
1424
+ ...options.shouldDisableDefaultRedact ? [] : REDACT_COMMON_FIELDS,
1425
+ ...options.redactPaths.flatMap((path2) => leafNameOf(path2) ?? [])
1426
+ ];
1427
+ if (names.length === 0) {
1428
+ return PASS_THROUGH;
1429
+ }
1430
+ return createNameRedactor(names, options.redactCensor);
1431
+ }
1432
+ function resolveRedactOption(options) {
1433
+ const paths = options.redactStrategy === "paths" ? compileRedactPaths(options.redactPaths, options.shouldDisableDefaultRedact) : [...new Set(options.redactPaths)];
1434
+ if (paths.length === 0) {
1435
+ return void 0;
1436
+ }
1437
+ return { paths, censor: options.redactCensor };
1438
+ }
897
1439
  function buildPinoInstance(options, logContext, destinations) {
898
1440
  const maxBytes = options.maxEntrySizeBytes;
1441
+ const redact = resolveNameRedactor(options);
1442
+ const bound = (serializer) => createSizeBoundedSerializer((input) => redact(serializer(input)), maxBytes);
899
1443
  const serializers = {
900
- err: createSizeBoundedSerializer(pino.stdSerializers.err, maxBytes),
1444
+ err: bound(serializeErrorValue),
901
1445
  ...Object.fromEntries(
902
1446
  Object.entries(options.serializers).map(
903
- ([key, serializer]) => [
904
- key,
905
- createSizeBoundedSerializer(serializer, maxBytes)
906
- ]
1447
+ ([key, serializer]) => [key, bound(serializer)]
907
1448
  )
908
1449
  )
909
1450
  };
1451
+ const redactOption = resolveRedactOption(options);
910
1452
  const pinoOptions = {
911
1453
  level: options.level,
912
- redact: {
913
- paths: compileRedactPaths(options.redactPaths, options.shouldDisableDefaultRedact),
914
- censor: options.redactCensor
915
- },
916
- base: { service: options.service },
1454
+ ...redactOption === void 0 ? {} : { redact: redactOption },
1455
+ // Resource identity, resolved ONCE here rather than per entry: Pino
1456
+ // pre-serializes `base` into the instance's chindings, so the cost of the
1457
+ // extra attributes is one string built at construction, not work per log.
1458
+ base: buildResourceBindings(
1459
+ resolveServiceMetadata(options.service),
1460
+ options.resourceFormat,
1461
+ extraServiceFields(options.service)
1462
+ ),
917
1463
  // Pino requires the timestamp fn to emit the `,"time":"..."` fragment.
918
1464
  timestamp: () => `,"time":"${options.timestamp()}"`,
919
1465
  formatters: {
920
1466
  // Emit the level as a string label instead of the numeric code — easier
921
1467
  // for log aggregators. Trace context is injected via `mixin`, never via
922
1468
  // `formatters.log` (which cannot see ambient ALS / OTel state).
923
- level: (label) => ({ level: label })
1469
+ level: (label) => ({ level: label }),
1470
+ // `base` bindings never reach `formatters.log` — Pino pre-serializes them
1471
+ // into the instance's `chindings`, the same reason `child()` has to redact
1472
+ // its own. That matters because `service` is CONSUMER-supplied and
1473
+ // `applyDefaults` keeps whatever it was given: a `{ name, version, apiKey }`
1474
+ // went out in clear, where the path expansion had covered it via `*.*.apiKey`.
1475
+ // Redacting here rather than trimming to the two declared fields keeps a
1476
+ // consumer's extra metadata instead of silently dropping it. Runs ONCE, at
1477
+ // logger construction — no per-entry cost. `true`: bindings are a record
1478
+ // root, iterated rather than serialized.
1479
+ bindings: (bindings) => redact(bindings, true),
1480
+ // The name-walk redactor runs here, on the merged record (mixin output +
1481
+ // the caller's object), which is exactly the surface a caller controls.
1482
+ // `base` and child bindings are NOT visible at this hook — they are
1483
+ // library-owned (`service`, the `@InjectLogger` context) and carry nothing
1484
+ // sensitive, which `pino-factory.spec.ts` pins.
1485
+ log: (record) => withSemconvException(
1486
+ withEventName(redact(record, true), options.eventNameField),
1487
+ options.errorFormat
1488
+ )
924
1489
  },
925
1490
  serializers,
926
- mixin: createTraceContextMixin(logContext, options.otel)
1491
+ mixin: createTraceContextMixin(logContext, options.otel),
1492
+ // `formatters.log` is NOT the first code to touch the caller's object: Pino
1493
+ // merges the mixin result with it first, and the default strategy's
1494
+ // `Object.assign` invokes every own getter. A throwing getter therefore
1495
+ // crashed the log call before the redactor could return its fail-closed
1496
+ // envelope. Owning the merge moves that boundary inside the guarantee.
1497
+ mixinMergeStrategy: (mergeObject, mixinObject) => {
1498
+ try {
1499
+ return Object.assign({}, mixinObject, mergeObject);
1500
+ } catch {
1501
+ return {
1502
+ ...mixinObject,
1503
+ _redactionFailed: true,
1504
+ _logKey: RESERVED_LOG_KEYS.LOGGER_REDACTION_FAILED
1505
+ };
1506
+ }
1507
+ }
927
1508
  };
928
1509
  const streams = destinations.map((destination) => ({
929
1510
  level: destination.minLevel ?? options.level,
@@ -939,9 +1520,10 @@ var DestinationRegistry = class {
939
1520
  * @param logger - Structured logger used to report init failures. Injected by
940
1521
  * token explicitly for the same bundling reason.
941
1522
  */
942
- constructor(registered, logger) {
1523
+ constructor(registered, logger, options) {
943
1524
  this.registered = registered;
944
1525
  this.logger = logger;
1526
+ this.options = options;
945
1527
  /**
946
1528
  * Destinations that initialized successfully, in registration order.
947
1529
  *
@@ -973,6 +1555,43 @@ var DestinationRegistry = class {
973
1555
  );
974
1556
  }
975
1557
  }
1558
+ this.announceBootstrap();
1559
+ }
1560
+ /**
1561
+ * Emit the one-shot bootstrap entries, AFTER every destination has been
1562
+ * initialized.
1563
+ *
1564
+ * Ordering is the whole point of doing this here rather than from an eagerly
1565
+ * instantiated provider factory: a provider factory runs while Nest is still
1566
+ * building the graph, before `onModuleInit`, so a sink that only accepts
1567
+ * writes once its own `onInit()` has run would drop these entries — including
1568
+ * `LOGGER_BOOTSTRAP_WARNING`, which exists precisely so a security review can
1569
+ * see that PII redaction was turned off. A signal that a custom destination
1570
+ * can silently swallow is not an audit trail. Emitting from the registry that
1571
+ * owns destination initialization makes the ordering structural instead of a
1572
+ * cross-provider hook-order assumption.
1573
+ */
1574
+ announceBootstrap() {
1575
+ this.logger.info(RESERVED_LOG_KEYS.LOGGER_BOOTSTRAP_OK, "BymaxLoggerModule initialized");
1576
+ if (this.options.shouldDisableDefaultRedact) {
1577
+ this.logger.warnStructured(
1578
+ RESERVED_LOG_KEYS.LOGGER_BOOTSTRAP_WARNING,
1579
+ "Default PII redaction is DISABLED \u2014 sensitive fields will be logged verbatim unless every one of them is listed in options.redactPaths",
1580
+ void 0,
1581
+ {
1582
+ shouldDisableDefaultRedact: true,
1583
+ redactPathCount: this.options.redactPaths.length
1584
+ }
1585
+ );
1586
+ }
1587
+ if (this.options.otel.shouldAutoInjectTraceContext && detectOtelTraceApi() === void 0) {
1588
+ this.logger.warnStructured(
1589
+ RESERVED_LOG_KEYS.LOGGER_BOOTSTRAP_WARNING,
1590
+ "Trace-context auto-injection is enabled but @opentelemetry/api could not be resolved \u2014 traceId and spanId will be absent from every entry",
1591
+ void 0,
1592
+ { reason: "OTEL_API_UNAVAILABLE", shouldAutoInjectTraceContext: true }
1593
+ );
1594
+ }
976
1595
  }
977
1596
  /**
978
1597
  * Flush and close active destinations in REVERSE registration order, so a
@@ -981,6 +1600,15 @@ var DestinationRegistry = class {
981
1600
  * point in the shutdown sequence.
982
1601
  */
983
1602
  async onApplicationShutdown() {
1603
+ this.logger.info(
1604
+ RESERVED_LOG_KEYS.LOGGER_SHUTDOWN_OK,
1605
+ "BymaxLoggerModule shutting down",
1606
+ void 0,
1607
+ { destinations: this.active.length }
1608
+ );
1609
+ await new Promise((resolve) => {
1610
+ setImmediate(resolve);
1611
+ });
984
1612
  for (const destination of [...this.active].reverse()) {
985
1613
  try {
986
1614
  await destination.onShutdown?.();
@@ -1005,9 +1633,10 @@ var DestinationRegistry = class {
1005
1633
  DestinationRegistry = __decorateClass([
1006
1634
  Injectable(),
1007
1635
  __decorateParam(0, Inject(LOGGER_DESTINATIONS_TOKEN)),
1008
- __decorateParam(1, Inject(PinoLoggerService))
1636
+ __decorateParam(1, Inject(PinoLoggerService)),
1637
+ __decorateParam(2, Inject(LOGGER_OPTIONS_TOKEN))
1009
1638
  ], DestinationRegistry);
1010
- var FORBIDDEN_CONTEXT_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1639
+ var FORBIDDEN_CONTEXT_KEYS = PROTOTYPE_POLLUTING_KEYS;
1011
1640
  var LogContextService = class {
1012
1641
  constructor() {
1013
1642
  this.als = new AsyncLocalStorage();
@@ -1093,24 +1722,21 @@ LogContextService = __decorateClass([
1093
1722
  ], LogContextService);
1094
1723
 
1095
1724
  // src/server/logger.module.ts
1096
- var LOGGER_BOOTSTRAP_TOKEN = /* @__PURE__ */ Symbol("BYMAX_LOGGER_BOOTSTRAP");
1097
1725
  function resolveDestinations(options) {
1098
1726
  return options.destinations.length > 0 ? options.destinations : [new DefaultStdoutDestination()];
1099
1727
  }
1100
- function bootstrapProvider() {
1101
- return {
1102
- provide: LOGGER_BOOTSTRAP_TOKEN,
1103
- useFactory: (logger) => {
1104
- logger.info(RESERVED_LOG_KEYS.LOGGER_BOOTSTRAP_OK, "BymaxLoggerModule initialized");
1105
- return true;
1106
- },
1107
- inject: [PinoLoggerService]
1108
- };
1109
- }
1110
1728
  function buildCommonProviders() {
1111
1729
  return [
1112
1730
  LogContextService,
1113
1731
  { provide: LOG_CONTEXT_TOKEN, useExisting: LogContextService },
1732
+ {
1733
+ // The DEFAULT-coverage redactor, shared by the Pino factory's hooks and by
1734
+ // `PinoLoggerService.child()`. The latter needs it as a provider because
1735
+ // Pino pre-serializes child bindings before `formatters.log` can see them.
1736
+ provide: LOGGER_REDACTOR_TOKEN,
1737
+ useFactory: (options) => resolveNameRedactor(options),
1738
+ inject: [LOGGER_OPTIONS_TOKEN]
1739
+ },
1114
1740
  {
1115
1741
  provide: LOGGER_PINO_INSTANCE_TOKEN,
1116
1742
  useFactory: (options, logContext, destinations) => buildPinoInstance(options, logContext, destinations),
@@ -1126,8 +1752,7 @@ function buildCommonProviders() {
1126
1752
  // exported — consumers never inject it directly.
1127
1753
  DestinationRegistry,
1128
1754
  // One child-logger provider per context discovered from @InjectLogger(context).
1129
- ...collectContextLoggerProviders(),
1130
- bootstrapProvider()
1755
+ ...collectContextLoggerProviders()
1131
1756
  ];
1132
1757
  }
1133
1758
  function asyncHttpInterceptorProvider() {
@@ -1147,6 +1772,7 @@ function augmentLoggerModule(base, providers) {
1147
1772
  LOGGER_OPTIONS_TOKEN,
1148
1773
  LOGGER_PINO_INSTANCE_TOKEN,
1149
1774
  LOGGER_DESTINATIONS_TOKEN,
1775
+ LOGGER_REDACTOR_TOKEN,
1150
1776
  LOG_CONTEXT_TOKEN,
1151
1777
  LogContextService,
1152
1778
  PinoLoggerService,
@@ -1337,6 +1963,126 @@ var PrettyDevDestination = class {
1337
1963
  });
1338
1964
  }
1339
1965
  };
1966
+ var HttpAccessLogMiddleware = class {
1967
+ /**
1968
+ * @param logger - The structured logger the access entries are written to.
1969
+ * Injected by token explicitly: the package is bundled without
1970
+ * `emitDecoratorMetadata`, so implicit class-type injection would not resolve.
1971
+ * @param options - Resolved module options (read for `http.excludePaths`).
1972
+ */
1973
+ constructor(logger, logContext, options) {
1974
+ this.logger = logger;
1975
+ this.logContext = logContext;
1976
+ this.isEnabled = options.http.isEnabled;
1977
+ this.excludePaths = options.http.excludePaths;
1978
+ }
1979
+ /**
1980
+ * Emit START, arm the terminal entry on `'close'`, and continue the chain.
1981
+ *
1982
+ * @param req - The incoming request (Express satisfies the contract).
1983
+ * @param res - The outgoing response (Express satisfies the contract).
1984
+ * @param next - The next handler in the chain.
1985
+ */
1986
+ use(req, res, next) {
1987
+ if (!this.isEnabled) {
1988
+ next();
1989
+ return;
1990
+ }
1991
+ const target = req.originalUrl ?? req.url;
1992
+ const path2 = stripQueryString(target);
1993
+ if (this.excludePaths.some((pattern) => pattern.test(path2))) {
1994
+ next();
1995
+ return;
1996
+ }
1997
+ markRecorderActive(req);
1998
+ const method = req.method;
1999
+ const url = normalizeUrl(target);
2000
+ const start = Date.now();
2001
+ this.logger.info(RESERVED_LOG_KEYS.HTTP_REQUEST_START, `${method} ${url}`, readUserId(req), {
2002
+ method,
2003
+ url,
2004
+ // Query string stripped: it may carry secrets (reset tokens, OAuth codes)
2005
+ // that no key-name redaction can scrub out of a string VALUE.
2006
+ fullUrl: path2,
2007
+ // Conventional for an access log and personal data under GDPR/LGPD. Its
2008
+ // fidelity is the app's to establish: behind a proxy it is only the real
2009
+ // client when the app configures `trust proxy`, and a value the app has not
2010
+ // established is attacker-chosen. Suppress with `redactPaths: ['ip']`.
2011
+ ip: req.ip,
2012
+ userAgent: readUserAgent(req)
2013
+ });
2014
+ const emitTerminal = () => {
2015
+ this.logTerminal(req, res, method, url, Date.now() - start);
2016
+ };
2017
+ const bound = AsyncResource.bind(emitTerminal);
2018
+ res.on("close", () => {
2019
+ if (this.logContext.getStore() !== void 0) {
2020
+ emitTerminal();
2021
+ } else {
2022
+ bound();
2023
+ }
2024
+ });
2025
+ next();
2026
+ }
2027
+ /**
2028
+ * Emit the terminal entry for a closed request.
2029
+ *
2030
+ * `userId` is read HERE rather than at START because authentication runs in a
2031
+ * guard, downstream of this middleware — at START there is no principal yet.
2032
+ *
2033
+ * @param req - The request, carrying any error the interceptor recorded.
2034
+ * @param res - The closed response.
2035
+ * @param method - The HTTP method.
2036
+ * @param url - The normalized request URL.
2037
+ * @param duration - Elapsed milliseconds.
2038
+ */
2039
+ logTerminal(req, res, method, url, duration) {
2040
+ const statusCode = res.statusCode;
2041
+ const userId = readUserId(req);
2042
+ const meta = { method, url, statusCode, duration };
2043
+ if (!res.writableFinished) {
2044
+ this.logger.warnStructured(
2045
+ RESERVED_LOG_KEYS.HTTP_REQUEST_ABORTED,
2046
+ `${method} ${url} \u2192 ${statusCode} not delivered (${duration}ms)`,
2047
+ userId,
2048
+ meta
2049
+ );
2050
+ return;
2051
+ }
2052
+ if (statusCode >= HTTP_SERVER_ERROR_MIN) {
2053
+ const recorded = readRecordedError(req);
2054
+ this.logger.errorStructured(
2055
+ RESERVED_LOG_KEYS.HTTP_REQUEST_SERVER_ERROR,
2056
+ recorded?.error ?? new Error(`${method} ${url} \u2192 ${statusCode}`),
2057
+ userId,
2058
+ meta
2059
+ );
2060
+ return;
2061
+ }
2062
+ if (statusCode >= HTTP_CLIENT_ERROR_MIN) {
2063
+ const recorded = readRecordedError(req);
2064
+ this.logger.warnStructured(
2065
+ RESERVED_LOG_KEYS.HTTP_REQUEST_CLIENT_ERROR,
2066
+ `${method} ${url} \u2192 ${statusCode}`,
2067
+ userId,
2068
+ recorded === void 0 ? meta : { ...meta, errorMessage: recorded.error.message }
2069
+ );
2070
+ return;
2071
+ }
2072
+ this.logger.info(
2073
+ statusCode >= HTTP_REDIRECT_MIN ? RESERVED_LOG_KEYS.HTTP_REQUEST_REDIRECT : RESERVED_LOG_KEYS.HTTP_REQUEST_SUCCESS,
2074
+ `${method} ${url} \u2192 ${statusCode} (${duration}ms)`,
2075
+ userId,
2076
+ meta
2077
+ );
2078
+ }
2079
+ };
2080
+ HttpAccessLogMiddleware = __decorateClass([
2081
+ Injectable(),
2082
+ __decorateParam(0, Inject(PinoLoggerService)),
2083
+ __decorateParam(1, Inject(LogContextService)),
2084
+ __decorateParam(2, Inject(LOGGER_OPTIONS_TOKEN))
2085
+ ], HttpAccessLogMiddleware);
1340
2086
  var REQUEST_ID_HEADER = "x-request-id";
1341
2087
  var MAX_CORRELATION_ID_LENGTH = 256;
1342
2088
  var CORRELATION_ID_CHARSET = /^[A-Za-z0-9._:/+=-]+$/;
@@ -1387,9 +2133,9 @@ RequestIdMiddleware = __decorateClass([
1387
2133
  ], RequestIdMiddleware);
1388
2134
 
1389
2135
  // src/server/middlewares/apply-request-id-middleware.ts
1390
- var ALL_ROUTES = "*";
2136
+ var ALL_ROUTES = "/";
1391
2137
  function applyRequestIdMiddleware(consumer, routes = ALL_ROUTES) {
1392
- consumer.apply(RequestIdMiddleware).forRoutes(routes);
2138
+ consumer.apply(RequestIdMiddleware, HttpAccessLogMiddleware).forRoutes(routes);
1393
2139
  }
1394
2140
  var INJECT_LOGGER_CONTEXT_METADATA_KEY = "bymax_logger:context";
1395
2141
  function InjectLogger(context) {
@@ -1452,4 +2198,4 @@ function LogPerformance(thresholdMs = DEFAULT_THRESHOLD_MS) {
1452
2198
  // src/shared/constants/log-keys-convention.constants.ts
1453
2199
  var LOG_KEYS_CONVENTION_REGEX = /^[A-Z][A-Z0-9_]+_[A-Z][A-Z0-9_]+(_[A-Z][A-Z0-9_]+)?$/;
1454
2200
 
1455
- export { BymaxLoggerModule, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, HttpExceptionFilter, HttpLoggingInterceptor, InjectLogger, LOGGER_DESTINATIONS_TOKEN, LOGGER_OPTIONS_TOKEN, LOGGER_PINO_INSTANCE_TOKEN, LOG_CONTEXT_METADATA_KEY, LOG_CONTEXT_TOKEN, LOG_KEYS_CONVENTION_REGEX, LogContext, LogContextService, LogPerformance, PinoLoggerService, PrettyDevDestination, RESERVED_LOG_KEYS, RequestIdMiddleware, applyRequestIdMiddleware };
2201
+ export { BymaxLoggerModule, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, HttpExceptionFilter, HttpLoggingInterceptor, InjectLogger, LOGGER_DESTINATIONS_TOKEN, LOGGER_OPTIONS_TOKEN, LOGGER_PINO_INSTANCE_TOKEN, LOG_CONTEXT_METADATA_KEY, LOG_CONTEXT_TOKEN, LOG_KEYS_CONVENTION_REGEX, LogContext, LogContextService, LogPerformance, PINO_LEVEL_NAMES, PINO_LEVEL_NUMBERS, PinoLoggerService, PrettyDevDestination, RESERVED_LOG_KEYS, RESERVED_LOG_KEYS_NOT_EMITTED, RequestIdMiddleware, applyRequestIdMiddleware };