@remit/logger-lambda 0.0.9 → 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
@@ -1,35 +1,26 @@
1
1
  # @remit/logger-lambda
2
2
 
3
- Shared logging package for Remit Lambda workers. Wraps [Pino](https://getpino.io/) with Lambda-specific configuration and environment-aware formatting.
3
+ The logging seam every Remit service writes through. One JSON object per line on
4
+ stdout, via [pino](https://getpino.io/). The exported `Logger` interface is what
5
+ consuming code imports; the writer behind it is an implementation detail.
4
6
 
5
- ## Features
6
-
7
- - **Structured Logging**: JSON output for CloudWatch ingestion in production.
8
- - **Pretty Print**: Human-readable output for local development.
9
- - **Request Context**: Automatically correlates logs with Lambda request IDs.
10
- - **Zero Configuration**: Sensible defaults based on `NODE_ENV`.
11
-
12
- ## Installation
13
-
14
- ```bash
15
- npm install @remit/logger-lambda
16
- ```
7
+ The field names on each line are a contract an operator's log pipeline parses.
8
+ They are documented, with the reserved names and the personal-data caveat, in
9
+ [`deploy/vps/README.md`](../../deploy/vps/README.md) under "Logs".
17
10
 
18
11
  ## Usage
19
12
 
20
13
  ```typescript
21
14
  import { createLogger } from "@remit/logger-lambda";
22
- import type { SQSEvent, Context } from "aws-lambda";
15
+ import type { SQSEvent } from "aws-lambda";
23
16
 
24
- export const handler = async (event: SQSEvent, context: Context) => {
25
- // Initialize logger with Lambda context
26
- const log = createLogger(context);
17
+ const log = createLogger().child({ queue: "remit-imap-sync" });
27
18
 
28
- log.info({ event }, "Processing SQS event");
19
+ export const handler = async (event: SQSEvent) => {
20
+ log.debug({ records: event.Records.length }, "Batch received");
29
21
 
30
22
  try {
31
23
  // ... business logic
32
- log.info("Success");
33
24
  } catch (error) {
34
25
  log.error({ error }, "Processing failed");
35
26
  throw error;
@@ -37,9 +28,69 @@ export const handler = async (event: SQSEvent, context: Context) => {
37
28
  };
38
29
  ```
39
30
 
40
- ## Environment Variables
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
+
65
+ Both argument orders work: `(bindings, message)` and `(message, bindings)`.
66
+ Bindings land at the top level of the line, never nested. A value under `error`
67
+ that is an `Error` is expanded to `type`, `message` and `stack`; anything else is
68
+ written as it is.
69
+
70
+ `child(bindings)` returns a logger that adds those bindings to every line, and
71
+ `setBindings(bindings)` adds them to an existing one. Neither is per-request:
72
+ they belong to the logger instance, so on a process serving requests
73
+ concurrently use `withLogContext` instead.
74
+
75
+ ```typescript
76
+ import { logger, withLogContext } from "@remit/logger-lambda";
77
+
78
+ const handler = (request: Request) =>
79
+ withLogContext({ requestId: request.id }, async () => {
80
+ logger.debug("Request received"); // carries requestId
81
+ return respond(request);
82
+ });
83
+ ```
84
+
85
+ The scope follows the work through its asynchronous continuations, so two
86
+ overlapping requests never see each other's fields. Scopes nest, and an inner
87
+ one adds to the bindings of the one it runs inside.
88
+
89
+ ## Environment variables
41
90
 
42
- | Variable | Default | Description |
43
- | ----------- | ------------ | ------------------------------------------------------- |
44
- | `LOG_LEVEL` | `info` | Pino log level (trace, debug, info, warn, error, fatal) |
45
- | `NODE_ENV` | `production` | If `development`, enables pretty printing |
91
+ | Variable | Default | Description |
92
+ | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
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
+ | `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.9",
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/logger": "^2",
21
- "@aws-lambda-powertools/metrics": "^2",
22
- "@types/aws-lambda": "*"
24
+ "@types/aws-lambda": "*",
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";
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ process.env.LOG_LEVEL = "verbose";
5
+ delete process.env.REMIT_SERVICE_NAME;
6
+
7
+ type Line = Record<string, unknown>;
8
+
9
+ const originalWrite = process.stdout.write.bind(process.stdout);
10
+ const written: string[] = [];
11
+ let capturing = true;
12
+
13
+ process.stdout.write = ((
14
+ chunk: string | Uint8Array,
15
+ ...rest: unknown[]
16
+ ): boolean => {
17
+ if (capturing && typeof chunk === "string") {
18
+ written.push(chunk);
19
+ return true;
20
+ }
21
+ return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
22
+ }) as typeof process.stdout.write;
23
+
24
+ // Capturing starts before the import: an unusable LOG_LEVEL is reported while
25
+ // the module is evaluated, not on the first call.
26
+ const { createLogger } = await import("./logger.js");
27
+ capturing = false;
28
+
29
+ const startup = written
30
+ .join("")
31
+ .split("\n")
32
+ .filter((line) => line.length > 0)
33
+ .map((line) => JSON.parse(line) as Line);
34
+
35
+ const capture = (emit: () => void): Line[] => {
36
+ written.length = 0;
37
+ capturing = true;
38
+ try {
39
+ emit();
40
+ } finally {
41
+ capturing = false;
42
+ }
43
+ return written
44
+ .join("")
45
+ .split("\n")
46
+ .filter((line) => line.length > 0)
47
+ .map((line) => JSON.parse(line) as Line);
48
+ };
49
+
50
+ describe("log level", () => {
51
+ it("reports an unusable LOG_LEVEL instead of failing or ignoring it", () => {
52
+ assert.equal(startup.length, 1);
53
+ const [line] = startup;
54
+ assert.equal(line.level, "warn");
55
+ assert.match(String(line.msg), /LOG_LEVEL/);
56
+ assert.equal(line.configured, "verbose");
57
+ assert.match(String(line.expected), /trace/);
58
+ });
59
+
60
+ it("falls back to info, so debug and trace are dropped", () => {
61
+ const log = createLogger();
62
+ const lines = capture(() => {
63
+ log.trace("dropped");
64
+ log.debug("dropped");
65
+ log.warn("kept");
66
+ });
67
+ assert.deepEqual(
68
+ lines.map((line) => line.msg),
69
+ ["kept"],
70
+ );
71
+ });
72
+
73
+ it("defaults the service name when none is stamped into the build", () => {
74
+ const [line] = capture(() => createLogger().warn("unnamed"));
75
+ assert.equal(line.service, "remit");
76
+ });
77
+ });
@@ -0,0 +1,355 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+
4
+ // The field contract these tests pin down is documented for operators in
5
+ // deploy/vps/README.md ("Logs"). A change here is a change to what every
6
+ // log-shipping rule against this deployment parses.
7
+ process.env.LOG_LEVEL = "trace";
8
+ process.env.REMIT_SERVICE_NAME = "test-service";
9
+
10
+ type Line = Record<string, unknown>;
11
+
12
+ const originalWrite = process.stdout.write.bind(process.stdout);
13
+ const written: string[] = [];
14
+ let capturing = false;
15
+
16
+ // pino uses `process.stdout` directly when its `write` has been replaced, and
17
+ // the real fd otherwise — so the hook has to be in place before the import
18
+ // below, and it passes writes through whenever a test is not capturing so the
19
+ // test runner's own output still reaches the terminal.
20
+ process.stdout.write = ((
21
+ chunk: string | Uint8Array,
22
+ ...rest: unknown[]
23
+ ): boolean => {
24
+ if (capturing && typeof chunk === "string") {
25
+ written.push(chunk);
26
+ return true;
27
+ }
28
+ return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
29
+ }) as typeof process.stdout.write;
30
+
31
+ const { createLogger, logger, withLogContext } = await import("./logger.js");
32
+
33
+ const parse = (): Line[] =>
34
+ written
35
+ .join("")
36
+ .split("\n")
37
+ .filter((line) => line.length > 0)
38
+ .map((line) => JSON.parse(line) as Line);
39
+
40
+ const capture = (emit: () => void): Line[] => {
41
+ written.length = 0;
42
+ capturing = true;
43
+ try {
44
+ emit();
45
+ } finally {
46
+ capturing = false;
47
+ }
48
+ return parse();
49
+ };
50
+
51
+ const captureAsync = async (emit: () => Promise<void>): Promise<Line[]> => {
52
+ written.length = 0;
53
+ capturing = true;
54
+ try {
55
+ await emit();
56
+ } finally {
57
+ capturing = false;
58
+ }
59
+ return parse();
60
+ };
61
+
62
+ const one = (emit: () => void): Line => {
63
+ const lines = capture(emit);
64
+ assert.equal(lines.length, 1, "expected exactly one log line");
65
+ return lines[0];
66
+ };
67
+
68
+ describe("log output", () => {
69
+ it("writes one JSON object per line on stdout", () => {
70
+ const log = createLogger();
71
+ const lines = capture(() => {
72
+ log.warn("first");
73
+ log.warn("second");
74
+ });
75
+ assert.deepEqual(
76
+ lines.map((line) => line.msg),
77
+ ["first", "second"],
78
+ );
79
+ });
80
+
81
+ it("carries level, time, service and msg on every line", () => {
82
+ const line = one(() => createLogger().warn("hello"));
83
+ assert.equal(line.level, "warn");
84
+ assert.equal(line.service, "test-service");
85
+ assert.equal(line.msg, "hello");
86
+ assert.equal(typeof line.time, "string");
87
+ assert.equal(
88
+ new Date(line.time as string).toISOString(),
89
+ line.time,
90
+ "time is RFC 3339 in UTC",
91
+ );
92
+ });
93
+
94
+ it("names each level in lowercase, with fatal as its own name", () => {
95
+ const log = createLogger();
96
+ const levels = capture(() => {
97
+ log.trace("t");
98
+ log.debug("d");
99
+ log.info("i");
100
+ log.warn("w");
101
+ log.error("e");
102
+ log.fatal("f");
103
+ }).map((line) => line.level);
104
+ assert.deepEqual(levels, [
105
+ "trace",
106
+ "debug",
107
+ "info",
108
+ "warn",
109
+ "error",
110
+ "fatal",
111
+ ]);
112
+ });
113
+
114
+ it("puts bindings at the top level, message first", () => {
115
+ const line = one(() =>
116
+ createLogger().error(
117
+ { error: "boom", messageId: "m1" },
118
+ "Failed to parse message",
119
+ ),
120
+ );
121
+ assert.equal(line.msg, "Failed to parse message");
122
+ assert.equal(line.error, "boom");
123
+ assert.equal(line.messageId, "m1");
124
+ });
125
+
126
+ it("emits an empty msg when only bindings are given", () => {
127
+ const line = one(() => createLogger().warn({ count: 3 }));
128
+ assert.equal(line.msg, "");
129
+ assert.equal(line.count, 3);
130
+ });
131
+
132
+ it("accepts bindings after a message", () => {
133
+ const line = one(() =>
134
+ createLogger().warn("watch out", { reason: "slow" }),
135
+ );
136
+ assert.equal(line.msg, "watch out");
137
+ assert.equal(line.reason, "slow");
138
+ });
139
+
140
+ it("ignores a non-object second argument after a message", () => {
141
+ const line = one(() =>
142
+ (createLogger().warn as (msg: string, obj?: unknown) => void)(
143
+ "plain",
144
+ "not-bindings",
145
+ ),
146
+ );
147
+ assert.equal(line.msg, "plain");
148
+ });
149
+
150
+ it("child bindings appear on every line the child writes", () => {
151
+ const child = createLogger().child({ queue: "imap" });
152
+ const line = one(() => child.warn({ done: true }, "child log"));
153
+ assert.equal(line.queue, "imap");
154
+ assert.equal(line.done, true);
155
+ assert.equal(line.msg, "child log");
156
+ });
157
+
158
+ it("a call-site field shadowing a child binding is written once", () => {
159
+ const child = createLogger().child({ queue: "from-child" });
160
+ const [line] = capture(() => child.warn({ queue: "from-callsite" }, "x"));
161
+ assert.equal(line.queue, "from-callsite");
162
+ const repeats = written.join("").match(/"queue"/g) ?? [];
163
+ assert.equal(repeats.length, 1, "one key per line, not two");
164
+ });
165
+
166
+ it("a child inherits the bindings its parent had when it was created", () => {
167
+ const parent = createLogger();
168
+ parent.setBindings({ accountId: "a1" });
169
+ const child = parent.child({ queue: "smtp" });
170
+ const line = one(() => child.warn("nested"));
171
+ assert.equal(line.accountId, "a1");
172
+ assert.equal(line.queue, "smtp");
173
+ });
174
+
175
+ it("setBindings applies to later lines and replaces a key without repeating it", () => {
176
+ const log = createLogger();
177
+ log.setBindings({ requestId: "r1", path: "/one" });
178
+ const first = one(() => log.warn("first request"));
179
+ assert.equal(first.requestId, "r1");
180
+ assert.equal(first.path, "/one");
181
+
182
+ log.setBindings({ requestId: "r2", path: "/two" });
183
+ const [raw] = capture(() => log.warn("second request"));
184
+ assert.equal(raw.requestId, "r2");
185
+ assert.equal(raw.path, "/two");
186
+
187
+ const repeats = written.join("").match(/"requestId"/g) ?? [];
188
+ assert.equal(repeats.length, 1, "a rebound key is written once, not twice");
189
+ });
190
+
191
+ it("bindings set on one logger do not leak into another", () => {
192
+ const first = createLogger();
193
+ const second = createLogger();
194
+ first.setBindings({ owner: "first" });
195
+ const line = one(() => second.warn("independent"));
196
+ assert.equal(line.owner, undefined);
197
+ });
198
+
199
+ it("the shared logger writes the same shape", () => {
200
+ const line = one(() => logger.warn("shared"));
201
+ assert.equal(line.service, "test-service");
202
+ assert.equal(line.msg, "shared");
203
+ });
204
+
205
+ it("carries no pid or hostname", () => {
206
+ const line = one(() => createLogger().warn("lean"));
207
+ assert.equal(line.pid, undefined);
208
+ assert.equal(line.hostname, undefined);
209
+ });
210
+
211
+ it("drops a call-site field using a reserved name", () => {
212
+ const [line] = capture(() =>
213
+ createLogger().warn(
214
+ { level: "trace", time: "1999", service: "other", msg: "hijack" },
215
+ "collide",
216
+ ),
217
+ );
218
+ assert.equal(line.level, "warn");
219
+ assert.equal(line.service, "test-service");
220
+ assert.equal(line.msg, "collide");
221
+ for (const key of ['"level"', '"time"', '"service"', '"msg"']) {
222
+ const repeats = written.join("").match(new RegExp(key, "g")) ?? [];
223
+ assert.equal(repeats.length, 1, `${key} is written once`);
224
+ }
225
+ });
226
+ });
227
+
228
+ // The shapes the worker and backend failure paths actually produce. A contract
229
+ // test that asserts a key no call site writes is how a serialisation regression
230
+ // stays green, so each case below names the call site it mirrors.
231
+ describe("error serialisation", () => {
232
+ it("expands an Error logged under error — imap-worker/src/index.ts", () => {
233
+ const line = one(() =>
234
+ createLogger().error(
235
+ { error: new Error("boom"), messageId: "m1" },
236
+ "Event processing failed",
237
+ ),
238
+ );
239
+ const error = line.error as Record<string, unknown>;
240
+ assert.equal(error.type, "Error");
241
+ assert.equal(error.message, "boom");
242
+ assert.match(String(error.stack), /^Error: boom\n\s+at /);
243
+ assert.equal(line.messageId, "m1");
244
+ });
245
+
246
+ it("keeps a subclass name and its own properties", () => {
247
+ class UpstreamError extends Error {
248
+ readonly statusCode = 502;
249
+ }
250
+ const line = one(() =>
251
+ createLogger().error({ error: new UpstreamError("gone") }, "failed"),
252
+ );
253
+ const error = line.error as Record<string, unknown>;
254
+ assert.equal(error.type, "UpstreamError");
255
+ assert.equal(error.statusCode, 502);
256
+ });
257
+
258
+ it("leaves a string alone — backend/src/error.ts", () => {
259
+ const failure = new Error("ElectroError: bad key");
260
+ const line = one(() =>
261
+ createLogger().error(
262
+ { error: failure.message, name: failure.name, stack: failure.stack },
263
+ "Unhandled Error",
264
+ ),
265
+ );
266
+ assert.equal(line.error, "ElectroError: bad key");
267
+ assert.equal(line.name, "Error");
268
+ assert.match(String(line.stack), /^Error: ElectroError: bad key\n\s+at /);
269
+ });
270
+
271
+ it("leaves a plain object alone — backend/src/response.ts", () => {
272
+ const line = one(() =>
273
+ createLogger().error(
274
+ { "problematicValue_/items": { path: "/items", error: "too long" } },
275
+ "Response validation failed",
276
+ ),
277
+ );
278
+ assert.deepEqual(line["problematicValue_/items"], {
279
+ path: "/items",
280
+ error: "too long",
281
+ });
282
+ });
283
+
284
+ it("expands an Error under err too", () => {
285
+ const line = one(() =>
286
+ createLogger().error({ err: new Error("kaboom") }, "handler failed"),
287
+ );
288
+ const err = line.err as Record<string, unknown>;
289
+ assert.equal(err.type, "Error");
290
+ assert.equal(err.message, "kaboom");
291
+ });
292
+ });
293
+
294
+ describe("withLogContext", () => {
295
+ it("adds its bindings to every line written inside it", () => {
296
+ const line = one(() =>
297
+ withLogContext({ requestId: "r1", path: "/one" }, () => {
298
+ logger.warn("inside");
299
+ }),
300
+ );
301
+ assert.equal(line.requestId, "r1");
302
+ assert.equal(line.path, "/one");
303
+ });
304
+
305
+ it("does not leak past the scope", () => {
306
+ withLogContext({ requestId: "r1" }, () => {});
307
+ const line = one(() => logger.warn("outside"));
308
+ assert.equal(line.requestId, undefined);
309
+ });
310
+
311
+ it("keeps overlapping scopes apart across awaits", async () => {
312
+ const request = (id: string, delayMs: number) =>
313
+ withLogContext({ requestId: id }, async () => {
314
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
315
+ logger.warn(`handled ${id}`);
316
+ });
317
+
318
+ // The slow request opens its scope first and logs last, which is exactly
319
+ // the interleaving a shared mutable binding gets wrong.
320
+ const lines = await captureAsync(async () => {
321
+ await Promise.all([request("slow", 10), request("fast", 1)]);
322
+ });
323
+
324
+ assert.deepEqual(
325
+ lines.map((line) => [line.msg, line.requestId]),
326
+ [
327
+ ["handled fast", "fast"],
328
+ ["handled slow", "slow"],
329
+ ],
330
+ );
331
+ });
332
+
333
+ it("nests, adding to the scope it runs inside", () => {
334
+ const line = one(() =>
335
+ withLogContext({ requestId: "r1" }, () =>
336
+ withLogContext({ accountId: "a1" }, () => {
337
+ logger.warn("nested scope");
338
+ }),
339
+ ),
340
+ );
341
+ assert.equal(line.requestId, "r1");
342
+ assert.equal(line.accountId, "a1");
343
+ });
344
+
345
+ it("a call-site field wins over a scope binding, and is written once", () => {
346
+ const [line] = capture(() =>
347
+ withLogContext({ requestId: "from-scope" }, () => {
348
+ logger.warn({ requestId: "from-callsite" }, "x");
349
+ }),
350
+ );
351
+ assert.equal(line.requestId, "from-callsite");
352
+ const repeats = written.join("").match(/"requestId"/g) ?? [];
353
+ assert.equal(repeats.length, 1);
354
+ });
355
+ });