@remit/logger-lambda 0.0.12 → 0.0.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/logger-lambda",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -28,7 +28,9 @@ process.stdout.write = ((
28
28
  return (originalWrite as (...args: unknown[]) => boolean)(chunk, ...rest);
29
29
  }) as typeof process.stdout.write;
30
30
 
31
- const { createLogger, logger, withLogContext } = await import("./logger.js");
31
+ const { createLogger, logger, withLogContext, withTelemetry } = await import(
32
+ "./logger.js"
33
+ );
32
34
 
33
35
  const parse = (): Line[] =>
34
36
  written
@@ -65,6 +67,23 @@ const one = (emit: () => void): Line => {
65
67
  return lines[0];
66
68
  };
67
69
 
70
+ // Both scopes have to be open at the same moment for a leak to be observable;
71
+ // a gate each side waits on pins that interleaving.
72
+ const gate = (): { reached: Promise<void>; pass: () => void } => {
73
+ let pass!: () => void;
74
+ const reached = new Promise<void>((resolve) => {
75
+ pass = () => resolve();
76
+ });
77
+ return { reached, pass };
78
+ };
79
+
80
+ // Sorted by message, so the comparison covers every line exactly once without
81
+ // naming the order the scheduler happened to finish them in.
82
+ const requestIdPerMessage = (lines: Line[]): [string, unknown][] =>
83
+ lines
84
+ .map((line): [string, unknown] => [String(line.msg), line.requestId])
85
+ .sort(([left], [right]) => left.localeCompare(right));
86
+
68
87
  describe("log output", () => {
69
88
  it("writes one JSON object per line on stdout", () => {
70
89
  const log = createLogger();
@@ -309,25 +328,30 @@ describe("withLogContext", () => {
309
328
  });
310
329
 
311
330
  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
- });
331
+ const slowStarted = gate();
332
+ const fastLogged = gate();
317
333
 
318
334
  // The slow request opens its scope first and logs last, which is exactly
319
335
  // the interleaving a shared mutable binding gets wrong.
320
336
  const lines = await captureAsync(async () => {
321
- await Promise.all([request("slow", 10), request("fast", 1)]);
337
+ await Promise.all([
338
+ withLogContext({ requestId: "slow" }, async () => {
339
+ slowStarted.pass();
340
+ await fastLogged.reached;
341
+ logger.warn("handled slow");
342
+ }),
343
+ withLogContext({ requestId: "fast" }, async () => {
344
+ await slowStarted.reached;
345
+ logger.warn("handled fast");
346
+ fastLogged.pass();
347
+ }),
348
+ ]);
322
349
  });
323
350
 
324
- assert.deepEqual(
325
- lines.map((line) => [line.msg, line.requestId]),
326
- [
327
- ["handled fast", "fast"],
328
- ["handled slow", "slow"],
329
- ],
330
- );
351
+ assert.deepEqual(requestIdPerMessage(lines), [
352
+ ["handled fast", "fast"],
353
+ ["handled slow", "slow"],
354
+ ]);
331
355
  });
332
356
 
333
357
  it("nests, adding to the scope it runs inside", () => {
@@ -353,3 +377,87 @@ describe("withLogContext", () => {
353
377
  assert.equal(repeats.length, 1);
354
378
  });
355
379
  });
380
+
381
+ describe("withTelemetry", () => {
382
+ const context = {
383
+ awsRequestId: "req-1",
384
+ functionName: "test-function",
385
+ } as unknown as Parameters<Parameters<typeof withTelemetry>[0]>[1];
386
+
387
+ it("puts its own start line inside the scope", async () => {
388
+ const lines = await captureAsync(async () => {
389
+ await withTelemetry(async () => "ok")({}, context);
390
+ });
391
+ const started = lines.find(
392
+ (line) => line.msg === "Lambda invocation started",
393
+ );
394
+ assert.ok(started, "expected the start line");
395
+ assert.equal(started.requestId, "req-1");
396
+ });
397
+
398
+ // The line an operator correlates from is written after the handler has
399
+ // unwound, which is exactly what a scope opened inside the handler misses.
400
+ it("puts its own failure line inside the scope", async () => {
401
+ const lines = await captureAsync(async () => {
402
+ await assert.rejects(
403
+ withTelemetry(async () => {
404
+ throw new Error("boom");
405
+ })({}, context),
406
+ );
407
+ });
408
+ const failed = lines.find(
409
+ (line) => line.msg === "Lambda invocation failed",
410
+ );
411
+ assert.ok(failed, "expected the failure line");
412
+ assert.equal(failed.requestId, "req-1");
413
+ });
414
+
415
+ it("carries the same requestId onto the handler's own lines", async () => {
416
+ const lines = await captureAsync(async () => {
417
+ await withTelemetry(async () => {
418
+ logger.warn("from the handler");
419
+ return "ok";
420
+ })({}, context);
421
+ });
422
+ assert.deepEqual(
423
+ [...new Set(lines.map((line) => line.requestId))],
424
+ ["req-1"],
425
+ );
426
+ });
427
+
428
+ it("keeps concurrent invocations apart", async () => {
429
+ const slowStarted = gate();
430
+ const fastLogged = gate();
431
+
432
+ const invoke = (id: string, handler: () => Promise<void>) =>
433
+ withTelemetry(handler)({}, {
434
+ awsRequestId: id,
435
+ functionName: "test-function",
436
+ } as unknown as typeof context);
437
+
438
+ const lines = await captureAsync(async () => {
439
+ await Promise.all([
440
+ invoke("slow", async () => {
441
+ slowStarted.pass();
442
+ await fastLogged.reached;
443
+ logger.warn("handled slow");
444
+ }),
445
+ invoke("fast", async () => {
446
+ await slowStarted.reached;
447
+ logger.warn("handled fast");
448
+ fastLogged.pass();
449
+ }),
450
+ ]);
451
+ });
452
+
453
+ assert.deepEqual(
454
+ requestIdPerMessage(
455
+ lines.filter((line) => String(line.msg).startsWith("handled ")),
456
+ ),
457
+ [
458
+ ["handled fast", "fast"],
459
+ ["handled slow", "slow"],
460
+ ],
461
+ );
462
+ });
463
+ });
package/src/logger.ts CHANGED
@@ -159,32 +159,38 @@ export const logger: Logger = createAdapter(root, {});
159
159
 
