@remit/logger-lambda 0.0.9 → 0.0.10
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 +39 -24
- package/package.json +3 -3
- package/src/log-level.test.ts +77 -0
- package/src/log-output.test.ts +355 -0
- package/src/logger.test.ts +7 -101
- package/src/logger.ts +127 -57
package/README.md
CHANGED
|
@@ -1,35 +1,26 @@
|
|
|
1
1
|
# @remit/logger-lambda
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
15
|
+
import type { SQSEvent } from "aws-lambda";
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
// Initialize logger with Lambda context
|
|
26
|
-
const log = createLogger(context);
|
|
17
|
+
const log = createLogger().child({ queue: "remit-imap-sync" });
|
|
27
18
|
|
|
28
|
-
|
|
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,33 @@ export const handler = async (event: SQSEvent, context: Context) => {
|
|
|
37
28
|
};
|
|
38
29
|
```
|
|
39
30
|
|
|
40
|
-
|
|
31
|
+
Both argument orders work: `(bindings, message)` and `(message, bindings)`.
|
|
32
|
+
Bindings land at the top level of the line, never nested. A value under `error`
|
|
33
|
+
that is an `Error` is expanded to `type`, `message` and `stack`; anything else is
|
|
34
|
+
written as it is.
|
|
35
|
+
|
|
36
|
+
`child(bindings)` returns a logger that adds those bindings to every line, and
|
|
37
|
+
`setBindings(bindings)` adds them to an existing one. Neither is per-request:
|
|
38
|
+
they belong to the logger instance, so on a process serving requests
|
|
39
|
+
concurrently use `withLogContext` instead.
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { logger, withLogContext } from "@remit/logger-lambda";
|
|
43
|
+
|
|
44
|
+
const handler = (request: Request) =>
|
|
45
|
+
withLogContext({ requestId: request.id }, async () => {
|
|
46
|
+
logger.debug("Request received"); // carries requestId
|
|
47
|
+
return respond(request);
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The scope follows the work through its asynchronous continuations, so two
|
|
52
|
+
overlapping requests never see each other's fields. Scopes nest, and an inner
|
|
53
|
+
one adds to the bindings of the one it runs inside.
|
|
54
|
+
|
|
55
|
+
## Environment variables
|
|
41
56
|
|
|
42
|
-
| Variable
|
|
43
|
-
|
|
|
44
|
-
| `LOG_LEVEL`
|
|
45
|
-
| `
|
|
57
|
+
| Variable | Default | Description |
|
|
58
|
+
| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
59
|
+
| `LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`, `fatal` or `silent`. An unrecognised value logs one warning and falls back to the default. |
|
|
60
|
+
| `REMIT_SERVICE_NAME` | `remit` | The `service` field on every line. Stamped into each service bundle at build time by `npm-scripts/docker-bundle.mjs`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/logger-lambda",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"build": "npm run test:typecheck"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@aws-lambda-powertools/logger": "^2",
|
|
21
20
|
"@aws-lambda-powertools/metrics": "^2",
|
|
22
|
-
"@types/aws-lambda": "*"
|
|
21
|
+
"@types/aws-lambda": "*",
|
|
22
|
+
"pino": "^10"
|
|
23
23
|
},
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"publishConfig": {
|
|
@@ -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
|
+
});
|
package/src/logger.test.ts
CHANGED
|
@@ -2,31 +2,14 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { beforeEach, describe, it, mock } from "node:test";
|
|
3
3
|
import type { Context } from "aws-lambda";
|
|
4
4
|
|
|
5
|
+
// The logging half of this package is covered by log-output.test.ts against its
|
|
6
|
+
// real stdout output; this file is the telemetry wrapper, so the logger is
|
|
7
|
+
// silenced and only the metric calls are observed.
|
|
8
|
+
process.env.LOG_LEVEL = "silent";
|
|
9
|
+
|
|
5
10
|
const addMetric = mock.fn();
|
|
6
11
|
const publishStoredMetrics = mock.fn();
|
|
7
12
|
const captureColdStartMetric = mock.fn();
|
|
8
|
-
const addContext = mock.fn();
|
|
9
|
-
const logTrace = mock.fn();
|
|
10
|
-
const logDebug = mock.fn();
|
|
11
|
-
const logInfo = mock.fn();
|
|
12
|
-
const logWarn = mock.fn();
|
|
13
|
-
const logError = mock.fn();
|
|
14
|
-
const logCritical = mock.fn();
|
|
15
|
-
const appendPersistentKeys = mock.fn();
|
|
16
|
-
|
|
17
|
-
class MockLogger {
|
|
18
|
-
addContext = addContext;
|
|
19
|
-
trace = logTrace;
|
|
20
|
-
debug = logDebug;
|
|
21
|
-
info = logInfo;
|
|
22
|
-
warn = logWarn;
|
|
23
|
-
error = logError;
|
|
24
|
-
critical = logCritical;
|
|
25
|
-
appendPersistentKeys = appendPersistentKeys;
|
|
26
|
-
createChild = () => createChild();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const createChild = mock.fn(() => new MockLogger());
|
|
30
13
|
|
|
31
14
|
class MockMetrics {
|
|
32
15
|
addMetric = addMetric;
|
|
@@ -34,10 +17,6 @@ class MockMetrics {
|
|
|
34
17
|
captureColdStartMetric = captureColdStartMetric;
|
|
35
18
|
}
|
|
36
19
|
|
|
37
|
-
mock.module("@aws-lambda-powertools/logger", {
|
|
38
|
-
namedExports: { Logger: MockLogger },
|
|
39
|
-
});
|
|
40
|
-
|
|
41
20
|
mock.module("@aws-lambda-powertools/metrics", {
|
|
42
21
|
namedExports: {
|
|
43
22
|
Metrics: MockMetrics,
|
|
@@ -45,7 +24,7 @@ mock.module("@aws-lambda-powertools/metrics", {
|
|
|
45
24
|
},
|
|
46
25
|
});
|
|
47
26
|
|
|
48
|
-
const {
|
|
27
|
+
const { metrics, withTelemetry } = await import("./logger.js");
|
|
49
28
|
const { Metrics } = await import("@aws-lambda-powertools/metrics");
|
|
50
29
|
|
|
51
30
|
type Recorded = { mock: { calls: { arguments: unknown[] }[] } };
|
|
@@ -53,26 +32,7 @@ type Recorded = { mock: { calls: { arguments: unknown[] }[] } };
|
|
|
53
32
|
const calls = (fn: Recorded): unknown[][] =>
|
|
54
33
|
fn.mock.calls.map((call) => call.arguments);
|
|
55
34
|
|
|
56
|
-
const
|
|
57
|
-
const recorded = calls(fn);
|
|
58
|
-
assert.ok(recorded.length > 0, "expected the mock to have been called");
|
|
59
|
-
return recorded[recorded.length - 1];
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const recorded = [
|
|
63
|
-
addMetric,
|
|
64
|
-
publishStoredMetrics,
|
|
65
|
-
captureColdStartMetric,
|
|
66
|
-
addContext,
|
|
67
|
-
logTrace,
|
|
68
|
-
logDebug,
|
|
69
|
-
logInfo,
|
|
70
|
-
logWarn,
|
|
71
|
-
logError,
|
|
72
|
-
logCritical,
|
|
73
|
-
appendPersistentKeys,
|
|
74
|
-
createChild,
|
|
75
|
-
];
|
|
35
|
+
const recorded = [addMetric, publishStoredMetrics, captureColdStartMetric];
|
|
76
36
|
|
|
77
37
|
const makeContext = (): Context =>
|
|
78
38
|
({
|
|
@@ -99,60 +59,6 @@ describe("remit-logger-lambda", () => {
|
|
|
99
59
|
assert.ok(metrics instanceof Metrics);
|
|
100
60
|
});
|
|
101
61
|
|
|
102
|
-
it("object-first call maps to Powertools message + attributes", () => {
|
|
103
|
-
const log = createLogger(makeContext());
|
|
104
|
-
log.error({ error: "boom", messageId: "m1" }, "Failed to parse message");
|
|
105
|
-
assert.deepEqual(lastCall(logError), [
|
|
106
|
-
"Failed to parse message",
|
|
107
|
-
{ error: "boom", messageId: "m1" },
|
|
108
|
-
]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("object-first call without message uses empty string", () => {
|
|
112
|
-
const log = createLogger();
|
|
113
|
-
log.info({ count: 3 });
|
|
114
|
-
assert.deepEqual(lastCall(logInfo), ["", { count: 3 }]);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("string-first call passes message then attributes", () => {
|
|
118
|
-
const log = createLogger();
|
|
119
|
-
log.warn("watch out", { reason: "slow" });
|
|
120
|
-
assert.deepEqual(lastCall(logWarn), ["watch out", { reason: "slow" }]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("string-first call without attributes passes only the message", () => {
|
|
124
|
-
const log = createLogger();
|
|
125
|
-
log.debug("hello");
|
|
126
|
-
assert.deepEqual(lastCall(logDebug), ["hello"]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("fatal maps to Powertools critical", () => {
|
|
130
|
-
const log = createLogger();
|
|
131
|
-
log.fatal({ fatal: true }, "the end");
|
|
132
|
-
assert.deepEqual(lastCall(logCritical), ["the end", { fatal: true }]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("trace maps to Powertools trace", () => {
|
|
136
|
-
const log = createLogger();
|
|
137
|
-
log.trace("trace me");
|
|
138
|
-
assert.deepEqual(lastCall(logTrace), ["trace me"]);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("child creates a Powertools child and appends bindings", () => {
|
|
142
|
-
const log = createLogger();
|
|
143
|
-
const child = log.child({ queue: "imap" });
|
|
144
|
-
assert.equal(calls(createChild).length, 1);
|
|
145
|
-
assert.deepEqual(lastCall(appendPersistentKeys), [{ queue: "imap" }]);
|
|
146
|
-
child.info({ done: true }, "child log");
|
|
147
|
-
assert.deepEqual(lastCall(logInfo), ["child log", { done: true }]);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("setBindings appends persistent keys", () => {
|
|
151
|
-
const log = createLogger();
|
|
152
|
-
log.setBindings({ requestId: "r1" });
|
|
153
|
-
assert.deepEqual(lastCall(appendPersistentKeys), [{ requestId: "r1" }]);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
62
|
it("withTelemetry calls the handler and returns its result", async () => {
|
|
157
63
|
const handler = mock.fn(async () => "hello");
|
|
158
64
|
const wrapped = withTelemetry(handler);
|
package/src/logger.ts
CHANGED
|
@@ -1,40 +1,90 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics";
|
|
3
3
|
import type { Context } from "aws-lambda";
|
|
4
|
+
import { pino, stdSerializers } from "pino";
|
|
4
5
|
|
|
5
6
|
type LogBindings = Record<string, unknown>;
|
|
6
7
|
|
|
7
|
-
type
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
type EmitLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
|
9
|
+
|
|
10
|
+
const LEVELS = [
|
|
11
|
+
"trace",
|
|
12
|
+
"debug",
|
|
13
|
+
"info",
|
|
14
|
+
"warn",
|
|
15
|
+
"error",
|
|
16
|
+
"fatal",
|
|
17
|
+
"silent",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
const DEFAULT_LEVEL = "info";
|
|
21
|
+
|
|
22
|
+
const isLevel = (value: string): boolean =>
|
|
23
|
+
(LEVELS as readonly string[]).includes(value);
|
|
24
|
+
|
|
25
|
+
const requestedLevel = process.env.LOG_LEVEL?.trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
const level =
|
|
28
|
+
requestedLevel && isLevel(requestedLevel) ? requestedLevel : DEFAULT_LEVEL;
|
|
14
29
|
|
|
15
30
|
const isBindings = (value: unknown): value is LogBindings =>
|
|
16
31
|
typeof value === "object" && value !== null;
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
// Written by the writer itself, so a binding of the same name would be a second
|
|
34
|
+
// occurrence of the key on the line and every JSON parser takes the last one —
|
|
35
|
+
// a call-site field named `level` would silently retag the line. Dropped rather
|
|
36
|
+
// than renamed: a field the operator cannot rely on is worse than an absent one.
|
|
37
|
+
const RESERVED = ["level", "time", "service", "msg"] as const;
|
|
38
|
+
|
|
39
|
+
const withoutReserved = (fields: LogBindings): LogBindings => {
|
|
40
|
+
let kept: LogBindings | undefined;
|
|
41
|
+
for (const key of RESERVED) {
|
|
42
|
+
if (!(key in fields)) continue;
|
|
43
|
+
kept ??= { ...fields };
|
|
44
|
+
delete kept[key];
|
|
45
|
+
}
|
|
46
|
+
return kept ?? fields;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// One JSON object per line on stdout: `level` as a lowercase name, `time` as
|
|
50
|
+
// RFC 3339, `service` naming the image, `msg` always present, and every binding
|
|
51
|
+
// at the top level. The field contract is documented in deploy/vps/README.md
|
|
52
|
+
// under "Logs" — log-shipping rules are written against these names.
|
|
53
|
+
//
|
|
54
|
+
// `error` carries an Error at most call sites in this repo, so it is serialised
|
|
55
|
+
// the way pino serialises `err`: a bare Error spreads to nothing, which is how a
|
|
56
|
+
// stack trace disappears from a worker failure line.
|
|
57
|
+
const root = pino({
|
|
58
|
+
level,
|
|
59
|
+
base: { service: process.env.REMIT_SERVICE_NAME ?? "remit" },
|
|
60
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
61
|
+
formatters: { level: (label: string) => ({ level: label }) },
|
|
62
|
+
serializers: { error: stdSerializers.err },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
type PinoLogger = typeof root;
|
|
66
|
+
|
|
67
|
+
// A level nobody can spell is worth one line rather than a crashed container:
|
|
68
|
+
// the operator asked for something and did not get it, and every other line
|
|
69
|
+
// still arrives.
|
|
70
|
+
if (requestedLevel && requestedLevel !== level) {
|
|
71
|
+
root.warn(
|
|
72
|
+
{ configured: requestedLevel, expected: LEVELS.join(", ") },
|
|
73
|
+
`LOG_LEVEL is not a level name; logging at ${level}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The interface accepts (bindings, message) and (message, bindings); pino reads
|
|
78
|
+
// (bindings, message) only, and treats an object after a string message as a
|
|
79
|
+
// format argument. Normalising here is what keeps both call shapes working.
|
|
80
|
+
const normalize = (
|
|
21
81
|
first: LogBindings | string,
|
|
22
82
|
second?: LogBindings | string,
|
|
23
|
-
):
|
|
24
|
-
if (typeof first
|
|
25
|
-
|
|
26
|
-
target[level](message, first);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
if (second === undefined) {
|
|
30
|
-
target[level](first);
|
|
31
|
-
return;
|
|
83
|
+
): [LogBindings, string] => {
|
|
84
|
+
if (typeof first === "string") {
|
|
85
|
+
return [isBindings(second) ? second : {}, first];
|
|
32
86
|
}
|
|
33
|
-
|
|
34
|
-
target[level](first, second);
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
target[level](first, second);
|
|
87
|
+
return [first, typeof second === "string" ? second : ""];
|
|
38
88
|
};
|
|
39
89
|
|
|
40
90
|
export interface Logger {
|
|
@@ -54,50 +104,70 @@ export interface Logger {
|
|
|
54
104
|
setBindings(bindings: LogBindings): void;
|
|
55
105
|
}
|
|
56
106
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
// Bindings from a scope opened by withLogContext. A server handling requests
|
|
108
|
+
// concurrently cannot carry per-request fields on a shared logger instance:
|
|
109
|
+
// whichever request wrote last owns them until the next one overwrites them.
|
|
110
|
+
const scope = new AsyncLocalStorage<LogBindings>();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Runs `fn` with `bindings` on every line any logger writes inside it,
|
|
114
|
+
* including asynchronous continuations. Scopes nest: an inner call adds to the
|
|
115
|
+
* bindings of the one it runs inside.
|
|
116
|
+
*/
|
|
117
|
+
export const withLogContext = <T>(bindings: LogBindings, fn: () => T): T =>
|
|
118
|
+
scope.run({ ...scope.getStore(), ...bindings }, fn);
|
|
119
|
+
|
|
120
|
+
// Every binding is merged into one object here rather than handed to pino:
|
|
121
|
+
// pino's own child/setBindings append to a cached string it writes verbatim, so
|
|
122
|
+
// a shadowing key would appear twice on one line, and a per-request setBindings
|
|
123
|
+
// on a long-lived logger would grow that string without bound.
|
|
124
|
+
const createAdapter = (target: PinoLogger, persistent: LogBindings): Logger => {
|
|
125
|
+
const emit = (
|
|
126
|
+
level: EmitLevel,
|
|
127
|
+
first: LogBindings | string,
|
|
128
|
+
second?: LogBindings | string,
|
|
129
|
+
): void => {
|
|
130
|
+
const [fields, message] = normalize(first, second);
|
|
131
|
+
target[level](
|
|
132
|
+
withoutReserved({ ...persistent, ...scope.getStore(), ...fields }),
|
|
133
|
+
message,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
81
136
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
137
|
+
return {
|
|
138
|
+
trace: (first: LogBindings | string, second?: LogBindings | string): void =>
|
|
139
|
+
emit("trace", first, second),
|
|
140
|
+
debug: (first: LogBindings | string, second?: LogBindings | string): void =>
|
|
141
|
+
emit("debug", first, second),
|
|
142
|
+
info: (first: LogBindings | string, second?: LogBindings | string): void =>
|
|
143
|
+
emit("info", first, second),
|
|
144
|
+
warn: (first: LogBindings | string, second?: LogBindings | string): void =>
|
|
145
|
+
emit("warn", first, second),
|
|
146
|
+
error: (first: LogBindings | string, second?: LogBindings | string): void =>
|
|
147
|
+
emit("error", first, second),
|
|
148
|
+
fatal: (first: LogBindings | string, second?: LogBindings | string): void =>
|
|
149
|
+
emit("fatal", first, second),
|
|
150
|
+
child: (bindings: LogBindings): Logger =>
|
|
151
|
+
createAdapter(target, { ...persistent, ...bindings }),
|
|
152
|
+
setBindings: (bindings: LogBindings): void => {
|
|
153
|
+
Object.assign(persistent, bindings);
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
};
|
|
85
157
|
|
|
86
|
-
export const logger: Logger = createAdapter(
|
|
158
|
+
export const logger: Logger = createAdapter(root, {});
|
|
87
159
|
|
|
88
160
|
export const metrics = new Metrics({
|
|
89
161
|
namespace: process.env.POWERTOOLS_METRICS_NAMESPACE ?? "Remit",
|
|
90
162
|
serviceName: process.env.POWERTOOLS_SERVICE_NAME ?? "remit",
|
|
91
163
|
});
|
|
92
164
|
|
|
93
|
-
export const createLogger = (
|
|
94
|
-
createAdapter(powertoolsLogger);
|
|
165
|
+
export const createLogger = (): Logger => createAdapter(root, {});
|
|
95
166
|
|
|
96
167
|
export const withTelemetry = <TEvent, TResult>(
|
|
97
168
|
handler: (event: TEvent, context: Context) => Promise<TResult>,
|
|
98
169
|
): ((event: TEvent, context: Context) => Promise<TResult>) => {
|
|
99
170
|
return async (event: TEvent, context: Context): Promise<TResult> => {
|
|
100
|
-
powertoolsLogger.addContext(context);
|
|
101
171
|
logger.debug("Lambda invocation started", {
|
|
102
172
|
functionName: context.functionName,
|
|
103
173
|
});
|
|
@@ -116,7 +186,7 @@ export const withTelemetry = <TEvent, TResult>(
|
|
|
116
186
|
return result;
|
|
117
187
|
} catch (err) {
|
|
118
188
|
metrics.addMetric("errorCount", MetricUnit.Count, 1);
|
|
119
|
-
logger.error("Lambda invocation failed", { error:
|
|
189
|
+
logger.error("Lambda invocation failed", { error: err });
|
|
120
190
|
throw err;
|
|
121
191
|
} finally {
|
|
122
192
|
metrics.publishStoredMetrics();
|