Skip to content

Commit 770c5c4

Browse files
committed
Add allocation metrics
1 parent 0ece632 commit 770c5c4

4 files changed

Lines changed: 176 additions & 1 deletion

File tree

lambdas/supplier-allocator/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1": "npm:@nhsdigital/nhs-notify-event-schemas-letter-rendering@^1.1.5",
1010
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "^1.0.8",
1111
"@types/aws-lambda": "^8.10.148",
12+
"aws-embedded-metrics": "^4.2.1",
1213
"aws-lambda": "^1.0.7",
1314
"esbuild": "^0.27.2",
1415
"pino": "^9.7.0",

lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,39 @@ import {
77
$LetterEvent,
88
LetterEvent,
99
} from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events";
10+
import { MetricStatus } from "@internal/helpers";
1011
import createSupplierAllocatorHandler from "../allocate-handler";
1112
import { Deps } from "../../config/deps";
1213
import { EnvVars } from "../../config/env";
1314

15+
function assertMetricLogged(
16+
logger: pino.Logger,
17+
supplier: string,
18+
priority: string,
19+
status: MetricStatus,
20+
count: number,
21+
) {
22+
expect(logger.info).toHaveBeenCalledWith(
23+
expect.objectContaining({
24+
Supplier: supplier,
25+
Priority: priority,
26+
_aws: expect.objectContaining({
27+
CloudWatchMetrics: expect.arrayContaining([
28+
expect.objectContaining({
29+
Metrics: [
30+
expect.objectContaining({
31+
Name: status,
32+
Value: count,
33+
}),
34+
],
35+
}),
36+
]),
37+
}),
38+
[status]: count,
39+
}),
40+
);
41+
}
42+
1443
function createSQSEvent(records: SQSRecord[]): SQSEvent {
1544
return {
1645
Records: records,
@@ -182,6 +211,14 @@ describe("createSupplierAllocatorHandler", () => {
182211
specId: "spec1",
183212
priority: 10,
184213
});
214+
215+
assertMetricLogged(
216+
mockedDeps.logger,
217+
"supplier1",
218+
"10",
219+
MetricStatus.Success,
220+
1,
221+
);
185222
});
186223

