@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.
@@ -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
- const addMetric = mock.fn();
6
- const publishStoredMetrics = mock.fn();
7
- 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();
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
- 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
-
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
- describe("remit-logger-lambda", () => {
94
- beforeEach(() => {
95
- for (const fn of recorded) fn.mock.resetCalls();
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
- 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
- });
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
- it("setBindings appends persistent keys", () => {
151
- const log = createLogger();
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 publishes metrics in finally even on error", async () => {
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
- const wrapped = withTelemetry(handler);
177
- await assert.rejects(wrapped({}, makeContext()));
178
- assert.ok(calls(publishStoredMetrics).length > 0);
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 emits errorCount on handler failure", async () => {
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
- const wrapped = withTelemetry(handler);
193
- await wrapped({}, makeContext());
194
- const [countCall, latencyCall] = calls(addMetric);
195
- assert.deepEqual(countCall, ["invocationCount", "Count", 1]);
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 { Logger as PowertoolsLogger } from "@aws-lambda-powertools/logger";
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 PowertoolsLevel =
8
- | "trace"
9
- | "debug"
10
- | "info"
11
- | "warn"
12
- | "error"
13
- | "critical";
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
- const emit = (
19
- target: PowertoolsLogger,
20
- level: PowertoolsLevel,
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
- ): void => {
24
- if (typeof first !== "string") {
25
- const message = typeof second === "string" ? second : "";
26
- target[level](message, first);
27
- return;
83
+ ): [LogBindings, string] => {
84
+ if (typeof first === "string") {
85
+ return [isBindings(second) ? second : {}, first];
28
86
  }
29
- if (second === undefined) {
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
- const createAdapter = (target: PowertoolsLogger): Logger => ({
58
- trace: (first: LogBindings | string, second?: LogBindings | string): void =>
59
- emit(target, "trace", first, second),
60
- debug: (first: LogBindings | string, second?: LogBindings | string): void =>
61
- emit(target, "debug", first, second),
62
- info: (first: LogBindings | string, second?: LogBindings | string): void =>
63
- emit(target, "info", first, second),
64
- warn: (first: LogBindings | string, second?: LogBindings | string): void =>
65
- emit(target, "warn", first, second),
66
- error: (first: LogBindings | string, second?: LogBindings | string): void =>
67
- emit(target, "error", first, second),
68
- fatal: (first: LogBindings | string, second?: LogBindings | string): void =>
69
- emit(target, "critical", first, second),
70
- child: (bindings: LogBindings): Logger => {
71
- const childLogger = target.createChild();
72
- if (isBindings(bindings)) {
73
- childLogger.appendPersistentKeys(bindings);
74
- }
75
- return createAdapter(childLogger);
76
- },
77
- setBindings: (bindings: LogBindings): void => {
78
- target.appendPersistentKeys(bindings);
79
- },
80
- });
81
-
82
- const powertoolsLogger = new PowertoolsLogger({
83
- serviceName: process.env.POWERTOOLS_SERVICE_NAME ?? "remit",
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
- export const logger: Logger = createAdapter(powertoolsLogger);
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 metrics = new Metrics({
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 = (_context?: Context): Logger =>
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
- const duration = Date.now() - start;
112
-
113
- metrics.addMetric("invocationCount", MetricUnit.Count, 1);
114
- metrics.addMetric("invocationLatency", MetricUnit.Milliseconds, duration);
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
- metrics.addMetric("errorCount", MetricUnit.Count, 1);
119
- logger.error("Lambda invocation failed", { error: String(err) });
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
  };