@bymax-one/nest-core 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, HttpStatus } from '@nestjs/common';
2
- import { HttpAdapterHost, BaseExceptionFilter, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
2
+ import { HttpAdapterHost, DiscoveryService, Reflector, BaseExceptionFilter, APP_FILTER, APP_INTERCEPTOR, DiscoveryModule } from '@nestjs/core';
3
3
  import { tap, catchError, throwError } from 'rxjs';
4
4
 
5
5
  var __defProp = Object.defineProperty;
@@ -14,10 +14,23 @@ var __decorateClass = (decorators, target, key, kind) => {
14
14
  };
15
15
  var __decorateParam = (index, decorator) => (target, key) => decorator(target, key, index);
16
16
 
17
+ // src/runtime.environment.ts
18
+ var NON_PRODUCTION_ENVIRONMENTS = /* @__PURE__ */ new Set(["development", "test"]);
19
+ function isProductionRuntime(value = process.env["NODE_ENV"]) {
20
+ if (value === void 0) {
21
+ return true;
22
+ }
23
+ return !NON_PRODUCTION_ENVIRONMENTS.has(value.trim().toLowerCase());
24
+ }
25
+
17
26
  // src/core.options.ts
18
27
  var DEFAULT_HEALTH_PATH = "health";
19
28
  var DEFAULT_INDICATOR_TIMEOUT_MS = 5e3;
20
29
  var DEFAULT_METRICS_PATH = "metrics";
30
+ var DEFAULT_OPENAPI_PATH = "docs";
31
+ var DEFAULT_OPENAPI_JSON_PATH = "docs-json";
32
+ var DEFAULT_OPENAPI_TITLE = "API";
33
+ var DEFAULT_OPENAPI_VERSION = "1.0.0";
21
34
  function deepFreeze(value) {
22
35
  if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
23
36
  Object.freeze(value);
@@ -43,7 +56,8 @@ function resolveHealth(raw) {
43
56
  enabled: raw?.enabled ?? true,
44
57
  path: raw?.path ?? DEFAULT_HEALTH_PATH,
45
58
  exposeIndicatorErrors: raw?.exposeIndicatorErrors ?? false,
46
- indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS
59
+ indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS,
60
+ autoDiscover: raw?.autoDiscover ?? false
47
61
  };
48
62
  }
49
63
  function resolveMetrics(raw) {
@@ -54,12 +68,44 @@ function resolveMetrics(raw) {
54
68
  defaultLabels: { ...raw?.defaultLabels ?? {} }
55
69
  };
56
70
  }
71
+ function cloneServers(raw) {
72
+ return (raw ?? []).map(
73
+ (server) => server.description === void 0 ? { url: server.url } : { url: server.url, description: server.description }
74
+ );
75
+ }
76
+ function resolveOpenApi(raw) {
77
+ const requested = raw?.enabled ?? false;
78
+ const production = isProductionRuntime();
79
+ return {
80
+ enabled: requested && !production,
81
+ suppressedInProduction: requested && production,
82
+ path: raw?.path ?? DEFAULT_OPENAPI_PATH,
83
+ jsonPath: raw?.jsonPath ?? DEFAULT_OPENAPI_JSON_PATH,
84
+ title: raw?.title ?? DEFAULT_OPENAPI_TITLE,
85
+ description: raw?.description ?? "",
86
+ version: raw?.version ?? DEFAULT_OPENAPI_VERSION,
87
+ servers: cloneServers(raw?.servers),
88
+ // Structured-cloned rather than shallow-copied: the values are
89
+ // consumer-owned nested objects, and the deep-freeze below would otherwise
90
+ // reach into them.
91
+ securitySchemes: structuredClone(raw?.securitySchemes ?? {}),
92
+ includeCoreSchemas: raw?.includeCoreSchemas ?? true
93
+ };
94
+ }
95
+ function resolveTelemetry(raw) {
96
+ return {
97
+ enabled: raw?.enabled ?? false,
98
+ exposeTraceId: raw?.exposeTraceId ?? false
99
+ };
100
+ }
57
101
  function normalizeCoreOptions(raw) {
58
102
  return deepFreeze({
59
103
  envelope: resolveEnvelope(raw?.envelope),
60
104
  timing: resolveTiming(raw?.timing),
61
105
  health: resolveHealth(raw?.health),
62
- metrics: resolveMetrics(raw?.metrics)
106
+ metrics: resolveMetrics(raw?.metrics),
107
+ openapi: resolveOpenApi(raw?.openapi),
108
+ telemetry: resolveTelemetry(raw?.telemetry)
63
109
  });
64
110
  }
65
111
  normalizeCoreOptions();
@@ -70,6 +116,70 @@ var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol("BYMAX_CORRELATION_PROVI
70
116
  var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol("BYMAX_TIMING_SINK");
71
117
  var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol("BYMAX_HEALTH_INDICATORS");
72
118
  var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol("BYMAX_METRICS_REGISTRY");
119
+ var BYMAX_TRACE_CONTEXT = /* @__PURE__ */ Symbol("BYMAX_TRACE_CONTEXT");
120
+
121
+ // src/optional-peer.ts
122
+ function isMissingModuleError(cause) {
123
+ const code = cause.code;
124
+ return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
125
+ }
126
+ function missingPeerMessage(option, peer) {
127
+ return `${option} is true but the optional peer ${peer} is not installed. Run: pnpm add ${peer}`;
128
+ }
129
+
130
+ // src/telemetry/trace-context.ts
131
+ var MISSING_PEER_MESSAGE = missingPeerMessage("telemetry.enabled", "@opentelemetry/api");
132
+ var NoopTraceContextProvider = class {
133
+ /**
134
+ * Resolve no trace context.
135
+ *
136
+ * @returns Always `undefined`.
137
+ */
138
+ getTraceContext() {
139
+ return void 0;
140
+ }
141
+ };
142
+ async function loadOtelApi() {
143
+ try {
144
+ return await import('@opentelemetry/api');
145
+ } catch (cause) {
146
+ if (isMissingModuleError(cause)) {
147
+ throw new Error(MISSING_PEER_MESSAGE, { cause });
148
+ }
149
+ throw cause;
150
+ }
151
+ }
152
+ var OtelTraceContextProvider = class {
153
+ /**
154
+ * @param api - The loaded OpenTelemetry API surface.
155
+ */
156
+ constructor(api) {
157
+ this.api = api;
158
+ }
159
+ /**
160
+ * Resolve the identifiers of the currently active span.
161
+ *
162
+ * @returns The active trace context, or `undefined` when no valid span is
163
+ * recording.
164
+ */
165
+ getTraceContext() {
166
+ const span = this.api.trace.getActiveSpan();
167
+ if (span === void 0) {
168
+ return void 0;
169
+ }
170
+ const spanContext = span.spanContext();
171
+ if (!this.api.isSpanContextValid(spanContext)) {
172
+ return void 0;
173
+ }
174
+ return { traceId: spanContext.traceId, spanId: spanContext.spanId };
175
+ }
176
+ };
177
+ async function resolveTraceContextProvider(options) {
178
+ if (!options.telemetry.enabled) {
179
+ return new NoopTraceContextProvider();
180
+ }
181
+ return new OtelTraceContextProvider(await loadOtelApi());
182
+ }
73
183
 
74
184
  // src/timing/timing.clock.ts
75
185
  var DEFAULT_MONOTONIC_CLOCK = {
@@ -100,6 +210,13 @@ var NoopTimingSink = class {
100
210
  function buildDefaultProviders() {
101
211
  return [{ provide: BYMAX_TIMING_CLOCK, useValue: DEFAULT_MONOTONIC_CLOCK }];
102
212
  }
213
+ function buildTraceContextProvider() {
214
+ return {
215
+ provide: BYMAX_TRACE_CONTEXT,
216
+ useFactory: (options) => resolveTraceContextProvider(options),
217
+ inject: [BYMAX_CORE_OPTIONS]
218
+ };
219
+ }
103
220
 
104
221
  // src/envelope/error-codes.ts
105
222
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
@@ -159,7 +276,8 @@ function buildErrorEnvelope(input) {
159
276
  return {
160
277
  ...base,
161
278
  ...input.details !== void 0 ? { details: input.details } : {},
162
- ...input.correlationId !== void 0 ? { correlationId: input.correlationId } : {}
279
+ ...input.correlationId !== void 0 ? { correlationId: input.correlationId } : {},
280
+ ...input.traceId !== void 0 ? { traceId: input.traceId } : {}
163
281
  };
164
282
  }
165
283
 
@@ -210,12 +328,13 @@ var BymaxExceptionFilter = class {
210
328
  * nothing is bound, this falls back to a no-op that omits `correlationId`.
211
329
  * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
212
330
  */
213
- constructor(options, correlation, adapterHost) {
331
+ constructor(options, correlation, adapterHost, traceContext) {
214
332
  this.options = options;
215
333
  this.adapterHost = adapterHost;
216
334
  /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
217
335
  this.now = () => /* @__PURE__ */ new Date();
218
336
  this.correlation = correlation ?? new NoopCorrelationIdProvider();
337
+ this.traceContext = traceContext ?? new NoopTraceContextProvider();
219
338
  }
220
339
  /**
221
340
  * Format the exception into the stable envelope and reply with it.
@@ -226,6 +345,34 @@ var BymaxExceptionFilter = class {
226
345
  * @param exception - The error that escaped the handler.
227
346
  * @param host - The arguments host for the current execution context.
228
347
  */
348
+ /**
349
+ * Resolve one optional annotation for the envelope, treating any failure as
350
+ * "absent".
351
+ *
352
+ * Both annotations this filter attaches — the correlation id and the trace id
353
+ * — come from providers it does not own: one is supplied by the consumer, the
354
+ * other reads a third-party API. Their contracts say they do not throw, but
355
+ * this filter is the last thing standing between an error and the client, and
356
+ * a guarantee that depends on someone else's good behavior is not one. A
357
+ * failed lookup costs an optional field; an unguarded one would cost the whole
358
+ * response.
359
+ *
360
+ * The failure is deliberately silent, and the same reasoning applies as for
361
+ * the {@link BymaxExceptionFilter.onUnexpectedError} seam a few lines below:
362
+ * this runs while an error is already being formatted, so reporting a
363
+ * telemetry failure here would replace the failure the caller actually needs
364
+ * to see.
365
+ *
366
+ * @param read - The lookup to attempt.
367
+ * @returns The resolved value, or `undefined` when absent or on failure.
368
+ */
369
+ readAnnotation(read) {
370
+ try {
371
+ return read();
372
+ } catch {
373
+ return void 0;
374
+ }
375
+ }
229
376
  catch(exception, host) {
230
377
  if (host.getType() !== "http") {
231
378
  throw exception;
@@ -234,11 +381,13 @@ var BymaxExceptionFilter = class {
234
381
  const ctx = host.switchToHttp();
235
382
  const request = ctx.getRequest();
236
383
  const response = ctx.getResponse();
237
- const correlationId = this.correlation.getCorrelationId();
384
+ const correlationId = this.readAnnotation(() => this.correlation.getCorrelationId());
385
+ const traceId = this.readAnnotation(() => this.traceContext.getTraceContext()?.traceId);
238
386
  const context = {
239
387
  method: String(httpAdapter.getRequestMethod(request)),
240
388
  path: String(httpAdapter.getRequestUrl(request)),
241
- ...correlationId !== void 0 ? { correlationId } : {}
389
+ ...correlationId !== void 0 ? { correlationId } : {},
390
+ ...traceId !== void 0 ? { traceId } : {}
242
391
  };
243
392
  const envelope = this.buildEnvelope(exception, context);
244
393
  httpAdapter.reply(response, envelope, envelope.statusCode);
@@ -332,7 +481,13 @@ var BymaxExceptionFilter = class {
332
481
  path: context.path,
333
482
  now: this.now,
334
483
  ...details !== void 0 ? { details } : {},
335
- ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {}
484
+ // Stryker disable next-line ConditionalExpression: equivalent — the always-spread form hands the builder `correlationId: undefined`, and `buildErrorEnvelope` re-guards `input.correlationId !== undefined` and omits an undefined value, so the emitted envelope is byte-for-byte identical whether or not a correlation id is present.
485
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {},
486
+ // Gated separately from the context above: the trace id reaches the
487
+ // observability seam either way, and the response body only when the
488
+ // operator opted into publishing it.
489
+ // Stryker disable next-line ConditionalExpression: equivalent — same as the correlation id above. The always-spread form hands the builder `traceId: undefined`, which `buildErrorEnvelope` re-guards and omits. The call-site guard exists because `exactOptionalPropertyTypes` rejects an explicit `undefined` for an optional field, not because it changes the output.
490
+ ...this.options.telemetry.exposeTraceId && context.traceId !== void 0 ? { traceId: context.traceId } : {}
336
491
  });
337
492
  }
338
493
  /**
@@ -354,7 +509,9 @@ BymaxExceptionFilter = __decorateClass([
354
509
  __decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
355
510
  __decorateParam(1, Optional()),
356
511
  __decorateParam(1, Inject(BYMAX_CORRELATION_PROVIDER)),
357
- __decorateParam(2, Inject(HttpAdapterHost))
512
+ __decorateParam(2, Inject(HttpAdapterHost)),
513
+ __decorateParam(3, Optional()),
514
+ __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
358
515
  ], BymaxExceptionFilter);
359
516
 
360
517
  // src/timing/request-info.accessor.ts
@@ -390,11 +547,16 @@ var TimingInterceptor = class {
390
547
  * @param clock - Monotonic clock seam; defaults to `performance.now()`, and
391
548
  * is bound explicitly through {@link BYMAX_TIMING_CLOCK} so tests inject a
392
549
  * stub advancing by controlled amounts.
550
+ * @param traceContext - Reads the active span's identifiers. Injected with
551
+ * `@Optional()` so this interceptor stays constructible on its own; when
552
+ * nothing resolves, a no-op resolves no trace and the sample simply omits
553
+ * the fields.
393
554
  */
394
- constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK) {
555
+ constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK, traceContext) {
395
556
  this.options = options;
396
557
  this.clock = clock;
397
558
  this.sink = sink ?? new NoopTimingSink();
559
+ this.traceContext = traceContext ?? new NoopTraceContextProvider();
398
560
  }
399
561
  /**
400
562
  * Measure the handler chain and record exactly one sample per completed
@@ -455,9 +617,22 @@ var TimingInterceptor = class {
455
617
  const durationMs = this.clock.now() - start;
456
618
  const threshold = this.options.timing.slowRequestThresholdMs;
457
619
  const slow = threshold !== void 0 && durationMs > threshold;
458
- const sample = { method, route, statusCode, durationMs, slow };
620
+ let trace;
621
+ try {
622
+ trace = this.traceContext.getTraceContext();
623
+ } catch {
624
+ }
459
625
  try {
460
- this.sink.record(sample);
626
+ this.sink.record({
627
+ method,
628
+ route,
629
+ statusCode,
630
+ durationMs,
631
+ slow,
632
+ // Spread rather than assigned: an absent trace must leave the keys off
633
+ // the sample entirely, so a sink cannot mistake `undefined` for an id.
634
+ ...trace !== void 0 ? { traceId: trace.traceId, spanId: trace.spanId } : {}
635
+ });
461
636
  } catch {
462
637
  }
463
638
  }
@@ -467,7 +642,9 @@ TimingInterceptor = __decorateClass([
467
642
  __decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
468
643
  __decorateParam(1, Optional()),
469
644
  __decorateParam(1, Inject(BYMAX_TIMING_SINK)),
470
- __decorateParam(2, Inject(BYMAX_TIMING_CLOCK))
645
+ __decorateParam(2, Inject(BYMAX_TIMING_CLOCK)),
646
+ __decorateParam(3, Optional()),
647
+ __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
471
648
  ], TimingInterceptor);
472
649
 
473
650
  // src/passthrough.providers.ts
@@ -514,6 +691,53 @@ function selectAsyncExceptionFilter(options, correlation, adapterHost) {
514
691
  function selectAsyncTimingInterceptor(options, sink, clock) {
515
692
  return options.timing.enabled ? new TimingInterceptor(options, sink, clock) : new PassThroughInterceptor();
516
693
  }
694
+
695
+ // src/discovery.ts
696
+ function labelFor(className, token) {
697
+ return className === "" ? String(token) : className;
698
+ }
699
+ function findMarkedProviders(discovery, reflector, metadataKey) {
700
+ const marked = [];
701
+ for (const wrapper of discovery.getProviders()) {
702
+ const metatype = wrapper.metatype;
703
+ if (typeof metatype !== "function") {
704
+ continue;
705
+ }
706
+ if (reflector.get(metadataKey, metatype) !== true) {
707
+ continue;
708
+ }
709
+ marked.push({ instance: wrapper.instance, label: labelFor(metatype.name, wrapper.name) });
710
+ }
711
+ return marked;
712
+ }
713
+ var BYMAX_HEALTH_INDICATOR_METADATA = "bymax-one:health-indicator";
714
+
715
+ // src/health/health.discovery.ts
716
+ function isIndicator(instance) {
717
+ if (instance === null || instance === void 0) {
718
+ return false;
719
+ }
720
+ const candidate = instance;
721
+ return typeof candidate.name === "string" && candidate.name !== "" && typeof candidate.check === "function";
722
+ }
723
+ function discoverIndicators(discovery, reflector) {
724
+ const discovered = [];
725
+ for (const marked of findMarkedProviders(discovery, reflector, BYMAX_HEALTH_INDICATOR_METADATA)) {
726
+ if (!isIndicator(marked.instance)) {
727
+ throw new Error(
728
+ `[BymaxCoreModule] "${marked.label}" is marked with @BymaxHealthIndicator() but does not implement IHealthIndicator: it must expose a non-empty "name" and a "check" method.`
729
+ );
730
+ }
731
+ discovered.push(marked.instance);
732
+ }
733
+ return discovered.sort((left, right) => left.name.localeCompare(right.name));
734
+ }
735
+ function mergeIndicators(explicit, discovered) {
736
+ const claimed = new Set(explicit.map((indicator) => indicator.name));
737
+ return [...explicit, ...discovered.filter((indicator) => !claimed.has(indicator.name))];
738
+ }
739
+
740
+ // src/health/health.service.ts
517
741
  var MAX_ERROR_MESSAGE_LENGTH = 300;
518
742
  var TRUNCATION_ELLIPSIS = "...";
519
743
  function summarizeRejection(reason) {
@@ -548,16 +772,24 @@ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
548
772
  }
549
773
  var HealthService = class {
550
774
  /**
551
- * @param indicators - Every registered indicator; empty when none resolve.
552
- * Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
553
- * this token, so a consumer's own `BYMAX_HEALTH_INDICATORS` binding (from
554
- * their own, globally-visible module) is not shadowed by one; when nothing
555
- * is bound, this defaults to an empty array.
775
+ * @param indicators - Every explicitly registered indicator; empty when none
776
+ * resolve. Injected with `@Optional()`: `BymaxCoreModule` binds no local
777
+ * default for this token, so a consumer's own `BYMAX_HEALTH_INDICATORS`
778
+ * binding (from their own, globally-visible module) is not shadowed by one;
779
+ * when nothing is bound, this defaults to an empty array.
556
780
  * @param options - Resolved core options; supplies `indicatorTimeoutMs`.
781
+ * @param discovery - Nest's provider-graph reader, present only when
782
+ * `DiscoveryModule` is imported, which `BymaxCoreModule` does exactly when
783
+ * discovery can be needed. Optional so this service stays constructible
784
+ * without it when the feature is off.
785
+ * @param reflector - Nest's metadata reader, used to match the indicator
786
+ * marker. Optional for the same reason.
557
787
  */
558
- constructor(indicators = [], options) {
788
+ constructor(indicators = [], options, discovery, reflector) {
559
789
  this.indicators = indicators;
560
790
  this.options = options;
791
+ this.discovery = discovery;
792
+ this.reflector = reflector;
561
793
  /**
562
794
  * Nest's own logger, scoped to this class. The failure reason of a `down`
563
795
  * indicator is written here rather than into the HTTP response, so the
@@ -565,6 +797,47 @@ var HealthService = class {
565
797
  */
566
798
  this.logger = new Logger(HealthService.name);
567
799
  }
800
+ /**
801
+ * Resolve the readiness set once the whole container is instantiated.
802
+ *
803
+ * Bound to `onApplicationBootstrap`, not `onModuleInit`: module-init hooks run
804
+ * concurrently across modules, so a provider this scan needs may not exist
805
+ * yet. Running here also means a provider marked as an indicator but not
806
+ * implementing the contract fails the boot, instead of failing the first
807
+ * readiness probe in production.
808
+ */
809
+ onApplicationBootstrap() {
810
+ this.resolveIndicators();
811
+ }
812
+ /**
813
+ * The effective readiness set, computed on first use and memoized.
814
+ *
815
+ * @returns The explicit indicators, plus the discovered ones when the feature
816
+ * is enabled.
817
+ * @throws Error When discovery is enabled but Nest's discovery services are
818
+ * not reachable, which means this service was constructed outside
819
+ * `BymaxCoreModule`; a silent fallback would leave an operator believing
820
+ * checks are running that never run.
821
+ */
822
+ resolveIndicators() {
823
+ if (this.effectiveIndicators !== void 0) {
824
+ return this.effectiveIndicators;
825
+ }
826
+ if (!this.options.health.enabled || !this.options.health.autoDiscover) {
827
+ this.effectiveIndicators = this.indicators;
828
+ return this.effectiveIndicators;
829
+ }
830
+ if (this.discovery === void 0 || this.reflector === void 0) {
831
+ throw new Error(
832
+ "[BymaxCoreModule] health.autoDiscover is enabled but Nest's DiscoveryService is not available. Register the health feature through BymaxCoreModule, which imports DiscoveryModule for it."
833
+ );
834
+ }
835
+ this.effectiveIndicators = mergeIndicators(
836
+ this.indicators,
837
+ discoverIndicators(this.discovery, this.reflector)
838
+ );
839
+ return this.effectiveIndicators;
840
+ }
568
841
  /**
569
842
  * Liveness check: the process is up and able to respond. Runs no
570
843
  * indicators, so it never depends on the health of anything else.
@@ -575,7 +848,7 @@ var HealthService = class {
575
848
  return { status: "ok", checks: [] };
576
849
  }
577
850
  /**
578
- * Readiness check: run every registered indicator concurrently and
851
+ * Readiness check: run every indicator in the effective set concurrently and
579
852
  * aggregate the results. `status` is `'ok'` only when every indicator
580
853
  * reports `up`; an empty indicator list is vacuously `'ok'`.
581
854
  *
@@ -585,7 +858,7 @@ var HealthService = class {
585
858
  const timeoutMs = this.options.health.indicatorTimeoutMs;
586
859
  const exposeErrors = this.options.health.exposeIndicatorErrors;
587
860
  const checks = await Promise.all(
588
- this.indicators.map(
861
+ this.resolveIndicators().map(
589
862
  (indicator) => runIndicator(indicator, timeoutMs, exposeErrors, this.logger)
590
863
  )
591
864
  );
@@ -597,7 +870,11 @@ HealthService = __decorateClass([
597
870
  Injectable(),
598
871
  __decorateParam(0, Optional()),
599
872
  __decorateParam(0, Inject(BYMAX_HEALTH_INDICATORS)),
600
- __decorateParam(1, Inject(BYMAX_CORE_OPTIONS))
873
+ __decorateParam(1, Inject(BYMAX_CORE_OPTIONS)),
874
+ __decorateParam(2, Optional()),
875
+ __decorateParam(2, Inject(DiscoveryService)),
876
+ __decorateParam(3, Optional()),
877
+ __decorateParam(3, Inject(Reflector))
601
878
  ], HealthService);
602
879
 
603
880
  // src/health/health.controller.ts
@@ -647,6 +924,98 @@ function createHealthController(registeredPath) {
647
924
  ], HealthController);
648
925
  return HealthController;
649
926
  }
927
+ var BYMAX_METRICS_CONTRIBUTOR_METADATA = "bymax-one:metrics-contributor";
928
+
929
+ // src/metrics/metrics.contribution.ts
930
+ function isContributor(instance) {
931
+ if (instance === null || instance === void 0) {
932
+ return false;
933
+ }
934
+ return typeof instance.registerMetrics === "function";
935
+ }
936
+ function discoverContributors(discovery, reflector) {
937
+ const found = [];
938
+ for (const marked of findMarkedProviders(
939
+ discovery,
940
+ reflector,
941
+ BYMAX_METRICS_CONTRIBUTOR_METADATA
942
+ )) {
943
+ if (!isContributor(marked.instance)) {
944
+ throw new Error(
945
+ `[BymaxCoreModule] "${marked.label}" is marked with @BymaxMetricsContributor() but does not implement IMetricsContributor: it must expose a "registerMetrics" method.`
946
+ );
947
+ }
948
+ found.push({ contributor: marked.instance, label: marked.label });
949
+ }
950
+ return found.sort((left, right) => left.label.localeCompare(right.label));
951
+ }
952
+ function applyContributions(contributors, registry) {
953
+ for (const { contributor, label } of contributors) {
954
+ try {
955
+ contributor.registerMetrics(registry);
956
+ } catch (cause) {
957
+ const reason = cause instanceof Error ? cause.message : String(cause);
958
+ throw new Error(`[BymaxCoreModule] "${label}" failed to register its metrics: ${reason}`, {
959
+ cause
960
+ });
961
+ }
962
+ }
963
+ }
964
+ var MetricsContributionRunner = class {
965
+ /**
966
+ * @param options - Resolved core options; the metrics gate is read from here.
967
+ * @param registry - The registry the scrape endpoint serves. On the async path
968
+ * this resolves to a guarded placeholder while metrics are disabled, which is
969
+ * why the gate below runs before the registry is ever passed on.
970
+ * @param discovery - Nest's provider-graph reader, present when
971
+ * `DiscoveryModule` is imported. Optional so this service stays constructible
972
+ * without it.
973
+ * @param reflector - Nest's metadata reader, used to match the marker.
974
+ */
975
+ constructor(options, registry, discovery, reflector) {
976
+ this.options = options;
977
+ this.registry = registry;
978
+ this.discovery = discovery;
979
+ this.reflector = reflector;
980
+ /** Nest's logger, scoped to the module, for the one line this feature writes. */
981
+ this.logger = new Logger("BymaxCoreModule");
982
+ }
983
+ /**
984
+ * Find the contributors and let each register its collectors.
985
+ *
986
+ * @throws Error When a marked provider is not a contributor, when one fails to
987
+ * register, or when metrics are enabled without Nest's discovery services —
988
+ * which means this runner was constructed outside `BymaxCoreModule`, and a
989
+ * silent skip would leave an operator with an endpoint quietly missing every
990
+ * metric a library publishes.
991
+ */
992
+ onApplicationBootstrap() {
993
+ if (!this.options.metrics.enabled) {
994
+ return;
995
+ }
996
+ if (this.discovery === void 0 || this.reflector === void 0) {
997
+ throw new Error(
998
+ "[BymaxCoreModule] metrics are enabled but Nest's DiscoveryService is not available. Register the metrics feature through BymaxCoreModule, which imports DiscoveryModule for it."
999
+ );
1000
+ }
1001
+ const contributors = discoverContributors(this.discovery, this.reflector);
1002
+ applyContributions(contributors, this.registry);
1003
+ if (contributors.length > 0) {
1004
+ this.logger.log(
1005
+ `Registered metrics from ${contributors.length} contributor(s): ${contributors.map(({ label }) => label).join(", ")}`
1006
+ );
1007
+ }
1008
+ }
1009
+ };
1010
+ MetricsContributionRunner = __decorateClass([
1011
+ Injectable(),
1012
+ __decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
1013
+ __decorateParam(1, Inject(BYMAX_METRICS_REGISTRY)),
1014
+ __decorateParam(2, Optional()),
1015
+ __decorateParam(2, Inject(DiscoveryService)),
1016
+ __decorateParam(3, Optional()),
1017
+ __decorateParam(3, Inject(Reflector))
1018
+ ], MetricsContributionRunner);
650
1019
  function assertControllerMatchesOptions2(options, registeredPath) {
651
1020
  assertAsyncFeatureEnabled("metrics", options.metrics.enabled);
652
1021
  if (options.metrics.path !== registeredPath) {
@@ -688,17 +1057,13 @@ function createMetricsController(registeredPath) {
688
1057
  }
689
1058
 
690
1059
  // src/metrics/metrics.registry.ts
691
- var MISSING_PEER_MESSAGE = "metrics.enabled is true but the optional peer prom-client is not installed. Run: pnpm add prom-client";
692
- function isMissingModuleError(cause) {
693
- const code = cause.code;
694
- return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
695
- }
1060
+ var MISSING_PEER_MESSAGE2 = missingPeerMessage("metrics.enabled", "prom-client");
696
1061
  async function loadPromClient() {
697
1062
  try {
698
1063
  return await import('prom-client');
699
1064
  } catch (cause) {
700
1065
  if (isMissingModuleError(cause)) {
701
- throw new Error(MISSING_PEER_MESSAGE, { cause });
1066
+ throw new Error(MISSING_PEER_MESSAGE2, { cause });
702
1067
  }
703
1068
  throw cause;
704
1069
  }
@@ -844,8 +1209,12 @@ function buildSyncProviders(resolved) {
844
1209
  if (resolved.health.enabled) {
845
1210
  providers.push(HealthService);
846
1211
  }
1212
+ if (resolved.telemetry.enabled) {
1213
+ providers.push(buildTraceContextProvider());
1214
+ }
847
1215
  if (resolved.metrics.enabled) {
848
1216
  providers.push(buildMetricsRegistryProvider());
1217
+ providers.push(MetricsContributionRunner);
849
1218
  if (resolved.timing.enabled) {
850
1219
  providers.push(buildMetricsTimingSinkProvider());
851
1220
  }
@@ -880,10 +1249,11 @@ function buildAsyncSlots() {
880
1249
  }
881
1250
  ];
882
1251
  }
883
- function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS]) {
1252
+ function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS], imports = []) {
884
1253
  return {
885
1254
  ...base,
886
1255
  module: BymaxCoreModule,
1256
+ imports: [...base.imports ?? [], ...imports],
887
1257
  providers: [...base.providers ?? [], ...providers],
888
1258
  controllers: [...base.controllers ?? [], ...controllers],
889
1259
  exports: [...base.exports ?? [], ...exportTokens]
@@ -915,11 +1285,14 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
915
1285
  exportTokens.push(BYMAX_TIMING_SINK);
916
1286
  }
917
1287
  }
1288
+ const scansProviders = resolved.health.enabled && resolved.health.autoDiscover || resolved.metrics.enabled;
1289
+ const imports = scansProviders ? [DiscoveryModule] : [];
918
1290
  return augmentModule(
919
1291
  super.forRoot(options),
920
1292
  providers,
921
1293
  buildControllers(resolved),
922
- exportTokens
1294
+ exportTokens,
1295
+ imports
923
1296
  );
924
1297
  }
925
1298
  /**
@@ -953,13 +1326,16 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
953
1326
  ...buildAsyncSlots(),
954
1327
  HealthService,
955
1328
  buildMetricsRegistryProvider(),
956
- buildMetricsTimingSinkProvider()
1329
+ buildMetricsTimingSinkProvider(),
1330
+ MetricsContributionRunner,
1331
+ buildTraceContextProvider()
957
1332
  ];
958
1333
  return augmentModule(
959
1334
  super.forRootAsync(options),
960
1335
  providers,
961
1336
  [createHealthController(DEFAULT_HEALTH_PATH), createMetricsController(DEFAULT_METRICS_PATH)],
962
- [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY]
1337
+ [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY],
1338
+ [DiscoveryModule]
963
1339
  );
964
1340
  }
965
1341
  };
@@ -967,4 +1343,4 @@ BymaxCoreModule = __decorateClass([
967
1343
  Module({})
968
1344
  ], BymaxCoreModule);
969
1345
 
970
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, TimingInterceptor, buildErrorEnvelope, codeForStatus };
1346
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, TimingInterceptor, buildErrorEnvelope, codeForStatus };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ var common = require('@nestjs/common');
4
+
5
+ // src/metrics/metrics.contract.ts
6
+ var BYMAX_METRICS_CONTRIBUTOR_METADATA = "bymax-one:metrics-contributor";
7
+ function BymaxMetricsContributor() {
8
+ return common.SetMetadata(BYMAX_METRICS_CONTRIBUTOR_METADATA, true);
9
+ }
10
+
11
+ exports.BYMAX_METRICS_CONTRIBUTOR_METADATA = BYMAX_METRICS_CONTRIBUTOR_METADATA;
12
+ exports.BymaxMetricsContributor = BymaxMetricsContributor;