@geonosis/observability 1.0.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/LICENSE +202 -0
- package/README.md +333 -0
- package/bin/geonosis-observability.mjs +4 -0
- package/dist/chunk-2ZTQU3OO.js +161 -0
- package/dist/chunk-EJS3A3ZV.js +82 -0
- package/dist/chunk-UFR2R2VM.js +141 -0
- package/dist/health/index.d.ts +63 -0
- package/dist/health/index.js +89 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +145 -0
- package/dist/observability-cli.js +229 -0
- package/dist/ports-Ct5eq-HS.d.ts +116 -0
- package/dist/posthog/index.d.ts +58 -0
- package/dist/posthog/index.js +7 -0
- package/package.json +63 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// src/attributes.ts
|
|
2
|
+
import { Result, TaggedError as tagged } from "better-result";
|
|
3
|
+
var ATTRIBUTE_KEYS = [
|
|
4
|
+
"actor",
|
|
5
|
+
"envelopeId",
|
|
6
|
+
"release",
|
|
7
|
+
"runtime",
|
|
8
|
+
"service",
|
|
9
|
+
"tenant"
|
|
10
|
+
];
|
|
11
|
+
var SPAN_NAME_CONVENTION = "A span name is `<area>.<action>`, lowercase, at least two dot-separated segments, letters and digits with dashes inside a segment: `queue.handle-batch`, `db.cell.query`. A name is a dimension in every trace query, so `queue.handle-batch` and `Queue Handle Batch` in one tree are two dimensions where the author meant one.";
|
|
12
|
+
var InvalidAttributes = class extends tagged("InvalidAttributes") {
|
|
13
|
+
};
|
|
14
|
+
var TENANT_IS_A_PERSON = "the tenant is a group id, never a person: it is the join key every dashboard fans out on, and the one field an erasure request cannot reach once an address is in it";
|
|
15
|
+
var optionalStrings = [
|
|
16
|
+
"actor",
|
|
17
|
+
"envelopeId",
|
|
18
|
+
"release",
|
|
19
|
+
"runtime",
|
|
20
|
+
"tenant"
|
|
21
|
+
];
|
|
22
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
var isAttributeValue = (value) => value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string";
|
|
24
|
+
var tenantIsAPerson = (tenant) => tenant.includes("@");
|
|
25
|
+
var problemsOf = (value) => {
|
|
26
|
+
if (!isRecord(value)) return ["attributes must be an object"];
|
|
27
|
+
const problems = [];
|
|
28
|
+
const service = value.service;
|
|
29
|
+
if (typeof service !== "string" || service.trim() === "") {
|
|
30
|
+
problems.push(
|
|
31
|
+
"attributes.service is required and must be a non-empty string: a report nobody can route to a service is a report nobody reads"
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
for (const key of optionalStrings) {
|
|
35
|
+
const one = value[key];
|
|
36
|
+
if (one !== void 0 && (typeof one !== "string" || one.trim() === "")) {
|
|
37
|
+
problems.push(`attributes.${key} must be a non-empty string when it is present`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (typeof value.tenant === "string" && tenantIsAPerson(value.tenant)) {
|
|
41
|
+
problems.push(`attributes.tenant "${value.tenant}" looks like a person \u2014 ${TENANT_IS_A_PERSON}`);
|
|
42
|
+
}
|
|
43
|
+
const extra = value.extra;
|
|
44
|
+
if (extra !== void 0) {
|
|
45
|
+
if (!isRecord(extra)) {
|
|
46
|
+
problems.push("attributes.extra must be an object");
|
|
47
|
+
} else {
|
|
48
|
+
for (const key of Object.keys(extra)) {
|
|
49
|
+
if (ATTRIBUTE_KEYS.includes(key)) {
|
|
50
|
+
problems.push(
|
|
51
|
+
`attributes.extra.${key} shadows the vocabulary's own "${key}" \u2014 a report with two answers for one question has none`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (!isAttributeValue(extra[key])) {
|
|
55
|
+
problems.push(
|
|
56
|
+
`attributes.extra.${key} must be a string, number, boolean or null \u2014 a vendor puts these on a wire without a serializer of its own`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return problems;
|
|
63
|
+
};
|
|
64
|
+
var attributesOf = (value) => {
|
|
65
|
+
const taken = { service: value.service };
|
|
66
|
+
for (const key of optionalStrings) {
|
|
67
|
+
if (value[key] !== void 0) taken[key] = value[key];
|
|
68
|
+
}
|
|
69
|
+
if (value.extra !== void 0) taken.extra = value.extra;
|
|
70
|
+
return taken;
|
|
71
|
+
};
|
|
72
|
+
var parseAttributes = (value) => {
|
|
73
|
+
const problems = problemsOf(value);
|
|
74
|
+
if (problems.length > 0) {
|
|
75
|
+
return Result.err(new InvalidAttributes({ message: problems.join("; ") }));
|
|
76
|
+
}
|
|
77
|
+
return Result.ok(attributesOf(value));
|
|
78
|
+
};
|
|
79
|
+
var attributesSchema = (z) => {
|
|
80
|
+
const zod = z;
|
|
81
|
+
const text = () => zod.string().min(1);
|
|
82
|
+
const extraValue = zod.union([zod.string(), zod.number(), zod.boolean(), zod.null()]);
|
|
83
|
+
return zod.object({
|
|
84
|
+
actor: text().optional(),
|
|
85
|
+
envelopeId: text().optional(),
|
|
86
|
+
extra: zod.record(zod.string().min(0), extraValue).optional(),
|
|
87
|
+
release: text().optional(),
|
|
88
|
+
runtime: text().optional(),
|
|
89
|
+
service: zod.string().min(1),
|
|
90
|
+
tenant: text().optional()
|
|
91
|
+
}).check((context) => {
|
|
92
|
+
for (const problem of problemsOf(context.value)) {
|
|
93
|
+
context.issues.push({ code: "custom", input: context.value, message: problem, path: [] });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/ports.ts
|
|
99
|
+
import { TaggedError as tagged2 } from "better-result";
|
|
100
|
+
var SinkFailure = class extends tagged2("SinkFailure") {
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// src/uuid.ts
|
|
104
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
105
|
+
var isUuid = (value) => UUID.test(value);
|
|
106
|
+
var NAMESPACE = "a3c2b1f0-6d84-4b9e-9f27-1c5e0d3a7b48";
|
|
107
|
+
var bytesOfUuid = (uuid) => {
|
|
108
|
+
const hex2 = uuid.replaceAll("-", "");
|
|
109
|
+
const bytes = new Uint8Array(16);
|
|
110
|
+
for (let index = 0; index < 16; index += 1) {
|
|
111
|
+
bytes[index] = Number.parseInt(hex2.slice(index * 2, index * 2 + 2), 16);
|
|
112
|
+
}
|
|
113
|
+
return bytes;
|
|
114
|
+
};
|
|
115
|
+
var hex = (bytes) => [...bytes].map((one) => one.toString(16).padStart(2, "0")).join("");
|
|
116
|
+
var webCrypto = () => globalThis.crypto ?? {};
|
|
117
|
+
var sha1 = async (data) => {
|
|
118
|
+
const subtle = webCrypto().subtle;
|
|
119
|
+
if (subtle === void 0 || typeof subtle.digest !== "function") {
|
|
120
|
+
throw new TypeError(
|
|
121
|
+
"crypto.subtle is not available in this runtime, so an envelope id that is not already a UUID cannot be derived into a stable one. Node 19+, workerd and every browser have it."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return new Uint8Array(await subtle.digest("SHA-1", data));
|
|
125
|
+
};
|
|
126
|
+
var uuidFrom = async (value, inNamespace = NAMESPACE) => {
|
|
127
|
+
const namespace = bytesOfUuid(inNamespace);
|
|
128
|
+
const name = new TextEncoder().encode(value);
|
|
129
|
+
const input = new Uint8Array(namespace.length + name.length);
|
|
130
|
+
input.set(namespace, 0);
|
|
131
|
+
input.set(name, namespace.length);
|
|
132
|
+
const digest = (await sha1(input)).slice(0, 16);
|
|
133
|
+
digest[6] = (digest[6] ?? 0) & 15 | 80;
|
|
134
|
+
digest[8] = (digest[8] ?? 0) & 63 | 128;
|
|
135
|
+
const text = hex(digest);
|
|
136
|
+
return `${text.slice(0, 8)}-${text.slice(8, 12)}-${text.slice(12, 16)}-${text.slice(16, 20)}-${text.slice(20)}`;
|
|
137
|
+
};
|
|
138
|
+
var randomUuid = () => {
|
|
139
|
+
const crypto = webCrypto();
|
|
140
|
+
if (typeof crypto.randomUUID !== "function") {
|
|
141
|
+
throw new TypeError("crypto.randomUUID is not available in this runtime");
|
|
142
|
+
}
|
|
143
|
+
return crypto.randomUUID();
|
|
144
|
+
};
|
|
145
|
+
var uuidForEnvelope = async (envelopeId) => {
|
|
146
|
+
if (envelopeId === void 0) return randomUuid();
|
|
147
|
+
return isUuid(envelopeId) ? envelopeId : uuidFrom(envelopeId);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
ATTRIBUTE_KEYS,
|
|
152
|
+
SPAN_NAME_CONVENTION,
|
|
153
|
+
InvalidAttributes,
|
|
154
|
+
parseAttributes,
|
|
155
|
+
attributesSchema,
|
|
156
|
+
SinkFailure,
|
|
157
|
+
isUuid,
|
|
158
|
+
uuidFrom,
|
|
159
|
+
randomUuid,
|
|
160
|
+
uuidForEnvelope
|
|
161
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SinkFailure,
|
|
3
|
+
parseAttributes,
|
|
4
|
+
uuidForEnvelope
|
|
5
|
+
} from "./chunk-2ZTQU3OO.js";
|
|
6
|
+
|
|
7
|
+
// src/posthog/index.ts
|
|
8
|
+
import { Result } from "better-result";
|
|
9
|
+
var defaultIdentity = (attributes) => attributes.actor ?? attributes.tenant ?? attributes.service;
|
|
10
|
+
var propertiesOf = (attributes, groupName) => ({
|
|
11
|
+
...attributes.extra,
|
|
12
|
+
...attributes.envelopeId === void 0 ? {} : { envelopeId: attributes.envelopeId },
|
|
13
|
+
...attributes.release === void 0 ? {} : { release: attributes.release },
|
|
14
|
+
...attributes.runtime === void 0 ? {} : { runtime: attributes.runtime },
|
|
15
|
+
service: attributes.service,
|
|
16
|
+
...attributes.tenant === void 0 ? {} : (
|
|
17
|
+
// `captureException` takes no groups of its own, and `$groups` is the wire property `groups`
|
|
18
|
+
// becomes — so an exception is filed under its organisation like everything else rather than
|
|
19
|
+
// floating free of one. during.day spells it by hand for the same reason.
|
|
20
|
+
{ $groups: { [groupName]: attributes.tenant }, tenant: attributes.tenant }
|
|
21
|
+
)
|
|
22
|
+
});
|
|
23
|
+
var failureOf = (cause) => new SinkFailure({
|
|
24
|
+
cause,
|
|
25
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
26
|
+
sink: "posthog"
|
|
27
|
+
});
|
|
28
|
+
var createPostHogSink = ({
|
|
29
|
+
groupName = "organization",
|
|
30
|
+
identityOf = defaultIdentity,
|
|
31
|
+
newClient
|
|
32
|
+
}) => {
|
|
33
|
+
let buffered = [];
|
|
34
|
+
const send = async (taken) => {
|
|
35
|
+
const refusals = [];
|
|
36
|
+
try {
|
|
37
|
+
const client = newClient(taken.length);
|
|
38
|
+
client.on("error", (one) => refusals.push(one));
|
|
39
|
+
for (const one of taken) {
|
|
40
|
+
client.captureException(
|
|
41
|
+
one.error,
|
|
42
|
+
identityOf(one.attributes),
|
|
43
|
+
propertiesOf(one.attributes, groupName),
|
|
44
|
+
one.uuid
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
await client.shutdown();
|
|
48
|
+
return refusals.length === 0 ? Result.ok(void 0) : Result.err(failureOf(refusals[0]));
|
|
49
|
+
} catch (cause) {
|
|
50
|
+
return Result.err(failureOf(cause));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const flushable = () => {
|
|
54
|
+
const taken = buffered;
|
|
55
|
+
buffered = [];
|
|
56
|
+
return {
|
|
57
|
+
pending: taken.length,
|
|
58
|
+
promise: taken.length === 0 ? Promise.resolve(Result.ok(void 0)) : send(taken)
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
capture: async (error, attributes) => {
|
|
63
|
+
const parsed = parseAttributes(attributes);
|
|
64
|
+
if (parsed.isErr()) return Result.err(failureOf(parsed.error));
|
|
65
|
+
const taken = parsed.unwrap();
|
|
66
|
+
try {
|
|
67
|
+
const uuid = await uuidForEnvelope(taken.envelopeId);
|
|
68
|
+
buffered.push({ attributes: taken, error, uuid });
|
|
69
|
+
return Result.ok({ deduplicated: false, id: uuid });
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
return Result.err(failureOf(cause));
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
flush: () => flushable().promise,
|
|
75
|
+
flushable,
|
|
76
|
+
kind: "posthog"
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export {
|
|
81
|
+
createPostHogSink
|
|
82
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SinkFailure,
|
|
3
|
+
parseAttributes,
|
|
4
|
+
randomUuid
|
|
5
|
+
} from "./chunk-2ZTQU3OO.js";
|
|
6
|
+
|
|
7
|
+
// src/memory.ts
|
|
8
|
+
import { Result } from "better-result";
|
|
9
|
+
var idFor = (attributes) => attributes.envelopeId ?? randomUuid();
|
|
10
|
+
var createMemorySink = (options = {}) => {
|
|
11
|
+
const now = options.now ?? Date.now;
|
|
12
|
+
const captured = [];
|
|
13
|
+
let flushes = 0;
|
|
14
|
+
return {
|
|
15
|
+
capture: (error, attributes) => {
|
|
16
|
+
const parsed = parseAttributes(attributes);
|
|
17
|
+
if (parsed.isErr()) {
|
|
18
|
+
return Promise.resolve(
|
|
19
|
+
Result.err(
|
|
20
|
+
new SinkFailure({
|
|
21
|
+
cause: parsed.error,
|
|
22
|
+
message: parsed.error.message,
|
|
23
|
+
sink: "memory"
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const taken = parsed.unwrap();
|
|
29
|
+
const id = idFor(taken);
|
|
30
|
+
captured.push({ at: now(), attributes: taken, error, id });
|
|
31
|
+
return Promise.resolve(Result.ok({ deduplicated: false, id }));
|
|
32
|
+
},
|
|
33
|
+
get captured() {
|
|
34
|
+
return captured;
|
|
35
|
+
},
|
|
36
|
+
clear: () => {
|
|
37
|
+
captured.length = 0;
|
|
38
|
+
},
|
|
39
|
+
flush: () => {
|
|
40
|
+
flushes += 1;
|
|
41
|
+
return Promise.resolve(Result.ok(void 0));
|
|
42
|
+
},
|
|
43
|
+
get flushes() {
|
|
44
|
+
return flushes;
|
|
45
|
+
},
|
|
46
|
+
kind: "memory"
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
var createSwallowingSink = () => ({
|
|
50
|
+
capture: (_error, attributes) => Promise.resolve(Result.ok({ deduplicated: false, id: idFor(attributes) })),
|
|
51
|
+
flush: () => Promise.resolve(Result.ok(void 0)),
|
|
52
|
+
kind: "swallowing"
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// src/prove.ts
|
|
56
|
+
var PROBE_SERVICE = "geonosis-observability-probe";
|
|
57
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
58
|
+
var POLL_MS = 50;
|
|
59
|
+
var sleep = (ms) => new Promise((resolve) => {
|
|
60
|
+
setTimeout(resolve, ms);
|
|
61
|
+
});
|
|
62
|
+
var memoryProbe = () => {
|
|
63
|
+
const sink = createMemorySink();
|
|
64
|
+
return {
|
|
65
|
+
kind: sink.kind,
|
|
66
|
+
seen: () => Promise.resolve(sink.captured.map((one) => one.id)),
|
|
67
|
+
sink
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
var lookFor = async ({
|
|
71
|
+
deadline,
|
|
72
|
+
probe,
|
|
73
|
+
traceId
|
|
74
|
+
}) => {
|
|
75
|
+
for (; ; ) {
|
|
76
|
+
const found = await probe.seen(traceId);
|
|
77
|
+
if (found.includes(traceId)) return found;
|
|
78
|
+
if (Date.now() >= deadline) return found;
|
|
79
|
+
await sleep(POLL_MS);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
var proveObservability = async ({
|
|
83
|
+
attributes,
|
|
84
|
+
probe,
|
|
85
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
86
|
+
}) => {
|
|
87
|
+
if (probe === void 0) {
|
|
88
|
+
return { kind: "none", reason: "no sink configured", verdict: "cannot-measure" };
|
|
89
|
+
}
|
|
90
|
+
const traceId = randomUuid();
|
|
91
|
+
try {
|
|
92
|
+
const planted = await probe.sink.capture(
|
|
93
|
+
new Error(`geonosis observability probe ${traceId} \u2014 planted, not a real incident`),
|
|
94
|
+
{ service: PROBE_SERVICE, ...attributes, envelopeId: traceId }
|
|
95
|
+
);
|
|
96
|
+
if (planted.isErr()) {
|
|
97
|
+
return {
|
|
98
|
+
kind: probe.kind,
|
|
99
|
+
reason: `the sink refused the plant: ${planted.error.message}`,
|
|
100
|
+
traceId,
|
|
101
|
+
verdict: "cannot-measure"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const flushed = await probe.sink.flush();
|
|
105
|
+
if (flushed.isErr()) {
|
|
106
|
+
return {
|
|
107
|
+
kind: probe.kind,
|
|
108
|
+
reason: `the sink took the plant and could not flush it: ${flushed.error.message}`,
|
|
109
|
+
traceId,
|
|
110
|
+
verdict: "cannot-measure"
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const found = await lookFor({ deadline: Date.now() + timeoutMs, probe, traceId });
|
|
114
|
+
if (found.includes(traceId)) return { found, kind: probe.kind, traceId, verdict: "proven" };
|
|
115
|
+
return found.length === 0 ? { found, kind: probe.kind, traceId, verdict: "cannot-fail" } : { found, kind: probe.kind, traceId, verdict: "misread" };
|
|
116
|
+
} finally {
|
|
117
|
+
await probe.close?.();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
var formatProof = (proof) => {
|
|
121
|
+
if (proof.verdict === "proven") {
|
|
122
|
+
return `PROVEN observability: the planted error reached ${proof.kind} as ${proof.traceId}`;
|
|
123
|
+
}
|
|
124
|
+
if (proof.verdict === "cannot-fail") return "CANNOT FAIL: the sink swallowed it";
|
|
125
|
+
if (proof.verdict === "misread") {
|
|
126
|
+
return `MISREAD: ${proof.kind} kept ${proof.found?.length ?? 0} report(s) but not the planted ${proof.traceId} \u2014 it holds ${(proof.found ?? []).join(", ")}, so every lookup with the id the caller was handed comes back empty`;
|
|
127
|
+
}
|
|
128
|
+
return `CANNOT MEASURE: ${proof.reason ?? "the probe could not be run"}`;
|
|
129
|
+
};
|
|
130
|
+
var exitCodeOf = (proof) => proof.verdict === "proven" ? 0 : 2;
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
idFor,
|
|
134
|
+
createMemorySink,
|
|
135
|
+
createSwallowingSink,
|
|
136
|
+
PROBE_SERVICE,
|
|
137
|
+
memoryProbe,
|
|
138
|
+
proveObservability,
|
|
139
|
+
formatProof,
|
|
140
|
+
exitCodeOf
|
|
141
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiered dependency registry.
|
|
3
|
+
*
|
|
4
|
+
* during.day's `healthStatus` runs every probe on every call, in parallel, with no tier, no TTL and
|
|
5
|
+
* no timeout, and answers 503 if ANY of them failed. Its own comment records why the probes exist
|
|
6
|
+
* at all: "a check that answers for itself answers `ok` while the database behind it is gone;
|
|
7
|
+
* production returned 200 to every probe while its pages threw."
|
|
8
|
+
*
|
|
9
|
+
* Three things are missing from that, and each of them is why a real check does not get added:
|
|
10
|
+
*
|
|
11
|
+
* - **No timeout.** A probe that hangs hangs the endpoint. A check that never answers is not a
|
|
12
|
+
* check that failed, and a load balancer reads the difference as its own timeout.
|
|
13
|
+
* - **No tier.** Wire a probe for a mail provider and its outage takes production down to report a
|
|
14
|
+
* degradation. So nobody wires it, and you are back to a `/health` that checks nothing.
|
|
15
|
+
* - **No TTL.** A load balancer at 1 Hz is a query per second per instance of pure liveness
|
|
16
|
+
* traffic, which is what makes a real probe feel expensive.
|
|
17
|
+
*
|
|
18
|
+
* Midday's `packages/health/src/registry.ts` has all three, and the split this takes from it:
|
|
19
|
+
* `ready()` answers ONLY for tier 1 — is it safe to send traffic here — and `dependencies()`
|
|
20
|
+
* reports every tier. No framework is imported; the Hono and `node:http` fixtures are in the README
|
|
21
|
+
* because a health registry that depended on a server could not be used by the other one.
|
|
22
|
+
*/
|
|
23
|
+
type HealthTier = 1 | 2 | 3 | 4;
|
|
24
|
+
type HealthDependency = {
|
|
25
|
+
/** Whatever proves the thing is really there. It throws, or it does not. */
|
|
26
|
+
check: () => Promise<unknown>;
|
|
27
|
+
name: string;
|
|
28
|
+
/** 1 core (traffic must not be sent without it) … 4 optional. */
|
|
29
|
+
tier: HealthTier;
|
|
30
|
+
/** How long a good answer may be reused. Absent means every call runs the check. */
|
|
31
|
+
ttlMs?: number;
|
|
32
|
+
/** How long to wait before calling it dead. 5s by default; a hang is a failure, not a wait. */
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
};
|
|
35
|
+
type DependencyStatus = {
|
|
36
|
+
/** True when this answer came from inside its TTL rather than from a check that just ran. */
|
|
37
|
+
cached: boolean;
|
|
38
|
+
detail?: string;
|
|
39
|
+
durationMs: number;
|
|
40
|
+
name: string;
|
|
41
|
+
ok: boolean;
|
|
42
|
+
tier: HealthTier;
|
|
43
|
+
};
|
|
44
|
+
type HealthReport = {
|
|
45
|
+
/** Any dependency in this report failed — including one whose tier does not take traffic out. */
|
|
46
|
+
degraded: boolean;
|
|
47
|
+
dependencies: readonly DependencyStatus[];
|
|
48
|
+
/** Every TIER-1 dependency answered. This is the one a load balancer reads. */
|
|
49
|
+
ok: boolean;
|
|
50
|
+
status: 200 | 503;
|
|
51
|
+
};
|
|
52
|
+
type HealthRegistry = {
|
|
53
|
+
/** Every tier, for a human and for a dashboard. Its `status` still follows tier 1 alone. */
|
|
54
|
+
dependencies: () => Promise<HealthReport>;
|
|
55
|
+
/** Tier 1 only. 503 when any of them failed or timed out. */
|
|
56
|
+
ready: () => Promise<HealthReport>;
|
|
57
|
+
};
|
|
58
|
+
declare const createHealthRegistry: ({ dependencies, now, }: {
|
|
59
|
+
dependencies: readonly HealthDependency[];
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}) => HealthRegistry;
|
|
62
|
+
|
|
63
|
+
export { type DependencyStatus, type HealthDependency, type HealthRegistry, type HealthReport, type HealthTier, createHealthRegistry };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/health/index.ts
|
|
2
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
3
|
+
var TIERS = [1, 2, 3, 4];
|
|
4
|
+
var detailOf = (thrown) => thrown instanceof Error ? thrown.message : String(thrown);
|
|
5
|
+
var TimedOut = class extends Error {
|
|
6
|
+
};
|
|
7
|
+
var ran = async (dependency, now) => {
|
|
8
|
+
const timeoutMs = dependency.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
9
|
+
const started = now();
|
|
10
|
+
let timer;
|
|
11
|
+
try {
|
|
12
|
+
await Promise.race([
|
|
13
|
+
dependency.check(),
|
|
14
|
+
new Promise((_resolve, reject) => {
|
|
15
|
+
timer = setTimeout(() => reject(new TimedOut(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
16
|
+
})
|
|
17
|
+
]);
|
|
18
|
+
return { durationMs: now() - started, ok: true };
|
|
19
|
+
} catch (thrown) {
|
|
20
|
+
return { detail: detailOf(thrown), durationMs: now() - started, ok: false };
|
|
21
|
+
} finally {
|
|
22
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var declared = (dependencies) => {
|
|
26
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const one of dependencies) {
|
|
28
|
+
if (typeof one.name !== "string" || one.name.trim() === "") {
|
|
29
|
+
throw new TypeError(
|
|
30
|
+
"a health dependency must have a name: it is what a 503 names, and a dependency nobody can name is one nobody can go and look at"
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (!TIERS.includes(one.tier)) {
|
|
34
|
+
throw new TypeError(
|
|
35
|
+
`health dependency "${one.name}" has tier ${String(one.tier)} \u2014 the tiers are 1 (core) to 4 (optional), and a dependency outside them cannot be told from one that takes traffic out`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (seen.has(one.name)) {
|
|
39
|
+
throw new TypeError(
|
|
40
|
+
`two health dependencies are called "${one.name}" \u2014 a report with two answers under one name has none`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
seen.add(one.name);
|
|
44
|
+
}
|
|
45
|
+
return [...dependencies].toSorted((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
|
|
46
|
+
};
|
|
47
|
+
var createHealthRegistry = ({
|
|
48
|
+
dependencies,
|
|
49
|
+
now = Date.now
|
|
50
|
+
}) => {
|
|
51
|
+
const ordered = declared(dependencies);
|
|
52
|
+
const cache = /* @__PURE__ */ new Map();
|
|
53
|
+
const statusOf = async (dependency) => {
|
|
54
|
+
const ttlMs = dependency.ttlMs;
|
|
55
|
+
const held = cache.get(dependency.name);
|
|
56
|
+
if (ttlMs !== void 0 && held !== void 0 && now() - held.at <= ttlMs) {
|
|
57
|
+
return { ...held.status, cached: true };
|
|
58
|
+
}
|
|
59
|
+
const outcome = await ran(dependency, now);
|
|
60
|
+
const status = {
|
|
61
|
+
cached: false,
|
|
62
|
+
...outcome.detail === void 0 ? {} : { detail: outcome.detail },
|
|
63
|
+
durationMs: outcome.durationMs,
|
|
64
|
+
name: dependency.name,
|
|
65
|
+
ok: outcome.ok,
|
|
66
|
+
tier: dependency.tier
|
|
67
|
+
};
|
|
68
|
+
if (ttlMs !== void 0 && status.ok) cache.set(dependency.name, { at: now(), status });
|
|
69
|
+
return status;
|
|
70
|
+
};
|
|
71
|
+
const reportOver = async (chosen) => {
|
|
72
|
+
const statuses = await Promise.all(chosen.map((one) => statusOf(one)));
|
|
73
|
+
const core = statuses.filter((one) => one.tier === 1);
|
|
74
|
+
const ok = core.length > 0 && core.every((one) => one.ok);
|
|
75
|
+
return {
|
|
76
|
+
degraded: statuses.some((one) => !one.ok),
|
|
77
|
+
dependencies: statuses,
|
|
78
|
+
ok,
|
|
79
|
+
status: ok ? 200 : 503
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
dependencies: () => reportOver(ordered),
|
|
84
|
+
ready: () => reportOver(ordered.filter((one) => one.tier === 1))
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
export {
|
|
88
|
+
createHealthRegistry
|
|
89
|
+
};
|