@remit/logger-lambda 0.0.11 → 0.0.13

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.13",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -28,7 +28,9 @@ process.stdout.write = ((
28
28
  return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
29
29
  }) as typeof process.stdout.write;
30
30
 
31
- const { createLogger, logger, withLogContext } = await import("./logger.js");
31
+ const { createLogger, logger, withLogContext, withTelemetry } = await import(
32
+ "./logger.js"
33
+ );
32
34
 
33
35
  const parse = (): Line[] =>
34
36
  written
@@ -353,3 +355,76 @@ describe("withLogContext", () => {
353
355
  assert.equal(repeats.length, 1);
354
356
  });
355
357
  });
358
+
359
+ describe("withTelemetry", () => {
360
+ const context = {
361
+ awsRequestId: "req-1",
362
+ functionName: "test-function",
363
+ } as unknown as Parameters<Parameters<typeof withTelemetry>[0]>[1];
364
+
365
+ it("puts its own start line inside the scope", async () => {
366
+ const lines = await captureAsync(async () => {
367
+ await withTelemetry(async () => "ok")({}, context);
368
+ });
369
+ const started = lines.find(
370
+ (line) => line.msg === "Lambda invocation started",
371
+ );
372
+ assert.ok(started, "expected the start line");
373
+ assert.equal(started.requestId, "req-1");
374
+ });
375
+
376
+ // The line an operator correlates from is written after the handler has
377
+ // unwound, which is exactly what a scope opened inside the handler misses.
378
+ it("puts its own failure line inside the scope", async () => {
379
+ const lines = await captureAsync(async () => {
380
+ await assert.rejects(
381
+ withTelemetry(async () => {
382
+ throw new Error("boom");
383
+ })({}, context),
384
+ );
385
+ });
386
+ const failed = lines.find(
387
+ (line) => line.msg === "Lambda invocation failed",
388
+ );
389
+ assert.ok(failed, "expected the failure line");
390
+ assert.equal(failed.requestId, "req-1");
391
+ });
392
+
393
+ it("carries the same requestId onto the handler's own lines", async () => {
394
+ const lines = await captureAsync(async () => {
395
+ await withTelemetry(async () => {
396
+ logger.warn("from the handler");
397
+ return "ok";
398
+ })({}, context);
399
+ });
400
+ assert.deepEqual(
401
+ [...new Set(lines.map((line) => line.requestId))],
402
+ ["req-1"],
403
+ );
404
+ });
405
+
406
+ it("keeps concurrent invocations apart", async () => {
407
+ const invoke = (id: string, delayMs: number) =>
408
+ withTelemetry(async () => {
409
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
410
+ logger.warn(`handled ${id}`);
411
+ })({}, {
412
+ awsRequestId: id,
413
+ functionName: "test-function",
414
+ } as unknown as typeof context);
415
+
416
+ const lines = await captureAsync(async () => {
417
+ await Promise.all([invoke("slow", 10), invoke("fast", 1)]);
418
+ });
419
+
420
+ assert.deepEqual(
421
+ lines
422
+ .filter((line) => String(line.msg).startsWith("handled "))
423
+ .map((line) => [line.msg, line.requestId]),
424
+ [
425
+ ["handled fast", "fast"],
426
+ ["handled slow", "slow"],
427
+ ],
428
+ );
429
+ });
430
+ });
package/src/logger.ts CHANGED
@@ -159,32 +159,38 @@ export const logger: Logger = createAdapter(root, {});
159
159
 
160
160
  export const createLogger = (): Logger => createAdapter(root, {});
161
161
 
162
+ // The scope opens around the wrapper's own two lines, not just the handler:
163
+ // "Lambda invocation failed" is the line an operator correlates from, and it is
164
+ // written after the handler has already unwound, so a scope opened inside the
165
+ // handler no longer covers it. Everything the invocation writes — the wrapper's
166
+ // lines and the handler's — carries the same `requestId`.
162
167
  export const withTelemetry = <TEvent, TResult>(
163
168
  handler: (event: TEvent, context: Context) => Promise<TResult>,
164
169
  ): ((event: TEvent, context: Context) => Promise<TResult>) => {
165
- return async (event: TEvent, context: Context): Promise<TResult> => {
166
- logger.debug("Lambda invocation started", {
167
- functionName: context.functionName,
168
- });
169
-
170
- const start = Date.now();
171
-
172
- try {
173
- const result = await handler(event, context);
174
- recordHandlerOutcome({
175
- handler: context.functionName,
176
- outcome: "success",
177
- durationMs: Date.now() - start,
178
- });
179
- return result;
180
- } catch (err) {
181
- recordHandlerOutcome({
182
- handler: context.functionName,
183
- outcome: "failure",
184
- durationMs: Date.now() - start,
170
+ return async (event: TEvent, context: Context): Promise<TResult> =>
171
+ withLogContext({ requestId: context.awsRequestId }, async () => {
172
+ logger.debug("Lambda invocation started", {
173
+ functionName: context.functionName,
185
174
  });
186
- logger.error("Lambda invocation failed", { error: err });
187
- throw err;
188
- }
189
- };
175
+
176
+ const start = Date.now();
177
+
178
+ try {
179
+ const result = await handler(event, context);
180
+ recordHandlerOutcome({
181
+ handler: context.functionName,
182
+ outcome: "success",
183
+ durationMs: Date.now() - start,
184
+ });
185
+ return result;
186
+ } catch (err) {
187
+ recordHandlerOutcome({
188
+ handler: context.functionName,
189
+ outcome: "failure",
190
+ durationMs: Date.now() - start,
191
+ });
192
+ logger.error("Lambda invocation failed", { error: err });
193
+ throw err;
194
+ }
195
+ });
190
196
  };
@@ -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
  };