@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/src/logger.test.ts
CHANGED
|
@@ -2,78 +2,19 @@ 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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();
|
|
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 what reaches the metric registry is observed.
|
|
8
|
+
process.env.LOG_LEVEL = "silent";
|
|
16
9
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
31
|
-
class MockMetrics {
|
|
32
|
-
addMetric = addMetric;
|
|
33
|
-
publishStoredMetrics = publishStoredMetrics;
|
|
34
|
-
captureColdStartMetric = captureColdStartMetric;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
mock.module("@aws-lambda-powertools/logger", {
|
|
38
|
-
namedExports: { Logger: MockLogger },
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
mock.module("@aws-lambda-powertools/metrics", {
|
|
42
|
-
namedExports: {
|
|
43
|
-
Metrics: MockMetrics,
|
|
44
|
-
MetricUnit: { Count: "Count", Milliseconds: "Milliseconds" },
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const { createLogger, metrics, withTelemetry } = await import("./logger.js");
|
|
49
|
-
const { Metrics } = await import("@aws-lambda-powertools/metrics");
|
|
10
|
+
const { withTelemetry } = await import("./logger.js");
|
|
11
|
+
const { registry, resetMetrics } = await import("./metrics.js");
|
|
50
12
|
|
|
51
13
|
type Recorded = { mock: { calls: { arguments: unknown[] }[] } };
|
|
52
14
|
|
|
53
15
|
const calls = (fn: Recorded): unknown[][] =>
|
|
54
16
|
fn.mock.calls.map((call) => call.arguments);
|
|
55
17
|
|
|
56
|
-
const lastCall = (fn: Recorded): unknown[] => {
|
|
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
|
-
];
|
|
76
|
-
|
|
77
18
|
const makeContext = (): Context =>
|
|
78
19
|
({
|
|
79
20
|
awsRequestId: "test-request-id",
|
|
@@ -90,68 +31,34 @@ const makeContext = (): Context =>
|
|
|
90
31
|
succeed: () => {},
|
|
91
32
|
}) as unknown as Context;
|
|
92
33
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
it("exports metrics as a Metrics instance", () => {
|
|
99
|
-
assert.ok(metrics instanceof Metrics);
|
|
100
|
-
});
|
|
101
|
-
|
|
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
|
-
});
|
|
34
|
+
type HistogramValue = {
|
|
35
|
+
metricName?: string;
|
|
36
|
+
labels: Record<string, string | number>;
|
|
37
|
+
value: number;
|
|
38
|
+
};
|
|
140
39
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
40
|
+
const handlerAggregate = async (
|
|
41
|
+
suffix: string,
|
|
42
|
+
labels: Record<string, string>,
|
|
43
|
+
): Promise<number> => {
|
|
44
|
+
const metric = registry.getSingleMetric("remit_handler_duration_seconds");
|
|
45
|
+
assert.ok(metric, "expected the handler duration histogram to be registered");
|
|
46
|
+
const { values } = (await metric.get()) as { values: HistogramValue[] };
|
|
47
|
+
const match = values.find(
|
|
48
|
+
(value) =>
|
|
49
|
+
value.metricName === `remit_handler_duration_seconds_${suffix}` &&
|
|
50
|
+
value.labels.handler === labels.handler &&
|
|
51
|
+
value.labels.outcome === labels.outcome,
|
|
52
|
+
);
|
|
53
|
+
assert.ok(
|
|
54
|
+
match,
|
|
55
|
+
`expected a _${suffix} sample for ${JSON.stringify(labels)}`,
|
|
56
|
+
);
|
|
57
|
+
return match.value;
|
|
58
|
+
};
|
|
149
59
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
log.setBindings({ requestId: "r1" });
|
|
153
|
-
assert.deepEqual(lastCall(appendPersistentKeys), [{ requestId: "r1" }]);
|
|
154
|
-
});
|
|
60
|
+
describe("remit-logger-lambda", () => {
|
|
61
|
+
beforeEach(() => resetMetrics());
|
|
155
62
|
|
|
156
63
|
it("withTelemetry calls the handler and returns its result", async () => {
|
|
157
64
|
const handler = mock.fn(async () => "hello");
|
|
@@ -169,34 +76,20 @@ describe("remit-logger-lambda", () => {
|
|
|
169
76
|
await assert.rejects(wrapped({}, makeContext()), /boom/);
|
|
170
77
|
});
|
|
171
78
|
|
|
172
|
-
it("withTelemetry
|
|
79
|
+
it("withTelemetry records a failed invocation against the registry", async () => {
|
|
173
80
|
const handler = mock.fn(async () => {
|
|
174
81
|
throw new Error("fail");
|
|
175
82
|
});
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
assert.
|
|
83
|
+
await assert.rejects(withTelemetry(handler)({}, makeContext()));
|
|
84
|
+
const labels = { handler: "test-function", outcome: "failure" };
|
|
85
|
+
assert.equal(await handlerAggregate("count", labels), 1);
|
|
179
86
|
});
|
|
180
87
|
|
|
181
|
-
it("withTelemetry
|
|
182
|
-
const handler = mock.fn(async () => {
|
|
183
|
-
throw new Error("fail");
|
|
184
|
-
});
|
|
185
|
-
const wrapped = withTelemetry(handler);
|
|
186
|
-
await assert.rejects(wrapped({}, makeContext()));
|
|
187
|
-
assert.deepEqual(calls(addMetric), [["errorCount", "Count", 1]]);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("withTelemetry emits invocationCount and invocationLatency on success", async () => {
|
|
88
|
+
it("withTelemetry records a successful invocation and its duration", async () => {
|
|
191
89
|
const handler = mock.fn(async () => 42);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
assert.
|
|
196
|
-
assert.deepEqual(latencyCall.slice(0, 2), [
|
|
197
|
-
"invocationLatency",
|
|
198
|
-
"Milliseconds",
|
|
199
|
-
]);
|
|
200
|
-
assert.equal(typeof latencyCall[2], "number");
|
|
90
|
+
await withTelemetry(handler)({}, makeContext());
|
|
91
|
+
const labels = { handler: "test-function", outcome: "success" };
|
|
92
|
+
assert.equal(await handlerAggregate("count", labels), 1);
|
|
93
|
+
assert.equal(typeof (await handlerAggregate("sum", labels)), "number");
|
|
201
94
|
});
|
|
202
95
|
});
|
package/src/logger.ts
CHANGED
|
@@ -1,40 +1,90 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics";
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
2
|
import type { Context } from "aws-lambda";
|
|
3
|
+
import { pino, stdSerializers } from "pino";
|
|
4
|
+
import { recordHandlerOutcome } from "./metrics.js";
|
|
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;
|
|
83
|
+
): [LogBindings, string] => {
|
|
84
|
+
if (typeof first === "string") {
|
|
85
|
+
return [isBindings(second) ? second : {}, first];
|
|
28
86
|
}
|
|
29
|
-
|
|
30
|
-
target[level](first);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
if (typeof second === "string") {
|
|
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,72 +104,87 @@ 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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
};
|
|
85
136
|
|
|
86
|
-
|
|
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
|
+
};
|
|
87
157
|
|
|
88
|
-
export const
|
|
89
|
-
namespace: process.env.POWERTOOLS_METRICS_NAMESPACE ?? "Remit",
|
|
90
|
-
serviceName: process.env.POWERTOOLS_SERVICE_NAME ?? "remit",
|
|
91
|
-
});
|
|
158
|
+
export const logger: Logger = createAdapter(root, {});
|
|
92
159
|
|
|
93
|
-
export const createLogger = (
|
|
94
|
-
createAdapter(powertoolsLogger);
|
|
160
|
+
export const createLogger = (): Logger => createAdapter(root, {});
|
|
95
161
|
|
|
96
162
|
export const withTelemetry = <TEvent, TResult>(
|
|
97
163
|
handler: (event: TEvent, context: Context) => Promise<TResult>,
|
|
98
164
|
): ((event: TEvent, context: Context) => Promise<TResult>) => {
|
|
99
165
|
return async (event: TEvent, context: Context): Promise<TResult> => {
|
|
100
|
-
powertoolsLogger.addContext(context);
|
|
101
166
|
logger.debug("Lambda invocation started", {
|
|
102
167
|
functionName: context.functionName,
|
|
103
168
|
});
|
|
104
169
|
|
|
105
|
-
metrics.captureColdStartMetric();
|
|
106
|
-
|
|
107
170
|
const start = Date.now();
|
|
108
171
|
|
|
109
172
|
try {
|
|
110
173
|
const result = await handler(event, context);
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
174
|
+
recordHandlerOutcome({
|
|
175
|
+
handler: context.functionName,
|
|
176
|
+
outcome: "success",
|
|
177
|
+
durationMs: Date.now() - start,
|
|
178
|
+
});
|
|
116
179
|
return result;
|
|
117
180
|
} catch (err) {
|
|
118
|
-
|
|
119
|
-
|
|
181
|
+
recordHandlerOutcome({
|
|
182
|
+
handler: context.functionName,
|
|
183
|
+
outcome: "failure",
|
|
184
|
+
durationMs: Date.now() - start,
|
|
185
|
+
});
|
|
186
|
+
logger.error("Lambda invocation failed", { error: err });
|
|
120
187
|
throw err;
|
|
121
|
-
} finally {
|
|
122
|
-
metrics.publishStoredMetrics();
|
|
123
188
|
}
|
|
124
189
|
};
|
|
125
190
|
};
|