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