@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.
@@ -765,12 +765,26 @@ var HTTP_SERVER_ERROR_MIN = 500;
765
765
  var UNKNOWN_USER_AGENT = "unknown";
766
766
  var RECORDER_ACTIVE = /* @__PURE__ */ Symbol("bymax-one/nest-logger:recorder");
767
767
  var RECORDED_ERROR = /* @__PURE__ */ Symbol("bymax-one/nest-logger:error");
768
+ var OWN_REQUEST_ID = /* @__PURE__ */ Symbol("bymax-one/nest-logger:request-id");
769
+ var CORRELATION_OPENED = /* @__PURE__ */ Symbol("bymax-one/nest-logger:correlation");
768
770
  function markRecorderActive(req) {
769
771
  Reflect.set(req, RECORDER_ACTIVE, true);
770
772
  }
771
773
  function isRecorderActive(req) {
772
774
  return Reflect.get(req, RECORDER_ACTIVE) === true;
773
775
  }
776
+ function markCorrelationOpened(req) {
777
+ Reflect.set(req, CORRELATION_OPENED, true);
778
+ }
779
+ function isCorrelationOpened(req) {
780
+ return Reflect.get(req, CORRELATION_OPENED) === true;
781
+ }
782
+ function markOwnRequestId(req, requestId) {
783
+ Reflect.set(req, OWN_REQUEST_ID, requestId);
784
+ }
785
+ function readOwnRequestId(req) {
786
+ return Reflect.get(req, OWN_REQUEST_ID);
787
+ }
774
788
  function recordError(req, error) {
775
789
  Reflect.set(req, RECORDED_ERROR, { error });
776
790
  }
@@ -788,6 +802,16 @@ function readUserAgent(req) {
788
802
  return typeof raw === "string" ? raw : UNKNOWN_USER_AGENT;
789
803
  }
790
804
 
805
+ // src/server/utils/matches-exclude-path.util.ts
806
+ function matchesExcludePath(patterns, path2) {
807
+ return patterns.some((pattern) => {
808
+ if (pattern.global || pattern.sticky) {
809
+ pattern.lastIndex = 0;
810
+ }
811
+ return pattern.test(path2);
812
+ });
813
+ }
814
+
791
815
  // src/server/interceptors/http-logging.interceptor.ts
792
816
  var HTTP_SUCCESS_MIN = 200;
