diff --git a/lambdas/authorizer/package.json b/lambdas/authorizer/package.json index 6c755d28..dfa02f97 100644 --- a/lambdas/authorizer/package.json +++ b/lambdas/authorizer/package.json @@ -4,6 +4,7 @@ "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", "@internal/helpers": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "0.27.2", "pino": "^10.3.0", diff --git a/lambdas/authorizer/src/__tests__/index.test.ts b/lambdas/authorizer/src/__tests__/index.test.ts index 4020b55e..cce287b3 100644 --- a/lambdas/authorizer/src/__tests__/index.test.ts +++ b/lambdas/authorizer/src/__tests__/index.test.ts @@ -4,11 +4,29 @@ import { Callback, Context, } from "aws-lambda"; +import { metricScope } from "aws-embedded-metrics"; import pino from "pino"; import { Deps } from "../deps"; import { EnvVars } from "../env"; import createAuthorizerHandler from "../authorizer"; +jest.mock("aws-embedded-metrics", () => { + const metricsMock = { + setNamespace: jest.fn(), + putMetric: jest.fn(), + }; + + return { + metricScope: jest.fn((handler) => async () => { + const wrapped = handler(metricsMock); + if (typeof wrapped === "function") { + await wrapped(); + } + }), + __metricsMock: metricsMock, + }; +}); + const mockedDeps: jest.Mocked = { logger: { info: jest.fn(), @@ -60,6 +78,13 @@ describe("Authorizer Lambda Function", () => { jest .useFakeTimers({ doNotFake: ["nextTick"] }) .setSystemTime(new Date("2025-11-03T14:19:00Z")); + (metricScope as jest.Mock).mockClear(); + (mockedDeps.logger.warn as jest.Mock).mockClear(); + const metricsMock = jest.requireMock( + "aws-embedded-metrics", + ).__metricsMock; + metricsMock.setNamespace.mockClear(); + metricsMock.putMetric.mockClear(); }); afterEach(() => { @@ -73,10 +98,7 @@ describe("Authorizer Lambda Function", () => { handler(mockEvent, mockContext, mockCallback); await new Promise(process.nextTick); - const mockedInfo = mockedDeps.logger.info as jest.Mock; - expect(mockedInfo.mock.calls).not.toContainEqual( - expect.stringContaining("CloudWatchMetrics"), - ); + expect(metricScope).not.toHaveBeenCalled(); }); it("Should log CloudWatch metric when the certificate expiry threshold is reached", async () => { @@ -88,29 +110,20 @@ describe("Authorizer Lambda Function", () => { handler(mockEvent, mockContext, mockCallback); await new Promise(process.nextTick); - const mockedInfo = mockedDeps.logger.info as jest.Mock; - expect(mockedInfo.mock.calls.map((call) => call[0])).toContain( - JSON.stringify({ - _aws: { - Timestamp: 1_762_179_540_000, - CloudWatchMetrics: [ - { - Namespace: "cloudwatch-namespace", - Dimensions: ["SUBJECT_DN", "NOT_AFTER"], - Metrics: [ - { - Name: "apim-client-certificate-near-expiry", - Unit: "Count", - Value: 1, - }, - ], - }, - ], - }, - SUBJECT_DN: "CN=test-subject", - NOT_AFTER: "2025-11-17T14:19:00Z", - "apim-client-certificate-near-expiry": 1, - }), + const metricsMock = jest.requireMock( + "aws-embedded-metrics", + ).__metricsMock; + + expect(metricScope).toHaveBeenCalledTimes(1); + expect(mockedDeps.logger.warn).toHaveBeenCalledWith({ + description: "APIM Certificate expiry", + days: 14, + }); + expect(metricsMock.setNamespace).toHaveBeenCalledWith("authorizer"); + expect(metricsMock.putMetric).toHaveBeenCalledWith( + "apim-client-certificate-near-expiry", + 14, + "Count", ); }); @@ -123,10 +136,7 @@ describe("Authorizer Lambda Function", () => { handler(mockEvent, mockContext, mockCallback); await new Promise(process.nextTick); - const mockedInfo = mockedDeps.logger.info as jest.Mock; - expect(mockedInfo.mock.calls).not.toContainEqual( - expect.stringContaining("CloudWatchMetrics"), - ); + expect(metricScope).not.toHaveBeenCalled(); }); }); diff --git a/lambdas/authorizer/src/authorizer.ts b/lambdas/authorizer/src/authorizer.ts index d5a0bd55..b26ddd25 100644 --- a/lambdas/authorizer/src/authorizer.ts +++ b/lambdas/authorizer/src/authorizer.ts @@ -7,6 +7,7 @@ import { Callback, Context, } from "aws-lambda"; +import { MetricsLogger, metricScope } from "aws-embedded-metrics"; import { Supplier } from "@internal/datastore"; import { Deps } from "./deps"; @@ -100,33 +101,6 @@ function getCertificateExpiryInDays( return (expiry - now) / (1000 * 60 * 60 * 24); } -function buildCloudWatchMetric( - namespace: string, - certificate: APIGatewayEventClientCertificate, -) { - return { - _aws: { - Timestamp: Date.now(), - CloudWatchMetrics: [ - { - Namespace: namespace, - Dimensions: ["SUBJECT_DN", "NOT_AFTER"], - Metrics: [ - { - Name: "apim-client-certificate-near-expiry", - Unit: "Count", - Value: 1, - }, - ], - }, - ], - }, - SUBJECT_DN: certificate.subjectDN, - NOT_AFTER: certificate.validity.notAfter, - "apim-client-certificate-near-expiry": 1, - }; -} - async function checkCertificateExpiry( certificate: APIGatewayEventClientCertificate | null, deps: Deps, @@ -146,10 +120,15 @@ async function checkCertificateExpiry( const expiry = getCertificateExpiryInDays(certificate); if (expiry <= deps.env.CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS) { - deps.logger.info( - JSON.stringify( - buildCloudWatchMetric(deps.env.CLOUDWATCH_NAMESPACE, certificate), - ), - ); + await metricScope((metrics: MetricsLogger) => async () => { + deps.logger.warn({ + description: "APIM Certificate expiry", + days: expiry, + }); + metrics.setNamespace( + process.env.AWS_LAMBDA_FUNCTION_NAME || "authorizer", + ); + metrics.putMetric("apim-client-certificate-near-expiry", expiry, "Count"); + })(); } } diff --git a/package-lock.json b/package-lock.json index d33bae5f..adf19f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -347,6 +347,7 @@ "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/datastore": "*", "@internal/helpers": "*", + "aws-embedded-metrics": "^4.2.1", "aws-lambda": "^1.0.7", "esbuild": "0.27.2", "pino": "^10.3.0",