160
160
  export const createLogger = (): Logger => createAdapter(root, {});
161
161
 
162
+ // The scope opens around the wrapper's own two lines, not just the handler:
163
+ // "Lambda invocation failed" is the line an operator correlates from, and it is
164
+ // written after the handler has already unwound, so a scope opened inside the
165
+ // handler no longer covers it. Everything the invocation writes — the wrapper's
166
+ // lines and the handler's — carries the same `requestId`.
162
167
  export const withTelemetry = <TEvent, TResult>(
163
168
  handler: (event: TEvent, context: Context) => Promise<TResult>,
164
169
  ): ((event: TEvent, context: Context) => Promise<TResult>) => {
165
- return async (event: TEvent, context: Context): Promise<TResult> => {
166
- logger.debug("Lambda invocation started", {
167
- functionName: context.functionName,
168
- });
169
-
170
- const start = Date.now();
171
-
172
- try {
173
- const result = await handler(event, context);
174
- recordHandlerOutcome({
175
- handler: context.functionName,
176
- outcome: "success",
177
- durationMs: Date.now() - start,
178
- });
179
- return result;
180
- } catch (err) {
181
- recordHandlerOutcome({
182
- handler: context.functionName,
183
- outcome: "failure",
184
- durationMs: Date.now() - start,
170
+ return async (event: TEvent, context: Context): Promise<TResult> =>
171
+ withLogContext({ requestId: context.awsRequestId }, async () => {
172
+ logger.debug("Lambda invocation started", {
173
+ functionName: context.functionName,
185
174
  });
186
- logger.error("Lambda invocation failed", { error: err });
187
- throw err;
188
- }
189
- };
175
+
176
+ const start = Date.now();
177
+
178
+ try {
179
+ const result = await handler(event, context);
180
+ recordHandlerOutcome({
181
+ handler: context.functionName,
182
+ outcome: "success",
183
+ durationMs: Date.now() - start,
184
+ });
185
+ return result;
186
+ } catch (err) {
187
+ recordHandlerOutcome({
188
+ handler: context.functionName,
189
+ outcome: "failure",
190
+ durationMs: Date.now() - start,
191
+ });
192
+ logger.error("Lambda invocation failed", { error: err });
193
+ throw err;
194
+ }
195
+ });
190
196
  };
@@ -353,7 +353,7 @@ describe("the /metrics endpoint", () => {
353
353
  onError: (error) => reported.push(error),
354
354
  });
355
355
  servers.push(server);
356
- await new Promise((resolve) => setTimeout(resolve, 50));
356
+ await new Promise<void>((resolve) => server.once("error", () => resolve()));
357
357
  assert.equal(reported.length, 1);
358
358
  assert.match(String(reported[0]), /EADDRINUSE/);
359
359
  // The port that was already bound still answers: nothing died.
@@ -370,7 +370,7 @@ describe("the /metrics endpoint", () => {
370
370
  }) as typeof process.stderr.write;
371
371
  const server = startMetricsServer({ port: held, host: "127.0.0.1" });
372
372
  servers.push(server);
373
- await new Promise((resolve) => setTimeout(resolve, 50));
373
+ await new Promise<void>((resolve) => server.once("error", () => resolve()));
374
374
  process.stderr.write = restore;
375
375
 
376
376
  assert.equal(written.length, 1);