Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions packages/event-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@
"types": "./lib/esm/http/middleware/tracer.d.ts",
"default": "./lib/esm/http/middleware/tracer.js"
}
},
"./http/middleware/metrics": {
"require": {
"types": "./lib/cjs/http/middleware/metrics.d.ts",
"default": "./lib/cjs/http/middleware/metrics.js"
},
"import": {
"types": "./lib/esm/http/middleware/metrics.d.ts",
"default": "./lib/esm/http/middleware/metrics.js"
}
}
},
"typesVersions": {
Expand Down Expand Up @@ -140,6 +150,10 @@
"http/middleware/tracer": [
"./lib/cjs/http/middleware/tracer.d.ts",
"./lib/esm/http/middleware/tracer.d.ts"
],
"http/middleware/metrics": [
"./lib/cjs/http/middleware/metrics.d.ts",
"./lib/esm/http/middleware/metrics.d.ts"
]
}
},
Expand All @@ -158,11 +172,15 @@
},
"peerDependencies": {
"@aws-lambda-powertools/tracer": ">=2.32.0",
"@aws-lambda-powertools/metrics": ">=2.32.0",
Comment thread
svozza marked this conversation as resolved.
"@standard-schema/spec": "^1.0.0"
},
"peerDependenciesMeta": {
"@aws-lambda-powertools/tracer": {
"optional": true
},
"@aws-lambda-powertools/metrics": {
"optional": true
}
},
"keywords": [
Expand Down
2 changes: 2 additions & 0 deletions packages/event-handler/src/http/RouteHandlerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ class RouteHandlerRegistry {
if (staticRoute != null) {
return {
handler: staticRoute.handler as RouteHandler,
route: `${method} ${getPathString(staticRoute.path)}`,
rawParams: {},
params: {},
middleware: staticRoute.middleware,
Expand Down Expand Up @@ -250,6 +251,7 @@ class RouteHandlerRegistry {

return {
handler: route.handler as RouteHandler,
route: `${method} ${getPathString(route.path)}`,
params: processedParams,
rawParams: params,
middleware: route.middleware,
Expand Down
61 changes: 43 additions & 18 deletions packages/event-handler/src/http/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ import {
composeMiddleware,
getBase64EncodingFromHeaders,
getBase64EncodingFromResult,
getResponseType,
getStatusCode,
HttpResponseStream,
isALBEvent,
Expand Down Expand Up @@ -247,6 +246,38 @@ class Router<TEnv extends Env = Env> {
};
}

#buildRequestContext(
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent,
context: Context,
options: {
req: Request;
res: Response;
isHttpStreaming?: boolean;
} & Pick<RequestContext<TEnv>, 'set' | 'get' | 'has' | 'delete' | 'shared'>
): RequestContext<TEnv> {
const common = {
context,
req: options.req,
res: options.res,
route: null as string | null,
params: {} as Record<string, string>,
isHttpStreaming: options.isHttpStreaming,
set: options.set,
get: options.get,
has: options.has,
delete: options.delete,
shared: options.shared,
};

if (isAPIGatewayProxyEventV2(event)) {
return { ...common, event, responseType: 'ApiGatewayV2' };
}
if (isALBEvent(event)) {
return { ...common, event, responseType: 'ALB' };
}
return { ...common, event, responseType: 'ApiGatewayV1' };
}

/**
* Core resolution logic shared by both resolve and resolveStream methods.
* Validates the event, routes to handlers, executes middleware, and handles errors.
Expand All @@ -272,8 +303,8 @@ class Router<TEnv extends Env = Env> {
throw new InvalidEventError();
}

const responseType = getResponseType(event);
const requestStore = new Store<RequestStoreOf<TEnv>>();
const storeAccessors = this.#createStoreAccessors(requestStore);

let req: Request;
try {
Expand All @@ -283,48 +314,42 @@ class Router<TEnv extends Env = Env> {
this.logger.error(err);
// We can't throw a MethodNotAllowedError outside the try block as it
// will be converted to an internal server error by the API Gateway runtime
return {
event,
context,
return this.#buildRequestContext(event, context, {
req: new Request('https://invalid'),
res: new Response(null, {
status: HttpStatusCodes.METHOD_NOT_ALLOWED,
...(options?.isHttpStreaming && {
headers: { 'transfer-encoding': 'chunked' },
}),
}),
params: {},
responseType,
...this.#createStoreAccessors(requestStore),
};
...storeAccessors,
});
}
throw err;
}

const requestContext: RequestContext<TEnv> = {
event,
context,
const requestContext = this.#buildRequestContext(event, context, {
req,
// this response should be overwritten by the handler, if it isn't
// it means something went wrong with the middleware chain
res: new Response('', {
status: HttpStatusCodes.INTERNAL_SERVER_ERROR,
...(options?.isHttpStreaming && {
headers: { 'transfer-encoding': 'chunked' },
}),
}),
params: {},
responseType,
isHttpStreaming: options?.isHttpStreaming,
...this.#createStoreAccessors(requestStore),
};
...storeAccessors,
});

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

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

if (route !== null) {
requestContext.route = route.route;
}

const handlerMiddleware: Middleware = async ({ reqCtx, next }) => {
let handlerRes: HandlerResponse;
if (route === null) {
Expand Down
1 change: 1 addition & 0 deletions packages/event-handler/src/http/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { compress } from './compress.js';
export { cors } from './cors.js';
export { metrics } from './metrics.js';
export { tracer } from './tracer.js';
120 changes: 120 additions & 0 deletions packages/event-handler/src/http/middleware/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { Metrics } from '@aws-lambda-powertools/metrics';
import { MetricUnit } from '@aws-lambda-powertools/metrics';
import type { Middleware, RequestContext } from '../../types/http.js';
import { HttpError } from '../errors.js';

const getHeaderMetadata = (req: Request): Record<string, string> => {
const metadata: Record<string, string> = {};

const userAgent = req.headers.get('User-Agent');
if (userAgent) {
metadata.userAgent = userAgent;
}

return metadata;
};

const getIpAddress = (reqCtx: RequestContext): string | undefined => {
if (reqCtx.responseType === 'ApiGatewayV1') {
return reqCtx.event.requestContext.identity.sourceIp;
}
if (reqCtx.responseType === 'ApiGatewayV2') {
return reqCtx.event.requestContext.http.sourceIp;
}
const xForwardedFor = reqCtx.req.headers.get('X-Forwarded-For');
if (xForwardedFor) {
return xForwardedFor.split(',')[0].trim();
}
return undefined;
};

const getEventMetadata = (reqCtx: RequestContext): Record<string, string> => {
const metadata: Record<string, string> = {};

const ipAddress = getIpAddress(reqCtx);
if (ipAddress) {
metadata.ipAddress = ipAddress;
}

if (reqCtx.responseType !== 'ALB') {
metadata.apiGwRequestId = reqCtx.event.requestContext.requestId;
metadata.apiGwApiId = reqCtx.event.requestContext.apiId;
}
if (reqCtx.responseType === 'ApiGatewayV1') {
const extendedRequestId = reqCtx.event.requestContext.extendedRequestId;
if (extendedRequestId) {
metadata.apiGwExtendedRequestId = extendedRequestId;
}
}

return metadata;
};

/**
* A middleware for emitting per-request metrics using Powertools Metrics.
*
* This middleware automatically:
* - Adds the matched route as a metric dimension (uses `NOT_FOUND` when no route matches to prevent dimension explosion)
* - Emits `latency` (Milliseconds), `fault` (Count), and `error` (Count) metrics
* - Adds `httpMethod` and `path` metadata for all requests
* - Adds `ipAddress` and `userAgent` metadata from request headers when available
* - Adds `apiGwRequestId` and `apiGwApiId` metadata for API Gateway V1 and V2 events
* - Adds `apiGwExtendedRequestId` metadata for API Gateway V1 events when available
* - Publishes stored metrics after each request
*
* @example
* ```typescript
* import { Router } from '@aws-lambda-powertools/event-handler/http';
* import { metrics as metricsMiddleware } from '@aws-lambda-powertools/event-handler/http/middleware/metrics';
* import { Metrics } from '@aws-lambda-powertools/metrics';
*
* const metrics = new Metrics({ namespace: 'my-app', serviceName: 'my-service' });
* const app = new Router();
*
* app.use(metricsMiddleware(metrics));
* ```
*
* @param metrics - The Metrics instance to use for emitting metrics
*/
const metrics = (metrics: Metrics): Middleware => {
return async ({ reqCtx, next }) => {
const start = performance.now();
let status = 500;

try {
await next();
status = reqCtx.res.status;
} catch (error) {
status = error instanceof HttpError ? error.statusCode : 500;
throw error;
} finally {
const url = new URL(reqCtx.req.url);
const metadata = {
httpMethod: reqCtx.req.method,
path: url.pathname,
statusCode: String(status),
...getHeaderMetadata(reqCtx.req),
...getEventMetadata(reqCtx),
};
for (const [key, value] of Object.entries(metadata)) {
metrics.addMetadata(key, value);
}
metrics
.addDimension('route', reqCtx.route ?? 'NOT_FOUND')
.addMetric(
'latency',
MetricUnit.Milliseconds,
performance.now() - start
)
.addMetric('fault', MetricUnit.Count, status >= 500 ? 1 : 0)
.addMetric(
'error',
MetricUnit.Count,
status >= 400 && status < 500 ? 1 : 0
)
.publishStoredMetrics();
}
};
};

export { metrics };
9 changes: 0 additions & 9 deletions packages/event-handler/src/http/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import type {
Middleware,
Path,
ResponseStream,
ResponseType,
ValidationResult,
} from '../types/http.js';
import type { ResolveOptions } from '../types/index.js';
Expand Down Expand Up @@ -154,14 +153,6 @@ export const isALBEvent = (event: unknown): event is ALBEvent => {
return isRecord(event.requestContext.elb);
};

export const getResponseType = (
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent
): ResponseType => {
if (isAPIGatewayProxyEventV2(event)) return 'ApiGatewayV2';
if (isALBEvent(event)) return 'ALB';
return 'ApiGatewayV1';
};

export const isHttpMethod = (method: string): method is HttpMethod => {
return Object.keys(HttpVerbs).includes(method);
};
Expand Down
31 changes: 21 additions & 10 deletions packages/event-handler/src/types/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ type RequestStoreMethods<TEnv extends Env = Env> = Pick<
'set' | 'get' | 'has' | 'delete'
>;

type EventTypeMap = {
ApiGatewayV1: APIGatewayProxyEvent;
ApiGatewayV2: APIGatewayProxyEventV2;
ALB: ALBEvent;
};

type ResponseTypeMap = {
ApiGatewayV1: APIGatewayProxyResult;
ApiGatewayV2: APIGatewayProxyStructuredResultV2;
Expand Down Expand Up @@ -159,16 +165,19 @@ type ValidatedResponse<TRes extends ResSchema = ResSchema> = {
};

type RequestContext<TEnv extends Env = Env> = {
req: Request;
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent;
context: Context;
res: Response;
params: Record<string, string>;
responseType: ResponseType;
isBase64Encoded?: boolean;
isHttpStreaming?: boolean;
shared: IStore<SharedStoreOf<TEnv>>;
} & RequestStoreMethods<TEnv>;
[T in ResponseType]: {
req: Request;
event: EventTypeMap[T];
context: Context;
res: Response;
route: string | null;
params: Record<string, string>;
responseType: T;
isBase64Encoded?: boolean;
isHttpStreaming?: boolean;
shared: IStore<SharedStoreOf<TEnv>>;
} & RequestStoreMethods<TEnv>;
}[ResponseType];

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

type HttpRouteHandlerOptions = {
handler: RouteHandler;
route: string;
params: Record<string, string>;
rawParams: Record<string, string>;
middleware: Middleware[];
Expand Down Expand Up @@ -601,6 +611,7 @@ export type {
RequestContext,
TypedRequestContext,
ResponseType,
EventTypeMap,
ResponseTypeMap,
HttpRouterOptions,
RouteHandler,
Expand Down
Loading
Loading