@remit/doctor 0.0.1
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/package.json +29 -0
- package/src/attempt.ts +34 -0
- package/src/check.test.ts +81 -0
- package/src/check.ts +36 -0
- package/src/cli.ts +43 -0
- package/src/config.test.ts +115 -0
- package/src/config.ts +241 -0
- package/src/deadman.test.ts +31 -0
- package/src/deadman.ts +34 -0
- package/src/dwell.test.ts +120 -0
- package/src/dwell.ts +53 -0
- package/src/heartbeats.test.ts +68 -0
- package/src/heartbeats.ts +68 -0
- package/src/index.ts +18 -0
- package/src/log.ts +71 -0
- package/src/loop.test.ts +301 -0
- package/src/loop.ts +154 -0
- package/src/main.ts +69 -0
- package/src/prometheus.test.ts +105 -0
- package/src/prometheus.ts +136 -0
- package/src/report.test.ts +165 -0
- package/src/report.ts +106 -0
- package/src/scrape.test.ts +71 -0
- package/src/scrape.ts +63 -0
- package/src/state.test.ts +111 -0
- package/src/state.ts +127 -0
- package/src/verdict.test.ts +554 -0
- package/src/verdict.ts +386 -0
- package/src/webhook.test.ts +241 -0
- package/src/webhook.ts +155 -0
- package/tsconfig.json +8 -0
package/src/loop.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { attempt } from "./attempt.js";
|
|
2
|
+
import type { DoctorConfig } from "./config.js";
|
|
3
|
+
import { advance } from "./dwell.js";
|
|
4
|
+
import type { Log } from "./log.js";
|
|
5
|
+
import type { CounterState, DoctorState } from "./state.js";
|
|
6
|
+
import type { CheckResult } from "./verdict.js";
|
|
7
|
+
import type { Delivery } from "./webhook.js";
|
|
8
|
+
|
|
9
|
+
export interface LoopDependencies {
|
|
10
|
+
readonly runCheck: (
|
|
11
|
+
counters: Readonly<Record<string, CounterState>>,
|
|
12
|
+
) => Promise<CheckResult>;
|
|
13
|
+
readonly saveState: (state: DoctorState) => Promise<void>;
|
|
14
|
+
readonly postWebhook: (result: CheckResult) => Promise<Delivery>;
|
|
15
|
+
readonly pingDeadMan: () => Promise<void>;
|
|
16
|
+
readonly now: () => Date;
|
|
17
|
+
readonly log: Log;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One turn of the loop: check, decide, announce a settled change, record, ping.
|
|
22
|
+
*
|
|
23
|
+
* A transition is spent when the endpoint has answered about it, not when it is
|
|
24
|
+
* decided. A payload an endpoint rejects (4xx) is a decision that repeating
|
|
25
|
+
* cannot change, so `firedVerdict` advances and the operator gets one error
|
|
26
|
+
* line. A payload that never arrived — a timeout, a refused connection, a 5xx —
|
|
27
|
+
* is not a decision, so `firedVerdict` is left where it was and the next check
|
|
28
|
+
* announces the same transition again. `candidateRuns` is already pinned at the
|
|
29
|
+
* dwell count, so the retry is immediate rather than another three checks away.
|
|
30
|
+
*
|
|
31
|
+
* The dead-man's switch cannot cover a lost delivery. It is a different URL at
|
|
32
|
+
* a different provider and it answers 200 all week while a webhook is down, so
|
|
33
|
+
* without the retry one transient 5xx on the firing check is an outage the
|
|
34
|
+
* operator is never told about, and the next thing they hear is the recovery.
|
|
35
|
+
*
|
|
36
|
+
* The state is written after the attempt, which means a crash in the gap
|
|
37
|
+
* between a delivered alert and the write re-announces once on restart. That is
|
|
38
|
+
* the cheaper of the two failures: a duplicate is noise, a dropped outage alert
|
|
39
|
+
* is the thing this exists to prevent.
|
|
40
|
+
*
|
|
41
|
+
* The dead-man ping is last and unconditional on the verdict. It reports that
|
|
42
|
+
* the checker is running, not what the checker found — a scrape failure
|
|
43
|
+
* degrades the verdict and still pings. A check that throws before producing a
|
|
44
|
+
* verdict does not reach it, which is the whole signal.
|
|
45
|
+
*/
|
|
46
|
+
export const runOnce = async (
|
|
47
|
+
deps: LoopDependencies,
|
|
48
|
+
config: DoctorConfig,
|
|
49
|
+
state: DoctorState,
|
|
50
|
+
): Promise<DoctorState> => {
|
|
51
|
+
const result = await deps.runCheck(state.counters);
|
|
52
|
+
const transition = advance(
|
|
53
|
+
{ ...state, counters: result.counters },
|
|
54
|
+
result.verdict,
|
|
55
|
+
config.dwellChecks,
|
|
56
|
+
deps.now(),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
deps.log.debug(
|
|
60
|
+
{
|
|
61
|
+
verdict: result.verdict,
|
|
62
|
+
reasons: result.reasons.map((reason) => reason.code),
|
|
63
|
+
settledRuns: transition.state.candidateRuns,
|
|
64
|
+
fires: transition.fires ?? null,
|
|
65
|
+
},
|
|
66
|
+
"doctor: check complete",
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
let next = transition.state;
|
|
70
|
+
const fired = transition.fires;
|
|
71
|
+
if (fired !== undefined && config.webhookUrl !== undefined) {
|
|
72
|
+
const delivery = await attempt(deps.postWebhook(result));
|
|
73
|
+
const outcome: Delivery = delivery.ok
|
|
74
|
+
? delivery.value
|
|
75
|
+
: { kind: "unreachable", detail: delivery.error };
|
|
76
|
+
if (outcome.kind === "sent") {
|
|
77
|
+
deps.log.info({ verdict: fired }, "doctor: alert sent");
|
|
78
|
+
} else if (outcome.kind === "rejected") {
|
|
79
|
+
deps.log.error(
|
|
80
|
+
{ verdict: fired, error: outcome.detail },
|
|
81
|
+
"doctor: the webhook refused this payload; the transition is spent",
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
// Roll the announcement back. Everything else the check learned —
|
|
85
|
+
// the counter baselines, the settled run — is kept.
|
|
86
|
+
next = { ...next, firedVerdict: state.firedVerdict };
|
|
87
|
+
deps.log.error(
|
|
88
|
+
{ verdict: fired, error: outcome.detail },
|
|
89
|
+
"doctor: the webhook could not be reached; retrying on the next check",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await deps.saveState(next);
|
|
95
|
+
|
|
96
|
+
if (config.deadManUrl !== undefined) {
|
|
97
|
+
const ping = await attempt(deps.pingDeadMan());
|
|
98
|
+
if (!ping.ok) {
|
|
99
|
+
deps.log.error({ error: ping.error }, "doctor: dead-man ping failed");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return next;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export interface RunLoopOptions {
|
|
107
|
+
readonly deps: LoopDependencies;
|
|
108
|
+
readonly config: DoctorConfig;
|
|
109
|
+
readonly initial: DoctorState;
|
|
110
|
+
readonly signal: AbortSignal;
|
|
111
|
+
readonly sleep: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs until aborted. A check that throws is logged and the loop continues: the
|
|
116
|
+
* container that reports the stack's failures must not be brought down by one
|
|
117
|
+
* of them, and a crash-looping checker is a dead-man's switch that fires for
|
|
118
|
+
* the wrong reason.
|
|
119
|
+
*/
|
|
120
|
+
export const runLoop = async (
|
|
121
|
+
options: RunLoopOptions,
|
|
122
|
+
): Promise<DoctorState> => {
|
|
123
|
+
let state = options.initial;
|
|
124
|
+
while (!options.signal.aborted) {
|
|
125
|
+
const turn = await attempt(runOnce(options.deps, options.config, state));
|
|
126
|
+
if (turn.ok) {
|
|
127
|
+
state = turn.value;
|
|
128
|
+
} else {
|
|
129
|
+
options.deps.log.error(
|
|
130
|
+
{ error: turn.error },
|
|
131
|
+
"doctor: check failed to produce a verdict",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
await options.sleep(options.config.intervalMs, options.signal);
|
|
135
|
+
}
|
|
136
|
+
return state;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
|
140
|
+
new Promise((resolve) => {
|
|
141
|
+
if (signal.aborted) {
|
|
142
|
+
resolve();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const timer = setTimeout(() => {
|
|
146
|
+
signal.removeEventListener("abort", onAbort);
|
|
147
|
+
resolve();
|
|
148
|
+
}, ms);
|
|
149
|
+
const onAbort = () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
resolve();
|
|
152
|
+
};
|
|
153
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
154
|
+
});
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { runCheck } from "./check.js";
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { pingDeadMan } from "./deadman.js";
|
|
4
|
+
import { describeError, log, setLogLevel } from "./log.js";
|
|
5
|
+
import { runLoop, sleep } from "./loop.js";
|
|
6
|
+
import { readState, writeState } from "./state.js";
|
|
7
|
+
import { postWebhook } from "./webhook.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The `doctor` service (D9). A socket-free node process on the compose network:
|
|
11
|
+
* it scrapes the `/metrics` endpoints over that network and mounts the
|
|
12
|
+
* heartbeat volume read-only, and nothing else.
|
|
13
|
+
*
|
|
14
|
+
* It is its own container rather than a job inside the backend because an
|
|
15
|
+
* alerter inside the backend dies with the thing it is meant to report. It runs
|
|
16
|
+
* whether or not alerting is configured, because `remit doctor` execs into it.
|
|
17
|
+
*/
|
|
18
|
+
const config = await Promise.resolve()
|
|
19
|
+
.then(() => loadConfig())
|
|
20
|
+
.catch((error: unknown) => {
|
|
21
|
+
log.error({ error: describeError(error) }, "doctor: refusing to start");
|
|
22
|
+
return process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
setLogLevel(config.logLevel);
|
|
26
|
+
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
29
|
+
process.on(signal, () => {
|
|
30
|
+
log.info({ signal }, "doctor: shutting down");
|
|
31
|
+
controller.abort();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
log.info(
|
|
36
|
+
{
|
|
37
|
+
intervalSeconds: config.intervalMs / 1000,
|
|
38
|
+
dwellChecks: config.dwellChecks,
|
|
39
|
+
targets: config.targets.map((target) => target.service),
|
|
40
|
+
alerting: config.webhookUrl !== undefined,
|
|
41
|
+
},
|
|
42
|
+
"doctor: started",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
await runLoop({
|
|
46
|
+
config,
|
|
47
|
+
initial: await readState(config.stateDir),
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
sleep,
|
|
50
|
+
deps: {
|
|
51
|
+
runCheck: (counters) => runCheck(config, counters),
|
|
52
|
+
saveState: (state) => writeState(config.stateDir, state),
|
|
53
|
+
postWebhook: (result) =>
|
|
54
|
+
postWebhook(
|
|
55
|
+
{
|
|
56
|
+
// Reached only when a transition fired, which requires the URL.
|
|
57
|
+
url: config.webhookUrl ?? "",
|
|
58
|
+
template: config.webhookTemplate,
|
|
59
|
+
contentType: config.webhookContentType,
|
|
60
|
+
timeoutMs: config.requestTimeoutMs,
|
|
61
|
+
},
|
|
62
|
+
result,
|
|
63
|
+
),
|
|
64
|
+
pingDeadMan: () =>
|
|
65
|
+
pingDeadMan(config.deadManUrl ?? "", config.requestTimeoutMs),
|
|
66
|
+
now: () => new Date(),
|
|
67
|
+
log,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { parseMetrics, seriesNamed, sumOf } from "./prometheus.js";
|
|
4
|
+
|
|
5
|
+
describe("parseMetrics", () => {
|
|
6
|
+
it("reads a labelled sample", () => {
|
|
7
|
+
const samples = parseMetrics(
|
|
8
|
+
'# HELP remit_queue_messages Messages.\n# TYPE remit_queue_messages gauge\nremit_queue_messages{queue="imap-sync",role="work"} 3\n',
|
|
9
|
+
);
|
|
10
|
+
assert.deepEqual(samples, [
|
|
11
|
+
{
|
|
12
|
+
name: "remit_queue_messages",
|
|
13
|
+
labels: { queue: "imap-sync", role: "work" },
|
|
14
|
+
value: 3,
|
|
15
|
+
},
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads a sample with no labels", () => {
|
|
20
|
+
assert.deepEqual(parseMetrics("remit_search_index_backlog_rows 0\n"), [
|
|
21
|
+
{ name: "remit_search_index_backlog_rows", labels: {}, value: 0 },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("drops comments and blank lines without dropping the response", () => {
|
|
26
|
+
const samples = parseMetrics("# HELP x y\n\n \nx 1\n");
|
|
27
|
+
assert.equal(samples.length, 1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("skips a line it does not recognise rather than refusing the response", () => {
|
|
31
|
+
const samples = parseMetrics("not a sample at all\nx 1\n");
|
|
32
|
+
assert.deepEqual(
|
|
33
|
+
samples.map((sample) => sample.name),
|
|
34
|
+
["x"],
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("unescapes a quote, a backslash and a newline in a label value", () => {
|
|
39
|
+
const [sample] = parseMetrics(
|
|
40
|
+
'x{a="he said \\"hi\\"",b="c:\\\\path",c="one\\ntwo"} 1\n',
|
|
41
|
+
);
|
|
42
|
+
assert.equal(sample.labels.a, 'he said "hi"');
|
|
43
|
+
assert.equal(sample.labels.b, "c:\\path");
|
|
44
|
+
assert.equal(sample.labels.c, "one\ntwo");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps an unknown escape verbatim, both characters", () => {
|
|
48
|
+
const [sample] = parseMetrics('x{a="tab\\there"} 1\n');
|
|
49
|
+
assert.equal(sample.labels.a, "tab\\there");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("splits on the comma between pairs, not one inside a value", () => {
|
|
53
|
+
const [sample] = parseMetrics('x{a="one, two",b="three"} 1\n');
|
|
54
|
+
assert.deepEqual(sample.labels, { a: "one, two", b: "three" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reads a quoted comma at the end of a value", () => {
|
|
58
|
+
const [sample] = parseMetrics('x{a="ends\\",",b="two"} 1\n');
|
|
59
|
+
assert.deepEqual(sample.labels, { a: 'ends",', b: "two" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("reads infinities and NaN", () => {
|
|
63
|
+
const samples = parseMetrics("a +Inf\nb -Inf\nc NaN\n");
|
|
64
|
+
assert.equal(samples[0].value, Number.POSITIVE_INFINITY);
|
|
65
|
+
assert.equal(samples[1].value, Number.NEGATIVE_INFINITY);
|
|
66
|
+
assert.ok(Number.isNaN(samples[2].value));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ignores a trailing timestamp", () => {
|
|
70
|
+
const [sample] = parseMetrics("x 5 1700000000000\n");
|
|
71
|
+
assert.equal(sample.value, 5);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("reads an exponent-notation value", () => {
|
|
75
|
+
const [sample] = parseMetrics("x 1.5e+03\n");
|
|
76
|
+
assert.equal(sample.value, 1500);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("tolerates an empty label block", () => {
|
|
80
|
+
const [sample] = parseMetrics("x{} 2\n");
|
|
81
|
+
assert.deepEqual(sample.labels, {});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("stops on a malformed label block instead of throwing", () => {
|
|
85
|
+
assert.deepEqual(parseMetrics("x{a} 2\n")[0].labels, {});
|
|
86
|
+
assert.deepEqual(parseMetrics('x{a=b"} 2\n')[0].labels, {});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("seriesNamed and sumOf", () => {
|
|
91
|
+
const samples = parseMetrics('a{k="1"} 2\na{k="2"} 3\nb 9\nc +Inf\n');
|
|
92
|
+
|
|
93
|
+
it("selects one series", () => {
|
|
94
|
+
assert.equal(seriesNamed(samples, "a").length, 2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("sums a series and reads an absent one as zero", () => {
|
|
98
|
+
assert.equal(sumOf(samples, "a"), 5);
|
|
99
|
+
assert.equal(sumOf(samples, "missing"), 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not let a non-finite sample poison a sum", () => {
|
|
103
|
+
assert.equal(sumOf(samples, "c"), 0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subset of the Prometheus text exposition format this checker reads back.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written rather than a dependency: the checker is the container that has
|
|
5
|
+
* to keep working when the rest of the stack does not, and the whole grammar it
|
|
6
|
+
* needs is a name, an optional label set and a float. prom-client renders the
|
|
7
|
+
* format and does not parse it, so a parser is either sixty lines here or a
|
|
8
|
+
* third-party package in the one image whose job is to outlive failures.
|
|
9
|
+
*/
|
|
10
|
+
export interface Sample {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly labels: Readonly<Record<string, string>>;
|
|
13
|
+
readonly value: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const UNESCAPE: Readonly<Record<string, string>> = {
|
|
17
|
+
n: "\n",
|
|
18
|
+
'"': '"',
|
|
19
|
+
"\\": "\\",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Label values escape `\`, `"` and a newline, and nothing else — a `\t` in the
|
|
24
|
+
* wire format is a literal backslash followed by `t`, so an unknown escape
|
|
25
|
+
* keeps both characters rather than swallowing the backslash.
|
|
26
|
+
*/
|
|
27
|
+
const unescapeLabelValue = (raw: string): string => {
|
|
28
|
+
let out = "";
|
|
29
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
30
|
+
const char = raw[index];
|
|
31
|
+
if (char !== "\\" || index === raw.length - 1) {
|
|
32
|
+
out += char;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const next = raw[index + 1];
|
|
36
|
+
const decoded = UNESCAPE[next];
|
|
37
|
+
out += decoded ?? `\\${next}`;
|
|
38
|
+
index += 1;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Split a label block on the commas that separate pairs, ignoring any comma
|
|
45
|
+
* inside a quoted value. Written as a scan rather than a regular expression
|
|
46
|
+
* because a value may contain `",` verbatim once unescaped.
|
|
47
|
+
*/
|
|
48
|
+
const parseLabels = (block: string): Record<string, string> => {
|
|
49
|
+
const labels: Record<string, string> = {};
|
|
50
|
+
let index = 0;
|
|
51
|
+
while (index < block.length) {
|
|
52
|
+
const equals = block.indexOf("=", index);
|
|
53
|
+
if (equals === -1) break;
|
|
54
|
+
const name = block.slice(index, equals).trim();
|
|
55
|
+
const openQuote = block.indexOf('"', equals);
|
|
56
|
+
if (openQuote === -1) break;
|
|
57
|
+
let cursor = openQuote + 1;
|
|
58
|
+
let raw = "";
|
|
59
|
+
while (cursor < block.length) {
|
|
60
|
+
const char = block[cursor];
|
|
61
|
+
if (char === "\\") {
|
|
62
|
+
raw += block.slice(cursor, cursor + 2);
|
|
63
|
+
cursor += 2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === '"') break;
|
|
67
|
+
raw += char;
|
|
68
|
+
cursor += 1;
|
|
69
|
+
}
|
|
70
|
+
// Only when the value was actually closed. A truncated block is garbage,
|
|
71
|
+
// and recording a label whose value is "whatever was left" invents a
|
|
72
|
+
// series the exporter never rendered.
|
|
73
|
+
if (name !== "" && block[cursor] === '"') {
|
|
74
|
+
labels[name] = unescapeLabelValue(raw);
|
|
75
|
+
}
|
|
76
|
+
const comma = block.indexOf(",", cursor);
|
|
77
|
+
if (comma === -1) break;
|
|
78
|
+
index = comma + 1;
|
|
79
|
+
}
|
|
80
|
+
return labels;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The value, or `undefined` when the token is not one. `NaN` is a legitimate
|
|
85
|
+
* sample value and parses; a word that merely happens to sit where a value goes
|
|
86
|
+
* does not, so a line of prose cannot become a series.
|
|
87
|
+
*/
|
|
88
|
+
const parseValue = (raw: string): number | undefined => {
|
|
89
|
+
const token = raw.trim().split(/\s+/)[0] ?? "";
|
|
90
|
+
if (token === "+Inf") return Number.POSITIVE_INFINITY;
|
|
91
|
+
if (token === "-Inf") return Number.NEGATIVE_INFINITY;
|
|
92
|
+
if (token === "NaN") return Number.NaN;
|
|
93
|
+
const parsed = Number(token);
|
|
94
|
+
return token === "" || Number.isNaN(parsed) ? undefined : parsed;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const SERIES = /^([A-Za-z_:][A-Za-z0-9_:]*)(\{(.*)\})?[ \t]+(.+)$/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every sample in an exposition response, in the order it was rendered.
|
|
101
|
+
* `# HELP`/`# TYPE` lines and blanks are dropped; a line that is not a sample
|
|
102
|
+
* is skipped rather than throwing, because a scraper that refuses a whole
|
|
103
|
+
* response over one unfamiliar line reports a healthy service as unreachable.
|
|
104
|
+
*/
|
|
105
|
+
export const parseMetrics = (body: string): Sample[] => {
|
|
106
|
+
const samples: Sample[] = [];
|
|
107
|
+
for (const line of body.split("\n")) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
110
|
+
const match = SERIES.exec(trimmed);
|
|
111
|
+
if (!match) continue;
|
|
112
|
+
const [, name, , labelBlock, raw] = match;
|
|
113
|
+
const value = parseValue(raw);
|
|
114
|
+
if (value === undefined) continue;
|
|
115
|
+
samples.push({
|
|
116
|
+
name,
|
|
117
|
+
labels: labelBlock === undefined ? {} : parseLabels(labelBlock),
|
|
118
|
+
value,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return samples;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Every sample of one series, across every scraped target. */
|
|
125
|
+
export const seriesNamed = (
|
|
126
|
+
samples: readonly Sample[],
|
|
127
|
+
name: string,
|
|
128
|
+
): Sample[] => samples.filter((sample) => sample.name === name);
|
|
129
|
+
|
|
130
|
+
/** The sum of one series, `0` when nothing exported it. */
|
|
131
|
+
export const sumOf = (samples: readonly Sample[], name: string): number =>
|
|
132
|
+
seriesNamed(samples, name).reduce(
|
|
133
|
+
(total, sample) =>
|
|
134
|
+
total + (Number.isFinite(sample.value) ? sample.value : 0),
|
|
135
|
+
0,
|
|
136
|
+
);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { exitCodeFor, renderJson, renderLines } from "./report.js";
|
|
4
|
+
import type { CheckResult } from "./verdict.js";
|
|
5
|
+
|
|
6
|
+
const degraded: CheckResult = {
|
|
7
|
+
verdict: "degraded",
|
|
8
|
+
checkedAt: "2026-07-27T10:00:00.000Z",
|
|
9
|
+
summary: "remit is degraded",
|
|
10
|
+
reasons: [
|
|
11
|
+
{
|
|
12
|
+
code: "account_sync_stalled",
|
|
13
|
+
summary: "1 of 3 accounts have not completed a sync in over 3h",
|
|
14
|
+
detail: "0f8a: 40000s",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
code: "dead_letter_queue_not_empty",
|
|
18
|
+
summary:
|
|
19
|
+
"2 messages are quarantined on 1 dead-letter queue (imap-sync-dlq)",
|
|
20
|
+
detail: undefined,
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
counters: {},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const healthy: CheckResult = {
|
|
27
|
+
verdict: "healthy",
|
|
28
|
+
checkedAt: "2026-07-27T10:00:00.000Z",
|
|
29
|
+
summary: "remit is healthy",
|
|
30
|
+
reasons: [],
|
|
31
|
+
counters: {},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The parse the wrapper does: first token is the key, the rest is the value. */
|
|
35
|
+
const parseLines = (out: string): [string, string][] =>
|
|
36
|
+
out
|
|
37
|
+
.split("\n")
|
|
38
|
+
.filter((line) => line !== "")
|
|
39
|
+
.map((line) => {
|
|
40
|
+
const space = line.indexOf(" ");
|
|
41
|
+
return [line.slice(0, space), line.slice(space + 1)] as [string, string];
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("the line format", () => {
|
|
45
|
+
it("opens with the verdict, the timestamp and the headline", () => {
|
|
46
|
+
const records = parseLines(renderLines(degraded));
|
|
47
|
+
assert.deepEqual(records.slice(0, 3), [
|
|
48
|
+
["verdict", "degraded"],
|
|
49
|
+
["checked-at", "2026-07-27T10:00:00.000Z"],
|
|
50
|
+
["summary", "remit is degraded"],
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("carries one record per reason, then the details", () => {
|
|
55
|
+
const records = parseLines(renderLines(degraded));
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
records.filter(([key]) => key === "reason").map(([, value]) => value),
|
|
58
|
+
[
|
|
59
|
+
"account_sync_stalled 1 of 3 accounts have not completed a sync in over 3h",
|
|
60
|
+
"dead_letter_queue_not_empty 2 messages are quarantined on 1 dead-letter queue (imap-sync-dlq)",
|
|
61
|
+
],
|
|
62
|
+
);
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
records.filter(([key]) => key === "detail").map(([, value]) => value),
|
|
65
|
+
["account_sync_stalled 0f8a: 40000s"],
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("uses a closed key vocabulary, so an unknown key is a version skew and not a value", () => {
|
|
70
|
+
const keys = new Set(parseLines(renderLines(degraded)).map(([key]) => key));
|
|
71
|
+
assert.deepEqual([...keys].sort(), [
|
|
72
|
+
"checked-at",
|
|
73
|
+
"detail",
|
|
74
|
+
"reason",
|
|
75
|
+
"summary",
|
|
76
|
+
"verdict",
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("puts no reason records in a healthy report", () => {
|
|
81
|
+
const records = parseLines(renderLines(healthy));
|
|
82
|
+
assert.equal(records.length, 3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("never wraps a record, so one line is always one record", () => {
|
|
86
|
+
for (const line of renderLines(degraded).trimEnd().split("\n")) {
|
|
87
|
+
assert.ok(line.length > 0);
|
|
88
|
+
assert.ok(!line.includes("\n"));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// A queue name can hold a newline: the exposition format escapes it and the
|
|
93
|
+
// parser decodes it back. Split across two lines, a caller reading by
|
|
94
|
+
// position takes the remainder as a record with a garbage key and silently
|
|
95
|
+
// drops half the reason. The JSON body escapes its way out of this; a line
|
|
96
|
+
// format cannot.
|
|
97
|
+
it("keeps a newline in a value from splitting the record", () => {
|
|
98
|
+
const nasty: CheckResult = {
|
|
99
|
+
...degraded,
|
|
100
|
+
reasons: [
|
|
101
|
+
{
|
|
102
|
+
code: "dead_letter_queue_not_empty",
|
|
103
|
+
summary:
|
|
104
|
+
'2 messages are quarantined on 1 dead-letter queue (bad\nname"x)',
|
|
105
|
+
detail: "first\r\nsecond",
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
const out = renderLines(nasty);
|
|
110
|
+
assert.equal(out.trimEnd().split("\n").length, 5);
|
|
111
|
+
const records = parseLines(out);
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
records.filter(([key]) => key === "reason").map(([, value]) => value),
|
|
114
|
+
[
|
|
115
|
+
'dead_letter_queue_not_empty 2 messages are quarantined on 1 dead-letter queue (bad name"x)',
|
|
116
|
+
],
|
|
117
|
+
);
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
records.filter(([key]) => key === "detail").map(([, value]) => value),
|
|
120
|
+
["dead_letter_queue_not_empty first second"],
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("collapses every C0 control character, not only the newline", () => {
|
|
125
|
+
const out = renderLines({
|
|
126
|
+
...degraded,
|
|
127
|
+
reasons: [
|
|
128
|
+
{
|
|
129
|
+
code: "scrape_failed",
|
|
130
|
+
summary: "a\u0000b\u001bc\u007fd",
|
|
131
|
+
detail: undefined,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
assert.equal(out.trimEnd().split("\n").length, 4);
|
|
136
|
+
assert.match(out, /reason scrape_failed a b c d/);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("the json format", () => {
|
|
141
|
+
it("parses, and carries the same verdict and reasons", () => {
|
|
142
|
+
const parsed = JSON.parse(renderJson(degraded)) as {
|
|
143
|
+
verdict: string;
|
|
144
|
+
reasons: { code: string; summary: string; detail: string | null }[];
|
|
145
|
+
};
|
|
146
|
+
assert.equal(parsed.verdict, "degraded");
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
parsed.reasons.map((reason) => reason.code),
|
|
149
|
+
["account_sync_stalled", "dead_letter_queue_not_empty"],
|
|
150
|
+
);
|
|
151
|
+
assert.equal(parsed.reasons[1].detail, null);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("renders a healthy verdict as an empty reason list, not an absent key", () => {
|
|
155
|
+
const parsed = JSON.parse(renderJson(healthy)) as { reasons: unknown[] };
|
|
156
|
+
assert.deepEqual(parsed.reasons, []);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("exit codes", () => {
|
|
161
|
+
it("is zero on healthy and non-zero on degraded, so cron can use it directly", () => {
|
|
162
|
+
assert.equal(exitCodeFor(healthy), 0);
|
|
163
|
+
assert.equal(exitCodeFor(degraded), 1);
|
|
164
|
+
});
|
|
165
|
+
});
|