@bymax-one/nest-core 1.5.3 → 1.6.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/CHANGELOG.md +129 -1
- package/README.md +100 -12
- package/dist/health/index.d.cts +1 -0
- package/dist/health/index.d.ts +1 -0
- package/dist/health.transition-B_IgXJTz.d.cts +119 -0
- package/dist/health.transition-B_IgXJTz.d.ts +119 -0
- package/dist/index.cjs +212 -24
- package/dist/index.d.cts +23 -8
- package/dist/index.d.ts +23 -8
- package/dist/index.mjs +212 -25
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,133 @@ heading here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.6.0] - 2026-08-29
|
|
15
|
+
|
|
16
|
+
A readiness check that fails without rejecting left no record an operator would
|
|
17
|
+
ever read, and one that fails by rejecting left one per probe. Both are the same
|
|
18
|
+
missing seam, and this release adds it: the aggregator now holds the last state
|
|
19
|
+
of every check and reports each **change** — to its own logger, and to an
|
|
20
|
+
optional sink a consumer binds.
|
|
21
|
+
|
|
22
|
+
**Apply to a derived backend:** nothing is required. Transitions now reach Nest's
|
|
23
|
+
logger on every path, where before only a rejection did. Bind
|
|
24
|
+
`BYMAX_HEALTH_TRANSITION_SINK` to route them into a structured logging surface,
|
|
25
|
+
and read `cause.kind` to tell the three failure shapes apart. If you have written
|
|
26
|
+
a health reporter of your own, delete it — including any wrapper that races a
|
|
27
|
+
timer beneath `health.indicatorTimeoutMs` to recover a verdict, which this seam
|
|
28
|
+
makes unnecessary.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`IHealthTransitionSink`, bound under `BYMAX_HEALTH_TRANSITION_SINK`.**
|
|
33
|
+
Receives one `HealthTransition` per change of readiness state, per check name —
|
|
34
|
+
never one per probe. `@Optional()` and unbound by default, like
|
|
35
|
+
`BYMAX_HEALTH_INDICATORS`, so a consumer's binding is never shadowed by a local
|
|
36
|
+
one.
|
|
37
|
+
|
|
38
|
+
With no sink the aggregator writes the transition to Nest's logger, so a
|
|
39
|
+
readiness failure is never silent by default; binding one stands that line down
|
|
40
|
+
in favour of the sink. Both destinations are usually the same logger in a
|
|
41
|
+
consuming application, and two records of one transition side by side is the
|
|
42
|
+
noise this feature exists to remove. The sink is handed the cause as structured
|
|
43
|
+
data, strictly more than the line renders, so what reaches the log after that
|
|
44
|
+
is the consumer's decision rather than this package's.
|
|
45
|
+
|
|
46
|
+
The de-duplication rule lives in the aggregator rather than in the sink, and
|
|
47
|
+
that is the whole point. A readiness check runs every few seconds, so a line
|
|
48
|
+
per failing probe turns one outage into thousands of identical records that
|
|
49
|
+
bury the one carrying the cause; leaving that rule to each consumer means every
|
|
50
|
+
backend re-deriving it, slightly differently. A sink that never sees raw
|
|
51
|
+
outcomes cannot get it wrong.
|
|
52
|
+
|
|
53
|
+
Overlapping probes are ordered by when they started, not by when they
|
|
54
|
+
finished. Readiness is not called one at a time — an orchestrator's probe and a
|
|
55
|
+
load balancer's health check reach it concurrently — and a dependency that
|
|
56
|
+
hangs until the bound elapses is exactly what makes an earlier probe finish
|
|
57
|
+
last. Comparing states alone would write such an outage backwards: the later
|
|
58
|
+
probe reports the recovery, and the earlier one's timeout lands behind it and
|
|
59
|
+
reports the dependency down again on evidence that is already stale.
|
|
60
|
+
|
|
61
|
+
The rule is asymmetric on a first observation, deliberately. A first
|
|
62
|
+
observation that is **failing** is reported — a process that boots against a
|
|
63
|
+
dependency already down would otherwise look healthy in the log forever — while
|
|
64
|
+
a first observation that is healthy is not, since announcing the expected state
|
|
65
|
+
would write one line per dependency on every boot.
|
|
66
|
+
|
|
67
|
+
- **`HealthTransitionCause`, distinguishing the three ways a check is down.**
|
|
68
|
+
`reported-down` (the indicator answered and said so), `rejected` (carrying the
|
|
69
|
+
summarized message, bounded to 300 characters), and `timed-out` (carrying the
|
|
70
|
+
`timeoutMs` that elapsed). A discriminated union rather than a string, so a
|
|
71
|
+
consumer switches exhaustively and the compiler names the arm it has not
|
|
72
|
+
handled if a future version adds one.
|
|
73
|
+
|
|
74
|
+
`timed-out` is the one that exists nowhere else. An indicator this package
|
|
75
|
+
gave up on is never told, so it reports nothing, and a consumer racing its own
|
|
76
|
+
timer beneath `indicatorTimeoutMs` still never learns the bound that actually
|
|
77
|
+
applied. A hung dependency is also the common shape rather than a refused one:
|
|
78
|
+
a database under load, a network partition and a paused container all hang,
|
|
79
|
+
while a refusal returns immediately and would have been reported. The obstacle
|
|
80
|
+
for a consumer is information, not effort — which is the argument for this
|
|
81
|
+
living in the library at all.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **An `Error` whose `message` is not a string broke the readiness aggregation.**
|
|
86
|
+
`message` is a writable property, so `Object.assign(new Error(), { message:
|
|
87
|
+
null })` is an `Error` that reads without throwing and then throws on every
|
|
88
|
+
string operation. The summarizer coerced a non-`Error` reason but trusted an
|
|
89
|
+
`Error`'s own `message`, then measured and truncated it outside that guard.
|
|
90
|
+
|
|
91
|
+
It surfaced where the guard was supposed to hold. Summarizing runs inside the
|
|
92
|
+
rejection-to-`down` conversion, so an indicator rejecting with such an error
|
|
93
|
+
rejected the whole aggregation: the probe answered `500` instead of `503`, and
|
|
94
|
+
reported nothing about the dependencies that were healthy — the exact outcome
|
|
95
|
+
the conversion exists to prevent.
|
|
96
|
+
|
|
97
|
+
**This is present in 1.5.3 and earlier**, on the indicator path. The transition
|
|
98
|
+
sink added in this release reaches the same summarizer, so the fix covers both.
|
|
99
|
+
|
|
100
|
+
- **An `async` timing sink could take the process down.** `ITimingSink.record`
|
|
101
|
+
is declared to return `void`, and TypeScript accepts any return value in a
|
|
102
|
+
void-returning position, so `async record()` compiles — and it is the natural
|
|
103
|
+
shape when the backend behind the sink is async. Its rejection settled a
|
|
104
|
+
microtask after the recorder's `try`/`catch` had exited, so instead of the
|
|
105
|
+
contained failure the contract promises it became an unhandled rejection, able
|
|
106
|
+
to kill the process under `--unhandled-rejections=strict`. An observer that can
|
|
107
|
+
break what it observes is the one thing a fire-and-forget contract exists to
|
|
108
|
+
rule out.
|
|
109
|
+
|
|
110
|
+
Both the middleware and the deprecated interceptor were affected, and both now
|
|
111
|
+
route delivery through one implementation rather than two copies of the same
|
|
112
|
+
`try`/`catch` — the containment guarantee is worth exactly as much as its least
|
|
113
|
+
careful copy. Nothing to change in a consumer: a sink that already returned
|
|
114
|
+
synchronously behaves identically, and an `async` one is now caught.
|
|
115
|
+
|
|
116
|
+
Found while reviewing the health transition sink, which had the same hole
|
|
117
|
+
before release.
|
|
118
|
+
|
|
119
|
+
### Changed
|
|
120
|
+
|
|
121
|
+
- **A rejecting indicator is logged once per outage, not once per probe.** The
|
|
122
|
+
aggregator already wrote a warning for a rejection, on every readiness check
|
|
123
|
+
for as long as the dependency stayed down: roughly sixty identical lines for a
|
|
124
|
+
ten-minute outage probed every ten seconds. That line now follows the
|
|
125
|
+
transition rule like every other path. No API moves; log volume does, and a
|
|
126
|
+
recovery now writes a line where nothing did before.
|
|
127
|
+
|
|
128
|
+
This arrives with the upgrade, not with the binding — a deployment that wires
|
|
129
|
+
no sink still stops repeating.
|
|
130
|
+
|
|
131
|
+
The cost is stated rather than hidden: a dependency that stays down while its
|
|
132
|
+
failure mode changes underneath keeps the cause observed **at the transition**.
|
|
133
|
+
That is the trade for one line per outage instead of one per probe.
|
|
134
|
+
|
|
135
|
+
- **The root bundle's size budget moved from 15 to 17 KiB brotli**, measured
|
|
136
|
+
14.25 → 15.82. Checked before the number moved: the transition contract is
|
|
137
|
+
types-only and erases at build time, no module entered the root that was not
|
|
138
|
+
already there, and the rationale prose was moved into the erased file — this
|
|
139
|
+
bundle ships its comments — before the budget was touched.
|
|
140
|
+
|
|
14
141
|
## [1.5.3] - 2026-08-18
|
|
15
142
|
|
|
16
143
|
Three findings from a functional and security audit of a derived backend
|
|
@@ -875,4 +1002,5 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
875
1002
|
[1.5.1]: https://github.com/bymaxone/nest-core/compare/v1.5.0...v1.5.1
|
|
876
1003
|
[1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
|
|
877
1004
|
[1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
|
|
878
|
-
[
|
|
1005
|
+
[1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
|
|
1006
|
+
[Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.0...HEAD
|
package/README.md
CHANGED
|
@@ -515,9 +515,9 @@ requirement.
|
|
|
515
515
|
|
|
516
516
|
## 🔑 DI Tokens
|
|
517
517
|
|
|
518
|
-
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER
|
|
519
|
-
`BYMAX_HEALTH_INDICATORS`
|
|
520
|
-
the module: provide either from your own module to supply your own
|
|
518
|
+
Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
|
|
519
|
+
`BYMAX_HEALTH_INDICATORS` and `BYMAX_HEALTH_TRANSITION_SINK` are consumed with
|
|
520
|
+
`@Optional()` and are not bound by the module: provide either from your own module to supply your own
|
|
521
521
|
implementation, otherwise the internal fallback in the last column applies.
|
|
522
522
|
`BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
|
|
523
523
|
`forRootAsync`, where options resolve after the module is defined: there the
|
|
@@ -528,14 +528,15 @@ consumer `BYMAX_TIMING_SINK` override is honored on `forRoot` but shadowed on
|
|
|
528
528
|
[Integration with `@bymax-one/nest-logger`](#-integration-with-bymax-onenest-logger)
|
|
529
529
|
below.
|
|
530
530
|
|
|
531
|
-
| Token
|
|
532
|
-
|
|
|
533
|
-
| `BYMAX_CORE_OPTIONS`
|
|
534
|
-
| `BYMAX_CORRELATION_PROVIDER`
|
|
535
|
-
| `BYMAX_TIMING_SINK`
|
|
536
|
-
| `BYMAX_HEALTH_INDICATORS`
|
|
537
|
-
| `
|
|
538
|
-
| `
|
|
531
|
+
| Token | Provides | When you do not provide one |
|
|
532
|
+
| ------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
533
|
+
| `BYMAX_CORE_OPTIONS` | The resolved `BymaxCoreModuleOptions` | always set by the module |
|
|
534
|
+
| `BYMAX_CORRELATION_PROVIDER` | `ICorrelationIdProvider` | internal no-op (omits `correlationId`) |
|
|
535
|
+
| `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
|
|
536
|
+
| `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
|
|
537
|
+
| `BYMAX_HEALTH_TRANSITION_SINK` | `IHealthTransitionSink` | no sink; readiness transitions go to Nest's logger instead (binding one stands that line down) |
|
|
538
|
+
| `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
|
|
539
|
+
| `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
|
|
539
540
|
|
|
540
541
|
## 🚨 Error Envelope
|
|
541
542
|
|
|
@@ -757,6 +758,13 @@ class LoggerTimingSink implements ITimingSink {
|
|
|
757
758
|
export class ObservabilityModule {}
|
|
758
759
|
```
|
|
759
760
|
|
|
761
|
+
A sink that fails cannot reach the request: both a synchronous throw and a
|
|
762
|
+
rejection from an `async record()` are absorbed. Write it `async` if the backend
|
|
763
|
+
behind it is async — the `void` return type accepts it, and the rejection is
|
|
764
|
+
caught. The failure is swallowed rather than logged, unlike a health transition
|
|
765
|
+
sink, because this runs on every request and a systematically failing sink would
|
|
766
|
+
otherwise become a second flood beside the first.
|
|
767
|
+
|
|
760
768
|
## 📄 Pagination
|
|
761
769
|
|
|
762
770
|
Framework-neutral, pure functions on the `./pagination` subpath: no NestJS
|
|
@@ -900,6 +908,82 @@ A rejecting, throwing, or slow indicator (past `indicatorTimeoutMs`) is
|
|
|
900
908
|
converted to a `down` entry with a safe, bounded diagnostic detail; it never
|
|
901
909
|
hides the results of the other registered indicators.
|
|
902
910
|
|
|
911
|
+
### A failing check leaves a record
|
|
912
|
+
|
|
913
|
+
A readiness failure that nothing records is a `503` an orchestrator acts on with
|
|
914
|
+
an empty log behind it, and diagnosing it means reproducing it. That is easy to
|
|
915
|
+
arrive at without deciding to: probe paths are the highest-volume request a
|
|
916
|
+
backend serves, so they are usually excluded from the HTTP log surface, and a
|
|
917
|
+
well-written indicator returns `{ status: 'down' }` rather than throwing —
|
|
918
|
+
because readiness is typically unauthenticated and a driver's error carries
|
|
919
|
+
hosts, ports and sometimes credentials.
|
|
920
|
+
|
|
921
|
+
So the aggregator records it. It holds the last state of every check and reports
|
|
922
|
+
each **change** — never once per probe — to its own logger, and to a sink you
|
|
923
|
+
bind:
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
import { Injectable } from '@nestjs/common'
|
|
927
|
+
import type { HealthTransition, IHealthTransitionSink } from '@bymax-one/nest-core/health'
|
|
928
|
+
|
|
929
|
+
@Injectable()
|
|
930
|
+
export class HealthTransitionLogger implements IHealthTransitionSink {
|
|
931
|
+
constructor(private readonly logger: MyStructuredLogger) {}
|
|
932
|
+
|
|
933
|
+
record(transition: HealthTransition): void {
|
|
934
|
+
if (transition.isUp) {
|
|
935
|
+
this.logger.info('HEALTH_CHECK_RECOVERED', { check: transition.name })
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
this.logger.warn('HEALTH_CHECK_DEGRADED', {
|
|
939
|
+
check: transition.name,
|
|
940
|
+
cause: transition.cause.kind
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
Bind it under `BYMAX_HEALTH_TRANSITION_SINK` from your own `@Global()` module,
|
|
947
|
+
the same override pattern as the indicator token above.
|
|
948
|
+
|
|
949
|
+
Binding nothing is supported, and is not the silent case: the transitions reach
|
|
950
|
+
Nest's logger instead, so a readiness failure always leaves a record. **Binding a
|
|
951
|
+
sink stands that line down** — both destinations are usually the same logger, so
|
|
952
|
+
keeping it would put two records of one transition side by side, which is the
|
|
953
|
+
noise this feature exists to remove. Your sink receives the cause as structured
|
|
954
|
+
data, strictly more than the line renders, so what reaches the log after that is
|
|
955
|
+
your decision rather than this package's.
|
|
956
|
+
|
|
957
|
+
`transition.cause` distinguishes the three ways a check can be down, which the
|
|
958
|
+
`up`/`down` response body cannot:
|
|
959
|
+
|
|
960
|
+
| `cause.kind` | Meaning | Carries |
|
|
961
|
+
| --------------- | -------------------------------------------------- | ----------- |
|
|
962
|
+
| `reported-down` | The indicator answered, and reported it down. | — |
|
|
963
|
+
| `rejected` | The indicator rejected or threw. | `message` |
|
|
964
|
+
| `timed-out` | The aggregator gave up after `indicatorTimeoutMs`. | `timeoutMs` |
|
|
965
|
+
|
|
966
|
+
`timed-out` is the one that cannot be observed anywhere else. An indicator the
|
|
967
|
+
aggregator abandoned is never told, so it reports nothing — and a hung
|
|
968
|
+
dependency is the common shape, not a refused one: a database under load, a
|
|
969
|
+
network partition and a paused container all hang, while a refusal comes back
|
|
970
|
+
immediately and would have been reported. That is why the rule lives in the
|
|
971
|
+
aggregator rather than in each backend: the obstacle is information, not effort.
|
|
972
|
+
|
|
973
|
+
Two details worth knowing before you rely on it. A first observation that is
|
|
974
|
+
**failing** is reported, while a first observation that is healthy is not — a
|
|
975
|
+
process that boots against a dependency already down would otherwise look
|
|
976
|
+
healthy in the log forever, whereas announcing every healthy check would write a
|
|
977
|
+
line per dependency on every boot. And the cause is the one seen **at the
|
|
978
|
+
transition**: a dependency that stays down while its failure mode changes
|
|
979
|
+
underneath keeps the first cause, which is the cost of one line per outage
|
|
980
|
+
instead of one per probe.
|
|
981
|
+
|
|
982
|
+
The sink is called synchronously on the readiness path, so keep it cheap. A
|
|
983
|
+
throw is caught and logged rather than failing the probe — readiness answering
|
|
984
|
+
`500` because its own logging broke would take a healthy deployment out of
|
|
985
|
+
rotation — but do not use that as flow control.
|
|
986
|
+
|
|
903
987
|
### Discovered indicators
|
|
904
988
|
|
|
905
989
|
Registering every indicator by hand stops scaling once the libraries an
|
|
@@ -1636,7 +1720,8 @@ in the sections above.
|
|
|
1636
1720
|
| `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
|
|
1637
1721
|
| `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
|
|
1638
1722
|
| `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
|
|
1639
|
-
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_METRICS_REGISTRY`
|
|
1723
|
+
| `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
|
|
1724
|
+
| `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
|
|
1640
1725
|
| `ICorrelationIdProvider` | type | The correlation-provider contract. |
|
|
1641
1726
|
| `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
|
|
1642
1727
|
| `BymaxExceptionFilter` | class | The envelope exception filter. |
|
|
@@ -1667,6 +1752,9 @@ in the sections above.
|
|
|
1667
1752
|
| `HealthIndicatorResult` | type | The outcome of a single indicator check. |
|
|
1668
1753
|
| `HealthCheckEntry` | type | One named entry in a `HealthResponse.checks` array. |
|
|
1669
1754
|
| `HealthResponse` | type | The stable liveness and readiness response shape. |
|
|
1755
|
+
| `IHealthTransitionSink` | type | Receives one event per change of readiness state. |
|
|
1756
|
+
| `HealthTransition` | type | One change of state, for one check. |
|
|
1757
|
+
| `HealthTransitionCause` | type | Why a check went down, as the aggregator sees it. |
|
|
1670
1758
|
| `BymaxHealthIndicator` | function | Class decorator marking a provider as discoverable. |
|
|
1671
1759
|
| `BYMAX_HEALTH_INDICATOR_METADATA` | constant | The metadata key the marker writes. |
|
|
1672
1760
|
|
package/dist/health/index.d.cts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CustomDecorator } from '@nestjs/common';
|
|
2
|
+
export { H as HealthTransition, a as HealthTransitionCause, I as IHealthTransitionSink } from '../health.transition-B_IgXJTz.cjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
|
package/dist/health/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CustomDecorator } from '@nestjs/common';
|
|
2
|
+
export { H as HealthTransition, a as HealthTransitionCause, I as IHealthTransitionSink } from '../health.transition-B_IgXJTz.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Reflect metadata key carrying the discoverable marker. Namespaced so it cannot
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The readiness transition contract: a sink that receives one
|
|
3
|
+
* event each time a check changes state, and the cause that says why.
|
|
4
|
+
*
|
|
5
|
+
* This exists because a readiness failure can otherwise leave no record an
|
|
6
|
+
* operator will ever read. There are three ways for a check to be down, and the
|
|
7
|
+
* aggregator is the only place that can tell them apart: the indicator rejected,
|
|
8
|
+
* the indicator reported `down` on purpose, or the aggregator gave up waiting.
|
|
9
|
+
* The third is knowledge that exists nowhere else — an indicator the aggregator
|
|
10
|
+
* abandoned is never told, so it cannot report anything — and it is the shape a
|
|
11
|
+
* hung dependency takes. A database under load, a network partition and a paused
|
|
12
|
+
* container all hang; a refusal comes back immediately.
|
|
13
|
+
*
|
|
14
|
+
* Two decisions elsewhere combine to make that silence total in a typical
|
|
15
|
+
* deployment, and each is right on its own. Probe paths are excluded from the
|
|
16
|
+
* HTTP log surface, because probes are the highest-volume request a backend
|
|
17
|
+
* serves. And a well-written indicator returns `{ status: 'down' }` rather than
|
|
18
|
+
* throwing, because readiness is usually unauthenticated and a driver's error
|
|
19
|
+
* carries hosts, ports and sometimes credentials — which is exactly the row this
|
|
20
|
+
* package's own contract steers implementations toward.
|
|
21
|
+
*
|
|
22
|
+
* **Transitions, not outcomes.** The sink is called once per *change* of state
|
|
23
|
+
* per check name, never once per probe. That rule lives in the aggregator rather
|
|
24
|
+
* than in the sink deliberately: a readiness check runs every few seconds, so a
|
|
25
|
+
* line per failure turns one outage into thousands of identical records that
|
|
26
|
+
* bury the one carrying the cause. Leaving the de-duplication to each consumer
|
|
27
|
+
* would mean every backend re-deriving the same rule, and re-deriving it subtly
|
|
28
|
+
* differently. A sink that never sees raw outcomes cannot get it wrong.
|
|
29
|
+
* @layer Contract
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Why a check is down, as only the aggregator can distinguish it.
|
|
33
|
+
*
|
|
34
|
+
* A discriminated union rather than a string, so a consumer switches
|
|
35
|
+
* exhaustively and the compiler reports the arm it has not handled when a future
|
|
36
|
+
* version adds one. "The dependency answered that it is down" and "the
|
|
37
|
+
* dependency accepted the work and never answered" are different outages calling
|
|
38
|
+
* for different responses, and the readiness body cannot tell them apart because
|
|
39
|
+
* it carries only `up` or `down`.
|
|
40
|
+
*/
|
|
41
|
+
type HealthTransitionCause = {
|
|
42
|
+
/** The indicator rejected, or threw synchronously. */
|
|
43
|
+
readonly kind: 'rejected';
|
|
44
|
+
/**
|
|
45
|
+
* The rejection's top-level message, truncated to 300 characters. Never
|
|
46
|
+
* the raw error, its stack, or any nested cause.
|
|
47
|
+
*
|
|
48
|
+
* This reaches the sink whether or not `health.exposeIndicatorErrors` is
|
|
49
|
+
* set: that option governs what is written into the HTTP response, which
|
|
50
|
+
* is typically unauthenticated, while a sink is application-side code at
|
|
51
|
+
* the same trust level as the logger. An indicator usually does not author
|
|
52
|
+
* this text — it lets a driver's error propagate — so treat it as
|
|
53
|
+
* potentially carrying hosts and ports, and send it where access is
|
|
54
|
+
* already controlled.
|
|
55
|
+
*/
|
|
56
|
+
readonly message: string;
|
|
57
|
+
} | {
|
|
58
|
+
/** The indicator answered, and reported its dependency unhealthy. */
|
|
59
|
+
readonly kind: 'reported-down';
|
|
60
|
+
} | {
|
|
61
|
+
/** The aggregator gave up waiting for the indicator. */
|
|
62
|
+
readonly kind: 'timed-out';
|
|
63
|
+
/** The bound that elapsed, from `health.indicatorTimeoutMs`. */
|
|
64
|
+
readonly timeoutMs: number;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* One change of state for one check.
|
|
68
|
+
*
|
|
69
|
+
* A discriminated union on `isUp` rather than an object with an optional cause,
|
|
70
|
+
* so the compiler guarantees that a transition to `down` carries one and that a
|
|
71
|
+
* transition to `up` does not — a recovery has no cause to describe.
|
|
72
|
+
*/
|
|
73
|
+
type HealthTransition = {
|
|
74
|
+
/** The check's name, as {@link IHealthIndicator.name} declares it. */
|
|
75
|
+
readonly name: string;
|
|
76
|
+
/** The dependency is reachable again. */
|
|
77
|
+
readonly isUp: true;
|
|
78
|
+
} | {
|
|
79
|
+
/** The check's name, as {@link IHealthIndicator.name} declares it. */
|
|
80
|
+
readonly name: string;
|
|
81
|
+
/** The dependency is not reachable. */
|
|
82
|
+
readonly isUp: false;
|
|
83
|
+
/** Why, as far as the aggregator can tell. */
|
|
84
|
+
readonly cause: HealthTransitionCause;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Receives one event per change of readiness state, per check.
|
|
88
|
+
*
|
|
89
|
+
* Bind an implementation under `BYMAX_HEALTH_TRANSITION_SINK` from a module of
|
|
90
|
+
* your own marked `@Global()`. Nothing is bound by default, and with no sink the
|
|
91
|
+
* aggregator writes its own transition lines to Nest's logger, so a readiness
|
|
92
|
+
* failure is never silent.
|
|
93
|
+
*
|
|
94
|
+
* Binding a sink stands that line down. Both destinations are usually the same
|
|
95
|
+
* logger in a consuming application, so keeping it would put two records of one
|
|
96
|
+
* transition side by side — the noise this feature exists to remove. The sink is
|
|
97
|
+
* handed the cause as structured data, strictly more than the line renders, so
|
|
98
|
+
* what reaches the log after that is the consumer's decision.
|
|
99
|
+
*
|
|
100
|
+
* Called synchronously from the readiness path, so an implementation must be
|
|
101
|
+
* cheap and must not block: hand the event to a logger and return. A throw is
|
|
102
|
+
* caught and reported by the aggregator rather than failing the probe — a
|
|
103
|
+
* readiness endpoint that answers `500` because its *logging* broke would take a
|
|
104
|
+
* healthy deployment out of rotation — but do not rely on that as flow control.
|
|
105
|
+
*
|
|
106
|
+
* Takes a single object, matching `ITimingSink.record`, so a later field is an
|
|
107
|
+
* additive change rather than a new positional parameter.
|
|
108
|
+
*/
|
|
109
|
+
interface IHealthTransitionSink {
|
|
110
|
+
/**
|
|
111
|
+
* Record one change of readiness state.
|
|
112
|
+
*
|
|
113
|
+
* @param transition - The check that changed, its new state, and the cause
|
|
114
|
+
* when it went down.
|
|
115
|
+
*/
|
|
116
|
+
record(transition: HealthTransition): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type { HealthTransition as H, IHealthTransitionSink as I, HealthTransitionCause as a };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The readiness transition contract: a sink that receives one
|
|
3
|
+
* event each time a check changes state, and the cause that says why.
|
|
4
|
+
*
|
|
5
|
+
* This exists because a readiness failure can otherwise leave no record an
|
|
6
|
+
* operator will ever read. There are three ways for a check to be down, and the
|
|
7
|
+
* aggregator is the only place that can tell them apart: the indicator rejected,
|
|
8
|
+
* the indicator reported `down` on purpose, or the aggregator gave up waiting.
|
|
9
|
+
* The third is knowledge that exists nowhere else — an indicator the aggregator
|
|
10
|
+
* abandoned is never told, so it cannot report anything — and it is the shape a
|
|
11
|
+
* hung dependency takes. A database under load, a network partition and a paused
|
|
12
|
+
* container all hang; a refusal comes back immediately.
|
|
13
|
+
*
|
|
14
|
+
* Two decisions elsewhere combine to make that silence total in a typical
|
|
15
|
+
* deployment, and each is right on its own. Probe paths are excluded from the
|
|
16
|
+
* HTTP log surface, because probes are the highest-volume request a backend
|
|
17
|
+
* serves. And a well-written indicator returns `{ status: 'down' }` rather than
|
|
18
|
+
* throwing, because readiness is usually unauthenticated and a driver's error
|
|
19
|
+
* carries hosts, ports and sometimes credentials — which is exactly the row this
|
|
20
|
+
* package's own contract steers implementations toward.
|
|
21
|
+
*
|
|
22
|
+
* **Transitions, not outcomes.** The sink is called once per *change* of state
|
|
23
|
+
* per check name, never once per probe. That rule lives in the aggregator rather
|
|
24
|
+
* than in the sink deliberately: a readiness check runs every few seconds, so a
|
|
25
|
+
* line per failure turns one outage into thousands of identical records that
|
|
26
|
+
* bury the one carrying the cause. Leaving the de-duplication to each consumer
|
|
27
|
+
* would mean every backend re-deriving the same rule, and re-deriving it subtly
|
|
28
|
+
* differently. A sink that never sees raw outcomes cannot get it wrong.
|
|
29
|
+
* @layer Contract
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Why a check is down, as only the aggregator can distinguish it.
|
|
33
|
+
*
|
|
34
|
+
* A discriminated union rather than a string, so a consumer switches
|
|
35
|
+
* exhaustively and the compiler reports the arm it has not handled when a future
|
|
36
|
+
* version adds one. "The dependency answered that it is down" and "the
|
|
37
|
+
* dependency accepted the work and never answered" are different outages calling
|
|
38
|
+
* for different responses, and the readiness body cannot tell them apart because
|
|
39
|
+
* it carries only `up` or `down`.
|
|
40
|
+
*/
|
|
41
|
+
type HealthTransitionCause = {
|
|
42
|
+
/** The indicator rejected, or threw synchronously. */
|
|
43
|
+
readonly kind: 'rejected';
|
|
44
|
+
/**
|
|
45
|
+
* The rejection's top-level message, truncated to 300 characters. Never
|
|
46
|
+
* the raw error, its stack, or any nested cause.
|
|
47
|
+
*
|
|
48
|
+
* This reaches the sink whether or not `health.exposeIndicatorErrors` is
|
|
49
|
+
* set: that option governs what is written into the HTTP response, which
|
|
50
|
+
* is typically unauthenticated, while a sink is application-side code at
|
|
51
|
+
* the same trust level as the logger. An indicator usually does not author
|
|
52
|
+
* this text — it lets a driver's error propagate — so treat it as
|
|
53
|
+
* potentially carrying hosts and ports, and send it where access is
|
|
54
|
+
* already controlled.
|
|
55
|
+
*/
|
|
56
|
+
readonly message: string;
|
|
57
|
+
} | {
|
|
58
|
+
/** The indicator answered, and reported its dependency unhealthy. */
|
|
59
|
+
readonly kind: 'reported-down';
|
|
60
|
+
} | {
|
|
61
|
+
/** The aggregator gave up waiting for the indicator. */
|
|
62
|
+
readonly kind: 'timed-out';
|
|
63
|
+
/** The bound that elapsed, from `health.indicatorTimeoutMs`. */
|
|
64
|
+
readonly timeoutMs: number;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* One change of state for one check.
|
|
68
|
+
*
|
|
69
|
+
* A discriminated union on `isUp` rather than an object with an optional cause,
|
|
70
|
+
* so the compiler guarantees that a transition to `down` carries one and that a
|
|
71
|
+
* transition to `up` does not — a recovery has no cause to describe.
|
|
72
|
+
*/
|
|
73
|
+
type HealthTransition = {
|
|
74
|
+
/** The check's name, as {@link IHealthIndicator.name} declares it. */
|
|
75
|
+
readonly name: string;
|
|
76
|
+
/** The dependency is reachable again. */
|
|
77
|
+
readonly isUp: true;
|
|
78
|
+
} | {
|
|
79
|
+
/** The check's name, as {@link IHealthIndicator.name} declares it. */
|
|
80
|
+
readonly name: string;
|
|
81
|
+
/** The dependency is not reachable. */
|
|
82
|
+
readonly isUp: false;
|
|
83
|
+
/** Why, as far as the aggregator can tell. */
|
|
84
|
+
readonly cause: HealthTransitionCause;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Receives one event per change of readiness state, per check.
|
|
88
|
+
*
|
|
89
|
+
* Bind an implementation under `BYMAX_HEALTH_TRANSITION_SINK` from a module of
|
|
90
|
+
* your own marked `@Global()`. Nothing is bound by default, and with no sink the
|
|
91
|
+
* aggregator writes its own transition lines to Nest's logger, so a readiness
|
|
92
|
+
* failure is never silent.
|
|
93
|
+
*
|
|
94
|
+
* Binding a sink stands that line down. Both destinations are usually the same
|
|
95
|
+
* logger in a consuming application, so keeping it would put two records of one
|
|
96
|
+
* transition side by side — the noise this feature exists to remove. The sink is
|
|
97
|
+
* handed the cause as structured data, strictly more than the line renders, so
|
|
98
|
+
* what reaches the log after that is the consumer's decision.
|
|
99
|
+
*
|
|
100
|
+
* Called synchronously from the readiness path, so an implementation must be
|
|
101
|
+
* cheap and must not block: hand the event to a logger and return. A throw is
|
|
102
|
+
* caught and reported by the aggregator rather than failing the probe — a
|
|
103
|
+
* readiness endpoint that answers `500` because its *logging* broke would take a
|
|
104
|
+
* healthy deployment out of rotation — but do not rely on that as flow control.
|
|
105
|
+
*
|
|
106
|
+
* Takes a single object, matching `ITimingSink.record`, so a later field is an
|
|
107
|
+
* additive change rather than a new positional parameter.
|
|
108
|
+
*/
|
|
109
|
+
interface IHealthTransitionSink {
|
|
110
|
+
/**
|
|
111
|
+
* Record one change of readiness state.
|
|
112
|
+
*
|
|
113
|
+
* @param transition - The check that changed, its new state, and the cause
|
|
114
|
+
* when it went down.
|
|
115
|
+
*/
|
|
116
|
+
record(transition: HealthTransition): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type { HealthTransition as H, IHealthTransitionSink as I, HealthTransitionCause as a };
|