@bymax-one/nest-core 1.0.0 → 1.1.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.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Get, Res, Controller, HttpStatus } from '@nestjs/common';
2
- import { HttpAdapterHost, BaseExceptionFilter, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
1
+ import { Catch, Inject, Optional, Injectable, ConfigurableModuleBuilder, Module, HttpException, Logger, Get, Res, Controller, HttpStatus } from '@nestjs/common';
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);
@@ -42,7 +55,9 @@ function resolveHealth(raw) {
42
55
  return {
43
56
  enabled: raw?.enabled ?? true,
44
57
  path: raw?.path ?? DEFAULT_HEALTH_PATH,
45
- indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS
58
+ exposeIndicatorErrors: raw?.exposeIndicatorErrors ?? false,
59
+ indicatorTimeoutMs: raw?.indicatorTimeoutMs ?? DEFAULT_INDICATOR_TIMEOUT_MS,
60
+ autoDiscover: raw?.autoDiscover ?? false
46
61
  };
47
62
  }
48
63
  function resolveMetrics(raw) {
@@ -53,12 +68,44 @@ function resolveMetrics(raw) {
53
68
  defaultLabels: { ...raw?.defaultLabels ?? {} }
54
69
  };
55
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
+ }
56
101
  function normalizeCoreOptions(raw) {
57
102
  return deepFreeze({
58
103
  envelope: resolveEnvelope(raw?.envelope),
59
104
  timing: resolveTiming(raw?.timing),
60
105
  health: resolveHealth(raw?.health),
61
- metrics: resolveMetrics(raw?.metrics)
106
+ metrics: resolveMetrics(raw?.metrics),
107
+ openapi: resolveOpenApi(raw?.openapi),
108
+ telemetry: resolveTelemetry(raw?.telemetry)
62
109
  });
63
110
  }
64
111
  normalizeCoreOptions();
@@ -69,6 +116,70 @@ var BYMAX_CORRELATION_PROVIDER = /* @__PURE__ */ Symbol("BYMAX_CORRELATION_PROVI
69
116
  var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol("BYMAX_TIMING_SINK");
70
117
  var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol("BYMAX_HEALTH_INDICATORS");
71
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
+ }
72
183
 
73
184
  // src/timing/timing.clock.ts
