@remit/logger-lambda 0.0.9 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -24
- package/package.json +8 -4
- package/src/index.ts +1 -1
- package/src/log-level.test.ts +77 -0
- package/src/log-output.test.ts +355 -0
- package/src/logger.test.ts +41 -148
- package/src/logger.ts +138 -73
- package/src/metrics.test.ts +405 -0
- package/src/metrics.ts +261 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import type { AddressInfo } from "node:net";
|
|
3
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
createMetricsRequestListener,
|
|
6
|
+
DEFAULT_METRICS_PORT,
|
|
7
|
+
metricsContentType,
|
|
8
|
+
onScrape,
|
|
9
|
+
queueNameFromEventSource,
|
|
10
|
+
recordHandlerOutcome,
|
|
11
|
+
recordImapFailure,
|
|
12
|
+
recordQueueEvent,
|
|
13
|
+
recordSmtpFailure,
|
|
14
|
+
registry,
|
|
15
|
+
renderMetrics,
|
|
16
|
+
resetMetrics,
|
|
17
|
+
setAccountSyncAges,
|
|
18
|
+
startMetricsServer,
|
|
19
|
+
} from "./metrics.js";
|
|
20
|
+
|
|
21
|
+
/** A `#{name}{labels} value` line, matched on the labels a test cares about. */
|
|
22
|
+
const sample = (text: string, name: string, labels: string[] = []): number => {
|
|
23
|
+
const line = text
|
|
24
|
+
.split("\n")
|
|
25
|
+
.find(
|
|
26
|
+
(candidate) =>
|
|
27
|
+
candidate.startsWith(`${name}{`) ||
|
|
28
|
+
(labels.length === 0 && candidate.startsWith(`${name} `)),
|
|
29
|
+
);
|
|
30
|
+
const matching = text
|
|
31
|
+
.split("\n")
|
|
32
|
+
.filter(
|
|
33
|
+
(candidate) =>
|
|
34
|
+
candidate.startsWith(name) &&
|
|
35
|
+
labels.every((label) => candidate.includes(label)),
|
|
36
|
+
);
|
|
37
|
+
const chosen = labels.length > 0 ? matching[0] : line;
|
|
38
|
+
assert.ok(
|
|
39
|
+
chosen,
|
|
40
|
+
`expected a sample for ${name}${labels.join("")} in:\n${text}`,
|
|
41
|
+
);
|
|
42
|
+
return Number(chosen.slice(chosen.lastIndexOf(" ") + 1));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const get = async (
|
|
46
|
+
port: number,
|
|
47
|
+
path: string,
|
|
48
|
+
): Promise<{ status: number; contentType: string; body: string }> => {
|
|
49
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`);
|
|
50
|
+
return {
|
|
51
|
+
status: response.status,
|
|
52
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
53
|
+
body: await response.text(),
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("metrics registry", () => {
|
|
58
|
+
beforeEach(() => resetMetrics());
|
|
59
|
+
|
|
60
|
+
it("renders exposition text with a HELP and TYPE line per metric", async () => {
|
|
61
|
+
recordHandlerOutcome({
|
|
62
|
+
handler: "imap-worker-body",
|
|
63
|
+
outcome: "success",
|
|
64
|
+
durationMs: 1500,
|
|
65
|
+
});
|
|
66
|
+
const text = await renderMetrics();
|
|
67
|
+
assert.match(text, /^# HELP remit_handler_duration_seconds .+$/m);
|
|
68
|
+
assert.match(text, /^# TYPE remit_handler_duration_seconds histogram$/m);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("records handler duration in seconds, by target and outcome", async () => {
|
|
72
|
+
recordHandlerOutcome({
|
|
73
|
+
handler: "smtp-worker",
|
|
74
|
+
outcome: "failure",
|
|
75
|
+
durationMs: 2500,
|
|
76
|
+
});
|
|
77
|
+
const text = await renderMetrics();
|
|
78
|
+
assert.equal(
|
|
79
|
+
sample(text, "remit_handler_duration_seconds_sum", [
|
|
80
|
+
'handler="smtp-worker"',
|
|
81
|
+
'outcome="failure"',
|
|
82
|
+
]),
|
|
83
|
+
2.5,
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
sample(text, "remit_handler_duration_seconds_count", [
|
|
87
|
+
'handler="smtp-worker"',
|
|
88
|
+
'outcome="failure"',
|
|
89
|
+
]),
|
|
90
|
+
1,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("records per-message work by queue, event type and outcome", async () => {
|
|
95
|
+
recordQueueEvent({
|
|
96
|
+
queue: "remit-body",
|
|
97
|
+
eventType: "SYNC_MESSAGE_BODY",
|
|
98
|
+
outcome: "success",
|
|
99
|
+
durationMs: 1000,
|
|
100
|
+
});
|
|
101
|
+
recordQueueEvent({
|
|
102
|
+
queue: "remit-body",
|
|
103
|
+
eventType: "SYNC_MESSAGE_BODY",
|
|
104
|
+
outcome: "failure",
|
|
105
|
+
durationMs: 500,
|
|
106
|
+
});
|
|
107
|
+
const text = await renderMetrics();
|
|
108
|
+
assert.equal(
|
|
109
|
+
sample(text, "remit_queue_event_duration_seconds_count", [
|
|
110
|
+
'queue="remit-body"',
|
|
111
|
+
'event_type="SYNC_MESSAGE_BODY"',
|
|
112
|
+
'outcome="success"',
|
|
113
|
+
]),
|
|
114
|
+
1,
|
|
115
|
+
);
|
|
116
|
+
assert.equal(
|
|
117
|
+
sample(text, "remit_queue_event_duration_seconds_sum", [
|
|
118
|
+
'outcome="failure"',
|
|
119
|
+
]),
|
|
120
|
+
0.5,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("counts IMAP authentication failures apart from other kinds", async () => {
|
|
125
|
+
recordImapFailure("SYNC_MESSAGES", "auth");
|
|
126
|
+
recordImapFailure("SYNC_MESSAGES", "auth");
|
|
127
|
+
recordImapFailure("SYNC_MESSAGES", "network");
|
|
128
|
+
const text = await renderMetrics();
|
|
129
|
+
assert.equal(
|
|
130
|
+
sample(text, "remit_imap_failures_total", [
|
|
131
|
+
'operation="SYNC_MESSAGES"',
|
|
132
|
+
'kind="auth"',
|
|
133
|
+
]),
|
|
134
|
+
2,
|
|
135
|
+
);
|
|
136
|
+
assert.equal(
|
|
137
|
+
sample(text, "remit_imap_failures_total", ['kind="network"']),
|
|
138
|
+
1,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("counts SMTP authentication failures apart from other kinds", async () => {
|
|
143
|
+
recordSmtpFailure("auth");
|
|
144
|
+
recordSmtpFailure("other");
|
|
145
|
+
const text = await renderMetrics();
|
|
146
|
+
assert.equal(sample(text, "remit_smtp_failures_total", ['kind="auth"']), 1);
|
|
147
|
+
assert.equal(
|
|
148
|
+
sample(text, "remit_smtp_failures_total", ['kind="other"']),
|
|
149
|
+
1,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("labels sync age by account id and never by address", async () => {
|
|
154
|
+
setAccountSyncAges([
|
|
155
|
+
{ accountId: "acct-1", ageSeconds: 42 },
|
|
156
|
+
{ accountId: "acct-2", ageSeconds: 900 },
|
|
157
|
+
]);
|
|
158
|
+
const text = await renderMetrics();
|
|
159
|
+
assert.equal(
|
|
160
|
+
sample(text, "remit_account_sync_age_seconds", ['account_id="acct-1"']),
|
|
161
|
+
42,
|
|
162
|
+
);
|
|
163
|
+
assert.equal(
|
|
164
|
+
sample(text, "remit_account_sync_age_seconds", ['account_id="acct-2"']),
|
|
165
|
+
900,
|
|
166
|
+
);
|
|
167
|
+
assert.ok(!text.includes("@"));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("drops the sync age series of an account that is gone", async () => {
|
|
171
|
+
setAccountSyncAges([{ accountId: "acct-1", ageSeconds: 42 }]);
|
|
172
|
+
setAccountSyncAges([{ accountId: "acct-2", ageSeconds: 7 }]);
|
|
173
|
+
const text = await renderMetrics();
|
|
174
|
+
assert.ok(!text.includes('account_id="acct-1"'));
|
|
175
|
+
assert.equal(
|
|
176
|
+
sample(text, "remit_account_sync_age_seconds", ['account_id="acct-2"']),
|
|
177
|
+
7,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("declares no unlabelled series, so no process renders a value it cannot know", async () => {
|
|
182
|
+
const text = await renderMetrics();
|
|
183
|
+
const samples = text
|
|
184
|
+
.split("\n")
|
|
185
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
186
|
+
assert.deepEqual(
|
|
187
|
+
samples,
|
|
188
|
+
[],
|
|
189
|
+
"a fresh registry must render nothing until something records",
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("runs registered collectors before rendering", async () => {
|
|
194
|
+
let ran = 0;
|
|
195
|
+
onScrape(async () => {
|
|
196
|
+
ran += 1;
|
|
197
|
+
recordSmtpFailure("other");
|
|
198
|
+
});
|
|
199
|
+
const text = await renderMetrics();
|
|
200
|
+
assert.equal(ran, 1);
|
|
201
|
+
assert.equal(
|
|
202
|
+
sample(text, "remit_smtp_failures_total", ['kind="other"']),
|
|
203
|
+
1,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("serialises overlapping scrapes so neither sees the other's reset", async () => {
|
|
208
|
+
// The sync-age gauge clears itself before repopulating. Two scrapes that
|
|
209
|
+
// interleave would let one render from the gauge the other just emptied.
|
|
210
|
+
let renders = 0;
|
|
211
|
+
onScrape(async () => {
|
|
212
|
+
setAccountSyncAges([{ accountId: "acct-1", ageSeconds: 1 }]);
|
|
213
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
214
|
+
renders += 1;
|
|
215
|
+
});
|
|
216
|
+
const [first, second] = await Promise.all([
|
|
217
|
+
renderMetrics(),
|
|
218
|
+
renderMetrics(),
|
|
219
|
+
]);
|
|
220
|
+
assert.equal(renders, 2);
|
|
221
|
+
for (const text of [first, second]) {
|
|
222
|
+
assert.equal(
|
|
223
|
+
sample(text, "remit_account_sync_age_seconds", ['account_id="acct-1"']),
|
|
224
|
+
1,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("recovers after a scrape that failed", async () => {
|
|
230
|
+
let fail = true;
|
|
231
|
+
onScrape(async () => {
|
|
232
|
+
if (fail) {
|
|
233
|
+
fail = false;
|
|
234
|
+
throw new Error("transient");
|
|
235
|
+
}
|
|
236
|
+
recordSmtpFailure("auth");
|
|
237
|
+
});
|
|
238
|
+
await assert.rejects(renderMetrics(), /transient/);
|
|
239
|
+
const text = await renderMetrics();
|
|
240
|
+
assert.equal(sample(text, "remit_smtp_failures_total", ['kind="auth"']), 1);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("fails the render when a collector cannot evaluate its signal", async () => {
|
|
244
|
+
onScrape(async () => {
|
|
245
|
+
throw new Error("database is gone");
|
|
246
|
+
});
|
|
247
|
+
await assert.rejects(renderMetrics(), /database is gone/);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("queueNameFromEventSource", () => {
|
|
252
|
+
it("reads the queue name from a real SQS ARN", () => {
|
|
253
|
+
assert.equal(
|
|
254
|
+
queueNameFromEventSource("arn:aws:sqs:eu-west-1:123456789012:remit-body"),
|
|
255
|
+
"remit-body",
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("reads the queue name from the sidecar queue URL the poller passes", () => {
|
|
260
|
+
assert.equal(
|
|
261
|
+
queueNameFromEventSource(
|
|
262
|
+
"http://queue:9324/000000000000/remit-flags.fifo",
|
|
263
|
+
),
|
|
264
|
+
"remit-flags.fifo",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("ignores a query string", () => {
|
|
269
|
+
assert.equal(
|
|
270
|
+
queueNameFromEventSource("http://queue:9324/0/remit-smtp?x=1"),
|
|
271
|
+
"remit-smtp",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("reports unknown rather than guessing when the source is absent", () => {
|
|
276
|
+
assert.equal(queueNameFromEventSource(undefined), "unknown");
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("the /metrics endpoint", () => {
|
|
281
|
+
const servers: { close: (cb: () => void) => void }[] = [];
|
|
282
|
+
|
|
283
|
+
const listen = async (): Promise<number> => {
|
|
284
|
+
const server = startMetricsServer({ port: 0, host: "127.0.0.1" });
|
|
285
|
+
servers.push(server);
|
|
286
|
+
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
287
|
+
return (server.address() as AddressInfo).port;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
beforeEach(() => resetMetrics());
|
|
291
|
+
|
|
292
|
+
afterEach(async () => {
|
|
293
|
+
while (servers.length > 0) {
|
|
294
|
+
const server = servers.pop();
|
|
295
|
+
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("serves Prometheus text on GET /metrics", async () => {
|
|
300
|
+
recordSmtpFailure("auth");
|
|
301
|
+
const port = await listen();
|
|
302
|
+
const response = await get(port, "/metrics");
|
|
303
|
+
assert.equal(response.status, 200);
|
|
304
|
+
assert.equal(response.contentType, metricsContentType);
|
|
305
|
+
assert.match(response.contentType, /^text\/plain/);
|
|
306
|
+
assert.match(response.body, /^# TYPE remit_smtp_failures_total counter$/m);
|
|
307
|
+
assert.match(
|
|
308
|
+
response.body,
|
|
309
|
+
/^remit_smtp_failures_total\{kind="auth"\} 1$/m,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("serves /metrics with a query string attached", async () => {
|
|
314
|
+
const port = await listen();
|
|
315
|
+
assert.equal((await get(port, "/metrics?debug=1")).status, 200);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("serves nothing else — no health route, no other path", async () => {
|
|
319
|
+
const port = await listen();
|
|
320
|
+
assert.equal((await get(port, "/health")).status, 404);
|
|
321
|
+
assert.equal((await get(port, "/")).status, 404);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("refuses any method other than GET", async () => {
|
|
325
|
+
const port = await listen();
|
|
326
|
+
const response = await fetch(`http://127.0.0.1:${port}/metrics`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
});
|
|
329
|
+
assert.equal(response.status, 404);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("answers 500 when a signal cannot be evaluated", async () => {
|
|
333
|
+
onScrape(async () => {
|
|
334
|
+
throw new Error("queue store unreachable");
|
|
335
|
+
});
|
|
336
|
+
const port = await listen();
|
|
337
|
+
const response = await get(port, "/metrics");
|
|
338
|
+
assert.equal(response.status, 500);
|
|
339
|
+
assert.match(response.body, /queue store unreachable/);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("defaults to the documented port when none is configured", () => {
|
|
343
|
+
assert.equal(DEFAULT_METRICS_PORT, 9464);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("survives a port it cannot bind instead of taking the process down", async () => {
|
|
347
|
+
const held = await listen();
|
|
348
|
+
const reported: Error[] = [];
|
|
349
|
+
const server = startMetricsServer({
|
|
350
|
+
port: held,
|
|
351
|
+
host: "127.0.0.1",
|
|
352
|
+
onError: (error) => reported.push(error),
|
|
353
|
+
});
|
|
354
|
+
servers.push(server);
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
356
|
+
assert.equal(reported.length, 1);
|
|
357
|
+
assert.match(String(reported[0]), /EADDRINUSE/);
|
|
358
|
+
// The port that was already bound still answers: nothing died.
|
|
359
|
+
assert.equal((await get(held, "/metrics")).status, 200);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports a bind failure as one JSON line on stderr by default", async () => {
|
|
363
|
+
const held = await listen();
|
|
364
|
+
const written: string[] = [];
|
|
365
|
+
const restore = process.stderr.write.bind(process.stderr);
|
|
366
|
+
process.stderr.write = ((chunk: string) => {
|
|
367
|
+
written.push(String(chunk));
|
|
368
|
+
return true;
|
|
369
|
+
}) as typeof process.stderr.write;
|
|
370
|
+
const server = startMetricsServer({ port: held, host: "127.0.0.1" });
|
|
371
|
+
servers.push(server);
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
373
|
+
process.stderr.write = restore;
|
|
374
|
+
|
|
375
|
+
assert.equal(written.length, 1);
|
|
376
|
+
const line = JSON.parse(written[0]) as {
|
|
377
|
+
level: string;
|
|
378
|
+
msg: string;
|
|
379
|
+
error: string;
|
|
380
|
+
};
|
|
381
|
+
assert.equal(line.level, "error");
|
|
382
|
+
assert.match(line.msg, /serving no metrics/);
|
|
383
|
+
assert.match(line.error, /EADDRINUSE/);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("reads the host from METRICS_HOST", async () => {
|
|
387
|
+
process.env.METRICS_HOST = "127.0.0.1";
|
|
388
|
+
const server = startMetricsServer({ port: 0 });
|
|
389
|
+
servers.push(server);
|
|
390
|
+
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
391
|
+
delete process.env.METRICS_HOST;
|
|
392
|
+
assert.equal((server.address() as AddressInfo).address, "127.0.0.1");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("reads the port from METRICS_PORT", async () => {
|
|
396
|
+
const listener = createMetricsRequestListener();
|
|
397
|
+
assert.equal(typeof listener, "function");
|
|
398
|
+
process.env.METRICS_PORT = "0";
|
|
399
|
+
const server = startMetricsServer({ host: "127.0.0.1" });
|
|
400
|
+
servers.push(server);
|
|
401
|
+
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
402
|
+
delete process.env.METRICS_PORT;
|
|
403
|
+
assert.ok((server.address() as AddressInfo).port > 0);
|
|
404
|
+
});
|
|
405
|
+
});
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer,
|
|
3
|
+
type IncomingMessage,
|
|
4
|
+
type Server,
|
|
5
|
+
type ServerResponse,
|
|
6
|
+
} from "node:http";
|
|
7
|
+
import { Counter, Gauge, Histogram, Registry } from "prom-client";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The process-wide metric registry every service renders at `/metrics`
|
|
11
|
+
* (docs/design/standalone-observability.md D2/D5). Powertools' `Metrics`
|
|
12
|
+
* emitted CloudWatch Embedded Metric Format, which no configuration turns into
|
|
13
|
+
* a scrape endpoint; this registry is what the endpoint renders.
|
|
14
|
+
*
|
|
15
|
+
* The exported recorders are the only way a series enters it. The registry
|
|
16
|
+
* itself is exported for a service that owns a signal of its own shape — the
|
|
17
|
+
* queue sidecar's per-queue depth gauges — so the whole process still renders
|
|
18
|
+
* from one registry.
|
|
19
|
+
*/
|
|
20
|
+
export const registry = new Registry();
|
|
21
|
+
|
|
22
|
+
/** Content type of the exposition format `renderMetrics` produces. */
|
|
23
|
+
export const metricsContentType = registry.contentType;
|
|
24
|
+
|
|
25
|
+
/** The port every service that has no listener of its own serves `/metrics` on. */
|
|
26
|
+
export const DEFAULT_METRICS_PORT = 9464;
|
|
27
|
+
|
|
28
|
+
// A mail sync spans three orders of magnitude: a flag push is milliseconds, a
|
|
29
|
+
// body fetch or an indexing round runs to the queue's 300 s visibility timeout.
|
|
30
|
+
const DURATION_BUCKETS_SECONDS = [0.05, 0.25, 1, 5, 15, 60, 300];
|
|
31
|
+
|
|
32
|
+
export type HandlerOutcome = "success" | "failure";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Authentication is its own kind because it is the one class that never
|
|
36
|
+
* resolves itself: an expired grant or a changed password fails identically
|
|
37
|
+
* forever, and it is the most common way a self-hosted mailbox goes quiet.
|
|
38
|
+
*/
|
|
39
|
+
export type MailFailureKind = "auth" | "network" | "other";
|
|
40
|
+
|
|
41
|
+
const handlerDurationSeconds = new Histogram({
|
|
42
|
+
name: "remit_handler_duration_seconds",
|
|
43
|
+
help: "Queue handler invocation duration, by poller target and outcome.",
|
|
44
|
+
labelNames: ["handler", "outcome"],
|
|
45
|
+
buckets: DURATION_BUCKETS_SECONDS,
|
|
46
|
+
registers: [registry],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const queueEventDurationSeconds = new Histogram({
|
|
50
|
+
name: "remit_queue_event_duration_seconds",
|
|
51
|
+
help: "Per-message work duration, by queue, event type and outcome.",
|
|
52
|
+
labelNames: ["queue", "event_type", "outcome"],
|
|
53
|
+
buckets: DURATION_BUCKETS_SECONDS,
|
|
54
|
+
registers: [registry],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const imapFailuresTotal = new Counter({
|
|
58
|
+
name: "remit_imap_failures_total",
|
|
59
|
+
help: "IMAP operation failures, by operation and failure kind.",
|
|
60
|
+
labelNames: ["operation", "kind"],
|
|
61
|
+
registers: [registry],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const smtpFailuresTotal = new Counter({
|
|
65
|
+
name: "remit_smtp_failures_total",
|
|
66
|
+
help: "SMTP send failures, by failure kind.",
|
|
67
|
+
labelNames: ["kind"],
|
|
68
|
+
registers: [registry],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const accountSyncAgeSeconds = new Gauge({
|
|
72
|
+
name: "remit_account_sync_age_seconds",
|
|
73
|
+
help: "Seconds since this account last completed a message-sync round.",
|
|
74
|
+
labelNames: ["account_id"],
|
|
75
|
+
registers: [registry],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** One queue handler invocation — a whole SQS batch, from `withTelemetry`. */
|
|
79
|
+
export const recordHandlerOutcome = (input: {
|
|
80
|
+
handler: string;
|
|
81
|
+
outcome: HandlerOutcome;
|
|
82
|
+
durationMs: number;
|
|
83
|
+
}): void => {
|
|
84
|
+
handlerDurationSeconds.observe(
|
|
85
|
+
{ handler: input.handler, outcome: input.outcome },
|
|
86
|
+
input.durationMs / 1000,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** One message's work, where the worker knows which event it just ran. */
|
|
91
|
+
export const recordQueueEvent = (input: {
|
|
92
|
+
queue: string;
|
|
93
|
+
eventType: string;
|
|
94
|
+
outcome: HandlerOutcome;
|
|
95
|
+
durationMs: number;
|
|
96
|
+
}): void => {
|
|
97
|
+
queueEventDurationSeconds.observe(
|
|
98
|
+
{ queue: input.queue, event_type: input.eventType, outcome: input.outcome },
|
|
99
|
+
input.durationMs / 1000,
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const recordImapFailure = (
|
|
104
|
+
operation: string,
|
|
105
|
+
kind: MailFailureKind,
|
|
106
|
+
count = 1,
|
|
107
|
+
): void => {
|
|
108
|
+
imapFailuresTotal.inc({ operation, kind }, count);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const recordSmtpFailure = (kind: MailFailureKind): void => {
|
|
112
|
+
smtpFailuresTotal.inc({ kind });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The whole per-account series, replaced. Accounts come and go, and a gauge
|
|
117
|
+
* that only ever gains label sets would keep reporting a deleted account's last
|
|
118
|
+
* known age forever.
|
|
119
|
+
*/
|
|
120
|
+
export const setAccountSyncAges = (
|
|
121
|
+
ages: readonly { accountId: string; ageSeconds: number }[],
|
|
122
|
+
): void => {
|
|
123
|
+
accountSyncAgeSeconds.reset();
|
|
124
|
+
for (const { accountId, ageSeconds } of ages) {
|
|
125
|
+
accountSyncAgeSeconds.set({ account_id: accountId }, ageSeconds);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type ScrapeCollector = () => Promise<void>;
|
|
130
|
+
|
|
131
|
+
const scrapeCollectors: ScrapeCollector[] = [];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Register work that has to run before a scrape is answered — the gauges whose
|
|
135
|
+
* value is a database read, not something a handler counted. A collector that
|
|
136
|
+
* throws fails the scrape, which is the honest answer: a signal that cannot be
|
|
137
|
+
* evaluated must not render as a healthy number.
|
|
138
|
+
*/
|
|
139
|
+
export const onScrape = (collector: ScrapeCollector): void => {
|
|
140
|
+
scrapeCollectors.push(collector);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Scrapes are serialised. Both whole-series gauges clear themselves before
|
|
144
|
+
// repopulating, so two overlapping scrapes — a scraper's retry, or a Prometheus
|
|
145
|
+
// poll landing on top of a `remit doctor` exec — can render one response from a
|
|
146
|
+
// gauge the other has just emptied, dropping an account or a queue from it. A
|
|
147
|
+
// series an alert evaluates on absence must not flap for that reason.
|
|
148
|
+
let inFlightScrape: Promise<string> = Promise.resolve("");
|
|
149
|
+
|
|
150
|
+
const collectAndRender = async (): Promise<string> => {
|
|
151
|
+
for (const collect of scrapeCollectors) {
|
|
152
|
+
await collect();
|
|
153
|
+
}
|
|
154
|
+
return registry.metrics();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const renderMetrics = (): Promise<string> => {
|
|
158
|
+
inFlightScrape = inFlightScrape.then(collectAndRender, collectAndRender);
|
|
159
|
+
return inFlightScrape;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Drops every recorded sample and every registered collector — test use only. */
|
|
163
|
+
export const resetMetrics = (): void => {
|
|
164
|
+
registry.resetMetrics();
|
|
165
|
+
scrapeCollectors.length = 0;
|
|
166
|
+
inFlightScrape = Promise.resolve("");
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Derive a queue name from an SQS record's `eventSourceARN`. Lambda delivers a
|
|
171
|
+
* real ARN (`arn:aws:sqs:<region>:<account>:<name>`); the standalone poller sets
|
|
172
|
+
* the queue URL it received from (`http://queue:9324/<account>/<name>`). The
|
|
173
|
+
* name is the last segment either way.
|
|
174
|
+
*/
|
|
175
|
+
export const queueNameFromEventSource = (
|
|
176
|
+
eventSourceArn: string | undefined,
|
|
177
|
+
): string => {
|
|
178
|
+
const withoutQuery = (eventSourceArn ?? "").split("?")[0];
|
|
179
|
+
const segments = withoutQuery.split(/[/:]/).filter(Boolean);
|
|
180
|
+
return segments.at(-1) ?? "unknown";
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const METRICS_PATH = "/metrics";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `GET /metrics` and nothing else. No health route (worker liveness is a
|
|
187
|
+
* heartbeat file, not a request path), no other method, no other path.
|
|
188
|
+
*/
|
|
189
|
+
export type MetricsRequestListener = (
|
|
190
|
+
req: IncomingMessage,
|
|
191
|
+
res: ServerResponse,
|
|
192
|
+
) => void;
|
|
193
|
+
|
|
194
|
+
export const createMetricsRequestListener =
|
|
195
|
+
(): MetricsRequestListener => (req, res) => {
|
|
196
|
+
const path = (req.url ?? "").split("?")[0];
|
|
197
|
+
if (req.method !== "GET" || path !== METRICS_PATH) {
|
|
198
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
199
|
+
res.end("not found\n");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
renderMetrics()
|
|
203
|
+
.then((body) => {
|
|
204
|
+
res.writeHead(200, { "content-type": metricsContentType });
|
|
205
|
+
res.end(body);
|
|
206
|
+
})
|
|
207
|
+
.catch((error: unknown) => {
|
|
208
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
209
|
+
res.end(`metrics collection failed: ${String(error)}\n`);
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export interface MetricsServerOptions {
|
|
214
|
+
readonly port?: number;
|
|
215
|
+
readonly host?: string;
|
|
216
|
+
/** Where a bind or socket failure is reported. Defaults to a stderr JSON line. */
|
|
217
|
+
readonly onError?: (error: Error) => void;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// This module is imported by the logger, so it cannot import the logger back.
|
|
221
|
+
// One JSON object on stderr matches the shape every service writes anyway.
|
|
222
|
+
const reportToStderr = (error: Error): void => {
|
|
223
|
+
process.stderr.write(
|
|
224
|
+
`${JSON.stringify({
|
|
225
|
+
level: "error",
|
|
226
|
+
time: new Date().toISOString(),
|
|
227
|
+
msg: "metrics server unavailable; this service is serving no metrics",
|
|
228
|
+
error: error.message,
|
|
229
|
+
})}\n`,
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The listener D2 adds to each worker image, for `/metrics` alone. Bound to the
|
|
235
|
+
* compose network and never published to the host: the only host ports in the
|
|
236
|
+
* deployment stay caddy's 80 and 443.
|
|
237
|
+
*
|
|
238
|
+
* A port it cannot bind is reported and then dropped. `listen` resolves on a
|
|
239
|
+
* later tick, by which time the poll loop is already running, so an unhandled
|
|
240
|
+
* `'error'` event here would kill a worker in the middle of syncing mail —
|
|
241
|
+
* 9464 is the OpenTelemetry Prometheus exporter's default, so a collector on
|
|
242
|
+
* the same host is a real trigger. An observability endpoint must never be able
|
|
243
|
+
* to stop mail from arriving; absent metrics are the correct failure.
|
|
244
|
+
*
|
|
245
|
+
* Unreferenced from the event loop, so it never keeps a worker alive past the
|
|
246
|
+
* end of its poll loop.
|
|
247
|
+
*/
|
|
248
|
+
export const startMetricsServer = (
|
|
249
|
+
options: MetricsServerOptions = {},
|
|
250
|
+
): Server => {
|
|
251
|
+
const port =
|
|
252
|
+
options.port ??
|
|
253
|
+
Number(process.env.METRICS_PORT ?? String(DEFAULT_METRICS_PORT));
|
|
254
|
+
const host = options.host ?? process.env.METRICS_HOST ?? "0.0.0.0";
|
|
255
|
+
const onError = options.onError ?? reportToStderr;
|
|
256
|
+
const server = createServer(createMetricsRequestListener());
|
|
257
|
+
server.unref();
|
|
258
|
+
server.on("error", onError);
|
|
259
|
+
server.listen(port, host);
|
|
260
|
+
return server;
|
|
261
|
+
};
|