@remit/logger-lambda 0.0.11 → 0.0.12

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
@@ -92,5 +92,5 @@ one adds to the bindings of the one it runs inside.
92
92
  | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
93
93
  | `LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` or `silent`. An unrecognised value logs one warning and falls back to the default. |
94
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. |
95
+ | `METRICS_PORT` | `9464` | Port `startMetricsServer` binds. Empty is unset and takes the default; anything that is not a port number in 0–65535 logs one error and serves no metrics, rather than falling back. A service that cannot be scraped is a smaller failure than one that reports on the wrong port. |
96
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.11",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,4 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
+ import type { Server } from "node:http";
2
3
  import type { AddressInfo } from "node:net";
3
4
  import { afterEach, beforeEach, describe, it } from "node:test";
4
5
  import {
@@ -402,4 +403,80 @@ describe("the /metrics endpoint", () => {
402
403
  delete process.env.METRICS_PORT;
403
404
  assert.ok((server.address() as AddressInfo).port > 0);
404
405
  });
406
+
407
+ const startWithMetricsPort = (value: string, reported: Error[]) => {
408
+ process.env.METRICS_PORT = value;
409
+ try {
410
+ return startMetricsServer({
411
+ host: "127.0.0.1",
412
+ onError: (error) => reported.push(error),
413
+ });
414
+ } finally {
415
+ delete process.env.METRICS_PORT;
416
+ }
417
+ };
418
+
419
+ for (const value of ["nine-thousand", "99999", "-1", "9464.5"]) {
420
+ it(`refuses to bind METRICS_PORT=${value} instead of taking the worker down`, () => {
421
+ const reported: Error[] = [];
422
+ const server = startWithMetricsPort(value, reported);
423
+ assert.equal(server.listening, false);
424
+ assert.equal(reported.length, 1);
425
+ assert.match(reported[0].message, /METRICS_PORT/);
426
+ assert.ok(reported[0].message.includes(value));
427
+ });
428
+ }
429
+
430
+ it("treats a blank METRICS_PORT as unconfigured", async () => {
431
+ const reported: Error[] = [];
432
+ const server = startWithMetricsPort(" ", reported);
433
+ servers.push(server);
434
+ const bound = await new Promise<boolean>((resolve) => {
435
+ server.once("listening", () => resolve(true));
436
+ server.once("error", () => resolve(false));
437
+ });
438
+ if (!bound) {
439
+ // 9464 is already taken on this host, which is itself proof the
440
+ // fallback resolved to it: an ephemeral port is never in use.
441
+ assert.match(String(reported[0]), /EADDRINUSE/);
442
+ return;
443
+ }
444
+ assert.deepEqual(reported, []);
445
+ assert.equal((server.address() as AddressInfo).port, DEFAULT_METRICS_PORT);
446
+ });
447
+
448
+ it("refuses an out-of-range port passed by a caller", () => {
449
+ const reported: Error[] = [];
450
+ const server = startMetricsServer({
451
+ port: 70000,
452
+ host: "127.0.0.1",
453
+ onError: (error) => reported.push(error),
454
+ });
455
+ assert.equal(server.listening, false);
456
+ assert.equal(reported.length, 1);
457
+ assert.match(reported[0].message, /70000/);
458
+ });
459
+
460
+ it("reports a malformed port as one JSON line on stderr by default", () => {
461
+ const written: string[] = [];
462
+ const restore = process.stderr.write.bind(process.stderr);
463
+ process.env.METRICS_PORT = "not-a-port";
464
+ process.stderr.write = ((chunk: string) => {
465
+ written.push(String(chunk));
466
+ return true;
467
+ }) as typeof process.stderr.write;
468
+ let server: Server;
469
+ try {
470
+ server = startMetricsServer({ host: "127.0.0.1" });
471
+ } finally {
472
+ process.stderr.write = restore;
473
+ delete process.env.METRICS_PORT;
474
+ }
475
+
476
+ assert.equal(server.listening, false);
477
+ assert.equal(written.length, 1);
478
+ const line = JSON.parse(written[0]) as { level: string; error: string };
479
+ assert.equal(line.level, "error");
480
+ assert.match(line.error, /not-a-port/);
481
+ });
405
482
  });
package/src/metrics.ts CHANGED
@@ -230,6 +230,31 @@ const reportToStderr = (error: Error): void => {
230
230
  );
231
231
  };
232
232
 
233
+ const MAX_PORT = 65535;
234
+
235
+ const isBindablePort = (port: number): boolean =>
236
+ Number.isInteger(port) && port >= 0 && port <= MAX_PORT;
237
+
238
+ // `listen` rejects anything outside 0..65535 by throwing ERR_SOCKET_BAD_PORT
239
+ // synchronously, which the `'error'` handler never sees. A blank value is a
240
+ // variable someone left empty rather than a request for port 0, so it reads as
241
+ // unset; anything else that does not parse is a typo worth naming.
242
+ const configuredPort = (override: number | undefined): number | Error => {
243
+ if (override !== undefined) {
244
+ return isBindablePort(override)
245
+ ? override
246
+ : new Error(`not a port number: ${override}`);
247
+ }
248
+ const configured = (process.env.METRICS_PORT ?? "").trim();
249
+ if (configured === "") {
250
+ return DEFAULT_METRICS_PORT;
251
+ }
252
+ const parsed = Number(configured);
253
+ return isBindablePort(parsed)
254
+ ? parsed
255
+ : new Error(`METRICS_PORT is not a port number: ${configured}`);
256
+ };
257
+
233
258
  /**
234
259
  * The listener D2 adds to each worker image, for `/metrics` alone. Bound to the
235
260
  * compose network and never published to the host: the only host ports in the
@@ -239,8 +264,10 @@ const reportToStderr = (error: Error): void => {
239
264
  * later tick, by which time the poll loop is already running, so an unhandled
240
265
  * `'error'` event here would kill a worker in the middle of syncing mail —
241
266
  * 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.
267
+ * the same host is a real trigger. A port that is not a port at all is refused
268
+ * the same way, before `listen` can throw it at the caller. An observability
269
+ * endpoint must never be able to stop mail from arriving; absent metrics are
270
+ * the correct failure.
244
271
  *
245
272
  * Unreferenced from the event loop, so it never keeps a worker alive past the
246
273
  * end of its poll loop.
@@ -248,14 +275,18 @@ const reportToStderr = (error: Error): void => {
248
275
  export const startMetricsServer = (
249
276
  options: MetricsServerOptions = {},
250
277
  ): Server => {
251
- const port =
252
- options.port ??
253
- Number(process.env.METRICS_PORT ?? String(DEFAULT_METRICS_PORT));
254
278
  const host = options.host ?? process.env.METRICS_HOST ?? "0.0.0.0";
255
279
  const onError = options.onError ?? reportToStderr;
256
280
  const server = createServer(createMetricsRequestListener());
257
281
  server.unref();
258
282
  server.on("error", onError);
283
+
284
+ const port = configuredPort(options.port);
285
+ if (port instanceof Error) {
286
+ onError(port);
287
+ return server;
288
+ }
289
+
259
290
  server.listen(port, host);
260
291
  return server;
261
292
  };