I sketched Tier 2 with primary sources in API logging in production. Here I build it: three changes take the same two-Lambda API from Tier 1 (hand-rolled wrapper, JSON logs) to Tier 2.
- Powertools logger replaces
lib/log.ts. Auto-injectsservice,function_name,function_request_id,cold_start, andxray_trace_idon every line. - EMF for custom metrics. One JSON log line that CloudWatch parses into both a log entry and a metric.
- W3C
traceparentpropagation. Inbound header becomes the parent; the same value gets threaded into the EventBridgedetailso the downstream Lambda continues the trace.
Repo: github.com/danieljohnmorris/aws-api-logging-tiers. The tier-2/ folder runs on LocalStack with one command. Compare against tier-1/ to see what changed:
git diff tier-1..tier-2 -- handlers/ lib/
The API
Same two Lambdas as Tier 1. apiHandler accepts POST /orders, writes to DynamoDB, emits OrderCreated to EventBridge, returns 201. processOrder is the EventBridge subscriber that updates the order’s status. Two Lambdas is the smallest setup that still has a cross-service trace to thread.
Powertools logger
Tier 1’s lib/log.ts was a module-level setContext wrapper around console.log. Tier 2’s equivalent is one import:
// tier-2/lib/powertools.ts
import { Logger } from '@aws-lambda-powertools/logger';
import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics';
export const logger = new Logger();
export const metrics = new Metrics();
export { MetricUnit };
In the handler, addContext brings in the Lambda context (request id, function name, cold start), and appendKeys adds the business fields:
// tier-2/handlers/api.ts
import { logger } from '../lib/powertools.js';
export const handler: APIGatewayProxyHandlerV2 = async (event, context) => {
logger.addContext(context);
logger.appendKeys({ correlationId, traceparent, orderId, customerId, sku });
// ...
logger.info('order.created');
};
One logger.info('order.created') emits a log line with everything attached. Here is what lands in CloudWatch from a real invocation against the repo:
{
"level": "INFO",
"message": "order.created",
"timestamp": "2026-05-14T16:46:54.901Z",
"service": "tier2-api",
"cold_start": true,
"function_arn": "arn:aws:lambda:us-east-1:000000000000:function:tier2-api",
"function_memory_size": "256",
"function_name": "tier2-api",
"function_request_id": "568c6bf4-e511-480c-b82a-2ae3da06534c",
"sampling_rate": 0,
"xray_trace_id": "1-6a05fc7d-3c3cdc1aebf6f307bf6f6621",
"correlationId": "caea7e96-2079-48cd-b912-32456604ea3d",
"traceparent": "00-0cf179108988c6a5215a3f645dcabbd1-8e2172063a32edde-01",
"orderId": "129fc0f8-883b-4e11-88c9-2b7583beab81",
"customerId": "c1",
"sku": "AVON-TORTOISE"
}
The Tier 1 wrapper produced the same business fields. What’s new is everything above correlationId: Powertools fills it in once you call addContext, and the per-Lambda boilerplate is gone. cold_start: true in particular is one of the more useful fields to filter on during latency investigations and you do not have to write the cold-start-detection code yourself.
EMF for custom metrics
A line in the same log group, written in the same invocation:
{
"_aws": {
"Timestamp": 1778777214899,
"CloudWatchMetrics": [{
"Namespace": "AwsApiLoggingTiers",
"Dimensions": [["service", "route"]],
"Metrics": [
{ "Name": "OrdersCreated", "Unit": "Count" },
{ "Name": "OrderAmount", "Unit": "None" }
]
}]
},
"service": "tier2-api",
"route": "POST /orders",
"OrdersCreated": 1,
"OrderAmount": 145
}
CloudWatch parses the _aws envelope on the log stream and extracts a metric in the AwsApiLoggingTiers namespace, with OrdersCreated and OrderAmount as the two metrics and service / route as the dimensions. The values come from the same line. The handler code that produces it:
metrics.addDimension('route', 'POST /orders');
metrics.addMetric('OrdersCreated', MetricUnit.Count, 1);
metrics.addMetric('OrderAmount', MetricUnit.NoUnit, amount);
metrics.publishStoredMetrics();
EMF is cheaper than PutMetricData because the metric ships out on the log line you were already writing; no second API call. The trap is dimension cardinality. route is fine; customerId is not. Each unique combination of dimension values becomes a separate custom metric, billed per-metric. Keep high-cardinality identifiers in the log body and use the dimensions only for things you would group an alarm by.
traceparent propagation
OTel auto-instrumentation handles inbound HTTP and Lambda-to-Lambda HTTP calls. EventBridge it does not handle. So the W3C traceparent header has to be threaded through the detail payload by hand:
// On the way out
await events.send(new PutEventsCommand({
Entries: [{
EventBusName: process.env.EVENT_BUS_NAME,
Source: process.env.EVENT_SOURCE,
DetailType: 'OrderCreated',
Detail: JSON.stringify({
orderId, customerId, sku, amount,
correlationId, traceparent,
}),
}],
}));
// In the EventBridge subscriber
const { correlationId, traceparent } = Detail.parse(event.detail);
logger.appendKeys({ correlationId, traceparent });
correlationId and traceparent live alongside each other in Tier 2. correlationId is the human-readable companion you use in Logs Insights. traceparent is the machine ID that Tier 3 will pick up when an OTel SDK turns these strings into spans.
If the inbound HTTP request has no traceparent header, the API handler mints one so the downstream invocation still has a valid trace context. Minting code in tier-2/lib/powertools.ts.
How to run it
git clone https://github.com/danieljohnmorris/aws-api-logging-tiers
cd aws-api-logging-tiers
docker compose up -d # LocalStack
pnpm install
pnpm --filter @aws-api-logging-tiers/tier-2 run deploy
pnpm --filter @aws-api-logging-tiers/tier-2 run trigger
pnpm --filter @aws-api-logging-tiers/tier-2 run tail:api
tail:api pipes the structured log lines straight to your terminal. The trigger script posts one order with a generated traceparent, so the API and the EventBridge subscriber both log the same trace id.
What still needs real AWS
Two pieces of Tier 2 that do not show up on LocalStack:
- CloudWatch Application Signals trace map. LocalStack does not implement Application Signals. Local testing covers the log shape and the EMF metric ingestion, not the AWS trace UI.
- The ADOT Lambda layer that auto-instruments HTTP and AWS SDK calls into X-Ray spans. The OTel SDK runs fine inside Lambda containers, but the X-Ray side of the export only renders in a real AWS account.
Tier 2 in this repo proves the log shape, the EMF parse, and the traceparent threading. The production trace UI for cross-service investigations only renders against real AWS at this tier.
Diff against Tier 1
Three import changes and a handful of method calls. The CDK stack adds three Powertools env vars (POWERTOOLS_SERVICE_NAME, POWERTOOLS_METRICS_NAMESPACE, POWERTOOLS_LOG_LEVEL) per function. The hand-rolled wrapper in tier-1/lib/log.ts is gone. The events schema gains an optional traceparent field. docs/tier-diff.md in the repo walks through the file-by-file delta.
Tier 3 is next: same API, same EventBridge hop, but the OTel SDK takes over from manual traceparent strings and the trace lands in Grafana Tempo instead of CloudWatch.