@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/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
@@ -199,11 +218,13 @@ falls back to the documented default. Pass only what you want to change.
199
218
 
200
219
  ### `health`
201
220
 
202
- | Option | Type | Default | Description |
203
- | -------------------- | --------- | ---------- | ------------------------------------------------------ |
204
- | `enabled` | `boolean` | `true` | Registers the health controller. |
205
- | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
206
- | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
221
+ | Option | Type | Default | Description |
222
+ | ----------------------- | --------- | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
223
+ | `enabled` | `boolean` | `true` | Registers the health controller. |
224
+ | `path` | `string` | `'health'` | Route prefix: `GET /<path>/live`, `GET /<path>/ready`. |
225
+ | `indicatorTimeoutMs` | `number` | `5000` | Per-indicator timeout before a check reports down. |
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. |
207
228
 
208
229
  On `forRoot`, `enabled` and `path` are applied at module-definition time: a
209
230
  disabled feature registers no controller, and a custom `path` mounts the routes.
@@ -226,6 +247,31 @@ As with `health`, `enabled` and `path` register conditionally on `forRoot`. On
226
247
  and enforces `enabled` and the default path with a request-time guard, so a
227
248
  disabled or custom-path async configuration fails fast at the route.
228
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
+
229
275
  ## 🔑 DI Tokens
230
276
 
231
277
  Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER` and
@@ -241,13 +287,14 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
241
287
  [Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
242
288
  below.
243
289
 
244
- | Token | Provides | When you do not provide one |
245
- | ---------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- |
246
- | `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
247
- | `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
248
- | `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
249
- | `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
250
- | `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 |
251
298
 
252
299
  ## 🚨 Error Envelope
253
300
 
@@ -391,6 +438,12 @@ Liveness always replies `200` with an empty checks array; readiness runs
391
438
  every registered indicator concurrently and replies `200` only when every
392
439
  indicator reports `up`, `503` otherwise, naming every check either way.
393
440
 
441
+ A failing indicator is named but not quoted: the response says which check is
442
+ down, and the reason goes to the logger. See
443
+ [the security model](#-security-model) for why, and
444
+ `health.exposeIndicatorErrors` if you want the message in the response while
445
+ debugging locally.
446
+
394
447
  ```json
395
448
  { "status": "ok", "checks": [{ "name": "redis", "status": "up" }] }
396
449
  ```
@@ -440,6 +493,52 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
440
493
  converted to a `down` entry with a safe, bounded diagnostic detail; it never
441
494
  hides the results of the other registered indicators.
442
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
+
443
542
  ## 📈 Metrics
444
543
 
445
544
  Disabled by default. Enabling it registers `GET /metrics`, serving Prometheus
@@ -465,6 +564,165 @@ default HTTP metrics with a bounded label set:
465
564
  Inject `BYMAX_METRICS_REGISTRY` to register your own application metrics
466
565
  against the same registry the endpoint scrapes.
467
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
+
468
726
  ## 🔗 Integration with `@bymax-one/nest-logger`
469
727
 
470
728
  Pairing this package with `@bymax-one/nest-logger` yields correlated logs and
@@ -523,9 +781,21 @@ BYMAX_CORRELATION_PROVIDER no module,
523
781
  comes from) script or a test
524
782
  ```
525
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
+
526
794
  Each feature registers only when it is on. Turning metrics off does not leave a
527
795
  disabled provider in the container — it leaves no provider, and `prom-client` is
528
- 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.
529
799
 
530
800
  Nothing here holds state across requests. The timing interceptor emits and forgets;
531
801
  the health service runs the indicators the app registered and folds their results;
@@ -558,12 +828,28 @@ original message and stack are captured for your logger, not for the response.
558
828
  `envelope.exposeInternals` puts them in the body and exists for local debugging — its own
559
829
  documentation says never to enable it in production, and it defaults to `false`.
560
830
 
561
- ### Health output is bounded by construction
831
+ ### The readiness response names the failure, it does not describe it
562
832
 
563
- An indicator that rejects is folded into a `down` entry from its top-level `Error#message`
564
- only — never the raw error, its stack, or a nested cause — and the message is truncated. An
565
- indicator cannot leak more than it already chose to put in a message, and a slow one is
566
- converted to `down` by the aggregator rather than hanging the probe.
833
+ A failing indicator produces `{ name, status: 'down' }` and nothing else. The reason goes
834
+ to the logger.
835
+
836
+ That split is deliberate. Readiness is usually unauthenticated and reachable by whatever
837
+ probes it, and an indicator rarely authors its own failure text — it writes
838
+ `await this.redis.ping()` and lets the driver's error propagate. Driver errors carry hosts,
839
+ ports, and in the case of a connection string, credentials. Putting that text in the
840
+ response publishes it to everyone who can reach the endpoint; putting it in the log keeps
841
+ it where access is already controlled, without losing the diagnostic.
842
+
843
+ `health.exposeIndicatorErrors` puts the message back in the response for local debugging.
844
+ It defaults to `false`, and its documentation says the same thing `envelope.exposeInternals`
845
+ does: never enable it in production. The two options are the same decision, made the same
846
+ way, about the same risk.
847
+
848
+ What reaches the log is bounded the same way it always was: the top-level `Error#message`
849
+ only — never the raw error, its stack, or a nested cause — truncated at 300 characters.
850
+ A slow indicator is converted to `down` by the aggregator rather than hanging the probe,
851
+ and its `timedOutAfterMs` stays in the response either way, because that number is one this
852
+ library chose rather than text an indicator produced.
567
853
 
