@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 +1 -1
- package/package.json +1 -1
- package/src/log-output.test.ts +76 -1
- package/src/logger.ts +30 -24
- package/src/metrics.test.ts +77 -0
- package/src/metrics.ts +36 -5
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
package/src/log-output.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
};
|
package/src/metrics.test.ts
CHANGED
|
@@ -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.
|
|
243
|
-
*
|
|
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
|
};
|