From e84bf1c530e8ef1117eeb3aad713d8cf373e1d60 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Tue, 10 Feb 2026 12:42:55 +0000 Subject: [PATCH 01/13] log EMF object for letter-status-update failure --- .../api-handler/src/handlers/get-letters.ts | 1 - .../src/handlers/letter-status-update.ts | 17 +++++++++- lambdas/api-handler/src/utils/metrics.ts | 31 +++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lambdas/api-handler/src/handlers/get-letters.ts b/lambdas/api-handler/src/handlers/get-letters.ts index 34d11bef..54d4f8ae 100644 --- a/lambdas/api-handler/src/handlers/get-letters.ts +++ b/lambdas/api-handler/src/handlers/get-letters.ts @@ -14,7 +14,6 @@ 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"; diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index fa14e672..4a7fa62d 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -1,10 +1,13 @@ -import { SQSEvent, SQSHandler } from "aws-lambda"; +import { SQSEvent, SQSHandler, SQSRecord } from "aws-lambda"; +import { Unit } from "aws-embedded-metrics"; +import pino from "pino"; import { UpdateLetterCommand, UpdateLetterCommandSchema, } from "../contracts/letters"; import { Deps } from "../config/deps"; import { mapToUpdateLetter } from "../mappers/letter-mapper"; +import { buildEMFObject } from "../utils/metrics"; export default function createLetterStatusUpdateHandler( deps: Deps, @@ -27,9 +30,21 @@ export default function createLetterStatusUpdateHandler( }, "Error processing letter status update", ); + // create metric object + emitAndFlushMetricLog(message, deps.logger); } }); await Promise.all(tasks); }; } + +function emitAndFlushMetricLog(message: SQSRecord, logger: pino.Logger) { + const metric = { + key: "statusUpdateFailed", + value: 1, + unit: Unit.Count, + }; + const emf = buildEMFObject("letter-status-update", {}, metric); + logger.info(emf); +} diff --git a/lambdas/api-handler/src/utils/metrics.ts b/lambdas/api-handler/src/utils/metrics.ts index 83a32c3a..c0925495 100644 --- a/lambdas/api-handler/src/utils/metrics.ts +++ b/lambdas/api-handler/src/utils/metrics.ts @@ -20,3 +20,34 @@ export enum MetricStatus { Success = "success", Failure = "failure", } + +interface MetricEntry { + key: string; + value: number; + unit: Unit; +} + +// build EMF object +export function buildEMFObject( + functionName: string, + dimensions: Record, + metric: MetricEntry, +) { + const namespace = process.env.AWS_LAMBDA_FUNCTION_NAME || functionName; + return { + LogGroup: namespace, + ServiceName: namespace, + ...dimensions, + _aws: { + Timestamp: Date.now(), + CloudWatchMetrics: [ + { + Namespace: namespace, + Dimensions: [[...Object.keys(dimensions), "ServiceName", "LogGroup"]], + Metrics: [{ Name: metric.key, Unit: metric.value }], + }, + ], + }, + [metric.key]: metric.value, + }; +} From 460aa00fc668c21961fb72ff7ae1db48bffcad24 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Wed, 11 Feb 2026 11:34:14 +0000 Subject: [PATCH 02/13] log separate entries for batch post letters --- .../src/handlers/letter-status-update.ts | 4 +- .../api-handler/src/handlers/post-letters.ts | 166 ++++++++++-------- lambdas/api-handler/src/utils/metrics.ts | 2 +- 3 files changed, 92 insertions(+), 80 deletions(-) diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index 4a7fa62d..acc3d44e 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -7,7 +7,7 @@ import { } from "../contracts/letters"; import { Deps } from "../config/deps"; import { mapToUpdateLetter } from "../mappers/letter-mapper"; -import { buildEMFObject } from "../utils/metrics"; +import { buildEMFObject, MetricEntry } from "../utils/metrics"; export default function createLetterStatusUpdateHandler( deps: Deps, @@ -40,7 +40,7 @@ export default function createLetterStatusUpdateHandler( } function emitAndFlushMetricLog(message: SQSRecord, logger: pino.Logger) { - const metric = { + const metric: MetricEntry = { key: "statusUpdateFailed", value: 1, unit: Unit.Count, diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index 28e67a65..87becbfe 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -1,5 +1,6 @@ import { APIGatewayProxyHandler } from "aws-lambda"; -import { MetricsLogger, Unit, metricScope } from "aws-embedded-metrics"; +import { Unit } from "aws-embedded-metrics"; +import pino from "pino"; import type { Deps } from "../config/deps"; import { ApiErrorDetail } from "../contracts/errors"; import { @@ -13,7 +14,7 @@ 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"; +import { MetricEntry, MetricStatus, buildEMFObject } from "../utils/metrics"; function duplicateIdsExist(postLettersRequest: PostLettersRequest) { const ids = postLettersRequest.data.map((item) => item.id); @@ -23,17 +24,23 @@ function duplicateIdsExist(postLettersRequest: PostLettersRequest) { /** * emits metrics of successful letter updates, including the supplier and grouped by status */ -function emitMetics( - metrics: MetricsLogger, +function emitSuccessMetrics( supplierId: string, statusesMapping: Map, + logger: pino.Logger, ) { for (const [status, count] of statusesMapping) { - metrics.putDimensions({ + const dimensions: Record = { supplier: supplierId, eventType: status, - }); - metrics.putMetric(MetricStatus.Success, count, Unit.Count); + }; + const metric: MetricEntry = { + key: "Letters posted", + value: count, + unit: Unit.Count, + }; + const emf = buildEMFObject("postLetters", dimensions, metric); + logger.info(emf); } } @@ -48,85 +55,90 @@ function populateStatusesMap(updateLetterCommands: UpdateLetterCommand[]) { export default function createPostLettersHandler( deps: Deps, ): APIGatewayProxyHandler { - return metricScope((metrics: MetricsLogger) => { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, - ); + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, + ); - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, - ); - } + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, + ); + } - const maxUpdateItems = requireEnvVar(deps.env, "MAX_LIMIT"); - requireEnvVar(deps.env, "QUEUE_URL"); + 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", + const { supplierId } = commonIds.value; + try { + const body = assertNotEmpty( + event.body, + new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), ); + + let postLettersRequest: PostLettersRequest; + try { - const body = assertNotEmpty( - event.body, - new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), - ); + postLettersRequest = PostLettersRequestSchema.parse(JSON.parse(body)); + } catch (error) { + const typedError = + error instanceof Error + ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { + cause: error, + }) + : error; + throw typedError; + } - let postLettersRequest: PostLettersRequest; + if (postLettersRequest.data.length > maxUpdateItems) { + throw new ValidationError( + ApiErrorDetail.InvalidRequestLettersToUpdate, + { args: [maxUpdateItems] }, + ); + } - try { - postLettersRequest = PostLettersRequestSchema.parse(JSON.parse(body)); - } catch (error) { - const typedError = - error instanceof Error - ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { - cause: error, - }) - : error; - throw typedError; - } + if (duplicateIdsExist(postLettersRequest)) { + throw new ValidationError( + ApiErrorDetail.InvalidRequestDuplicateLetterId, + ); + } - if (postLettersRequest.data.length > maxUpdateItems) { - throw new ValidationError( - ApiErrorDetail.InvalidRequestLettersToUpdate, - { args: [maxUpdateItems] }, - ); - } + const updateLetterCommands: UpdateLetterCommand[] = mapToUpdateCommands( + postLettersRequest, + supplierId, + ); + const statusesMapping = populateStatusesMap(updateLetterCommands); + await enqueueLetterUpdateRequests( + updateLetterCommands, + commonIds.value.correlationId, + deps, + ); - if (duplicateIdsExist(postLettersRequest)) { - throw new ValidationError( - ApiErrorDetail.InvalidRequestDuplicateLetterId, - ); - } + emitSuccessMetrics(supplierId, statusesMapping, deps.logger); + return { + statusCode: 202, + body: "", + }; + } catch (error) { + // error metrics + emitErrorMetrics(supplierId, deps.logger); - const updateLetterCommands: UpdateLetterCommand[] = mapToUpdateCommands( - postLettersRequest, - supplierId, - ); - const statusesMapping = populateStatusesMap(updateLetterCommands); - await enqueueLetterUpdateRequests( - updateLetterCommands, - commonIds.value.correlationId, - deps, - ); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; +} - 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); - } - }; - }); +function emitErrorMetrics(supplierId: string, logger: pino.Logger) { + const dimensions: Record = { supplier: supplierId }; + const metric: MetricEntry = { + key: MetricStatus.Failure, + value: 1, + unit: Unit.Count, + }; + const emf = buildEMFObject("postLetters", dimensions, metric); + logger.info(emf); } diff --git a/lambdas/api-handler/src/utils/metrics.ts b/lambdas/api-handler/src/utils/metrics.ts index c0925495..1142f63c 100644 --- a/lambdas/api-handler/src/utils/metrics.ts +++ b/lambdas/api-handler/src/utils/metrics.ts @@ -21,7 +21,7 @@ export enum MetricStatus { Failure = "failure", } -interface MetricEntry { +export interface MetricEntry { key: string; value: number; unit: Unit; From 56b8727c3cdbda046f3b30baf32f205983474213 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Wed, 11 Feb 2026 12:19:44 +0000 Subject: [PATCH 03/13] fix lint error --- lambdas/api-handler/src/handlers/letter-status-update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/api-handler/src/handlers/letter-status-update.ts b/lambdas/api-handler/src/handlers/letter-status-update.ts index e184c268..ffb6bd70 100644 --- a/lambdas/api-handler/src/handlers/letter-status-update.ts +++ b/lambdas/api-handler/src/handlers/letter-status-update.ts @@ -7,7 +7,7 @@ import { } from "../contracts/letters"; import { Deps } from "../config/deps"; import { mapToUpdateLetter } from "../mappers/letter-mapper"; -import { buildEMFObject, MetricEntry } from "../utils/metrics"; +import { MetricEntry, buildEMFObject } from "../utils/metrics"; export default function createLetterStatusUpdateHandler( deps: Deps, From a69a638f9d2183a94f626507dd1e221a834b8a47 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Thu, 12 Feb 2026 09:52:20 +0000 Subject: [PATCH 04/13] log process.env for lambda runtime environment --- lambdas/api-handler/src/handlers/post-letters.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index 8c9b7225..38812840 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -41,6 +41,7 @@ function emitSuccessMetrics( }; const emf = buildEMFObject("postLetters", dimensions, metric); logger.info(emf); + logger.info(`process.env: ${process.env}`); } } From aa23c01b950c1e446783bbb6fc2687dfb1a961f5 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Thu, 12 Feb 2026 10:23:35 +0000 Subject: [PATCH 05/13] stringify the process.env object --- lambdas/api-handler/src/handlers/post-letters.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index 38812840..9ecef650 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -41,7 +41,7 @@ function emitSuccessMetrics( }; const emf = buildEMFObject("postLetters", dimensions, metric); logger.info(emf); - logger.info(`process.env: ${process.env}`); + logger.info(`process.env: ${JSON.stringify(process.env)}`); } } From 8f3d6e33f9454c9af185b5d9aef1010d5298c9a3 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Thu, 12 Feb 2026 12:57:11 +0000 Subject: [PATCH 06/13] correct metrics for the rest of the lambdas --- .../api-handler/src/handlers/patch-letter.ts | 14 ++- .../api-handler/src/handlers/post-letters.ts | 3 +- lambdas/api-handler/src/handlers/post-mi.ts | 13 +-- lambdas/api-handler/src/utils/metrics.ts | 6 +- .../src/letter-updates-transformer.ts | 105 ++++++++++-------- .../src/mi-updates-transformer.ts | 83 ++++++++------ 6 files changed, 124 insertions(+), 100 deletions(-) diff --git a/lambdas/api-handler/src/handlers/patch-letter.ts b/lambdas/api-handler/src/handlers/patch-letter.ts index fe906d14..fc71b67d 100644 --- a/lambdas/api-handler/src/handlers/patch-letter.ts +++ b/lambdas/api-handler/src/handlers/patch-letter.ts @@ -56,6 +56,7 @@ export default function createPatchLetterHandler( try { patchLetterRequest = PatchLetterRequestSchema.parse(JSON.parse(body)); } catch (error) { + emitErrorMetric(metrics, supplierId); const typedError = error instanceof Error ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { @@ -79,6 +80,7 @@ export default function createPatchLetterHandler( ); if (updateLetterCommand.id !== letterId) { + emitErrorMetric(metrics, supplierId); throw new ValidationError( ApiErrorDetail.InvalidRequestLetterIdsMismatch, ); @@ -100,12 +102,16 @@ export default function createPatchLetterHandler( body: "", }; } catch (error) { - metrics.putDimensions({ - supplier: supplierId, - }); - metrics.putMetric(MetricStatus.Success, 1, Unit.Count); + emitErrorMetric(metrics, supplierId); return processError(error, commonIds.value.correlationId, deps.logger); } }; }); } + +function emitErrorMetric(metrics: MetricsLogger, supplierId: string) { + metrics.putDimensions({ + supplier: supplierId, + }); + metrics.putMetric(MetricStatus.Failure, 1, Unit.Count); +} diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index 9ecef650..bde478ea 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -32,7 +32,7 @@ function emitSuccessMetrics( for (const [status, count] of statusesMapping) { const dimensions: Record = { supplier: supplierId, - eventType: status, + status, }; const metric: MetricEntry = { key: "Letters posted", @@ -41,7 +41,6 @@ function emitSuccessMetrics( }; const emf = buildEMFObject("postLetters", dimensions, metric); logger.info(emf); - logger.info(`process.env: ${JSON.stringify(process.env)}`); } } diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts index 0d17512b..727a4eca 100644 --- a/lambdas/api-handler/src/handlers/post-mi.ts +++ b/lambdas/api-handler/src/handlers/post-mi.ts @@ -42,6 +42,7 @@ export default function createPostMIHandler( try { postMIRequest = PostMIRequestSchema.parse(JSON.parse(body)); } catch (error) { + emitErrorMetric(metrics, supplierId); const typedError = error instanceof Error ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { @@ -86,15 +87,13 @@ export default function createPostMIHandler( body: JSON.stringify(result, null, 2), }; } catch (error) { - emitForSingleSupplier( - metrics, - "postMi", - supplierId, - 1, - MetricStatus.Failure, - ); + emitErrorMetric(metrics, supplierId); return processError(error, commonIds.value.correlationId, deps.logger); } }; }); } + +function emitErrorMetric(metrics: MetricsLogger, supplierId: string) { + emitForSingleSupplier(metrics, "postMi", supplierId, 1, MetricStatus.Failure); +} diff --git a/lambdas/api-handler/src/utils/metrics.ts b/lambdas/api-handler/src/utils/metrics.ts index 1142f63c..4f820dda 100644 --- a/lambdas/api-handler/src/utils/metrics.ts +++ b/lambdas/api-handler/src/utils/metrics.ts @@ -43,8 +43,10 @@ export function buildEMFObject( CloudWatchMetrics: [ { Namespace: namespace, - Dimensions: [[...Object.keys(dimensions), "ServiceName", "LogGroup"]], - Metrics: [{ Name: metric.key, Unit: metric.value }], + Dimensions: [...Object.keys(dimensions), "ServiceName", "LogGroup"], + Metrics: [ + { Name: metric.key, Value: metric.value, Unit: metric.unit }, + ], }, ], }, diff --git a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts index 11b83c3b..5dc7966c 100644 --- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts +++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts @@ -10,7 +10,8 @@ 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 { Unit } from "aws-embedded-metrics"; +import pino from "pino"; import mapLetterToCloudEvent from "./mappers/letter-mapper"; import { Deps } from "./deps"; import { LetterForEventPub, LetterSchemaForEventPub } from "./types"; @@ -19,46 +20,42 @@ import { LetterForEventPub, LetterSchemaForEventPub } from "./types"; const BATCH_SIZE = 10; export default function createHandler(deps: Deps): Handler { - return metricScope((metrics: MetricsLogger) => { - return async (streamEvent: KinesisStreamEvent) => { - deps.logger.info({ description: "Received event", streamEvent }); + 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)); + + const eventTypeCount: Map = + populateEventTypeMap(cloudEvents); + for (const batch of generateBatches(cloudEvents)) { deps.logger.info({ - description: "Number of records", - count: streamEvent.Records?.length || 0, + description: "Publishing batch", + size: batch.length, + letterEvents: batch, }); - - // Ensure logging by extracting all records first - const ddbRecords: DynamoDBRecord[] = streamEvent.Records.map((record) => - extractPayload(record, deps), + await deps.snsClient.send( + new PublishBatchCommand({ + TopicArn: deps.env.EVENTPUB_SNS_TOPIC_ARN, + PublishBatchRequestEntries: batch.map((element, index) => + buildMessage(element, index), + ), + }), ); - - const cloudEvents: LetterEvent[] = ddbRecords - .filter((record) => filterRecord(record, deps)) - .map((element) => extractNewLetter(element)) - .map((element) => - mapLetterToCloudEvent(element, deps.env.EVENT_SOURCE), - ); - - const eventTypeCount: Map = - populateEventTypeMap(cloudEvents); - 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) => - buildMessage(element, index), - ), - }), - ); - } - emitMetrics(metrics, eventTypeCount); - }; - }); + } + emitMetrics(deps.logger, eventTypeCount); + }; } function populateEventTypeMap(cloudEvents: LetterEvent[]) { @@ -69,18 +66,30 @@ function populateEventTypeMap(cloudEvents: LetterEvent[]) { return evtMap; } -function emitMetrics( - metrics: MetricsLogger, - eventTypeCount: Map, -) { - metrics.setNamespace( - process.env.AWS_LAMBDA_FUNCTION_NAME || "letter-updates-transformer", - ); +function emitMetrics(logger: pino.Logger, eventTypeCount: Map) { + const namespace = + process.env.AWS_LAMBDA_FUNCTION_NAME || "letter-updates-transformer"; + for (const [type, count] of eventTypeCount) { - metrics.putDimensions({ + const emf = { + LogGroup: namespace, + ServiceName: namespace, eventType: type, - }); - metrics.putMetric("events published", count, Unit.Count); + _aws: { + Timestamp: Date.now(), + CloudWatchMetrics: [ + { + Namespace: namespace, + Dimensions: ["eventType", "ServiceName", "LogGroup"], + Metrics: [ + { Name: "events published", Value: count, Unit: Unit.Count }, + ], + }, + ], + }, + "events published": count, + }; + logger.info(emf); } } diff --git a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts index aa02fcdf..344dd937 100644 --- a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts +++ b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts @@ -11,7 +11,8 @@ 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 { Unit } from "aws-embedded-metrics"; +import pino from "pino"; import { mapMIToCloudEvent } from "./mappers/mi-mapper"; import { Deps } from "./deps"; @@ -50,49 +51,57 @@ function extractMIData(record: DynamoDBRecord): MI { return MISchema.parse(unmarshall(newImage as any)); } -function emitMetrics( - metrics: MetricsLogger, - eventTypeCount: Map, -) { - metrics.setNamespace( - process.env.AWS_LAMBDA_FUNCTION_NAME || "letter-updates-transformer", - ); +function emitMetrics(logger: pino.Logger, eventTypeCount: Map) { + const namespace = + process.env.AWS_LAMBDA_FUNCTION_NAME || "mi-updates-transformer"; for (const [type, count] of eventTypeCount) { - metrics.putDimensions({ + const emf = { + LogGroup: namespace, + ServiceName: namespace, eventType: type, - }); - metrics.putMetric("events published", count, Unit.Count); + _aws: { + Timestamp: Date.now(), + CloudWatchMetrics: [ + { + Namespace: namespace, + Dimensions: ["LogGroup", "ServiceName", "eventType"], + Metrics: [ + { Name: "events published", Value: count, Unit: Unit.Count }, + ], + }, + ], + }, + }; + logger.info(emf); } } export default function createHandler(deps: Deps): Handler { - return metricScope((metrics: MetricsLogger) => { - return async (streamEvent: KinesisStreamEvent) => { - deps.logger.info({ description: "Received event", streamEvent }); + 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)); - 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); - }), + 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); }), - ); - } - emitMetrics(metrics, eventTypeCount); - }; - }); + }), + ); + } + emitMetrics(deps.logger, eventTypeCount); + }; } From fa49df98fc4e96ab36b0a3ae3699a254e1e03d57 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Mon, 16 Feb 2026 12:42:34 +0000 Subject: [PATCH 07/13] add another dimension to postLetters success --- lambdas/api-handler/src/handlers/post-letters.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index bde478ea..2add4992 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -33,6 +33,7 @@ function emitSuccessMetrics( const dimensions: Record = { supplier: supplierId, status, + name: "vlasis", }; const metric: MetricEntry = { key: "Letters posted", From b0611b4462800d0721fb95385a2c7c719721a5e9 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Mon, 16 Feb 2026 13:09:45 +0000 Subject: [PATCH 08/13] correct dimensions creation --- lambdas/api-handler/src/handlers/post-letters.ts | 1 - lambdas/api-handler/src/utils/metrics.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index 2add4992..bde478ea 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -33,7 +33,6 @@ function emitSuccessMetrics( const dimensions: Record = { supplier: supplierId, status, - name: "vlasis", }; const metric: MetricEntry = { key: "Letters posted", diff --git a/lambdas/api-handler/src/utils/metrics.ts b/lambdas/api-handler/src/utils/metrics.ts index 4f820dda..1b3929b4 100644 --- a/lambdas/api-handler/src/utils/metrics.ts +++ b/lambdas/api-handler/src/utils/metrics.ts @@ -43,7 +43,7 @@ export function buildEMFObject( CloudWatchMetrics: [ { Namespace: namespace, - Dimensions: [...Object.keys(dimensions), "ServiceName", "LogGroup"], + Dimensions: [[...Object.keys(dimensions), "ServiceName", "LogGroup"]], Metrics: [ { Name: metric.key, Value: metric.value, Unit: metric.unit }, ], From 0a5c4f9fa5927eb9e4fc0b3c6110245d56167fc5 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Tue, 17 Feb 2026 11:34:06 +0000 Subject: [PATCH 09/13] clarify post-mi metrics --- .../api-handler/src/handlers/post-letters.ts | 2 +- lambdas/api-handler/src/handlers/post-mi.ts | 150 +++++++++--------- 2 files changed, 78 insertions(+), 74 deletions(-) diff --git a/lambdas/api-handler/src/handlers/post-letters.ts b/lambdas/api-handler/src/handlers/post-letters.ts index bde478ea..665a9d45 100644 --- a/lambdas/api-handler/src/handlers/post-letters.ts +++ b/lambdas/api-handler/src/handlers/post-letters.ts @@ -35,7 +35,7 @@ function emitSuccessMetrics( status, }; const metric: MetricEntry = { - key: "Letters posted", + key: MetricStatus.Success, value: count, unit: Unit.Count, }; diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts index 727a4eca..de04abe4 100644 --- a/lambdas/api-handler/src/handlers/post-mi.ts +++ b/lambdas/api-handler/src/handlers/post-mi.ts @@ -1,5 +1,6 @@ import { APIGatewayProxyHandler } from "aws-lambda"; -import { MetricsLogger, metricScope } from "aws-embedded-metrics"; +import { Unit } from "aws-embedded-metrics"; +import pino from "pino"; import postMIOperation from "../services/mi-operations"; import { ApiErrorDetail } from "../contracts/errors"; import ValidationError from "../errors/validation-error"; @@ -9,91 +10,94 @@ 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"; +import { MetricEntry, MetricStatus, buildEMFObject } from "../utils/metrics"; export default function createPostMIHandler( deps: Deps, ): APIGatewayProxyHandler { - return metricScope((metrics: MetricsLogger) => { - return async (event) => { - const commonIds = extractCommonIds( - event.headers, - event.requestContext, - deps, + return async (event) => { + const commonIds = extractCommonIds( + event.headers, + event.requestContext, + deps, + ); + + if (!commonIds.ok) { + return processError( + commonIds.error, + commonIds.correlationId, + deps.logger, ); + } - if (!commonIds.ok) { - return processError( - commonIds.error, - commonIds.correlationId, - deps.logger, - ); - } + const { supplierId } = commonIds.value; + try { + const body = assertNotEmpty( + event.body, + new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), + ); - const { supplierId } = commonIds.value; - try { - const body = assertNotEmpty( - event.body, - new ValidationError(ApiErrorDetail.InvalidRequestMissingBody), - ); + let postMIRequest: PostMIRequest; - let postMIRequest: PostMIRequest; + try { + postMIRequest = PostMIRequestSchema.parse(JSON.parse(body)); + } catch (error) { + emitErrorMetric(supplierId, deps.logger); + const typedError = + error instanceof Error + ? new ValidationError(ApiErrorDetail.InvalidRequestBody, { + cause: error, + }) + : error; + throw typedError; + } + validateIso8601Timestamp(postMIRequest.data.attributes.timestamp); - try { - postMIRequest = PostMIRequestSchema.parse(JSON.parse(body)); - } catch (error) { - emitErrorMetric(metrics, supplierId); - 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, + ); - const result = await postMIOperation( - mapToMI(postMIRequest, supplierId), - deps.miRepo, - ); + deps.logger.info({ + description: "Posted management information", + supplierId: commonIds.value.supplierId, + correlationId: commonIds.value.correlationId, + }); - deps.logger.info({ - description: "Posted management information", - supplierId: commonIds.value.supplierId, - correlationId: commonIds.value.correlationId, - }); + // metric with count 1 specifying the supplier + const dimensions: Record = { supplier: supplierId }; + const metric: MetricEntry = { + key: MetricStatus.Success, + value: 1, + unit: Unit.Count, + }; + let emf = buildEMFObject("postMi", dimensions, metric); + deps.logger.info(emf); - // 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 }, - ); + // metric displaying the type/number of lineItems posted per supplier + dimensions.lineItem = postMIRequest.data.attributes.lineItem; + metric.key = "LineItem per supplier"; + emf = buildEMFObject("postMi", dimensions, metric); + deps.logger.info(emf); - return { - statusCode: 201, - body: JSON.stringify(result, null, 2), - }; - } catch (error) { - emitErrorMetric(metrics, supplierId); - return processError(error, commonIds.value.correlationId, deps.logger); - } - }; - }); + return { + statusCode: 201, + body: JSON.stringify(result, null, 2), + }; + } catch (error) { + emitErrorMetric(supplierId, deps.logger); + return processError(error, commonIds.value.correlationId, deps.logger); + } + }; } -function emitErrorMetric(metrics: MetricsLogger, supplierId: string) { - emitForSingleSupplier(metrics, "postMi", supplierId, 1, MetricStatus.Failure); +function emitErrorMetric(supplierId: string, logger: pino.Logger) { + const dimensions: Record = { supplier: supplierId }; + const metric: MetricEntry = { + key: MetricStatus.Failure, + value: 1, + unit: Unit.Count, + }; + const emf = buildEMFObject("postMi", dimensions, metric); + logger.info(emf); } From e08cdb38b84bd074a95a3bebdd6b914c0123e8db Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Tue, 17 Feb 2026 11:58:58 +0000 Subject: [PATCH 10/13] record the quantity of the lineItems --- lambdas/api-handler/src/handlers/post-mi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts index de04abe4..82090855 100644 --- a/lambdas/api-handler/src/handlers/post-mi.ts +++ b/lambdas/api-handler/src/handlers/post-mi.ts @@ -77,6 +77,7 @@ export default function createPostMIHandler( // metric displaying the type/number of lineItems posted per supplier dimensions.lineItem = postMIRequest.data.attributes.lineItem; metric.key = "LineItem per supplier"; + metric.value = postMIRequest.data.attributes.quantity; emf = buildEMFObject("postMi", dimensions, metric); deps.logger.info(emf); From c996b082cabdaa891fc2dc571835403ab4d7e111 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Tue, 17 Feb 2026 12:27:31 +0000 Subject: [PATCH 11/13] correct dimensions in transformers --- .../src/letter-updates-transformer.ts | 2 +- lambdas/mi-updates-transformer/src/mi-updates-transformer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts index 5dc7966c..3d13d1bf 100644 --- a/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts +++ b/lambdas/letter-updates-transformer/src/letter-updates-transformer.ts @@ -80,7 +80,7 @@ function emitMetrics(logger: pino.Logger, eventTypeCount: Map) { CloudWatchMetrics: [ { Namespace: namespace, - Dimensions: ["eventType", "ServiceName", "LogGroup"], + Dimensions: [["eventType", "ServiceName", "LogGroup"]], Metrics: [ { Name: "events published", Value: count, Unit: Unit.Count }, ], diff --git a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts index 344dd937..e30910f3 100644 --- a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts +++ b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts @@ -64,7 +64,7 @@ function emitMetrics(logger: pino.Logger, eventTypeCount: Map) { CloudWatchMetrics: [ { Namespace: namespace, - Dimensions: ["LogGroup", "ServiceName", "eventType"], + Dimensions: [["LogGroup", "ServiceName", "eventType"]], Metrics: [ { Name: "events published", Value: count, Unit: Unit.Count }, ], From 29295a228837aa69c80f9d3e4df6f12879c34c79 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Tue, 17 Feb 2026 13:01:23 +0000 Subject: [PATCH 12/13] add metric name for mi-updates-transformer --- lambdas/mi-updates-transformer/src/mi-updates-transformer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts index e30910f3..c2524585 100644 --- a/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts +++ b/lambdas/mi-updates-transformer/src/mi-updates-transformer.ts @@ -71,6 +71,7 @@ function emitMetrics(logger: pino.Logger, eventTypeCount: Map) { }, ], }, + "events published": count, }; logger.info(emf); } From a50cc0034c3d33681d3f0129254bf7bf251b12f1 Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Tue, 17 Feb 2026 15:25:34 +0000 Subject: [PATCH 13/13] add metrics to amendment-event-transformer --- .../handlers/amendment-event-transformer.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/lambdas/api-handler/src/handlers/amendment-event-transformer.ts b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts index 2ae23333..1a390232 100644 --- a/lambdas/api-handler/src/handlers/amendment-event-transformer.ts +++ b/lambdas/api-handler/src/handlers/amendment-event-transformer.ts @@ -2,11 +2,14 @@ import { SQSBatchItemFailure, SQSEvent, SQSHandler } from "aws-lambda"; import { PublishCommand } from "@aws-sdk/client-sns"; import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; import { mapLetterToCloudEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-mapper"; +import { Unit } from "aws-embedded-metrics"; +import pino from "pino"; import { UpdateLetterCommand, UpdateLetterCommandSchema, } from "../contracts/letters"; import { Deps } from "../config/deps"; +import { MetricEntry, MetricStatus, buildEMFObject } from "../utils/metrics"; export default function createTransformAmendmentEventHandler( deps: Deps, @@ -39,6 +42,11 @@ export default function createTransformAmendmentEventHandler( messageId: message.messageId, correlationId: message.messageAttributes.CorrelationId.stringValue, }); + emitSuccessMetrics( + updateLetterCommand.supplierId, + updateLetterCommand.status, + deps.logger, + ); } catch (error) { deps.logger.error({ description: "Error processing letter status update", @@ -52,7 +60,7 @@ export default function createTransformAmendmentEventHandler( }); await Promise.all(tasks); - + emitFailedItems(batchItemFailures, deps.logger); return { batchItemFailures }; }; } @@ -66,3 +74,43 @@ function buildSnsCommand( Message: JSON.stringify(letterEvent), }); } + +function emitSuccessMetrics( + supplierId: string, + status: string, + logger: pino.Logger, +) { + const dimensions: Record = { + supplier: supplierId, + status, + }; + const metric: MetricEntry = { + key: MetricStatus.Success, + value: 1, + unit: Unit.Count, + }; + const emf = buildEMFObject("amendment-event-transformer", dimensions, metric); + logger.info(emf); +} + +function emitFailedItems( + batchFailures: SQSBatchItemFailure[], + logger: pino.Logger, +) { + for (const item of batchFailures) { + const dimensions: Record = { + identifier: item.itemIdentifier, + }; + const metric: MetricEntry = { + key: MetricStatus.Failure, + value: 1, + unit: Unit.Count, + }; + const emf = buildEMFObject( + "amendment-event-transformer", + dimensions, + metric, + ); + logger.info(emf); + } +}