@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/verdict.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type { HeartbeatReading } from "./heartbeats.js";
|
|
2
|
+
import { seriesNamed } from "./prometheus.js";
|
|
3
|
+
import type { ScrapeResult } from "./scrape.js";
|
|
4
|
+
import type { CounterState } from "./state.js";
|
|
5
|
+
|
|
6
|
+
export type Verdict = "healthy" | "degraded";
|
|
7
|
+
|
|
8
|
+
export type ReasonCode =
|
|
9
|
+
| "scrape_failed"
|
|
10
|
+
| "worker_heartbeat_stale"
|
|
11
|
+
| "dead_letter_queue_not_empty"
|
|
12
|
+
| "account_sync_stalled"
|
|
13
|
+
| "mail_auth_failing"
|
|
14
|
+
| "signal_missing";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One thing that is wrong.
|
|
18
|
+
*
|
|
19
|
+
* `summary` carries counts, service names and queue names, and nothing else —
|
|
20
|
+
* D10. It is the only field a webhook payload ever reads, so the rule is a
|
|
21
|
+
* property of the type rather than a habit of the renderer: an address, a
|
|
22
|
+
* subject or an account id in a summary would leave the box, and there is no
|
|
23
|
+
* path by which `detail` can.
|
|
24
|
+
*
|
|
25
|
+
* `detail` is what the operator needs at a shell on the box to act on the
|
|
26
|
+
* reason — the account ids behind "2 of 5 accounts". It is printed by the exec
|
|
27
|
+
* seam and never sent anywhere.
|
|
28
|
+
*
|
|
29
|
+
* The boundary is enforced by a test, not by the type: `verdict.test.ts` runs
|
|
30
|
+
* every reason over a scrape whose `account_id`, `folder` and `mailbox` labels
|
|
31
|
+
* are a sentinel and asserts no summary contains it. That is a tripwire, not a
|
|
32
|
+
* guarantee — it cannot catch a NEW reason derived from a series the fixture
|
|
33
|
+
* does not carry, and the `deepEqual` on the reason-code set only makes such a
|
|
34
|
+
* reason noticeable, not safe.
|
|
35
|
+
*
|
|
36
|
+
* The thing that would actually scale this to reasons nobody has written yet is
|
|
37
|
+
* a branded `Summary` type that only a sanitising constructor can produce, so
|
|
38
|
+
* interpolating a raw label into one is a compile error rather than a review
|
|
39
|
+
* catch. If you are adding a reason and reaching for a label value here, that is
|
|
40
|
+
* the change to make first.
|
|
41
|
+
*/
|
|
42
|
+
export interface Reason {
|
|
43
|
+
readonly code: ReasonCode;
|
|
44
|
+
readonly summary: string;
|
|
45
|
+
readonly detail: string | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CheckResult {
|
|
49
|
+
readonly verdict: Verdict;
|
|
50
|
+
readonly checkedAt: string;
|
|
51
|
+
readonly summary: string;
|
|
52
|
+
readonly reasons: readonly Reason[];
|
|
53
|
+
/**
|
|
54
|
+
* Counter totals to compare the next check against. Carried forward
|
|
55
|
+
* unchanged for any service that did not answer this time, so a scrape
|
|
56
|
+
* failure cannot manufacture a delta when the service comes back.
|
|
57
|
+
*/
|
|
58
|
+
readonly counters: Readonly<Record<string, CounterState>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VerdictThresholds {
|
|
62
|
+
readonly heartbeatMaxAgeSeconds: number;
|
|
63
|
+
readonly syncAgeMaxSeconds: number;
|
|
64
|
+
readonly authFailureHoldSeconds: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface VerdictInput extends VerdictThresholds {
|
|
68
|
+
readonly scrapes: readonly ScrapeResult[];
|
|
69
|
+
readonly heartbeats: readonly HeartbeatReading[];
|
|
70
|
+
readonly previousCounters: Readonly<Record<string, CounterState>>;
|
|
71
|
+
readonly now: Date;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const IMAP_AUTH = "imap-worker:imap_auth_failures";
|
|
75
|
+
const SMTP_AUTH = "smtp-worker:smtp_auth_failures";
|
|
76
|
+
|
|
77
|
+
export const formatDuration = (seconds: number): string => {
|
|
78
|
+
if (seconds >= 3600) {
|
|
79
|
+
const hours = seconds / 3600;
|
|
80
|
+
return `${Number.isInteger(hours) ? hours : hours.toFixed(1)}h`;
|
|
81
|
+
}
|
|
82
|
+
if (seconds >= 60) {
|
|
83
|
+
const minutes = seconds / 60;
|
|
84
|
+
return `${Number.isInteger(minutes) ? minutes : minutes.toFixed(1)}m`;
|
|
85
|
+
}
|
|
86
|
+
return `${seconds}s`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const plural = (count: number, one: string, many: string): string =>
|
|
90
|
+
count === 1 ? one : many;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* "1 of 4 services is not answering". The noun agrees with the total and the
|
|
94
|
+
* verb with the affected count, which is the only combination that reads as
|
|
95
|
+
* English at every pair of numbers.
|
|
96
|
+
*/
|
|
97
|
+
const outOf = (
|
|
98
|
+
affected: number,
|
|
99
|
+
total: number,
|
|
100
|
+
noun: readonly [string, string],
|
|
101
|
+
verb: readonly [string, string],
|
|
102
|
+
): string =>
|
|
103
|
+
`${affected} of ${total} ${plural(total, noun[0], noun[1])} ${plural(affected, verb[0], verb[1])}`;
|
|
104
|
+
|
|
105
|
+
const scrapeFailures = (
|
|
106
|
+
scrapes: readonly ScrapeResult[],
|
|
107
|
+
): Reason | undefined => {
|
|
108
|
+
const failed = scrapes.filter((scrape) => scrape.error !== undefined);
|
|
109
|
+
if (failed.length === 0) return undefined;
|
|
110
|
+
const names = failed.map((scrape) => scrape.service).join(", ");
|
|
111
|
+
return {
|
|
112
|
+
code: "scrape_failed",
|
|
113
|
+
summary: `${outOf(failed.length, scrapes.length, ["service", "services"], ["is", "are"])} not answering /metrics (${names})`,
|
|
114
|
+
detail: failed
|
|
115
|
+
.map((scrape) => `${scrape.service}: ${scrape.error}`)
|
|
116
|
+
.join("; "),
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const staleHeartbeats = (
|
|
121
|
+
heartbeats: readonly HeartbeatReading[],
|
|
122
|
+
maxAgeSeconds: number,
|
|
123
|
+
): Reason | undefined => {
|
|
124
|
+
const stale = heartbeats.filter(
|
|
125
|
+
(reading) =>
|
|
126
|
+
reading.ageSeconds === undefined || reading.ageSeconds > maxAgeSeconds,
|
|
127
|
+
);
|
|
128
|
+
if (stale.length === 0) return undefined;
|
|
129
|
+
const names = stale.map((reading) => reading.service).join(", ");
|
|
130
|
+
return {
|
|
131
|
+
code: "worker_heartbeat_stale",
|
|
132
|
+
summary: `${outOf(stale.length, heartbeats.length, ["worker", "workers"], ["has", "have"])} stopped polling for over ${formatDuration(maxAgeSeconds)} (${names})`,
|
|
133
|
+
detail: stale
|
|
134
|
+
.map((reading) =>
|
|
135
|
+
reading.ageSeconds === undefined
|
|
136
|
+
? `${reading.service}: ${reading.error}`
|
|
137
|
+
: `${reading.service}: ${Math.round(reading.ageSeconds)}s`,
|
|
138
|
+
)
|
|
139
|
+
.join("; "),
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const deadLetterDepth = (
|
|
144
|
+
samples: readonly ScrapeResult[],
|
|
145
|
+
): Reason | undefined => {
|
|
146
|
+
const all = samples.flatMap((scrape) => [...scrape.samples]);
|
|
147
|
+
const occupied = seriesNamed(all, "remit_queue_messages").filter(
|
|
148
|
+
(sample) => sample.labels.role === "dead_letter" && sample.value > 0,
|
|
149
|
+
);
|
|
150
|
+
if (occupied.length === 0) return undefined;
|
|
151
|
+
const total = occupied.reduce((sum, sample) => sum + sample.value, 0);
|
|
152
|
+
const names = occupied
|
|
153
|
+
.map((sample) => sample.labels.queue ?? "unknown")
|
|
154
|
+
.sort()
|
|
155
|
+
.join(", ");
|
|
156
|
+
return {
|
|
157
|
+
code: "dead_letter_queue_not_empty",
|
|
158
|
+
summary: `${total} ${plural(total, "message is", "messages are")} quarantined on ${occupied.length} ${plural(occupied.length, "dead-letter queue", "dead-letter queues")} (${names})`,
|
|
159
|
+
detail: undefined,
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const stalledSync = (
|
|
164
|
+
samples: readonly ScrapeResult[],
|
|
165
|
+
maxAgeSeconds: number,
|
|
166
|
+
): Reason | undefined => {
|
|
167
|
+
const all = samples.flatMap((scrape) => [...scrape.samples]);
|
|
168
|
+
const ages = seriesNamed(all, "remit_account_sync_age_seconds");
|
|
169
|
+
const stalled = ages.filter((sample) => sample.value > maxAgeSeconds);
|
|
170
|
+
if (stalled.length === 0) return undefined;
|
|
171
|
+
return {
|
|
172
|
+
code: "account_sync_stalled",
|
|
173
|
+
// Counts only. Which accounts is on the box, behind `remit doctor`.
|
|
174
|
+
summary: `${outOf(stalled.length, ages.length, ["account", "accounts"], ["has", "have"])} not completed a sync in over ${formatDuration(maxAgeSeconds)}`,
|
|
175
|
+
detail: stalled
|
|
176
|
+
.map(
|
|
177
|
+
(sample) =>
|
|
178
|
+
`${sample.labels.account_id ?? "unknown"}: ${Math.round(sample.value)}s`,
|
|
179
|
+
)
|
|
180
|
+
.sort()
|
|
181
|
+
.join("; "),
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
interface CounterReading {
|
|
186
|
+
readonly key: string;
|
|
187
|
+
/** `undefined` when the exporting service did not answer this check. */
|
|
188
|
+
readonly state: CounterState | undefined;
|
|
189
|
+
/** How much it went up by, when this check is the one that saw it rise. */
|
|
190
|
+
readonly delta: number | undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Authentication failures are counters, and a counter that has been non-zero
|
|
195
|
+
* since March is not news — an alert on the total fires forever after one
|
|
196
|
+
* expired grant. The increase since the previous check is the one piece of
|
|
197
|
+
* history a checker with a state volume can hold without a time-series
|
|
198
|
+
* database.
|
|
199
|
+
*
|
|
200
|
+
* That increase is true for exactly one check, which is not long enough to
|
|
201
|
+
* satisfy a dwell, so `lastRoseAt` turns the instant into a condition: the
|
|
202
|
+
* signal stays on until the counter has stopped rising for the hold window.
|
|
203
|
+
* Failures arrive in one burst per sync tick, so the quiet stretch between two
|
|
204
|
+
* bursts is not a recovery and must not read as one.
|
|
205
|
+
*
|
|
206
|
+
* A service that did not answer has no reading: its previous state is carried
|
|
207
|
+
* forward untouched, so the delta on its return is measured against what it
|
|
208
|
+
* last really exported rather than against a zero nobody observed — and its
|
|
209
|
+
* `lastRoseAt` keeps holding the condition open across the outage.
|
|
210
|
+
*
|
|
211
|
+
* A total below the previous one is the exporter having restarted, not work
|
|
212
|
+
* being undone, so the whole current total counts as new.
|
|
213
|
+
*/
|
|
214
|
+
const readCounter = (
|
|
215
|
+
scrapes: readonly ScrapeResult[],
|
|
216
|
+
key: string,
|
|
217
|
+
service: string,
|
|
218
|
+
metric: string,
|
|
219
|
+
previous: Readonly<Record<string, CounterState>>,
|
|
220
|
+
now: number,
|
|
221
|
+
): CounterReading => {
|
|
222
|
+
const before = previous[key];
|
|
223
|
+
const scrape = scrapes.find((candidate) => candidate.service === service);
|
|
224
|
+
if (scrape === undefined || scrape.error !== undefined) {
|
|
225
|
+
return { key, state: before, delta: undefined };
|
|
226
|
+
}
|
|
227
|
+
const total = seriesNamed(scrape.samples, metric)
|
|
228
|
+
.filter((sample) => sample.labels.kind === "auth")
|
|
229
|
+
.reduce((sum, sample) => sum + sample.value, 0);
|
|
230
|
+
if (before === undefined) {
|
|
231
|
+
// First sight of the counter is a baseline, not an event. Whatever it
|
|
232
|
+
// already holds happened before this checker was watching.
|
|
233
|
+
return { key, state: { total, lastRoseAt: null }, delta: undefined };
|
|
234
|
+
}
|
|
235
|
+
const delta = total < before.total ? total : total - before.total;
|
|
236
|
+
if (delta <= 0) return { key, state: { ...before, total }, delta: undefined };
|
|
237
|
+
return { key, state: { total, lastRoseAt: now }, delta };
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const PROTOCOL: Readonly<Record<string, string>> = {
|
|
241
|
+
[IMAP_AUTH]: "IMAP",
|
|
242
|
+
[SMTP_AUTH]: "SMTP",
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Failing while the counter is still rising, and for `holdSeconds` after the
|
|
247
|
+
* last rise. The summary says how long ago rather than how many, because the
|
|
248
|
+
* count is an artefact of the retry cadence and the age is the fact the
|
|
249
|
+
* operator acts on.
|
|
250
|
+
*/
|
|
251
|
+
const authFailures = (
|
|
252
|
+
readings: readonly CounterReading[],
|
|
253
|
+
holdSeconds: number,
|
|
254
|
+
now: number,
|
|
255
|
+
): Reason | undefined => {
|
|
256
|
+
const failing = readings.filter((reading) => {
|
|
257
|
+
const rose = reading.state?.lastRoseAt;
|
|
258
|
+
return (
|
|
259
|
+
rose !== undefined && rose !== null && now - rose <= holdSeconds * 1000
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
if (failing.length === 0) return undefined;
|
|
263
|
+
const parts = failing.map((reading) => {
|
|
264
|
+
const since = Math.max(0, now - (reading.state?.lastRoseAt ?? now)) / 1000;
|
|
265
|
+
return `${PROTOCOL[reading.key] ?? "mail"} (last failure ${formatDuration(Math.round(since))} ago)`;
|
|
266
|
+
});
|
|
267
|
+
return {
|
|
268
|
+
code: "mail_auth_failing",
|
|
269
|
+
// No address and no account id: the counters carry neither, and the
|
|
270
|
+
// operator identifies the mailbox by running `remit doctor` on the box.
|
|
271
|
+
summary: `mail authentication is failing: ${parts.join(", ")}`,
|
|
272
|
+
detail: undefined,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A 200 that carries no samples is not a healthy service. A metric rename, a
|
|
278
|
+
* collector that starts returning nothing instead of throwing, or a target on
|
|
279
|
+
* the wrong port that happens to answer 200 would all render as "nothing
|
|
280
|
+
* wrong", which is the `healthy`-produced-by-a-check-that-failed-to-look that
|
|
281
|
+
* D4 rules out.
|
|
282
|
+
*
|
|
283
|
+
* Only `remit_queue_messages` is required. It is the one series a working
|
|
284
|
+
* deployment always exports — the queue set is declared in `queues.json` and
|
|
285
|
+
* the sidecar renders a sample per queue whether or not anything is on it.
|
|
286
|
+
* `remit_account_sync_age_seconds` is legitimately empty on a fresh install
|
|
287
|
+
* with no mailbox yet, and the auth counters do not exist until something has
|
|
288
|
+
* failed once, so neither can be required without alerting on a healthy
|
|
289
|
+
* install.
|
|
290
|
+
*/
|
|
291
|
+
const REQUIRED_SERIES: readonly { service: string; metric: string }[] = [
|
|
292
|
+
{ service: "queue", metric: "remit_queue_messages" },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
const missingSeries = (
|
|
296
|
+
scrapes: readonly ScrapeResult[],
|
|
297
|
+
): Reason | undefined => {
|
|
298
|
+
const missing = REQUIRED_SERIES.flatMap(({ service, metric }) => {
|
|
299
|
+
const scrape = scrapes.find((candidate) => candidate.service === service);
|
|
300
|
+
// Not configured at all is missing, not fine. A `DOCTOR_TARGETS` with no
|
|
301
|
+
// queue endpoint would otherwise read healthy with the dead-letter signal
|
|
302
|
+
// silently gone — the epic's headline check failing open, and the one
|
|
303
|
+
// failure mode with no symptom.
|
|
304
|
+
if (scrape === undefined) {
|
|
305
|
+
return [{ service, why: `${metric} has no configured target` }];
|
|
306
|
+
}
|
|
307
|
+
// A target that did not answer is already `scrape_failed`. Saying it twice
|
|
308
|
+
// tells the operator nothing and costs a line in the alert.
|
|
309
|
+
if (scrape.error !== undefined) return [];
|
|
310
|
+
return seriesNamed(scrape.samples, metric).length === 0
|
|
311
|
+
? [{ service, why: `${metric} absent from the response` }]
|
|
312
|
+
: [];
|
|
313
|
+
});
|
|
314
|
+
if (missing.length === 0) return undefined;
|
|
315
|
+
return {
|
|
316
|
+
code: "signal_missing",
|
|
317
|
+
summary: `${missing.length} ${plural(missing.length, "signal", "signals")} the check depends on ${plural(missing.length, "is", "are")} not being read (${missing.map(({ service }) => service).join(", ")})`,
|
|
318
|
+
detail: missing.map(({ service, why }) => `${service}: ${why}`).join("; "),
|
|
319
|
+
};
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const ORDER: readonly ReasonCode[] = [
|
|
323
|
+
"scrape_failed",
|
|
324
|
+
"signal_missing",
|
|
325
|
+
"worker_heartbeat_stale",
|
|
326
|
+
"account_sync_stalled",
|
|
327
|
+
"mail_auth_failing",
|
|
328
|
+
"dead_letter_queue_not_empty",
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The verdict, from the signals as read. Pure: every input is a value, so the
|
|
333
|
+
* loop, the exec seam and the tests all evaluate the same function.
|
|
334
|
+
*
|
|
335
|
+
* A signal that could not be evaluated is `degraded`, never skipped — a
|
|
336
|
+
* `healthy` produced by a check that failed to look is the worst outcome
|
|
337
|
+
* available.
|
|
338
|
+
*/
|
|
339
|
+
export const evaluate = (input: VerdictInput): CheckResult => {
|
|
340
|
+
const now = input.now.getTime();
|
|
341
|
+
const counters = [
|
|
342
|
+
readCounter(
|
|
343
|
+
input.scrapes,
|
|
344
|
+
IMAP_AUTH,
|
|
345
|
+
"imap-worker",
|
|
346
|
+
"remit_imap_failures_total",
|
|
347
|
+
input.previousCounters,
|
|
348
|
+
now,
|
|
349
|
+
),
|
|
350
|
+
readCounter(
|
|
351
|
+
input.scrapes,
|
|
352
|
+
SMTP_AUTH,
|
|
353
|
+
"smtp-worker",
|
|
354
|
+
"remit_smtp_failures_total",
|
|
355
|
+
input.previousCounters,
|
|
356
|
+
now,
|
|
357
|
+
),
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
const found = [
|
|
361
|
+
scrapeFailures(input.scrapes),
|
|
362
|
+
missingSeries(input.scrapes),
|
|
363
|
+
staleHeartbeats(input.heartbeats, input.heartbeatMaxAgeSeconds),
|
|
364
|
+
stalledSync(input.scrapes, input.syncAgeMaxSeconds),
|
|
365
|
+
authFailures(counters, input.authFailureHoldSeconds, now),
|
|
366
|
+
deadLetterDepth(input.scrapes),
|
|
367
|
+
].filter((reason): reason is Reason => reason !== undefined);
|
|
368
|
+
|
|
369
|
+
const reasons = [...found].sort(
|
|
370
|
+
(left, right) => ORDER.indexOf(left.code) - ORDER.indexOf(right.code),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const nextCounters = { ...input.previousCounters };
|
|
374
|
+
for (const reading of counters) {
|
|
375
|
+
if (reading.state !== undefined) nextCounters[reading.key] = reading.state;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const verdict: Verdict = reasons.length === 0 ? "healthy" : "degraded";
|
|
379
|
+
return {
|
|
380
|
+
verdict,
|
|
381
|
+
checkedAt: input.now.toISOString(),
|
|
382
|
+
summary: verdict === "healthy" ? "remit is healthy" : "remit is degraded",
|
|
383
|
+
reasons,
|
|
384
|
+
counters: nextCounters,
|
|
385
|
+
};
|
|
386
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { CheckResult, Reason } from "./verdict.js";
|
|
4
|
+
import {
|
|
5
|
+
buildBody,
|
|
6
|
+
defaultTemplate,
|
|
7
|
+
escapeFor,
|
|
8
|
+
expandTemplate,
|
|
9
|
+
payloadValues,
|
|
10
|
+
postWebhook,
|
|
11
|
+
render,
|
|
12
|
+
} from "./webhook.js";
|
|
13
|
+
|
|
14
|
+
const result = (
|
|
15
|
+
verdict: "healthy" | "degraded",
|
|
16
|
+
reasons: readonly Reason[] = [],
|
|
17
|
+
): CheckResult => ({
|
|
18
|
+
verdict,
|
|
19
|
+
checkedAt: "2026-07-27T10:00:00.000Z",
|
|
20
|
+
summary: verdict === "healthy" ? "remit is healthy" : "remit is degraded",
|
|
21
|
+
reasons,
|
|
22
|
+
counters: {},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const reason = (summary: string, detail?: string): Reason => ({
|
|
26
|
+
code: "dead_letter_queue_not_empty",
|
|
27
|
+
summary,
|
|
28
|
+
detail,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("the default templates", () => {
|
|
32
|
+
it("produces valid JSON Slack accepts, for a real reason set", () => {
|
|
33
|
+
const body = buildBody(
|
|
34
|
+
result("degraded", [
|
|
35
|
+
reason(
|
|
36
|
+
"3 messages are quarantined on 1 dead-letter queue (imap-sync-dlq)",
|
|
37
|
+
),
|
|
38
|
+
reason("2 of 5 accounts have not completed a sync in over 3h"),
|
|
39
|
+
]),
|
|
40
|
+
undefined,
|
|
41
|
+
"application/json",
|
|
42
|
+
);
|
|
43
|
+
const parsed = JSON.parse(body) as { text: string };
|
|
44
|
+
assert.match(parsed.text, /^remit is degraded\n/);
|
|
45
|
+
assert.match(parsed.text, /• 3 messages are quarantined/);
|
|
46
|
+
assert.match(parsed.text, /• 2 of 5 accounts/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("produces a plain body for ntfy, with real newlines", () => {
|
|
50
|
+
const body = buildBody(
|
|
51
|
+
result("degraded", [
|
|
52
|
+
reason("1 worker has stopped polling (imap-worker)"),
|
|
53
|
+
]),
|
|
54
|
+
undefined,
|
|
55
|
+
"text/plain",
|
|
56
|
+
);
|
|
57
|
+
assert.equal(
|
|
58
|
+
body,
|
|
59
|
+
"remit is degraded\n• 1 worker has stopped polling (imap-worker)",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("says so plainly when a recovery has nothing to list", () => {
|
|
64
|
+
const body = buildBody(result("healthy"), undefined, "text/plain");
|
|
65
|
+
assert.equal(body, "remit is healthy\nno problems found");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("escaping", () => {
|
|
70
|
+
it("keeps a quote, a backslash and a newline from breaking the JSON document", () => {
|
|
71
|
+
const body = buildBody(
|
|
72
|
+
result("degraded", [reason('queue "odd\\name"\nsecond line')]),
|
|
73
|
+
undefined,
|
|
74
|
+
"application/json",
|
|
75
|
+
);
|
|
76
|
+
const parsed = JSON.parse(body) as { text: string };
|
|
77
|
+
assert.match(parsed.text, /queue "odd\\name"/);
|
|
78
|
+
assert.match(parsed.text, /\nsecond line/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("survives a control character", () => {
|
|
82
|
+
const body = buildBody(
|
|
83
|
+
result("degraded", [reason("tab\there and a bell")]),
|
|
84
|
+
undefined,
|
|
85
|
+
"application/json",
|
|
86
|
+
);
|
|
87
|
+
assert.doesNotThrow(() => JSON.parse(body));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("leaves a plain-text body exactly as it reads", () => {
|
|
91
|
+
const body = buildBody(
|
|
92
|
+
result("degraded", [reason('a "quoted" thing')]),
|
|
93
|
+
undefined,
|
|
94
|
+
"text/plain",
|
|
95
|
+
);
|
|
96
|
+
assert.match(body, /a "quoted" thing/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("escapes for a charset-qualified JSON content type too", () => {
|
|
100
|
+
assert.equal(escapeFor("application/json; charset=utf-8")('"'), '\\"');
|
|
101
|
+
assert.equal(escapeFor("text/plain; charset=utf-8")('"'), '"');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("operator templates", () => {
|
|
106
|
+
it("substitutes the three documented placeholders", () => {
|
|
107
|
+
const body = buildBody(
|
|
108
|
+
result("degraded", [reason("one thing")]),
|
|
109
|
+
'{"title":"{{verdict}}","body":"{{summary}} / {{reasons}}"}',
|
|
110
|
+
"application/json",
|
|
111
|
+
);
|
|
112
|
+
const parsed = JSON.parse(body) as { title: string; body: string };
|
|
113
|
+
assert.equal(parsed.title, "degraded");
|
|
114
|
+
assert.equal(parsed.body, "remit is degraded / • one thing");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("leaves anything else in braces alone", () => {
|
|
118
|
+
assert.equal(
|
|
119
|
+
render(
|
|
120
|
+
"{{summary}} {{unknown}} {not a placeholder}",
|
|
121
|
+
{ verdict: "healthy", summary: "s", reasons: "r" },
|
|
122
|
+
(value) => value,
|
|
123
|
+
),
|
|
124
|
+
"s {{unknown}} {not a placeholder}",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("turns a backslash-n in a plain-text template into a newline, since .env cannot carry one", () => {
|
|
129
|
+
assert.equal(expandTemplate("a\\nb", "text/plain"), "a\nb");
|
|
130
|
+
assert.equal(expandTemplate("a\\tb", "text/plain"), "a\tb");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves a JSON template's own escapes alone", () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
expandTemplate('{"t":"a\\nb"}', "application/json"),
|
|
136
|
+
'{"t":"a\\nb"}',
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("defaults by content type", () => {
|
|
141
|
+
assert.match(defaultTemplate("application/json"), /^\{"text"/);
|
|
142
|
+
assert.equal(defaultTemplate("text/plain"), "{{summary}}\n{{reasons}}");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("what a payload may carry", () => {
|
|
147
|
+
it("never reaches a reason's local-only detail", () => {
|
|
148
|
+
const values = payloadValues(
|
|
149
|
+
result("degraded", [
|
|
150
|
+
reason(
|
|
151
|
+
"1 of 3 accounts have not completed a sync in over 3h",
|
|
152
|
+
"0f8a-secret: 40000s",
|
|
153
|
+
),
|
|
154
|
+
]),
|
|
155
|
+
);
|
|
156
|
+
for (const value of Object.values(values)) {
|
|
157
|
+
assert.ok(!value.includes("0f8a-secret"));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("has exactly three fields, so nothing new can leak in by accident", () => {
|
|
162
|
+
assert.deepEqual(Object.keys(payloadValues(result("healthy"))).sort(), [
|
|
163
|
+
"reasons",
|
|
164
|
+
"summary",
|
|
165
|
+
"verdict",
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("postWebhook", () => {
|
|
171
|
+
it("posts the rendered body with the declared content type", async () => {
|
|
172
|
+
let seen: { url: string; init: RequestInit } | undefined;
|
|
173
|
+
const outcome = await postWebhook(
|
|
174
|
+
{
|
|
175
|
+
url: "https://hooks.example/x",
|
|
176
|
+
template: undefined,
|
|
177
|
+
contentType: "application/json",
|
|
178
|
+
timeoutMs: 1000,
|
|
179
|
+
},
|
|
180
|
+
result("healthy"),
|
|
181
|
+
(async (url: string, init: RequestInit) => {
|
|
182
|
+
seen = { url, init };
|
|
183
|
+
return new Response("ok", { status: 200 });
|
|
184
|
+
}) as unknown as typeof fetch,
|
|
185
|
+
);
|
|
186
|
+
assert.equal(seen?.url, "https://hooks.example/x");
|
|
187
|
+
assert.equal(seen?.init.method, "POST");
|
|
188
|
+
assert.deepEqual(seen?.init.headers, {
|
|
189
|
+
"content-type": "application/json",
|
|
190
|
+
});
|
|
191
|
+
assert.doesNotThrow(() => JSON.parse(String(seen?.init.body)));
|
|
192
|
+
assert.deepEqual(outcome, { kind: "sent" });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const deliver = (responder: () => Promise<Response>) =>
|
|
196
|
+
postWebhook(
|
|
197
|
+
{
|
|
198
|
+
url: "https://hooks.example/x",
|
|
199
|
+
template: undefined,
|
|
200
|
+
contentType: "application/json",
|
|
201
|
+
timeoutMs: 1000,
|
|
202
|
+
},
|
|
203
|
+
result("healthy"),
|
|
204
|
+
responder as unknown as typeof fetch,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// The split that decides whether a transition is spent or retried. A wrong
|
|
208
|
+
// answer either way is a real cost: retrying a 4xx forever, or losing an
|
|
209
|
+
// outage alert to one transient 503.
|
|
210
|
+
it("reads a 4xx as the endpoint refusing this payload", async () => {
|
|
211
|
+
for (const status of [400, 401, 403, 404, 410, 422]) {
|
|
212
|
+
assert.deepEqual(
|
|
213
|
+
await deliver(async () => new Response("no", { status })),
|
|
214
|
+
{ kind: "rejected", detail: `HTTP ${status}` },
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("reads a 5xx, a 429 and a transport failure as never having arrived", async () => {
|
|
220
|
+
for (const status of [500, 502, 503, 504, 429]) {
|
|
221
|
+
assert.deepEqual(
|
|
222
|
+
await deliver(async () => new Response("", { status })),
|
|
223
|
+
{ kind: "unreachable", detail: `HTTP ${status}` },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
await deliver(async () => {
|
|
228
|
+
throw new Error("socket hang up");
|
|
229
|
+
}),
|
|
230
|
+
{ kind: "unreachable", detail: "socket hang up" },
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("never throws, so the loop always reaches the dead-man ping", async () => {
|
|
235
|
+
await assert.doesNotReject(
|
|
236
|
+
deliver(async () => {
|
|
237
|
+
throw new Error("boom");
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
});
|