568
854
  ### Cursors are opaque, not secret
569
855
 
@@ -576,20 +862,33 @@ do not treat a cursor as proof of anything.
576
862
  It is off by default. When it is on, nothing in this library authenticates it — apply the
577
863
  guard you would apply to any internal endpoint, or keep it off the public listener.
578
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
+
579
874
  ---
580
875
 
581
876
  ## 🛡️ Security Table
582
877
 
583
- | Layer | Implementation |
584
- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
585
- | Error responses | One shape for everything; unknown errors become a generic 500 |
586
- | Internals | Message and stack captured for logging, in the body only under `exposeInternals` (default `false`) |
587
- | Health output | Top-level `Error#message` only, truncated; no raw error, stack or cause |
588
- | Slow indicators | Converted to `down` by the aggregator, so a probe cannot hang on one |
589
- | Correlation | Resolved through `BYMAX_CORRELATION_PROVIDER` — the app decides where the id comes from |
590
- | Pagination cursors | Opaque, not authenticated; treated as client-supplied input on the way back in |
591
- | Metrics | Opt-in; `prom-client` never imported while it is off |
592
- | 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 |
593
892
 
594
893
  > [!IMPORTANT]
595
894
  > **`exposeInternals` is a debugging switch, not a verbosity setting.** With it on,
@@ -603,7 +902,9 @@ guard you would apply to any internal endpoint, or keep it off the public listen
603
902
  - **Runtime:** Node.js 24+
604
903
  - **Framework:** NestJS 11 (`ConfigurableModuleBuilder`, `APP_FILTER`, `APP_INTERCEPTOR`)
605
904
  - **Peers:** `@nestjs/common ^11`, `@nestjs/core ^11`, `rxjs ^7`, `reflect-metadata ^0.2`
606
- - **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
607
908
  - **Build:** tsup — ESM + CJS per subpath, with `.d.ts` _and_ `.d.cts` declarations
608
909
  - **Tests:** Jest (unit + e2e over a real Nest application) + Stryker (mutation)
609
910
  - **TypeScript:** 5.x strict (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`), zero `any`
@@ -616,11 +917,13 @@ This library sits in the path of every request and every failure of every servic
616
917
  installs it, so the suite is held to a bar beyond "the tests pass".
617
918
 
618
919
  - ✅ **100% line coverage** — statements, branches, functions and lines, enforced as a gate
619
- - ✅ **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
620
921
  `break: 95`; every killable survivor was killed by a strengthened test, with no production
621
- change ([report](./docs/mutation_testing_results.md))
622
- - ✅ **End-to-end against a real application** — the filter, the interceptor and the health
623
- 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
624
927
  - ✅ **Published-artifact gates** — `check:exports` resolves the types the way each module
625
928
  system does, `check:runtime` loads every subpath from the packed tarball in ESM and
626
929
  CommonJS, and `check:published` compiles this README's snippets against `dist/`
@@ -644,20 +947,21 @@ in the sections above.
644
947
 
645
948
  ### `.` (root)
646
949
 
647
- | Export | Kind | Description |
648
- | ---------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------- |
649
- | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
650
- | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
651
- | `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). |
652
- | `ICorrelationIdProvider` | type | The correlation-provider contract. |
653
- | `BymaxExceptionFilter` | class | The envelope exception filter. |
654
- | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
655
- | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
656
- | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
657
- | `TimingInterceptor` | class | The request-timing interceptor. |
658
- | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
659
- | `BYMAX_BAD_GATEWAY` `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
660
- | `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. |
661
965
 
662
966
  ### `./pagination`
663
967
 
@@ -670,12 +974,31 @@ in the sections above.
670
974
 
671
975
  ### `./health`
672
976
 
673
- | Export | Kind | Description |
674
- | ----------------------- | ---- | --------------------------------------------------- |
675
- | `IHealthIndicator` | type | The pluggable indicator contract. |
676
- | `HealthIndicatorResult` | type | The outcome of a single indicator check. |
677
- | `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
678
- | `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'`. |
679
1002
 
680
1003
  ## 🧩 Compatibility
681
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;