@bymax-one/nest-core 1.5.2 → 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 +225 -1
- package/README.md +225 -14
- 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 +218 -27
- package/dist/index.d.cts +29 -11
- package/dist/index.d.ts +29 -11
- package/dist/index.mjs +218 -28
- package/dist/openapi/index.cjs +41 -17
- package/dist/openapi/index.mjs +41 -17
- package/dist/pagination/index.cjs +11 -1
- package/dist/pagination/index.d.cts +32 -3
- package/dist/pagination/index.d.ts +32 -3
- package/dist/pagination/index.mjs +11 -1
- package/package.json +1 -1
|
@@ -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 };
|
package/dist/index.cjs
CHANGED
|
@@ -157,6 +157,9 @@ var BYMAX_TIMING_SINK = /* @__PURE__ */ Symbol.for("@bymax-one/nest-core:timing-
|
|
|
157
157
|
var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
|
|
158
158
|
"@bymax-one/nest-core:health-indicators"
|
|
159
159
|
);
|
|
160
|
+
var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
|
|
161
|
+
"@bymax-one/nest-core:health-transition-sink"
|
|
162
|
+
);
|
|
160
163
|
var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
|
|
161
164
|
"@bymax-one/nest-core:metrics-registry"
|
|
162
165
|
);
|
|
@@ -636,6 +639,13 @@ function selectAsyncExceptionFilter(options, correlation, adapterHost) {
|
|
|
636
639
|
return options.envelope.enabled ? new exports.BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
|
|
637
640
|
}
|
|
638
641
|
|
|
642
|
+
// src/contain-rejection.ts
|
|
643
|
+
function containRejection(returned, onFailure) {
|
|
644
|
+
if (typeof returned?.then === "function") {
|
|
645
|
+
Promise.resolve(returned).catch(onFailure);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
639
649
|
// src/discovery.ts
|
|
640
650
|
function labelFor(className, token) {
|
|
641
651
|
return className === "" ? String(token) : className;
|
|
@@ -687,7 +697,7 @@ var TRUNCATION_ELLIPSIS = "...";
|
|
|
687
697
|
function summarizeRejection(reason) {
|
|
688
698
|
let message;
|
|
689
699
|
try {
|
|
690
|
-
message = reason instanceof Error ? reason.message :
|
|
700
|
+
message = String(reason instanceof Error ? reason.message : reason);
|
|
691
701
|
} catch {
|
|
692
702
|
message = "Unknown error";
|
|
693
703
|
}
|
|
@@ -696,17 +706,27 @@ function summarizeRejection(reason) {
|
|
|
696
706
|
}
|
|
697
707
|
return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - TRUNCATION_ELLIPSIS.length)}${TRUNCATION_ELLIPSIS}`;
|
|
698
708
|
}
|
|
699
|
-
async function runIndicator(indicator, timeoutMs, exposeErrors
|
|
709
|
+
async function runIndicator(indicator, timeoutMs, exposeErrors) {
|
|
700
710
|
let timer;
|
|
701
711
|
const timedOut = new Promise((resolve) => {
|
|
702
712
|
timer = setTimeout(() => {
|
|
703
|
-
resolve({
|
|
713
|
+
resolve({
|
|
714
|
+
entry: { name: indicator.name, status: "down", details: { timedOutAfterMs: timeoutMs } },
|
|
715
|
+
isUp: false,
|
|
716
|
+
cause: { kind: "timed-out", timeoutMs }
|
|
717
|
+
});
|
|
704
718
|
}, timeoutMs);
|
|
705
719
|
});
|
|
706
|
-
const checked = Promise.resolve().then(() => indicator.check()).then((result) =>
|
|
720
|
+
const checked = Promise.resolve().then(() => indicator.check()).then((result) => {
|
|
721
|
+
const entry = { ...result, name: indicator.name };
|
|
722
|
+
return result.status === "up" ? { entry, isUp: true } : { entry, isUp: false, cause: { kind: "reported-down" } };
|
|
723
|
+
}).catch((reason) => {
|
|
707
724
|
const message = summarizeRejection(reason);
|
|
708
|
-
|
|
709
|
-
|
|
725
|
+
return {
|
|
726
|
+
entry: exposeErrors ? { name: indicator.name, status: "down", details: { error: message } } : { name: indicator.name, status: "down" },
|
|
727
|
+
isUp: false,
|
|
728
|
+
cause: { kind: "rejected", message }
|
|
729
|
+
};
|
|
710
730
|
});
|
|
711
731
|
try {
|
|
712
732
|
return await Promise.race([checked, timedOut]);
|
|
@@ -714,6 +734,16 @@ async function runIndicator(indicator, timeoutMs, exposeErrors, logger) {
|
|
|
714
734
|
clearTimeout(timer);
|
|
715
735
|
}
|
|
716
736
|
}
|
|
737
|
+
function describeCause(cause) {
|
|
738
|
+
switch (cause.kind) {
|
|
739
|
+
case "rejected":
|
|
740
|
+
return `the indicator rejected: ${cause.message}`;
|
|
741
|
+
case "timed-out":
|
|
742
|
+
return `the indicator did not answer within ${cause.timeoutMs}ms`;
|
|
743
|
+
case "reported-down":
|
|
744
|
+
return "the indicator reported it down";
|
|
745
|
+
}
|
|
746
|
+
}
|
|
717
747
|
var HealthService = class {
|
|
718
748
|
/**
|
|
719
749
|
* @param indicators - Every explicitly registered indicator; empty when none
|
|
@@ -728,18 +758,169 @@ var HealthService = class {
|
|
|
728
758
|
* without it when the feature is off.
|
|
729
759
|
* @param reflector - Nest's metadata reader, used to match the indicator
|
|
730
760
|
* marker. Optional for the same reason.
|
|
761
|
+
* @param transitionSink - Receives one event per change of readiness state.
|
|
762
|
+
* `@Optional()` and bound to nothing by default, for the same reason as the
|
|
763
|
+
* indicator array: a local default here would shadow a consumer's override.
|
|
731
764
|
*/
|
|
732
|
-
constructor(indicators = [], options, discovery, reflector) {
|
|
765
|
+
constructor(indicators = [], options, discovery, reflector, transitionSink) {
|
|
733
766
|
this.indicators = indicators;
|
|
734
767
|
this.options = options;
|
|
735
768
|
this.discovery = discovery;
|
|
736
769
|
this.reflector = reflector;
|
|
770
|
+
this.transitionSink = transitionSink;
|
|
737
771
|
/**
|
|
738
772
|
* Nest's own logger, scoped to this class. The failure reason of a `down`
|
|
739
773
|
* indicator is written here rather than into the HTTP response, so the
|
|
740
774
|
* diagnostic survives without being served to whoever can reach the probe.
|
|
741
775
|
*/
|
|
742
776
|
this.logger = new common.Logger(HealthService.name);
|
|
777
|
+
/**
|
|
778
|
+
* The last state observed per check name, absent until the first observation,
|
|
779
|
+
* tagged with the probe that observed it. Keyed by name, so one map serves
|
|
780
|
+
* every check and one dependency can neither trigger nor suppress another's
|
|
781
|
+
* line. This is the state that makes the difference between a line per change
|
|
782
|
+
* and a line per probe.
|
|
783
|
+
*/
|
|
784
|
+
this.lastState = /* @__PURE__ */ new Map();
|
|
785
|
+
/**
|
|
786
|
+
* Counts readiness probes, so an outcome can be ordered against what is
|
|
787
|
+
* already recorded. Readiness is not called one at a time: an orchestrator's
|
|
788
|
+
* probe and a load balancer's health check reach this concurrently, and this
|
|
789
|
+
* feature's own subject — a dependency that hangs until `indicatorTimeoutMs`
|
|
790
|
+
* elapses — is exactly what makes an earlier probe finish after a later one.
|
|
791
|
+
* Comparing states alone would then let the stale observation win.
|
|
792
|
+
*/
|
|
793
|
+
this.probeSequence = 0;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Report one check's current state, emitting only when it differs from the
|
|
797
|
+
* last state observed for that name.
|
|
798
|
+
*
|
|
799
|
+
* The asymmetry on a first observation is deliberate. A first observation that
|
|
800
|
+
* is FAILING is a transition: a process that boots against a dependency
|
|
801
|
+
* already down would otherwise look healthy in the log forever. A first
|
|
802
|
+
* observation that is HEALTHY is not: that is the expected state, and
|
|
803
|
+
* announcing it would write one line per dependency on every boot — the noise
|
|
804
|
+
* the probe-path exclusion exists to keep out.
|
|
805
|
+
*
|
|
806
|
+
* A cause is recorded at the transition, so a dependency that stays down while
|
|
807
|
+
* its failure mode changes underneath keeps the first one. That is the cost of
|
|
808
|
+
* one line per outage instead of one per probe.
|
|
809
|
+
*
|
|
810
|
+
* @param outcome - The indicator's resolved outcome for this probe.
|
|
811
|
+
* @param seq - The probe that produced it, from {@link probeSequence}.
|
|
812
|
+
*/
|
|
813
|
+
reportTransition(outcome, seq) {
|
|
814
|
+
const name = outcome.entry.name;
|
|
815
|
+
const previous = this.lastState.get(name);
|
|
816
|
+
if (previous !== void 0 && seq <= previous.seq) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const changed = previous?.isUp !== outcome.isUp;
|
|
820
|
+
this.lastState.set(name, { isUp: outcome.isUp, seq });
|
|
821
|
+
if (!changed) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (outcome.isUp) {
|
|
825
|
+
if (previous === void 0) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
this.describe(`Health check "${name}" recovered`, false);
|
|
829
|
+
this.emit({ name, isUp: true });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
this.describe(`Health check "${name}" went down: ${describeCause(outcome.cause)}`, true);
|
|
833
|
+
this.emit({ name, isUp: false, cause: outcome.cause });
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Write a transition to this package's own logger, unless a sink is bound —
|
|
837
|
+
* the reasoning for standing down is on `IHealthTransitionSink`.
|
|
838
|
+
*
|
|
839
|
+
* Down is a warning, not an error: an unreachable dependency is what readiness
|
|
840
|
+
* exists to route around, while `error` is what pages someone.
|
|
841
|
+
*
|
|
842
|
+
* @param message - The line to write.
|
|
843
|
+
* @param isDown - Whether it describes a check going down, which sets the level.
|
|
844
|
+
*/
|
|
845
|
+
describe(message, isDown) {
|
|
846
|
+
if (this.transitionSink !== void 0) {
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
this.writeLine(message, isDown);
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Write one line to this package's logger, absorbing a logger that fails.
|
|
853
|
+
*
|
|
854
|
+
* Nest's logger is replaceable, so what this calls is application code. It can
|
|
855
|
+
* throw — most plausibly for the very reason a transition is being reported at
|
|
856
|
+
* all, when the sink and the logger share a backend that is down. Uncontained
|
|
857
|
+
* that throw leaves the readiness path: synchronously it rejects the probe,
|
|
858
|
+
* and from the asynchronous sink handler it becomes an unhandled rejection.
|
|
859
|
+
* Either way a healthy deployment leaves rotation because its logging broke,
|
|
860
|
+
* which is the outcome this whole seam exists to prevent.
|
|
861
|
+
*
|
|
862
|
+
* The failure is swallowed rather than reported, because a logger is the last
|
|
863
|
+
* place a failure could be reported to. There is nowhere left to go.
|
|
864
|
+
*
|
|
865
|
+
* @param message - The line to write.
|
|
866
|
+
* @param asWarning - Whether to write it at warning level.
|
|
867
|
+
*/
|
|
868
|
+
writeLine(message, asWarning) {
|
|
869
|
+
try {
|
|
870
|
+
if (asWarning) {
|
|
871
|
+
this.logger.warn(message);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
this.logger.log(message);
|
|
875
|
+
} catch {
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Hand a transition to the consumer's sink, if one is bound. A failure is
|
|
880
|
+
* contained: readiness answering `500` because its own logging broke would
|
|
881
|
+
* take a healthy deployment out of rotation over an observability fault.
|
|
882
|
+
*
|
|
883
|
+
* Both ways a sink can fail are caught, for the same reason `runIndicator`
|
|
884
|
+
* defends against an indicator that throws synchronously — a public seam
|
|
885
|
+
* cannot assume the implementation behind it honors its own signature.
|
|
886
|
+
* `record` is declared to return `void`, but TypeScript accepts any return
|
|
887
|
+
* value in a void-returning position, so `async record()` compiles and is the
|
|
888
|
+
* shape a consumer reaches for when the logger it delegates to is async. Its
|
|
889
|
+
* rejection lands a microtask after the `try` block has exited, which is an
|
|
890
|
+
* unhandled rejection rather than the contained failure documented on the
|
|
891
|
+
* contract.
|
|
892
|
+
*
|
|
893
|
+
* Whatever comes back is assimilated with `Promise.resolve`, not tested with
|
|
894
|
+
* `instanceof Promise`: `Promise` is a per-realm binding, so an `async` sink
|
|
895
|
+
* defined in another realm returns a native promise that fails `instanceof`
|
|
896
|
+
* here, and a userland promise library's result is not an instance either.
|
|
897
|
+
* Assimilation is the language's own thenable test, and it is inert for the
|
|
898
|
+
* `undefined` an ordinary synchronous sink returns.
|
|
899
|
+
*
|
|
900
|
+
* @param transition - The event to deliver.
|
|
901
|
+
*/
|
|
902
|
+
emit(transition) {
|
|
903
|
+
if (this.transitionSink === void 0) {
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
containRejection(this.transitionSink.record(transition), (error) => {
|
|
908
|
+
this.reportSinkFailure(error);
|
|
909
|
+
});
|
|
910
|
+
} catch (error) {
|
|
911
|
+
this.reportSinkFailure(error);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Log a sink failure, without letting it reach the probe.
|
|
916
|
+
*
|
|
917
|
+
* @param error - Whatever the sink threw or rejected with.
|
|
918
|
+
*/
|
|
919
|
+
reportSinkFailure(error) {
|
|
920
|
+
this.writeLine(
|
|
921
|
+
`Health transition sink threw and was ignored: ${summarizeRejection(error)}`,
|
|
922
|
+
true
|
|
923
|
+
);
|
|
743
924
|
}
|
|
744
925
|
/**
|
|
745
926
|
* Resolve the readiness set once the whole container is instantiated.
|
|
@@ -801,11 +982,14 @@ var HealthService = class {
|
|
|
801
982
|
async checkReadiness() {
|
|
802
983
|
const timeoutMs = this.options.health.indicatorTimeoutMs;
|
|
803
984
|
const exposeErrors = this.options.health.exposeIndicatorErrors;
|
|
804
|
-
const
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
)
|
|
985
|
+
const seq = ++this.probeSequence;
|
|
986
|
+
const outcomes = await Promise.all(
|
|
987
|
+
this.resolveIndicators().map((indicator) => runIndicator(indicator, timeoutMs, exposeErrors))
|
|
808
988
|
);
|
|
989
|
+
for (const outcome of outcomes) {
|
|
990
|
+
this.reportTransition(outcome, seq);
|
|
991
|
+
}
|
|
992
|
+
const checks = outcomes.map((outcome) => outcome.entry);
|
|
809
993
|
const status = checks.every((check) => check.status === "up") ? "ok" : "error";
|
|
810
994
|
return { status, checks };
|
|
811
995
|
}
|
|
@@ -818,7 +1002,9 @@ HealthService = __decorateClass([
|
|
|
818
1002
|
__decorateParam(2, common.Optional()),
|
|
819
1003
|
__decorateParam(2, common.Inject(core.DiscoveryService)),
|
|
820
1004
|
__decorateParam(3, common.Optional()),
|
|
821
|
-
__decorateParam(3, common.Inject(core.Reflector))
|
|
1005
|
+
__decorateParam(3, common.Inject(core.Reflector)),
|
|
1006
|
+
__decorateParam(4, common.Optional()),
|
|
1007
|
+
__decorateParam(4, common.Inject(BYMAX_HEALTH_TRANSITION_SINK))
|
|
822
1008
|
], HealthService);
|
|
823
1009
|
|
|
824
1010
|
// src/health/health.controller.ts
|
|
@@ -1206,6 +1392,13 @@ function buildTimingSample(input) {
|
|
|
1206
1392
|
...trace !== void 0 ? { traceId: trace.traceId, spanId: trace.spanId } : {}
|
|
1207
1393
|
};
|
|
1208
1394
|
}
|
|
1395
|
+
function deliverSample(sink, sample) {
|
|
1396
|
+
try {
|
|
1397
|
+
containRejection(sink.record(sample), () => {
|
|
1398
|
+
});
|
|
1399
|
+
} catch {
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1209
1402
|
|
|
1210
1403
|
// src/timing/timing.middleware.ts
|
|
1211
1404
|
exports.BymaxTimingMiddleware = class BymaxTimingMiddleware {
|
|
@@ -1255,7 +1448,8 @@ exports.BymaxTimingMiddleware = class BymaxTimingMiddleware {
|
|
|
1255
1448
|
next();
|
|
1256
1449
|
}
|
|
1257
1450
|
/**
|
|
1258
|
-
* Build the sample and
|
|
1451
|
+
* Build the sample and deliver it. `deliverSample` absorbs whatever the sink
|
|
1452
|
+
* does with it, so a broken sink never reaches the request it observes.
|
|
1259
1453
|
*
|
|
1260
1454
|
* @param request - The framework request object.
|
|
1261
1455
|
* @param response - The framework response object.
|
|
@@ -1273,10 +1467,7 @@ exports.BymaxTimingMiddleware = class BymaxTimingMiddleware {
|
|
|
1273
1467
|
threshold: this.options.timing.slowRequestThresholdMs,
|
|
1274
1468
|
trace
|
|
1275
1469
|
});
|
|
1276
|
-
|
|
1277
|
-
this.sink.record(sample);
|
|
1278
|
-
} catch {
|
|
1279
|
-
}
|
|
1470
|
+
deliverSample(this.sink, sample);
|
|
1280
1471
|
}
|
|
1281
1472
|
};
|
|
1282
1473
|
exports.BymaxTimingMiddleware = __decorateClass([
|
|
@@ -1397,9 +1588,12 @@ exports.BymaxCoreModule = class BymaxCoreModule extends BymaxCoreModuleBase {
|
|
|
1397
1588
|
* it is scoped to, while `'{*splat}'` — the form Nest 11's migration guide
|
|
1398
1589
|
* prescribes for "all routes" — stops matching the prefixed root once an
|
|
1399
1590
|
* application calls `setGlobalPrefix`. That was reported as nest#14520 and
|
|
1400
|
-
* fixed by nest#14522, whose regression test covers Fastify; on
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1591
|
+
* fixed by nest#14522, whose regression test covers Fastify; on the Express
|
|
1592
|
+
* adapter the prefixed root still reaches no middleware while resolving to
|
|
1593
|
+
* `200`. Measured on `@nestjs/core` 11.1.28 and re-measured unchanged on
|
|
1594
|
+
* 11.2.1 — a minor release is exactly where this would plausibly have been
|
|
1595
|
+
* fixed, so the version this was last confirmed against is part of the
|
|
1596
|
+
* claim rather than a footnote to it.
|
|
1403
1597
|
*
|
|
1404
1598
|
* On Fastify the same `'/'` is an exact match rather than a mount — one of
|
|
1405
1599
|
* three requests reached the middleware — so the wildcard is the only form
|
|
@@ -1589,9 +1783,8 @@ exports.TimingInterceptor = class TimingInterceptor {
|
|
|
1589
1783
|
return error instanceof common.HttpException ? error.getStatus() : UNKNOWN_ERROR_STATUS;
|
|
1590
1784
|
}
|
|
1591
1785
|
/**
|
|
1592
|
-
* Build the sample and deliver it
|
|
1593
|
-
*
|
|
1594
|
-
* observing.
|
|
1786
|
+
* Build the sample and deliver it. `deliverSample` absorbs whatever the sink
|
|
1787
|
+
* does with it: a broken sink must never affect the request it observes.
|
|
1595
1788
|
*
|
|
1596
1789
|
* @param method - HTTP method of the request.
|
|
1597
1790
|
* @param route - Route template of the request.
|
|
@@ -1607,10 +1800,7 @@ exports.TimingInterceptor = class TimingInterceptor {
|
|
|
1607
1800
|
threshold: this.options.timing.slowRequestThresholdMs,
|
|
1608
1801
|
trace: readTraceContext(this.traceContext)
|
|
1609
1802
|
});
|
|
1610
|
-
|
|
1611
|
-
this.sink.record(sample);
|
|
1612
|
-
} catch {
|
|
1613
|
-
}
|
|
1803
|
+
deliverSample(this.sink, sample);
|
|
1614
1804
|
}
|
|
1615
1805
|
};
|
|
1616
1806
|
exports.TimingInterceptor = __decorateClass([
|
|
@@ -1632,6 +1822,7 @@ exports.BYMAX_CORRELATION_PROVIDER = BYMAX_CORRELATION_PROVIDER;
|
|
|
1632
1822
|
exports.BYMAX_FORBIDDEN = BYMAX_FORBIDDEN;
|
|
1633
1823
|
exports.BYMAX_GATEWAY_TIMEOUT = BYMAX_GATEWAY_TIMEOUT;
|
|
1634
1824
|
exports.BYMAX_HEALTH_INDICATORS = BYMAX_HEALTH_INDICATORS;
|
|
1825
|
+
exports.BYMAX_HEALTH_TRANSITION_SINK = BYMAX_HEALTH_TRANSITION_SINK;
|
|
1635
1826
|
exports.BYMAX_INTERNAL_ERROR = BYMAX_INTERNAL_ERROR;
|
|
1636
1827
|
exports.BYMAX_METRICS_REGISTRY = BYMAX_METRICS_REGISTRY;
|
|
1637
1828
|
exports.BYMAX_NOT_FOUND = BYMAX_NOT_FOUND;
|