@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 +36 -0
- package/package.json +7 -3
- package/src/index.ts +1 -1
- package/src/logger.test.ts +39 -52
- package/src/logger.ts +11 -16
- package/src/metrics.test.ts +405 -0
- package/src/metrics.ts +261 -0
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.
|
|
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
package/src/logger.test.ts
CHANGED
|
@@ -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
|
|
7
|
+
// silenced and only what reaches the metric registry is observed.
|
|
8
8
|
process.env.LOG_LEVEL = "silent";
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
const
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
assert.
|
|
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
|
|
88
|
+
it("withTelemetry records a successful invocation and its duration", async () => {
|
|
97
89
|
const handler = mock.fn(async () => 42);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
+
};
|