@bymax-one/nest-core 1.0.1 → 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.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,11 @@ 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
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {},
487
+ // Gated separately from the context above: the trace id reaches the
488
+ // observability seam either way, and the response body only when the
489
+ // operator opted into publishing it.
490
+ ...this.options.telemetry.exposeTraceId && context.traceId !== void 0 ? { traceId: context.traceId } : {}
338
491
  });
339
492
  }
340
493
  /**
@@ -356,7 +509,9 @@ exports.BymaxExceptionFilter = __decorateClass([
356
509
  __decorateParam(0, common.Inject(BYMAX_CORE_OPTIONS)),
357
510
  __decorateParam(1, common.Optional()),
358
511
  __decorateParam(1, common.Inject(BYMAX_CORRELATION_PROVIDER)),
359
- __decorateParam(2, common.Inject(core.HttpAdapterHost))
512
+ __decorateParam(2, common.Inject(core.HttpAdapterHost)),
513
+ __decorateParam(3, common.Optional()),
514
+ __decorateParam(3, common.Inject(BYMAX_TRACE_CONTEXT))
360
515
  ], exports.BymaxExceptionFilter);
361
516
 
362
517
  // src/timing/request-info.accessor.ts
@@ -392,11 +547,16 @@ exports.TimingInterceptor = class TimingInterceptor {
392
547
  * @param clock - Monotonic clock seam; defaults to `performance.now()`, and
393
548
  * is bound explicitly through {@link BYMAX_TIMING_CLOCK} so tests inject a
394
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.
395
554
  */