187224
test("parses SNS notification and sends message to SQS queue for v1 event", async () => {
@@ -229,6 +266,14 @@ describe("createSupplierAllocatorHandler", () => {
229266
expect(result.batchItemFailures).toHaveLength(1);
230267
expect(result.batchItemFailures[0].itemIdentifier).toBe("invalid-event");
231268
expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1);
269+
270+
assertMetricLogged(
271+
mockedDeps.logger,
272+
"unknown",
273+
"unknown",
274+
MetricStatus.Failure,
275+
1,
276+
);
232277
});
233278

234279
test("unwraps EventBridge envelope and extracts event details", async () => {
@@ -398,6 +443,14 @@ describe("createSupplierAllocatorHandler", () => {
398443
expect(result.batchItemFailures).toHaveLength(1);
399444
expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1");
400445
expect((mockedDeps.logger.error as jest.Mock).mock.calls).toHaveLength(1);
446+
447+
assertMetricLogged(
448+
mockedDeps.logger,
449+
"supplier1",
450+
"10",
451+
MetricStatus.Failure,
452+
1,
453+
);
401454
});
402455

403456
test("processes mixed batch with successes and failures", async () => {
@@ -423,6 +476,21 @@ describe("createSupplierAllocatorHandler", () => {
423476
expect(result.batchItemFailures[0].itemIdentifier).toBe("fail-msg");
424477

425478
expect(mockSqsClient.send).toHaveBeenCalledTimes(2);
479+
480+
assertMetricLogged(
481+
mockedDeps.logger,
482+
"supplier1",
483+
"10",
484+
MetricStatus.Success,
485+
2,
486+
);
487+
assertMetricLogged(
488+
mockedDeps.logger,
489+
"unknown",
490+
"unknown",
491+
MetricStatus.Failure,
492+
1,
493+
);
426494
});
427495

428496
test("sends correct queue URL in SQS message command", async () => {
@@ -441,4 +509,48 @@ describe("createSupplierAllocatorHandler", () => {
441509
const sendCall = (mockSqsClient.send as jest.Mock).mock.calls[0][0];
442510
expect(sendCall.input.QueueUrl).toBe(queueUrl);
443511
});
512+
513+
test("emits separate metrics per supplier and priority combination", async () => {
514+
mockedDeps.env.VARIANT_MAP = {
515+
lv1: { supplierId: "supplier1", specId: "spec1", priority: 10 },
516+
lv2: { supplierId: "supplier2", specId: "spec2", priority: 5 },
517+
} as any;
518+
519+
const eventForSupplier1 = createPreparedV2Event({ domainId: "letter1" });
520+
const eventForSupplier2 = {
521+
...createPreparedV2Event({ domainId: "letter2" }),
522+
data: {
523+
...createPreparedV2Event({ domainId: "letter2" }).data,
524+
letterVariantId: "lv2",
525+
},
526+
};
527+
528+
const evt: SQSEvent = createSQSEvent([
529+
createSqsRecord("msg1", JSON.stringify(eventForSupplier1)),
530+
createSqsRecord("msg2", JSON.stringify(eventForSupplier2)),
531+
]);
532+
533+
process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue";
534+
535+
const handler = createSupplierAllocatorHandler(mockedDeps);
536+
const result = await handler(evt, {} as any, {} as any);
537+
if (!result) throw new Error("expected BatchResponse, got void");
538+
539+
expect(result.batchItemFailures).toHaveLength(0);
540+
541+
assertMetricLogged(
542+
mockedDeps.logger,
543+
"supplier1",
544+
"10",
545+
MetricStatus.Success,
546+
1,
547+
);
548+
assertMetricLogged(
549+
mockedDeps.logger,
550+
"supplier2",
551+
"5",
552+
MetricStatus.Success,
553+
1,
554+
);
555+
});
444556
});

lambdas/supplier-allocator/src/handler/allocate-handler.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas
44

55
import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering";
66
import z from "zod";
7+
import { Unit } from "aws-embedded-metrics";
8+
import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers";
79
import { Deps } from "../config/deps";
810

9-
type SupplierSpec = { supplierId: string; specId: string };
11+
type SupplierSpec = { supplierId: string; specId: string; priority: number };
1012
type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent;
1113

1214
// small envelope that must exist in all inputs
@@ -50,11 +52,63 @@ function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec {
5052
return resolveSupplierForVariant(letterEvent.data.letterVariantId, deps);
5153
}
5254

55+
type AllocationMetrics = Map<string, Map<string, number>>;
56+
57+
function incrementMetric(
58+
map: AllocationMetrics,
59+
supplier: string,
60+
priority: string,
61+
) {
62+
const byPriority = map.get(supplier) ?? new Map<string, number>();
63+
byPriority.set(priority, (byPriority.get(priority) ?? 0) + 1);
64+
map.set(supplier, byPriority);
65+
}
66+
67+
function emitMetrics(
68+
successMetrics: AllocationMetrics,
69+
failedMetrics: AllocationMetrics,
70+
deps: Deps,
71+
) {
72+
const namespace = "supplier-allocator";
73+
for (const [supplier, byPriority] of successMetrics) {
74+
for (const [priority, count] of byPriority) {
75+
const dimensions: Record<string, string> = {
76+
Priority: priority,
77+
Supplier: supplier,
78+
};
79+
const metric: MetricEntry = {
80+
key: MetricStatus.Success,
81+
value: count,
82+
unit: Unit.Count,
83+
};
84+
deps.logger.info(buildEMFObject(namespace, dimensions, metric));
85+
}
86+
}
87+
for (const [supplier, byPriority] of failedMetrics) {
88+
for (const [priority, count] of byPriority) {
89+
const dimensions: Record<string, string> = {
90+
Priority: priority,
91+
Supplier: supplier,
92+
};
93+
const metric: MetricEntry = {
94+
key: MetricStatus.Failure,
95+
value: count,
96+
unit: Unit.Count,
97+
};
98+
deps.logger.info(buildEMFObject(namespace, dimensions, metric));
99+
}
100+
}
101+
}
102+
53103
export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler {
54104
return async (event: SQSEvent) => {
55105
const batchItemFailures: SQSBatchItemFailure[] = [];
106+
const perAllocationSuccess: AllocationMetrics = new Map();
107+
const perAllocationFailure: AllocationMetrics = new Map();
56108

57109
const tasks = event.Records.map(async (record) => {
110+
let supplier = "unknown";
111+
let priority = "unknown";
58112
try {
59113
const letterEvent: unknown = JSON.parse(record.body);
60114

@@ -67,6 +121,9 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler {
67121

68122
const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps);
69123

124+
supplier = supplierSpec.supplierId;
125+
priority = String(supplierSpec.priority);
126+
70127
deps.logger.info({
71128
description: "Resolved supplier spec",
72129
supplierSpec,
@@ -95,19 +152,23 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler {
95152
MessageBody: JSON.stringify(queueMessage),
96153
}),
97154
);
155+
156+
incrementMetric(perAllocationSuccess, supplier, priority);
98157
} catch (error) {
99158
deps.logger.error({
100159
description: "Error processing allocation of record",
101160
err: error,
102161
messageId: record.messageId,
103162
message: record.body,
104163
});
164+
incrementMetric(perAllocationFailure, supplier, priority);
105165
batchItemFailures.push({ itemIdentifier: record.messageId });
106166
}
107167
});
108168

109169
await Promise.all(tasks);
110170

171+
emitMetrics(perAllocationSuccess, perAllocationFailure, deps);
111172
return { batchItemFailures };
112173
};
113174
}

package-lock.json

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

0 commit comments

Comments
 (0)