Skip to content

Commit 5af28e8

Browse files
authored
feat(event-handler): add metrics middleware for HTTP routes (#5086)
1 parent cb7fa14 commit 5af28e8

12 files changed

Lines changed: 680 additions & 37 deletions

File tree

package-lock.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/event-handler/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,16 @@
109109
"types": "./lib/esm/http/middleware/tracer.d.ts",
110110
"default": "./lib/esm/http/middleware/tracer.js"
111111
}
112+
},
113+
"./http/middleware/metrics": {
114+
"require": {
115+
"types": "./lib/cjs/http/middleware/metrics.d.ts",
116+
"default": "./lib/cjs/http/middleware/metrics.js"
117+
},
118+
"import": {
119+
"types": "./lib/esm/http/middleware/metrics.d.ts",
120+
"default": "./lib/esm/http/middleware/metrics.js"
121+
}
112122
}
113123
},
114124
"typesVersions": {
@@ -140,6 +150,10 @@
140150
"http/middleware/tracer": [
141151
"./lib/cjs/http/middleware/tracer.d.ts",
142152
"./lib/esm/http/middleware/tracer.d.ts"
153+
],
154+
"http/middleware/metrics": [
155+
"./lib/cjs/http/middleware/metrics.d.ts",
156+
"./lib/esm/http/middleware/metrics.d.ts"
143157
]
144158
}
145159
},
@@ -158,11 +172,15 @@
158172
},
159173
"peerDependencies": {
160174
"@aws-lambda-powertools/tracer": ">=2.32.0",
175+
"@aws-lambda-powertools/metrics": ">=2.32.0",
161176
"@standard-schema/spec": "^1.0.0"
162177
},
163178
"peerDependenciesMeta": {
164179
"@aws-lambda-powertools/tracer": {
165180
"optional": true
181+
},
182+
"@aws-lambda-powertools/metrics": {
183+
"optional": true
166184
}
167185
},
168186
"keywords": [

packages/event-handler/src/http/RouteHandlerRegistry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ class RouteHandlerRegistry {
188188
if (staticRoute != null) {
189189
return {
190190
handler: staticRoute.handler as RouteHandler,
191+
route: `${method} ${getPathString(staticRoute.path)}`,
191192
rawParams: {},
192193
params: {},
193194
middleware: staticRoute.middleware,
@@ -250,6 +251,7 @@ class RouteHandlerRegistry {
250251

251252
return {
252253
handler: route.handler as RouteHandler,
254+
route: `${method} ${getPathString(route.path)}`,
253255
params: processedParams,
254256
rawParams: params,
255257
middleware: route.middleware,

packages/event-handler/src/http/Router.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ import {
7373
composeMiddleware,
7474
getBase64EncodingFromHeaders,
7575
getBase64EncodingFromResult,
76-
getResponseType,
7776
getStatusCode,
7877
HttpResponseStream,
7978
isALBEvent,
@@ -247,6 +246,38 @@ class Router<TEnv extends Env = Env> {
247246
};
248247
}
249248

249+
#buildRequestContext(
250+
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent,
251+
context: Context,
252+
options: {
253+
req: Request;
254+
res: Response;
255+
isHttpStreaming?: boolean;
256+
} & Pick<RequestContext<TEnv>, 'set' | 'get' | 'has' | 'delete' | 'shared'>
257+
): RequestContext<TEnv> {
258+
const common = {
259+
context,
260+
req: options.req,
261+
res: options.res,
262+
route: null as string | null,
263+
params: {} as Record<string, string>,
264+
isHttpStreaming: options.isHttpStreaming,
265+
set: options.set,
266+
get: options.get,
267+
has: options.has,
268+
delete: options.delete,
269+
shared: options.shared,
270+
};
271+
272+
if (isAPIGatewayProxyEventV2(event)) {
273+
return { ...common, event, responseType: 'ApiGatewayV2' };
274+
}
275+
if (isALBEvent(event)) {
276+
return { ...common, event, responseType: 'ALB' };
277+
}
278+
return { ...common, event, responseType: 'ApiGatewayV1' };
279+
}
280+
250281
/**
251282
* Core resolution logic shared by both resolve and resolveStream methods.
252283
* Validates the event, routes to handlers, executes middleware, and handles errors.
@@ -272,8 +303,8 @@ class Router<TEnv extends Env = Env> {
272303
throw new InvalidEventError();
273304
}
274305

275-
const responseType = getResponseType(event);
276306
const requestStore = new Store<RequestStoreOf<TEnv>>();
307+
const storeAccessors = this.#createStoreAccessors(requestStore);
277308

278309
let req: Request;
279310
try {
@@ -283,48 +314,42 @@ class Router<TEnv extends Env = Env> {
283314
this.logger.error(err);
284315
// We can't throw a MethodNotAllowedError outside the try block as it
285316
// will be converted to an internal server error by the API Gateway runtime
286-
return {
287-
event,
288-
context,
317+
return this.#buildRequestContext(event, context, {
289318
req: new Request('https://invalid'),
290319
res: new Response(null, {
291320
status: HttpStatusCodes.METHOD_NOT_ALLOWED,
292321
...(options?.isHttpStreaming && {
293322
headers: { 'transfer-encoding': 'chunked' },
294323
}),
295324
}),
296-
params: {},
297-
responseType,
298-
...this.#createStoreAccessors(requestStore),
299-
};
325+
...storeAccessors,
326+
});
300327
}
301328
throw err;
302329
}
303330

304-
const requestContext: RequestContext<TEnv> = {
305-
event,
306-
context,
331+
const requestContext = this.#buildRequestContext(event, context, {
307332
req,
308-
// this response should be overwritten by the handler, if it isn't
309-
// it means something went wrong with the middleware chain
310333
res: new Response('', {
311334
status: HttpStatusCodes.INTERNAL_SERVER_ERROR,
312335
...(options?.isHttpStreaming && {
313336
headers: { 'transfer-encoding': 'chunked' },
314337
}),
315338
}),
316-
params: {},
317-
responseType,
318339
isHttpStreaming: options?.isHttpStreaming,
319-
...this.#createStoreAccessors(requestStore),
320-
};
340+
...storeAccessors,
341+
});
321342

322343
try {
323344
const method = req.method as HttpMethod;
324345
const path = new URL(req.url).pathname as Path;
325346

326347
const route = this.routeRegistry.resolve(method, path);
327348

349+
if (route !== null) {
350+
requestContext.route = route.route;
351+
}
352+
328353
const handlerMiddleware: Middleware = async ({ reqCtx, next }) => {
329354
let handlerRes: HandlerResponse;
330355
if (route === null) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { compress } from './compress.js';
22
export { cors } from './cors.js';
3+
export { metrics } from './metrics.js';
34
export { tracer } from './tracer.js';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { Metrics } from '@aws-lambda-powertools/metrics';
2+
import { MetricUnit } from '@aws-lambda-powertools/metrics';
3+
import type { Middleware, RequestContext } from '../../types/http.js';
4+
import { HttpError } from '../errors.js';
5+
6+
const getHeaderMetadata = (req: Request): Record<string, string> => {
7+
const metadata: Record<string, string> = {};
8+
9+
const userAgent = req.headers.get('User-Agent');
10+
if (userAgent) {
11+
metadata.userAgent = userAgent;
12+
}
13+
14+
return metadata;
15+
};
16+
17+
const getIpAddress = (reqCtx: RequestContext): string | undefined => {
18+
if (reqCtx.responseType === 'ApiGatewayV1') {
19+
return reqCtx.event.requestContext.identity.sourceIp;
20+
}
21+
if (reqCtx.responseType === 'ApiGatewayV2') {
22+
return reqCtx.event.requestContext.http.sourceIp;
23+
}
24+
const xForwardedFor = reqCtx.req.headers.get('X-Forwarded-For');
25+
if (xForwardedFor) {
26+
return xForwardedFor.split(',')[0].trim();
27+
}
28+
return undefined;
29+
};
30+
31+
const getEventMetadata = (reqCtx: RequestContext): Record<string, string> => {
32+
const metadata: Record<string, string> = {};
33+
34+
const ipAddress = getIpAddress(reqCtx);
35+
if (ipAddress) {
36+
metadata.ipAddress = ipAddress;
37+
}
38+
39+
if (reqCtx.responseType !== 'ALB') {
40+
metadata.apiGwRequestId = reqCtx.event.requestContext.requestId;
41+
metadata.apiGwApiId = reqCtx.event.requestContext.apiId;
42+
}
43+
if (reqCtx.responseType === 'ApiGatewayV1') {
44+
const extendedRequestId = reqCtx.event.requestContext.extendedRequestId;
45+
if (extendedRequestId) {
46+
metadata.apiGwExtendedRequestId = extendedRequestId;
47+
}
48+
}
49+
50+
return metadata;
51+
};
52+
53+
/**
54+
* A middleware for emitting per-request metrics using Powertools Metrics.
55+
*
56+
* This middleware automatically:
57+
* - Adds the matched route as a metric dimension (uses `NOT_FOUND` when no route matches to prevent dimension explosion)
58+
* - Emits `latency` (Milliseconds), `fault` (Count), and `error` (Count) metrics
59+
* - Adds `httpMethod` and `path` metadata for all requests
60+
* - Adds `ipAddress` and `userAgent` metadata from request headers when available
61+
* - Adds `apiGwRequestId` and `apiGwApiId` metadata for API Gateway V1 and V2 events
62+
* - Adds `apiGwExtendedRequestId` metadata for API Gateway V1 events when available
63+
* - Publishes stored metrics after each request
64+
*
65+
* @example
66+
* ```typescript
67+
* import { Router } from '@aws-lambda-powertools/event-handler/http';
68+
* import { metrics as metricsMiddleware } from '@aws-lambda-powertools/event-handler/http/middleware/metrics';
69+
* import { Metrics } from '@aws-lambda-powertools/metrics';
70+
*
71+
* const metrics = new Metrics({ namespace: 'my-app', serviceName: 'my-service' });
72+
* const app = new Router();
73+
*
74+
* app.use(metricsMiddleware(metrics));
75+
* ```
76+
*
77+
* @param metrics - The Metrics instance to use for emitting metrics
78+
*/
79+
const metrics = (metrics: Metrics): Middleware => {
80+
return async ({ reqCtx, next }) => {
81+
const start = performance.now();
82+
let status = 500;
83+
84+
try {
85+
await next();
86+
status = reqCtx.res.status;
87+
} catch (error) {
88+
status = error instanceof HttpError ? error.statusCode : 500;
89+
throw error;
90+
} finally {
91+
const url = new URL(reqCtx.req.url);
92+
const metadata = {
93+
httpMethod: reqCtx.req.method,
94+
path: url.pathname,
95+
statusCode: String(status),
96+
...getHeaderMetadata(reqCtx.req),
97+
...getEventMetadata(reqCtx),
98+
};
99+
for (const [key, value] of Object.entries(metadata)) {
100+
metrics.addMetadata(key, value);
101+
}
102+
metrics
103+
.addDimension('route', reqCtx.route ?? 'NOT_FOUND')
104+
.addMetric(
105+
'latency',
106+
MetricUnit.Milliseconds,
107+
performance.now() - start
108+
)
109+
.addMetric('fault', MetricUnit.Count, status >= 500 ? 1 : 0)
110+
.addMetric(
111+
'error',
112+
MetricUnit.Count,
113+
status >= 400 && status < 500 ? 1 : 0
114+
)
115+
.publishStoredMetrics();
116+
}
117+
};
118+
};
119+
120+
export { metrics };

packages/event-handler/src/http/utils.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import type {
2020
Middleware,
2121
Path,
2222
ResponseStream,
23-
ResponseType,
2423
ValidationResult,
2524
} from '../types/http.js';
2625
import type { ResolveOptions } from '../types/index.js';
@@ -154,14 +153,6 @@ export const isALBEvent = (event: unknown): event is ALBEvent => {
154153
return isRecord(event.requestContext.elb);
155154
};
156155

157-
export const getResponseType = (
158-
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent
159-
): ResponseType => {
160-
if (isAPIGatewayProxyEventV2(event)) return 'ApiGatewayV2';
161-
if (isALBEvent(event)) return 'ALB';
162-
return 'ApiGatewayV1';
163-
};
164-
165156
export const isHttpMethod = (method: string): method is HttpMethod => {
166157
return Object.keys(HttpVerbs).includes(method);
167158
};

packages/event-handler/src/types/http.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,12 @@ type RequestStoreMethods<TEnv extends Env = Env> = Pick<
116116
'set' | 'get' | 'has' | 'delete'
117117
>;
118118

119+
type EventTypeMap = {
120+
ApiGatewayV1: APIGatewayProxyEvent;
121+
ApiGatewayV2: APIGatewayProxyEventV2;
122+
ALB: ALBEvent;
123+
};
124+
119125
type ResponseTypeMap = {
120126
ApiGatewayV1: APIGatewayProxyResult;
121127
ApiGatewayV2: APIGatewayProxyStructuredResultV2;
@@ -159,16 +165,19 @@ type ValidatedResponse<TRes extends ResSchema = ResSchema> = {
159165
};
160166

161167
type RequestContext<TEnv extends Env = Env> = {
162-
req: Request;
163-
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent;
164-
context: Context;
165-
res: Response;
166-
params: Record<string, string>;
167-
responseType: ResponseType;
168-
isBase64Encoded?: boolean;
169-
isHttpStreaming?: boolean;
170-
shared: IStore<SharedStoreOf<TEnv>>;
171-
} & RequestStoreMethods<TEnv>;
168+
[T in ResponseType]: {
169+
req: Request;
170+
event: EventTypeMap[T];
171+
context: Context;
172+
res: Response;
173+
route: string | null;
174+
params: Record<string, string>;
175+
responseType: T;
176+
isBase64Encoded?: boolean;
177+
isHttpStreaming?: boolean;
178+
shared: IStore<SharedStoreOf<TEnv>>;
179+
} & RequestStoreMethods<TEnv>;
180+
}[ResponseType];
172181

173182
type TypedRequestContext<
174183
TEnv extends Env = Env,
@@ -262,6 +271,7 @@ type Path = `/${string}` | RegExp;
262271

263272
type HttpRouteHandlerOptions = {
264273
handler: RouteHandler;
274+
route: string;
265275
params: Record<string, string>;
266276
rawParams: Record<string, string>;
267277
middleware: Middleware[];
@@ -601,6 +611,7 @@ export type {
601611
RequestContext,
602612
TypedRequestContext,
603613
ResponseType,
614+
EventTypeMap,
604615
ResponseTypeMap,
605616
HttpRouterOptions,
606617
RouteHandler,

0 commit comments

Comments
 (0)