396
- constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK) {
555
+ constructor(options, sink, clock = DEFAULT_MONOTONIC_CLOCK, traceContext) {
397
556
  this.options = options;
398
557
  this.clock = clock;
399
558
  this.sink = sink ?? new NoopTimingSink();
559
+ this.traceContext = traceContext ?? new NoopTraceContextProvider();
400
560
  }
401
561
  /**
402
562
  * Measure the handler chain and record exactly one sample per completed
@@ -457,9 +617,22 @@ exports.TimingInterceptor = class TimingInterceptor {
457
617
  const durationMs = this.clock.now() - start;
458
618
  const threshold = this.options.timing.slowRequestThresholdMs;
459
619
  const slow = threshold !== void 0 && durationMs > threshold;
460
- const sample = { method, route, statusCode, durationMs, slow };
620
+ let trace;
621
+ try {
622
+ trace = this.traceContext.getTraceContext();
623
+ } catch {
624
+ }
461
625
  try {
462
- 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
+ });
463
636
  } catch {
464
637
  }
465
638
  }
@@ -469,7 +642,9 @@ exports.TimingInterceptor = __decorateClass([
469
642
  __decorateParam(0, common.Inject(BYMAX_CORE_OPTIONS)),
470
643
  __decorateParam(1, common.Optional()),
471
644
  __decorateParam(1, common.Inject(BYMAX_TIMING_SINK)),
472
- __decorateParam(2, common.Inject(BYMAX_TIMING_CLOCK))
645
+ __decorateParam(2, common.Inject(BYMAX_TIMING_CLOCK)),
646
+ __decorateParam(3, common.Optional()),
647
+ __decorateParam(3, common.Inject(BYMAX_TRACE_CONTEXT))
473
648
  ], exports.TimingInterceptor);
474
649
 
475
650
  // src/passthrough.providers.ts
@@ -516,6 +691,53 @@ function selectAsyncExceptionFilter(options, correlation, adapterHost) {
516
691
  function selectAsyncTimingInterceptor(options, sink, clock) {
517
692
  return options.timing.enabled ? new exports.TimingInterceptor(options, sink, clock) : new PassThroughInterceptor();
518
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
519
741
  var MAX_ERROR_MESSAGE_LENGTH = 300;
520
742
  var TRUNCATION_ELLIPSIS = "...";
521
743
  function summarizeRejection(reason) {
@@ -550,16 +772,24 @@ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
550
772
  }
551
773
  var HealthService = class {
552
774
  /**
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.
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.
558
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.
559
787
  */
560
- constructor(indicators = [], options) {
788
+ constructor(indicators = [], options, discovery, reflector) {
561
789
  this.indicators = indicators;
562
790
  this.options = options;
791
+ this.discovery = discovery;
792
+ this.reflector = reflector;
563
793
  /**
564
794
  * Nest's own logger, scoped to this class. The failure reason of a `down`
565
795
  * indicator is written here rather than into the HTTP response, so the
@@ -567,6 +797,47 @@ var HealthService = class {
567
797
  */
568
798
  this.logger = new common.Logger(HealthService.name);
569
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
+ }
570
841
  /**
571
842
  * Liveness check: the process is up and able to respond. Runs no
572
843
  * indicators, so it never depends on the health of anything else.
@@ -577,7 +848,7 @@ var HealthService = class {
577
848
  return { status: "ok", checks: [] };
578
849
  }
579
850
  /**
580
- * Readiness check: run every registered indicator concurrently and
851
+ * Readiness check: run every indicator in the effective set concurrently and
581
852
  * aggregate the results. `status` is `'ok'` only when every indicator
582
853
  * reports `up`; an empty indicator list is vacuously `'ok'`.
583
854
  *
@@ -587,7 +858,7 @@ var HealthService = class {
587
858
  const timeoutMs = this.options.health.indicatorTimeoutMs;
588
859
  const exposeErrors = this.options.health.exposeIndicatorErrors;
589
860
  const checks = await Promise.all(
590
- this.indicators.map(
861
+ this.resolveIndicators().map(
591
862
  (indicator) => runIndicator(indicator, timeoutMs, exposeErrors, this.logger)
592
863
  )
593
864
  );
@@ -599,7 +870,11 @@ HealthService = __decorateClass([
599
870
  common.Injectable(),
600
871
  __decorateParam(0, common.Optional()),
601
872
  __decorateParam(0, common.Inject(BYMAX_HEALTH_INDICATORS)),
602
- __decorateParam(1, common.Inject(BYMAX_CORE_OPTIONS))
873
+ __decorateParam(1, common.Inject(BYMAX_CORE_OPTIONS)),
874
+ __decorateParam(2, common.Optional()),
875
+ __decorateParam(2, common.Inject(core.DiscoveryService)),
876
+ __decorateParam(3, common.Optional()),
877
+ __decorateParam(3, common.Inject(core.Reflector))
603
878
  ], HealthService);
604
879
 
605
880
  // src/health/health.controller.ts
@@ -649,6 +924,98 @@ function createHealthController(registeredPath) {
649
924
  ], HealthController);
650
925
  return HealthController;
651
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 common.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
+ common.Injectable(),
1012
+ __decorateParam(0, common.Inject(BYMAX_CORE_OPTIONS)),
1013
+ __decorateParam(1, common.Inject(BYMAX_METRICS_REGISTRY)),
1014
+ __decorateParam(2, common.Optional()),
1015
+ __decorateParam(2, common.Inject(core.DiscoveryService)),
1016
+ __decorateParam(3, common.Optional()),
1017
+ __decorateParam(3, common.Inject(core.Reflector))
1018
+ ], MetricsContributionRunner);
652
1019
  function assertControllerMatchesOptions2(options, registeredPath) {
653
1020
  assertAsyncFeatureEnabled("metrics", options.metrics.enabled);
654
1021
  if (options.metrics.path !== registeredPath) {
@@ -690,17 +1057,13 @@ function createMetricsController(registeredPath) {
690
1057
  }
691
1058
 
692
1059
  // 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
- }
1060
+ var MISSING_PEER_MESSAGE2 = missingPeerMessage("metrics.enabled", "prom-client");
698
1061
  async function loadPromClient() {
699
1062
  try {
700
1063
  return await import('prom-client');
701
1064
  } catch (cause) {
702
1065
  if (isMissingModuleError(cause)) {
703
- throw new Error(MISSING_PEER_MESSAGE, { cause });
1066
+ throw new Error(MISSING_PEER_MESSAGE2, { cause });
704
1067
  }
705
1068
  throw cause;
706
1069
  }
@@ -846,8 +1209,12 @@ function buildSyncProviders(resolved) {
846
1209
  if (resolved.health.enabled) {
847
1210
  providers.push(HealthService);
848
1211
  }
1212
+ if (resolved.telemetry.enabled) {
1213
+ providers.push(buildTraceContextProvider());
1214
+ }
849
1215
  if (resolved.metrics.enabled) {
850
1216
  providers.push(buildMetricsRegistryProvider());
1217
+ providers.push(MetricsContributionRunner);
851
1218
  if (resolved.timing.enabled) {
852
1219
  providers.push(buildMetricsTimingSinkProvider());
853
1220
  }
@@ -882,10 +1249,11 @@ function buildAsyncSlots() {
882
1249
  }
883
1250
  ];
884
1251
  }
885
- function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS]) {
1252
+ function augmentModule(base, providers, controllers, exportTokens = [BYMAX_CORE_OPTIONS], imports = []) {
886
1253
  return {
887
1254
  ...base,
888
1255
  module: exports.BymaxCoreModule,
1256
+ imports: [...base.imports ?? [], ...imports],
889
1257
  providers: [...base.providers ?? [], ...providers],
890
1258
  controllers: [...base.controllers ?? [], ...controllers],
891
1259
  exports: [...base.exports ?? [], ...exportTokens]
@@ -917,11 +1285,14 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
917
1285
  exportTokens.push(BYMAX_TIMING_SINK);
918
1286
  }
919
1287
  }
1288
+ const scansProviders = resolved.health.enabled && resolved.health.autoDiscover || resolved.metrics.enabled;
1289
+ const imports = scansProviders ? [core.DiscoveryModule] : [];
920
1290
  return augmentModule(
921
1291
  super.forRoot(options),
922
1292
  providers,
923
1293
  buildControllers(resolved),
924
- exportTokens
1294
+ exportTokens,
1295
+ imports
925
1296
  );
926
1297
  }
927
1298
  /**
@@ -955,13 +1326,16 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
955
1326
  ...buildAsyncSlots(),
956
1327
  HealthService,
957
1328
  buildMetricsRegistryProvider(),
958
- buildMetricsTimingSinkProvider()
1329
+ buildMetricsTimingSinkProvider(),
1330
+ MetricsContributionRunner,
1331
+ buildTraceContextProvider()
959
1332
  ];
960
1333
  return augmentModule(
961
1334
  super.forRootAsync(options),
962
1335
  providers,
963
1336
  [createHealthController(DEFAULT_HEALTH_PATH), createMetricsController(DEFAULT_METRICS_PATH)],
964
- [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY]
1337
+ [BYMAX_CORE_OPTIONS, BYMAX_TIMING_SINK, BYMAX_METRICS_REGISTRY],
1338
+ [core.DiscoveryModule]
965
1339
  );
966
1340
  }
967
1341
  };
@@ -986,6 +1360,7 @@ exports.BYMAX_PAYLOAD_TOO_LARGE = BYMAX_PAYLOAD_TOO_LARGE;
986
1360
  exports.BYMAX_SERVICE_UNAVAILABLE = BYMAX_SERVICE_UNAVAILABLE;
987
1361
  exports.BYMAX_TIMING_SINK = BYMAX_TIMING_SINK;
988
1362
  exports.BYMAX_TOO_MANY_REQUESTS = BYMAX_TOO_MANY_REQUESTS;
1363
+ exports.BYMAX_TRACE_CONTEXT = BYMAX_TRACE_CONTEXT;
989
1364
  exports.BYMAX_UNAUTHORIZED = BYMAX_UNAUTHORIZED;
990
1365
  exports.BYMAX_UNPROCESSABLE_ENTITY = BYMAX_UNPROCESSABLE_ENTITY;
991
1366
  exports.BYMAX_UNSUPPORTED_MEDIA_TYPE = BYMAX_UNSUPPORTED_MEDIA_TYPE;