74
185
  var DEFAULT_MONOTONIC_CLOCK = {
@@ -99,6 +210,13 @@ var NoopTimingSink = class {
99
210
  function buildDefaultProviders() {
100
211
  return [{ provide: BYMAX_TIMING_CLOCK, useValue: DEFAULT_MONOTONIC_CLOCK }];
101
212
  }
213
+ function buildTraceContextProvider() {
214
+ return {
215
+ provide: BYMAX_TRACE_CONTEXT,
216
+ useFactory: (options) => resolveTraceContextProvider(options),
217
+ inject: [BYMAX_CORE_OPTIONS]
218
+ };
219
+ }
102
220
 
103
221
  // src/envelope/error-codes.ts
104
222
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
@@ -158,7 +276,8 @@ function buildErrorEnvelope(input) {
158
276
  return {
159
277
  ...base,
160
278
  ...input.details !== void 0 ? { details: input.details } : {},
161
- ...input.correlationId !== void 0 ? { correlationId: input.correlationId } : {}
279
+ ...input.correlationId !== void 0 ? { correlationId: input.correlationId } : {},
280
+ ...input.traceId !== void 0 ? { traceId: input.traceId } : {}
162
281
  };
163
282
  }
164
283
 
@@ -209,12 +328,13 @@ var BymaxExceptionFilter = class {
209
328
  * nothing is bound, this falls back to a no-op that omits `correlationId`.
210
329
  * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
211
330
  */
212
- constructor(options, correlation, adapterHost) {
331
+ constructor(options, correlation, adapterHost, traceContext) {
213
332
  this.options = options;
214
333
  this.adapterHost = adapterHost;
215
334
  /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
216
335
  this.now = () => /* @__PURE__ */ new Date();
217
336
  this.correlation = correlation ?? new NoopCorrelationIdProvider();
337
+ this.traceContext = traceContext ?? new NoopTraceContextProvider();
218
338
  }
219
339
  /**
220
340
  * Format the exception into the stable envelope and reply with it.
@@ -225,6 +345,34 @@ var BymaxExceptionFilter = class {
225
345
  * @param exception - The error that escaped the handler.
226
346
  * @param host - The arguments host for the current execution context.
227
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
+ }
228
376
  catch(exception, host) {
229
377
  if (host.getType() !== "http") {
230
378
  throw exception;
@@ -233,11 +381,13 @@ var BymaxExceptionFilter = class {
233
381
  const ctx = host.switchToHttp();
234
382
  const request = ctx.getRequest();
235
383
  const response = ctx.getResponse();
236
- const correlationId = this.correlation.getCorrelationId();
384
+ const correlationId = this.readAnnotation(() => this.correlation.getCorrelationId());
385
+ const traceId = this.readAnnotation(() => this.traceContext.getTraceContext()?.traceId);
237
386
  const context = {
238
387
  method: String(httpAdapter.getRequestMethod(request)),
239
388
  path: String(httpAdapter.getRequestUrl(request)),
240
- ...correlationId !== void 0 ? { correlationId } : {}
389
+ ...correlationId !== void 0 ? { correlationId } : {},
390
+ ...traceId !== void 0 ? { traceId } : {}
241
391
  };
242
392
  const envelope = this.buildEnvelope(exception, context);
243
393
  httpAdapter.reply(response, envelope, envelope.statusCode);
@@ -331,7 +481,11 @@ var BymaxExceptionFilter = class {
331
481
  path: context.path,
332
482
  now: this.now,
333
483
  ...details !== void 0 ? { details } : {},
334
- ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {}
484
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {},
485
+ // Gated separately from the context above: the trace id reaches the
486
+ // observability seam either way, and the response body only when the
487
+ // operator opted into publishing it.
488
+ ...this.options.telemetry.exposeTraceId && context.traceId !== void 0 ? { traceId: context.traceId } : {}
335
489
  });
336
490
  }
337
491
  /**
@@ -353,7 +507,9 @@ BymaxExceptionFilter = __decorateClass([
353
507
  __decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
354
508
  __decorateParam(1, Optional()),
355
509
  __decorateParam(1, Inject(BYMAX_CORRELATION_PROVIDER)),
356
- __decorateParam(2, Inject(HttpAdapterHost))
510
+ __decorateParam(2, Inject(HttpAdapterHost)),
511
+ __decorateParam(3, Optional()),
512
+ __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
357
513
  ], BymaxExceptionFilter);
358
514
 
359
515
  // src/timing/request-info.accessor.ts
@@ -389,11 +545,16 @@ var TimingInterceptor = class {
389
545
  * @param clock - Monotonic clock seam; defaults to `performance.now()`, and
390
546
  * is bound explicitly through {@link BYMAX_TIMING_CLOCK} so tests inject a
391
547
  * stub advancing by controlled amounts.
548
+ * @param traceContext - Reads the active span's identifiers. Injected with
549
+ * `@Optional()` so this interceptor stays constructible on its own; when
550
+ * nothing resolves, a no-op resolves no trace and the sample simply omits
551
+ * the fields.
392
552
  */
393
- constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK) {
553
+ constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK, traceContext) {
394
554
  this.options = options;
395
555
  this.clock = clock;
396
556
  this.sink = sink ?? new NoopTimingSink();
557
+ this.traceContext = traceContext ?? new NoopTraceContextProvider();
397
558
  }
398
559
  /**
399
560
  * Measure the handler chain and record exactly one sample per completed
@@ -454,9 +615,22 @@ var TimingInterceptor = class {
454
615
  const durationMs = this.clock.now() - start;
455
616
  const threshold = this.options.timing.slowRequestThresholdMs;
456
617
  const slow = threshold !== void 0 && durationMs > threshold;
457
- const sample = { method, route, statusCode, durationMs, slow };
618
+ let trace;
619
+ try {
620
+ trace = this.traceContext.getTraceContext();
621
+ } catch {
622
+ }
458
623
  try {
459
- this.sink.record(sample);
624
+ this.sink.record({
625
+ method,
626
+ route,
627
+ statusCode,
628
+ durationMs,
629
+ slow,
630
+ // Spread rather than assigned: an absent trace must leave the keys off
631
+ // the sample entirely, so a sink cannot mistake `undefined` for an id.
632
+ ...trace !== void 0 ? { traceId: trace.traceId, spanId: trace.spanId } : {}
633
+ });
460
634
  } catch {
461
635
  }
462
636
  }
@@ -466,7 +640,9 @@ TimingInterceptor = __decorateClass([
466
640
  __decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
467
641
  __decorateParam(1, Optional()),
468
642
  __decorateParam(1, Inject(BYMAX_TIMING_SINK)),
469
- __decorateParam(2, Inject(BYMAX_TIMING_CLOCK))
643
+ __decorateParam(2, Inject(BYMAX_TIMING_CLOCK)),
644
+ __decorateParam(3, Optional()),
645
+ __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
470
646
  ], TimingInterceptor);
471
647
 
472
648
  // src/passthrough.providers.ts
@@ -513,6 +689,53 @@ function selectAsyncExceptionFilter(options, correlation, adapterHost) {
513
689
  function selectAsyncTimingInterceptor(options, sink, clock) {
514
690
  return options.timing.enabled ? new TimingInterceptor(options, sink, clock) : new PassThroughInterceptor();
515
691
  }
692
+
693
+ // src/discovery.ts
694
+ function labelFor(className, token) {
695
+ return className === "" ? String(token) : className;
696
+ }
697
+ function findMarkedProviders(discovery, reflector, metadataKey) {
698
+ const marked = [];
699
+ for (const wrapper of discovery.getProviders()) {
700
+ const metatype = wrapper.metatype;
701
+ if (typeof metatype !== "function") {
702
+ continue;
703
+ }
704
+ if (reflector.get(metadataKey, metatype) !== true) {
705
+ continue;
706
+ }
707
+ marked.push({ instance: wrapper.instance, label: labelFor(metatype.name, wrapper.name) });
708
+ }
709
+ return marked;
710
+ }
711
+ var BYMAX_HEALTH_INDICATOR_METADATA = "bymax-one:health-indicator";
712
+
713
+ // src/health/health.discovery.ts
714
+ function isIndicator(instance) {
715
+ if (instance === null || instance === void 0) {
716
+ return false;
717
+ }
718
+ const candidate = instance;
719
+ return typeof candidate.name === "string" && candidate.name !== "" && typeof candidate.check === "function";
720
+ }
721
+ function discoverIndicators(discovery, reflector) {
722
+ const discovered = [];
723
+ for (const marked of findMarkedProviders(discovery, reflector, BYMAX_HEALTH_INDICATOR_METADATA)) {
724
+ if (!isIndicator(marked.instance)) {
725
+ throw new Error(
726
+ `[BymaxCoreModule] "${marked.label}" is marked with @BymaxHealthIndicator() but does not implement IHealthIndicator: it must expose a non-empty "name" and a "check" method.`
727
+ );
728
+ }
729
+ discovered.push(marked.instance);
730
+ }
731
+ return discovered.sort((left, right) => left.name.localeCompare(right.name));
732
+ }
733
+ function mergeIndicators(explicit, discovered) {
734
+ const claimed = new Set(explicit.map((indicator) => indicator.name));
735
+ return [...explicit, ...discovered.filter((indicator) => !claimed.has(indicator.name))];
736
+ }
737
+
738
+ // src/health/health.service.ts
516
739
  var MAX_ERROR_MESSAGE_LENGTH = 300;
517
740
  var TRUNCATION_ELLIPSIS = "...";
518
741
  function summarizeRejection(reason) {
@@ -527,18 +750,18 @@ function summarizeRejection(reason) {
527
750
  }
528
751
  return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
529
752
  }
530
- async function runIndicator(indicator, timeoutMs) {
753
+ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
531
754
  let timer;
532
755
  const timedOut = new Promise((resolve) => {
533
756
  timer = setTimeout(() => {
534
757
  resolve({ name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } });
535
758
  }, timeoutMs);
536
759
  });
537
- const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => ({
538
- name: indicator.name,
539
- status: "down",
540
- details: { error: summarizeRejection(reason) }
541
- }));
760
+ const checked = Promise.resolve().then(() => indicator.check()).then((result) => ({ ...result, name: indicator.name })).catch((reason) => {
761
+ const message = summarizeRejection(reason);
762
+ logger.warn(`Health indicator "${indicator.name}" reported down: ${message}`);
763
+ return exposeErrors ? { name: indicator.name, status: "down", details: { error: message } } : { name: indicator.name, status: "down" };
764
+ });
542
765
  try {
543
766
  return await Promise.race([checked, timedOut]);
544
767
  } finally {
@@ -547,16 +770,71 @@ async function runIndicator(indicator, timeoutMs) {
547
770
  }
548
771
  var HealthService = class {
549
772
  /**
550
- * @param indicators - Every registered indicator; empty when none resolve.
551
- * Injected with `@Optional()`: `BymaxCoreModule` binds no local default for
552
- * this token, so a consumer's own `BYMAX_HEALTH_INDICATORS` binding (from
553
- * their own, globally-visible module) is not shadowed by one; when nothing
554
- * is bound, this defaults to an empty array.
773
+ * @param indicators - Every explicitly registered indicator; empty when none
774
+ * resolve. Injected with `@Optional()`: `BymaxCoreModule` binds no local
775
+ * default for this token, so a consumer's own `BYMAX_HEALTH_INDICATORS`
776
+ * binding (from their own, globally-visible module) is not shadowed by one;
777
+ * when nothing is bound, this defaults to an empty array.
555
778
  * @param options - Resolved core options; supplies `indicatorTimeoutMs`.
779
+ * @param discovery - Nest's provider-graph reader, present only when
780
+ * `DiscoveryModule` is imported, which `BymaxCoreModule` does exactly when
781
+ * discovery can be needed. Optional so this service stays constructible
782
+ * without it when the feature is off.
783
+ * @param reflector - Nest's metadata reader, used to match the indicator
784
+ * marker. Optional for the same reason.
556
785
  */
557
- constructor(indicators = [], options) {
786
+ constructor(indicators = [], options, discovery, reflector) {
558
787
  this.indicators = indicators;
559
788
  this.options = options;
789
+ this.discovery = discovery;
790
+ this.reflector = reflector;
791
+ /**
792
+ * Nest's own logger, scoped to this class. The failure reason of a `down`
793
+ * indicator is written here rather than into the HTTP response, so the
794
+ * diagnostic survives without being served to whoever can reach the probe.
795
+ */
796
+ this.logger = new Logger(HealthService.name);
797
+ }
798
+ /**
799
+ * Resolve the readiness set once the whole container is instantiated.
800
+ *
801
+ * Bound to `onApplicationBootstrap`, not `onModuleInit`: module-init hooks run
802
+ * concurrently across modules, so a provider this scan needs may not exist
803
+ * yet. Running here also means a provider marked as an indicator but not
804
+ * implementing the contract fails the boot, instead of failing the first
805
+ * readiness probe in production.
806
+ */
807
+ onApplicationBootstrap() {
808
+ this.resolveIndicators();
809
+ }
810
+ /**
811
+ * The effective readiness set, computed on first use and memoized.
812
+ *
813
+ * @returns The explicit indicators, plus the discovered ones when the feature
814
+ * is enabled.
815
+ * @throws Error When discovery is enabled but Nest's discovery services are
816
+ * not reachable, which means this service was constructed outside
817
+ * `BymaxCoreModule`; a silent fallback would leave an operator believing
818
+ * checks are running that never run.
819
+ */
820
+ resolveIndicators() {
821
+ if (this.effectiveIndicators !== void 0) {
822
+ return this.effectiveIndicators;
823
+ }
824
+ if (!this.options.health.enabled || !this.options.health.autoDiscover) {
825
+ this.effectiveIndicators = this.indicators;
826
+ return this.effectiveIndicators;
827
+ }
828
+ if (this.discovery === void 0 || this.reflector === void 0) {
829
+ throw new Error(
830
+ "[BymaxCoreModule] health.autoDiscover is enabled but Nest's DiscoveryService is not available. Register the health feature through BymaxCoreModule, which imports DiscoveryModule for it."
831
+ );
832
+ }
833
+ this.effectiveIndicators = mergeIndicators(
834
+ this.indicators,
835
+ discoverIndicators(this.discovery, this.reflector)
836
+ );
837
+ return this.effectiveIndicators;
560
838
  }
561
839
  /**
562
840
  * Liveness check: the process is up and able to respond. Runs no
@@ -568,7 +846,7 @@ var HealthService = class {
568
846
  return { status: "ok", checks: [] };
569
847
  }
570
848
  /**
571
- * Readiness check: run every registered indicator concurrently and
849
+ * Readiness check: run every indicator in the effective set concurrently and
572
850
  * aggregate the results. `status` is `'ok'` only when every indicator
573
851
  * reports `up`; an empty indicator list is vacuously `'ok'`.
574
852
  *
@@ -576,8 +854,11 @@ var HealthService = class {
576
854
  */
577
855
  async checkReadiness() {
578
856
  const timeoutMs = this.options.health.indicatorTimeoutMs;
857
+ const exposeErrors = this.options.health.exposeIndicatorErrors;
579
858
  const checks = await Promise.all(
580
- this.indicators.map((indicator) => runIndicator(indicator, timeoutMs))
859
+ this.resolveIndicators().map(
860
+ (indicator) => runIndicator(indicator, timeoutMs, exposeErrors, this.logger)
861
+ )
581
862
  );
582
863
  const status = checks.every((check) => check.status === "up") ? "ok" : "error";
583
864
  return { status, checks };
@@ -587,7 +868,11 @@ HealthService = __decorateClass([
587
868
  Injectable(),
588
869
  __decorateParam(0, Optional()),
589
870
  __decorateParam(0, Inject(BYMAX_HEALTH_INDICATORS)),
590
- __decorateParam(1, Inject(BYMAX_CORE_OPTIONS))
871
+ __decorateParam(1, Inject(BYMAX_CORE_OPTIONS)),
872
+ __decorateParam(2, Optional()),
873
+ __decorateParam(2, Inject(DiscoveryService)),
874
+ __decorateParam(3, Optional()),
875
+ __decorateParam(3, Inject(Reflector))
591
876
  ], HealthService);
592
877
 
593
878
  // src/health/health.controller.ts
@@ -637,6 +922,98 @@ function createHealthController(registeredPath) {
637
922
  ], HealthController);
638
923
  return HealthController;
639
924
  }
925
+ var BYMAX_METRICS_CONTRIBUTOR_METADATA = "bymax-one:metrics-contributor";
926
+
927
+ // src/metrics/metrics.contribution.ts
928
+ function isContributor(instance) {
929
+ if (instance === null || instance === void 0) {
930
+ return false;
931
+ }
932
+ return typeof instance.registerMetrics === "function";
933
+ }
934
+ function discoverContributors(discovery, reflector) {
935
+ const found = [];
936
+ for (const marked of findMarkedProviders(
937
+ discovery,
938
+ reflector,
939
+ BYMAX_METRICS_CONTRIBUTOR_METADATA
940
+ )) {
941
+ if (!isContributor(marked.instance)) {
942
+ throw new Error(
943
+ `[BymaxCoreModule] "${marked.label}" is marked with @BymaxMetricsContributor() but does not implement IMetricsContributor: it must expose a "registerMetrics" method.`
944
+ );
945
+ }
946
+ found.push({ contributor: marked.instance, label: marked.label });
947
+ }
948
+ return found.sort((left, right) => left.label.localeCompare(right.label));
949
+ }
950
+ function applyContributions(contributors, registry) {
951
+ for (const { contributor, label } of contributors) {
952
+ try {
953
+ contributor.registerMetrics(registry);
954
+ } catch (cause) {
955
+ const reason = cause instanceof Error ? cause.message : String(cause);
956
+ throw new Error(`[BymaxCoreModule] "${label}" failed to register its metrics: ${reason}`, {
957
+ cause
958
+ });
959
+ }
960
+ }
961
+ }
962
+ var MetricsContributionRunner = class {
963
+ /**
964
+ * @param options - Resolved core options; the metrics gate is read from here.
965
+ * @param registry - The registry the scrape endpoint serves. On the async path
966
+ * this resolves to a guarded placeholder while metrics are disabled, which is
967
+ * why the gate below runs before the registry is ever passed on.
968
+ * @param discovery - Nest's provider-graph reader, present when
969
+ * `DiscoveryModule` is imported. Optional so this service stays constructible
970
+ * without it.
971
+ * @param reflector - Nest's metadata reader, used to match the marker.
972
+ */
973
+ constructor(options, registry, discovery, reflector) {
974
+ this.options = options;
975
+ this.registry = registry;
976
+ this.discovery = discovery;
977
+ this.reflector = reflector;
978
+ /** Nest's logger, scoped to the module, for the one line this feature writes. */
979
+ this.logger = new Logger("BymaxCoreModule");
980
+ }
981
+ /**
982
+ * Find the contributors and let each register its collectors.
983
+ *
984
+ * @throws Error When a marked provider is not a contributor, when one fails to
985
+ * register, or when metrics are enabled without Nest's discovery services —
986
+ * which means this runner was constructed outside `BymaxCoreModule`, and a
987
+ * silent skip would leave an operator with an endpoint quietly missing every
988
+ * metric a library publishes.
989
+ */
990
+ onApplicationBootstrap() {
991
+ if (!this.options.metrics.enabled) {
992
+ return;
993
+ }
994
+ if (this.discovery === void 0 || this.reflector === void 0) {
995
+ throw new Error(
996
+ "[BymaxCoreModule] metrics are enabled but Nest's DiscoveryService is not available. Register the metrics feature through BymaxCoreModule, which imports DiscoveryModule for it."
997
+ );
998
+ }
999
+ const contributors = discoverContributors(this.discovery, this.reflector);
1000
+ applyContributions(contributors, this.registry);
1001
+ if (contributors.length > 0) {
1002
+ this.logger.log(
1003
+ `Registered metrics from ${contributors.length} contributor(s): ${contributors.map(({ label }) => label).join(", ")}`
1004
+ );
1005
+ }
1006
+ }
1007
+ };
1008
+ MetricsContributionRunner = __decorateClass([
1009
+ Injectable(),
1010
+ __decorateParam(0, Inject(BYMAX_CORE_OPTIONS)),
1011
+ __decorateParam(1, Inject(BYMAX_METRICS_REGISTRY)),
1012
+ __decorateParam(2, Optional()),
1013
+ __decorateParam(2, Inject(DiscoveryService)),
1014
+ __decorateParam(3, Optional()),
1015
+ __decorateParam(3, Inject(Reflector))
1016
+ ], MetricsContributionRunner);
640
1017
  function assertControllerMatchesOptions2(options, registeredPath) {
641
1018
  assertAsyncFeatureEnabled("metrics", options.metrics.enabled);
642
1019
  if (options.metrics.path !== registeredPath) {
@@ -678,17 +1055,13 @@ function createMetricsController(registeredPath) {
678
1055
  }
679
1056
 
680
1057
  // src/metrics/metrics.registry.ts
681
- var MISSING_PEER_MESSAGE = "metrics.enabled is true but the optional peer prom-client is not installed. Run: pnpm add prom-client";
682
- function isMissingModuleError(cause) {
683
- const code = cause.code;
684
- return code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND";
685
- }
1058
+ var MISSING_PEER_MESSAGE2 = missingPeerMessage("metrics.enabled", "prom-client");
686
1059
  async function loadPromClient() {
687
1060
  try {
688
1061
  return await import('prom-client');
689
1062
  } catch (cause) {
690
1063
  if (isMissingModuleError(cause)) {
691
- throw new Error(MISSING_PEER_MESSAGE, { cause });
1064
+ throw new Error(MISSING_PEER_MESSAGE2, { cause });
692
1065
  }
693
1066
  throw cause;
694
1067
  }
@@ -834,8 +1207,12 @@ function buildSyncProviders(resolved) {
834
1207
  if (resolved.health.enabled) {
835
1208
  providers.push(HealthService);
836
1209
  }
1210
+ if (resolved.telemetry.enabled) {
1211
+ providers.push(buildTraceContextProvider());
1212
+ }
837
1213
  if (resolved.metrics.enabled) {
838
1214
  providers.push(buildMetricsRegistryProvider());
1215
+ providers.push(MetricsContributionRunner);
839
1216
  if (resolved.timing.enabled) {
840
1217
  providers.push(buildMetricsTimingSinkProvider());
841
1218
  }
@@ -870,10 +1247,11 @@ function buildAsyncSlots() {
870
1247
  }
871
1248
  ];
872
1249
  }
873
- function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS]) {
1250
+ function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS], imports = []) {
874
1251
  return {
875
1252
  ...base,
876
1253
  module: BymaxCoreModule,
1254
+ imports: [...base.imports ?? [], ...imports],
877
1255
  providers: [...base.providers ?? [], ...providers],
878
1256
  controllers: [...base.controllers ?? [], ...controllers],
879
1257
  exports: [...base.exports ?? [], ...exportTokens]
@@ -905,11 +1283,14 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
905
1283
  exportTokens.push(BYMAX_TIMING_SINK);
906
1284
  }
907
1285
  }
1286
+ const scansProviders = resolved.health.enabled && resolved.health.autoDiscover || resolved.metrics.enabled;
1287
+ const imports = scansProviders ? [DiscoveryModule] : [];
908
1288
  return augmentModule(
909
1289
  super.forRoot(options),
910
1290
  providers,
911
1291
  buildControllers(resolved),
912
- exportTokens
1292
+ exportTokens,
1293
+ imports
913
1294
  );
914
1295
  }
915
1296
  /**
@@ -943,13 +1324,16 @@ var BymaxCoreModule = class extends BymaxCoreModuleBase {
943
1324
  ...buildAsyncSlots(),
944
1325
  HealthService,
945
1326
  buildMetricsRegistryProvider(),
946
- buildMetricsTimingSinkProvider()
1327
+ buildMetricsTimingSinkProvider(),
1328
+ MetricsContributionRunner,
1329
+ buildTraceContextProvider()
947
1330
  ];
948
1331
  return augmentModule(
949
1332
  super.forRootAsync(options),
950
1333
  providers,
951
1334
  [createHealthController(DEFAULT_HEALTH_PATH), createMetricsController(DEFAULT_METRICS_PATH)],
952
- [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY]
1335
+ [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY],
1336
+ [DiscoveryModule]
953
1337
  );
954
1338
  }
955
1339
  };
@@ -957,4 +1341,4 @@ BymaxCoreModule = __decorateClass([
957
1341
  Module({})
958
1342
  ], BymaxCoreModule);
959
1343
 
960
- 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 };
1344
+ 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 };