With Lambda Powertools TypeScript you get structured logs, custom metrics and traces without writing the same helper functions for every function again. In Serverless projects I keep seeing the same picture: every Lambda logs a little differently, the only metrics are the built-in ones, and when something breaks at night you dig through CloudWatch until your eyes hurt.

In this article we build a small API for a fictional bike rental from scratch. It logs JSON with request context, counts rentals as a CloudWatch metric and shows every DynamoDB call in X-Ray. Infrastructure comes from the Serverless Framework this time, and we test locally with Vitest, without any AWS account.

In short

Powertools for TypeScript are three small packages (Logger, Metrics, Tracer) that you plug into any handler via Middy. The biggest win: consistent JSON logs with a correlation ID and metrics without a single API call. The most common pitfall is metric dimensions with many values, because every combination is billed as a separate metric.

What you will learn in this article

  • Install Logger, Metrics and Tracer and wire them up cleanly with Middy
  • Use correlation IDs and buffered DEBUG logs for troubleshooting
  • When a value should be a dimension, metadata or an X-Ray annotation
  • Test the handler locally with Vitest, including the EMF output
  • What Powertools cost in cold start time and on your bill

What are Lambda Powertools for TypeScript?

Powertools for AWS Lambda is an open source toolkit from AWS, available for Python, TypeScript, Java and .NET. The TypeScript flavor consists of several npm packages that you install one by one. For observability you need three of them. Observability here means: you can tell from the outside what is happening in your system without shipping new code.

  • Logger writes structured JSON logs and automatically adds Lambda context, for example the function name, request ID and a cold start flag.
  • Metrics creates custom CloudWatch metrics in the Embedded Metric Format (EMF). That is a JSON format which CloudWatch reads from the logs and turns into metrics. Your function makes no API call for it.
  • Tracer is a thin layer on top of the AWS X-Ray SDK. X-Ray records which services an invocation touches and how long each step takes.

The most convenient way to plug in all three is Middy, a small middleware engine for Lambda. A middleware is code that runs before and after your handler. Alternatively there are class decorators or the manual way, but in practice Middy reads best.

Diagram: Lambda function sends data via Logger, Metrics and Tracer to CloudWatch Logs, CloudWatch Metrics and X-Ray

One invocation produces three signals: logs, metrics and traces.

Why console.log is not enough in Lambda

Of course console.log ends up in CloudWatch too. The trouble starts when you search. Free-text logs are hard to filter, and without a request ID you cannot tell which line belongs to which invocation. You are also missing business metrics: Lambda gives you invocations, errors and duration, but not “how many e-bikes were rented today?”. These three packages close exactly that gap, with a handful of lines.

Prerequisites

  • Node.js 22 or newer and npm (the example runs on Lambda with nodejs24.x)
  • Serverless Framework v4 (npm i -g serverless); v4 requires you to sign in, and it is free for small organizations
  • An AWS account with credentials if you want to deploy. You do not need one for the local tests.
  • Basic knowledge of Lambda and TypeScript

Set up Lambda Powertools TypeScript step by step

We build a function called createRental. It accepts a rental via HTTP API (station, bike type, minutes), stores it in DynamoDB and responds with 201. It is deliberately small so the focus stays on logs, metrics and traces.

Step 1: Create the project and install the packages

mkdir bike-rental && cd bike-rental npm init -y npm install @aws-lambda-powertools/logger @aws-lambda-powertools/metrics @aws-lambda-powertools/tracer @aws-lambda-powertools/jmespath @middy/core @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb npm install -D typescript @types/aws-lambda @types/node vitest aws-sdk-client-mock 

You only need the jmespath package for the correlation ID (more on that in a moment). This is what the package.json looks like afterwards, with the versions I tested the example with:

// package.json { "name": "bike-rental-observability", "version": "1.0.0", "private": true, "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { "@aws-lambda-powertools/jmespath": "^2.36.0", "@aws-lambda-powertools/logger": "^2.36.0", "@aws-lambda-powertools/metrics": "^2.36.0", "@aws-lambda-powertools/tracer": "^2.36.0", "@aws-sdk/client-dynamodb": "^3.1161.0", "@aws-sdk/lib-dynamodb": "^3.1161.0", "@middy/core": "^7.9.2" }, "devDependencies": { "@types/aws-lambda": "^8.10.164", "@types/node": "^24.19.2", "aws-sdk-client-mock": "^4.1.0", "esbuild": "^0.28.2", "typescript": "^5.9.3", "vitest": "^5.1.0" } } 

