@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/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  <a href="https://www.npmjs.com/package/@bymax-one/nest-core"><img src="https://img.shields.io/npm/dm/@bymax-one/nest-core?style=flat-square&colorA=000000&colorB=000000" alt="npm downloads" /></a>
15
15
  <a href="https://github.com/bymaxone/nest-core/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/bymaxone/nest-core/ci.yml?branch=main&style=flat-square&colorA=000000&label=CI" alt="CI status" /></a>
16
16
  <a href="https://github.com/bymaxone/nest-core/actions/workflows/ci.yml"><img src="https://img.shields.io/badge/coverage-100%25-brightgreen?style=flat-square&colorA=000000" alt="coverage" /></a>
17
- <a href="https://github.com/bymaxone/nest-core/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-97.86%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
17
+ <a href="https://github.com/bymaxone/nest-core/blob/main/docs/mutation_testing_results.md"><img src="https://img.shields.io/badge/mutation-98.76%25-brightgreen?style=flat-square&colorA=000000" alt="mutation score" /></a>
18
18
  <a href="https://scorecard.dev/viewer/?uri=github.com/bymaxone/nest-core"><img src="https://api.scorecard.dev/projects/github.com/bymaxone/nest-core/badge?style=flat-square" alt="OpenSSF Scorecard" /></a>
19
19
  <a href="https://github.com/bymaxone/nest-core/blob/main/LICENSE"><img src="https://img.shields.io/github/license/bymaxone/nest-core?style=flat-square&colorA=000000&colorB=000000" alt="license" /></a>
20
20
  <a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript" /></a>
@@ -40,7 +40,9 @@ different ways, and how a client integration breaks because one of them changed
40
40
  body.
41
41
 
