@btravstack/core 0.5.0 → 0.7.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 +7 -4
- package/dist/index.cjs +100 -14
- package/dist/index.d.cts +94 -1
- package/dist/index.d.mts +95 -2
- package/dist/index.mjs +97 -15
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -112,10 +112,13 @@ is a module that exports no runtime port at all.
|
|
|
112
112
|
## What you get
|
|
113
113
|
|
|
114
114
|
- **A drain that survives Kubernetes.** On SIGTERM: readiness flips false, the
|
|
115
|
-
kernel waits `preDrainDelayMs` (default `5_000`) before
|
|
116
|
-
to stop accepting — endpoint removal is eventually
|
|
117
|
-
in-flight work gets `drainTimeoutMs` (
|
|
118
|
-
open is aborted and reported
|
|
115
|
+
kernel waits `preDrainDelayMs` (`PRE_DRAIN_DELAY_MS`, default `5_000`) before
|
|
116
|
+
telling the runtime to stop accepting — endpoint removal is eventually
|
|
117
|
+
consistent — then in-flight work gets `drainTimeoutMs` (`DRAIN_TIMEOUT_MS`,
|
|
118
|
+
default `20_000`); whatever is still open is aborted and reported
|
|
119
|
+
`abandoned`. A second signal skips the drain. Both timings have to agree with
|
|
120
|
+
the pod's `terminationGracePeriodSeconds`, which is why the deployment can
|
|
121
|
+
set them and the option only pins them.
|
|
119
122
|
- **Probes from the state machine.** `GET /livez` and `GET /readyz` on
|
|
120
123
|
`PROBE_PORT` (default `9000`), up before the graph is built.
|
|
121
124
|
- **Configuration from the environment, typed.** The kernel provides
|
package/dist/index.cjs
CHANGED
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
let unthrown = require("unthrown");
|
|
3
2
|
let _btravstack_di = require("@btravstack/di");
|
|
3
|
+
let unthrown = require("unthrown");
|
|
4
4
|
let _btravstack_config = require("@btravstack/config");
|
|
5
5
|
let node_http = require("node:http");
|
|
6
6
|
let node_async_hooks = require("node:async_hooks");
|
|
7
|
+
//#region src/instrumentation.ts
|
|
8
|
+
/**
|
|
9
|
+
* The set port a package contributes its instrumentation to.
|
|
10
|
+
*
|
|
11
|
+
* Nothing collects it unless an OTel SDK is composed, which is the point: a
|
|
12
|
+
* starter DECLARES what it can instrument, and composing
|
|
13
|
+
* `@btravstack/observability/otel` is what turns the declarations on. A graph
|
|
14
|
+
* without it registers nothing and pays nothing — the Spring Boot starter
|
|
15
|
+
* shape, in one port.
|
|
16
|
+
*/
|
|
17
|
+
var Instrumentations = class extends _btravstack_di.Port.many("Instrumentations") {};
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/health.ts
|
|
20
|
+
/**
|
|
21
|
+
* A component could not answer for itself. Modeled rather than thrown, because
|
|
22
|
+
* a health check that throws is a bug in the check; one that fails is the news
|
|
23
|
+
* `/healthz` exists to carry.
|
|
24
|
+
*/
|
|
25
|
+
var HealthCheckFailed = class extends (0, unthrown.TaggedError)("HealthCheckFailed") {};
|
|
26
|
+
/**
|
|
27
|
+
* The set port every starter contributes to and the kernel reads whole.
|
|
28
|
+
*
|
|
29
|
+
* A set port rather than a registry the kernel hands out: a starter DECLARES
|
|
30
|
+
* its check the same way it declares anything else, and a starter an
|
|
31
|
+
* application never composed contributes nothing — no registration call to
|
|
32
|
+
* forget, and no order to get wrong.
|
|
33
|
+
*/
|
|
34
|
+
var HealthChecks = class extends _btravstack_di.Port.many("HealthChecks") {};
|
|
35
|
+
/**
|
|
36
|
+
* Runs every check and folds the results into one report — the whole app is
|
|
37
|
+
* unhealthy if any single component is.
|
|
38
|
+
*
|
|
39
|
+
* Each check's failure is recovered into a component line BEFORE `allAsync`
|
|
40
|
+
* sees it, so a failing dependency cannot short-circuit the others: a report
|
|
41
|
+
* naming one component is worth less than one naming all of them. What
|
|
42
|
+
* survives to `allAsync`'s error channel is a `Defect` alone — a check that
|
|
43
|
+
* threw rather than answered, which is a bug in the check.
|
|
44
|
+
*/
|
|
45
|
+
const runHealthChecks = (checks) => (0, unthrown.allAsync)(checks.map((health) => health.check().map(() => ({
|
|
46
|
+
name: health.name,
|
|
47
|
+
status: "healthy"
|
|
48
|
+
})).recoverErrCases((matcher) => matcher.with(unthrown.P.tag("HealthCheckFailed"), (error) => ({
|
|
49
|
+
name: health.name,
|
|
50
|
+
status: "unhealthy",
|
|
51
|
+
reason: error.reason
|
|
52
|
+
}))))).map((components) => ({
|
|
53
|
+
status: components.every((c) => c.status === "healthy") ? "healthy" : "unhealthy",
|
|
54
|
+
components
|
|
55
|
+
}));
|
|
56
|
+
//#endregion
|
|
7
57
|
//#region src/clock.ts
|
|
8
58
|
const systemClock = {
|
|
9
59
|
now: () => Date.now(),
|
|
@@ -185,6 +235,12 @@ const startProbeServer = (args) => (0, unthrown.fromSafePromise)(new Promise((re
|
|
|
185
235
|
respond(response, args.ready(), "ready");
|
|
186
236
|
return;
|
|
187
237
|
}
|
|
238
|
+
if (request.url === "/healthz") {
|
|
239
|
+
args.health().map((report) => {
|
|
240
|
+
response.writeHead(report.status === "healthy" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(report));
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
188
244
|
response.writeHead(404).end();
|
|
189
245
|
});
|
|
190
246
|
const onBindError = (cause) => {
|
|
@@ -305,6 +361,37 @@ const createUnitRegistry = () => {
|
|
|
305
361
|
//#endregion
|
|
306
362
|
//#region src/start.ts
|
|
307
363
|
const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === _btravstack_config.Env.portId) || module.imports.some(providesEnv);
|
|
364
|
+
/**
|
|
365
|
+
* What the kernel reads from the environment for itself. Each field is pinned
|
|
366
|
+
* by the matching `StartOptions` field — explicit beats environment beats
|
|
367
|
+
* default, per field, exactly as a starter's own configuration is.
|
|
368
|
+
*/
|
|
369
|
+
const KERNEL_DEFAULTS = {
|
|
370
|
+
probePort: 9e3,
|
|
371
|
+
preDrainDelayMs: 5e3,
|
|
372
|
+
drainTimeoutMs: 2e4
|
|
373
|
+
};
|
|
374
|
+
const readKernelConfig = (options, env) => {
|
|
375
|
+
const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
|
|
376
|
+
const result = _btravstack_config.Config.object({
|
|
377
|
+
probePort: _btravstack_config.Config.pinned(probePort, _btravstack_config.Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
|
|
378
|
+
preDrainDelayMs: _btravstack_config.Config.pinned(options.preDrainDelayMs, _btravstack_config.Config.integer("PRE_DRAIN_DELAY_MS", {
|
|
379
|
+
default: KERNEL_DEFAULTS.preDrainDelayMs,
|
|
380
|
+
min: 0
|
|
381
|
+
})),
|
|
382
|
+
drainTimeoutMs: _btravstack_config.Config.pinned(options.drainTimeoutMs, _btravstack_config.Config.integer("DRAIN_TIMEOUT_MS", {
|
|
383
|
+
default: KERNEL_DEFAULTS.drainTimeoutMs,
|
|
384
|
+
min: 0
|
|
385
|
+
}))
|
|
386
|
+
})["~standard"].validate(env);
|
|
387
|
+
return result.issues === void 0 ? (0, unthrown.Ok)(result.value) : (0, unthrown.Err)(new RuntimeStartFailed({
|
|
388
|
+
runtime: "kernel",
|
|
389
|
+
cause: new _btravstack_config.ConfigInvalid({
|
|
390
|
+
port: "kernel",
|
|
391
|
+
issues: result.issues
|
|
392
|
+
})
|
|
393
|
+
}));
|
|
394
|
+
};
|
|
308
395
|
const start = (module, options = {}) => {
|
|
309
396
|
const clock = options.clock ?? systemClock;
|
|
310
397
|
const env = options.env ?? process.env;
|
|
@@ -326,8 +413,8 @@ const start = (module, options = {}) => {
|
|
|
326
413
|
const shutdown = Promise.withResolvers();
|
|
327
414
|
const teardownErrors = [];
|
|
328
415
|
const startedAt = clock.now();
|
|
329
|
-
const
|
|
330
|
-
const drainTimeoutMs =
|
|
416
|
+
const kernelConfig = readKernelConfig(options, env);
|
|
417
|
+
const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
|
|
331
418
|
const skipDrain = new AbortController();
|
|
332
419
|
let shutdownRequestedAt = startedAt;
|
|
333
420
|
let shutdownRequested = false;
|
|
@@ -359,24 +446,18 @@ const start = (module, options = {}) => {
|
|
|
359
446
|
requestShutdown("uncaught");
|
|
360
447
|
});
|
|
361
448
|
let disposeProbes = () => {};
|
|
449
|
+
let healthChecks = [];
|
|
450
|
+
const health = () => runHealthChecks(healthChecks);
|
|
362
451
|
const probeBound = Promise.withResolvers();
|
|
363
452
|
const runtimePublished = Promise.withResolvers();
|
|
364
453
|
emit({ type: "building" });
|
|
365
|
-
const probesOptions =
|
|
366
|
-
runtime: "probes",
|
|
367
|
-
cause: new _btravstack_config.ConfigInvalid({
|
|
368
|
-
port: "probes",
|
|
369
|
-
issues: [{
|
|
370
|
-
message: reason,
|
|
371
|
-
path: ["PROBE_PORT"]
|
|
372
|
-
}]
|
|
373
|
-
})
|
|
374
|
-
}))) : (0, unthrown.Ok)(options.probes);
|
|
454
|
+
const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
|
|
375
455
|
if (options.probes === false) probeBound.resolve(void 0);
|
|
376
456
|
const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? (0, unthrown.OkAsync)() : startProbeServer({
|
|
377
457
|
port: probes.port,
|
|
378
458
|
live,
|
|
379
|
-
ready
|
|
459
|
+
ready,
|
|
460
|
+
health
|
|
380
461
|
}).tap((server) => {
|
|
381
462
|
probeBoundPort = server.port;
|
|
382
463
|
probeBound.resolve(server.port);
|
|
@@ -444,6 +525,7 @@ const start = (module, options = {}) => {
|
|
|
444
525
|
tracker.advanceTo("starting");
|
|
445
526
|
const runtime = ctx.get(RuntimePort);
|
|
446
527
|
runtimeName = runtime.name;
|
|
528
|
+
healthChecks = ctx.get(HealthChecks);
|
|
447
529
|
const runtimeCtx = ctx;
|
|
448
530
|
const unit = options.unit;
|
|
449
531
|
const run = (meta, work) => registry.run(meta, (signal) => {
|
|
@@ -554,6 +636,9 @@ const runMain = async (module, options = {}, exit = (code) => {
|
|
|
554
636
|
await awaitExit(start(module, options), exit);
|
|
555
637
|
};
|
|
556
638
|
//#endregion
|
|
639
|
+
exports.HealthCheckFailed = HealthCheckFailed;
|
|
640
|
+
exports.HealthChecks = HealthChecks;
|
|
641
|
+
exports.Instrumentations = Instrumentations;
|
|
557
642
|
exports.LEVELS = LEVELS;
|
|
558
643
|
exports.Logger = Logger;
|
|
559
644
|
exports.Meter = Meter;
|
|
@@ -563,6 +648,7 @@ exports.SPAN_STATUS = SPAN_STATUS;
|
|
|
563
648
|
exports.Tracer = Tracer;
|
|
564
649
|
exports.currentUnit = currentUnit;
|
|
565
650
|
exports.releasedBy = releasedBy;
|
|
651
|
+
exports.runHealthChecks = runHealthChecks;
|
|
566
652
|
exports.runMain = runMain;
|
|
567
653
|
exports.start = start;
|
|
568
654
|
exports.stderrSink = stderrSink;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
1
|
import { AsyncResult, Result } from "unthrown";
|
|
2
2
|
import { AnyPort, Context, Module, PortClass, Scope, ServiceOf } from "@btravstack/di";
|
|
3
3
|
import { Env, Environment } from "@btravstack/config";
|
|
4
|
+
//#region src/instrumentation.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* One package's offer of an OpenTelemetry instrumentation.
|
|
7
|
+
*
|
|
8
|
+
* **Async, and answers `undefined` rather than failing, because the package
|
|
9
|
+
* supplying the instrumentation is an OPTIONAL peer.** A starter cannot know at
|
|
10
|
+
* composition time whether the consumer installed it, so it contributes the
|
|
11
|
+
* ATTEMPT and the collector drops what did not load — which is what lets
|
|
12
|
+
* `@btravstack/prisma` declare engine tracing without making every consumer
|
|
13
|
+
* install `@prisma/instrumentation`. A contributor that wants to say why its
|
|
14
|
+
* load answered nothing logs that itself; it is the one that knows.
|
|
15
|
+
*
|
|
16
|
+
* The instrumentation is `unknown` on purpose. Naming OpenTelemetry's
|
|
17
|
+
* `Instrumentation` would put the vendor in the package every other one peers
|
|
18
|
+
* on — the same reason `Tracer` and `Meter` are declared as narrowings rather
|
|
19
|
+
* than as OTel's own types. The collector
|
|
20
|
+
* (`@btravstack/observability/otel`) is where the vendor is already a
|
|
21
|
+
* dependency, so that is where the cast belongs.
|
|
22
|
+
*/
|
|
23
|
+
type InstrumentationLoader = () => Promise<unknown>;
|
|
24
|
+
declare const Instrumentations_base: import("@btravstack/di").ManyPortClass<"Instrumentations">;
|
|
25
|
+
/**
|
|
26
|
+
* The set port a package contributes its instrumentation to.
|
|
27
|
+
*
|
|
28
|
+
* Nothing collects it unless an OTel SDK is composed, which is the point: a
|
|
29
|
+
* starter DECLARES what it can instrument, and composing
|
|
30
|
+
* `@btravstack/observability/otel` is what turns the declarations on. A graph
|
|
31
|
+
* without it registers nothing and pays nothing — the Spring Boot starter
|
|
32
|
+
* shape, in one port.
|
|
33
|
+
*/
|
|
34
|
+
declare class Instrumentations extends Instrumentations_base<InstrumentationLoader> {}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/health.d.ts
|
|
37
|
+
declare const HealthCheckFailed_base: import("unthrown").TaggedErrorConstructor<"HealthCheckFailed">;
|
|
38
|
+
/**
|
|
39
|
+
* A component could not answer for itself. Modeled rather than thrown, because
|
|
40
|
+
* a health check that throws is a bug in the check; one that fails is the news
|
|
41
|
+
* `/healthz` exists to carry.
|
|
42
|
+
*/
|
|
43
|
+
declare class HealthCheckFailed extends HealthCheckFailed_base<{
|
|
44
|
+
readonly reason: string;
|
|
45
|
+
}> {}
|
|
46
|
+
/** One contribution: what to call the component, and how to ask it. */
|
|
47
|
+
type HealthCheck = {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly check: () => AsyncResult<void, HealthCheckFailed>;
|
|
50
|
+
};
|
|
51
|
+
declare const HealthChecks_base: import("@btravstack/di").ManyPortClass<"HealthChecks">;
|
|
52
|
+
/**
|
|
53
|
+
* The set port every starter contributes to and the kernel reads whole.
|
|
54
|
+
*
|
|
55
|
+
* A set port rather than a registry the kernel hands out: a starter DECLARES
|
|
56
|
+
* its check the same way it declares anything else, and a starter an
|
|
57
|
+
* application never composed contributes nothing — no registration call to
|
|
58
|
+
* forget, and no order to get wrong.
|
|
59
|
+
*/
|
|
60
|
+
declare class HealthChecks extends HealthChecks_base<HealthCheck> {}
|
|
61
|
+
type ComponentHealth = {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly status: "healthy" | "unhealthy";
|
|
64
|
+
/** Present only when the component is unhealthy. */
|
|
65
|
+
readonly reason?: string;
|
|
66
|
+
};
|
|
67
|
+
type HealthReport = {
|
|
68
|
+
readonly status: "healthy" | "unhealthy";
|
|
69
|
+
readonly components: readonly ComponentHealth[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Runs every check and folds the results into one report — the whole app is
|
|
73
|
+
* unhealthy if any single component is.
|
|
74
|
+
*
|
|
75
|
+
* Each check's failure is recovered into a component line BEFORE `allAsync`
|
|
76
|
+
* sees it, so a failing dependency cannot short-circuit the others: a report
|
|
77
|
+
* naming one component is worth less than one naming all of them. What
|
|
78
|
+
* survives to `allAsync`'s error channel is a `Defect` alone — a check that
|
|
79
|
+
* threw rather than answered, which is a bug in the check.
|
|
80
|
+
*/
|
|
81
|
+
declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
|
|
82
|
+
//#endregion
|
|
4
83
|
//#region src/clock.d.ts
|
|
5
84
|
type Clock = {
|
|
6
85
|
readonly now: () => number;
|
|
@@ -379,7 +458,21 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
|
|
|
379
458
|
readonly probes?: {
|
|
380
459
|
readonly port: number;
|
|
381
460
|
} | false;
|
|
461
|
+
/**
|
|
462
|
+
* How long readiness stays false before the runtime is told to stop
|
|
463
|
+
* accepting. Unset, it is bound from `PRE_DRAIN_DELAY_MS` in `env` (default
|
|
464
|
+
* `5_000`) — the window that covers Kubernetes' eventually-consistent
|
|
465
|
+
* endpoint removal, which is a property of the cluster rather than of the
|
|
466
|
+
* code, so a deployment must be able to set it.
|
|
467
|
+
*/
|
|
382
468
|
readonly preDrainDelayMs?: number;
|
|
469
|
+
/**
|
|
470
|
+
* How long in-flight work gets before it is aborted and reported
|
|
471
|
+
* `abandoned`. Unset, it is bound from `DRAIN_TIMEOUT_MS` in `env` (default
|
|
472
|
+
* `20_000`). Keep it under the pod's `terminationGracePeriodSeconds`, which
|
|
473
|
+
* is the reason this one belongs in the environment: the two are set
|
|
474
|
+
* together, in the same manifest.
|
|
475
|
+
*/
|
|
383
476
|
readonly drainTimeoutMs?: number;
|
|
384
477
|
readonly onEvent?: EventSink;
|
|
385
478
|
};
|
|
@@ -455,4 +548,4 @@ declare const start: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X,
|
|
|
455
548
|
*/
|
|
456
549
|
declare const runMain: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X, E, Scope | Env> & StartGate<X, UnitNeeds>, options?: StartOptions<UnitX, UnitNeeds>, exit?: (code: number) => void) => Promise<void>;
|
|
457
550
|
//#endregion
|
|
458
|
-
export { type Attributes, type Clock, type Counter, type DrainReport, type EventSink, type ExitReport, type Histogram, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runMain, start, stderrSink, systemClock };
|
|
551
|
+
export { type Attributes, type Clock, type ComponentHealth, type Counter, type DrainReport, type EventSink, type ExitReport, type HealthCheck, HealthCheckFailed, HealthChecks, type HealthReport, type Histogram, type InstrumentationLoader, Instrumentations, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,85 @@
|
|
|
1
|
-
import { AsyncResult, Result } from "unthrown";
|
|
2
1
|
import { AnyPort, Context, Module, PortClass, Scope, ServiceOf } from "@btravstack/di";
|
|
2
|
+
import { AsyncResult, Result } from "unthrown";
|
|
3
3
|
import { Env, Environment } from "@btravstack/config";
|
|
4
|
+
//#region src/instrumentation.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* One package's offer of an OpenTelemetry instrumentation.
|
|
7
|
+
*
|
|
8
|
+
* **Async, and answers `undefined` rather than failing, because the package
|
|
9
|
+
* supplying the instrumentation is an OPTIONAL peer.** A starter cannot know at
|
|
10
|
+
* composition time whether the consumer installed it, so it contributes the
|
|
11
|
+
* ATTEMPT and the collector drops what did not load — which is what lets
|
|
12
|
+
* `@btravstack/prisma` declare engine tracing without making every consumer
|
|
13
|
+
* install `@prisma/instrumentation`. A contributor that wants to say why its
|
|
14
|
+
* load answered nothing logs that itself; it is the one that knows.
|
|
15
|
+
*
|
|
16
|
+
* The instrumentation is `unknown` on purpose. Naming OpenTelemetry's
|
|
17
|
+
* `Instrumentation` would put the vendor in the package every other one peers
|
|
18
|
+
* on — the same reason `Tracer` and `Meter` are declared as narrowings rather
|
|
19
|
+
* than as OTel's own types. The collector
|
|
20
|
+
* (`@btravstack/observability/otel`) is where the vendor is already a
|
|
21
|
+
* dependency, so that is where the cast belongs.
|
|
22
|
+
*/
|
|
23
|
+
type InstrumentationLoader = () => Promise<unknown>;
|
|
24
|
+
declare const Instrumentations_base: import("@btravstack/di").ManyPortClass<"Instrumentations">;
|
|
25
|
+
/**
|
|
26
|
+
* The set port a package contributes its instrumentation to.
|
|
27
|
+
*
|
|
28
|
+
* Nothing collects it unless an OTel SDK is composed, which is the point: a
|
|
29
|
+
* starter DECLARES what it can instrument, and composing
|
|
30
|
+
* `@btravstack/observability/otel` is what turns the declarations on. A graph
|
|
31
|
+
* without it registers nothing and pays nothing — the Spring Boot starter
|
|
32
|
+
* shape, in one port.
|
|
33
|
+
*/
|
|
34
|
+
declare class Instrumentations extends Instrumentations_base<InstrumentationLoader> {}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/health.d.ts
|
|
37
|
+
declare const HealthCheckFailed_base: import("unthrown").TaggedErrorConstructor<"HealthCheckFailed">;
|
|
38
|
+
/**
|
|
39
|
+
* A component could not answer for itself. Modeled rather than thrown, because
|
|
40
|
+
* a health check that throws is a bug in the check; one that fails is the news
|
|
41
|
+
* `/healthz` exists to carry.
|
|
42
|
+
*/
|
|
43
|
+
declare class HealthCheckFailed extends HealthCheckFailed_base<{
|
|
44
|
+
readonly reason: string;
|
|
45
|
+
}> {}
|
|
46
|
+
/** One contribution: what to call the component, and how to ask it. */
|
|
47
|
+
type HealthCheck = {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly check: () => AsyncResult<void, HealthCheckFailed>;
|
|
50
|
+
};
|
|
51
|
+
declare const HealthChecks_base: import("@btravstack/di").ManyPortClass<"HealthChecks">;
|
|
52
|
+
/**
|
|
53
|
+
* The set port every starter contributes to and the kernel reads whole.
|
|
54
|
+
*
|
|
55
|
+
* A set port rather than a registry the kernel hands out: a starter DECLARES
|
|
56
|
+
* its check the same way it declares anything else, and a starter an
|
|
57
|
+
* application never composed contributes nothing — no registration call to
|
|
58
|
+
* forget, and no order to get wrong.
|
|
59
|
+
*/
|
|
60
|
+
declare class HealthChecks extends HealthChecks_base<HealthCheck> {}
|
|
61
|
+
type ComponentHealth = {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly status: "healthy" | "unhealthy";
|
|
64
|
+
/** Present only when the component is unhealthy. */
|
|
65
|
+
readonly reason?: string;
|
|
66
|
+
};
|
|
67
|
+
type HealthReport = {
|
|
68
|
+
readonly status: "healthy" | "unhealthy";
|
|
69
|
+
readonly components: readonly ComponentHealth[];
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Runs every check and folds the results into one report — the whole app is
|
|
73
|
+
* unhealthy if any single component is.
|
|
74
|
+
*
|
|
75
|
+
* Each check's failure is recovered into a component line BEFORE `allAsync`
|
|
76
|
+
* sees it, so a failing dependency cannot short-circuit the others: a report
|
|
77
|
+
* naming one component is worth less than one naming all of them. What
|
|
78
|
+
* survives to `allAsync`'s error channel is a `Defect` alone — a check that
|
|
79
|
+
* threw rather than answered, which is a bug in the check.
|
|
80
|
+
*/
|
|
81
|
+
declare const runHealthChecks: (checks: readonly HealthCheck[]) => AsyncResult<HealthReport, never>;
|
|
82
|
+
//#endregion
|
|
4
83
|
//#region src/clock.d.ts
|
|
5
84
|
type Clock = {
|
|
6
85
|
readonly now: () => number;
|
|
@@ -379,7 +458,21 @@ type StartOptions<UnitX = never, UnitNeeds = never> = {
|
|
|
379
458
|
readonly probes?: {
|
|
380
459
|
readonly port: number;
|
|
381
460
|
} | false;
|
|
461
|
+
/**
|
|
462
|
+
* How long readiness stays false before the runtime is told to stop
|
|
463
|
+
* accepting. Unset, it is bound from `PRE_DRAIN_DELAY_MS` in `env` (default
|
|
464
|
+
* `5_000`) — the window that covers Kubernetes' eventually-consistent
|
|
465
|
+
* endpoint removal, which is a property of the cluster rather than of the
|
|
466
|
+
* code, so a deployment must be able to set it.
|
|
467
|
+
*/
|
|
382
468
|
readonly preDrainDelayMs?: number;
|
|
469
|
+
/**
|
|
470
|
+
* How long in-flight work gets before it is aborted and reported
|
|
471
|
+
* `abandoned`. Unset, it is bound from `DRAIN_TIMEOUT_MS` in `env` (default
|
|
472
|
+
* `20_000`). Keep it under the pod's `terminationGracePeriodSeconds`, which
|
|
473
|
+
* is the reason this one belongs in the environment: the two are set
|
|
474
|
+
* together, in the same manifest.
|
|
475
|
+
*/
|
|
383
476
|
readonly drainTimeoutMs?: number;
|
|
384
477
|
readonly onEvent?: EventSink;
|
|
385
478
|
};
|
|
@@ -455,4 +548,4 @@ declare const start: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X,
|
|
|
455
548
|
*/
|
|
456
549
|
declare const runMain: <X, E, UnitX = never, UnitNeeds = never>(module: Module<X, E, Scope | Env> & StartGate<X, UnitNeeds>, options?: StartOptions<UnitX, UnitNeeds>, exit?: (code: number) => void) => Promise<void>;
|
|
457
550
|
//#endregion
|
|
458
|
-
export { type Attributes, type Clock, type Counter, type DrainReport, type EventSink, type ExitReport, type Histogram, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runMain, start, stderrSink, systemClock };
|
|
551
|
+
export { type Attributes, type Clock, type ComponentHealth, type Counter, type DrainReport, type EventSink, type ExitReport, type HealthCheck, HealthCheckFailed, HealthChecks, type HealthReport, type Histogram, type InstrumentationLoader, Instrumentations, type KernelEvent, LEVELS, type Level, Logger, type LoggerService, Meter, type MeterService, type Phase, type RunUnit, type RunningApp, type Runtime, type RuntimeHost, type RuntimeInfoOf, RuntimePort, RuntimeStartFailed, SPAN_STATUS, type Serving, type Span, type SpanStatusCode, type StartGate, type StartOptions, type TeardownError, Tracer, type TracerService, type UnitMeta, type UnitRecord, type UnitRegistry, type UnitWork, currentUnit, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock };
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,58 @@
|
|
|
1
|
-
import { Err, Ok, OkAsync, P, TaggedError, fromSafePromise } from "unthrown";
|
|
2
1
|
import { Module, Port, Provider } from "@btravstack/di";
|
|
2
|
+
import { Err, Ok, OkAsync, P, TaggedError, allAsync, fromSafePromise } from "unthrown";
|
|
3
3
|
import { Config, ConfigInvalid, Env } from "@btravstack/config";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
//#region src/instrumentation.ts
|
|
7
|
+
/**
|
|
8
|
+
* The set port a package contributes its instrumentation to.
|
|
9
|
+
*
|
|
10
|
+
* Nothing collects it unless an OTel SDK is composed, which is the point: a
|
|
11
|
+
* starter DECLARES what it can instrument, and composing
|
|
12
|
+
* `@btravstack/observability/otel` is what turns the declarations on. A graph
|
|
13
|
+
* without it registers nothing and pays nothing — the Spring Boot starter
|
|
14
|
+
* shape, in one port.
|
|
15
|
+
*/
|
|
16
|
+
var Instrumentations = class extends Port.many("Instrumentations") {};
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/health.ts
|
|
19
|
+
/**
|
|
20
|
+
* A component could not answer for itself. Modeled rather than thrown, because
|
|
21
|
+
* a health check that throws is a bug in the check; one that fails is the news
|
|
22
|
+
* `/healthz` exists to carry.
|
|
23
|
+
*/
|
|
24
|
+
var HealthCheckFailed = class extends TaggedError("HealthCheckFailed") {};
|
|
25
|
+
/**
|
|
26
|
+
* The set port every starter contributes to and the kernel reads whole.
|
|
27
|
+
*
|
|
28
|
+
* A set port rather than a registry the kernel hands out: a starter DECLARES
|
|
29
|
+
* its check the same way it declares anything else, and a starter an
|
|
30
|
+
* application never composed contributes nothing — no registration call to
|
|
31
|
+
* forget, and no order to get wrong.
|
|
32
|
+
*/
|
|
33
|
+
var HealthChecks = class extends Port.many("HealthChecks") {};
|
|
34
|
+
/**
|
|
35
|
+
* Runs every check and folds the results into one report — the whole app is
|
|
36
|
+
* unhealthy if any single component is.
|
|
37
|
+
*
|
|
38
|
+
* Each check's failure is recovered into a component line BEFORE `allAsync`
|
|
39
|
+
* sees it, so a failing dependency cannot short-circuit the others: a report
|
|
40
|
+
* naming one component is worth less than one naming all of them. What
|
|
41
|
+
* survives to `allAsync`'s error channel is a `Defect` alone — a check that
|
|
42
|
+
* threw rather than answered, which is a bug in the check.
|
|
43
|
+
*/
|
|
44
|
+
const runHealthChecks = (checks) => allAsync(checks.map((health) => health.check().map(() => ({
|
|
45
|
+
name: health.name,
|
|
46
|
+
status: "healthy"
|
|
47
|
+
})).recoverErrCases((matcher) => matcher.with(P.tag("HealthCheckFailed"), (error) => ({
|
|
48
|
+
name: health.name,
|
|
49
|
+
status: "unhealthy",
|
|
50
|
+
reason: error.reason
|
|
51
|
+
}))))).map((components) => ({
|
|
52
|
+
status: components.every((c) => c.status === "healthy") ? "healthy" : "unhealthy",
|
|
53
|
+
components
|
|
54
|
+
}));
|
|
55
|
+
//#endregion
|
|
6
56
|
//#region src/clock.ts
|
|
7
57
|
const systemClock = {
|
|
8
58
|
now: () => Date.now(),
|
|
@@ -184,6 +234,12 @@ const startProbeServer = (args) => fromSafePromise(new Promise((resolve) => {
|
|
|
184
234
|
respond(response, args.ready(), "ready");
|
|
185
235
|
return;
|
|
186
236
|
}
|
|
237
|
+
if (request.url === "/healthz") {
|
|
238
|
+
args.health().map((report) => {
|
|
239
|
+
response.writeHead(report.status === "healthy" ? 200 : 503, { "content-type": "application/json" }).end(JSON.stringify(report));
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
187
243
|
response.writeHead(404).end();
|
|
188
244
|
});
|
|
189
245
|
const onBindError = (cause) => {
|
|
@@ -304,6 +360,37 @@ const createUnitRegistry = () => {
|
|
|
304
360
|
//#endregion
|
|
305
361
|
//#region src/start.ts
|
|
306
362
|
const providesEnv = (module) => module.provides.some((provider) => provider.port.portId === Env.portId) || module.imports.some(providesEnv);
|
|
363
|
+
/**
|
|
364
|
+
* What the kernel reads from the environment for itself. Each field is pinned
|
|
365
|
+
* by the matching `StartOptions` field — explicit beats environment beats
|
|
366
|
+
* default, per field, exactly as a starter's own configuration is.
|
|
367
|
+
*/
|
|
368
|
+
const KERNEL_DEFAULTS = {
|
|
369
|
+
probePort: 9e3,
|
|
370
|
+
preDrainDelayMs: 5e3,
|
|
371
|
+
drainTimeoutMs: 2e4
|
|
372
|
+
};
|
|
373
|
+
const readKernelConfig = (options, env) => {
|
|
374
|
+
const probePort = options.probes === void 0 ? void 0 : options.probes === false ? KERNEL_DEFAULTS.probePort : options.probes.port;
|
|
375
|
+
const result = Config.object({
|
|
376
|
+
probePort: Config.pinned(probePort, Config.port("PROBE_PORT", { default: KERNEL_DEFAULTS.probePort })),
|
|
377
|
+
preDrainDelayMs: Config.pinned(options.preDrainDelayMs, Config.integer("PRE_DRAIN_DELAY_MS", {
|
|
378
|
+
default: KERNEL_DEFAULTS.preDrainDelayMs,
|
|
379
|
+
min: 0
|
|
380
|
+
})),
|
|
381
|
+
drainTimeoutMs: Config.pinned(options.drainTimeoutMs, Config.integer("DRAIN_TIMEOUT_MS", {
|
|
382
|
+
default: KERNEL_DEFAULTS.drainTimeoutMs,
|
|
383
|
+
min: 0
|
|
384
|
+
}))
|
|
385
|
+
})["~standard"].validate(env);
|
|
386
|
+
return result.issues === void 0 ? Ok(result.value) : Err(new RuntimeStartFailed({
|
|
387
|
+
runtime: "kernel",
|
|
388
|
+
cause: new ConfigInvalid({
|
|
389
|
+
port: "kernel",
|
|
390
|
+
issues: result.issues
|
|
391
|
+
})
|
|
392
|
+
}));
|
|
393
|
+
};
|
|
307
394
|
const start = (module, options = {}) => {
|
|
308
395
|
const clock = options.clock ?? systemClock;
|
|
309
396
|
const env = options.env ?? process.env;
|
|
@@ -325,8 +412,8 @@ const start = (module, options = {}) => {
|
|
|
325
412
|
const shutdown = Promise.withResolvers();
|
|
326
413
|
const teardownErrors = [];
|
|
327
414
|
const startedAt = clock.now();
|
|
328
|
-
const
|
|
329
|
-
const drainTimeoutMs =
|
|
415
|
+
const kernelConfig = readKernelConfig(options, env);
|
|
416
|
+
const { preDrainDelayMs, drainTimeoutMs } = kernelConfig.getOrElse(() => KERNEL_DEFAULTS);
|
|
330
417
|
const skipDrain = new AbortController();
|
|
331
418
|
let shutdownRequestedAt = startedAt;
|
|
332
419
|
let shutdownRequested = false;
|
|
@@ -358,24 +445,18 @@ const start = (module, options = {}) => {
|
|
|
358
445
|
requestShutdown("uncaught");
|
|
359
446
|
});
|
|
360
447
|
let disposeProbes = () => {};
|
|
448
|
+
let healthChecks = [];
|
|
449
|
+
const health = () => runHealthChecks(healthChecks);
|
|
361
450
|
const probeBound = Promise.withResolvers();
|
|
362
451
|
const runtimePublished = Promise.withResolvers();
|
|
363
452
|
emit({ type: "building" });
|
|
364
|
-
const probesOptions =
|
|
365
|
-
runtime: "probes",
|
|
366
|
-
cause: new ConfigInvalid({
|
|
367
|
-
port: "probes",
|
|
368
|
-
issues: [{
|
|
369
|
-
message: reason,
|
|
370
|
-
path: ["PROBE_PORT"]
|
|
371
|
-
}]
|
|
372
|
-
})
|
|
373
|
-
}))) : Ok(options.probes);
|
|
453
|
+
const probesOptions = kernelConfig.map(({ probePort }) => options.probes === false ? false : { port: probePort });
|
|
374
454
|
if (options.probes === false) probeBound.resolve(void 0);
|
|
375
455
|
const probesStarted = probesOptions.toAsync().flatMap((probes) => probes === false ? OkAsync() : startProbeServer({
|
|
376
456
|
port: probes.port,
|
|
377
457
|
live,
|
|
378
|
-
ready
|
|
458
|
+
ready,
|
|
459
|
+
health
|
|
379
460
|
}).tap((server) => {
|
|
380
461
|
probeBoundPort = server.port;
|
|
381
462
|
probeBound.resolve(server.port);
|
|
@@ -443,6 +524,7 @@ const start = (module, options = {}) => {
|
|
|
443
524
|
tracker.advanceTo("starting");
|
|
444
525
|
const runtime = ctx.get(RuntimePort);
|
|
445
526
|
runtimeName = runtime.name;
|
|
527
|
+
healthChecks = ctx.get(HealthChecks);
|
|
446
528
|
const runtimeCtx = ctx;
|
|
447
529
|
const unit = options.unit;
|
|
448
530
|
const run = (meta, work) => registry.run(meta, (signal) => {
|
|
@@ -553,4 +635,4 @@ const runMain = async (module, options = {}, exit = (code) => {
|
|
|
553
635
|
await awaitExit(start(module, options), exit);
|
|
554
636
|
};
|
|
555
637
|
//#endregion
|
|
556
|
-
export { LEVELS, Logger, Meter, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, releasedBy, runMain, start, stderrSink, systemClock };
|
|
638
|
+
export { HealthCheckFailed, HealthChecks, Instrumentations, LEVELS, Logger, Meter, RuntimePort, RuntimeStartFailed, SPAN_STATUS, Tracer, currentUnit, releasedBy, runHealthChecks, runMain, start, stderrSink, systemClock };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@btravstack/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The application kernel: boot a di module into a running process, and stop it without losing work",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"application",
|
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
"typescript": "7.0.2",
|
|
54
54
|
"unthrown": "5.7.0",
|
|
55
55
|
"vitest": "4.1.11",
|
|
56
|
-
"@btravstack/config": "0.
|
|
57
|
-
"@btravstack/di": "0.
|
|
56
|
+
"@btravstack/config": "0.7.0",
|
|
57
|
+
"@btravstack/di": "0.7.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"unthrown": "^5.0.0",
|
|
61
|
-
"@btravstack/config": "^0.
|
|
62
|
-
"@btravstack/di": "^0.
|
|
61
|
+
"@btravstack/config": "^0.7.0",
|
|
62
|
+
"@btravstack/di": "^0.7.0"
|
|
63
63
|
},
|
|
64
64
|
"engines": {
|
|
65
65
|
"node": ">=22"
|