Add a lean tsconfig.json. tsc does the type checking, and esbuild in the Serverless Framework handles bundling later.

// tsconfig.json { "compilerOptions": { "target": "ES2023", "module": "preserve", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "test"] } 

Step 2: Create Logger, Metrics and Tracer in one place

All three objects belong outside the handler. That way they are created once per execution environment and reused on every following invocation. I put them in a separate file so that helper functions can use the same logger later.

// src/observability.ts import { Logger } from '@aws-lambda-powertools/logger'; import { search } from '@aws-lambda-powertools/logger/correlationId'; import { Metrics } from '@aws-lambda-powertools/metrics'; import { Tracer } from '@aws-lambda-powertools/tracer'; // Create once per execution environment, outside the handler. // Service name and namespace come from the environment variables // POWERTOOLS_SERVICE_NAME and POWERTOOLS_METRICS_NAMESPACE. export const logger = new Logger({ correlationIdSearchFn: search, persistentKeys: { stage: process.env.STAGE ?? 'dev' }, logBufferOptions: { enabled: true, // buffer DEBUG logs ... bufferAtVerbosity: 'DEBUG', flushOnErrorLog: true, // ... and only write them when an error is logged }, }); export const metrics = new Metrics(); export const tracer = new Tracer(); 

Two options are worth a closer look. persistentKeys adds a key to every log line, here the stage. The log buffer collects DEBUG lines per invocation in memory and only writes them out when an error is logged. Normally your logs stay lean, and when something fails you still get the full context.

Step 3: Wire up the handler with Middy

Now for the actual handler. Watch out for three things: the patched DynamoDB client, the places with addMetric and the middleware chain at the end.

// src/handler.ts import { randomUUID } from 'node:crypto'; import middy from '@middy/core'; import { correlationPaths } from '@aws-lambda-powertools/logger/correlationId'; import { injectLambdaContext } from '@aws-lambda-powertools/logger/middleware'; import { MetricUnit } from '@aws-lambda-powertools/metrics'; import { logMetrics } from '@aws-lambda-powertools/metrics/middleware'; import { captureLambdaHandler } from '@aws-lambda-powertools/tracer/middleware'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda'; import { logger, metrics, tracer } from './observability'; // The tracer patches the client: every DynamoDB call becomes its own subsegment. const ddb = DynamoDBDocumentClient.from( tracer.captureAWSv3Client(new DynamoDBClient({})), ); const TABLE_NAME = process.env.RENTALS_TABLE ?? 'rentals'; const BIKE_TYPES = ['city', 'ebike', 'cargo'] as const; type BikeType = (typeof BIKE_TYPES)[number]; interface RentalRequest { stationId: string; bikeType: BikeType; minutes: number; } function parseRequest(body: string | undefined): RentalRequest | undefined { try { const data = JSON.parse(body ?? ''); const valid = typeof data.stationId === 'string' && BIKE_TYPES.includes(data.bikeType) && Number.isInteger(data.minutes) && data.minutes > 0 && data.minutes <= 24 * 60; return valid ? (data as RentalRequest) : undefined; } catch { return undefined; } } const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({ statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const createRental = async ( event: APIGatewayProxyEventV2, ): Promise<APIGatewayProxyResultV2> => { logger.debug('Raw request data', { body: event.body }); const request = parseRequest(event.body); if (!request) { logger.warn('Invalid rental rejected'); metrics.addMetric('RentalRejected', MetricUnit.Count, 1); return json(400, { message: 'check stationId, bikeType and minutes' }); } const rentalId = randomUUID(); // Applies to all following log lines of this invocation. logger.appendKeys({ rentalId, bikeType: request.bikeType }); // Annotations are indexed in X-Ray and can be filtered. tracer.putAnnotation('bikeType', request.bikeType); tracer.putAnnotation('stationId', request.stationId); try { await ddb.send( new PutCommand({ TableName: TABLE_NAME, Item: { rentalId, ...request, createdAt: new Date().toISOString() }, }), ); } catch (error) { // flushOnErrorLog: the buffered DEBUG logs are written now as well. logger.error('Could not save rental', error as Error); metrics.addMetric('RentalFailed', MetricUnit.Count, 1); return json(500, { message: 'Rental failed', traceId: tracer.getRootXrayTraceId(), }); } // Few values (3 bike types) => fine as a dimension. metrics.addDimension('bikeType', request.bikeType); metrics.addMetric('RentalCreated', MetricUnit.Count, 1); metrics.addMetric('RentalMinutes', MetricUnit.Count, request.minutes); // Many values (every ID is new) => metadata, not a dimension. metrics.addMetadata('rentalId', rentalId); logger.info('Rental created', { minutes: request.minutes }); return json(201, { rentalId }); }; export const handler = middy(createRental) .use(captureLambdaHandler(tracer)) .use(logMetrics(metrics, { captureColdStartMetric: true })) .use( injectLambdaContext(logger, { correlationIdPath: correlationPaths.API_GATEWAY_HTTP, resetKeys: true, flushBufferOnUncaughtError: true, }), ); 

