-
Notifications
You must be signed in to change notification settings - Fork 174
Description
Use case
I'm attempting to emit a request log (also known as canonical log) per request. Using global middleware I've got something working but I have a few bits of feedback.
Per request, I'd like to be able to emit key metadata like source ip, user-agent, requestId, http method, route, path, status code, error message, error type, error stack and useful information I pick out of the request.
I've hacked something together that kind of works, but it's not quite 100%. The code below has 3 routes, /ping
returns a 200. /error
throws a 400 BadRequestError and /fault
throws an uncaught Error resulting in a 500 Internal Server Error.
import { BadRequestError, Router } from '@aws-lambda-powertools/event-handler/experimental-rest';
import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics';
import { Logger } from '@aws-lambda-powertools/logger';
import {
correlationPaths,
search,
} from '@aws-lambda-powertools/logger/correlationId';
import type { Context } from 'aws-lambda';
let startTime: number;
const logger = new Logger({
correlationIdSearchFn: search,
});
const metrics = new Metrics({
namespace: 'testing',
serviceName: 'powertools-event-handler',
});
const app = new Router({ logger });
app.use(async ({ reqCtx, next }) => {
try {
metrics.addMetadata('route', reqCtx.route); //note route doesn't exist on context
metrics.addMetadata('path', reqCtx.event.path);
metrics.addMetadata('method', reqCtx.req.method);
metrics.addMetadata('ip', reqCtx.event.headers['X-Forwarded-For'].split(',')[0]);
metrics.addMetadata('userAgent', reqCtx.event.headers['User-Agent']);
metrics.addMetadata('apiGRequestId', reqCtx.event.requestId);
metrics.addMetadata('apiGExtendedRequestId', reqCtx.event.extendedRequestId);
metrics.addMetadata('cfRequestId', reqCtx.event.headers['X-Amz-Cf-Id']);
metrics.addMetadata('requestId', reqCtx.context.awsRequestId);
metrics.addMetadata('functionVersion', reqCtx.context.functionVersion);
metrics.addMetadata('traceId', reqCtx.event.headers['X-Amzn-Trace-Id']?.replace('Root=', ''));
}catch(error){
logger.error('Error adding metadata', { error });
}
try {
await next();
} catch(error) {
if(error){
metrics.addMetadata('errorDetails', error.message);
metrics.addMetadata('errorType', error.name);
metrics.addMetadata('errorStack', error.cause ? error.cause.stack : error.stack);
if (reqCtx.res.status >= 500) {
throw error;
}
}
}
finally {
metrics.addMetadata('responseCode', reqCtx.res.status + "");
metrics.addMetric('fault', MetricUnit.Count, reqCtx.res.status >= 500 ? 1 : 0);
metrics.addMetric('error', MetricUnit.Count, (reqCtx.res.status >= 400 && reqCtx.res.status < 500) ? 1 : 0);
metrics.addMetric('latency', MetricUnit.Milliseconds, Date.now() - startTime);
metrics.publishStoredMetrics();
}
});
app.get('/ping', async () => {
return { message: 'pong' };
});
app.get('/error', async () => {
throw new BadRequestError("This is an error");
});
app.get('/fault', async () => {
throw new Error('This is a fault');
});
export const handler = async (event: unknown, context: Context) => {
startTime = Date.now();
logger.addContext(context);
logger.setCorrelationId(event, correlationPaths.API_GATEWAY_REST);
return app.resolve(event, context);
};
Below are some sample log outputs for the exposed routes:
/ping
{
"_aws": {
"Timestamp": 1759372476245,
"CloudWatchMetrics": [
{
"Namespace": "testing",
"Dimensions": [
[
"service"
]
],
"Metrics": [
{
"Name": "fault",
"Unit": "Count"
},
{
"Name": "error",
"Unit": "Count"
},
{
"Name": "latency",
"Unit": "Milliseconds"
}
]
}
]
},
"service": "powertools-event-handler",
"fault": 0,
"error": 0,
"latency": 241,
"path": "/ping",
"method": "GET",
"ip": "71.212.29.6",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
"apiGRequestId": "8300e8f6-45ca-467c-92ee-fac812d54f1d",
"apiGExtendedRequestId": "RzCtYHR8iYcEu2A=",
"cfRequestId": "viQrbfwMYaFhUEEkozh1ykmtBbixdN0JzRBMx-oNMpAH5sLzcszLaA==",
"requestId": "cf60e110-fe37-4a6b-8ab4-ac251c3f7271",
"functionVersion": "39",
"traceId": "1-68dde4bb-44b8df72029d7fff1208aba3",
"responseCode": "200"
}
/error
{
"_aws": {
"Timestamp": 1759372479479,
"CloudWatchMetrics": [
{
"Namespace": "testing",
"Dimensions": [
[
"service"
]
],
"Metrics": [
{
"Name": "fault",
"Unit": "Count"
},
{
"Name": "error",
"Unit": "Count"
},
{
"Name": "latency",
"Unit": "Milliseconds"
}
]
}
]
},
"service": "powertools-event-handler",
"fault": 1,
"error": 0,
"latency": 2,
"path": "/error",
"method": "GET",
"ip": "71.212.29.6",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
"apiGRequestId": "5385f574-dc12-476a-a3b2-616f39e83b41",
"apiGExtendedRequestId": "RzCt-HVCCYcEv5Q=",
"cfRequestId": "XLdWKQSJAsw1-6E4_qKfeghpxlixMVL6MNcUz_h2TnNnBlTE8w2dYg==",
"requestId": "894ee350-9444-4b49-bf2c-9f095ee8b875",
"functionVersion": "39",
"traceId": "1-68dde4bf-4bc7c50f7cd809de016d0508",
"errorDetails": "This is an error",
"errorType": "BadRequestError",
"errorStack": "BadRequestError: This is an error\n at /var/task/index.js:6903:9\n at handlerMiddleware (/var/task/index.js:1683:39)\n at dispatch (/var/task/index.js:1019:38)\n at nextFn (/var/task/index.js:1014:23)\n at /var/task/index.js:6881:11\n at dispatch (/var/task/index.js:1019:38)\n at /var/task/index.js:1030:11\n at Router.resolve (/var/task/index.js:1694:38)\n at Runtime.handler (/var/task/index.js:6912:14)\n at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1306:29)",
"responseCode": "500"
}
Note this has the responseCode set to 500 even though the request returns a 400 so the fault and error counters are wrong.
/fault
{
"_aws": {
"Timestamp": 1759372482624,
"CloudWatchMetrics": [
{
"Namespace": "testing",
"Dimensions": [
[
"service"
]
],
"Metrics": [
{
"Name": "fault",
"Unit": "Count"
},
{
"Name": "error",
"Unit": "Count"
},
{
"Name": "latency",
"Unit": "Milliseconds"
}
]
}
]
},
"service": "powertools-event-handler",
"fault": 1,
"error": 0,
"latency": 5,
"path": "/fault",
"method": "GET",
"ip": "71.212.29.6",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
"apiGRequestId": "6b762937-0ca9-4406-b2fe-2f93c856cd2d",
"apiGExtendedRequestId": "RzCueEv6iYcEFlg=",
"cfRequestId": "p-ODGZvBOTfvfYIfjXcOvwbu6X7FWgpG0viM8rjjEo4wcyoCs9kvZA==",
"requestId": "1af4b006-09b7-4770-823a-15ddab8af6e3",
"functionVersion": "39",
"traceId": "1-68dde4c2-7a71c6d017b69c45550b64b7",
"errorDetails": "This is a fault",
"errorType": "Error",
"errorStack": "Error: This is a fault\n at /var/task/index.js:6906:9\n at handlerMiddleware (/var/task/index.js:1683:39)\n at dispatch (/var/task/index.js:1019:38)\n at nextFn (/var/task/index.js:1014:23)\n at /var/task/index.js:6881:11\n at dispatch (/var/task/index.js:1019:38)\n at /var/task/index.js:1030:11\n at Router.resolve (/var/task/index.js:1694:38)\n at Runtime.handler (/var/task/index.js:6912:14)\n at Runtime.handleOnceNonStreaming (file:///var/runtime/index.mjs:1306:29)",
"responseCode": "500"
}
/notfound
{
"_aws": {
"Timestamp": 1759372493850,
"CloudWatchMetrics": [
{
"Namespace": "testing",
"Dimensions": [
[
"service"
]
],
"Metrics": [
{
"Name": "fault",
"Unit": "Count"
},
{
"Name": "error",
"Unit": "Count"
},
{
"Name": "latency",
"Unit": "Milliseconds"
}
]
}
]
},
"service": "powertools-event-handler",
"fault": 0,
"error": 1,
"latency": 9,
"path": "/notfound",
"method": "GET",
"ip": "71.212.29.6",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:143.0) Gecko/20100101 Firefox/143.0",
"apiGRequestId": "a3ccaaef-1673-453e-9886-f414ead740e7",
"apiGExtendedRequestId": "RzCwOFQ-CYcEpvQ=",
"cfRequestId": "6Cc8LZyzOOhGVAbndbYXYGAFN8d6LawxHNSw-9IIQaWRlOrhUtDgRQ==",
"requestId": "287cbfd3-a533-4c5f-b78d-eec34b26d56a",
"functionVersion": "39",
"traceId": "1-68dde4cd-656de8ab1e1bdfcf550e9f06",
"responseCode": "404"
}
Solution/User Experience
After doing this exercise I have 4 requests.
- Normalize common metadata and expose it in a consistent way regardless of source, i.e. regardless of whether it's APIG or a Function URL, expose things like ip, path and user-agent with context.ip or some other mechanism so the user doesn't need to extract them from headers. lambda-api has a good example of this
- Expose the matched route to middleware on the context, I didn't see it surfaced.
- In global middleware, when throwing pre-defined errors, the status on the response should not be 500, it should be the status code for the actual error. When the response is returned to the browser it is the correct value, just in the global middleware it is 500 (see /error log above). Note, this is not the case on /notfound so I'm not sure whether this is user error. I did read the errorHandling docs but I don't want a separate handler per error type. I just want to throw the predefined error from the route and have the default propogate to the end user.
- Expose a way to set values on the context from middleware. For example if I had a JWT middleware, I might want to add username to the context. Both hono and lambda-api allow you to do something like
context.set('username', username)
so you can access it later in the request.
Alternative solutions
Acknowledgment
- This feature request meets Powertools for AWS Lambda (TypeScript) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Python, Java, and .NET
Future readers
Please react with 👍 and your use case to help us understand customer demand.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status