@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.
@@ -185,6 +185,7 @@ var RESERVED_LOG_KEYS = Object.freeze({
185
185
  HTTP_REQUEST_ABORTED: "HTTP_REQUEST_ABORTED",
186
186
  HTTP_EXCEPTION_HANDLED: "HTTP_EXCEPTION_HANDLED",
187
187
  HTTP_EXCEPTION_UNHANDLED: "HTTP_EXCEPTION_UNHANDLED",
188
+ HTTP_UNEXPECTED_ERROR: "HTTP_UNEXPECTED_ERROR",
188
189
  METHOD_EXECUTION: "METHOD_EXECUTION",
189
190
  METHOD_SLOW_EXECUTION: "METHOD_SLOW_EXECUTION",
190
191
  LOGGER_DESTINATION_INIT_FAILED: "LOGGER_DESTINATION_INIT_FAILED",
@@ -768,12 +769,26 @@ var HTTP_SERVER_ERROR_MIN = 500;
768
769
  var UNKNOWN_USER_AGENT = "unknown";
769
770
  var RECORDER_ACTIVE = /* @__PURE__ */ Symbol("bymax-one/nest-logger:recorder");
770
771
  var RECORDED_ERROR = /* @__PURE__ */ Symbol("bymax-one/nest-logger:error");
772
+ var OWN_REQUEST_ID = /* @__PURE__ */ Symbol("bymax-one/nest-logger:request-id");
773
+ var CORRELATION_OPENED = /* @__PURE__ */ Symbol("bymax-one/nest-logger:correlation");
771
774
  function markRecorderActive(req) {
772
775
  Reflect.set(req, RECORDER_ACTIVE, true);
773
776
  }
774
777
  function isRecorderActive(req) {
775
778
  return Reflect.get(req, RECORDER_ACTIVE) === true;
776
779
  }
780
+ function markCorrelationOpened(req) {
781
+ Reflect.set(req, CORRELATION_OPENED, true);
782
+ }
783
+ function isCorrelationOpened(req) {
784
+ return Reflect.get(req, CORRELATION_OPENED) === true;
785
+ }
786
+ function markOwnRequestId(req, requestId) {
787
+ Reflect.set(req, OWN_REQUEST_ID, requestId);
788
+ }
789
+ function readOwnRequestId(req) {
790
+ return Reflect.get(req, OWN_REQUEST_ID);
791
+ }
777
792
  function recordError(req, error) {
778
793
  Reflect.set(req, RECORDED_ERROR, { error });
779
794
  }
@@ -791,6 +806,16 @@ function readUserAgent(req) {
791
806
  return typeof raw === "string" ? raw : UNKNOWN_USER_AGENT;
792
807
  }
793
808
 
809
+ // src/server/utils/matches-exclude-path.util.ts
810
+ function matchesExcludePath(patterns, path) {
811
+ return patterns.some((pattern) => {
812
+ if (pattern.global || pattern.sticky) {
813
+ pattern.lastIndex = 0;
814
+ }
815
+ return pattern.test(path);
816
+ });
817
+ }
818
+
794
819
  // src/server/interceptors/http-logging.interceptor.ts
795
820
  var HTTP_SUCCESS_MIN = 200;
796
821
  exports.HttpLoggingInterceptor = class HttpLoggingInterceptor {
@@ -812,7 +837,7 @@ exports.HttpLoggingInterceptor = class HttpLoggingInterceptor {
812
837
  * @returns `true` when the path matches a configured exclude pattern.
813
838
  */
814
839
  isExcluded(path) {
815
- return this.excludePaths.some((pattern) => pattern.test(path));
840
+ return matchesExcludePath(this.excludePaths, path);
816
841
  }
817
842
  /**
818
843
  * Emit the START entry, then attach success/redirect/error logging to the
@@ -2094,8 +2119,21 @@ exports.LogContextService = class LogContextService {
2094
2119
  * Run `callback` inside a fresh context scope. Every log emitted
2095
2120
  * synchronously or asynchronously within the callback inherits `context`.
2096
2121
  *
2122
+ * **A nested `run()` REPLACES the enclosing context, it does not extend it.**
2123
+ * The scope starts from `context` alone, so a field an outer scope had set —
2124
+ * a `tenantId` resolved at the edge, say — is absent for the whole inner
2125
+ * scope: nothing throws, `get()` simply returns `undefined`, and the field is
2126
+ * missing from every entry the callback emits. Reach for {@link runMerged}
2127
+ * when the inner scope should extend the outer one.
2128
+ *
2129
+ * Replacement is the deliberate default because the opposite leaks: a
2130
+ * background job started inside a request scope would silently inherit the
2131
+ * caller's `userId` and attribute its entries to a user who never triggered
2132
+ * it. A missing field is visibly missing; an inherited one is wrong while
2133
+ * looking right.
2134
+ *
2097
2135
  * @typeParam T - Return type of the callback.
2098
- * @param context - Initial context bag for the scope.
2136
+ * @param context - Initial context bag for the scope. Becomes the WHOLE scope.
2099
2137
  * @param callback - Function executed within the scope.
2100
2138
  * @returns Whatever `callback` returns.
2101
2139
  * @example
@@ -2106,6 +2144,35 @@ exports.LogContextService = class LogContextService {
2106
2144
  run(context, callback) {
2107
2145
  return this.als.run(this.sanitizeContext(context), callback);
2108
2146
  }
2147
+ /**
2148
+ * Run `callback` inside a scope that EXTENDS the enclosing one: the active
2149
+ * context's fields are carried in, and `context` overrides them key by key.
2150
+ *
2151
+ * The explicit counterpart to {@link run}'s replacement semantics, for the
2152
+ * case where an inner scope is genuinely a refinement of the outer — adding a
2153
+ * `userId` once authentication resolved it, without having to restate the
2154
+ * `requestId` the middleware already set.
2155
+ *
2156
+ * Outside any scope there is nothing to merge and this behaves exactly like
2157
+ * {@link run}. Writes inside the new scope never reach the enclosing one: the
2158
+ * merge produces a fresh object, so the outer context is intact after the
2159
+ * callback returns.
2160
+ *
2161
+ * @typeParam T - Return type of the callback.
2162
+ * @param context - Fields to add or override on top of the active context.
2163
+ * @param callback - Function executed within the merged scope.
2164
+ * @returns Whatever `callback` returns.
2165
+ * @example
2166
+ * logContext.run({ requestId: 'r_1' }, () => {
2167
+ * logContext.runMerged({ userId: 'u_1' }, () => {
2168
+ * logContext.get('requestId') // 'r_1' — carried in from the outer scope
2169
+ * })
2170
+ * })
2171
+ */
2172
+ runMerged(context, callback) {
2173
+ const merged = { ...this.als.getStore(), ...this.sanitizeContext(context) };
2174
+ return this.als.run(merged, callback);
2175
+ }
2109
2176
  /**
2110
2177
  * Read the active context, or `undefined` when called outside any scope.
2111
2178
  *
@@ -2537,7 +2604,7 @@ var PrettyDevDestination = class {
2537
2604
  });
2538
2605
  }
2539
2606
  };
2540
- var HttpAccessLogMiddleware = class {
2607
+ exports.HttpAccessLogMiddleware = class HttpAccessLogMiddleware {
2541
2608
  /**
2542
2609
  * @param logger - The structured logger the access entries are written to.
2543
2610
  * Injected by token explicitly: the package is bundled without
@@ -2558,13 +2625,17 @@ var HttpAccessLogMiddleware = class {
2558
2625
  * @param next - The next handler in the chain.
2559
2626
  */
2560
2627
  use(req, res, next) {
2628
+ if (isRecorderActive(req)) {
2629
+ next();
2630
+ return;
2631
+ }
2561
2632
  if (!this.isEnabled) {
2562
2633
  next();
2563
2634
  return;
2564
2635
  }
2565
2636
  const target = req.originalUrl ?? req.url;
2566
2637
  const path = stripQueryString(target);
2567
- if (this.excludePaths.some((pattern) => pattern.test(path))) {
2638
+ if (matchesExcludePath(this.excludePaths, path)) {
2568
2639
  next();
2569
2640
  return;
2570
2641
  }
@@ -2651,15 +2722,16 @@ var HttpAccessLogMiddleware = class {
2651
2722
  );
2652
2723
  }
2653
2724
  };
2654
- HttpAccessLogMiddleware = __decorateClass([
2725
+ exports.HttpAccessLogMiddleware = __decorateClass([
2655
2726
  common.Injectable(),
2656
2727
  __decorateParam(0, common.Inject(exports.PinoLoggerService)),
2657
2728
  __decorateParam(1, common.Inject(exports.LogContextService)),
2658
2729
  __decorateParam(2, common.Inject(LOGGER_OPTIONS_TOKEN))
2659
- ], HttpAccessLogMiddleware);
2730
+ ], exports.HttpAccessLogMiddleware);
2660
2731
  var REQUEST_ID_HEADER = "x-request-id";
2661
2732
  var MAX_CORRELATION_ID_LENGTH = 256;
2662
2733
  var CORRELATION_ID_CHARSET = /^[A-Za-z0-9._:/+=-]+$/;
2734
+ var INHERITABLE_CONTEXT_KEYS = ["requestId", "tenantId"];
2663
2735
  function isAcceptableHeaderValue(value) {
2664
2736
  return typeof value === "string" && value.length <= MAX_CORRELATION_ID_LENGTH && CORRELATION_ID_CHARSET.test(value);
2665
2737
  }
@@ -2678,27 +2750,128 @@ exports.RequestIdMiddleware = class RequestIdMiddleware {
2678
2750
  }
2679
2751
  /**
2680
2752
  * Echo or mint the `x-request-id`, expose it on the response, and run the
2681
- * remainder of the request inside a fresh log-context scope.
2753
+ * remainder of the request inside its own log-context scope.
2754
+ *
2755
+ * Idempotent: mounting this middleware twice on the same request mints one id,
2756
+ * not two. The request gets its OWN store, started from the enclosing scope's
2757
+ * correlation fields — see {@link INHERITABLE_CONTEXT_KEYS} — and nothing
2758
+ * else, because no other field can be known to belong to this request.
2759
+ *
2760
+ * An enclosing scope's `requestId` is never adopted and never echoed: adoption
2761
+ * reads a value this middleware recorded on the REQUEST, which a scope opened
2762
+ * elsewhere does not have. What the two generation modes do with one, measured
2763
+ * on two requests sharing a long-lived enclosing scope:
2764
+ *
2765
+ * - `shouldGenerateRequestId: true` (default) — each request mints its own
2766
+ * id, the two requests differ, and the enclosing value reaches neither the
2767
+ * header nor the entries.
2768
+ * - `shouldGenerateRequestId: false` with no inbound header — nothing is
2769
+ * minted, so the request scope inherits the enclosing `requestId` and every
2770
+ * request inside that scope logs under it. No header is exposed. That is
2771
+ * the consumer's own scope being inherited, not correlation being reused,
2772
+ * and it is the configuration that says the gateway owns the id.
2682
2773
  *
2683
2774
  * @param req - The incoming request (Express satisfies the contract).
2684
2775
  * @param res - The outgoing response (Express satisfies the contract).
2685
2776
  * @param next - The next handler in the chain.
2686
2777
  */
2687
2778
  use(req, res, next) {
2688
- const incoming = Reflect.get(req.headers, REQUEST_ID_HEADER);
2689
- const fromHeader = isAcceptableHeaderValue(incoming) ? incoming : void 0;
2690
- const requestId = fromHeader ?? (this.shouldGenerateRequestId ? crypto.randomUUID() : void 0);
2691
- const context = {};
2779
+ if (isCorrelationOpened(req)) {
2780
+ const ownRequestId = readOwnRequestId(req);
2781
+ if (ownRequestId !== void 0) {
2782
+ this.adoptExistingScope(ownRequestId, req, res);
2783
+ }
2784
+ next();
2785
+ return;
2786
+ }
2787
+ markCorrelationOpened(req);
2788
+ const context = this.inheritableContext();
2789
+ const requestId = this.resolveRequestId(req);
2692
2790
  if (requestId !== void 0) {
2693
2791
  res.setHeader(REQUEST_ID_HEADER, requestId);
2694
2792
  context.requestId = requestId;
2793
+ markOwnRequestId(req, requestId);
2695
2794
  }
2696
- const rawTenantId = Reflect.get(req.headers, this.tenantIdHeader);
2697
- if (isAcceptableHeaderValue(rawTenantId)) {
2698
- context.tenantId = rawTenantId;
2795
+ if (context.tenantId === void 0) {
2796
+ const tenantId = this.resolveTenantId(req);
2797
+ if (tenantId !== void 0) {
2798
+ context.tenantId = tenantId;
2799
+ }
2699
2800
  }
2700
2801
  this.logContext.run(context, () => next());
2701
2802
  }
2803
+ /**
2804
+ * The enclosing scope's fields that a request scope may start from.
2805
+ *
2806
+ * Plain `run()` would discard everything the caller had set and a plain merge
2807
+ * would carry identity across requests; {@link INHERITABLE_CONTEXT_KEYS} is
2808
+ * the middle a request scope actually needs.
2809
+ *
2810
+ * @returns A fresh bag, never the enclosing store itself, so writes inside the
2811
+ * request never reach the scope that opened it.
2812
+ */
2813
+ inheritableContext() {
2814
+ const store = { ...this.logContext.getStore() };
2815
+ const inherited = {};
2816
+ for (const key of INHERITABLE_CONTEXT_KEYS) {
2817
+ const value = Reflect.get(store, key);
2818
+ if (value !== void 0) {
2819
+ Reflect.set(inherited, key, value);
2820
+ }
2821
+ }
2822
+ return inherited;
2823
+ }
2824
+ /**
2825
+ * Second mount on a request that already carries a correlation id: echo the id
2826
+ * onto the response and fill in a tenant the scope does not have yet, without
2827
+ * opening another scope.
2828
+ *
2829
+ * Nothing is minted and nothing already set is overwritten, so a third mount —
2830
+ * or a thirtieth — changes nothing. The response header is written on every
2831
+ * pass because the enclosing scope may have been opened by consumer code that
2832
+ * never exposed it.
2833
+ *
2834
+ * @param requestId - The id already in scope, which wins.
2835
+ * @param req - The incoming request, read for the tenant header.
2836
+ * @param res - The outgoing response, where the effective id is exposed.
2837
+ */
2838
+ adoptExistingScope(requestId, req, res) {
2839
+ res.setHeader(REQUEST_ID_HEADER, requestId);
2840
+ this.logContext.set("requestId", requestId);
2841
+ if (this.logContext.get("tenantId") === void 0) {
2842
+ const tenantId = this.resolveTenantId(req);
2843
+ if (tenantId !== void 0) {
2844
+ this.logContext.set("tenantId", tenantId);
2845
+ }
2846
+ }
2847
+ }
2848
+ /**
2849
+ * The correlation id for this request: the inbound header when acceptable,
2850
+ * otherwise a fresh UUID — or nothing at all when `shouldGenerateRequestId` is
2851
+ * off and the caller sent none, which leaves correlation to the upstream
2852
+ * gateway.
2853
+ *
2854
+ * @param req - The incoming request.
2855
+ * @returns The id, or `undefined` when none is available and none is minted.
2856
+ */
2857
+ resolveRequestId(req) {
2858
+ const incoming = Reflect.get(req.headers, REQUEST_ID_HEADER);
2859
+ const fromHeader = isAcceptableHeaderValue(incoming) ? incoming : void 0;
2860
+ return fromHeader ?? (this.shouldGenerateRequestId ? crypto.randomUUID() : void 0);
2861
+ }
2862
+ /**
2863
+ * The tenant id carried by the configured header, when it is acceptable.
2864
+ *
2865
+ * Never minted: a tenant is an assertion about who the request belongs to, and
2866
+ * inventing one would be a fabricated claim rather than a correlation aid.
2867
+ *
2868
+ * @param req - The incoming request.
2869
+ * @returns The tenant id, or `undefined`.
2870
+ */
2871
+ resolveTenantId(req) {
2872
+ const raw = Reflect.get(req.headers, this.tenantIdHeader);
2873
+ return isAcceptableHeaderValue(raw) ? raw : void 0;
2874
+ }
2702
2875
  };
2703
2876
  exports.RequestIdMiddleware = __decorateClass([
2704
2877
  common.Injectable(),
@@ -2706,10 +2879,93 @@ exports.RequestIdMiddleware = __decorateClass([
2706
2879
  __decorateParam(1, common.Inject(LOGGER_OPTIONS_TOKEN))
2707
2880
  ], exports.RequestIdMiddleware);
2708
2881
 
2882
+ // src/server/middlewares/apply-access-log.ts
2883
+ function resolveDependencies(app) {
2884
+ try {
2885
+ return {
2886
+ logger: app.get(exports.PinoLoggerService),
2887
+ logContext: app.get(exports.LogContextService),
2888
+ options: app.get(LOGGER_OPTIONS_TOKEN)
2889
+ };
2890
+ } catch (cause) {
2891
+ throw new Error(
2892
+ "[BymaxLoggerModule] applyAccessLog(app) called but BymaxLoggerModule was not imported",
2893
+ { cause }
2894
+ );
2895
+ }
2896
+ }
2897
+ function applyAccessLog(app) {
2898
+ const { logger, logContext, options } = resolveDependencies(app);
2899
+ const requestId = new exports.RequestIdMiddleware(logContext, options);
2900
+ const accessLog = new exports.HttpAccessLogMiddleware(logger, logContext, options);
2901
+ app.use((req, res, next) => {
2902
+ requestId.use(req, res, () => {
2903
+ accessLog.use(req, res, next);
2904
+ });
2905
+ });
2906
+ }
2907
+
2709
2908
  // src/server/middlewares/apply-request-id-middleware.ts
2710
2909
  var ALL_ROUTES = "/";
2711
2910
  function applyRequestIdMiddleware(consumer, routes = ALL_ROUTES) {
2712
- consumer.apply(exports.RequestIdMiddleware, HttpAccessLogMiddleware).forRoutes(routes);
2911
+ consumer.apply(exports.RequestIdMiddleware, exports.HttpAccessLogMiddleware).forRoutes(routes);
2912
+ }
2913
+ exports.PinoUnexpectedErrorSink = class PinoUnexpectedErrorSink {
2914
+ /**
2915
+ * @param logger - The structured logger the entry is written to. Injected by
2916
+ * token explicitly: the package is bundled without `emitDecoratorMetadata`,
2917
+ * so implicit class-type injection would not resolve.
2918
+ */
2919
+ constructor(logger) {
2920
+ this.logger = logger;
2921
+ }
2922
+ /**
2923
+ * Write one `error` entry for an unexpected error. Never throws.
2924
+ *
2925
+ * The entry carries `err` (name, message, scrubbed stack, cause chain and own
2926
+ * fields, through the configured `err` serializer), `method`, `route` and,
2927
+ * when present, `correlationId`. It deliberately carries nothing else from
2928
+ * the request: the report has no body, headers or query to leak, and this
2929
+ * method adds none.
2930
+ *
2931
+ * `report.traceId` is not written. The trace mixin already stamps the active
2932
+ * span's trace id under the configured `otel.traceIdField` — the same span
2933
+ * nest-core read it from — and writing it here would override that field and
2934
+ * ignore `otel.shouldAutoInjectTraceContext: false`.
2935
+ *
2936
+ * @param report - The error and its request coordinates, from nest-core.
2937
+ * @example
2938
+ * sink.record({ error: new TypeError('x is undefined'), method: 'GET', route: '/invoices/:id', correlationId: 'req-1' })
2939
+ * // → { level: 'error', logKey: 'HTTP_UNEXPECTED_ERROR', msg: 'x is undefined',
2940
+ * // err: { type: 'TypeError', message: 'x is undefined', stack: '…' },
2941
+ * // method: 'GET', route: '/invoices/:id', correlationId: 'req-1' }
2942
+ */
2943
+ record(report) {
2944
+ try {
2945
+ const metadata = { method: report.method, route: report.route };
2946
+ if (report.correlationId !== void 0) {
2947
+ metadata["correlationId"] = report.correlationId;
2948
+ }
2949
+ this.logger.errorStructured(
2950
+ RESERVED_LOG_KEYS.HTTP_UNEXPECTED_ERROR,
2951
+ toLoggableError(report.error),
2952
+ void 0,
2953
+ metadata
2954
+ );
2955
+ } catch {
2956
+ }
2957
+ }
2958
+ };
2959
+ exports.PinoUnexpectedErrorSink = __decorateClass([
2960
+ common.Injectable(),
2961
+ __decorateParam(0, common.Inject(exports.PinoLoggerService))
2962
+ ], exports.PinoUnexpectedErrorSink);
2963
+ function toLoggableError(value) {
2964
+ try {
2965
+ return isErrorLike(value) ? value : sanitizeError(value);
2966
+ } catch {
2967
+ return sanitizeError(value);
2968
+ }
2713
2969
  }
2714
2970
  var INJECT_LOGGER_CONTEXT_METADATA_KEY = "bymax_logger:context";
2715
2971
  function InjectLogger(context) {
@@ -2787,4 +3043,5 @@ exports.PINO_LEVEL_NUMBERS = PINO_LEVEL_NUMBERS;
2787
3043
  exports.PrettyDevDestination = PrettyDevDestination;
2788
3044
  exports.RESERVED_LOG_KEYS = RESERVED_LOG_KEYS;
2789
3045
  exports.RESERVED_LOG_KEYS_NOT_EMITTED = RESERVED_LOG_KEYS_NOT_EMITTED;
3046
+ exports.applyAccessLog = applyAccessLog;
2790
3047
  exports.applyRequestIdMiddleware = applyRequestIdMiddleware;