@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 +75 -24
- package/package.json +8 -4
- package/src/index.ts +1 -1
- package/src/log-level.test.ts +77 -0
- package/src/log-output.test.ts +355 -0
- package/src/logger.test.ts +41 -148
- package/src/logger.ts +138 -73
- package/src/metrics.test.ts +405 -0
- package/src/metrics.ts +261 -0
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,69 @@ export const handler = async (event: SQSEvent, context: Context) => {
|
|
|
37
28
|
};
|
|
38
29
|
```
|
|
39
30
|
|
|
40
|
-
##
|
|
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
|
|
43
|
-
|
|
|
44
|
-
| `LOG_LEVEL`
|
|
45
|
-
| `
|
|
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.
|
|
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
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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
|
@@ -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
|
+
});
|