Lambda Powertools TypeScript: logs, metrics, traces
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. 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 ({ statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const createRental = async ( event: APIGatewayProxyEventV2, ): Promise => { 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', async () => { ddbMock.on(PutCommand).resolves({}); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'info').mockImplementation(() => {}); const result = (await handler( httpEvent({ stationId: 'hbf-nord', bikeType: 'ebike', minutes: 45 }), context, )) as { statusCode: number; body: string }; expect(result.statusCode).toBe(201); expect(JSON.parse(result.body).rentalId).toMatch(/^[0-9a-f-]{36}$/); expect(ddbMock.commandCalls(PutCommand)).toHaveLength(1); const emf = emfOutput(log); const business = emf.find((entry: Record) => 'RentalCreated' in entry); expect(business.RentalCreated).toBe(1); expect(business.RentalMinutes).toBe(45); expect(business.bikeType).toBe('ebike'); expect(business._aws.CloudWatchMetrics[0].Namespace).toBe('BikeRental'); }); it('rejects invalid requests with 400', async () => { vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = (await handler(httpEvent({ stationId: 'hbf-nord', bikeType: 'tandem' }), context)) as { statusCode: number; }; expect(result.statusCode).toBe(400); expect(ddbMock.commandCalls(PutCommand)).toHaveLength(0); }); it('returns 500 and flushes buffered DEBUG logs when DynamoDB fails', async () => { ddbMock.on(PutCommand).rejects(new Error('ProvisionedThroughputExceeded')); vi.spyOn(console, 'log').mockImplementation(() => {}); const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); const result = (await handler( httpEvent({ stationId: 'hbf-nord', bikeType: 'cargo', minutes: 90 }), context, )) as { statusCode: number }; expect(result.statusCode).toBe(500); const flushed = debug.mock.calls.map((args) => String(args[0])); expect(flushed.some((line) => line.includes('Raw request data'))).toBe(true); }); }); npx tsc --noEmit npx vitest run Expected result: the type check passes without output, and Vitest reports 3 passed. The third test shows the log buffer in action. The DEBUG line “Raw request data” only appears because DynamoDB throws an error and logger.error flushes the buffer. Important: the buffer is keyed by the X-Ray trace ID that Lambda sets per invocation in _X_AMZN_TRACE_ID. That is why the test sets this variable itself. Step 6: Deploy and try it out serverless deploy --stage dev curl -X POST "https://.execute-api.eu-central-1.amazonaws.com/rentals" -H "content-type: application/json" -d '{"stationId":"hbf-nord","bikeType":"ebike","minutes":45}' serverless logs -f createRental --stage dev serverless deploy prints the API URL at the end. When you are done, serverless remove --stage dev cleans everything up again, including the table. What arrives in CloudWatch and X-Ray A log line from the logger looks like this (generated locally, values shortened). You can see the Lambda context, the correlation ID, the stage from persistentKeys and the keys the handler added with appendKeys: {"level":"INFO","message":"Rental created","timestamp":"2026-11-04T09:38:33.385Z","service":"bike-rental","cold_start":false,"function_name":"bike-rental-dev-createRental","function_memory_size":"512","function_request_id":"8f2c1d3e-4b5a-4c6d-9e7f-0a1b2c3d4e5f","sampling_rate":0,"xray_trace_id":"1-6729a1b2-3f4e5d6c7b8a9f0e1d2c3b4a","stage":"dev","correlation_id":"Ab1cDeFgHiJKLM=","rentalId":"fbec1d38-681c-47db-b0be-6f48792fcad1","bikeType":"ebike","minutes":45} Right after that, Metrics writes the EMF block. CloudWatch turns it into two metrics in the BikeRental namespace, each with the dimensions service and bikeType. The rental ID only appears as metadata in the log: {"_aws":{"Timestamp":1793785113387,"CloudWatchMetrics":[{"Namespace":"BikeRental","Dimensions":[["service","bikeType"]],"Metrics":[{"Name":"RentalCreated","Unit":"Count"},{"Name":"RentalMinutes","Unit":"Count"}]}]},"rentalId":"fbec1d38-681c-47db-b0be-6f48792fcad1","service":"bike-rental","bikeType":"ebike","RentalCreated":1,"RentalMinutes":45} Search the logs with Logs Insights Since every line is JSON, you can filter by field in CloudWatch Logs Insights right away. This query shows all warnings and errors of the service, newest first: fields @timestamp, level, message, rentalId, correlation_id | filter service = "bike-rental" and level in ["WARN", "ERROR"] | sort @timestamp desc | limit 50 If a user reports an error, the API also returns the X-Ray trace ID. With it you jump straight to the matching trace in the X-Ray console and see whether DynamoDB was slow or your own code. In which order do the middlewares belong? Middy runs the before phases in the order you register them with use, and the after phases in reverse. That is why I register the tracer first: its subsegment then wraps everything else. Metrics comes second and writes the EMF data after the handler. The logger sits closest to the handler, so the context is set as soon as your business logic starts. 🎬 Order of the Powertools middlewares in Middy – this animated diagram only runs on my site: watch it on sascha-ms.de Dimension, metadata or annotation? This question decides the cost and the value of your observability. Rule of thumb: anything with a few fixed values may be a dimension. Anything that is new on every invocation belongs in the logs. Where? What for? Bike rental example Metric dimension (addDimension) Group metrics and split them in dashboards. Every combination is a separate metric. bikeType with three values Metadata (addMetadata) Searchable in the EMF log, but not in metric graphs rentalId Log key (appendKeys) Connect all log lines of one invocation rentalId, bikeType X-Ray annotation (putAnnotation) Filter traces, for example all cargo bike rentals stationId, bikeType X-Ray metadata (putMetadata) Any object on the trace, not indexed Response of a third-party system What do logs, metrics and traces cost? Powertools itself is free, the data is not. According to the CloudWatch pricing page, custom metrics in us-east-1 cost USD 0.30 per metric and month for the first 10,000, and every unique combination of dimensions counts as a separate metric. The example creates two metrics times three bike types, so six, plus the cold start metric. If you used the rentalId as a dimension, every rental would create a new metric. That is the most expensive EMF mistake I know. Logs are billed per ingested gigabyte, and the EMF blocks are logs as well. So it pays off to use INFO as the log level in production, the log buffer for DEBUG and a retention period like the 14 days in the serverless.yml. X-Ray bills recorded traces at its own rate with a free tier. Check the current prices for your region before you enable tracing for functions with very high traffic. How much do Powertools slow down the cold start? Short answer: a little. I bundled the handler once with and once without the three packages using esbuild (minified, AWS SDK external). The bundle grows from less than one kilobyte to about 185 KB, around 55 KB compressed. For just loading the module I measured roughly 15 to 20 milliseconds more locally with Node.js 22, as the median of 21 runs. In Lambda the value depends on memory and architecture, but the order of magnitude helps you judge it. For an API I think that is a good trade, because without logs and traces you spend much longer hunting bugs. How to measure and reduce cold starts in general is covered in my guide Reduce Lambda cold starts. Common mistakes and how to avoid them Metrics end up in the wrong namespace Without POWERTOOLS_METRICS_NAMESPACE, Metrics uses a default namespace, and your metrics do not show up where you look for them. Always set namespace and service name via environment variables. Warning “No application metrics to publish” If an invocation finishes without addMetric, the middleware logs a warning. That is harmless. If you want to enforce metrics, set throwOnEmptyMetrics: true, and it throws a RangeError instead. Adding metrics in the global scope If you call addMetric or addDimension outside the handler, it only happens during the cold start. After that the values are missing. Shared dimensions therefore belong in defaultDimensions. Sensitive data in logs and traces By default the tracer stores your handler’s response as metadata. If it contains personal data, turn that off with POWERTOOLS_TRACER_CAPTURE_RESPONSE=false. You should also only enable logEvent in production on purpose, because then the complete event ends up in the log. My take I consider Powertools the most useful standard a team can agree on for TypeScript Lambdas. Not because the packages do anything magical. Their value is that every function logs, measures and traces the same way. Whoever hunts a bug at night does not first have to figure out how a colleague built their logging a year ago. Where I disagree is the reflex to instrument everything right away. Start with the logger, then add one or two business metrics that somebody actually looks at. Tracing pays off mostly when a function calls several services. And if your company already uses OpenTelemetry, check first whether the X-Ray based tracer fits your landscape. By the way, for protection against duplicate events there is another Powertools package, which I show in the article on idempotency in AWS Lambda. FAQ Do I need Middy for Lambda Powertools? No. All three packages also work with class decorators or manually, for example with logger.addContext(context) and metrics.publishStoredMetrics(). Middy, however, takes care of the cleanup after every invocation. Does Powertools for AWS Lambda cost anything? The library is open source and free. You pay for the data it produces: CloudWatch Logs, custom metrics and X-Ray traces. What is the difference between EMF and PutMetricData? PutMetricData is an API call that takes time and can be throttled. With the Embedded Metric Format the function only writes JSON to the logs, and CloudWatch creates the metrics from it asynchronously. Does Powertools work with ESM? Yes. When you bundle as an ES module, though, the X-Ray SDK inside the tracer needs a require shim as a banner, because it uses CommonJS. The Tracer documentation shows the line you need. Your next step Pick an existing Lambda that has given you headaches before and retrofit only the logger with a correlation ID first. That takes half an hour and pays off the very next time something breaks. After that, think about the one business metric you really want to see. Which one would that be for you, and which metric does nobody on your team ever look at? If parts of your application run outside AWS, CloudWatch API key: OpenTelemetry traces without SigV4 shows how their traces can reach CloudWatch too. Sources Powertools for AWS Lambda (TypeScript): Logger Powertools for AWS Lambda (TypeScript): Metrics Powertools for AWS Lambda (TypeScript): Tracer Amazon CloudWatch: Embedded Metric Format Amazon CloudWatch: Pricing Serverless Framework: Building (esbuild) Serverless Framework: Functions (tracing) This post was created with AI assistance. 👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser. Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to