Lambda Powertools for Node: Structured Logs That Survive On-Call

Lambda Powertools for Node: Structured Logs That Survive On-Call

Pretty console.log strings feel fine until 2 a.m., when you need every log line for one orderId across three Lambdas, a DLQ hop, and an API Gateway request id. Unstructured text does not survive CloudWatch Logs Insights at scale. AWS Lambda Powertools for TypeScript/Node is the unfair advantage: one Logger with persistent keys, EMF metrics without custom agents, and X-Ray traces that actually annotate business ids — without inventing your own observability framework.

⚡ TL;DR: Adopt @aws-lambda-powertools/logger, metrics, and tracer as the default handler middleware. Put cold_start, correlation_id, and business keys (tenantId, orderId) on every log. Emit EMF metrics for success/timeout/error; sample traces with annotations you can filter. Prefer JSON log format in CloudWatch. Illustrative bar: p99 Insights queries under a few seconds on a week of logs because every field is keyed — not regex archaeology.

Install once, wrap every handler

npm i @aws-lambda-powertools/logger @aws-lambda-powertools/metrics @aws-lambda-powertools/tracer
# optional middleware helper
npm i @aws-lambda-powertools/commons
// observability.ts — shared across functions in the service
import { Logger } from "@aws-lambda-powertools/logger";
import { Metrics } from "@aws-lambda-powertools/metrics";
import { Tracer } from "@aws-lambda-powertools/tracer";

export const logger = new Logger({
  serviceName: "checkout",
  logLevel: (process.env.LOG_LEVEL as any) ?? "INFO",
});

export const metrics = new Metrics({
  namespace: "CheatCoders/Checkout",
  serviceName: "checkout",
});

export const tracer = new Tracer({ serviceName: "checkout" });
// handler.ts
import type { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { logger, metrics, tracer } from "./observability";
import { MetricUnit } from "@aws-lambda-powertools/metrics";

const subsegmentAware = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
  const segment = tracer.getSegment();
  const sub = segment?.addNewSubsegment(name);
  try {
    return await fn();
  } catch (e) {
    sub?.addError(e as Error);
    throw e;
  } finally {
    sub?.close();
  }
};

export const handler: APIGatewayProxyHandlerV2 = async (event, context) => {
  // ✅ Persist request-scoped keys for all subsequent logs in this invoke
  logger.addContext(context);
  logger.appendKeys({
    correlation_id: event.headers?.["x-correlation-id"] ?? context.awsRequestId,
    path: event.rawPath,
  });

  metrics.captureColdStartMetric(); // Powertools cold-start metric helper when used with Middy / manual pattern

  try {
    const orderId = event.pathParameters?.orderId;
    if (!orderId) {
      logger.warn("missing_order_id");
      metrics.addMetric("ClientError", MetricUnit.Count, 1);
      return { statusCode: 400, body: "bad_request" };
    }

    logger.appendKeys({ orderId });
    tracer.putAnnotation("orderId", orderId);

    const result = await subsegmentAware("loadOrder", () => loadOrder(orderId));
    metrics.addMetric("OrderLoaded", MetricUnit.Count, 1);
    logger.info("order_loaded", { status: result.status });

    return { statusCode: 200, body: JSON.stringify(result) };
  } catch (err) {
    logger.error("handler_failed", { err: err instanceof Error ? err.message : String(err) });
    metrics.addMetric("HandlerError", MetricUnit.Count, 1);
    throw err;
  } finally {
    metrics.publishStoredMetrics();
  }
};

async function loadOrder(orderId: string) {
  return { orderId, status: "PAID" };
}

✅ One correlation id from the edge through every async hop (copy into SQS message attributes).
❌ Log only "error" with no keys — Insights cannot save you.

Structured logs that Insights can chew

Powertools Logger emits JSON. That unlocks queries like:

fields @timestamp, correlation_id, orderId, message, err
| filter service = "checkout"
| filter orderId = "ORD-9F2A"
| sort @timestamp asc

Set log retention and skip debug in prod by default:

// environment
// POWERTOOLS_SERVICE_NAME=checkout
// POWERTOOLS_LOG_LEVEL=INFO
// POWERTOOLS_METRICS_NAMESPACE=CheatCoders/Checkout
// POWERTOOLS_TRACE_ENABLED=true

Redact aggressively — structured does not mean “log the authorization header”:

logger.appendKeys({
  // ✅
  tenantId: "acme-42",
  // ❌ never
  // authorization: event.headers.authorization,
});