What happens here? captureAWSv3Client makes every DynamoDB call show up as its own subsegment in X-Ray. appendKeys adds the rental ID to all following log lines, and resetKeys: true clears it again after the invocation. Without that option, keys would “leak” into the next invocation in the same environment. The logger reads the correlation ID straight from the event via correlationPaths.API_GATEWAY_HTTP; for the HTTP API that is the API Gateway request ID.

Step 4: The serverless.yml

Serverless Framework v4 bundles TypeScript itself with esbuild, so you do not need a plugin. The Powertools packages are configured through environment variables.

# serverless.yml service: bike-rental frameworkVersion: '4' provider: name: aws runtime: nodejs24.x architecture: arm64 region: eu-central-1 memorySize: 512 timeout: 10 logRetentionInDays: 14 # Enable X-Ray: Lambda gets Active Tracing, # the Framework adds xray:PutTraceSegments and xray:PutTelemetryRecords. tracing: lambda: true environment: STAGE: ${sls:stage} POWERTOOLS_SERVICE_NAME: bike-rental POWERTOOLS_METRICS_NAMESPACE: BikeRental POWERTOOLS_LOG_LEVEL: INFO RENTALS_TABLE: { Ref: RentalsTable } iam: role: statements: - Effect: Allow Action: dynamodb:PutItem Resource: { 'Fn::GetAtt': [RentalsTable, Arn] } build: esbuild: bundle: true minify: true sourcemap: false functions: createRental: handler: src/handler.handler events: - httpApi: method: POST path: /rentals resources: Resources: RentalsTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: rentalId AttributeType: S KeySchema: - AttributeName: rentalId KeyType: HASH 

With tracing.lambda: true the Framework turns on Active Tracing and adds the IAM permissions for X-Ray automatically. A note on the module format: Middy 7 now ships as an ES module only. That is not a problem, because esbuild converts it to CommonJS while bundling. If you switch your own project to ESM, the X-Ray SDK needs a require shim as a banner, which the Tracer docs describe.

Step 5: Test locally

Before anything lands in AWS, we test locally. The tracer switches itself off outside of Lambda, and we replace DynamoDB with aws-sdk-client-mock. With POWERTOOLS_DEV=true Logger and Metrics write via console, so we can check the EMF output directly.

// test/handler.test.ts import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mockClient } from 'aws-sdk-client-mock'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; import type { Context } from 'aws-lambda'; vi.hoisted(() => { process.env.POWERTOOLS_SERVICE_NAME = 'bike-rental'; process.env.POWERTOOLS_METRICS_NAMESPACE = 'BikeRental'; process.env.POWERTOOLS_DEV = 'true'; // output via console.* so tests can read it process.env.POWERTOOLS_METRICS_DISABLED = 'false'; }); import { handler } from '../src/handler'; const ddbMock = mockClient(DynamoDBDocumentClient); const context = { functionName: 'createRental', functionVersion: '$LATEST', invokedFunctionArn: 'arn:aws:lambda:eu-central-1:123456789012:function:createRental', memoryLimitInMB: '512', awsRequestId: 'c0ffee00-0000-4000-8000-000000000001', getRemainingTimeInMillis: () => 3000, } as unknown as Context; const httpEvent = (body: unknown) => ({ body: JSON.stringify(body), requestContext: { requestId: 'req-4711' }, }) as never; const emfOutput = (spy: { mock: { calls: unknown[][] } }) => spy.mock.calls .map((args: unknown[]) => String(args[0])) .filter((line: string) => line.includes('"_aws"')) .map((line: string) => JSON.parse(line)); describe('createRental', () => { beforeEach(() => { // In Lambda the runtime sets this variable per invocation. The log buffer depends on it. process.env._X_AMZN_TRACE_ID = `Root=1-${Math.floor(Date.now() / 1000).toString(16)}-${'0'.repeat(24)};Sampled=1`; ddbMock.reset(); vi.restoreAllMocks(); }); it('creates a rental and writes metrics in EMF',Source:https://dev.to/sascha-ms/lambda-powertools-typescript-logs-metrics-traces-39dj

Related posts