@remit/logger-lambda 0.0.10 → 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 CHANGED
@@ -28,6 +28,40 @@ export const handler = async (event: SQSEvent) => {
28
28
  };
29
29
  ```
30
30
 
31
+ ## Metrics
32
+
33
+ `@remit/logger-lambda/metrics` owns the process-wide Prometheus registry every
34
+ service renders at `/metrics`, and the recorders that write to it. See
35
+ [docs/design/standalone-observability.md](../../docs/design/standalone-observability.md)
36
+ for the signal set and why it is pulled rather than pushed.
37
+
38
+ ```typescript
39
+ import {
40
+ onScrape,
41
+ recordImapFailure,
42
+ startMetricsServer,
43
+ } from "@remit/logger-lambda/metrics";
44
+
45
+ // A service with no listener of its own gets one for /metrics alone. A port it
46
+ // cannot bind is reported and then dropped: the process keeps doing its work
47
+ // and serves no metrics.
48
+ startMetricsServer();
49
+
50
+ // Counters and histograms are written where the work happens.
51
+ recordImapFailure("SYNC_MESSAGES", "auth");
52
+
53
+ // A gauge whose value is a read is collected when a scrape arrives. A collector
54
+ // that throws fails the scrape rather than rendering a stale or zero value.
55
+ onScrape(async () => setBacklog(await countUndrainedRows()));
56
+ ```
57
+
58
+ A signal only one service can answer for is declared by that service against the
59
+ exported `registry` — see `search-index-worker/src/metrics.ts` and
60
+ `queue-sidecar/src/metrics.ts`. Declaring it here would render it in every
61
+ process that imports this module, including the four that cannot know its value.
62
+
63
+ `withTelemetry` records handler duration and outcome into the same registry.
64
+
31
65
  Both argument orders work: `(bindings, message)` and `(message, bindings)`.
32
66
  Bindings land at the top level of the line, never nested. A value under `error`
33
67
  that is an `Error` is expanded to `type`, `message` and `stack`; anything else is
@@ -58,3 +92,5 @@ one adds to the bindings of the one it runs inside.
58
92
  | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
59
93
  | `LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` or `silent`. An unrecognised value logs one warning and falls back to the default. |
60
94
  | `REMIT_SERVICE_NAME` | `remit` | The `service` field on every line. Stamped into each service bundle at build time by `npm-scripts/docker-bundle.mjs`. |
95
+ | `METRICS_PORT` | `9464` | Port `startMetricsServer` binds. |
96
+ | `METRICS_HOST` | `0.0.0.0` | Interface `startMetricsServer` binds. Set `127.0.0.1` when the service runs as a host process rather than a container. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/logger-lambda",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -8,6 +8,10 @@
8
8
  ".": {
9
9
  "types": "./src/index.ts",
10
10
  "default": "./src/index.ts"
11
+ },
12
+ "./metrics": {
13
+ "types": "./src/metrics.ts",
14
+ "default": "./src/metrics.ts"
11
15
  }
12
16
  },
13
17
  "scripts": {
@@ -17,9 +21,9 @@
17
21
  "build": "npm run test:typecheck"
18
22
  },
19
23
  "dependencies": {
20
- "@aws-lambda-powertools/metrics": "^2",
21
24
  "@types/aws-lambda": "*",
22
- "pino": "^10"
25
+ "pino": "^10",
26
+ "prom-client": "^15.1.3"
23
27
  },
24
28
  "license": "MIT",
25
29
  "publishConfig": {
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { MetricUnit } from "@aws-lambda-powertools/metrics";
2
1
  export * from "./logger.js";
2
+ export * from "./metrics.js";
@@ -4,36 +4,17 @@ import type { Context } from "aws-lambda";
4
4
 
5
5
  // The logging half of this package is covered by log-output.test.ts against its
6
6
  // real stdout output; this file is the telemetry wrapper, so the logger is
7
- // silenced and only the metric calls are observed.
7
+ // silenced and only what reaches the metric registry is observed.
8
8
  process.env.LOG_LEVEL = "silent";
9
9
 
10
- const addMetric = mock.fn();
11
- const publishStoredMetrics = mock.fn();
12
- const captureColdStartMetric = mock.fn();
13
-
14
- class MockMetrics {
15
- addMetric = addMetric;
16
- publishStoredMetrics = publishStoredMetrics;
17
- captureColdStartMetric = captureColdStartMetric;
18
- }
19
-
20
- mock.module("@aws-lambda-powertools/metrics", {
21
- namedExports: {
22
- Metrics: MockMetrics,
23
- MetricUnit: { Count: "Count", Milliseconds: "Milliseconds" },
24
- },
25
- });
26
-
27
- const { metrics, withTelemetry } = await import("./logger.js");
28
- const { Metrics } = await import("@aws-lambda-powertools/metrics");
10
+ const { withTelemetry } = await import("./logger.js");
11
+ const { registry, resetMetrics } = await import("./metrics.js");
29
12
 
30
13
  type Recorded = { mock: { calls: { arguments: unknown[] }[] } };
31
14
 
32
15
  const calls = (fn: Recorded): unknown[][] =>
33
16
  fn.mock.calls.map((call) => call.arguments);
34
17
 
35
- const recorded = [addMetric, publishStoredMetrics, captureColdStartMetric];
36
-
37
18
  const makeContext = (): Context =>
38
19
  ({
39
20
  awsRequestId: "test-request-id",
@@ -50,14 +31,34 @@ const makeContext = (): Context =>
50
31
  succeed: () => {},
51
32
  }) as unknown as Context;
52
33
 
53
- describe("remit-logger-lambda", () => {
54
- beforeEach(() => {
55
- for (const fn of recorded) fn.mock.resetCalls();
56
- });
34
+ type HistogramValue = {
35
+ metricName?: string;
36
+ labels: Record<string, string | number>;
37
+ value: number;
38
+ };
39
+
40
+ const handlerAggregate = async (
41
+ suffix: string,
42
+ labels: Record<string, string>,
43
+ ): Promise<number> => {
44
+ const metric = registry.getSingleMetric("remit_handler_duration_seconds");
45
+ assert.ok(metric, "expected the handler duration histogram to be registered");
46
+ const { values } = (await metric.get()) as { values: HistogramValue[] };
47
+ const match = values.find(
48
+ (value) =>
49
+ value.metricName === `remit_handler_duration_seconds_${suffix}` &&
50
+ value.labels.handler === labels.handler &&
51
+ value.labels.outcome === labels.outcome,
52
+ );
53
+ assert.ok(
54
+ match,
55
+ `expected a _${suffix} sample for ${JSON.stringify(labels)}`,
56
+ );
57
+ return match.value;
58
+ };
57
59
 
58
- it("exports metrics as a Metrics instance", () => {
59
- assert.ok(metrics instanceof Metrics);
60
- });
60
+ describe("remit-logger-lambda", () => {
61
+ beforeEach(() => resetMetrics());
61
62
 
62
63
  it("withTelemetry calls the handler and returns its result", async () => {
63
64
  const handler = mock.fn(async () => "hello");
@@ -75,34 +76,20 @@ describe("remit-logger-lambda", () => {
75
76
  await assert.rejects(wrapped({}, makeContext()), /boom/);
76
77
  });
77
78
 
78
- it("withTelemetry publishes metrics in finally even on error", async () => {
79
+ it("withTelemetry records a failed invocation against the registry", async () => {
79
80
  const handler = mock.fn(async () => {
80
81
  throw new Error("fail");
81
82
  });
82
- const wrapped = withTelemetry(handler);
83
- await assert.rejects(wrapped({}, makeContext()));
84
- assert.ok(calls(publishStoredMetrics).length > 0);
85
- });
86
-
87
- it("withTelemetry emits errorCount on handler failure", async () => {
88
- const handler = mock.fn(async () => {
89
- throw new Error("fail");
90
- });
91
- const wrapped = withTelemetry(handler);
92
- await assert.rejects(wrapped({}, makeContext()));
93
- assert.deepEqual(calls(addMetric), [["errorCount", "Count", 1]]);
83
+ await assert.rejects(withTelemetry(handler)({}, makeContext()));
84
+ const labels = { handler: "test-function", outcome: "failure" };
85
+ assert.equal(await handlerAggregate("count", labels), 1);
94
86
  });
95
87
 
96
- it("withTelemetry emits invocationCount and invocationLatency on success", async () => {
88
+ it("withTelemetry records a successful invocation and its duration", async () => {
97
89
  const handler = mock.fn(async () => 42);
98
- const wrapped = withTelemetry(handler);
99
- await wrapped({}, makeContext());
100
- const [countCall, latencyCall] = calls(addMetric);
101
- assert.deepEqual(countCall, ["invocationCount", "Count", 1]);
102
- assert.deepEqual(latencyCall.slice(0, 2), [
103
- "invocationLatency",
104
- "Milliseconds",
105
- ]);
106
- assert.equal(typeof latencyCall[2], "number");
90
+ await withTelemetry(handler)({}, makeContext());
91
+ const labels = { handler: "test-function", outcome: "success" };
92
+ assert.equal(await handlerAggregate("count", labels), 1);
93
+ assert.equal(typeof (await handlerAggregate("sum", labels)), "number");
107
94
  });
108
95
  });
package/src/logger.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics";
3
2
  import type { Context } from "aws-lambda";
4
3
  import { pino, stdSerializers } from "pino";
4
+ import { recordHandlerOutcome } from "./metrics.js";
5
5
 
6
6
  type LogBindings = Record<string, unknown>;
7
7
 
@@ -157,11 +157,6 @@ const createAdapter = (target: PinoLogger, persistent: LogBindings): Logger => {
157
157
 
158
158
  export const logger: Logger = createAdapter(root, {});
159
159
 
160
- export const metrics = new Metrics({
161
- namespace: process.env.POWERTOOLS_METRICS_NAMESPACE ?? "Remit",
162
- serviceName: process.env.POWERTOOLS_SERVICE_NAME ?? "remit",
163
- });
164
-
165
160
  export const createLogger = (): Logger => createAdapter(root, {});
166
161
 
167
162
  export const withTelemetry = <TEvent, TResult>(
@@ -172,24 +167,24 @@ export const withTelemetry = <TEvent, TResult>(
172
167
  functionName: context.functionName,
173
168
  });
174
169
 
175
- metrics.captureColdStartMetric();
176
-
177
170
  const start = Date.now();
178
171
 
179
172
  try {
180
173
  const result = await handler(event, context);
181
- const duration = Date.now() - start;
182
-
183
- metrics.addMetric("invocationCount", MetricUnit.Count, 1);
184
- metrics.addMetric("invocationLatency", MetricUnit.Milliseconds, duration);
185
-
174
+ recordHandlerOutcome({
175
+ handler: context.functionName,
176
+ outcome: "success",
177
+ durationMs: Date.now() - start,
178
+ });
186
179
  return result;
187
180
  } catch (err) {
188
- metrics.addMetric("errorCount", MetricUnit.Count, 1);
181
+ recordHandlerOutcome({
182
+ handler: context.functionName,
183
+ outcome: "failure",
184
+ durationMs: Date.now() - start,
185
+ });
189
186
  logger.error("Lambda invocation failed", { error: err });
190
187
  throw err;
191
- } finally {
192
- metrics.publishStoredMetrics();
193
188
  }
194
189
  };
195
190
  };
@@ -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
+ };