@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 ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@remit/doctor",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "start": "node --import tsx src/main.ts",
15
+ "check": "node --import tsx src/cli.ts",
16
+ "test:typecheck": "tsgo --noEmit",
17
+ "test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-lines=95 --test 'src/**/*.test.ts'",
18
+ "test": "npm run test:typecheck && npm run test:run"
19
+ },
20
+ "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/remit-mail/reader.git",
27
+ "directory": "packages/doctor"
28
+ }
29
+ }
package/src/attempt.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Turn a failure into a value.
3
+ *
4
+ * Used only where the design says a failure IS the signal: an endpoint that
5
+ * refuses the connection, a heartbeat directory that cannot be read. Those are
6
+ * facts the verdict has to carry, and a checker that propagates the first one
7
+ * reports nothing about everything else it looked at — which is the outcome
8
+ * "a signal that cannot be evaluated is degraded, never skipped" rules out.
9
+ *
10
+ * Everywhere else in this package, errors propagate.
11
+ */
12
+ export type Attempt<T> =
13
+ | { readonly ok: true; readonly value: T }
14
+ | { readonly ok: false; readonly error: string };
15
+
16
+ export const describeError = (error: unknown): string =>
17
+ error instanceof Error ? error.message : String(error);
18
+
19
+ export const attempt = <T>(work: Promise<T>): Promise<Attempt<T>> =>
20
+ work.then(
21
+ (value) => ({ ok: true, value }) as const,
22
+ (error: unknown) => ({ ok: false, error: describeError(error) }) as const,
23
+ );
24
+
25
+ /**
26
+ * `JSON.parse` in a promise, so a malformed document is a rejection a caller
27
+ * can `.catch()` rather than a synchronous throw needing a block try/catch.
28
+ * Same shape as the backend's own helper; written here because this package
29
+ * deliberately depends on nothing.
30
+ */
31
+ export const safeJsonParse = <T>(raw: string): Promise<T> =>
32
+ new Promise((resolve) => {
33
+ resolve(JSON.parse(raw));
34
+ });
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { runCheck } from "./check.js";
7
+ import { loadConfig } from "./config.js";
8
+
9
+ const BODIES: Readonly<Record<string, string>> = {
10
+ backend: 'remit_account_sync_age_seconds{account_id="aaa"} 120\n',
11
+ queue: 'remit_queue_messages{queue="imap-sync-dlq",role="dead_letter"} 0\n',
12
+ "imap-worker": 'remit_imap_failures_total{operation="fetch",kind="auth"} 0\n',
13
+ "smtp-worker": 'remit_smtp_failures_total{kind="auth"} 0\n',
14
+ };
15
+
16
+ const stack = (overrides: Readonly<Record<string, string>> = {}) =>
17
+ (async (url: string) => {
18
+ const service = new URL(url).hostname;
19
+ const body = overrides[service] ?? BODIES[service];
20
+ if (body === undefined) throw new Error(`no such service: ${service}`);
21
+ return new Response(body, { status: 200 });
22
+ }) as unknown as typeof fetch;
23
+
24
+ const heartbeatDir = async (services: readonly string[]): Promise<string> => {
25
+ const directory = await mkdtemp(join(tmpdir(), "remit-doctor-check-"));
26
+ for (const service of services) {
27
+ await writeFile(join(directory, `${service}.queue`), "now\n");
28
+ }
29
+ return directory;
30
+ };
31
+
32
+ describe("runCheck", () => {
33
+ it("reads both surfaces and produces one verdict", async () => {
34
+ const directory = await heartbeatDir([
35
+ "imap-worker",
36
+ "smtp-worker",
37
+ "account-worker",
38
+ "search-index-worker",
39
+ ]);
40
+ const config = loadConfig({ DOCTOR_HEARTBEAT_DIR: directory });
41
+ const result = await runCheck(config, {}, new Date(), stack());
42
+ assert.equal(result.verdict, "healthy");
43
+ });
44
+
45
+ it("degrades on the heartbeat surface even when every scrape is clean", async () => {
46
+ const config = loadConfig({
47
+ DOCTOR_HEARTBEAT_DIR: await heartbeatDir(["imap-worker"]),
48
+ });
49
+ const result = await runCheck(config, {}, new Date(), stack());
50
+ assert.equal(result.verdict, "degraded");
51
+ assert.deepEqual(
52
+ result.reasons.map((reason) => reason.code),
53
+ ["worker_heartbeat_stale"],
54
+ );
55
+ });
56
+
57
+ it("degrades on the metrics surface even when every heartbeat is fresh", async () => {
58
+ const config = loadConfig({
59
+ DOCTOR_HEARTBEAT_DIR: await heartbeatDir([
60
+ "imap-worker",
61
+ "smtp-worker",
62
+ "account-worker",
63
+ "search-index-worker",
64
+ ]),
65
+ });
66
+ const result = await runCheck(
67
+ config,
68
+ {},
69
+ new Date(),
70
+ stack({
71
+ queue:
72
+ 'remit_queue_messages{queue="imap-sync-dlq",role="dead_letter"} 4\n',
73
+ }),
74
+ );
75
+ assert.equal(result.verdict, "degraded");
76
+ assert.deepEqual(
77
+ result.reasons.map((reason) => reason.code),
78
+ ["dead_letter_queue_not_empty"],
79
+ );
80
+ });
81
+ });
package/src/check.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { DoctorConfig } from "./config.js";
2
+ import { readHeartbeats } from "./heartbeats.js";
3
+ import { type Fetcher, scrapeAll } from "./scrape.js";
4
+ import type { CounterState } from "./state.js";
5
+ import { type CheckResult, evaluate } from "./verdict.js";
6
+
7
+ /**
8
+ * One check: scrape the endpoints that carry a signal, read the heartbeat
9
+ * volume, and evaluate. The same function the loop runs on its interval and the
10
+ * exec seam runs on demand — the verdict is computed in one place and read
11
+ * three ways, at a shell, as an exit code, and as an alert.
12
+ */
13
+ export const runCheck = async (
14
+ config: DoctorConfig,
15
+ previousCounters: Readonly<Record<string, CounterState>>,
16
+ now: Date = new Date(),
17
+ fetcher: Fetcher = fetch,
18
+ ): Promise<CheckResult> => {
19
+ const [scrapes, heartbeats] = await Promise.all([
20
+ scrapeAll(config.targets, config.scrapeTimeoutMs, fetcher),
21
+ readHeartbeats(
22
+ config.heartbeatDir,
23
+ config.heartbeatServices,
24
+ now.getTime(),
25
+ ),
26
+ ]);
27
+ return evaluate({
28
+ scrapes,
29
+ heartbeats,
30
+ previousCounters,
31
+ heartbeatMaxAgeSeconds: config.heartbeatMaxAgeSeconds,
32
+ syncAgeMaxSeconds: config.syncAgeMaxSeconds,
33
+ authFailureHoldSeconds: config.authFailureHoldSeconds,
34
+ now,
35
+ });
36
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { runCheck } from "./check.js";
2
+ import { loadConfig } from "./config.js";
3
+ import { describeError, log, setLogLevel } from "./log.js";
4
+ import {
5
+ exitCodeFor,
6
+ NO_VERDICT_EXIT_CODE,
7
+ renderJson,
8
+ renderLines,
9
+ } from "./report.js";
10
+ import { readState } from "./state.js";
11
+
12
+ /**
13
+ * The exec seam `remit doctor` drives (D4):
14
+ *
15
+ * docker compose exec -T doctor node check.mjs [--json]
16
+ *
17
+ * Runs a fresh check and prints it. Fresh rather than the loop's last verdict,
18
+ * because `remit doctor` answers "is anything wrong now", and because the
19
+ * loop's verdict is the settled one the dwell rule announces, which is
20
+ * deliberately up to three checks behind the current state.
21
+ *
22
+ * It reads the loop's state file and never writes it. The auth-failure signal
23
+ * is a delta against the totals the loop last saw, so the seam needs that
24
+ * baseline; writing a new one would move the loop's own reference point and
25
+ * hide the next real increase.
26
+ */
27
+ const json = process.argv.includes("--json");
28
+
29
+ const check = async (): Promise<number> => {
30
+ const config = loadConfig();
31
+ setLogLevel(config.logLevel);
32
+ const state = await readState(config.stateDir);
33
+ const result = await runCheck(config, state.counters);
34
+ process.stdout.write(json ? renderJson(result) : renderLines(result));
35
+ return exitCodeFor(result);
36
+ };
37
+
38
+ process.exit(
39
+ await check().catch((error: unknown) => {
40
+ log.error({ error: describeError(error) }, "doctor: check could not run");
41
+ return NO_VERDICT_EXIT_CODE;
42
+ }),
43
+ );
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { loadConfig, parseTargets } from "./config.js";
4
+
5
+ describe("loadConfig", () => {
6
+ it("needs nothing set, and sends nothing when nothing is set", () => {
7
+ const config = loadConfig({});
8
+ assert.equal(config.webhookUrl, undefined);
9
+ assert.equal(config.deadManUrl, undefined);
10
+ assert.equal(config.dwellChecks, 3);
11
+ assert.equal(config.intervalMs, 60_000);
12
+ assert.deepEqual(
13
+ config.targets.map((target) => target.service),
14
+ ["backend", "queue", "imap-worker", "smtp-worker"],
15
+ );
16
+ assert.deepEqual(config.heartbeatServices, [
17
+ "imap-worker",
18
+ "smtp-worker",
19
+ "account-worker",
20
+ "search-index-worker",
21
+ ]);
22
+ });
23
+
24
+ it("refuses a webhook with no dead-man's switch, naming both variables", () => {
25
+ assert.throws(
26
+ () => loadConfig({ DOCTOR_WEBHOOK_URL: "https://hooks.example/x" }),
27
+ (error: Error) => {
28
+ assert.match(error.message, /DOCTOR_WEBHOOK_URL/);
29
+ assert.match(error.message, /DOCTOR_HEARTBEAT_URL/);
30
+ return true;
31
+ },
32
+ );
33
+ });
34
+
35
+ it("accepts a dead-man's switch with no webhook", () => {
36
+ const config = loadConfig({ DOCTOR_HEARTBEAT_URL: "https://hc.example/x" });
37
+ assert.equal(config.deadManUrl, "https://hc.example/x");
38
+ });
39
+
40
+ it("treats a blank value as unset, so an empty compose passthrough is not a webhook", () => {
41
+ const config = loadConfig({
42
+ DOCTOR_WEBHOOK_URL: " ",
43
+ DOCTOR_HEARTBEAT_URL: "",
44
+ });
45
+ assert.equal(config.webhookUrl, undefined);
46
+ assert.equal(config.deadManUrl, undefined);
47
+ });
48
+
49
+ it("refuses a threshold that is not a positive number, by name", () => {
50
+ assert.throws(
51
+ () => loadConfig({ DOCTOR_SYNC_AGE_MAX_SECONDS: "soon" }),
52
+ /DOCTOR_SYNC_AGE_MAX_SECONDS/,
53
+ );
54
+ assert.throws(
55
+ () => loadConfig({ DOCTOR_DWELL_CHECKS: "0" }),
56
+ /DOCTOR_DWELL_CHECKS/,
57
+ );
58
+ assert.throws(
59
+ () => loadConfig({ DOCTOR_INTERVAL_SECONDS: "-5" }),
60
+ /DOCTOR_INTERVAL_SECONDS/,
61
+ );
62
+ });
63
+
64
+ it("reads the overrides an operator is expected to set", () => {
65
+ const config = loadConfig({
66
+ DOCTOR_INTERVAL_SECONDS: "15",
67
+ DOCTOR_DWELL_CHECKS: "2",
68
+ DOCTOR_SYNC_AGE_MAX_SECONDS: "600",
69
+ DOCTOR_HEARTBEAT_MAX_AGE_SECONDS: "120",
70
+ DOCTOR_HEARTBEAT_DIR: "/tmp/hb",
71
+ DOCTOR_HEARTBEAT_SERVICES: "imap-worker, smtp-worker",
72
+ DOCTOR_STATE_DIR: "/tmp/state",
73
+ DOCTOR_WEBHOOK_CONTENT_TYPE: "text/plain",
74
+ DOCTOR_WEBHOOK_TEMPLATE: "{{summary}}",
75
+ DOCTOR_HEARTBEAT_URL: "https://hc.example/x",
76
+ DOCTOR_WEBHOOK_URL: "https://ntfy.example/remit",
77
+ });
78
+ assert.equal(config.intervalMs, 15_000);
79
+ assert.equal(config.dwellChecks, 2);
80
+ assert.equal(config.syncAgeMaxSeconds, 600);
81
+ assert.equal(config.heartbeatMaxAgeSeconds, 120);
82
+ assert.equal(config.heartbeatDir, "/tmp/hb");
83
+ assert.deepEqual(config.heartbeatServices, ["imap-worker", "smtp-worker"]);
84
+ assert.equal(config.stateDir, "/tmp/state");
85
+ assert.equal(config.webhookContentType, "text/plain");
86
+ assert.equal(config.webhookTemplate, "{{summary}}");
87
+ });
88
+ });
89
+
90
+ describe("parseTargets", () => {
91
+ it("reads service=url pairs", () => {
92
+ assert.deepEqual(
93
+ parseTargets("a=http://a:1/metrics, b=http://b:2/metrics"),
94
+ [
95
+ { service: "a", url: "http://a:1/metrics" },
96
+ { service: "b", url: "http://b:2/metrics" },
97
+ ],
98
+ );
99
+ });
100
+
101
+ it("keeps the whole url, colons and all", () => {
102
+ assert.deepEqual(parseTargets("a=http://a:9464/metrics"), [
103
+ { service: "a", url: "http://a:9464/metrics" },
104
+ ]);
105
+ });
106
+
107
+ it("refuses an entry with no service name", () => {
108
+ assert.throws(() => parseTargets("http://a/metrics"), /DOCTOR_TARGETS/);
109
+ assert.throws(() => parseTargets("=http://a/metrics"), /DOCTOR_TARGETS/);
110
+ });
111
+
112
+ it("reads an empty list as no targets", () => {
113
+ assert.deepEqual(loadConfig({ DOCTOR_TARGETS: " , " }).targets, []);
114
+ });
115
+ });
package/src/config.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Everything the checker reads from its environment, in one place, validated
3
+ * once at startup.
4
+ *
5
+ * The `doctor` service takes no `env_file`. It is the one container in the
6
+ * stack that opens an outbound connection to a third-party endpoint, so it
7
+ * holds the variables it needs and none of the deployment's secrets — the
8
+ * compose file passes each of these through by name.
9
+ */
10
+ export type ContentType = string;
11
+
12
+ export interface ScrapeTarget {
13
+ readonly service: string;
14
+ readonly url: string;
15
+ }
16
+
17
+ export interface DoctorConfig {
18
+ readonly intervalMs: number;
19
+ readonly targets: readonly ScrapeTarget[];
20
+ readonly scrapeTimeoutMs: number;
21
+ readonly heartbeatDir: string;
22
+ readonly heartbeatServices: readonly string[];
23
+ readonly heartbeatMaxAgeSeconds: number;
24
+ readonly syncAgeMaxSeconds: number;
25
+ readonly authFailureHoldSeconds: number;
26
+ readonly stateDir: string;
27
+ readonly dwellChecks: number;
28
+ readonly webhookUrl: string | undefined;
29
+ readonly webhookTemplate: string | undefined;
30
+ readonly webhookContentType: ContentType;
31
+ readonly deadManUrl: string | undefined;
32
+ readonly requestTimeoutMs: number;
33
+ readonly logLevel: string | undefined;
34
+ }
35
+
36
+ /**
37
+ * The four endpoints that carry a signal the verdict reads: dead-letter depth,
38
+ * per-account sync age, and the two authentication counters. `account-worker`
39
+ * and `search-index-worker` are absent on purpose — their liveness is a
40
+ * heartbeat file, and scraping a service to prove it is up duplicates a signal
41
+ * that already survives the metrics server failing.
42
+ */
43
+ const DEFAULT_TARGETS: readonly ScrapeTarget[] = [
44
+ { service: "backend", url: "http://backend:8080/metrics" },
45
+ { service: "queue", url: "http://queue:9324/metrics" },
46
+ { service: "imap-worker", url: "http://imap-worker:9464/metrics" },
47
+ { service: "smtp-worker", url: "http://smtp-worker:9464/metrics" },
48
+ ];
49
+
50
+ const DEFAULT_HEARTBEAT_SERVICES = [
51
+ "imap-worker",
52
+ "smtp-worker",
53
+ "account-worker",
54
+ "search-index-worker",
55
+ ];
56
+
57
+ /** The same 420 s the workers' own compose healthcheck uses; one threshold. */
58
+ const DEFAULT_HEARTBEAT_MAX_AGE_SECONDS = 420;
59
+
60
+ /**
61
+ * Three hours. `remit_account_sync_age_seconds` sawtooths: the scheduler ticks
62
+ * hourly by default (`MAILBOX_SYNC_TICK_INTERVAL_SECONDS`) and a tick skips a
63
+ * mailbox stamped inside the freshness window, so a perfectly healthy account
64
+ * climbs past an hour every cycle. A threshold at or near the tick fires on
65
+ * accounts that are fine; raise this if you raised the tick.
66
+ */
67
+ const DEFAULT_SYNC_AGE_MAX_SECONDS = 3 * 60 * 60;
68
+
69
+ /**
70
+ * How long after the last authentication failure the condition still counts as
71
+ * failing. Three hours, for the same reason the sync-age threshold is three
72
+ * hours: authentication is retried on the sync tick, so the failures arrive in
73
+ * one burst per tick and the gaps between bursts are not recoveries.
74
+ *
75
+ * The signal is a counter delta, which is true for exactly one check. Without a
76
+ * hold the reason appears on one check in sixty and the three-check dwell never
77
+ * settles, so the one class of failure that never resolves itself would be the
78
+ * one that never alerts.
79
+ */
80
+ const DEFAULT_AUTH_FAILURE_HOLD_SECONDS = 3 * 60 * 60;
81
+
82
+ /** D8's number. Configurable so an operator can trade latency for quiet. */
83
+ const DEFAULT_DWELL_CHECKS = 3;
84
+
85
+ /**
86
+ * A dwell longer than this is indistinguishable from alerting being off, and a
87
+ * container that starts cleanly and never speaks is the worst way to learn that.
88
+ * An hour of agreeing checks is already far past any deliberate setting.
89
+ */
90
+ const MAX_DWELL_CHECKS = 60;
91
+
92
+ const DEFAULT_INTERVAL_SECONDS = 60;
93
+ const DEFAULT_SCRAPE_TIMEOUT_SECONDS = 10;
94
+ const DEFAULT_REQUEST_TIMEOUT_SECONDS = 10;
95
+
96
+ const DEFAULT_CONTENT_TYPE = "application/json";
97
+
98
+ export type Env = Record<string, string | undefined>;
99
+
100
+ const text = (env: Env, name: string): string | undefined => {
101
+ const value = env[name]?.trim();
102
+ return value === undefined || value === "" ? undefined : value;
103
+ };
104
+
105
+ /**
106
+ * A number a human typed. An unparseable or out-of-range value is refused by
107
+ * name rather than silently replaced by the default: a threshold that quietly
108
+ * reverts is a check the operator believes is stricter than it is.
109
+ */
110
+ const positiveNumber = (env: Env, name: string, fallback: number): number => {
111
+ const raw = text(env, name);
112
+ if (raw === undefined) return fallback;
113
+ const parsed = Number(raw);
114
+ if (!Number.isFinite(parsed) || parsed <= 0) {
115
+ throw new Error(`${name} must be a positive number, got: ${raw}`);
116
+ }
117
+ return parsed;
118
+ };
119
+
120
+ /**
121
+ * A count, not a measurement. `2.5` would persist a fractional run in the state
122
+ * file, and a very large value silently turns alerting off while the container
123
+ * starts cleanly and pings its dead-man forever — the failure mode with no
124
+ * symptom, which is the one this whole design exists to remove.
125
+ */
126
+ const boundedCount = (
127
+ env: Env,
128
+ name: string,
129
+ fallback: number,
130
+ max: number,
131
+ ): number => {
132
+ const raw = text(env, name);
133
+ if (raw === undefined) return fallback;
134
+ const parsed = Number(raw);
135
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
136
+ throw new Error(
137
+ `${name} must be a whole number of checks between 1 and ${max}, got: ${raw}`,
138
+ );
139
+ }
140
+ return parsed;
141
+ };
142
+
143
+ /** `name=url,name=url`. Written out in full so nothing is inferred from a name. */
144
+ export const parseTargets = (raw: string): ScrapeTarget[] =>
145
+ raw
146
+ .split(",")
147
+ .map((entry) => entry.trim())
148
+ .filter((entry) => entry !== "")
149
+ .map((entry) => {
150
+ const separator = entry.indexOf("=");
151
+ if (separator <= 0) {
152
+ throw new Error(
153
+ `DOCTOR_TARGETS entries are <service>=<url>, got: ${entry}`,
154
+ );
155
+ }
156
+ return {
157
+ service: entry.slice(0, separator).trim(),
158
+ url: entry.slice(separator + 1).trim(),
159
+ };
160
+ });
161
+
162
+ /**
163
+ * D11. A webhook without a dead-man's switch is the half-configuration that
164
+ * looks armed and is not: if the VM dies nothing fires and the channel reads
165
+ * like a quiet week. It is refused at startup, by name, rather than warned
166
+ * about — a warning in a log nobody is watching is the same silence.
167
+ */
168
+ export const loadConfig = (env: Env = process.env): DoctorConfig => {
169
+ const webhookUrl = text(env, "DOCTOR_WEBHOOK_URL");
170
+ const deadManUrl = text(env, "DOCTOR_HEARTBEAT_URL");
171
+ if (webhookUrl !== undefined && deadManUrl === undefined) {
172
+ throw new Error(
173
+ "DOCTOR_WEBHOOK_URL is set without DOCTOR_HEARTBEAT_URL. An alert channel " +
174
+ "nothing watches cannot tell a quiet week from a dead box: set " +
175
+ "DOCTOR_HEARTBEAT_URL to a dead-man's-switch URL (healthchecks.io, " +
176
+ "Cronitor, Uptime Kuma), or unset DOCTOR_WEBHOOK_URL.",
177
+ );
178
+ }
179
+
180
+ const targetsRaw = text(env, "DOCTOR_TARGETS");
181
+ const servicesRaw = text(env, "DOCTOR_HEARTBEAT_SERVICES");
182
+
183
+ return {
184
+ intervalMs:
185
+ positiveNumber(env, "DOCTOR_INTERVAL_SECONDS", DEFAULT_INTERVAL_SECONDS) *
186
+ 1000,
187
+ targets:
188
+ targetsRaw === undefined ? DEFAULT_TARGETS : parseTargets(targetsRaw),
189
+ scrapeTimeoutMs:
190
+ positiveNumber(
191
+ env,
192
+ "DOCTOR_SCRAPE_TIMEOUT_SECONDS",
193
+ DEFAULT_SCRAPE_TIMEOUT_SECONDS,
194
+ ) * 1000,
195
+ heartbeatDir: text(env, "DOCTOR_HEARTBEAT_DIR") ?? "/data/heartbeat",
196
+ heartbeatServices:
197
+ servicesRaw === undefined
198
+ ? DEFAULT_HEARTBEAT_SERVICES
199
+ : servicesRaw
200
+ .split(",")
201
+ .map((name) => name.trim())
202
+ .filter((name) => name !== ""),
203
+ heartbeatMaxAgeSeconds: positiveNumber(
204
+ env,
205
+ "DOCTOR_HEARTBEAT_MAX_AGE_SECONDS",
206
+ DEFAULT_HEARTBEAT_MAX_AGE_SECONDS,
207
+ ),
208
+ syncAgeMaxSeconds: positiveNumber(
209
+ env,
210
+ "DOCTOR_SYNC_AGE_MAX_SECONDS",
211
+ DEFAULT_SYNC_AGE_MAX_SECONDS,
212
+ ),
213
+ authFailureHoldSeconds: positiveNumber(
214
+ env,
215
+ "DOCTOR_AUTH_FAILURE_HOLD_SECONDS",
216
+ DEFAULT_AUTH_FAILURE_HOLD_SECONDS,
217
+ ),
218
+ stateDir: text(env, "DOCTOR_STATE_DIR") ?? "/data/doctor",
219
+ dwellChecks: boundedCount(
220
+ env,
221
+ "DOCTOR_DWELL_CHECKS",
222
+ DEFAULT_DWELL_CHECKS,
223
+ MAX_DWELL_CHECKS,
224
+ ),
225
+ webhookUrl,
226
+ webhookTemplate: text(env, "DOCTOR_WEBHOOK_TEMPLATE"),
227
+ webhookContentType:
228
+ text(env, "DOCTOR_WEBHOOK_CONTENT_TYPE") ?? DEFAULT_CONTENT_TYPE,
229
+ deadManUrl,
230
+ // `DOCTOR_LOG_LEVEL`, because the compose service passes DOCTOR_* variables
231
+ // and nothing else — a plain LOG_LEVEL could never reach this container, so
232
+ // the per-check verdict line could never be turned on.
233
+ logLevel: text(env, "DOCTOR_LOG_LEVEL") ?? text(env, "LOG_LEVEL"),
234
+ requestTimeoutMs:
235
+ positiveNumber(
236
+ env,
237
+ "DOCTOR_REQUEST_TIMEOUT_SECONDS",
238
+ DEFAULT_REQUEST_TIMEOUT_SECONDS,
239
+ ) * 1000,
240
+ };
241
+ };
@@ -0,0 +1,31 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { pingDeadMan } from "./deadman.js";
4
+
5
+ describe("pingDeadMan", () => {
6
+ it("GETs the configured url and sends nothing else", async () => {
7
+ let seen: { url: string; init: RequestInit } | undefined;
8
+ await pingDeadMan("https://hc-ping.example/uuid", 1000, (async (
9
+ url: string,
10
+ init: RequestInit,
11
+ ) => {
12
+ seen = { url, init };
13
+ return new Response("OK", { status: 200 });
14
+ }) as unknown as typeof fetch);
15
+ assert.equal(seen?.url, "https://hc-ping.example/uuid");
16
+ assert.equal(seen?.init.method, "GET");
17
+ assert.equal(seen?.init.body, undefined);
18
+ });
19
+
20
+ it("reports a rejected ping", async () => {
21
+ await assert.rejects(
22
+ pingDeadMan(
23
+ "https://hc-ping.example/uuid",
24
+ 1000,
25
+ (async () =>
26
+ new Response("", { status: 404 })) as unknown as typeof fetch,
27
+ ),
28
+ /HTTP 404/,
29
+ );
30
+ });
31
+ });
package/src/deadman.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Fetcher } from "./scrape.js";
2
+
3
+ /**
4
+ * D11. A ping on every completed check, whatever the verdict.
5
+ *
6
+ * If the VM is off, the disk is full, the network is gone or this container
7
+ * crashed, no alert fires — and an operator with only a webhook cannot tell
8
+ * that apart from a week with nothing wrong. That silent failure is the one
9
+ * this exists to remove, which is why the configuration refuses a webhook
10
+ * without a heartbeat rather than treating it as an upgrade.
11
+ *
12
+ * A check completes when it produces a verdict, including a `degraded` verdict
13
+ * produced from signals it could not read: a scrape failure degrades the
14
+ * verdict and still pings, because the checker is working. A check that throws
15
+ * before producing one does not ping.
16
+ *
17
+ * GET, not POST. It is the method healthchecks.io, Cronitor and Uptime Kuma's
18
+ * push monitor all accept, and Uptime Kuma accepts nothing else. Nothing is
19
+ * sent in the request beyond the URL the operator configured — the ping carries
20
+ * that the checker ran, not what it found.
21
+ */
22
+ export const pingDeadMan = async (
23
+ url: string,
24
+ timeoutMs: number,
25
+ fetcher: Fetcher = fetch,
26
+ ): Promise<void> => {
27
+ const response = await fetcher(url, {
28
+ method: "GET",
29
+ signal: AbortSignal.timeout(timeoutMs),
30
+ });
31
+ if (!response.ok) {
32
+ throw new Error(`dead-man ping rejected: HTTP ${response.status}`);
33
+ }
34
+ };