42
42
  It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflect-metadata`, and
43
- `prom-client` for the optional metrics endpoint is a peer whose version you already control.
43
+ the three optional peers behind the features that need them (`prom-client`, `@nestjs/swagger`,
44
+ `@opentelemetry/api`) — is a peer whose version you already control. A feature you leave off
45
+ never loads its peer, which the release gate asserts against the packed tarball.
44
46
 
45
47
  ### Why nest-core?
46
48
 
@@ -79,6 +81,12 @@ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflec
79
81
  branch without re-deriving the threshold
80
82
  - ✅ **Prometheus endpoint** — opt-in scrape route over `BYMAX_METRICS_REGISTRY`;
81
83
  `prom-client` is imported only when it is enabled
84
+ - ✅ **Contributed metrics** — a provider marked `@BymaxMetricsContributor()` publishes its
85
+ own collectors on that same registry, so an imported library's metrics land in your scrape
86
+ - ✅ **Trace correlation** — reads the active OpenTelemetry span, so timing samples (and,
87
+ when you opt in, error envelopes) carry the trace id; never creates a span or an SDK
88
+ - ✅ **OpenAPI document, development only** — one bootstrap call publishes an interactive
89
+ UI carrying the schemas this package owns; in production it is never served, guarded twice
82
90
 
83
91
  ### 📄 Pagination & Health
84
92
 
@@ -90,13 +98,15 @@ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflec
90
98
  without restarting the pod
91
99
  - ✅ **Pluggable indicators** — implement `IHealthIndicator` against a client you already own
92
100
  and register it under the `BYMAX_HEALTH_INDICATORS` multi-token
101
+ - ✅ **Discovered indicators** — opt in, and any provider marked
102
+ `@BymaxHealthIndicator()` joins readiness: a library you import brings its own check
93
103
 
94
104
  ### 🧩 Developer Experience
95
105
 
96
106
  - ✅ **Zero runtime dependencies** — `@nestjs/*`, `rxjs` and `reflect-metadata` arrive as
97
107
  peers, so you pin the versions
98
- - ✅ **Three subpaths** — the module, plus `./pagination` and `./health` that a package can
99
- import without pulling the module in
108
+ - ✅ **Five subpaths** — the module, plus `./pagination`, `./health`, `./metrics` and
109
+ `./openapi` that a package can import without pulling the module in
100
110
  - ✅ **Dual-format output** — ESM + CJS with declarations for each format, verified against
101
111
  the packed tarball on every run
102
112
  - ✅ **Independent features** — each is enabled on its own; the providers for the rest are
@@ -108,11 +118,13 @@ It ships `"dependencies": {}`. Everything it touches — NestJS, `rxjs`, `reflec
108
118
 
109
119
  ## 📦 Subpath Exports
110
120
 
111
- | Subpath | Contents |
112
- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
113
- | `.` | `BymaxCoreModule`, the error envelope and its code catalog, the timing interceptor, the DI tokens, and every option type |
114
- | `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved |
115
- | `./health` | `IHealthIndicator`, `HealthResponse` and the indicator contracts, so a package that only implements an indicator does not import the module |
121
+ | Subpath | Contents |
122
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `.` | `BymaxCoreModule`, the error envelope and its code catalog, the timing interceptor, the DI tokens, and every option type |
124
+ | `./pagination` | `normalizePageQuery`, `buildPageResult`, `normalizeCursorQuery`, `buildCursorResult`, `encodeCursor`, `decodeCursor` and their types — pure functions, no NestJS provider involved |
125
+ | `./health` | `IHealthIndicator`, `HealthResponse`, the indicator contracts and the `@BymaxHealthIndicator()` marker, so a package that only implements an indicator does not import the module |
126
+ | `./metrics` | `IMetricsContributor` and the `@BymaxMetricsContributor()` marker, so a package that only publishes metrics imports neither the module nor its DI tokens. The one subpath whose types name `prom-client`, which anyone implementing the contract already depends on |
127
+ | `./openapi` | `applyBymaxOpenApi`, the one bootstrap call that builds and mounts the OpenAPI document — separate so an application that never documents its API never loads the code that does |
116
128
 
117
129
  Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
118
130
  `require()` and `import` both resolve the declarations meant for them.
@@ -123,13 +135,20 @@ Each subpath ships ESM and CommonJS with its own `.d.ts` and `.d.cts`, so
123
135
  pnpm add @bymax-one/nest-core @nestjs/common @nestjs/core reflect-metadata rxjs
124
136
  ```
125
137
 
126
- Add `prom-client` as well if you enable the metrics feature; it is an optional
127
- peer dependency, so it is never required unless you turn metrics on:
138
+ Add `prom-client` if you enable the metrics feature, `@nestjs/swagger` if you
139
+ enable the OpenAPI feature, and `@opentelemetry/api` if you enable trace
140
+ correlation. All three are optional peer dependencies: none is required, or ever
141
+ loaded, unless you turn its feature on.
128
142
 
129
143
  ```bash
130
144
  pnpm add prom-client
145
+ pnpm add @opentelemetry/api
146
+ pnpm add -D @nestjs/swagger
131
147
  ```
132
148
 
149
+ `@nestjs/swagger` belongs in `devDependencies`: the document is never served in
150
+ production, so a production install has no reason to carry it.
151
+
133
152
  ## 🚀 Quick Start
134
153
 
135
154
  ```typescript
@@ -205,6 +224,7 @@ falls back to the documented default. Pass only what you want to change.
205
224
  | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
206
225
  | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
207
226
  | `exposeIndicatorErrors` | `boolean` | `false` | Includes the failing indicator's message in the response under `details.error`. Never enable in production — see below. |
227
+ | `autoDiscover` | `boolean` | `false` | Also aggregates every provider marked `@BymaxHealthIndicator()`, anywhere in the application. |
208
228
 
209
229
  On `forRoot`, `enabled` and `path` are applied at module-definition time: a
210
230
  disabled feature registers no controller, and a custom `path` mounts the routes.
@@ -227,6 +247,31 @@ As with `health`, `enabled` and `path` register conditionally on `forRoot`. On
227
247
  and enforces `enabled` and the default path with a request-time guard, so a
228
248
  disabled or custom-path async configuration fails fast at the route.
229
249
 
250
+ ### `telemetry`
251
+
252
+ | Option | Type | Default | Description |
253
+ | --------------- | --------- | ------- | ------------------------------------------------------------------------------- |
254
+ | `enabled` | `boolean` | `false` | Reads the active span and carries its ids into timing samples and the log seam. |
255
+ | `exposeTraceId` | `boolean` | `false` | Also publishes `traceId` in the error-envelope body served to the client. |
256
+
257
+ ### `openapi`
258
+
259
+ | Option | Type | Default | Description |
260
+ | -------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------ |
261
+ | `enabled` | `boolean` | `false` | Builds and serves the document. Ignored in production, where it is always off. |
262
+ | `path` | `string` | `'docs'` | Route serving the interactive UI. |
263
+ | `jsonPath` | `string` | `'docs-json'` | Route serving the raw JSON document. |
264
+ | `title` | `string` | `'API'` | Document title. |
265
+ | `description` | `string` | `''` | Document description. |
266
+ | `version` | `string` | `'1.0.0'` | Document version, independent of the package version. |
267
+ | `servers` | `{ url, description? }[]` | `[]` | Servers advertised by the document. |
268
+ | `securitySchemes` | `Record<string, object>` | `{}` | Security schemes copied into the document's components. |
269
+ | `includeCoreSchemas` | `boolean` | `true` | Contributes this package's own schemas — envelope, health, pagination. |
270
+
271
+ Unlike `health` and `metrics`, this block behaves identically on `forRoot` and
272
+ `forRootAsync`: the document is mounted from the bootstrap helper, after the
273
+ options have resolved, so a custom `path` is honored on both registration paths.
274
+
230
275
  ## 🔑 DI Tokens
231
276
 
232
277
  Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
@@ -242,13 +287,14 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
242
287
  [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
243
288
  below.
244
289
 
245
- | Token | Provides | When you do not provide one |
246
- | ---------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- |
247
- | `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
248
- | `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
249
- | `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
250
- | `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
251
- | `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
290
+ | Token | Provides | When you do not provide one |
291
+ | ---------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
292
+ | `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
293
+ | `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
294
+ | `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
295
+ | `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
296
+ | `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
297
+ | `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
252
298
 
253
299
  ## 🚨 Error Envelope
254
300
 
@@ -447,6 +493,52 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
447
493
  converted to a `down` entry with a safe, bounded diagnostic detail; it never
448
494
  hides the results of the other registered indicators.
449
495
 
496
+ ### Discovered indicators
497
+
498
+ Registering every indicator by hand stops scaling once the libraries an
499
+ application imports have their own health to report. Mark a provider instead,
500
+ and turn discovery on:
501
+
502
+ ```typescript
503
+ // in a library, or anywhere in your application
504
+ import { Injectable } from '@nestjs/common'
505
+ import { BymaxHealthIndicator } from '@bymax-one/nest-core/health'
506
+ import type { HealthIndicatorResult, IHealthIndicator } from '@bymax-one/nest-core/health'
507
+
508
+ @BymaxHealthIndicator()
509
+ @Injectable()
510
+ export class RedisHealthIndicator implements IHealthIndicator {
511
+ readonly name = 'redis'
512
+
513
+ async check(): Promise<HealthIndicatorResult> {
514
+ await this.redis.ping()
515
+ return { status: 'up' }
516
+ }
517
+ }
518
+ ```
519
+
520
+ ```typescript
521
+ BymaxCoreModule.forRoot({ health: { autoDiscover: true } })
522
+ ```
523
+
524
+ Readiness now includes `redis` with nothing registered anywhere. The rules:
525
+
526
+ | Rule | Behavior |
527
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
528
+ | Marked, not shaped | Only providers carrying the marker are collected. A provider that merely has `name` and `check` is ignored. |
529
+ | Explicit wins | An indicator registered under `BYMAX_HEALTH_INDICATORS` keeps its name and its position; a discovered one with the same name is dropped. |
530
+ | Stable order | Discovered indicators are sorted by name, so the `checks` array does not reshuffle between restarts. |
531
+ | Marked but incomplete fails | A marked provider that does not implement `IHealthIndicator` fails the boot, naming the class. Skipping it would hide a check you believe is running. |
532
+ | Scanned once | The provider graph is walked at bootstrap, not per probe. |
533
+
534
+ It is off by default because it changes which failures can take an application
535
+ out of rotation: with it on, a library you merely import gains the ability to
536
+ fail your readiness probe. That is the point — the dependency understands its own
537
+ health better than you do — but it is your decision, not something you inherit.
538
+
539
+ `@BymaxHealthIndicator()` lives in `./health`, alongside the contract, so a
540
+ library that only ships an indicator never imports the module.
541
+
450
542
  ## 📈 Metrics
451
543
 
452
544
  Disabled by default. Enabling it registers `GET /metrics`, serving Prometheus
@@ -472,6 +564,165 @@ default HTTP metrics with a bounded label set:
472
564
  Inject `BYMAX_METRICS_REGISTRY` to register your own application metrics
473
565
  against the same registry the endpoint scrapes.
474
566
 
567
+ ### Contributed metrics
568
+
569
+ Injecting the token works for your own code, but it makes a library depend on
570
+ this package's DI tokens — and therefore on the module. A library declares its
571
+ metrics instead:
572
+
573
+ ```typescript
574
+ // in a library
575
+ import { Injectable } from '@nestjs/common'
576
+ import { BymaxMetricsContributor } from '@bymax-one/nest-core/metrics'
577
+ import type { IMetricsContributor, MetricsRegistry } from '@bymax-one/nest-core/metrics'
578
+ import { Gauge } from 'prom-client'
579
+
580
+ @BymaxMetricsContributor()
581
+ @Injectable()
582
+ export class QueueMetrics implements IMetricsContributor {
583
+ registerMetrics(registry: MetricsRegistry): void {
584
+ new Gauge({ name: 'bymax_queue_depth', help: 'Jobs waiting', registers: [registry] })
585
+ }
586
+ }
587
+ ```
588
+
589
+ Enable metrics and the contributor runs — there is no second flag:
590
+
591
+ ```typescript
592
+ BymaxCoreModule.forRoot({ metrics: { enabled: true } })
593
+ ```
594
+
595
+ | Rule | Behavior |
596
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
597
+ | Marked, not shaped | Only providers carrying the marker are called. A provider that merely has `registerMetrics` is never touched. |
598
+ | Called once | At bootstrap, with the registry the scrape endpoint serves. Never per request, never per scrape. |
599
+ | Stable order | Contributors run sorted by class name, so a collision fails the same way on every boot. |
600
+ | Named failures | A registration failure — usually a metric name another library already claimed — fails the boot naming the contributor. `prom-client` names the metric; this names who registered it. |
601
+ | Off with metrics | With the metrics feature disabled, no contributor runs and `prom-client` is never loaded. |
602
+
603
+ **Naming and labels.** Contributors share one registry and one namespace, so the
604
+ conventions are part of the contract:
605
+
606
+ - Prefix every metric with `bymax_<library>_` (`bymax_queue_depth`,
607
+ `bymax_cache_hits_total`). An application's own metrics need no prefix — they
608
+ have no one to collide with but themselves.
609
+ - Follow Prometheus naming: `_total` for counters, `_seconds` for durations, base
610
+ units, no units in the middle of a name.
611
+ - Keep labels bounded. Route templates, never raw paths; status codes, never
612
+ messages. **Never** a tenant, user, or request id — one unbounded label is
613
+ enough to make a scrape endpoint the most expensive route in a service.
614
+
615
+ ## 📘 OpenAPI
616
+
617
+ Disabled by default, and **never served in production**. Enabling it and calling
618
+ one helper during bootstrap publishes an interactive UI at `GET /docs` and the
619
+ raw document at `GET /docs-json`:
620
+
621
+ ```typescript
622
+ // app.module.ts — configuration lives with every other feature
623
+ BymaxCoreModule.forRoot({ openapi: { enabled: true, title: 'Invoices API' } })
624
+ ```
625
+
626
+ ```typescript
627
+ // main.ts — the one call that needs the application instance
628
+ import { NestFactory } from '@nestjs/core'
629
+ import { applyBymaxOpenApi } from '@bymax-one/nest-core/openapi'
630
+ import { AppModule } from './app.module'
631
+
632
+ async function bootstrap(): Promise<void> {
633
+ const app = await NestFactory.create(AppModule)
634
+ await applyBymaxOpenApi(app)
635
+ await app.listen(3000)
636
+ }
637
+
638
+ void bootstrap()
639
+ ```
640
+
641
+ > [!IMPORTANT]
642
+ > Call `applyBymaxOpenApi` **before** `app.listen()`. Mounting the document
643
+ > re-registers routes on the HTTP adapter, and doing that against an
644
+ > already-initialized Express 5 application replaces the router: the document
645
+ > appears and every other route in the application — yours and this package's
646
+ > health endpoints alike — starts returning 404.
647
+
648
+ The call is safe to make unconditionally. It returns what it did, so a template
649
+ can emit it once and never branch:
650
+
651
+ | Result | Meaning |
652
+ | ------------------------------------------ | ------------------------------------------------------- |
653
+ | `{ mounted: true, path }` | The UI and the document are served at `path`. |
654
+ | `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
655
+ | `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
656
+
657
+ ### Production is a closed door
658
+
659
+ `NODE_ENV` decides, and the decision is fail-closed: only `development` and
660
+ `test` are non-production. Any other value — including an unset variable —
661
+ is production, and in production the document is never built and never mounted,
662
+ whatever the configuration says. The guard runs twice, independently: the option
663
+ resolver forces the feature off, and the bootstrap helper refuses again without
664
+ trusting that resolution. There is no override.
665
+
666
+ Enabling it in production is not an error, it is a no-op with a warning naming
667
+ the option that was ignored, so a single configuration can be shared across
668
+ environments.
669
+
670
+ ### What the library contributes
671
+
672
+ With `includeCoreSchemas` on, the document carries the contracts this package
673
+ already serves, so an operation can `$ref` them instead of redeclaring them:
674
+
675
+ | Component | Describes |
676
+ | --------------------------------------------------------------------- | --------------------------------------- |
677
+ | `BymaxErrorEnvelope`, `BymaxErrorDetails`, `BymaxErrorCode` | The error contract and its code catalog |
678
+ | `BymaxHealthResponse`, `BymaxHealthCheckEntry` | The liveness and readiness bodies |
679
+ | `BymaxPageResult`, `BymaxPageMeta`, `BymaxCursorResult` | The offset and cursor page shapes |
680
+ | `BymaxPageQueryPage`, `BymaxPageQueryLimit`, `BymaxCursorQueryCursor` | The pagination query parameters |
681
+
682
+ They are contributed as plain specification objects, not as decorated classes.
683
+ That is what keeps `@nestjs/swagger` genuinely optional: a decorator runs when
684
+ its class is defined, so describing these contracts with `@ApiProperty` would
685
+ load the peer in every application that imports this package, including the ones
686
+ that never enable the feature.
687
+
688
+ A contributed entry never overwrites one the document already has: if you
689
+ document your own `BymaxErrorEnvelope`, yours wins.
690
+
691
+ ## 🧵 Trace correlation
692
+
693
+ Off by default. Enabled, it reads the span your instrumentation already opened
694
+ and carries its identifiers into the signals this package produces:
695
+
696
+ ```typescript
697
+ BymaxCoreModule.forRoot({ telemetry: { enabled: true } })
698
+ ```
699
+
700
+ | Where it lands | When |
701
+ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
702
+ | `RequestTimingSample.traceId` / `.spanId` | Whenever a span is recording. Your sink forwards them to logs or wherever samples go. |
703
+ | `FilterErrorContext.traceId` | Always available to the `onUnexpectedError` seam, so a logging pipeline can record it. |
704
+ | The error-envelope body | Only with `exposeTraceId` on. |
705
+ | Metric labels | Never. A trace id is unbounded; as a label it would make the scrape endpoint the most expensive route in the service. |
706
+
707
+ This package **reads**; it never traces. It starts no span, configures no SDK,
708
+ registers no exporter, and installs no instrumentation — all of which your
709
+ collector setup already does, and doing it twice produces two spans per request.
710
+ `@opentelemetry/api` is an optional peer, loaded once at bootstrap and only when
711
+ the feature is on.
712
+
713
+ A request with nothing recording, or an all-zero span context, resolves to no
714
+ trace at all: the fields are absent rather than set to a sentinel, so a sink
715
+ never has to recognize a string of zeros.
716
+
717
+ ### Publishing the id is a separate decision
718
+
719
+ `exposeTraceId` is off by default. A trace id is not a secret, but in a response
720
+ body it tells a caller that a tracing backend exists and hands them the
721
+ identifier that ties their request to everything else in that trace. Support
722
+ teams often want exactly that, which is why the option exists; it is opt-in so it
723
+ is a decision rather than a side effect. With it off, the identifiers still reach
724
+ your samples and your logs.
725
+
475
726
  ## 🔗 Integration with `@bymax-one/nest-logger`
476
727
 
477
728
  Pairing this package with `@bymax-one/nest-logger` yields correlated logs and
@@ -530,9 +781,21 @@ BYMAX_CORRELATION_PROVIDER no module,
530
781
  comes from) script or a test
531
782
  ```
532
783
 
784
+ Four opt-in integrations attach to that spine rather than adding columns to it:
785
+
786
+ ```
787
+ health/ + @BymaxHealthIndicator() → a marked provider joins readiness
788
+ metrics/ + @BymaxMetricsContributor() → a marked provider publishes on the registry
789
+ envelope/ + telemetry → the active trace id reaches the envelope and the seam
790
+ timing/ + telemetry → the sample carries traceId and spanId
791
+ ./openapi → one bootstrap call serves the document, in development
792
+ ```
793
+
533
794
  Each feature registers only when it is on. Turning metrics off does not leave a
534
795
  disabled provider in the container — it leaves no provider, and `prom-client` is
535
- never imported, which is why it can stay an optional peer.
796
+ never imported, which is why it can stay an optional peer. The same holds for
797
+ `@nestjs/swagger` and `@opentelemetry/api`: the release gate loads the packed
798
+ tarball and fails if any of the three is reachable with its feature off.
536
799
 
537
800
  Nothing here holds state across requests. The timing interceptor emits and forgets;
538
801
  the health service runs the indicators the app registered and folds their results;
@@ -599,20 +862,33 @@ do not treat a cursor as proof of anything.
599
862
  It is off by default. When it is on, nothing in this library authenticates it — apply the
600
863
  guard you would apply to any internal endpoint, or keep it off the public listener.
601
864
 
865
+ ### The OpenAPI document does not exist in production
866
+
867
+ A published document is a map of every route, parameter and error shape an application has —
868
+ useful to a developer, and just as useful to anyone probing the service. So unlike the metrics
869
+ endpoint, it is not left to a guard: it is refused outright whenever the runtime is not
870
+ positively `development` or `test`, in two independent layers, with no option to override.
871
+ An unset `NODE_ENV` counts as production, because the deployment nobody configured is the one
872
+ most likely to be exposed.
873
+
602
874
  ---
603
875
 
604
876
  ## 🛡️ Security Table
605
877
 
606
- | Layer | Implementation |
607
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
608
- | Error responses | One shape for everything; unknown errors become a generic 500 |
609
- | Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
610
- | Health output | The response names which indicator is down and nothing more; the reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
611
- | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
612
- | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
613
- | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
614
- | Metrics | Opt-in; `prom-client` never imported while it is off |
615
- | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
878
+ | Layer | Implementation |
879
+ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
880
+ | Error responses | One shape for everything; unknown errors become a generic 500 |
881
+ | Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
882
+ | Health output | The response names which indicator is down and nothing more; the reason goes to the logger. `exposeIndicatorErrors` (default `false`) puts it back in the response for debugging |
883
+ | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
884
+ | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
885
+ | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
886
+ | Metrics | Opt-in; `prom-client` never imported while it is off |
887
+ | OpenAPI | Opt-in and development-only; refused in production by two independent guards, `@nestjs/swagger` never imported while it is off |
888
+ | Discovered checks | Matched by an explicit marker, never by shape; off by default, because it lets an imported library fail your readiness probe |
889
+ | Contributed metrics | Called only when marked; a name collision fails the boot naming the contributor rather than half-populating a scrape |
890
+ | Trace ids | Read-only, never used as a metric label; published in a response body only under `telemetry.exposeTraceId` (default off) |
891
+ | Supply chain | `dependencies: {}`; third-party Actions pinned by commit SHA (org-internal reusables by tag); CodeQL and OpenSSF Scorecard |
616
892
 
617
893
  > [!IMPORTANT]
618
894
  > **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
@@ -626,7 +902,9 @@ guard you would apply to any internal endpoint, or keep it off the public listen
626
902
  - **Runtime:** Node.js 24+
627
903
  - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
628
904
  - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
629
- - **Optional peer:** `prom-client ^15` — required only when metrics are enabled
905
+ - **Optional peers:** `prom-client ^15` when metrics are enabled, `@nestjs/swagger ^11` when
906
+ OpenAPI is enabled, `@opentelemetry/api ^1.9` when trace correlation is enabled — none is
907
+ imported while its feature is off
630
908
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
631
909
  - **Tests:** Jest (unit + e2e over a real Nest application) + Stryker (mutation)
632
910
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
@@ -639,11 +917,13 @@ This library sits in the path of every request and every failure of every servic
639
917
  installs it, so the suite is held to a bar beyond "the tests pass".
640
918
 
641
919
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
642
- - ✅ **97.86% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
920
+ - ✅ **98.76% mutation score** — verified with [Stryker](https://stryker-mutator.io/) at
643
921
  `break: 95`; every killable survivor was killed by a strengthened test, with no production
644
- change ([report](./docs/mutation_testing_results.md))
645
- - ✅ **End-to-end against a real application** — the filter, the interceptor and the health
646
- routes are exercised through a booted Nest app, not against mocks of it
922
+ change, and the nine that remain are documented equivalents rather than suppressions
923
+ ([report](./docs/mutation_testing_results.md))
924
+ - **End-to-end against a real application** — the filter, the interceptor, the health and
925
+ metrics routes, the served OpenAPI document, discovered indicators, contributed metrics and
926
+ trace correlation are all exercised through a booted Nest app, not against mocks of it
647
927
  - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
648
928
  system does, `check:runtime` loads every subpath from the packed tarball in ESM and
649
929
  CommonJS, and `check:published` compiles this README's snippets against `dist/`
@@ -667,20 +947,21 @@ in the sections above.
667
947
 
668
948
  ### `.` (root)
669
949
 
670
- | Export | Kind | Description |
671
- | ---------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
672
- | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
673
- | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
674
- | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY` | tokens | The DI tokens; see the [token table](#-di-tokens). |
675
- | `ICorrelationIdProvider` | type | The correlation-provider contract. |
676
- | `BymaxExceptionFilter` | class | The envelope exception filter. |
677
- | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
678
- | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
679
- | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
680
- | `TimingInterceptor` | class | The request-timing interceptor. |
681
- | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
682
- | `BYMAX_BAD_GATEWAY` `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
683
- | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
950
+ | Export | Kind | Description |
951
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
952
+ | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
953
+ | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
954
+ | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY` | tokens | The DI tokens; see the [token table](#-di-tokens). |
955
+ | `ICorrelationIdProvider` | type | The correlation-provider contract. |
956
+ | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
957
+ | `BymaxExceptionFilter` | class | The envelope exception filter. |
958
+ | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
959
+ | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
960
+ | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
961
+ | `TimingInterceptor` | class | The request-timing interceptor. |
962
+ | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
963
+ | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
964
+ | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
684
965
 
685
966
  ### `./pagination`
686
967
 
@@ -693,12 +974,31 @@ in the sections above.
693
974
 
694
975
  ### `./health`
695
976
 
696
- | Export | Kind | Description |
697
- | ----------------------- | ---- | --------------------------------------------------- |
698
- | `IHealthIndicator` | type | The pluggable indicator contract. |
699
- | `HealthIndicatorResult` | type | The outcome of a single indicator check. |
700
- | `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
701
- | `HealthResponse` | type | The stable liveness and readiness response shape. |
977
+ | Export | Kind | Description |
978
+ | --------------------------------- | -------- | --------------------------------------------------- |
979
+ | `IHealthIndicator` | type | The pluggable indicator contract. |
980
+ | `HealthIndicatorResult` | type | The outcome of a single indicator check. |
981
+ | `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
982
+ | `HealthResponse` | type | The stable liveness and readiness response shape. |
983
+ | `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
984
+ | `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
985
+
986
+ ### `./metrics`
987
+
988
+ | Export | Kind | Description |
989
+ | ------------------------------------ | -------- | ------------------------------------------------------------ |
990
+ | `BymaxMetricsContributor` | function | Class decorator marking a provider as a metrics contributor. |
991
+ | `BYMAX_METRICS_CONTRIBUTOR_METADATA` | constant | The metadata key the marker writes. |
992
+ | `IMetricsContributor` | type | The contract: `registerMetrics(registry)`. |
993
+ | `MetricsRegistry` | type | The `prom-client` registry the scrape endpoint serves. |
994
+
995
+ ### `./openapi`
996
+
997
+ | Export | Kind | Description |
998
+ | --------------------- | -------- | ----------------------------------------------------------------- |
999
+ | `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
1000
+ | `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
1001
+ | `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
702
1002
 
703
1003
  ## 🧩 Compatibility
704
1004
 
@@ -1,2 +1,12 @@
1
1
  'use strict';
2
2
 
3
+ var common = require('@nestjs/common');
4
+
5
+ // src/health/health.marker.ts
6
+ var BYMAX_HEALTH_INDICATOR_METADATA = "bymax-one:health-indicator";
7
+ function BymaxHealthIndicator() {
8
+ return common.SetMetadata(BYMAX_HEALTH_INDICATOR_METADATA, true);
9
+ }
10
+
11
+ exports.BYMAX_HEALTH_INDICATOR_METADATA = BYMAX_HEALTH_INDICATOR_METADATA;
12
+ exports.BymaxHealthIndicator = BymaxHealthIndicator;
@@ -1,3 +1,35 @@
1
+ import { CustomDecorator } from '@nestjs/common';
2
+
3
+ /**
4
+ * Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
5
+ * collide with a consumer's own metadata, and exported so a test — or a sibling
6
+ * library's own conformance suite — can assert a class is marked without
7
+ * depending on how the decorator is implemented.
8
+ */
9
+ declare const BYMAX_HEALTH_INDICATOR_METADATA = "bymax-one:health-indicator";
10
+ /**
11
+ * Mark a provider class as a readiness indicator, so `BymaxCoreModule` discovers
12
+ * it without the application registering it by hand.
13
+ *
14
+ * The class must implement `IHealthIndicator`; a marked provider that does not
15
+ * fails at bootstrap with a message naming it, rather than being skipped
16
+ * silently. Discovery has to be enabled (`health.autoDiscover`) for the marker to
17
+ * be read at all — marking a class in an application that never enables it costs
18
+ * one metadata entry and changes nothing.
19
+ *
20
+ * @returns The class decorator carrying the marker.
21
+ * @example
22
+ * \@BymaxHealthIndicator()
23
+ * \@Injectable()
24
+ * export class RedisHealthIndicator implements IHealthIndicator {
25
+ * readonly name = 'redis'
26
+ * async check(): Promise<HealthIndicatorResult> {
27
+ * return { status: 'up' }
28
+ * }
29
+ * }
30
+ */
31
+ declare function BymaxHealthIndicator(): CustomDecorator<string>;
32
+
1
33
  /**
2
34
  * @fileoverview Health-check contracts. Consumers implement
3
35
  * {@link IHealthIndicator} against a client they already own (a cache, a
@@ -61,4 +93,4 @@ interface HealthResponse {
61
93
  checks: HealthCheckEntry[];
62
94
  }
63
95
 
64
- export type { HealthCheckEntry, HealthIndicatorResult, HealthResponse, IHealthIndicator };
96
+ export { BYMAX_HEALTH_INDICATOR_METADATA, BymaxHealthIndicator, type HealthCheckEntry, type HealthIndicatorResult, type HealthResponse, type IHealthIndicator };