diff --git a/lambdas/api-handler/package.json b/lambdas/api-handler/package.json index 1306f33d3..92fb12998 100644 --- a/lambdas/api-handler/package.json +++ b/lambdas/api-handler/package.json @@ -7,6 +7,7 @@ "@aws-sdk/s3-request-presigner": "^3.925.0", "@internal/datastore": "*", "@internal/helpers": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.25.11", "pino": "^9.7.0", diff --git a/lambdas/api-handler/src/handlers/get-letter-data.ts b/lambdas/api-handler/src/handlers/get-letter-data.ts index 4d516645b..737fdec1c 100644 --- a/lambdas/api-handler/src/handlers/get-letter-data.ts +++ b/lambdas/api-handler/src/handlers/get-letter-data.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyHandler } from "aws-lambda"; +import { MetricsLogger, metricScope } from "aws-embedded-metrics"; import { assertNotEmpty } from "../utils/validation"; import { extractCommonIds } from "../utils/common-ids"; import { ApiErrorDetail } from "../contracts/errors"; @@ -6,46 +7,60 @@ import { processError } from "../mappers/error-mapper"; import ValidationError from "../errors/validation-error"; import { getLetterDataUrl } from "../services/letter-operations"; import type { Deps } from "../config/deps"; +import { MetricStatus, emitForSingleSupplier } from "../utils/metrics"; export default function createGetLetterDataHandler( deps: Deps, ): APIGatewayProxyHandler { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); - - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, + return metricScope((metrics: MetricsLogger) => { + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, ); - } - try { - const letterId = assertNotEmpty( - event.pathParameters?.id, - new ValidationError( - ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter, - ), - ); + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } - return { - statusCode: 303, - headers: { - Location: await getLetterDataUrl( - commonIds.value.supplierId, - letterId, - deps, + const { supplierId } = commonIds.value; + try { + const letterId = assertNotEmpty( + event.pathParameters?.id, + new ValidationError( + ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter, ), - }, - body: "", - }; - } catch (error) { - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; + ); + + emitForSingleSupplier( + metrics, + "getLetterData", + supplierId, + 1, + MetricStatus.Success, + ); + return { + statusCode: 303, + headers: { + Location: await getLetterDataUrl(supplierId, letterId, deps), + }, + body: "", + }; + } catch (error) { + emitForSingleSupplier( + metrics, + "getLetterData", + supplierId, + 1, + MetricStatus.Failure, + ); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; + }); } diff --git a/lambdas/api-handler/src/handlers/get-letter.ts b/lambdas/api-handler/src/handlers/get-letter.ts index a88a48822..fcd3b0990 100644 --- a/lambdas/api-handler/src/handlers/get-letter.ts +++ b/lambdas/api-handler/src/handlers/get-letter.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyHandler } from "aws-lambda"; +import { MetricsLogger, metricScope } from "aws-embedded-metrics"; import { assertNotEmpty } from "../utils/validation"; import { extractCommonIds } from "../utils/common-ids"; import ValidationError from "../errors/validation-error"; @@ -7,53 +8,72 @@ import { getLetterById } from "../services/letter-operations"; import { processError } from "../mappers/error-mapper"; import { mapToGetLetterResponse } from "../mappers/letter-mapper"; import { Deps } from "../config/deps"; +import { MetricStatus, emitForSingleSupplier } from "../utils/metrics"; +// Get letter data export default function createGetLetterHandler( deps: Deps, ): APIGatewayProxyHandler { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); - - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, + return metricScope((metrics: MetricsLogger) => { + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, ); - } - try { - const letterId = assertNotEmpty( - event.pathParameters?.id, - new ValidationError( - ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter, - ), - ); + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } - const letter = await getLetterById( - commonIds.value.supplierId, - letterId, - deps.letterRepo, - ); + const { supplierId } = commonIds.value; + try { + const letterId = assertNotEmpty( + event.pathParameters?.id, + new ValidationError( + ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter, + ), + ); + + const letter = await getLetterById( + supplierId, + letterId, + deps.letterRepo, + ); - const response = mapToGetLetterResponse(letter); + const response = mapToGetLetterResponse(letter); - deps.logger.info({ - description: "Letter successfully fetched by id", - supplierId: commonIds.value.supplierId, - letterId, - }); + deps.logger.info({ + description: "Letter successfully fetched by id", + supplierId, + letterId, + }); - return { - statusCode: 200, - body: JSON.stringify(response, null, 2), - }; - } catch (error) { - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; + emitForSingleSupplier( + metrics, + "getLetter", + supplierId, + 1, + MetricStatus.Success, + ); + return { + statusCode: 200, + body: JSON.stringify(response, null, 2), + }; + } catch (error) { + emitForSingleSupplier( + metrics, + "getLetter", + supplierId, + 1, + MetricStatus.Failure, + ); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; + }); } diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index adc3febc4..a1d1c47f4 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -3,6 +3,7 @@ import { APIGatewayProxyHandler, } from "aws-lambda"; import { Logger } from "pino"; +import { MetricsLogger, metricScope } from "aws-embedded-metrics"; import { getLettersForSupplier } from "../services/letter-operations"; import { extractCommonIds } from "../utils/common-ids"; import { requireEnvVar } from "../utils/validation"; @@ -11,7 +12,9 @@ import { processError } from "../mappers/error-mapper"; import ValidationError from "../errors/validation-error"; import { mapToGetLettersResponse } from "../mappers/letter-mapper"; import type { Deps } from "../config/deps"; +import { MetricStatus, emitForSingleSupplier } from "../utils/metrics"; +// List letters Handlers // The endpoint should only return pending letters for now const status = "PENDING"; @@ -82,53 +85,70 @@ function getLimitOrDefault( export default function createGetLettersHandler( deps: Deps, ): APIGatewayProxyHandler { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); - - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, + return metricScope((metrics: MetricsLogger) => { + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, ); - } - try { - const maxLimit = requireEnvVar(deps.env, "MAX_LIMIT"); + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } - const limitNumber = getLimitOrDefault( - event.queryStringParameters, - maxLimit, - deps.logger, - ); + const { supplierId } = commonIds.value; + try { + const maxLimit = requireEnvVar(deps.env, "MAX_LIMIT"); - const letters = await getLettersForSupplier( - commonIds.value.supplierId, - status, - limitNumber, - deps.letterRepo, - ); + const limitNumber = getLimitOrDefault( + event.queryStringParameters, + maxLimit, + deps.logger, + ); + + const letters = await getLettersForSupplier( + supplierId, + status, + limitNumber, + deps.letterRepo, + ); - const response = mapToGetLettersResponse(letters); + const response = mapToGetLettersResponse(letters); - deps.logger.info({ - description: "Pending letters successfully fetched", - supplierId: commonIds.value.supplierId, - limitNumber, - status, - lettersCount: letters.length, - }); + deps.logger.info({ + description: "Pending letters successfully fetched", + supplierId, + limitNumber, + status, + lettersCount: letters.length, + }); - return { - statusCode: 200, - body: JSON.stringify(response, null, 2), - }; - } catch (error) { - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; + emitForSingleSupplier( + metrics, + "getLetters", + supplierId, + letters.length, + MetricStatus.Success, + ); + return { + statusCode: 200, + body: JSON.stringify(response, null, 2), + }; + } catch (error) { + emitForSingleSupplier( + metrics, + "getLetters", + supplierId, + 1, + MetricStatus.Failure, + ); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; + }); } diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index 6d0be3c2c..d3c34d956 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyHandler } from "aws-lambda"; +import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; import { enqueueLetterUpdateRequests } from "../services/letter-operations"; import { PatchLetterRequest, @@ -12,74 +13,91 @@ import { assertNotEmpty } from "../utils/validation"; import { extractCommonIds } from "../utils/common-ids"; import { mapToUpdateCommand } from "../mappers/letter-mapper"; import type { Deps } from "../config/deps"; +import { MetricStatus } from "../utils/metrics"; export default function createPatchLetterHandler( deps: Deps, ): APIGatewayProxyHandler { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); - - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, + return metricScope((metrics: MetricsLogger) => { + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, ); - } - try { - const letterId = assertNotEmpty( - event.pathParameters?.id, - new ValidationError( - ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter, - ), - ); - const body = assertNotEmpty( - event.body, - new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), - ); + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } - let patchLetterRequest: PatchLetterRequest; + const { supplierId } = commonIds.value; + metrics.setNamespace( + process.env.AWS_LAMBDA_FUNCTION_NAME || "patchLetters", + ); try { - patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); - } catch (error) { - const typedError = - error instanceof Error - ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { - cause: error, - }) - : error; - throw typedError; - } + const letterId = assertNotEmpty( + event.pathParameters?.id, + new ValidationError( + ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter, + ), + ); + const body = assertNotEmpty( + event.body, + new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), + ); - const updateLetterCommand: UpdateLetterCommand = mapToUpdateCommand( - patchLetterRequest, - commonIds.value.supplierId, - ); + let patchLetterRequest: PatchLetterRequest; + + try { + patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); + } catch (error) { + const typedError = + error instanceof Error + ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { + cause: error, + }) + : error; + throw typedError; + } - if (updateLetterCommand.id !== letterId) { - throw new ValidationError( - ApiErrorDetail.InvalidRequestLetterIdsMismatch, + const updateLetterCommand: UpdateLetterCommand = mapToUpdateCommand( + patchLetterRequest, + supplierId, ); - } - await enqueueLetterUpdateRequests( - [updateLetterCommand], - commonIds.value.correlationId, - deps, - ); + if (updateLetterCommand.id !== letterId) { + throw new ValidationError( + ApiErrorDetail.InvalidRequestLetterIdsMismatch, + ); + } + + await enqueueLetterUpdateRequests( + [updateLetterCommand], + commonIds.value.correlationId, + deps, + ); - return { - statusCode: 202, - body: "", - }; - } catch (error) { - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; + metrics.putDimensions({ + supplier: supplierId, + status: updateLetterCommand.status, + }); + metrics.putMetric(MetricStatus.Success, 1, Unit.Count); + return { + statusCode: 202, + body: "", + }; + } catch (error) { + metrics.putDimensions({ + supplier: supplierId, + }); + metrics.putMetric(MetricStatus.Success, 1, Unit.Count); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; + }); } diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index d8f147878..9bc83cd7f 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyHandler } from "aws-lambda"; +import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; import type { Deps } from "../config/deps"; import { ApiErrorDetail } from "../contracts/errors"; import { @@ -11,78 +12,108 @@ import { mapToUpdateCommands } from "../mappers/letter-mapper"; import { enqueueLetterUpdateRequests } from "../services/letter-operations"; import { extractCommonIds } from "../utils/common-ids"; import { assertNotEmpty, requireEnvVar } from "../utils/validation"; +import { MetricStatus } from "../utils/metrics"; function duplicateIdsExist(postLettersRequest: PostLettersRequest) { const ids = postLettersRequest.data.map((item) => item.id); return new Set(ids).size !== ids.length; } +/** + * emits metrics of successful letter updates, including the supplier and grouped by status + */ +async function emitMetics( + metrics: MetricsLogger, + supplierId: string, + statusesMapping: Map, +) { + for (const [status, count] of statusesMapping) { + metrics.putDimensions({ + supplier: supplierId, + eventType: status, + }); + metrics.putMetric(MetricStatus.Success, count, Unit.Count); + } +} + export default function createPostLettersHandler( deps: Deps, ): APIGatewayProxyHandler { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); - - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, + return metricScope((metrics: MetricsLogger) => { + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, ); - } - const maxUpdateItems = requireEnvVar(deps.env, "MAX_LIMIT"); - requireEnvVar(deps.env, "QUEUE_URL"); + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } - try { - const body = assertNotEmpty( - event.body, - new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), + const maxUpdateItems = requireEnvVar(deps.env, "MAX_LIMIT"); + requireEnvVar(deps.env, "QUEUE_URL"); + + const { supplierId } = commonIds.value; + metrics.setNamespace( + process.env.AWS_LAMBDA_FUNCTION_NAME || "postLetters", ); + try { + const body = assertNotEmpty( + event.body, + new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), + ); - let postLettersRequest: PostLettersRequest; + let postLettersRequest: PostLettersRequest; - try { - postLettersRequest = PostLettersRequestSchema.parse(JSON.parse(body)); - } catch (error) { - const typedError = - error instanceof Error - ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { - cause: error, - }) - : error; - throw typedError; - } + try { + postLettersRequest = PostLettersRequestSchema.parse(JSON.parse(body)); + } catch (error) { + const typedError = + error instanceof Error + ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { + cause: error, + }) + : error; + throw typedError; + } - if (postLettersRequest.data.length > maxUpdateItems) { - throw new ValidationError( - ApiErrorDetail.InvalidRequestLettersToUpdate, - { args: [maxUpdateItems] }, - ); - } + if (postLettersRequest.data.length > maxUpdateItems) { + throw new ValidationError( + ApiErrorDetail.InvalidRequestLettersToUpdate, + { args: [maxUpdateItems] }, + ); + } - if (duplicateIdsExist(postLettersRequest)) { - throw new ValidationError( - ApiErrorDetail.InvalidRequestDuplicateLetterId, - ); - } + if (duplicateIdsExist(postLettersRequest)) { + throw new ValidationError( + ApiErrorDetail.InvalidRequestDuplicateLetterId, + ); + } - await enqueueLetterUpdateRequests( - mapToUpdateCommands(postLettersRequest, commonIds.value.supplierId), - commonIds.value.correlationId, - deps, - ); + const statusesMapping = new Map(); + await enqueueLetterUpdateRequests( + mapToUpdateCommands(postLettersRequest, supplierId, statusesMapping), + commonIds.value.correlationId, + deps, + ); - return { - statusCode: 202, - body: "", - }; - } catch (error) { - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; + await emitMetics(metrics, supplierId, statusesMapping); + return { + statusCode: 202, + body: "", + }; + } catch (error) { + metrics.putDimensions({ + supplier: supplierId, + }); + metrics.putMetric(MetricStatus.Failure, 1, Unit.Count); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; + }); } diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts index 74fc82e18..bb44b1c70 100644 --- a/lambdas/api-handler/src/handlers/post-mi.ts +++ b/lambdas/api-handler/src/handlers/post-mi.ts @@ -1,4 +1,5 @@ import { APIGatewayProxyHandler } from "aws-lambda"; +import { MetricsLogger, metricScope } from "aws-embedded-metrics"; import postMIOperation from "../services/mi-operations"; import { ApiErrorDetail } from "../contracts/errors"; import ValidationError from "../errors/validation-error"; @@ -8,57 +9,86 @@ import { extractCommonIds } from "../utils/common-ids"; import { PostMIRequest, PostMIRequestSchema } from "../contracts/mi"; import { mapToMI } from "../mappers/mi-mapper"; import { Deps } from "../config/deps"; +import { MetricStatus, emitForSingleSupplier } from "../utils/metrics"; export default function createPostMIHandler( deps: Deps, ): APIGatewayProxyHandler { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); - - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, - ); - } - - try { - const body = assertNotEmpty( - event.body, - new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), + return metricScope((metrics: MetricsLogger) => { + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, ); - let postMIRequest: PostMIRequest; + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } + const { supplierId } = commonIds.value; try { - postMIRequest = PostMIRequestSchema.parse(JSON.parse(body)); - } catch (error) { - const typedError = - error instanceof Error - ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { - cause: error, - }) - : error; - throw typedError; - } - validateIso8601Timestamp(postMIRequest.data.attributes.timestamp); + const body = assertNotEmpty( + event.body, + new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), + ); - const result = await postMIOperation( - mapToMI(postMIRequest, commonIds.value.supplierId), - deps.miRepo, - ); + let postMIRequest: PostMIRequest; + + try { + postMIRequest = PostMIRequestSchema.parse(JSON.parse(body)); + } catch (error) { + const typedError = + error instanceof Error + ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { + cause: error, + }) + : error; + throw typedError; + } + validateIso8601Timestamp(postMIRequest.data.attributes.timestamp); + + const result = await postMIOperation( + mapToMI(postMIRequest, supplierId), + deps.miRepo, + ); - return { - statusCode: 201, - body: JSON.stringify(result, null, 2), - }; - } catch (error) { - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; + // metric with count 1 specifying the supplier + emitForSingleSupplier( + metrics, + "postMi", + supplierId, + 1, + MetricStatus.Success, + ); + // metric displaying the supplier and the type/number of lineItems posted + emitForSingleSupplier( + metrics, + "postMi", + supplierId, + postMIRequest.data.attributes.quantity, + MetricStatus.Success, + { lineItem: postMIRequest.data.attributes.lineItem }, + ); + + return { + statusCode: 201, + body: JSON.stringify(result, null, 2), + }; + } catch (error) { + emitForSingleSupplier( + metrics, + "postMi", + supplierId, + 1, + MetricStatus.Failure, + ); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; + }); } diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts index fa7f9f81e..e9a0c58b9 100644 --- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts +++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts @@ -3,14 +3,104 @@ import { mapToGetLetterResponse, mapToGetLettersResponse, mapToPatchLetterResponse, + mapToUpdateCommands, } from "../letter-mapper"; import { GetLetterResponse, GetLettersResponse, PatchLetterResponse, + PostLettersRequest, } from "../../contracts/letters"; describe("letter-mapper", () => { + it("maps PostLetterRequest to UpdateLetterCommands", () => { + const request: PostLettersRequest = { + data: [ + { + id: "id1", + type: "Letter", + attributes: { + status: "REJECTED", + reasonCode: "123", + reasonText: "Reason text", + }, + }, + { id: "id2", type: "Letter", attributes: { status: "ACCEPTED" } }, + { id: "id3", type: "Letter", attributes: { status: "DELIVERED" } }, + ], + }; + const supplierId = "testSupplierId"; + const updateLetterCommands = mapToUpdateCommands(request, supplierId); + expect(updateLetterCommands).toEqual([ + { + id: "id1", + reasonCode: "123", + reasonText: "Reason text", + supplierId: "testSupplierId", + status: "REJECTED", + }, + { + id: "id2", + supplierId: "testSupplierId", + status: "ACCEPTED", + }, + { + id: "id3", + supplierId: "testSupplierId", + status: "DELIVERED", + }, + ]); + }); + + it("maps PostLetterRequest to UpdateLetterCommands and populates statuses map", () => { + const request: PostLettersRequest = { + data: [ + { + id: "id1", + type: "Letter", + attributes: { + status: "REJECTED", + reasonCode: "123", + reasonText: "Reason text", + }, + }, + { id: "id2", type: "Letter", attributes: { status: "ACCEPTED" } }, + { id: "id3", type: "Letter", attributes: { status: "DELIVERED" } }, + ], + }; + const supplierId = "testSupplierId"; + const statusesMap = new Map(); + const updateLetterCommands = mapToUpdateCommands( + request, + supplierId, + statusesMap, + ); + expect(updateLetterCommands).toEqual([ + { + id: "id1", + reasonCode: "123", + reasonText: "Reason text", + supplierId: "testSupplierId", + status: "REJECTED", + }, + { + id: "id2", + supplierId: "testSupplierId", + status: "ACCEPTED", + }, + { + id: "id3", + supplierId: "testSupplierId", + status: "DELIVERED", + }, + ]); + expect(Object.fromEntries(statusesMap)).toEqual({ + REJECTED: 1, + ACCEPTED: 1, + DELIVERED: 1, + }); + }); + it("maps an internal Letter to a PatchLetterResponse", () => { const date = new Date().toISOString(); const letter: Letter = { diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts index c11d6d8c0..9117c8282 100644 --- a/lambdas/api-handler/src/mappers/letter-mapper.ts +++ b/lambdas/api-handler/src/mappers/letter-mapper.ts @@ -58,14 +58,24 @@ export function mapToUpdateCommand( export function mapToUpdateCommands( request: PostLettersRequest, supplierId: string, + statusesMapping?: Map, ): UpdateLetterCommand[] { - return request.data.map((letterToUpdate: PostLettersRequestResource) => ({ - id: letterToUpdate.id, - supplierId, - status: LetterStatus.parse(letterToUpdate.attributes.status), - reasonCode: letterToUpdate.attributes.reasonCode, - reasonText: letterToUpdate.attributes.reasonText, - })); + return request.data.map((letterToUpdate: PostLettersRequestResource) => { + const letterCommand = { + id: letterToUpdate.id, + supplierId, + status: LetterStatus.parse(letterToUpdate.attributes.status), + reasonCode: letterToUpdate.attributes.reasonCode, + reasonText: letterToUpdate.attributes.reasonText, + }; + if (statusesMapping) { + statusesMapping.set( + letterCommand.status, + (statusesMapping.get(letterCommand.status) || 0) + 1, + ); + } + return letterCommand; + }); } // --------------------------------------------- diff --git a/lambdas/api-handler/src/utils/metrics.ts b/lambdas/api-handler/src/utils/metrics.ts new file mode 100644 index 000000000..83a32c3a3 --- /dev/null +++ b/lambdas/api-handler/src/utils/metrics.ts @@ -0,0 +1,22 @@ +import { MetricsLogger, Unit } from "aws-embedded-metrics"; + +export function emitForSingleSupplier( + metrics: MetricsLogger, + functionName: string, + supplierId: string, + count: number, + message: string, + dimensions?: Record, +) { + metrics.setNamespace(process.env.AWS_LAMBDA_FUNCTION_NAME || functionName); + metrics.putDimensions({ + ...dimensions, + Supplier: supplierId, + }); + metrics.putMetric(message, count, Unit.Count); +} + +export enum MetricStatus { + Success = "success", + Failure = "failure", +} diff --git a/lambdas/letter-updates-transformer/package.json b/lambdas/letter-updates-transformer/package.json index b563d16da..bd80badf5 100644 --- a/lambdas/letter-updates-transformer/package.json +++ b/lambdas/letter-updates-transformer/package.json @@ -5,6 +5,7 @@ "@internal/datastore": "^0.1.0", "@internal/helpers": "^0.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.27.2", "pino": "^10.1.0", diff --git a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts index c53151c07..5ff0b1705 100644 --- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts +++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts @@ -10,6 +10,7 @@ import { PublishBatchRequestEntry, } from "@aws-sdk/client-sns"; import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src"; +import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; import mapLetterToCloudEvent from "./mappers/letter-mapper"; import { Deps } from "./deps"; import { LetterForEventPub, LetterSchemaForEventPub } from "./types"; @@ -18,39 +19,64 @@ import { LetterForEventPub, LetterSchemaForEventPub } from "./types"; const BATCH_SIZE = 10; export default function createHandler(deps: Deps): Handler { - return async (streamEvent: KinesisStreamEvent) => { - deps.logger.info({ description: "Received event", streamEvent }); - deps.logger.info({ - description: "Number of records", - count: streamEvent.Records?.length || 0, - }); - - // Ensure logging by extracting all records first - const ddbRecords: DynamoDBRecord[] = streamEvent.Records.map((record) => - extractPayload(record, deps), - ); - - const cloudEvents: LetterEvent[] = ddbRecords - .filter((record) => filterRecord(record, deps)) - .map((element) => extractNewLetter(element)) - .map((element) => mapLetterToCloudEvent(element, deps.env.EVENT_SOURCE)); - - for (const batch of generateBatches(cloudEvents)) { + return metricScope((metrics: MetricsLogger) => { + return async (streamEvent: KinesisStreamEvent) => { + deps.logger.info({ description: "Received event", streamEvent }); deps.logger.info({ - description: "Publishing batch", - size: batch.length, - letterEvents: batch, + description: "Number of records", + count: streamEvent.Records?.length || 0, }); - await deps.snsClient.send( - new PublishBatchCommand({ - TopicArn: deps.env.EVENTPUB_SNS_TOPIC_ARN, - PublishBatchRequestEntries: batch.map((element, index) => - buildMessage(element, index), - ), - }), + + // Ensure logging by extracting all records first + const ddbRecords: DynamoDBRecord[] = streamEvent.Records.map((record) => + extractPayload(record, deps), ); - } - }; + + const cloudEvents: LetterEvent[] = ddbRecords + .filter((record) => filterRecord(record, deps)) + .map((element) => extractNewLetter(element)) + .map((element) => + mapLetterToCloudEvent(element, deps.env.EVENT_SOURCE), + ); + + const eventTypeCount = new Map(); + for (const batch of generateBatches(cloudEvents)) { + deps.logger.info({ + description: "Publishing batch", + size: batch.length, + letterEvents: batch, + }); + await deps.snsClient.send( + new PublishBatchCommand({ + TopicArn: deps.env.EVENTPUB_SNS_TOPIC_ARN, + PublishBatchRequestEntries: batch.map((element, index) => { + eventTypeCount.set( + element.type, + (eventTypeCount.get(element.type) || 0) + 1, + ); + return buildMessage(element, index); + }), + }), + ); + } + await emitMetrics(metrics, eventTypeCount); + }; + }); +} + +async function emitMetrics( + metrics: MetricsLogger, + eventTypeCount: Map, +) { + metrics.setNamespace( + process.env.AWS_LAMBDA_FUNCTION_NAME || "letter-updates-transformer", + ); + for (const [type, count] of eventTypeCount) { + metrics.putDimensions({ + eventType: type, + }); + metrics.putMetric("events published", count, Unit.Count); + } } function filterRecord(record: DynamoDBRecord, deps: Deps): boolean { diff --git a/lambdas/mi-updates-transformer/package.json b/lambdas/mi-updates-transformer/package.json index 35dd5567d..826ab2da1 100644 --- a/lambdas/mi-updates-transformer/package.json +++ b/lambdas/mi-updates-transformer/package.json @@ -4,6 +4,7 @@ "@aws-sdk/util-dynamodb": "^3.943.0", "@internal/datastore": "^0.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", "pino": "^10.1.0", diff --git a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts index 107b616fa..9c4d2926a 100644 --- a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts +++ b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts @@ -11,6 +11,7 @@ import { PublishBatchRequestEntry, } from "@aws-sdk/client-sns"; import { MISubmittedEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src"; +import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; import { mapMIToCloudEvent } from "./mappers/mi-mapper"; import { Deps } from "./deps"; @@ -49,26 +50,49 @@ function extractMIData(record: DynamoDBRecord): MI { return MISchema.parse(unmarshall(newImage as any)); } +async function emitMetrics( + metrics: MetricsLogger, + eventTypeCount: Map, +) { + metrics.setNamespace( + process.env.AWS_LAMBDA_FUNCTION_NAME || "letter-updates-transformer", + ); + for (const [type, count] of eventTypeCount) { + metrics.putDimensions({ + eventType: type, + }); + metrics.putMetric("events published", count, Unit.Count); + } +} + export default function createHandler(deps: Deps): Handler { - return async (streamEvent: KinesisStreamEvent) => { - deps.logger.info({ description: "Received event", streamEvent }); + return metricScope((metrics: MetricsLogger) => { + return async (streamEvent: KinesisStreamEvent) => { + deps.logger.info({ description: "Received event", streamEvent }); - const cloudEvents: MISubmittedEvent[] = streamEvent.Records.map((record) => - extractPayload(record, deps), - ) - .filter((record) => record.eventName === "INSERT") - .map((element) => extractMIData(element)) - .map((payload) => mapMIToCloudEvent(payload, deps)); + const cloudEvents: MISubmittedEvent[] = streamEvent.Records.map( + (record) => extractPayload(record, deps), + ) + .filter((record) => record.eventName === "INSERT") + .map((element) => extractMIData(element)) + .map((payload) => mapMIToCloudEvent(payload, deps)); - for (const batch of generateBatches(cloudEvents)) { - await deps.snsClient.send( - new PublishBatchCommand({ - TopicArn: deps.env.EVENTPUB_SNS_TOPIC_ARN, - PublishBatchRequestEntries: batch.map((element) => - buildMessage(element, deps), - ), - }), - ); - } - }; + const eventTypeCount = new Map(); + for (const batch of generateBatches(cloudEvents)) { + await deps.snsClient.send( + new PublishBatchCommand({ + TopicArn: deps.env.EVENTPUB_SNS_TOPIC_ARN, + PublishBatchRequestEntries: batch.map((element) => { + eventTypeCount.set( + element.type, + (eventTypeCount.get(element.type) || 0) + 1, + ); + return buildMessage(element, deps); + }), + }), + ); + } + await emitMetrics(metrics, eventTypeCount); + }; + }); } diff --git a/lambdas/upsert-letter/package.json b/lambdas/upsert-letter/package.json index e4858da0d..4cf3d7f38 100644 --- a/lambdas/upsert-letter/package.json +++ b/lambdas/upsert-letter/package.json @@ -7,6 +7,7 @@ "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8", "@types/aws-lambda": "^8.10.148", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.27.2", "pino": "^9.7.0", diff --git a/lambdas/upsert-letter/src/handler/upsert-handler.ts b/lambdas/upsert-letter/src/handler/upsert-handler.ts index a1b2ea08b..c94084610 100644 --- a/lambdas/upsert-letter/src/handler/upsert-handler.ts +++ b/lambdas/upsert-letter/src/handler/upsert-handler.ts @@ -19,6 +19,7 @@ import { LetterRequestPreparedEventV2, } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import z from "zod"; +import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; import { Deps } from "../config/deps"; type SupplierSpec = { supplierId: string; specId: string }; @@ -152,34 +153,76 @@ async function runUpsert( throw new Error("No matching schema for received message"); } -export default function createUpsertLetterHandler(deps: Deps): SQSHandler { - return async (event: SQSEvent) => { - const batchItemFailures: SQSBatchItemFailure[] = []; - - const tasks = event.Records.map(async (record) => { - try { - const message: string = parseSNSNotification(record); - - const snsEvent = JSON.parse(message); - - const letterEvent: unknown = removeEventBridgeWrapper(snsEvent); - - const type = getType(letterEvent); - - const operation = getOperationFromType(type); - - await runUpsert(operation, letterEvent, deps); - } catch (error) { - deps.logger.error( - { err: error, message: record.body }, - `Error processing upsert of record ${record.messageId}`, - ); - batchItemFailures.push({ itemIdentifier: record.messageId }); - } +async function emitMetrics( + metrics: MetricsLogger, + successMetrics: Map, + failedMetrics: Map, +) { + metrics.setNamespace(process.env.AWS_LAMBDA_FUNCTION_NAME || `upsertLetter`); + // emit success metrics + for (const [supplier, count] of successMetrics) { + metrics.putDimensions({ + Supplier: supplier, }); + metrics.putMetric("MessagesProcessed", count, Unit.Count); + } + // emit failure metrics + for (const [supplier, count] of failedMetrics) { + metrics.putDimensions({ + Supplier: supplier, + }); + metrics.putMetric("MessageFailed", count, Unit.Count); + } +} - await Promise.all(tasks); +function getSupplierId(snsEvent: any): string { + if (snsEvent && snsEvent.data && snsEvent.data.supplierId) { + return snsEvent.data.supplierId; + } + return "unknown"; +} - return { batchItemFailures }; - }; +export default function createUpsertLetterHandler(deps: Deps): SQSHandler { + return metricScope((metrics: MetricsLogger) => { + return async (event: SQSEvent) => { + const batchItemFailures: SQSBatchItemFailure[] = []; + const perSupplierSuccess: Map = new Map(); + const perSupplierFailure: Map = new Map(); + + const tasks = event.Records.map(async (record) => { + let supplier = "unknown"; + try { + const message: string = parseSNSNotification(record); + const snsEvent = JSON.parse(message); + supplier = getSupplierId(snsEvent); + const letterEvent: unknown = removeEventBridgeWrapper(snsEvent); + const type = getType(letterEvent); + + const operation = getOperationFromType(type); + + await runUpsert(operation, letterEvent, deps); + + perSupplierSuccess.set( + supplier, + (perSupplierSuccess.get(supplier) || 0) + 1, + ); + } catch (error) { + deps.logger.error( + { err: error, message: record.body }, + `Error processing upsert of record ${record.messageId}`, + ); + perSupplierFailure.set( + supplier, + (perSupplierFailure.get(supplier) || 0) + 1, + ); + batchItemFailures.push({ itemIdentifier: record.messageId }); + } + }); + + await Promise.all(tasks); + + await emitMetrics(metrics, perSupplierSuccess, perSupplierFailure); + return { batchItemFailures }; + }; + }); } diff --git a/package-lock.json b/package-lock.json index 64a58b073..c99dfe8cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -178,6 +178,7 @@ "@aws-sdk/s3-request-presigner": "^3.925.0", "@internal/datastore": "*", "@internal/helpers": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.25.11", "pino": "^9.7.0", @@ -269,6 +270,7 @@ "@internal/datastore": "^0.1.0", "@internal/helpers": "^0.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.27.2", "pino": "^10.1.0", @@ -333,6 +335,7 @@ "@aws-sdk/util-dynamodb": "^3.943.0", "@internal/datastore": "^0.1.0", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.24.0", "pino": "^10.1.0", @@ -798,6 +801,7 @@ "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5", "@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8", "@types/aws-lambda": "^8.10.148", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "^0.27.2", "pino": "^9.7.0", @@ -3338,6 +3342,12 @@ "node": ">=18" } }, + "node_modules/@datastructures-js/heap": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@datastructures-js/heap/-/heap-4.3.7.tgz", + "integrity": "sha512-Dx4un7Uj0dVxkfoq4RkpzsY2OrvNJgQYZ3n3UlGdl88RxxdHd7oTi21/l3zoxUUe0sXFuNUrfmWqlHzqnoN6Ug==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -6159,9 +6169,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.20.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.7.tgz", - "integrity": "sha512-aO7jmh3CtrmPsIJxUwYIzI5WVlMK8BMCPQ4D4nTzqTqBhbzvxHNzBMGcEg13yg/z9R2Qsz49NUFl0F0lVbTVFw==", + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.0.tgz", + "integrity": "sha512-bg2TfzgsERyETAxc/Ims/eJX8eAnIeTi4r4LHpMpfF/2NyO6RsWis0rjKcCPaGksljmOb23BZRiCeT/3NvwkXw==", "license": "Apache-2.0", "dependencies": { "@smithy/middleware-serde": "^4.2.9", @@ -9107,6 +9117,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-embedded-metrics": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/aws-embedded-metrics/-/aws-embedded-metrics-4.2.1.tgz", + "integrity": "sha512-uzydBXlGQVTB2sZ9ACCQZM3y0u4wdvxxRKFL9LP6RdfI2GcOrCcAsz65UKQvX9iagxFhah322VvvatgP8E7MIg==", + "license": "Apache-2.0", + "dependencies": { + "@datastructures-js/heap": "^4.0.2" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/aws-lambda": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/aws-lambda/-/aws-lambda-1.0.7.tgz", @@ -21309,13 +21331,6 @@ "node": ">=18.17" } }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "extraneous": true, - "license": "MIT" - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/tests/resources/prepared-letter.json b/tests/resources/prepared-letter.json index 50f6eba6c..f9c1fea55 100644 --- a/tests/resources/prepared-letter.json +++ b/tests/resources/prepared-letter.json @@ -4,13 +4,14 @@ "clientId": "testClientId", "createdAt": "2025-08-28T08:45:00.000Z", "domainId": "letter1", - "letterVariantId": "lv1", + "letterVariantId": "notify-standard", "pageCount": 2, "requestId": "0o5Fs0EELR0fUjHjbCnEtdUwQe3", "requestItemId": "0o5Fs0EELR0fUjHjbCnEtdUwQe4", "requestItemPlanId": "0o5Fs0EELR0fUjHjbCnEtdUwQe5", "sha256Hash": "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", "status": "PREPARED", + "supplierId": "testSupplierId", "templateId": "template_123", "url": "s3://nhs-820178564574-eu-west-2-pr280-supapi-test-letters/letter1.png" },