function scrub(event: Record<string, unknown>) {
  const clone = { ...event };
  delete (clone as any).password;
  delete (clone as any).cardNumber;
  return clone;
}

logger.debug("incoming", { event: scrub(event as any) });

For agent/tool Lambdas, the same discipline applies as in Amazon Bedrock Agents: Tool Use, Memory, and Production Guardrails — never put secrets into prompt attributes or log lines.

Metrics without a sidecar: Embedded Metric Format

Powertools Metrics buffers then prints EMF JSON that CloudWatch scrapes automatically. No StatsD daemon.

import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics";

const metrics = new Metrics({ namespace: "CheatCoders/Checkout", serviceName: "checkout" });

export function recordOutcome(kind: "success" | "timeout" | "error", latencyMs: number) {
  metrics.addDimension("Function", process.env.AWS_LAMBDA_FUNCTION_NAME ?? "local");
  metrics.addMetric("Invokes", MetricUnit.Count, 1);
  metrics.addMetric(
    kind === "success" ? "Success" : kind === "timeout" ? "Timeout" : "Error",
    MetricUnit.Count,
    1
  );
  metrics.addMetric("LatencyMs", MetricUnit.Milliseconds, latencyMs);
  metrics.publishStoredMetrics();
}

Alarm on Timeout and Error separately — see Lambda Timeouts, Retries, and DLQs for why blending them hides SEV-1s. Keep cardinality sane: dimensions like tenantId can explode metric costs; prefer logs for high-cardinality ids and metrics for aggregates.

Tracing: annotations over archaeology

Enable Active Tracing on the function. Powertools Tracer captures AWS SDK v3 clients when you instrument them, and lets you put annotations (indexed) vs metadata (not indexed):

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { tracer } from "./observability";

const raw = new DynamoDBClient({});
export const ddb = tracer.captureAWSv3Client(raw);

export async function markBusiness(orderId: string, amountCents: number) {
  tracer.putAnnotation("orderId", orderId); // filterable in X-Ray console
  tracer.putMetadata("checkout", { amountCents }); // detail pane only
}

Pass correlation_id as an SQS attribute so the next Lambda’s Logger can appendKeys on consume — that is how you stitch async architectures described in Event-Driven Architecture.

Middy middleware and sampling

If you already use Middy:

import middy from "@middy/core";
import { logger, metrics, tracer } from "./observability";
import { injectLambdaContext } from "@aws-lambda-powertools/logger/middleware";
import { logMetrics } from "@aws-lambda-powertools/metrics/middleware";
import { captureLambdaHandler } from "@aws-lambda-powertools/tracer/middleware";

export const handler = middy(async (event: any) => {
  logger.info("ok", { hello: "world" });
  return { statusCode: 200, body: "ok" };
})
  .use(captureLambdaHandler(tracer))
  .use(logMetrics(metrics, { captureColdStartMetric: true }))
  .use(injectLambdaContext(logger, { clearState: true }));

clearState: true matters: without it, persistent keys leak across warm invokes (tenant A’s orderId on tenant B’s request). That bug is worse than no Powertools at all.

For high-RPS functions, sample debug logs and traces (X-Ray reservoir + percentage). Keep errors always on.

On-call playbook baked into the code

  1. Page fires on Timeout metric or DLQ depth.
  2. Open Insights with correlation_id from the alert annotation.
  3. Jump to X-Ray trace via awsRequestId.
  4. Decide: dependency timeout (budget bug) vs poison message (idempotency / DLQ) vs cold-start eating the budget (Cold Starts on Node 20).

This pairs with Node.js Error Handling: Production Patterns and Monitoring — Powertools is how you make those patterns queryable on Lambda.

Closing checklist

✅ Dos
– ✅ JSON Logger with serviceName, correlation id, business keys
– ✅ clearState / clear keys between invokes when using middleware
– ✅ EMF metrics for success/timeout/error + latency
– ✅ Tracer annotations for ids you filter in incidents
– ✅ Redact tokens/PII; debug sampling in prod

❌ Don’ts
– ❌ Don’t console.log(JSON.stringify(event)) with auth headers
– ❌ Don’t put high-cardinality tenant ids on every metric dimension
– ❌ Don’t forget to publishStoredMetrics
– ❌ Don’t let persistent logger keys bleed across warm requests
– ❌ Don’t run prod at DEBUG “temporarily” for a week

Related reading

Last updated on September 10, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

2 Comments

Leave a Reply