793
817
  var HttpLoggingInterceptor = class {
@@ -809,7 +833,7 @@ var HttpLoggingInterceptor = class {
809
833
  * @returns `true` when the path matches a configured exclude pattern.
810
834
  */
811
835
  isExcluded(path2) {
812
- return this.excludePaths.some((pattern) => pattern.test(path2));
836
+ return matchesExcludePath(this.excludePaths, path2);
813
837
  }
814
838
  /**
815
839
  * Emit the START entry, then attach success/redirect/error logging to the
@@ -1134,7 +1158,6 @@ function safeMinLevel(destination) {
1134
1158
  const read = destination.minLevel;
1135
1159
  resolved = isLogLevel(read) ? read : void 0;
1136
1160
  } catch {
1137
- resolved = void 0;
1138
1161
  }
1139
1162
  resolvedMinLevels.set(destination, resolved);
1140
1163
  return resolved;
@@ -2092,8 +2115,21 @@ var LogContextService = class {
2092
2115
  * Run `callback` inside a fresh context scope. Every log emitted
2093
2116
  * synchronously or asynchronously within the callback inherits `context`.
2094
2117
  *
2118
+ * **A nested `run()` REPLACES the enclosing context, it does not extend it.**
2119
+ * The scope starts from `context` alone, so a field an outer scope had set —
2120
+ * a `tenantId` resolved at the edge, say — is absent for the whole inner
2121
+ * scope: nothing throws, `get()` simply returns `undefined`, and the field is
2122
+ * missing from every entry the callback emits. Reach for {@link runMerged}
2123
+ * when the inner scope should extend the outer one.
2124
+ *
2125
+ * Replacement is the deliberate default because the opposite leaks: a
2126
+ * background job started inside a request scope would silently inherit the
2127
+ * caller's `userId` and attribute its entries to a user who never triggered
2128
+ * it. A missing field is visibly missing; an inherited one is wrong while
2129
+ * looking right.
2130
+ *
2095
2131
  * @typeParam T - Return type of the callback.
2096
- * @param context - Initial context bag for the scope.
2132
+ * @param context - Initial context bag for the scope. Becomes the WHOLE scope.
2097
2133
  * @param callback - Function executed within the scope.
2098
2134
  * @returns Whatever `callback` returns.
2099
2135
  * @example
@@ -2104,6 +2140,35 @@ var LogContextService = class {
2104
2140
  run(context, callback) {
2105
2141
  return this.als.run(this.sanitizeContext(context), callback);
2106
2142
  }
2143
+ /**
2144
+ * Run `callback` inside a scope that EXTENDS the enclosing one: the active
2145
+ * context's fields are carried in, and `context` overrides them key by key.
2146
+ *
2147
+ * The explicit counterpart to {@link run}'s replacement semantics, for the
2148
+ * case where an inner scope is genuinely a refinement of the outer — adding a
2149
+ * `userId` once authentication resolved it, without having to restate the
2150
+ * `requestId` the middleware already set.
2151
+ *
2152
+ * Outside any scope there is nothing to merge and this behaves exactly like
2153
+ * {@link run}. Writes inside the new scope never reach the enclosing one: the
2154
+ * merge produces a fresh object, so the outer context is intact after the
2155
+ * callback returns.
2156
+ *
2157
+ * @typeParam T - Return type of the callback.
2158
+ * @param context - Fields to add or override on top of the active context.
2159
+ * @param callback - Function executed within the merged scope.
2160
+ * @returns Whatever `callback` returns.
2161
+ * @example
2162
+ * logContext.run({ requestId: 'r_1' }, () => {
2163
+ * logContext.runMerged({ userId: 'u_1' }, () => {
2164
+ * logContext.get('requestId') // 'r_1' — carried in from the outer scope
2165
+ * })
2166
+ * })
2167
+ */
2168
+ runMerged(context, callback) {
2169
+ const merged = { ...this.als.getStore(), ...this.sanitizeContext(context) };
2170
+ return this.als.run(merged, callback);
2171
+ }
2107
2172
  /**
2108
2173
  * Read the active context, or `undefined` when called outside any scope.
2109
2174
  *
@@ -2556,13 +2621,17 @@ var HttpAccessLogMiddleware = class {
2556
2621
  * @param next - The next handler in the chain.
2557
2622
  */
2558
2623
  use(req, res, next) {
2624
+ if (isRecorderActive(req)) {
2625
+ next();
2626
+ return;
2627
+ }
2559
2628
  if (!this.isEnabled) {
2560
2629
  next();
2561
2630
  return;
2562
2631
  }
2563
2632
  const target = req.originalUrl ?? req.url;
2564
2633
  const path2 = stripQueryString(target);
2565
- if (this.excludePaths.some((pattern) => pattern.test(path2))) {
2634
+ if (matchesExcludePath(this.excludePaths, path2)) {
2566
2635
  next();
2567
2636
  return;
2568
2637
  }
@@ -2658,6 +2727,7 @@ HttpAccessLogMiddleware = __decorateClass([
2658
2727
  var REQUEST_ID_HEADER = "x-request-id";
2659
2728
  var MAX_CORRELATION_ID_LENGTH = 256;
2660
2729
  var CORRELATION_ID_CHARSET = /^[A-Za-z0-9._:/+=-]+$/;
2730
+ var INHERITABLE_CONTEXT_KEYS = ["requestId", "tenantId"];
2661
2731
  function isAcceptableHeaderValue(value) {
2662
2732
  return typeof value === "string" && value.length <= MAX_CORRELATION_ID_LENGTH && CORRELATION_ID_CHARSET.test(value);
2663
2733
  }
@@ -2676,27 +2746,128 @@ var RequestIdMiddleware = class {
2676
2746
  }
2677
2747
  /**
2678
2748
  * Echo or mint the `x-request-id`, expose it on the response, and run the
2679
- * remainder of the request inside a fresh log-context scope.
2749
+ * remainder of the request inside its own log-context scope.
2750
+ *
2751
+ * Idempotent: mounting this middleware twice on the same request mints one id,
2752
+ * not two. The request gets its OWN store, started from the enclosing scope's
2753
+ * correlation fields — see {@link INHERITABLE_CONTEXT_KEYS} — and nothing
2754
+ * else, because no other field can be known to belong to this request.
2755
+ *
2756
+ * An enclosing scope's `requestId` is never adopted and never echoed: adoption
2757
+ * reads a value this middleware recorded on the REQUEST, which a scope opened
2758
+ * elsewhere does not have. What the two generation modes do with one, measured
2759
+ * on two requests sharing a long-lived enclosing scope:
2760
+ *
2761
+ * - `shouldGenerateRequestId: true` (default) — each request mints its own
2762
+ * id, the two requests differ, and the enclosing value reaches neither the
2763
+ * header nor the entries.
2764
+ * - `shouldGenerateRequestId: false` with no inbound header — nothing is
2765
+ * minted, so the request scope inherits the enclosing `requestId` and every
2766
+ * request inside that scope logs under it. No header is exposed. That is
2767
+ * the consumer's own scope being inherited, not correlation being reused,
2768
+ * and it is the configuration that says the gateway owns the id.
2680
2769
  *
2681
2770
  * @param req - The incoming request (Express satisfies the contract).
2682
2771
  * @param res - The outgoing response (Express satisfies the contract).
2683
2772
  * @param next - The next handler in the chain.
2684
2773
  */
2685
2774
  use(req, res, next) {
2686
- const incoming = Reflect.get(req.headers, REQUEST_ID_HEADER);
2687
- const fromHeader = isAcceptableHeaderValue(incoming) ? incoming : void 0;
2688
- const requestId = fromHeader ?? (this.shouldGenerateRequestId ? randomUUID() : void 0);
2689
- const context = {};
2775
+ if (isCorrelationOpened(req)) {
2776
+ const ownRequestId = readOwnRequestId(req);
2777
+ if (ownRequestId !== void 0) {
2778
+ this.adoptExistingScope(ownRequestId, req, res);
2779
+ }
2780
+ next();
2781
+ return;
2782
+ }
2783
+ markCorrelationOpened(req);
2784
+ const context = this.inheritableContext();
2785
+ const requestId = this.resolveRequestId(req);
2690
2786
  if (requestId !== void 0) {
2691
2787
  res.setHeader(REQUEST_ID_HEADER, requestId);
2692
2788
  context.requestId = requestId;
2789
+ markOwnRequestId(req, requestId);
2693
2790
  }
2694
- const rawTenantId = Reflect.get(req.headers, this.tenantIdHeader);
2695
- if (isAcceptableHeaderValue(rawTenantId)) {
2696
- context.tenantId = rawTenantId;
2791
+ if (context.tenantId === void 0) {
2792
+ const tenantId = this.resolveTenantId(req);
2793
+ if (tenantId !== void 0) {
2794
+ context.tenantId = tenantId;
2795
+ }
2697
2796
  }
2698
2797
  this.logContext.run(context, () => next());
2699
2798
  }
2799
+ /**
2800
+ * The enclosing scope's fields that a request scope may start from.
2801
+ *
2802
+ * Plain `run()` would discard everything the caller had set and a plain merge
2803
+ * would carry identity across requests; {@link INHERITABLE_CONTEXT_KEYS} is
2804
+ * the middle a request scope actually needs.
2805
+ *
2806
+ * @returns A fresh bag, never the enclosing store itself, so writes inside the
2807
+ * request never reach the scope that opened it.
2808
+ */
2809
+ inheritableContext() {
2810
+ const store = { ...this.logContext.getStore() };
2811
+ const inherited = {};
2812
+ for (const key of INHERITABLE_CONTEXT_KEYS) {
2813
+ const value = Reflect.get(store, key);
2814
+ if (value !== void 0) {
2815
+ Reflect.set(inherited, key, value);
2816
+ }
2817
+ }
2818
+ return inherited;
2819
+ }
2820
+ /**
2821
+ * Second mount on a request that already carries a correlation id: echo the id
2822
+ * onto the response and fill in a tenant the scope does not have yet, without
2823
+ * opening another scope.
2824
+ *
2825
+ * Nothing is minted and nothing already set is overwritten, so a third mount —
2826
+ * or a thirtieth — changes nothing. The response header is written on every
2827
+ * pass because the enclosing scope may have been opened by consumer code that
2828
+ * never exposed it.
2829
+ *
2830
+ * @param requestId - The id already in scope, which wins.
2831
+ * @param req - The incoming request, read for the tenant header.
2832
+ * @param res - The outgoing response, where the effective id is exposed.
2833
+ */
2834
+ adoptExistingScope(requestId, req, res) {
2835
+ res.setHeader(REQUEST_ID_HEADER, requestId);
2836
+ this.logContext.set("requestId", requestId);
2837
+ if (this.logContext.get("tenantId") === void 0) {
2838
+ const tenantId = this.resolveTenantId(req);
2839
+ if (tenantId !== void 0) {
2840
+ this.logContext.set("tenantId", tenantId);
2841
+ }
2842
+ }
2843
+ }
2844
+ /**
2845
+ * The correlation id for this request: the inbound header when acceptable,
2846
+ * otherwise a fresh UUID — or nothing at all when `shouldGenerateRequestId` is
2847
+ * off and the caller sent none, which leaves correlation to the upstream
2848
+ * gateway.
2849
+ *
2850
+ * @param req - The incoming request.
2851
+ * @returns The id, or `undefined` when none is available and none is minted.
2852
+ */
2853
+ resolveRequestId(req) {
2854
+ const incoming = Reflect.get(req.headers, REQUEST_ID_HEADER);
2855
+ const fromHeader = isAcceptableHeaderValue(incoming) ? incoming : void 0;
2856
+ return fromHeader ?? (this.shouldGenerateRequestId ? randomUUID() : void 0);
2857
+ }
2858
+ /**
2859
+ * The tenant id carried by the configured header, when it is acceptable.
2860
+ *
2861
+ * Never minted: a tenant is an assertion about who the request belongs to, and
2862
+ * inventing one would be a fabricated claim rather than a correlation aid.
2863
+ *
2864
+ * @param req - The incoming request.
2865
+ * @returns The tenant id, or `undefined`.
2866
+ */
2867
+ resolveTenantId(req) {
2868
+ const raw = Reflect.get(req.headers, this.tenantIdHeader);
2869
+ return isAcceptableHeaderValue(raw) ? raw : void 0;
2870
+ }
2700
2871
  };
2701
2872
  RequestIdMiddleware = __decorateClass([
2702
2873
  Injectable(),
@@ -2704,6 +2875,32 @@ RequestIdMiddleware = __decorateClass([
2704
2875
  __decorateParam(1, Inject(LOGGER_OPTIONS_TOKEN))
2705
2876
  ], RequestIdMiddleware);
2706
2877
 
2878
+ // src/server/middlewares/apply-access-log.ts
2879
+ function resolveDependencies(app) {
2880
+ try {
2881
+ return {
2882
+ logger: app.get(PinoLoggerService),
2883
+ logContext: app.get(LogContextService),
2884
+ options: app.get(LOGGER_OPTIONS_TOKEN)
2885
+ };
2886
+ } catch (cause) {
2887
+ throw new Error(
2888
+ "[BymaxLoggerModule] applyAccessLog(app) called but BymaxLoggerModule was not imported",
2889
+ { cause }
2890
+ );
2891
+ }
2892
+ }
2893
+ function applyAccessLog(app) {
2894
+ const { logger, logContext, options } = resolveDependencies(app);
2895
+ const requestId = new RequestIdMiddleware(logContext, options);
2896
+ const accessLog = new HttpAccessLogMiddleware(logger, logContext, options);
2897
+ app.use((req, res, next) => {
2898
+ requestId.use(req, res, () => {
2899
+ accessLog.use(req, res, next);
2900
+ });
2901
+ });
2902
+ }
2903
+
2707
2904
  // src/server/middlewares/apply-request-id-middleware.ts
2708
2905
  var ALL_ROUTES = "/";
2709
2906
  function applyRequestIdMiddleware(consumer, routes = ALL_ROUTES) {
@@ -2770,4 +2967,4 @@ function LogPerformance(thresholdMs = DEFAULT_THRESHOLD_MS) {
2770
2967
  // src/shared/constants/log-keys-convention.constants.ts
2771
2968
  var LOG_KEYS_CONVENTION_REGEX = /^[A-Z][A-Z0-9_]+_[A-Z][A-Z0-9_]+(_[A-Z][A-Z0-9_]+)?$/;
2772
2969
 
2773
- 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 };
2970
+ export { BymaxLoggerModule, DEFAULT_REDACT_PATHS, DefaultStdoutDestination, HttpAccessLogMiddleware, 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, applyAccessLog, applyRequestIdMiddleware };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bymax-one/nest-logger",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Structured JSON logging for NestJS based on Pino 10, with optional OpenTelemetry correlation.",
5
5
  "author": "Bymax One <support@bymax.one>",
6
6
  "license": "MIT",