From ec3537421efda835db1b6ef326833386fee9cc16 Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Fri, 24 Apr 2026 16:53:38 +0100 Subject: [PATCH 1/5] CCM-17116: Adding metrics to eventPublisher in nodejs. To add in other lambdas and in python CCM-17116: Updating dependency for the events publisher in the nodejs lambdas CCM-17116: replacing console.log as its wrapped by winston CCM-17116: Adding metric for total senders CCM-17116: Rename mesh metrics CCM-17116: Updated event_publisher to include the status when recording metrics CCM-17116: Updated metrics when recording events where the status can be different between events published --- .../terraform/components/dl/locals.tf | 1 + .../dl/module_lambda_core_notifier.tf | 1 + .../dl/module_lambda_file_scanner.tf | 2 + .../dl/module_lambda_mesh_acknowledge.tf | 1 + .../dl/module_lambda_mesh_download.tf | 8 +- .../components/dl/module_lambda_mesh_poll.tf | 21 +- .../dl/module_lambda_move_scanned_files.tf | 1 + .../dl/module_lambda_nhsapp_status_handler.tf | 2 + .../components/dl/module_lambda_pdm_poll.tf | 2 + .../dl/module_lambda_pdm_uploader.tf | 2 + .../dl/module_lambda_print_analyser.tf | 2 + .../dl/module_lambda_print_sender.tf | 1 + .../dl/module_lambda_print_status_handler.tf | 2 + .../module_lambda_report_event_transformer.tf | 3 + .../dl/module_lambda_report_generator.tf | 2 + .../dl/module_lambda_report_scheduler.tf | 1 + .../dl/module_lambda_report_sender.tf | 20 +- .../components/dl/module_lambda_ttl_create.tf | 1 + .../dl/module_lambda_ttl_handle_expiry.tf | 2 + .../components/dl/module_lambda_ttl_poll.tf | 10 +- .../src/__tests__/container.test.ts | 2 + .../src/__tests__/infra/config.test.ts | 11 +- lambdas/core-notifier-lambda/src/container.ts | 14 +- .../core-notifier-lambda/src/infra/config.ts | 2 + .../src/__tests__/container.test.ts | 4 + .../src/__tests__/index.test.ts | 4 + .../src/__tests__/infra/config.test.ts | 39 +- lambdas/file-scanner-lambda/src/container.ts | 6 + .../file-scanner-lambda/src/infra/config.ts | 14 + .../__tests__/test_handler.py | 1 + .../mesh_acknowledge/config.py | 21 +- .../mesh_acknowledge/handler.py | 1 + lambdas/mesh-download/mesh_download/config.py | 19 +- .../mesh-download/mesh_download/handler.py | 1 + .../mesh_poll/__tests__/test_processor.py | 85 +- lambdas/mesh-poll/mesh_poll/config.py | 42 +- lambdas/mesh-poll/mesh_poll/handler.py | 5 +- lambdas/mesh-poll/mesh_poll/processor.py | 11 + .../__tests__/app/move-file-handler.test.ts | 1 + .../src/__tests__/container.test.ts | 3 + .../src/__tests__/infra/config.test.ts | 17 +- .../src/container.ts | 17 +- .../src/infra/config.ts | 2 + .../src/__tests__/container.test.ts | 4 + .../nhsapp-status-handler/src/container.ts | 13 +- .../nhsapp-status-handler/src/infra/config.ts | 4 + .../src/__tests__/container.test.ts | 3 + lambdas/pdm-poll-lambda/src/container.ts | 6 + lambdas/pdm-poll-lambda/src/infra/config.ts | 4 + .../src/__tests__/container.test.ts | 3 + lambdas/pdm-uploader-lambda/src/container.ts | 6 + .../pdm-uploader-lambda/src/infra/config.ts | 4 + .../src/__tests__/container.test.ts | 3 + lambdas/print-analyser/src/container.ts | 18 +- lambdas/print-analyser/src/infra/config.ts | 4 + .../src/__tests__/container.test.ts | 5 + lambdas/print-sender-lambda/src/container.ts | 14 +- .../print-sender-lambda/src/infra/config.ts | 2 + .../src/__tests__/container.test.ts | 3 + lambdas/print-status-handler/src/container.ts | 18 +- .../print-status-handler/src/infra/config.ts | 4 + .../src/__tests__/container.test.ts | 3 + lambdas/report-generator/src/container.ts | 8 + lambdas/report-generator/src/infra/config.ts | 4 + .../apis/scheduled-event-handler.test.ts | 35 +- .../src/__tests__/container.test.ts | 3 + .../src/apis/scheduled-event-handler.ts | 5 +- lambdas/report-scheduler/src/container.ts | 15 +- lambdas/report-scheduler/src/infra/config.ts | 4 + lambdas/report-sender/report_sender/config.py | 4 +- .../report-sender/report_sender/handler.py | 3 +- .../src/__tests__/container.test.ts | 2 + lambdas/ttl-create-lambda/src/container.ts | 5 + lambdas/ttl-create-lambda/src/infra/config.ts | 2 + .../src/__tests__/container.test.ts | 4 + .../src/__tests__/index.test.ts | 1 + .../src/__tests__/infra/config.test.ts | 16 +- .../ttl-handle-expiry-lambda/src/container.ts | 20 +- .../src/infra/config.ts | 4 + package-lock.json | 1073 +++++++++-------- tests/playwright/helpers/event-bus-helpers.ts | 18 +- .../py-mock-mesh/py_mock_mesh/mesh_client.py | 20 + .../__tests__/test_event_publisher.py | 244 +++- utils/py-utils/dl_utils/event_publisher.py | 45 +- utils/py-utils/dl_utils/mesh_config.py | 6 +- utils/py-utils/dl_utils/metric_client.py | 10 +- utils/utils/package.json | 1 + .../cloudwatch/metric-handler.test.ts | 169 +++ .../event-publisher/event-publisher.test.ts | 167 ++- utils/utils/src/cloudwatch/index.ts | 1 + utils/utils/src/cloudwatch/metric-handler.ts | 100 ++ .../src/event-publisher/event-publisher.ts | 79 +- utils/utils/src/index.ts | 1 + 93 files changed, 2012 insertions(+), 585 deletions(-) create mode 100644 utils/utils/src/__tests__/cloudwatch/metric-handler.test.ts create mode 100644 utils/utils/src/cloudwatch/index.ts create mode 100644 utils/utils/src/cloudwatch/metric-handler.ts diff --git a/infrastructure/terraform/components/dl/locals.tf b/infrastructure/terraform/components/dl/locals.tf index f03b1bc1a..54dba8748 100644 --- a/infrastructure/terraform/components/dl/locals.tf +++ b/infrastructure/terraform/components/dl/locals.tf @@ -18,4 +18,5 @@ locals { unscanned_files_bucket = local.acct.additional_s3_buckets["digital-letters_unscanned-files"]["id"] bc_restricted_dev_role = try(tolist(data.aws_iam_roles.sso_bc_restricted_dev[0].arns)[0], null) + metrics_namespace_name = "nhs-${var.environment}-${var.component}" } diff --git a/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf b/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf index 4e0d8c59e..24284fede 100644 --- a/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf +++ b/infrastructure/terraform/components/dl/module_lambda_core_notifier.tf @@ -41,6 +41,7 @@ module "core_notifier" { "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url "ENVIRONMENT" = var.environment "NHS_APP_BASE_URL" = var.nhs_app_base_url + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf b/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf index bca3ff929..337ec7359 100644 --- a/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf +++ b/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf @@ -40,6 +40,8 @@ module "file_scanner" { "UNSCANNED_FILES_PATH_PREFIX" = local.csi "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_mesh_acknowledge.tf b/infrastructure/terraform/components/dl/module_lambda_mesh_acknowledge.tf index acbb5890c..38b2b6448 100644 --- a/infrastructure/terraform/components/dl/module_lambda_mesh_acknowledge.tf +++ b/infrastructure/terraform/components/dl/module_lambda_mesh_acknowledge.tf @@ -43,6 +43,7 @@ module "mesh_acknowledge" { SSM_MESH_PREFIX = local.ssm_mesh_prefix SSM_SENDERS_PREFIX = local.ssm_senders_prefix USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false" + DL_METRICS_NAMESPACE = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_mesh_download.tf b/infrastructure/terraform/components/dl/module_lambda_mesh_download.tf index ee77f050d..c126c38ef 100644 --- a/infrastructure/terraform/components/dl/module_lambda_mesh_download.tf +++ b/infrastructure/terraform/components/dl/module_lambda_mesh_download.tf @@ -37,10 +37,9 @@ module "mesh_download" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - DOWNLOAD_METRIC_NAME = "mesh-download-successful-downloads" - INTERNAL_DUPLICATE_DOWNLOAD_METRIC_NAME = "mesh-internal-duplicate-downloads" - TRUST_DUPLICATE_DOWNLOAD_METRIC_NAME = "mesh-trust-duplicate-downloads" - DOWNLOAD_METRIC_NAMESPACE = "dl-mesh-download" + DOWNLOAD_METRIC_NAME = "dl-mesh-download-successful-downloads" + INTERNAL_DUPLICATE_DOWNLOAD_METRIC_NAME = "dl-mesh-download-internal-duplicate-downloads" + TRUST_DUPLICATE_DOWNLOAD_METRIC_NAME = "dl-mesh-download-trust-duplicate-downloads" ENVIRONMENT = var.environment EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn @@ -48,6 +47,7 @@ module "mesh_download" { SSM_MESH_PREFIX = local.ssm_mesh_prefix SSM_SENDERS_PREFIX = local.ssm_senders_prefix USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false" + DL_METRICS_NAMESPACE = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf b/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf index a735c1b29..7f068717a 100644 --- a/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf @@ -37,17 +37,16 @@ module "mesh_poll" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - CERTIFICATE_EXPIRY_METRIC_NAME = "mesh-poll-client-certificate-near-expiry" - CERTIFICATE_EXPIRY_METRIC_NAMESPACE = "dl-mesh-poll" - ENVIRONMENT = var.environment - EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url - EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn - LAMBDA_TIMEOUT_BUFFER_MILLISECONDS = "60000" # 1 minute, to leave time for mesh-download to acknowledge the message before we run again. - POLLING_METRIC_NAME = "mesh-poll-successful-polls" - POLLING_METRIC_NAMESPACE = "dl-mesh-poll" - SSM_MESH_PREFIX = local.ssm_mesh_prefix - SSM_SENDERS_PREFIX = local.ssm_senders_prefix - USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false" + CERTIFICATE_EXPIRY_METRIC_NAME = "dl-mesh-poll-client-certificate-near-expiry" + ENVIRONMENT = var.environment + EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url + EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn + LAMBDA_TIMEOUT_BUFFER_MILLISECONDS = "60000" # 1 minute, to leave time for mesh-download to acknowledge the message before we run again. + POLLING_METRIC_NAME = "dl-mesh-poll-successful-polls" + SSM_MESH_PREFIX = local.ssm_mesh_prefix + SSM_SENDERS_PREFIX = local.ssm_senders_prefix + USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false" + DL_METRICS_NAMESPACE = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf b/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf index a26f4912f..7484fe44e 100644 --- a/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf +++ b/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf @@ -42,6 +42,7 @@ module "move_scanned_files" { "UNSCANNED_FILE_S3_BUCKET_NAME" = local.unscanned_files_bucket "SAFE_FILE_S3_BUCKET_NAME" = module.s3bucket_file_safe.bucket "QUARANTINE_FILE_S3_BUCKET_NAME" = module.s3bucket_file_quarantine.bucket + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf index a4e1d6b26..e656f1b3f 100644 --- a/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_nhsapp_status_handler.tf @@ -38,6 +38,8 @@ module "nhsapp_status_handler" { "TTL_TABLE_NAME" = aws_dynamodb_table.ttl.name "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf index 5d1126c01..a3b703a84 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_poll.tf @@ -40,6 +40,8 @@ module "pdm_poll" { "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url "POLL_MAX_RETRIES" = 10 + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_pdm_uploader.tf b/infrastructure/terraform/components/dl/module_lambda_pdm_uploader.tf index f76984ffc..5c3d305d9 100644 --- a/infrastructure/terraform/components/dl/module_lambda_pdm_uploader.tf +++ b/infrastructure/terraform/components/dl/module_lambda_pdm_uploader.tf @@ -39,6 +39,8 @@ module "pdm_uploader" { "APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME" = local.pdm_access_token_ssm_parameter_name "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf b/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf index 1c97647dc..a72acec32 100644 --- a/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf +++ b/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf @@ -37,6 +37,8 @@ module "print_analyser" { lambda_env_vars = { "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_print_sender.tf b/infrastructure/terraform/components/dl/module_lambda_print_sender.tf index a7c191c38..6f85b0604 100644 --- a/infrastructure/terraform/components/dl/module_lambda_print_sender.tf +++ b/infrastructure/terraform/components/dl/module_lambda_print_sender.tf @@ -39,6 +39,7 @@ module "print_sender" { "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url "ENVIRONMENT" = var.environment "ACCOUNT_TYPE" = var.aws_account_type + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf index 29c1a42af..245f42f2f 100644 --- a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf @@ -37,6 +37,8 @@ module "print_status_handler" { lambda_env_vars = { "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf b/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf index 83fda076e..fdb17fa36 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_event_transformer.tf @@ -29,4 +29,7 @@ module "report_event_transformer" { log_destination_arn = local.log_destination_arn log_subscription_role_arn = local.acct.log_subscription_role_arn + lambda_env_vars = { + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name + } } diff --git a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf index ca82ecdbe..e8fc2734b 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_generator.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_generator.tf @@ -42,6 +42,8 @@ module "report_generator" { "REPORTING_BUCKET" = module.s3bucket_reporting.bucket "REPORT_NAME" = "completed_communications" "WAIT_FOR_IN_SECONDS" = var.athena_query_polling_time_seconds + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_report_scheduler.tf b/infrastructure/terraform/components/dl/module_lambda_report_scheduler.tf index 047f8d244..67de1c80d 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_scheduler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_scheduler.tf @@ -39,6 +39,7 @@ module "report_scheduler" { "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_report_sender.tf b/infrastructure/terraform/components/dl/module_lambda_report_sender.tf index cc8ec956c..ba038392f 100644 --- a/infrastructure/terraform/components/dl/module_lambda_report_sender.tf +++ b/infrastructure/terraform/components/dl/module_lambda_report_sender.tf @@ -35,16 +35,16 @@ module "report_sender" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - REPORT_SENDER_METRIC_NAME = "report-sender-successful-sends" - REPORT_SENDER_METRIC_NAMESPACE = "dl-report-sender" - DLQ_URL = module.sqs_report_sender.sqs_dlq_url - ENVIRONMENT = var.environment - EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url - EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn - MOCK_MESH_BUCKET = module.s3bucket_non_pii_data.bucket - SSM_MESH_PREFIX = local.ssm_mesh_prefix - SSM_SENDERS_PREFIX = local.ssm_senders_prefix - USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false" + REPORT_SENDER_METRIC_NAME = "dl-report-sender-successful-sends" + DLQ_URL = module.sqs_report_sender.sqs_dlq_url + ENVIRONMENT = var.environment + EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url + EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn + MOCK_MESH_BUCKET = module.s3bucket_non_pii_data.bucket + SSM_MESH_PREFIX = local.ssm_mesh_prefix + SSM_SENDERS_PREFIX = local.ssm_senders_prefix + USE_MESH_MOCK = var.enable_mock_mesh ? "true" : "false" + DL_METRICS_NAMESPACE = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_ttl_create.tf b/infrastructure/terraform/components/dl/module_lambda_ttl_create.tf index 2f78f41f9..d0eac037e 100644 --- a/infrastructure/terraform/components/dl/module_lambda_ttl_create.tf +++ b/infrastructure/terraform/components/dl/module_lambda_ttl_create.tf @@ -40,6 +40,7 @@ module "ttl_create" { "TTL_SHARD_COUNT" = local.ttl_shard_count "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_ttl_handle_expiry.tf b/infrastructure/terraform/components/dl/module_lambda_ttl_handle_expiry.tf index ac51eae9d..08a35f25d 100644 --- a/infrastructure/terraform/components/dl/module_lambda_ttl_handle_expiry.tf +++ b/infrastructure/terraform/components/dl/module_lambda_ttl_handle_expiry.tf @@ -38,6 +38,8 @@ module "ttl_handle_expiry" { "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url "DLQ_URL" = module.sqs_ttl_handle_expiry_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/infrastructure/terraform/components/dl/module_lambda_ttl_poll.tf b/infrastructure/terraform/components/dl/module_lambda_ttl_poll.tf index 4ca436578..164d88e39 100644 --- a/infrastructure/terraform/components/dl/module_lambda_ttl_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_ttl_poll.tf @@ -36,10 +36,12 @@ module "ttl_poll" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - "TTL_TABLE_NAME" = aws_dynamodb_table.ttl.name - "CONCURRENCY" = 60 - "MAX_PROCESS_SECONDS" = 300 - "TTL_SHARD_COUNT" = local.ttl_shard_count + "TTL_TABLE_NAME" = aws_dynamodb_table.ttl.name + "CONCURRENCY" = 60 + "MAX_PROCESS_SECONDS" = 300 + "TTL_SHARD_COUNT" = local.ttl_shard_count + "ENVIRONMENT" = var.environment + "DL_METRICS_NAMESPACE" = local.metrics_namespace_name } } diff --git a/lambdas/core-notifier-lambda/src/__tests__/container.test.ts b/lambdas/core-notifier-lambda/src/__tests__/container.test.ts index 4c7b25a3a..bf1fcd19a 100644 --- a/lambdas/core-notifier-lambda/src/__tests__/container.test.ts +++ b/lambdas/core-notifier-lambda/src/__tests__/container.test.ts @@ -19,6 +19,7 @@ jest.mock('utils', () => ({ debug: jest.fn(), }, EventPublisher: jest.fn(), + MetricHandler: jest.fn(), eventBridgeClient: {}, sqsClient: {}, })); @@ -42,6 +43,7 @@ describe('createContainer', () => { apimBaseUrl: 'https://api.test.nhs.uk', environment: 'test', nhsAppBaseUrl: 'https://example.com', + dlMetricsNamespace: 'test-namespace', }; const mockSenderManagement = mock(); diff --git a/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts b/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts index 7e69debc8..897c7b0c9 100644 --- a/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts +++ b/lambdas/core-notifier-lambda/src/__tests__/infra/config.test.ts @@ -24,6 +24,7 @@ describe('loadConfig', () => { apimBaseUrl: 'https://api.test.nhs.uk', nhsAppBaseUrl: 'https://example.com', environment: 'test', + dlMetricsNamespace: 'test-namespace', }; mockGetValue @@ -32,12 +33,13 @@ describe('loadConfig', () => { .mockReturnValueOnce(mockConfig.apimAccessTokenSsmParameterName) .mockReturnValueOnce(mockConfig.apimBaseUrl) .mockReturnValueOnce(mockConfig.nhsAppBaseUrl) - .mockReturnValueOnce(mockConfig.environment); + .mockReturnValueOnce(mockConfig.environment) + .mockReturnValueOnce(mockConfig.dlMetricsNamespace); const result = loadConfig(); expect(result).toEqual(mockConfig); - expect(mockGetValue).toHaveBeenCalledTimes(6); + expect(mockGetValue).toHaveBeenCalledTimes(7); expect(mockGetValue).toHaveBeenNthCalledWith( 1, 'EVENT_PUBLISHER_EVENT_BUS_ARN', @@ -50,6 +52,7 @@ describe('loadConfig', () => { expect(mockGetValue).toHaveBeenNthCalledWith(4, 'APIM_BASE_URL'); expect(mockGetValue).toHaveBeenNthCalledWith(5, 'NHS_APP_BASE_URL'); expect(mockGetValue).toHaveBeenNthCalledWith(6, 'ENVIRONMENT'); + expect(mockGetValue).toHaveBeenNthCalledWith(7, 'DL_METRICS_NAMESPACE'); }); it('returns config with correct types', () => { @@ -59,7 +62,8 @@ describe('loadConfig', () => { .mockReturnValueOnce('/param') .mockReturnValueOnce('https://api') .mockReturnValueOnce('https://example.com') - .mockReturnValueOnce('prod'); + .mockReturnValueOnce('prod') + .mockReturnValueOnce('test-namespace'); const result: NotifySendMessageConfig = loadConfig(); @@ -69,5 +73,6 @@ describe('loadConfig', () => { expect(typeof result.apimBaseUrl).toBe('string'); expect(typeof result.nhsAppBaseUrl).toBe('string'); expect(typeof result.environment).toBe('string'); + expect(typeof result.dlMetricsNamespace).toBe('string'); }); }); diff --git a/lambdas/core-notifier-lambda/src/container.ts b/lambdas/core-notifier-lambda/src/container.ts index 378ebe5d9..2540b7865 100644 --- a/lambdas/core-notifier-lambda/src/container.ts +++ b/lambdas/core-notifier-lambda/src/container.ts @@ -1,5 +1,6 @@ import { EventPublisher, + MetricHandler, ParameterStoreCache, createGetApimAccessToken, eventBridgeClient, @@ -41,7 +42,17 @@ export async function createContainer(): Promise { logger, }); - const { eventPublisherDlqUrl, eventPublisherEventBusArn } = config; + const { + dlMetricsNamespace, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + } = config; + const metricHandler = new MetricHandler(dlMetricsNamespace, [ + { + Name: 'Environment', + Value: config.environment, + }, + ]); const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, @@ -49,6 +60,7 @@ export async function createContainer(): Promise { logger, sqsClient, eventBridgeClient, + metricHandler, }); const coreRequestMapper = new CoreRequestMapper(config.nhsAppBaseUrl); diff --git a/lambdas/core-notifier-lambda/src/infra/config.ts b/lambdas/core-notifier-lambda/src/infra/config.ts index 85f9afd77..4be2c001f 100644 --- a/lambdas/core-notifier-lambda/src/infra/config.ts +++ b/lambdas/core-notifier-lambda/src/infra/config.ts @@ -7,6 +7,7 @@ export type NotifySendMessageConfig = { apimBaseUrl: string; nhsAppBaseUrl: string; environment: string; + dlMetricsNamespace: string; }; export function loadConfig(): NotifySendMessageConfig { @@ -23,5 +24,6 @@ export function loadConfig(): NotifySendMessageConfig { apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'), nhsAppBaseUrl: defaultConfigReader.getValue('NHS_APP_BASE_URL'), environment: defaultConfigReader.getValue('ENVIRONMENT'), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/file-scanner-lambda/src/__tests__/container.test.ts b/lambdas/file-scanner-lambda/src/__tests__/container.test.ts index f4275bab7..4db8157ef 100644 --- a/lambdas/file-scanner-lambda/src/__tests__/container.test.ts +++ b/lambdas/file-scanner-lambda/src/__tests__/container.test.ts @@ -15,11 +15,13 @@ describe('createContainer', () => { it('should create container with all dependencies', () => { mockLoadConfig.mockReturnValue({ documentReferenceBucket: 'test-doc-ref-bucket', + environment: 'test', unscannedFilesBucket: 'test-unscanned-bucket', unscannedFilesPathPrefix: 'dev', eventPublisherEventBusArn: 'arn:aws:events:us-east-1:123456789012:event-bus/test', eventPublisherDlqUrl: 'https://sqs.us-east-1.amazonaws.com/dlq', + dlMetricsNamespace: 'test-namespace', }); const container = createContainer(); @@ -33,10 +35,12 @@ describe('createContainer', () => { it('should call loadConfig to get configuration', () => { const mockConfig = { documentReferenceBucket: 'test-bucket', + environment: 'test', unscannedFilesBucket: 'test-unscanned', unscannedFilesPathPrefix: 'dev', eventPublisherEventBusArn: 'arn:test', eventPublisherDlqUrl: 'url:test', + dlMetricsNamespace: 'test-namespace', }; mockLoadConfig.mockReturnValue(mockConfig); diff --git a/lambdas/file-scanner-lambda/src/__tests__/index.test.ts b/lambdas/file-scanner-lambda/src/__tests__/index.test.ts index 00232a5e9..f7fe480f2 100644 --- a/lambdas/file-scanner-lambda/src/__tests__/index.test.ts +++ b/lambdas/file-scanner-lambda/src/__tests__/index.test.ts @@ -1,11 +1,13 @@ // Set environment variables before any imports process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; +process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'test-prefix'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq'; +process.env.DL_METRICS_NAMESPACE = 'test-namespace'; // eslint-disable-next-line import-x/first import { handler } from '..'; @@ -13,10 +15,12 @@ import { handler } from '..'; describe('Lambda Handler', () => { afterAll(() => { delete process.env.DOCUMENT_REFERENCE_BUCKET; + delete process.env.ENVIRONMENT; delete process.env.UNSCANNED_FILES_BUCKET; delete process.env.UNSCANNED_FILES_PATH_PREFIX; delete process.env.EVENT_PUBLISHER_EVENT_BUS_ARN; delete process.env.EVENT_PUBLISHER_DLQ_URL; + delete process.env.DL_METRICS_NAMESPACE; }); it('should export handler function', () => { diff --git a/lambdas/file-scanner-lambda/src/__tests__/infra/config.test.ts b/lambdas/file-scanner-lambda/src/__tests__/infra/config.test.ts index 825baa305..a93e1fe01 100644 --- a/lambdas/file-scanner-lambda/src/__tests__/infra/config.test.ts +++ b/lambdas/file-scanner-lambda/src/__tests__/infra/config.test.ts @@ -14,49 +14,58 @@ describe('loadConfig', () => { it('should load valid configuration', () => { process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; const config = loadConfig(); expect(config).toEqual({ documentReferenceBucket: 'test-doc-ref-bucket', + environment: 'test', unscannedFilesBucket: 'test-unscanned-bucket', unscannedFilesPathPrefix: 'dev', eventPublisherEventBusArn: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', eventPublisherDlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', + dlMetricsNamespace: 'test-namespace', }); }); it('should throw error when DOCUMENT_REFERENCE_BUCKET is missing', () => { + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; - + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; expect(() => loadConfig()).toThrow('DOCUMENT_REFERENCE_BUCKET is not set'); }); it('should throw error when UNSCANNED_FILES_BUCKET is missing', () => { process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; expect(() => loadConfig()).toThrow('UNSCANNED_FILES_BUCKET is not set'); }); it('should throw error when UNSCANNED_FILES_PATH_PREFIX is missing', () => { process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; expect(() => loadConfig()).toThrow( 'UNSCANNED_FILES_PATH_PREFIX is not set', @@ -65,9 +74,11 @@ describe('loadConfig', () => { it('should throw error when EVENT_PUBLISHER_EVENT_BUS_ARN is missing', () => { process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; expect(() => loadConfig()).toThrow( 'EVENT_PUBLISHER_EVENT_BUS_ARN is not set', @@ -76,20 +87,46 @@ describe('loadConfig', () => { it('should throw error when EVENT_PUBLISHER_DLQ_URL is missing', () => { process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; expect(() => loadConfig()).toThrow('EVENT_PUBLISHER_DLQ_URL is not set'); }); it('should handle empty string values as missing', () => { process.env.DOCUMENT_REFERENCE_BUCKET = ''; + process.env.ENVIRONMENT = 'test'; process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; expect(() => loadConfig()).toThrow('DOCUMENT_REFERENCE_BUCKET is not set'); }); + + it('should throw error when DL_METRICS_NAMESPACE is missing', () => { + process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.ENVIRONMENT = 'test'; + process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; + process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; + process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; + process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; + + expect(() => loadConfig()).toThrow('DL_METRICS_NAMESPACE is not set'); + }); + + it('should throw error when ENVIRONMENT is missing', () => { + process.env.DOCUMENT_REFERENCE_BUCKET = 'test-doc-ref-bucket'; + process.env.UNSCANNED_FILES_BUCKET = 'test-unscanned-bucket'; + process.env.UNSCANNED_FILES_PATH_PREFIX = 'dev'; + process.env.EVENT_PUBLISHER_EVENT_BUS_ARN = 'arn:aws:events:test'; + process.env.EVENT_PUBLISHER_DLQ_URL = 'https://sqs.test.com/dlq'; + process.env.DL_METRICS_NAMESPACE = 'test-namespace'; + + expect(() => loadConfig()).toThrow('ENVIRONMENT is not set'); + }); }); diff --git a/lambdas/file-scanner-lambda/src/container.ts b/lambdas/file-scanner-lambda/src/container.ts index 106871151..9b20d4603 100644 --- a/lambdas/file-scanner-lambda/src/container.ts +++ b/lambdas/file-scanner-lambda/src/container.ts @@ -3,6 +3,7 @@ import { FileScanner } from 'app/file-scanner'; import { loadConfig } from 'infra/config'; import { EventPublisher, + MetricHandler, eventBridgeClient, logger, s3Client, @@ -11,7 +12,9 @@ import { export const createContainer = (): HandlerDependencies => { const { + dlMetricsNamespace, documentReferenceBucket, + environment, eventPublisherDlqUrl, eventPublisherEventBusArn, unscannedFilesBucket, @@ -24,6 +27,9 @@ export const createContainer = (): HandlerDependencies => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); const fileScanner = new FileScanner({ diff --git a/lambdas/file-scanner-lambda/src/infra/config.ts b/lambdas/file-scanner-lambda/src/infra/config.ts index 802931add..bdb5dbfa4 100644 --- a/lambdas/file-scanner-lambda/src/infra/config.ts +++ b/lambdas/file-scanner-lambda/src/infra/config.ts @@ -2,23 +2,31 @@ import { logger } from 'utils'; export interface Config { documentReferenceBucket: string; + environment: string; unscannedFilesBucket: string; unscannedFilesPathPrefix: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; } export function loadConfig(): Config { const documentReferenceBucket = process.env.DOCUMENT_REFERENCE_BUCKET; + const environment = process.env.ENVIRONMENT; const unscannedFilesBucket = process.env.UNSCANNED_FILES_BUCKET; const unscannedFilesPathPrefix = process.env.UNSCANNED_FILES_PATH_PREFIX; const eventPublisherEventBusArn = process.env.EVENT_PUBLISHER_EVENT_BUS_ARN; const eventPublisherDlqUrl = process.env.EVENT_PUBLISHER_DLQ_URL; + const dlMetricsNamespace = process.env.DL_METRICS_NAMESPACE; if (!documentReferenceBucket) { throw new Error('DOCUMENT_REFERENCE_BUCKET is not set'); } + if (!environment) { + throw new Error('ENVIRONMENT is not set'); + } + if (!unscannedFilesBucket) { throw new Error('UNSCANNED_FILES_BUCKET is not set'); } @@ -34,19 +42,25 @@ export function loadConfig(): Config { if (!eventPublisherDlqUrl) { throw new Error('EVENT_PUBLISHER_DLQ_URL is not set'); } + if (!dlMetricsNamespace) { + throw new Error('DL_METRICS_NAMESPACE is not set'); + } logger.info({ description: 'Configuration loaded', documentReferenceBucket, unscannedFilesBucket, unscannedFilesPathPrefix, + dlMetricsNamespace, }); return { documentReferenceBucket, + environment, unscannedFilesBucket, unscannedFilesPathPrefix, eventPublisherEventBusArn, eventPublisherDlqUrl, + dlMetricsNamespace, }; } diff --git a/lambdas/mesh-acknowledge/mesh_acknowledge/__tests__/test_handler.py b/lambdas/mesh-acknowledge/mesh_acknowledge/__tests__/test_handler.py index 31d1f72cf..1281d8b89 100644 --- a/lambdas/mesh-acknowledge/mesh_acknowledge/__tests__/test_handler.py +++ b/lambdas/mesh-acknowledge/mesh_acknowledge/__tests__/test_handler.py @@ -143,6 +143,7 @@ def test_handler_passes_correct_parameters( event_publisher_cls.assert_called_once_with( event_bus_arn=config.event_publisher_event_bus_arn, dlq_url=config.event_publisher_dlq_url, + event_metric=config.event_publisher_metric, logger=log, ) acknowledger_cls.assert_called_once_with( diff --git a/lambdas/mesh-acknowledge/mesh_acknowledge/config.py b/lambdas/mesh-acknowledge/mesh_acknowledge/config.py index c6fcef081..20c3fac0e 100644 --- a/lambdas/mesh-acknowledge/mesh_acknowledge/config.py +++ b/lambdas/mesh-acknowledge/mesh_acknowledge/config.py @@ -1,7 +1,7 @@ """ Module for configuring MESH Acknowledger application """ -from dl_utils import BaseMeshConfig +from dl_utils import BaseMeshConfig, Metric _REQUIRED_ENV_VAR_MAP = { "ssm_mesh_prefix": "SSM_MESH_PREFIX", @@ -9,6 +9,7 @@ "environment": "ENVIRONMENT", "event_publisher_event_bus_arn": "EVENT_PUBLISHER_EVENT_BUS_ARN", "event_publisher_dlq_url": "EVENT_PUBLISHER_DLQ_URL", + "dl_metrics_namespace": "DL_METRICS_NAMESPACE", "dlq_url": "DLQ_URL", } @@ -21,3 +22,21 @@ class Config(BaseMeshConfig): """ _REQUIRED_ENV_VAR_MAP = _REQUIRED_ENV_VAR_MAP + + def __init__(self, ssm=None): + super().__init__(ssm=ssm) + self.event_publisher_metric = None + + def __enter__(self): + super().__enter__() + self.event_publisher_metric = self.build_event_publisher_metric() + return self + + def build_event_publisher_metric(self): + """ + Returns a custom metric to record events published by the EventPublisher class + """ + return Metric( + namespace=self.dl_metrics_namespace, + dimensions={"Environment": self.environment} + ) diff --git a/lambdas/mesh-acknowledge/mesh_acknowledge/handler.py b/lambdas/mesh-acknowledge/mesh_acknowledge/handler.py index c3a367a58..61933e8ae 100644 --- a/lambdas/mesh-acknowledge/mesh_acknowledge/handler.py +++ b/lambdas/mesh-acknowledge/mesh_acknowledge/handler.py @@ -23,6 +23,7 @@ def handler(message: Dict[str, Any], _context: Any): event_publisher = EventPublisher( event_bus_arn=config.event_publisher_event_bus_arn, dlq_url=config.event_publisher_dlq_url, + event_metric=config.event_publisher_metric, logger=log ) acknowledger = MeshAcknowledger( diff --git a/lambdas/mesh-download/mesh_download/config.py b/lambdas/mesh-download/mesh_download/config.py index f537e5208..8f1aaee9b 100644 --- a/lambdas/mesh-download/mesh_download/config.py +++ b/lambdas/mesh-download/mesh_download/config.py @@ -11,7 +11,7 @@ "download_metric_name": "DOWNLOAD_METRIC_NAME", "internal_duplicate_download_metric_name": "INTERNAL_DUPLICATE_DOWNLOAD_METRIC_NAME", "trust_duplicate_download_metric_name": "TRUST_DUPLICATE_DOWNLOAD_METRIC_NAME", - "download_metric_namespace": "DOWNLOAD_METRIC_NAMESPACE", + "dl_metrics_namespace": "DL_METRICS_NAMESPACE", "event_publisher_event_bus_arn": "EVENT_PUBLISHER_EVENT_BUS_ARN", "event_publisher_dlq_url": "EVENT_PUBLISHER_DLQ_URL", "pii_bucket": "PII_BUCKET" @@ -32,6 +32,7 @@ def __init__(self, ssm=None, s3_client=None): self.download_metric = None self.internal_duplicate_download_metric = None self.trust_duplicate_download_metric = None + self.event_publisher_metric = None def __enter__(self): super().__enter__() @@ -40,6 +41,7 @@ def __enter__(self): self.download_metric = self.build_download_metric() self.internal_duplicate_download_metric = self.build_internal_duplicate_download_metric() self.trust_duplicate_download_metric = self.build_trust_duplicate_download_metric() + self.event_publisher_metric = self.build_event_publisher_metric() return self @@ -49,7 +51,7 @@ def build_download_metric(self): """ return Metric( name=self.download_metric_name, - namespace=self.download_metric_namespace, + namespace=self.dl_metrics_namespace, dimensions={"Environment": self.environment} ) @@ -59,7 +61,7 @@ def build_internal_duplicate_download_metric(self): """ return Metric( name=self.internal_duplicate_download_metric_name, - namespace=self.download_metric_namespace, + namespace=self.dl_metrics_namespace, dimensions={"Environment": self.environment} ) @@ -69,7 +71,16 @@ def build_trust_duplicate_download_metric(self): """ return Metric( name=self.trust_duplicate_download_metric_name, - namespace=self.download_metric_namespace, + namespace=self.dl_metrics_namespace, + dimensions={"Environment": self.environment} + ) + + def build_event_publisher_metric(self): + """ + Returns a custom metric to record events published by the EventPublisher class + """ + return Metric( + namespace=self.dl_metrics_namespace, dimensions={"Environment": self.environment} ) diff --git a/lambdas/mesh-download/mesh_download/handler.py b/lambdas/mesh-download/mesh_download/handler.py index 67c99c760..846946da1 100644 --- a/lambdas/mesh-download/mesh_download/handler.py +++ b/lambdas/mesh-download/mesh_download/handler.py @@ -36,6 +36,7 @@ def handler(event, context): event_publisher = EventPublisher( event_bus_arn=config.event_publisher_event_bus_arn, dlq_url=config.event_publisher_dlq_url, + event_metric=config.event_publisher_metric, logger=log ) diff --git a/lambdas/mesh-poll/mesh_poll/__tests__/test_processor.py b/lambdas/mesh-poll/mesh_poll/__tests__/test_processor.py index 4def4c696..0b337f644 100644 --- a/lambdas/mesh-poll/mesh_poll/__tests__/test_processor.py +++ b/lambdas/mesh-poll/mesh_poll/__tests__/test_processor.py @@ -26,13 +26,19 @@ def setup_mocks(): log = Mock() polling_metric = Mock() + messages_in_mailbox_metric = Mock() + finished_before_reading_all_messages_metric = Mock() + event_publisher_metric = Mock() return ( config, sender_lookup, mesh_client, log, - polling_metric + polling_metric, + messages_in_mailbox_metric, + finished_before_reading_all_messages_metric, + event_publisher_metric ) @@ -68,7 +74,7 @@ class TestMeshMessageProcessor: def test_process_messages_iterates_through_inbox(self, mock_event_publisher_class): """Test that processor iterates through all messages in MESH inbox""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message1 = setup_message_data("1") message2 = setup_message_data("2") @@ -78,10 +84,14 @@ def test_process_messages_iterates_through_inbox(self, mock_event_publisher_clas mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) mesh_client.iterate_all_messages.return_value = [message1, message2] + mesh_client.list_messages.return_value = ["messageId1", "messageId2"] sender_lookup.is_valid_sender.return_value = True processor.process_messages() @@ -90,10 +100,12 @@ def test_process_messages_iterates_through_inbox(self, mock_event_publisher_clas assert mesh_client.iterate_all_messages.call_count == 1 assert sender_lookup.is_valid_sender.call_count == 2 # Both messages validated polling_metric.record.assert_called_once() + messages_in_mailbox_metric.record.assert_called_once_with(2) + finished_before_reading_all_messages_metric.record.assert_not_called() def test_process_messages_stops_near_timeout(self, mock_event_publisher_class): """Test that processor stops processing when near timeout""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message1 = setup_message_data("1") mock_event_publisher = Mock() @@ -105,20 +117,26 @@ def test_process_messages_stops_near_timeout(self, mock_event_publisher_class): mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis_near_timeout, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) mesh_client.iterate_all_messages.return_value = [message1] + mesh_client.list_messages.return_value = ["messageId1"] processor.process_messages() sender_lookup.is_valid_sender.assert_not_called() mock_event_publisher.send_events.assert_not_called() # No events published when timeout polling_metric.record.assert_called_once() + messages_in_mailbox_metric.record.assert_called_once_with(1) + finished_before_reading_all_messages_metric.record.assert_called_once() def test_process_message_with_valid_sender(self, mock_event_publisher_class): """Test processing a single message from valid sender""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message = setup_message_data("1") mock_event_publisher = Mock() @@ -131,9 +149,14 @@ def test_process_message_with_valid_sender(self, mock_event_publisher_class): mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) + mesh_client.list_messages.return_value = ["messageId1"] + sender_lookup.is_valid_sender.return_value = True processor.process_message(message) @@ -145,7 +168,7 @@ def test_process_message_with_valid_sender(self, mock_event_publisher_class): def test_process_message_with_unknown_sender(self, mock_event_publisher_class): """Test that messages from unknown senders are rejected silently""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message = setup_message_data("1") mock_event_publisher = Mock() @@ -153,6 +176,7 @@ def test_process_message_with_unknown_sender(self, mock_event_publisher_class): # Invalid sender sender_lookup.is_valid_sender.return_value = False + mesh_client.list_messages.return_value = ["messageId1"] processor = MeshMessageProcessor( config=config, @@ -160,7 +184,10 @@ def test_process_message_with_unknown_sender(self, mock_event_publisher_class): mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) processor.process_message(message) @@ -174,12 +201,13 @@ def test_process_message_logs_error_on_event_publish_failure(self, mock_event_pu Test that processor logs error when event publishing fails and does not acknowledge message """ - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message = setup_message_data("1") mock_event_publisher = Mock() mock_event_publisher.send_events.return_value = [{"id": "failed-event-1"}] mock_event_publisher_class.return_value = mock_event_publisher + mesh_client.list_messages.return_value = ["messageId1"] sender_lookup.is_valid_sender.return_value = True sender_lookup.get_sender_id.return_value = "test_sender_id" @@ -190,7 +218,10 @@ def test_process_message_logs_error_on_event_publish_failure(self, mock_event_pu mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) processor.process_message(message) @@ -202,10 +233,11 @@ def test_process_message_logs_error_on_event_publish_failure(self, mock_event_pu def test_all_messages_are_processed_in_a_single_iteration(self, mock_event_publisher_class): """Test that processor processes all messages in a single iteration""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message1 = setup_message_data("1") message2 = setup_message_data("2") message3 = setup_message_data("3") + mesh_client.list_messages.return_value = ["messageId1", "messageId2", "messageId3"] mock_event_publisher = Mock() mock_event_publisher.send_events.return_value = [] # No failed events @@ -217,7 +249,10 @@ def test_all_messages_are_processed_in_a_single_iteration(self, mock_event_publi mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) mesh_client.iterate_all_messages.return_value = [message1, message2, message3] @@ -230,10 +265,13 @@ def test_all_messages_are_processed_in_a_single_iteration(self, mock_event_publi assert sender_lookup.is_valid_sender.call_count == 3 assert mock_event_publisher.send_events.call_count == 3 polling_metric.record.assert_called_once() + messages_in_mailbox_metric.record.assert_called_once_with(3) + finished_before_reading_all_messages_metric.record.assert_not_called() + def test_process_message_rejects_missing_local_id(self, mock_event_publisher_class): """Test that processor publishes MESHInboxMessageInvalid event for missing local_id""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message = setup_message_data("1") message.local_id = None @@ -250,7 +288,10 @@ def test_process_message_rejects_missing_local_id(self, mock_event_publisher_cla mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) processor.process_message(message) @@ -260,7 +301,7 @@ def test_process_message_rejects_missing_local_id(self, mock_event_publisher_cla def test_process_message_rejects_empty_local_id(self, mock_event_publisher_class): """Test that processor publishes MESHInboxMessageInvalid event for empty local_id""" - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message = setup_message_data("1") message.local_id = "" @@ -277,7 +318,10 @@ def test_process_message_rejects_empty_local_id(self, mock_event_publisher_class mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) processor.process_message(message) @@ -290,7 +334,7 @@ def test_process_message_rejects_whitespace_only_local_id(self, mock_event_publi Test that processor publishes MESHInboxMessageInvalid event for whitespace-only local_id """ - (config, sender_lookup, mesh_client, log, polling_metric) = setup_mocks() + (config, sender_lookup, mesh_client, log, polling_metric, messages_in_mailbox_metric, finished_before_reading_all_messages_metric, event_publisher_metric) = setup_mocks() message = setup_message_data("1") message.local_id = " " @@ -307,7 +351,10 @@ def test_process_message_rejects_whitespace_only_local_id(self, mock_event_publi mesh_client=mesh_client, get_remaining_time_in_millis=get_remaining_time_in_millis, log=log, - polling_metric=polling_metric + polling_metric=polling_metric, + messages_in_mailbox_metric=messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=finished_before_reading_all_messages_metric, + event_publisher_metric=event_publisher_metric ) processor.process_message(message) diff --git a/lambdas/mesh-poll/mesh_poll/config.py b/lambdas/mesh-poll/mesh_poll/config.py index 0bdc140e4..4491b5813 100644 --- a/lambdas/mesh-poll/mesh_poll/config.py +++ b/lambdas/mesh-poll/mesh_poll/config.py @@ -13,9 +13,8 @@ "event_bus_arn": "EVENT_PUBLISHER_EVENT_BUS_ARN", "event_publisher_dlq_url": "EVENT_PUBLISHER_DLQ_URL", "certificate_expiry_metric_name": "CERTIFICATE_EXPIRY_METRIC_NAME", - "certificate_expiry_metric_namespace": "CERTIFICATE_EXPIRY_METRIC_NAMESPACE", "polling_metric_name": "POLLING_METRIC_NAME", - "polling_metric_namespace": "POLLING_METRIC_NAMESPACE" + "dl_metrics_namespace": "DL_METRICS_NAMESPACE", } @@ -31,21 +30,56 @@ def __init__(self, ssm=None): super().__init__(ssm=ssm) self.polling_metric = None + self.messages_in_mailbox_metric = None + self.finished_before_reading_all_messages_metric = None + self.event_publisher_metric = None def __enter__(self): super().__enter__() # Build polling metric self.polling_metric = self.build_polling_metric() + self.messages_in_mailbox_metric = self.build_messages_in_mailbox_metric() + self.finished_before_reading_all_messages_metric = self.build_finished_before_reading_all_messages_metric() + self.event_publisher_metric = self.build_event_publisher_metric() return self def build_polling_metric(self): """ - Returns a custom metric to record messages found in the MESH inbox during polling + Returns a custom metric to record the poller finished succesfully. """ return Metric( name=self.polling_metric_name, - namespace=self.polling_metric_namespace, + namespace=self.dl_metrics_namespace, + dimensions={"Environment": self.environment} + ) + + def build_event_publisher_metric(self): + """ + Returns a custom metric to record event published by the EventPublisher class + """ + return Metric( + namespace=self.dl_metrics_namespace, + dimensions={"Environment": self.environment} + ) + + def build_messages_in_mailbox_metric(self): + """ + Returns a custom metric to record number of messages in the MESH mailbox + """ + return Metric( + name="dl-mesh-poll-messages-in-mailbox", + namespace=self.dl_metrics_namespace, + dimensions={"Environment": self.environment} + ) + + def build_finished_before_reading_all_messages_metric(self): + """ + Returns a custom metric to record that the poll lambda is exiting before processing all messages in the MESH inbox due to time constraints + """ + return Metric( + name="dl-mesh-poll-finished-before-reading-all-messages", + namespace=self.dl_metrics_namespace, dimensions={"Environment": self.environment} ) diff --git a/lambdas/mesh-poll/mesh_poll/handler.py b/lambdas/mesh-poll/mesh_poll/handler.py index 61d6738eb..6147bd88b 100644 --- a/lambdas/mesh-poll/mesh_poll/handler.py +++ b/lambdas/mesh-poll/mesh_poll/handler.py @@ -15,6 +15,9 @@ def handler(_, context): mesh_client=config.mesh_client, get_remaining_time_in_millis=context.get_remaining_time_in_millis, log=log, - polling_metric=config.polling_metric) + polling_metric=config.polling_metric, + messages_in_mailbox_metric=config.messages_in_mailbox_metric, + finished_before_reading_all_messages_metric=config.finished_before_reading_all_messages_metric, + event_publisher_metric=config.event_publisher_metric) processor.process_messages() diff --git a/lambdas/mesh-poll/mesh_poll/processor.py b/lambdas/mesh-poll/mesh_poll/processor.py index f492dad5f..9f9b7e360 100644 --- a/lambdas/mesh-poll/mesh_poll/processor.py +++ b/lambdas/mesh-poll/mesh_poll/processor.py @@ -27,6 +27,9 @@ def __init__(self, **kwargs): self.__get_remaining_time_in_millis = kwargs['get_remaining_time_in_millis'] self.__mesh_client.handshake() self.__polling_metric = kwargs['polling_metric'] + self.__messages_in_mailbox_metric = kwargs['messages_in_mailbox_metric'] + self.__finished_before_reading_all_messages_metric = kwargs['finished_before_reading_all_messages_metric'] + self.__event_publisher_metric = kwargs['event_publisher_metric'] environment = 'development' deployment = 'primary' @@ -39,6 +42,7 @@ def __init__(self, **kwargs): self.__event_publisher = EventPublisher( event_bus_arn=self.__config.event_bus_arn, dlq_url=self.__config.event_publisher_dlq_url, + event_metric=self.__event_publisher_metric, logger=self.__log ) @@ -57,6 +61,10 @@ def process_messages(self): """ self.__log.info('Polling for messages') + # Record how many messages are in the mailbox + remaining_messages = self.__mesh_client.list_messages() + self.__messages_in_mailbox_metric.record(len(remaining_messages)) + # Process all messages in the inbox message_count = 0 for message in self.__mesh_client.iterate_all_messages(): @@ -65,17 +73,20 @@ def process_messages(self): self.__log.info( 'Not enough time to process more files. Exiting') self.__polling_metric.record(1) + self.__finished_before_reading_all_messages_metric.record(1) return self.process_message(message) if message_count == 0: self.__log.info('No messages found in inbox') + self.__messages_in_mailbox_metric.record(0) else: self.__log.info(f'Processed {message_count} message(s)') self.__polling_metric.record(1) + def process_message(self, message): """ Processes an individual message from a MESH inbox - validates sender and publishes event diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts index 27d54fc02..2dd7188c3 100644 --- a/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts +++ b/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts @@ -65,6 +65,7 @@ describe('MoveFileHandler', () => { unscannedFileS3BucketName: 'unscanned-bucket', safeFileS3BucketName: 'safe-bucket', quarantineFileS3BucketName: 'quarantine-bucket', + dlMetricsNamespace: 'test-namespace', }; const mockCopyAndDeleteObjectS3 = jest.mocked(utils.copyAndDeleteObjectS3); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts index c0008c762..3e552c10d 100644 --- a/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts +++ b/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts @@ -12,6 +12,7 @@ jest.mock('utils', () => ({ debug: jest.fn(), }, EventPublisher: jest.fn(), + MetricHandler: jest.fn(), eventBridgeClient: {}, sqsClient: {}, })); @@ -25,6 +26,7 @@ describe('createContainer', () => { 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus', eventPublisherDlqUrl: 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq', + dlMetricsNamespace: 'test-namespace', environment: 'test', keyPrefixUnscannedFiles: 'dl/', unscannedFileS3BucketName: 'unscanned-bucket', @@ -79,6 +81,7 @@ describe('createContainer', () => { logger, sqsClient: expect.any(Object), eventBridgeClient: expect.any(Object), + metricHandler: expect.any(Object), }), ); }); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts index 15b9f91b5..8c3a01879 100644 --- a/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts +++ b/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts @@ -20,6 +20,7 @@ describe('loadConfig', () => { 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus', eventPublisherDlqUrl: 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq', + dlMetricsNamespace: 'test-namespace', environment: 'test', keyPrefixUnscannedFiles: 'dl/', unscannedFileS3BucketName: 'unscanned-bucket', @@ -30,6 +31,7 @@ describe('loadConfig', () => { mockGetValue .mockReturnValueOnce(mockConfig.eventPublisherEventBusArn) .mockReturnValueOnce(mockConfig.eventPublisherDlqUrl) + .mockReturnValueOnce(mockConfig.dlMetricsNamespace) .mockReturnValueOnce(mockConfig.keyPrefixUnscannedFiles) .mockReturnValueOnce(mockConfig.unscannedFileS3BucketName) .mockReturnValueOnce(mockConfig.safeFileS3BucketName) @@ -39,32 +41,34 @@ describe('loadConfig', () => { const result = loadConfig(); expect(result).toEqual(mockConfig); - expect(mockGetValue).toHaveBeenCalledTimes(7); + expect(mockGetValue).toHaveBeenCalledTimes(8); expect(mockGetValue).toHaveBeenNthCalledWith( 1, 'EVENT_PUBLISHER_EVENT_BUS_ARN', ); expect(mockGetValue).toHaveBeenNthCalledWith(2, 'EVENT_PUBLISHER_DLQ_URL'); + expect(mockGetValue).toHaveBeenNthCalledWith(3, 'DL_METRICS_NAMESPACE'); expect(mockGetValue).toHaveBeenNthCalledWith( - 3, + 4, 'KEY_PREFIX_UNSCANNED_FILES', ); expect(mockGetValue).toHaveBeenNthCalledWith( - 4, + 5, 'UNSCANNED_FILE_S3_BUCKET_NAME', ); - expect(mockGetValue).toHaveBeenNthCalledWith(5, 'SAFE_FILE_S3_BUCKET_NAME'); + expect(mockGetValue).toHaveBeenNthCalledWith(6, 'SAFE_FILE_S3_BUCKET_NAME'); expect(mockGetValue).toHaveBeenNthCalledWith( - 6, + 7, 'QUARANTINE_FILE_S3_BUCKET_NAME', ); - expect(mockGetValue).toHaveBeenNthCalledWith(7, 'ENVIRONMENT'); + expect(mockGetValue).toHaveBeenNthCalledWith(8, 'ENVIRONMENT'); }); it('returns config with correct types', () => { mockGetValue .mockReturnValueOnce('arn:test') .mockReturnValueOnce('https://dlq') + .mockReturnValueOnce('test-namespace') .mockReturnValueOnce('dl/') .mockReturnValueOnce('unscanned-bucket') .mockReturnValueOnce('safe-bucket') @@ -75,6 +79,7 @@ describe('loadConfig', () => { expect(typeof result.eventPublisherEventBusArn).toBe('string'); expect(typeof result.eventPublisherDlqUrl).toBe('string'); + expect(typeof result.dlMetricsNamespace).toBe('string'); expect(typeof result.environment).toBe('string'); expect(typeof result.keyPrefixUnscannedFiles).toBe('string'); expect(typeof result.unscannedFileS3BucketName).toBe('string'); diff --git a/lambdas/move-scanned-files-lambda/src/container.ts b/lambdas/move-scanned-files-lambda/src/container.ts index 868249f9a..51508f9de 100644 --- a/lambdas/move-scanned-files-lambda/src/container.ts +++ b/lambdas/move-scanned-files-lambda/src/container.ts @@ -1,4 +1,10 @@ -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + EventPublisher, + MetricHandler, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; import type { SqsHandlerDependencies } from 'apis/sqs-handler'; import { loadConfig } from 'infra/config'; import { MoveFileHandler } from 'app/move-file-handler'; @@ -6,7 +12,11 @@ import { MoveFileHandler } from 'app/move-file-handler'; export async function createContainer(): Promise { const config = loadConfig(); - const { eventPublisherDlqUrl, eventPublisherEventBusArn } = config; + const { + dlMetricsNamespace, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + } = config; const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, @@ -14,6 +24,9 @@ export async function createContainer(): Promise { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: config.environment }, + ]), }); const moveFileHandler = new MoveFileHandler(logger, config); diff --git a/lambdas/move-scanned-files-lambda/src/infra/config.ts b/lambdas/move-scanned-files-lambda/src/infra/config.ts index 96702b6d0..6454f3b5d 100644 --- a/lambdas/move-scanned-files-lambda/src/infra/config.ts +++ b/lambdas/move-scanned-files-lambda/src/infra/config.ts @@ -3,6 +3,7 @@ import { defaultConfigReader } from 'utils'; export type MoveScannedFilesConfig = { eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; environment: string; unscannedFileS3BucketName: string; safeFileS3BucketName: string; @@ -18,6 +19,7 @@ export function loadConfig(): MoveScannedFilesConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), // There is a limitation of how many buckets can be scanned with GuardDuty per account. // As DL will share the same bucket with other services, this is a safeguard to only process events for files for digital letters. keyPrefixUnscannedFiles: defaultConfigReader.getValue( diff --git a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts b/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts index d52ce0228..2f9aa0fb1 100644 --- a/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts +++ b/lambdas/nhsapp-status-handler/src/__tests__/container.test.ts @@ -2,8 +2,10 @@ import { createContainer } from 'container'; jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', ttlTableName: 'test-table', })), })); @@ -18,9 +20,11 @@ jest.mock('app/ttl-actions', () => ({ jest.mock('utils', () => ({ EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), dynamoDocumentClient: {}, eventBridgeClient: {}, logger: {}, + sqsClient: {}, })); describe('container', () => { diff --git a/lambdas/nhsapp-status-handler/src/container.ts b/lambdas/nhsapp-status-handler/src/container.ts index 132a9f1d2..4f829ccf1 100644 --- a/lambdas/nhsapp-status-handler/src/container.ts +++ b/lambdas/nhsapp-status-handler/src/container.ts @@ -1,5 +1,6 @@ import { EventPublisher, + MetricHandler, dynamoDocumentClient, eventBridgeClient, logger, @@ -10,8 +11,13 @@ import { TtlRepository } from 'infra/ttl-repository'; import { TtlActions } from 'app/ttl-actions'; export const createContainer = () => { - const { eventPublisherDlqUrl, eventPublisherEventBusArn, ttlTableName } = - loadConfig(); + const { + dlMetricsNamespace, + environment, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + ttlTableName, + } = loadConfig(); const requestTtlRepository = new TtlRepository( ttlTableName, @@ -26,6 +32,9 @@ export const createContainer = () => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); return { diff --git a/lambdas/nhsapp-status-handler/src/infra/config.ts b/lambdas/nhsapp-status-handler/src/infra/config.ts index d2122e00e..3f81612a2 100644 --- a/lambdas/nhsapp-status-handler/src/infra/config.ts +++ b/lambdas/nhsapp-status-handler/src/infra/config.ts @@ -1,13 +1,16 @@ import { defaultConfigReader } from 'utils'; export type TtlCreateConfig = { + environment: string; ttlTableName: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): TtlCreateConfig { return { + environment: defaultConfigReader.getValue('ENVIRONMENT'), ttlTableName: defaultConfigReader.getValue('TTL_TABLE_NAME'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', @@ -15,5 +18,6 @@ export function loadConfig(): TtlCreateConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts index 8833bc850..a8b362e93 100644 --- a/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-poll-lambda/src/__tests__/container.test.ts @@ -4,8 +4,10 @@ jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ apimBaseUrl: 'https://test-apim-url', apimAccessTokenSsmParameterName: 'test-ssm-parameter-name', + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', maxPollCount: 10, })), })); @@ -14,6 +16,7 @@ jest.mock('utils', () => ({ createGetApimAccessToken: jest.fn(() => ({})), eventBridgeClient: {}, EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), logger: {}, ParameterStoreCache: jest.fn(() => ({})), PdmClient: jest.fn(() => ({})), diff --git a/lambdas/pdm-poll-lambda/src/container.ts b/lambdas/pdm-poll-lambda/src/container.ts index d8514aa84..3efc05781 100644 --- a/lambdas/pdm-poll-lambda/src/container.ts +++ b/lambdas/pdm-poll-lambda/src/container.ts @@ -3,6 +3,7 @@ import { Pdm } from 'app/pdm'; import { loadConfig } from 'infra/config'; import { EventPublisher, + MetricHandler, ParameterStoreCache, PdmClient, createGetApimAccessToken, @@ -15,6 +16,8 @@ export const createContainer = (): HandlerDependencies => { const { apimAccessTokenSsmParameterName, apimBaseUrl, + dlMetricsNamespace, + environment, eventPublisherDlqUrl, eventPublisherEventBusArn, pollMaxRetries, @@ -26,6 +29,9 @@ export const createContainer = (): HandlerDependencies => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); const parameterStore = new ParameterStoreCache(); diff --git a/lambdas/pdm-poll-lambda/src/infra/config.ts b/lambdas/pdm-poll-lambda/src/infra/config.ts index e40455ff8..45ee2850f 100644 --- a/lambdas/pdm-poll-lambda/src/infra/config.ts +++ b/lambdas/pdm-poll-lambda/src/infra/config.ts @@ -2,15 +2,18 @@ import { defaultConfigReader } from 'utils'; export type PdmCreateConfig = { apimBaseUrl: string; + environment: string; pollMaxRetries: number; apimAccessTokenSsmParameterName: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): PdmCreateConfig { return { apimBaseUrl: defaultConfigReader.getValue('APIM_BASE_URL'), + environment: defaultConfigReader.getValue('ENVIRONMENT'), pollMaxRetries: defaultConfigReader.getInt('POLL_MAX_RETRIES'), apimAccessTokenSsmParameterName: defaultConfigReader.getValue( 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', @@ -21,5 +24,6 @@ export function loadConfig(): PdmCreateConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts b/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts index de443d4a6..23a03094f 100644 --- a/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts +++ b/lambdas/pdm-uploader-lambda/src/__tests__/container.test.ts @@ -4,8 +4,10 @@ jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ apimBaseUrl: 'https://test-apim-url', apimAccessTokenSsmParameterName: 'test-ssm-parameter-name', + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', })), })); @@ -17,6 +19,7 @@ jest.mock('utils', () => ({ createGetApimAccessToken: jest.fn(() => ({})), eventBridgeClient: {}, EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), logger: {}, ParameterStoreCache: jest.fn(() => ({})), PdmClient: jest.fn(() => ({})), diff --git a/lambdas/pdm-uploader-lambda/src/container.ts b/lambdas/pdm-uploader-lambda/src/container.ts index bdca7a878..54c19249a 100644 --- a/lambdas/pdm-uploader-lambda/src/container.ts +++ b/lambdas/pdm-uploader-lambda/src/container.ts @@ -1,5 +1,6 @@ import { EventPublisher, + MetricHandler, ParameterStoreCache, PdmClient, createGetApimAccessToken, @@ -14,6 +15,8 @@ export const createContainer = () => { const { apimAccessTokenSsmParameterName, apimBaseUrl, + dlMetricsNamespace, + environment, eventPublisherDlqUrl, eventPublisherEventBusArn, } = loadConfig(); @@ -38,6 +41,9 @@ export const createContainer = () => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); return { diff --git a/lambdas/pdm-uploader-lambda/src/infra/config.ts b/lambdas/pdm-uploader-lambda/src/infra/config.ts index 2005ccdf3..56883c85d 100644 --- a/lambdas/pdm-uploader-lambda/src/infra/config.ts +++ b/lambdas/pdm-uploader-lambda/src/infra/config.ts @@ -3,8 +3,10 @@ import { defaultConfigReader } from 'utils'; export type PdmCreateConfig = { apimBaseUrl: string; apimAccessTokenSsmParameterName: string; + environment: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): PdmCreateConfig { @@ -13,11 +15,13 @@ export function loadConfig(): PdmCreateConfig { apimAccessTokenSsmParameterName: defaultConfigReader.getValue( 'APIM_ACCESS_TOKEN_SSM_PARAMETER_NAME', ), + environment: defaultConfigReader.getValue('ENVIRONMENT'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ), eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/print-analyser/src/__tests__/container.test.ts b/lambdas/print-analyser/src/__tests__/container.test.ts index 64f1a694d..75af881d3 100644 --- a/lambdas/print-analyser/src/__tests__/container.test.ts +++ b/lambdas/print-analyser/src/__tests__/container.test.ts @@ -2,14 +2,17 @@ import { createContainer } from 'container'; jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', })), })); jest.mock('utils', () => ({ eventBridgeClient: {}, EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), logger: {}, sqsClient: {}, })); diff --git a/lambdas/print-analyser/src/container.ts b/lambdas/print-analyser/src/container.ts index 7fe49378d..971ebe65b 100644 --- a/lambdas/print-analyser/src/container.ts +++ b/lambdas/print-analyser/src/container.ts @@ -1,9 +1,20 @@ import { HandlerDependencies } from 'apis/sqs-handler'; import { loadConfig } from 'infra/config'; -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + EventPublisher, + MetricHandler, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; export const createContainer = (): HandlerDependencies => { - const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); + const { + dlMetricsNamespace, + environment, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + } = loadConfig(); const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, @@ -11,6 +22,9 @@ export const createContainer = (): HandlerDependencies => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); return { eventPublisher, logger }; diff --git a/lambdas/print-analyser/src/infra/config.ts b/lambdas/print-analyser/src/infra/config.ts index 855e66108..059052dad 100644 --- a/lambdas/print-analyser/src/infra/config.ts +++ b/lambdas/print-analyser/src/infra/config.ts @@ -1,17 +1,21 @@ import { defaultConfigReader } from 'utils'; export type Config = { + environment: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): Config { return { + environment: defaultConfigReader.getValue('ENVIRONMENT'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ), eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/print-sender-lambda/src/__tests__/container.test.ts b/lambdas/print-sender-lambda/src/__tests__/container.test.ts index 4715eb42f..ee71cb0b1 100644 --- a/lambdas/print-sender-lambda/src/__tests__/container.test.ts +++ b/lambdas/print-sender-lambda/src/__tests__/container.test.ts @@ -4,6 +4,9 @@ jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', + environment: 'test', + accountType: 'test-account', })), })); @@ -13,8 +16,10 @@ jest.mock('app/print-sender', () => ({ jest.mock('utils', () => ({ EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), eventBridgeClient: {}, logger: {}, + sqsClient: {}, })); describe('container', () => { diff --git a/lambdas/print-sender-lambda/src/container.ts b/lambdas/print-sender-lambda/src/container.ts index a7e9af9ad..540aa5c76 100644 --- a/lambdas/print-sender-lambda/src/container.ts +++ b/lambdas/print-sender-lambda/src/container.ts @@ -1,21 +1,33 @@ -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + EventPublisher, + MetricHandler, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; import { loadConfig } from 'infra/config'; import { PrintSender } from 'app/print-sender'; export const createContainer = () => { const { accountType, + dlMetricsNamespace, environment, eventPublisherDlqUrl, eventPublisherEventBusArn, } = loadConfig(); + const metricHandler = new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]); + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, + metricHandler, }); const printSender = new PrintSender( diff --git a/lambdas/print-sender-lambda/src/infra/config.ts b/lambdas/print-sender-lambda/src/infra/config.ts index a119f426e..3393d27a2 100644 --- a/lambdas/print-sender-lambda/src/infra/config.ts +++ b/lambdas/print-sender-lambda/src/infra/config.ts @@ -3,6 +3,7 @@ import { defaultConfigReader } from 'utils'; export type PrintSenderConfig = { eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; environment: string; accountType: string; }; @@ -15,6 +16,7 @@ export function loadConfig(): PrintSenderConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), environment: defaultConfigReader.getValue('ENVIRONMENT'), accountType: defaultConfigReader.getValue('ACCOUNT_TYPE'), }; diff --git a/lambdas/print-status-handler/src/__tests__/container.test.ts b/lambdas/print-status-handler/src/__tests__/container.test.ts index 64f1a694d..75af881d3 100644 --- a/lambdas/print-status-handler/src/__tests__/container.test.ts +++ b/lambdas/print-status-handler/src/__tests__/container.test.ts @@ -2,14 +2,17 @@ import { createContainer } from 'container'; jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', })), })); jest.mock('utils', () => ({ eventBridgeClient: {}, EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), logger: {}, sqsClient: {}, })); diff --git a/lambdas/print-status-handler/src/container.ts b/lambdas/print-status-handler/src/container.ts index 7fe49378d..971ebe65b 100644 --- a/lambdas/print-status-handler/src/container.ts +++ b/lambdas/print-status-handler/src/container.ts @@ -1,9 +1,20 @@ import { HandlerDependencies } from 'apis/sqs-handler'; import { loadConfig } from 'infra/config'; -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + EventPublisher, + MetricHandler, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; export const createContainer = (): HandlerDependencies => { - const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); + const { + dlMetricsNamespace, + environment, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + } = loadConfig(); const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, @@ -11,6 +22,9 @@ export const createContainer = (): HandlerDependencies => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); return { eventPublisher, logger }; diff --git a/lambdas/print-status-handler/src/infra/config.ts b/lambdas/print-status-handler/src/infra/config.ts index 855e66108..059052dad 100644 --- a/lambdas/print-status-handler/src/infra/config.ts +++ b/lambdas/print-status-handler/src/infra/config.ts @@ -1,17 +1,21 @@ import { defaultConfigReader } from 'utils'; export type Config = { + environment: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): Config { return { + environment: defaultConfigReader.getValue('ENVIRONMENT'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ), eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/report-generator/src/__tests__/container.test.ts b/lambdas/report-generator/src/__tests__/container.test.ts index c9aa4fe99..9f5db51d2 100644 --- a/lambdas/report-generator/src/__tests__/container.test.ts +++ b/lambdas/report-generator/src/__tests__/container.test.ts @@ -3,8 +3,10 @@ import { createContainer } from 'container'; jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ athenaNamedQueryId: 'test-named-query-id', + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', maxPollLimit: 10, reportName: 'test-report', reportingBucket: 'test-bucket', @@ -21,6 +23,7 @@ jest.mock('utils', () => ({ s3Client: {}, eventBridgeClient: {}, EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), logger: {}, sqsClient: {}, })); diff --git a/lambdas/report-generator/src/container.ts b/lambdas/report-generator/src/container.ts index b4bfc43b5..b76b14833 100644 --- a/lambdas/report-generator/src/container.ts +++ b/lambdas/report-generator/src/container.ts @@ -2,6 +2,7 @@ import { AthenaDataRepository, AthenaDataRepositoryDependencies, EventPublisher, + MetricHandler, ReportService, S3StorageRepository, eventBridgeClient, @@ -17,6 +18,8 @@ import { AthenaClient } from '@aws-sdk/client-athena'; export const createContainer = () => { const { athenaNamedQueryId, + dlMetricsNamespace, + environment, eventPublisherDlqUrl, eventPublisherEventBusArn, maxPollLimit, @@ -56,12 +59,17 @@ export const createContainer = () => { athenaNamedQueryId, ); + const metricHandler = new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]); + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, + metricHandler, }); return { diff --git a/lambdas/report-generator/src/infra/config.ts b/lambdas/report-generator/src/infra/config.ts index c0c1f9701..01ae84f54 100644 --- a/lambdas/report-generator/src/infra/config.ts +++ b/lambdas/report-generator/src/infra/config.ts @@ -2,8 +2,10 @@ import { defaultConfigReader } from 'utils'; export type ReportGeneratorConfig = { athenaNamedQueryId: string; + environment: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; maxPollLimit: number; reportingBucket: string; reportName: string; @@ -13,12 +15,14 @@ export type ReportGeneratorConfig = { export function loadConfig(): ReportGeneratorConfig { return { athenaNamedQueryId: defaultConfigReader.getValue('ATHENA_NAMED_QUERY_ID'), + environment: defaultConfigReader.getValue('ENVIRONMENT'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ), eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), maxPollLimit: defaultConfigReader.getInt('MAX_POLL_LIMIT'), reportingBucket: defaultConfigReader.getValue('REPORTING_BUCKET'), reportName: defaultConfigReader.getValue('REPORT_NAME'), diff --git a/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts b/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts index 83abed366..cd143a7d5 100644 --- a/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts +++ b/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts @@ -1,4 +1,4 @@ -import { EventPublisher, Logger, Sender } from 'utils'; +import { EventPublisher, Logger, MetricHandler, Sender } from 'utils'; import { ISenderManagement } from 'sender-management'; import { GenerateReport, validateGenerateReport } from 'digital-letters-events'; import { createHandler } from 'apis/scheduled-event-handler'; @@ -7,6 +7,7 @@ describe('scheduled-event-handler', () => { let mockSenderManagement: jest.Mocked; let mockEventPublisher: jest.Mocked; let mockLogger: jest.Mocked; + let mockMetricHandler: jest.Mocked; beforeEach(() => { mockSenderManagement = { @@ -24,6 +25,10 @@ describe('scheduled-event-handler', () => { child: jest.fn().mockReturnThis(), } as unknown as jest.Mocked; + mockMetricHandler = { + addMetrics: jest.fn(), + } as unknown as jest.Mocked; + jest.useFakeTimers(); }); @@ -39,11 +44,13 @@ describe('scheduled-event-handler', () => { const handler = createHandler({ senderManagement: mockSenderManagement, eventPublisher: mockEventPublisher, + metricHandler: mockMetricHandler, }); await handler(); expect(mockSenderManagement.listSenders).toHaveBeenCalledTimes(1); + expect(mockMetricHandler.addMetrics).toHaveBeenCalledTimes(1); }); it('should publish generate report events for each sender', async () => { @@ -62,6 +69,7 @@ describe('scheduled-event-handler', () => { const handler = createHandler({ senderManagement: mockSenderManagement, eventPublisher: mockEventPublisher, + metricHandler: mockMetricHandler, }); await handler(); @@ -71,6 +79,11 @@ describe('scheduled-event-handler', () => { expect(events).toHaveLength(3); expect(validator).toBeDefined(); + expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ + 'TotalSenders', + 'Count', + 3, + ]); }); it('should create events with correct structure for each sender', async () => { @@ -87,6 +100,7 @@ describe('scheduled-event-handler', () => { const handler = createHandler({ senderManagement: mockSenderManagement, eventPublisher: mockEventPublisher, + metricHandler: mockMetricHandler, }); await handler(); @@ -115,6 +129,11 @@ describe('scheduled-event-handler', () => { expect(event.datacontenttype).toBe('application/json'); expect(() => validateGenerateReport(event, mockLogger)).not.toThrow(); + expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ + 'TotalSenders', + 'Count', + 1, + ]); }); it('should handle empty sender list', async () => { @@ -124,12 +143,18 @@ describe('scheduled-event-handler', () => { const handler = createHandler({ senderManagement: mockSenderManagement, eventPublisher: mockEventPublisher, + metricHandler: mockMetricHandler, }); await handler(); const [[events]] = mockEventPublisher.sendEvents.mock.calls; expect(events).toHaveLength(0); + expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ + 'TotalSenders', + 'Count', + 0, + ]); }); it('should handle event publisher errors', async () => { @@ -142,9 +167,11 @@ describe('scheduled-event-handler', () => { const handler = createHandler({ senderManagement: mockSenderManagement, eventPublisher: mockEventPublisher, + metricHandler: mockMetricHandler, }); await expect(handler()).rejects.toThrow('Failed to publish events'); + expect(mockMetricHandler.addMetrics).toHaveBeenCalledTimes(0); }); it('should generate unique event IDs for multiple senders', async () => { @@ -159,6 +186,7 @@ describe('scheduled-event-handler', () => { const handler = createHandler({ senderManagement: mockSenderManagement, eventPublisher: mockEventPublisher, + metricHandler: mockMetricHandler, }); await handler(); @@ -167,6 +195,11 @@ describe('scheduled-event-handler', () => { const eventIds = events.map((e) => e.id); expect(new Set(eventIds).size).toBe(eventIds.length); + expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ + 'TotalSenders', + 'Count', + 2, + ]); }); }); }); diff --git a/lambdas/report-scheduler/src/__tests__/container.test.ts b/lambdas/report-scheduler/src/__tests__/container.test.ts index 2abec6611..383769e94 100644 --- a/lambdas/report-scheduler/src/__tests__/container.test.ts +++ b/lambdas/report-scheduler/src/__tests__/container.test.ts @@ -2,8 +2,10 @@ import { createContainer } from 'container'; jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ + environment: 'test', eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', })), })); @@ -13,6 +15,7 @@ jest.mock('sender-management', () => ({ jest.mock('utils', () => ({ EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), ParameterStoreCache: jest.fn(() => ({})), eventBridgeClient: {}, logger: {}, diff --git a/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts b/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts index 506b64853..d85d37635 100644 --- a/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts +++ b/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts @@ -1,4 +1,4 @@ -import { EventPublisher } from 'utils'; +import { EventPublisher, MetricHandler } from 'utils'; import { ISenderManagement } from 'sender-management'; import { GenerateReport, validateGenerateReport } from 'digital-letters-events'; import { randomUUID } from 'node:crypto'; @@ -6,10 +6,12 @@ import { randomUUID } from 'node:crypto'; export type CreateHandlerDependencies = { senderManagement: ISenderManagement; eventPublisher: EventPublisher; + metricHandler: MetricHandler; }; export const createHandler = ({ eventPublisher, + metricHandler, senderManagement, }: CreateHandlerDependencies) => { return async () => { @@ -43,5 +45,6 @@ export const createHandler = ({ })), validateGenerateReport, ); + metricHandler.addMetrics(['TotalSenders', 'Count', senders.length]); }; }; diff --git a/lambdas/report-scheduler/src/container.ts b/lambdas/report-scheduler/src/container.ts index 3a2166556..8ccc3b770 100644 --- a/lambdas/report-scheduler/src/container.ts +++ b/lambdas/report-scheduler/src/container.ts @@ -3,6 +3,7 @@ import { CreateHandlerDependencies } from 'apis/scheduled-event-handler'; import { SenderManagement } from 'sender-management'; import { EventPublisher, + MetricHandler, ParameterStoreCache, eventBridgeClient, logger, @@ -10,22 +11,32 @@ import { } from 'utils'; export const createContainer = (): CreateHandlerDependencies => { - const { eventPublisherDlqUrl, eventPublisherEventBusArn } = loadConfig(); + const { + dlMetricsNamespace, + environment, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + } = loadConfig(); const parameterStore = new ParameterStoreCache(); const senderManagement = SenderManagement({ parameterStore, }); + const metricHandler = new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]); + const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, dlqUrl: eventPublisherDlqUrl, logger, sqsClient, eventBridgeClient, + metricHandler, }); - return { senderManagement, eventPublisher }; + return { senderManagement, eventPublisher, metricHandler }; }; export default createContainer; diff --git a/lambdas/report-scheduler/src/infra/config.ts b/lambdas/report-scheduler/src/infra/config.ts index ec2dbd5e5..a7080ea81 100644 --- a/lambdas/report-scheduler/src/infra/config.ts +++ b/lambdas/report-scheduler/src/infra/config.ts @@ -1,17 +1,21 @@ import { defaultConfigReader } from 'utils'; export type ReportSchedulerConfig = { + environment: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): ReportSchedulerConfig { return { + environment: defaultConfigReader.getValue('ENVIRONMENT'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ), eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/report-sender/report_sender/config.py b/lambdas/report-sender/report_sender/config.py index bc3fc002b..e62901512 100644 --- a/lambdas/report-sender/report_sender/config.py +++ b/lambdas/report-sender/report_sender/config.py @@ -11,7 +11,7 @@ "event_publisher_event_bus_arn": "EVENT_PUBLISHER_EVENT_BUS_ARN", "event_publisher_dlq_url": "EVENT_PUBLISHER_DLQ_URL", "send_metric_name": "REPORT_SENDER_METRIC_NAME", - "send_metric_namespace": "REPORT_SENDER_METRIC_NAMESPACE" + "dl_metrics_namespace": "DL_METRICS_NAMESPACE" } @@ -41,6 +41,6 @@ def build_send_metric(self): """ return Metric( name=self.send_metric_name, - namespace=self.send_metric_namespace, + namespace=self.dl_metrics_namespace, dimensions={"Environment": self.environment} ) diff --git a/lambdas/report-sender/report_sender/handler.py b/lambdas/report-sender/report_sender/handler.py index 5fa3f4121..870a7e43d 100644 --- a/lambdas/report-sender/report_sender/handler.py +++ b/lambdas/report-sender/report_sender/handler.py @@ -30,7 +30,8 @@ def handler(event, context): event_publisher = EventPublisher( event_bus_arn=config.event_publisher_event_bus_arn, dlq_url=config.event_publisher_dlq_url, - logger=log + logger=log, + event_metric=config.send_metric ) reports_store = ReportsStore(config.s3_client) diff --git a/lambdas/ttl-create-lambda/src/__tests__/container.test.ts b/lambdas/ttl-create-lambda/src/__tests__/container.test.ts index 515723fa5..797f258f4 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/container.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/container.test.ts @@ -4,6 +4,7 @@ jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ eventPublisherDlqUrl: 'test-url', eventPublisherEventBusArn: 'test-arn', + dlMetricsNamespace: 'test-namespace', ttlShardCount: 1, ttlTableName: 'test-table', environment: 'test-environment', @@ -24,6 +25,7 @@ jest.mock('sender-management', () => ({ jest.mock('utils', () => ({ EventPublisher: jest.fn(() => ({})), + MetricHandler: jest.fn(() => ({})), dynamoClient: {}, eventBridgeClient: {}, logger: {}, diff --git a/lambdas/ttl-create-lambda/src/container.ts b/lambdas/ttl-create-lambda/src/container.ts index 8b892dfdd..ae401ef2c 100644 --- a/lambdas/ttl-create-lambda/src/container.ts +++ b/lambdas/ttl-create-lambda/src/container.ts @@ -1,5 +1,6 @@ import { EventPublisher, + MetricHandler, ParameterStoreCache, dynamoClient, eventBridgeClient, @@ -13,6 +14,7 @@ import { SenderManagement } from 'sender-management'; export const createContainer = () => { const { + dlMetricsNamespace, environment, eventPublisherDlqUrl, eventPublisherEventBusArn, @@ -43,6 +45,9 @@ export const createContainer = () => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); return { diff --git a/lambdas/ttl-create-lambda/src/infra/config.ts b/lambdas/ttl-create-lambda/src/infra/config.ts index 1e12c8489..6fe66c62f 100644 --- a/lambdas/ttl-create-lambda/src/infra/config.ts +++ b/lambdas/ttl-create-lambda/src/infra/config.ts @@ -6,6 +6,7 @@ export type TtlCreateConfig = { ttlShardCount: number; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; }; export function loadConfig(): TtlCreateConfig { @@ -19,5 +20,6 @@ export function loadConfig(): TtlCreateConfig { eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), }; } diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts index 5e814ba3f..f2be8c30f 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts @@ -4,6 +4,7 @@ import { createContainer } from 'container'; jest.mock('utils', () => ({ EventPublisher: jest.fn(), + MetricHandler: jest.fn(), eventBridgeClient: {}, logger: {}, sqsClient: {}, @@ -27,6 +28,8 @@ describe('createContainer', () => { 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', eventPublisherDlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-event-dlq', + environment: 'test', + dlMetricsNamespace: 'test-namespace', dlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', }; @@ -47,6 +50,7 @@ describe('createContainer', () => { logger: expect.any(Object), sqsClient: expect.any(Object), eventBridgeClient: expect.any(Object), + metricHandler: expect.any(Object), }); expect(container).toEqual({ diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/index.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/index.test.ts index f9f0dca65..e1d5d9546 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/index.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/index.test.ts @@ -5,6 +5,7 @@ jest.mock('utils', () => ({ EventPublisher: jest.fn().mockImplementation(() => ({ sendEvents: jest.fn().mockResolvedValue({}), })), + MetricHandler: jest.fn().mockImplementation(() => ({})), eventBridgeClient: {}, logger: { info: jest.fn(), diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/infra/config.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/infra/config.test.ts index 74e6a0d91..ea23047f2 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/infra/config.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/infra/config.test.ts @@ -25,41 +25,55 @@ describe('loadConfig', () => { 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq'; mockGetValue + .mockReturnValueOnce('test') .mockReturnValueOnce(mockEventBusArn) .mockReturnValueOnce(mockEventPublisherDlqUrl) + .mockReturnValueOnce('test-namespace') .mockReturnValueOnce(mockDlqUrl); const config = loadConfig(); - expect(defaultConfigReader.getValue).toHaveBeenCalledTimes(3); + expect(defaultConfigReader.getValue).toHaveBeenCalledTimes(5); + expect(defaultConfigReader.getValue).toHaveBeenCalledWith('ENVIRONMENT'); expect(defaultConfigReader.getValue).toHaveBeenCalledWith( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ); expect(defaultConfigReader.getValue).toHaveBeenCalledWith( 'EVENT_PUBLISHER_DLQ_URL', ); + expect(defaultConfigReader.getValue).toHaveBeenCalledWith( + 'DL_METRICS_NAMESPACE', + ); expect(defaultConfigReader.getValue).toHaveBeenCalledWith('DLQ_URL'); expect(config).toEqual({ + environment: 'test', eventPublisherEventBusArn: mockEventBusArn, eventPublisherDlqUrl: mockEventPublisherDlqUrl, + dlMetricsNamespace: 'test-namespace', dlqUrl: mockDlqUrl, }); }); it('should return config with correct structure', () => { mockGetValue + .mockReturnValueOnce('prod') .mockReturnValueOnce('test-bus-arn') .mockReturnValueOnce('test-publisher-dlq-url') + .mockReturnValueOnce('test-namespace') .mockReturnValueOnce('test-dlq-url'); const config = loadConfig(); + expect(config).toHaveProperty('environment'); expect(config).toHaveProperty('eventPublisherEventBusArn'); expect(config).toHaveProperty('eventPublisherDlqUrl'); + expect(config).toHaveProperty('dlMetricsNamespace'); expect(config).toHaveProperty('dlqUrl'); + expect(typeof config.environment).toBe('string'); expect(typeof config.eventPublisherEventBusArn).toBe('string'); expect(typeof config.eventPublisherDlqUrl).toBe('string'); + expect(typeof config.dlMetricsNamespace).toBe('string'); expect(typeof config.dlqUrl).toBe('string'); }); }); diff --git a/lambdas/ttl-handle-expiry-lambda/src/container.ts b/lambdas/ttl-handle-expiry-lambda/src/container.ts index 3b3e8c652..2d95e23cc 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/container.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/container.ts @@ -1,11 +1,22 @@ -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + EventPublisher, + MetricHandler, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; import { CreateHandlerDependencies } from 'apis/dynamodb-stream-handler'; import { loadConfig } from 'infra/config'; import { Dlq } from 'app/dlq'; export const createContainer = (): CreateHandlerDependencies => { - const { dlqUrl, eventPublisherDlqUrl, eventPublisherEventBusArn } = - loadConfig(); + const { + dlMetricsNamespace, + dlqUrl, + environment, + eventPublisherDlqUrl, + eventPublisherEventBusArn, + } = loadConfig(); const eventPublisher = new EventPublisher({ eventBusArn: eventPublisherEventBusArn, @@ -13,6 +24,9 @@ export const createContainer = (): CreateHandlerDependencies => { logger, sqsClient, eventBridgeClient, + metricHandler: new MetricHandler(dlMetricsNamespace, [ + { Name: 'Environment', Value: environment }, + ]), }); const dlq = new Dlq({ diff --git a/lambdas/ttl-handle-expiry-lambda/src/infra/config.ts b/lambdas/ttl-handle-expiry-lambda/src/infra/config.ts index f94a9af26..beefe3117 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/infra/config.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/infra/config.ts @@ -1,19 +1,23 @@ import { defaultConfigReader } from 'utils'; export type SendRequestConfig = { + environment: string; eventPublisherEventBusArn: string; eventPublisherDlqUrl: string; + dlMetricsNamespace: string; dlqUrl: string; }; export function loadConfig(): SendRequestConfig { return { + environment: defaultConfigReader.getValue('ENVIRONMENT'), eventPublisherEventBusArn: defaultConfigReader.getValue( 'EVENT_PUBLISHER_EVENT_BUS_ARN', ), eventPublisherDlqUrl: defaultConfigReader.getValue( 'EVENT_PUBLISHER_DLQ_URL', ), + dlMetricsNamespace: defaultConfigReader.getValue('DL_METRICS_NAMESPACE'), dlqUrl: defaultConfigReader.getValue('DLQ_URL'), }; } diff --git a/package-lock.json b/package-lock.json index e08f8d92d..a972f6e3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5766,6 +5766,58 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudwatch": { + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch/-/client-cloudwatch-3.1038.0.tgz", + "integrity": "sha512-n/6aUdWXZ3AUtjZ0ONqq1v1THYHyXKdz4uUfmfEH5CdeBFShPpgN9DFegcresKHixAmL9gAYmS+k5i+NyyuKlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-node": "^3.972.37", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.22", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-compression": "^4.3.46", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.5", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cloudwatch-logs": { "version": "3.999.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.999.0.tgz", @@ -5835,6 +5887,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudwatch/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.981.0", "license": "Apache-2.0", @@ -6308,23 +6376,24 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.15.tgz", - "integrity": "sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==", + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.6.tgz", + "integrity": "sha512-8Vu7zGxu+39ChR/s5J7nXBw3a2kMHAi0OfKT8ohgTVjX0qYed/8mIfdBb638oBmKrWCwwKjYAM5J/4gMJ8nAJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/xml-builder": "^3.972.8", - "@smithy/core": "^3.23.6", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/signature-v4": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.20", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.5", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -6345,15 +6414,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.13.tgz", - "integrity": "sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.32.tgz", + "integrity": "sha512-7vA4GHg8NSmQxquJHSBcSM3RgB4ZaaRi6u4+zGFKOmOH6aqlgr2Sda46clkZDYzlirgfY96w15Zj0jh6PT48ng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6361,20 +6430,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.15.tgz", - "integrity": "sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.34.tgz", + "integrity": "sha512-vBrhWujFCLp1u8ptJRWYlipMutzPptb8pDQ00rKVH9q67T7rGd3VTWIj63aKrlLuY6qSsw1Rt5F/D/7wnNgryA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -6382,24 +6451,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.13.tgz", - "integrity": "sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.36.tgz", + "integrity": "sha512-FBHyCmV8EB0gUvh1d+CZm87zt2PrdC7OyWexLRoH3I5zWSOUGa+9t58Y5jbxRfwUp3AWpHAFvKY6YzgR845sVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/credential-provider-env": "^3.972.13", - "@aws-sdk/credential-provider-http": "^3.972.15", - "@aws-sdk/credential-provider-login": "^3.972.13", - "@aws-sdk/credential-provider-process": "^3.972.13", - "@aws-sdk/credential-provider-sso": "^3.972.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.13", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-login": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6407,18 +6476,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.13.tgz", - "integrity": "sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.36.tgz", + "integrity": "sha512-IFap01lJKxQc0C/OHmZwZQr/cKq0DhrcmKedRrdnnl42D+P0SImnnnWQjv07uIPqpEdtqmkPXb9TiPYTU+prxQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6426,22 +6495,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.14.tgz", - "integrity": "sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.37.tgz", + "integrity": "sha512-/WFixFAAiw8WpmjZcI0l4t3DerXLmVinOIfuotmRZnu2qmsFPoqqmstASz0z8bi1pGdFXzeLzf6bwucM3mZcUQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.13", - "@aws-sdk/credential-provider-http": "^3.972.15", - "@aws-sdk/credential-provider-ini": "^3.972.13", - "@aws-sdk/credential-provider-process": "^3.972.13", - "@aws-sdk/credential-provider-sso": "^3.972.13", - "@aws-sdk/credential-provider-web-identity": "^3.972.13", - "@aws-sdk/types": "^3.973.4", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/credential-provider-env": "^3.972.32", + "@aws-sdk/credential-provider-http": "^3.972.34", + "@aws-sdk/credential-provider-ini": "^3.972.36", + "@aws-sdk/credential-provider-process": "^3.972.32", + "@aws-sdk/credential-provider-sso": "^3.972.36", + "@aws-sdk/credential-provider-web-identity": "^3.972.36", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6449,16 +6518,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.13.tgz", - "integrity": "sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.32.tgz", + "integrity": "sha512-uZp4tlGbpczV8QxmtIwOpSkcyGtBRR8/T4BAumRKfAt1nwCig3FSCZvrKl6ARDIDVRYn5p2oRcAsfFR01EgMGA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6466,18 +6535,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.13.tgz", - "integrity": "sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.36.tgz", + "integrity": "sha512-DsLr0UHMyKzRJKe2bjlwU8q1cfoXg8TIJKV/xwvnalAemiZLOZunFzj/whGnFDZIBVLdnbLiwv5SvRf1+CSwkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/token-providers": "3.999.0", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/token-providers": "3.1038.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6485,17 +6554,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.13.tgz", - "integrity": "sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.36.tgz", + "integrity": "sha512-uzrURO7frJhHQVVNR5zBJcCYeMYflmXcWBK1+MiBym2Dfjh6nXATrMixrmGZi+97Q7ETZ+y/4lUwAy0Nfnznjw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6626,14 +6695,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", - "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6655,13 +6724,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", - "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6669,15 +6738,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", - "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6685,24 +6754,24 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.15.tgz", - "integrity": "sha512-WDLgssevOU5BFx1s8jA7jj6cE5HuImz28sy9jKOaVtz0AW1lYqSzotzdyiybFaBcQTs5zxXOb2pUfyMxgEKY3Q==", + "version": "3.972.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.35.tgz", + "integrity": "sha512-lLppaNTAz+wNgLdi4FtHzrlwrGF0ODTnBWHBaFg85SKs0eJ+M+tP5ifrA8f/0lNd+Ak3MC1NGC6RavV3ny4HTg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/core": "^3.23.6", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/signature-v4": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-stream": "^4.5.15", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -6741,17 +6810,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.15.tgz", - "integrity": "sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.36.tgz", + "integrity": "sha512-O2beToxguBvrZFFZ+fFgPbbae8MvyIBjQ6lImee4APHEXXNAD5ZJ2ayLF1mb7rsKw86TM81y5czg82bZncjSjg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@smithy/core": "^3.23.6", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.5", "tslib": "^2.6.2" }, "engines": { @@ -6759,15 +6829,15 @@ } }, "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -6775,48 +6845,66 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.3.tgz", - "integrity": "sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==", + "version": "3.997.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.4.tgz", + "integrity": "sha512-4Sf+WY1lMJzXlw5MiyCMe/UzdILCwvuaHThbqMXS6dfh9gZy3No360I42RXquOI/ULUOhWy2HCyU0Fp20fQGPQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/middleware-host-header": "^3.972.6", - "@aws-sdk/middleware-logger": "^3.972.6", - "@aws-sdk/middleware-recursion-detection": "^3.972.6", - "@aws-sdk/middleware-user-agent": "^3.972.15", - "@aws-sdk/region-config-resolver": "^3.972.6", - "@aws-sdk/types": "^3.973.4", - "@aws-sdk/util-endpoints": "^3.996.3", - "@aws-sdk/util-user-agent-browser": "^3.972.6", - "@aws-sdk/util-user-agent-node": "^3.973.0", - "@smithy/config-resolver": "^4.4.9", - "@smithy/core": "^3.23.6", - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/hash-node": "^4.2.10", - "@smithy/invalid-dependency": "^4.2.10", - "@smithy/middleware-content-length": "^4.2.10", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-retry": "^4.4.37", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/node-http-handler": "^4.4.12", - "@smithy/protocol-http": "^5.3.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-body-length-node": "^4.2.2", - "@smithy/util-defaults-mode-browser": "^4.3.36", - "@smithy/util-defaults-mode-node": "^4.2.39", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.23", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.22", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.6", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.5", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.23.tgz", + "integrity": "sha512-wBbys3Y53Ikly556vyADurKpYQHXS7Jjaskbz+Ga9PZCz7PB/9f3VdKbDlz7dqIzn+xwz7L/a6TR4iXcOi8IRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "^3.972.35", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6824,15 +6912,15 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -6840,15 +6928,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", - "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/config-resolver": "^4.4.9", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6871,17 +6959,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.999.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.999.0.tgz", - "integrity": "sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==", + "version": "3.1038.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1038.0.tgz", + "integrity": "sha512-Qniru+9oGGb/HNK/gGZWbV3jsD0k71ngE7qMQ/x6gYNYLd2EOwHCS6E2E6jfkaqO4i0d+nNKmfRy8bNcshKdGQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.15", - "@aws-sdk/nested-clients": "^3.996.3", - "@aws-sdk/types": "^3.973.4", - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@aws-sdk/core": "^3.974.6", + "@aws-sdk/nested-clients": "^3.997.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6889,12 +6977,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", - "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -6902,7 +6990,9 @@ } }, "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.2", + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6949,27 +7039,28 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", - "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/types": "^4.13.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.0.tgz", - "integrity": "sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==", + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.22.tgz", + "integrity": "sha512-YTYqTmOUrwbm1h99Ee4y/mVYpFRl0oSO/amtP5cc1BZZWdaAVWs9zj3TkyRHWvR9aI/ZS8m3mS6awXtYUlWyaw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.15", - "@aws-sdk/types": "^3.973.4", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/types": "^4.13.0", + "@aws-sdk/middleware-user-agent": "^3.972.36", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -6985,13 +7076,14 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.15.tgz", - "integrity": "sha512-PxMRlCFNiQnke9YR29vjFQwz4jq+6Q04rOVFeTDR2K7Qpv9h9FOWOxG+zJjageimYbWqE3bTuLjmryWHAWbvaA==", + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.21.tgz", + "integrity": "sha512-qxNiHUtlrsjTeSlrPWiFkWps7uD6YB4eKzg7eLAFH8jbiHTlt0ePNlo2Xu+WlftP38JIcMaIX4jTUjOlE2ySWw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -9857,6 +9949,18 @@ "zod": "^4.1.11" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "license": "MIT", @@ -10545,16 +10649,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.9.tgz", - "integrity": "sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==", + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/types": "^4.13.0", - "@smithy/util-config-provider": "^4.2.1", - "@smithy/util-endpoints": "^3.3.1", - "@smithy/util-middleware": "^4.2.10", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -10562,20 +10666,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.6", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.6.tgz", - "integrity": "sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==", + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.11", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-body-length-browser": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-stream": "^4.5.15", - "@smithy/util-utf8": "^4.2.1", - "@smithy/uuid": "^1.1.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -10583,15 +10687,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.10.tgz", - "integrity": "sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -10669,15 +10773,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.11.tgz", - "integrity": "sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -10700,14 +10804,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.10.tgz", - "integrity": "sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/types": "^4.14.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -10729,12 +10833,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.10.tgz", - "integrity": "sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10742,9 +10846,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.1.tgz", - "integrity": "sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -10767,14 +10871,35 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/middleware-compression": { + "version": "4.3.46", + "resolved": "https://registry.npmjs.org/@smithy/middleware-compression/-/middleware-compression-4.3.46.tgz", + "integrity": "sha512-9f4AZ5dKqKRmO49MPhOoxFoQBLfBgxE9YKG8bQ6lsW9xk+Bn8rkfGlpW8OYlvhuarN+8mja9PjhEudFiR8wGFQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.17", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-utf8": "^4.2.2", + "fflate": "0.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.10.tgz", - "integrity": "sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10782,18 +10907,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.20", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.20.tgz", - "integrity": "sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==", + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-serde": "^4.2.11", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-middleware": "^4.2.10", + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -10801,19 +10926,20 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.37", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.37.tgz", - "integrity": "sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", + "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/service-error-classification": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-retry": "^4.2.10", - "@smithy/uuid": "^1.1.1", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -10821,13 +10947,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.11.tgz", - "integrity": "sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10835,12 +10962,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.10.tgz", - "integrity": "sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10848,14 +10975,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.10", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.10.tgz", - "integrity": "sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/shared-ini-file-loader": "^4.4.5", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10863,15 +10990,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.12.tgz", - "integrity": "sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/querystring-builder": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10879,12 +11005,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.10.tgz", - "integrity": "sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10892,12 +11018,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.10.tgz", - "integrity": "sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10905,13 +11031,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.10.tgz", - "integrity": "sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "@smithy/util-uri-escape": "^4.2.1", + "@smithy/types": "^4.14.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -10919,12 +11045,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.10.tgz", - "integrity": "sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10932,24 +11058,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.10.tgz", - "integrity": "sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", + "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.5.tgz", - "integrity": "sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -10957,18 +11083,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.10.tgz", - "integrity": "sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-uri-escape": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -10976,17 +11102,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.0.tgz", - "integrity": "sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==", + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.6", - "@smithy/middleware-endpoint": "^4.4.20", - "@smithy/middleware-stack": "^4.2.10", - "@smithy/protocol-http": "^5.3.10", - "@smithy/types": "^4.13.0", - "@smithy/util-stream": "^4.5.15", + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -10994,9 +11120,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11006,13 +11132,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.10.tgz", - "integrity": "sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11020,13 +11146,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.1.tgz", - "integrity": "sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -11034,9 +11160,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.1.tgz", - "integrity": "sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11046,9 +11172,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.2.tgz", - "integrity": "sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11058,12 +11184,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.1.tgz", - "integrity": "sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.1", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -11071,9 +11197,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.1.tgz", - "integrity": "sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11083,14 +11209,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.36", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.36.tgz", - "integrity": "sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==", + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11098,17 +11224,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.39", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.39.tgz", - "integrity": "sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==", + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.9", - "@smithy/credential-provider-imds": "^4.2.10", - "@smithy/node-config-provider": "^4.3.10", - "@smithy/property-provider": "^4.2.10", - "@smithy/smithy-client": "^4.12.0", - "@smithy/types": "^4.13.0", + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11116,13 +11242,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.1.tgz", - "integrity": "sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.10", - "@smithy/types": "^4.13.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11130,9 +11256,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.1.tgz", - "integrity": "sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11142,12 +11268,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.10.tgz", - "integrity": "sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11155,13 +11281,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.10.tgz", - "integrity": "sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.6.tgz", + "integrity": "sha512-p6/FO1n2KxMeQyna067i0uJ6TSbb165ZhnRtCpWh4Foxqbfc6oW+XITaL8QkFJj3KFnDe2URt4gOhgU06EP9ew==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11169,18 +11295,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.15", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.15.tgz", - "integrity": "sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==", + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.11", - "@smithy/node-http-handler": "^4.4.12", - "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-buffer-from": "^4.2.1", - "@smithy/util-hex-encoding": "^4.2.1", - "@smithy/util-utf8": "^4.2.1", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/types": "^4.14.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -11188,9 +11314,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.1.tgz", - "integrity": "sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11200,12 +11326,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.1.tgz", - "integrity": "sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.1", + "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -11213,13 +11339,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.10.tgz", - "integrity": "sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.3.0.tgz", + "integrity": "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.10", - "@smithy/types": "^4.13.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -11227,9 +11352,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.1.tgz", - "integrity": "sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -16241,9 +16366,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -16256,9 +16381,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -16267,9 +16392,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -16319,6 +16445,12 @@ "version": "4.2.3", "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz", + "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, @@ -22464,9 +22596,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", - "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -24447,9 +24579,9 @@ } }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", "funding": [ { "type": "github", @@ -28224,6 +28356,7 @@ "version": "0.0.1", "dependencies": { "@aws-sdk/client-athena": "^3.984.0", + "@aws-sdk/client-cloudwatch": "^3.984.0", "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/client-eventbridge": "^3.984.0", "@aws-sdk/client-lambda": "^3.984.0", diff --git a/tests/playwright/helpers/event-bus-helpers.ts b/tests/playwright/helpers/event-bus-helpers.ts index 6edfa99e0..604484a4f 100644 --- a/tests/playwright/helpers/event-bus-helpers.ts +++ b/tests/playwright/helpers/event-bus-helpers.ts @@ -1,12 +1,26 @@ -import { EVENT_BUS_ARN, EVENT_BUS_DLQ_URL } from 'constants/backend-constants'; -import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import { + ENV, + EVENT_BUS_ARN, + EVENT_BUS_DLQ_URL, +} from 'constants/backend-constants'; +import { + EventPublisher, + MetricHandler, + eventBridgeClient, + logger, + sqsClient, +} from 'utils'; +const metricHandler = new MetricHandler(`nhs-${ENV}-dl-component-test`, [ + { Name: 'Environment', Value: ENV }, +]); const eventPublisher = new EventPublisher({ eventBusArn: EVENT_BUS_ARN, dlqUrl: EVENT_BUS_DLQ_URL, logger, sqsClient, eventBridgeClient, + metricHandler, }); export default eventPublisher; diff --git a/utils/py-mock-mesh/py_mock_mesh/mesh_client.py b/utils/py-mock-mesh/py_mock_mesh/mesh_client.py index 2317b5bdd..cea21ca45 100644 --- a/utils/py-mock-mesh/py_mock_mesh/mesh_client.py +++ b/utils/py-mock-mesh/py_mock_mesh/mesh_client.py @@ -50,6 +50,26 @@ def iterate_all_messages(self): ContinuationToken=continuation_token ) + def list_messages(self, max_results: Optional[int] = None, workflow_filter: Optional[str] = None) -> List[str]: + """ + Lists message IDs in the inbox, with optional filtering by workflow_id and max_results + """ + response = self.s3_client.list_objects_v2( + Bucket=self.s3_bucket, + Prefix=self.inbox_prefix) + + message_ids = [] + + for s3_object in response.get('Contents', []): + + message_id = s3_object['Key'].split('/')[-1] + message_ids.append(message_id) + + if max_results and len(message_ids) >= max_results: + return message_ids + + return message_ids + def retrieve_message(self, message_id): """ Retrieves a specific message by ID from the inbox diff --git a/utils/py-utils/dl_utils/__tests__/test_event_publisher.py b/utils/py-utils/dl_utils/__tests__/test_event_publisher.py index 4ed1aa209..656ab9004 100644 --- a/utils/py-utils/dl_utils/__tests__/test_event_publisher.py +++ b/utils/py-utils/dl_utils/__tests__/test_event_publisher.py @@ -27,10 +27,16 @@ def mock_sqs_client(): @pytest.fixture -def test_config(mock_logger, mock_events_client, mock_sqs_client): +def mock_event_metric(): + return Mock() + + +@pytest.fixture +def test_config(mock_logger, mock_events_client, mock_sqs_client, mock_event_metric): return { 'event_bus_arn': 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', 'dlq_url': 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', + 'event_metric': mock_event_metric, 'logger': mock_logger, 'events_client': mock_events_client, 'sqs_client': mock_sqs_client, @@ -520,3 +526,239 @@ def test_should_not_retry_on_permanent_client_error( dlq_call_args = mock_sqs_client.send_message_batch.call_args[1] assert dlq_call_args['Entries'][0]['MessageBody'] == json.dumps(valid_cloud_event) assert dlq_call_args['Entries'][0]['MessageAttributes']['DlqReason']['StringValue'] == 'EVENTBRIDGE_FAILURE' + + +class TestEventMetricRecording: + """Tests for CloudWatch metric recording on EventBridge publish.""" + + def test_records_success_metric_when_events_sent_successfully( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, valid_cloud_event, mock_validator): + """Records a success metric using the event type as the metric name.""" + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 0, + 'Entries': [{'EventId': 'event-1'}] + } + + publisher = EventPublisher(**test_config) + publisher.send_events([valid_cloud_event], validator=mock_validator) + + event_type = valid_cloud_event['type'] + mock_event_metric.record.assert_called_once_with(1, name=f"{event_type}_published") + + def test_records_failure_metric_when_eventbridge_rejects_event( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, valid_cloud_event, mock_validator): + """Records a failure metric when EventBridge permanently rejects an event.""" + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 1, + 'Entries': [{'ErrorCode': 'AccessDenied', 'ErrorMessage': 'Access denied'}] + } + mock_sqs_client.send_message_batch.return_value = {'Successful': []} + + publisher = EventPublisher(**test_config) + publisher.send_events([valid_cloud_event], validator=mock_validator) + + event_type = valid_cloud_event['type'] + mock_event_metric.record.assert_called_once_with(1, name=f"{event_type}_not_published") + + def test_records_both_success_and_failure_metrics_for_mixed_batch( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, valid_cloud_event, valid_cloud_event2, mock_validator): + """Records separate success and failure metrics for a batch with mixed outcomes.""" + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 1, + 'Entries': [ + {'ErrorCode': 'AccessDenied', 'ErrorMessage': 'Access denied'}, + {'EventId': 'event-2'}, + ] + } + mock_sqs_client.send_message_batch.return_value = {'Successful': []} + + publisher = EventPublisher(**test_config) + publisher.send_events([valid_cloud_event, valid_cloud_event2], validator=mock_validator) + + event_type = valid_cloud_event['type'] + mock_event_metric.record.assert_any_call(1, name=f"{event_type}_published") + mock_event_metric.record.assert_any_call(1, name=f"{event_type}_not_published") + + +class TestEventMetricsForAcknowledgedEvent: + """Tests that metric names are recorded correctly for the acknowledged event type. + + The acknowledged event data contains `statusCode` (not `status`), so + `data.get('status', '')` returns an empty string and the metric names + must NOT include a status suffix. + """ + + @pytest.fixture + def acknowledged_event_202_1(self): + return { + 'profileversion': '1.0.0', + 'profilepublished': '2025-10', + 'id': '550e8400-e29b-41d4-a716-446655440099', + 'specversion': '1.0', + 'plane': 'data', + 'source': '/nhs/england/notify/production/primary/digitalletters/mesh', + 'subject': 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + 'type':'uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1', + 'time': '2023-06-20T12:00:00Z', + 'recordedtime': '2023-06-20T12:00:00.250Z', + 'severitynumber': 2, + 'severitytext': 'INFO', + 'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + 'dataschema': 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letters-mesh-inbox-message-acknowledged-data.schema.json', + 'dataschemaversion': '1.0.0', + 'data': { + 'senderId': 'sender1', + 'meshMailboxId': 'mailbox1', + 'receivedMeshMessageId': 'received-msg-001', + 'sentMeshMessageId': 'sent-msg-001', + 'statusCode': 202, + }, + } + + @pytest.fixture + def acknowledged_event_202_2(self): + return { + 'profileversion': '1.0.0', + 'profilepublished': '2025-10', + 'id': '550e8400-e29b-41d4-a716-446655440098', + 'specversion': '1.0', + 'plane': 'data', + 'source': '/nhs/england/notify/production/primary/digitalletters/mesh', + 'subject': 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + 'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1', + 'time': '2023-06-20T12:00:00Z', + 'recordedtime': '2023-06-20T12:00:00.250Z', + 'severitynumber': 2, + 'severitytext': 'INFO', + 'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + 'dataschema': 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letters-mesh-inbox-message-acknowledged-data.schema.json', + 'dataschemaversion': '1.0.0', + 'data': { + 'senderId': 'sender2', + 'meshMailboxId': 'mailbox1', + 'receivedMeshMessageId': 'received-msg-002', + 'sentMeshMessageId': 'sent-msg-002', + 'statusCode': 202, + }, + } + + @pytest.fixture + def acknowledged_event_400(self): + return { + 'profileversion': '1.0.0', + 'profilepublished': '2025-10', + 'id': '550e8400-e29b-41d4-a716-446655440097', + 'specversion': '1.0', + 'plane': 'data', + 'source': '/nhs/england/notify/production/primary/digitalletters/mesh', + 'subject': 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + 'type': 'uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1', + 'time': '2023-06-20T12:00:00Z', + 'recordedtime': '2023-06-20T12:00:00.250Z', + 'severitynumber': 2, + 'severitytext': 'INFO', + 'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + 'dataschema': 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letters-mesh-inbox-message-acknowledged-data.schema.json', + 'dataschemaversion': '1.0.0', + 'data': { + 'senderId': 'sender3', + 'meshMailboxId': 'mailbox1', + 'receivedMeshMessageId': 'received-msg-003', + 'sentMeshMessageId': 'sent-msg-003', + 'statusCode': 400, + }, + } + + def test_should_record_published_metric_when_acknowledged_event_is_sent_successfully( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, acknowledged_event_202_1, mock_validator): + """Records metric with name '_published' (no status suffix) on success.""" + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 0, + 'Entries': [{'EventId': 'event-1'}], + } + + publisher = EventPublisher(**test_config) + publisher.send_events([acknowledged_event_202_1], validator=mock_validator) + + mock_event_metric.record.assert_called_once_with( + 1, name='uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1_202_published' + ) + + def test_should_record_not_published_metric_when_acknowledged_event_fails_to_send( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, acknowledged_event_202_1, mock_validator): + """Records metric with name '_not_published' (no status suffix) on permanent failure.""" + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 1, + 'Entries': [{'ErrorCode': 'AccessDenied', 'ErrorMessage': 'Access denied'}], + } + mock_sqs_client.send_message_batch.return_value = {'Successful': []} + + publisher = EventPublisher(**test_config) + publisher.send_events([acknowledged_event_202_1], validator=mock_validator) + + mock_event_metric.record.assert_called_once_with( + 1, name='uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1_202_not_published' + ) + + def test_should_record_separate_metrics_per_status_code_when_all_published( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, acknowledged_event_202_1, acknowledged_event_202_2, + acknowledged_event_400, mock_validator): + """A batch with two 202 ACKs and one 400 ACK that all publish successfully + should record two separate metrics: count=2 for 202 and count=1 for 400.""" + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 0, + 'Entries': [{'EventId': 'e-1'}, {'EventId': 'e-2'}, {'EventId': 'e-3'}], + } + + publisher = EventPublisher(**test_config) + publisher.send_events( + [acknowledged_event_202_1, acknowledged_event_202_2, acknowledged_event_400], + validator=mock_validator, + ) + + assert mock_event_metric.record.call_count == 2 + mock_event_metric.record.assert_any_call( + 2, name='uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1_202_published' + ) + mock_event_metric.record.assert_any_call( + 1, name='uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1_400_published' + ) + + def test_should_record_separate_metrics_per_status_code_for_mixed_publish_outcome( + self, test_config, mock_events_client, mock_sqs_client, + mock_event_metric, acknowledged_event_202_1, acknowledged_event_202_2, + acknowledged_event_400, mock_validator): + """A batch with two 202 ACKs (successfully published) and one 400 ACK + (permanently rejected by EventBridge) should record: + - count=2 for 202_published + - count=1 for 400_not_published + """ + mock_events_client.put_events.return_value = { + 'FailedEntryCount': 1, + 'Entries': [ + {'EventId': 'e-1'}, + {'EventId': 'e-2'}, + {'ErrorCode': 'AccessDenied', 'ErrorMessage': 'Access denied'}, + ], + } + mock_sqs_client.send_message_batch.return_value = {'Successful': []} + + publisher = EventPublisher(**test_config) + publisher.send_events( + [acknowledged_event_202_1, acknowledged_event_202_2, acknowledged_event_400], + validator=mock_validator, + ) + + assert mock_event_metric.record.call_count == 2 + mock_event_metric.record.assert_any_call( + 2, name='uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1_202_published' + ) + mock_event_metric.record.assert_any_call( + 1, name='uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1_400_not_published' + ) diff --git a/utils/py-utils/dl_utils/event_publisher.py b/utils/py-utils/dl_utils/event_publisher.py index 5a4e25fb5..4b3b36387 100644 --- a/utils/py-utils/dl_utils/event_publisher.py +++ b/utils/py-utils/dl_utils/event_publisher.py @@ -13,6 +13,7 @@ import boto3 from botocore.config import Config from botocore.exceptions import ClientError +from .metric_client import Metric DlqReason = Literal['INVALID_EVENT', 'EVENTBRIDGE_FAILURE'] @@ -28,6 +29,8 @@ 'ServiceUnavailable', } +# Event where we expect a status code in the data and want to include it in the metric name. For other events, the metric name remains the same. +ACKNOWLEDGED_EVENT_TYPE = 'uk.nhs.notify.digital.letters.mesh.inbox.message.acknowledged.v1' class EventPublisher: """ @@ -41,6 +44,7 @@ def __init__( self, event_bus_arn: str, dlq_url: str, + event_metric: Metric, logger: Optional[logging.Logger] = None, events_client: Optional[Any] = None, sqs_client: Optional[Any] = None @@ -55,6 +59,7 @@ def __init__( self.event_bus_arn = event_bus_arn self.dlq_url = dlq_url + self.event_metric = event_metric self.logger = logger or logging.getLogger(__name__) self.events_client = events_client or boto3.client( 'events', @@ -183,6 +188,41 @@ def _send_batch_with_retry( return permanent_failures + events_to_retry + def _record_metric(self, events: List[Dict[str, Any]], is_success: bool): + """ + Record custom metrics, grouped by metric key. For acknowledged events the key + includes the statusCode, so a batch with mixed status codes (e.g. 202 and 400) + produces a separate metric per code. For all other event types the key is simply + the event type. + """ + if not events: + return + + outcome = 'published' if is_success else 'not_published' + counts: Dict[str, int] = {} + + for event in events: + event_type = event.get('type') + if event_type == ACKNOWLEDGED_EVENT_TYPE: + status_code = event.get('data', {}).get('statusCode', '') + key = f"{event_type}_{status_code}" if status_code else event_type + else: + key = event_type + counts[key] = counts.get(key, 0) + 1 + + # Changing metrics names will affect existing alarms and dashboards, please check with Platform / ITOC + for metric_key, count in counts.items(): + self.event_metric.record(count, name=f"{metric_key}_{outcome}") + + + def _record_metrics(self, events: List[Dict[str, Any]], failed_events: List[Dict[str, Any]]): + """ + Record custom metrics for published and failed events. + """ + self._record_metric(events, is_success=True) + self._record_metric(failed_events, is_success=False) + + def _send_to_event_bridge(self, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Send events to EventBridge in batches. @@ -209,7 +249,6 @@ def _send_to_event_bridge(self, events: List[Dict[str, Any]]) -> List[Dict[str, ) batch_failures = self._send_batch_with_retry(batch) - if batch_failures: for event in batch_failures: self.logger.warning( @@ -218,6 +257,10 @@ def _send_to_event_bridge(self, events: List[Dict[str, Any]]) -> List[Dict[str, ) failed_events.extend(batch_failures) + failed_ids = {event.get('id') for event in batch_failures} + successful = [event for event in batch if event.get('id') not in failed_ids] + self._record_metrics(successful, batch_failures) + return failed_events def _build_dlq_entries( diff --git a/utils/py-utils/dl_utils/mesh_config.py b/utils/py-utils/dl_utils/mesh_config.py index 5e7b12d1e..172adcf93 100644 --- a/utils/py-utils/dl_utils/mesh_config.py +++ b/utils/py-utils/dl_utils/mesh_config.py @@ -52,9 +52,7 @@ def __init__(self, ssm=None, s3_client=None): self.ssm_mesh_prefix = None self.environment = None self.certificate_expiry_metric_name = None - self.certificate_expiry_metric_namespace = None self.polling_metric_name = None - self.polling_metric_namespace = None self.use_mesh_mock = False self._load_required_env_vars() @@ -156,11 +154,11 @@ def build_mesh_client(self): ) # Use real MESH client - if self.certificate_expiry_metric_name and self.certificate_expiry_metric_namespace: + if self.certificate_expiry_metric_name: report_expiry_time( self.client_cert, self.certificate_expiry_metric_name, - self.certificate_expiry_metric_namespace, + self.dl_metrics_namespace, self.environment ) diff --git a/utils/py-utils/dl_utils/metric_client.py b/utils/py-utils/dl_utils/metric_client.py index dfbdb1ba2..0c7f5a5cd 100644 --- a/utils/py-utils/dl_utils/metric_client.py +++ b/utils/py-utils/dl_utils/metric_client.py @@ -11,12 +11,14 @@ class Metric: # pylint: disable=too-few-public-methods """ def __init__(self, **kwargs): - self.name = kwargs['name'] + self.name = kwargs.get('name', None) self.namespace = kwargs['namespace'] self.dimensions = kwargs.get("dimensions", {}) self.unit = kwargs.get("unit", 'Count') - def record(self, value): + def record(self, value, **kwargs): + + metric_name = kwargs.get('name', self.name) """ method for reporting metric """ @@ -30,12 +32,12 @@ def record(self, value): ], "Metrics": [ { - "Name": self.name, + "Name": metric_name, "Unit": self.unit, } ] }], }, **self.dimensions, - self.name: value, + metric_name: value, })) diff --git a/utils/utils/package.json b/utils/utils/package.json index b9414a801..378c2412e 100644 --- a/utils/utils/package.json +++ b/utils/utils/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@aws-sdk/client-athena": "^3.984.0", + "@aws-sdk/client-cloudwatch": "^3.984.0", "@aws-sdk/client-dynamodb": "^3.984.0", "@aws-sdk/client-eventbridge": "^3.984.0", "@aws-sdk/client-lambda": "^3.984.0", diff --git a/utils/utils/src/__tests__/cloudwatch/metric-handler.test.ts b/utils/utils/src/__tests__/cloudwatch/metric-handler.test.ts new file mode 100644 index 000000000..7ab1ec5fc --- /dev/null +++ b/utils/utils/src/__tests__/cloudwatch/metric-handler.test.ts @@ -0,0 +1,169 @@ +import { MetricHandler } from '../../cloudwatch/metric-handler'; + +const logMock = jest.spyOn(process.stdout, 'write').mockImplementation(); + +const dimensions = [ + { + Name: 'Environment', + Value: 'internal-dev', + }, +]; + +let metricHandler = new MetricHandler('namespace', dimensions); + +beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2022-01-01')); +}); + +beforeEach(() => { + metricHandler = new MetricHandler('namespace', dimensions); +}); + +afterEach(() => { + logMock.mockClear(); +}); + +afterAll(() => { + jest.useRealTimers(); + logMock.mockRestore(); +}); + +it('puts metric data without timestamp', () => { + metricHandler.addMetrics(['metric', 'Count', 47]); + + expect(logMock).toHaveBeenCalledTimes(1); + + const lastCalledWith = logMock.mock.calls[0][0] as string; + + expect(JSON.parse(lastCalledWith)).toEqual({ + _aws: { + Timestamp: new Date('2022-01-01').valueOf(), + CloudWatchMetrics: [ + { + Namespace: 'namespace', + Dimensions: [['Environment']], + Metrics: [ + { + Name: 'metric', + Unit: 'Count', + StorageResolution: 60, + }, + ], + }, + ], + }, + metric: 47, + Environment: 'internal-dev', + }); +}); + +it('logs multiple metrics', () => { + metricHandler.addMetrics([ + ['metric1', 'Count', 47], + ['metric2', 'Count', 50], + ]); + + expect(logMock).toHaveBeenCalledTimes(1); + + const calledWith = logMock.mock.calls[0][0] as string; + + expect(JSON.parse(calledWith)).toEqual({ + _aws: { + Timestamp: new Date('2022-01-01').valueOf(), + CloudWatchMetrics: [ + { + Namespace: 'namespace', + Dimensions: [['Environment']], + Metrics: [ + { + Name: 'metric1', + Unit: 'Count', + StorageResolution: 60, + }, + { + Name: 'metric2', + Unit: 'Count', + StorageResolution: 60, + }, + ], + }, + ], + }, + metric1: 47, + metric2: 50, + Environment: 'internal-dev', + }); +}); + +it('puts metric data with timestamp', () => { + metricHandler.addMetrics(['metric', 'Count', 47], { + timestamp: new Date('2022-01-02'), + }); + + expect(logMock).toHaveBeenCalledTimes(1); + + const lastCalledWith = logMock.mock.calls[0][0] as string; + + expect(JSON.parse(lastCalledWith)).toEqual({ + _aws: { + Timestamp: new Date('2022-01-02').valueOf(), + CloudWatchMetrics: [ + { + Namespace: 'namespace', + Dimensions: [['Environment']], + Metrics: [ + { + Name: 'metric', + Unit: 'Count', + StorageResolution: 60, + }, + ], + }, + ], + }, + metric: 47, + Environment: 'internal-dev', + }); +}); + +it('generates child metric handler', () => { + const childMetricHandler = metricHandler.getChildMetricHandler([ + { + Name: 'Client ID', + Value: 'vaccs', + }, + ]); + + childMetricHandler.addMetrics(['metric', 'Count', 47], { + timestamp: new Date('2022-01-02'), + extraDimensions: [{ Name: 'Request ID', Value: '123' }], + }); + + expect(logMock).toHaveBeenCalledTimes(1); + + const lastCalledWith = logMock.mock.calls[0][0] as string; + + expect(JSON.parse(lastCalledWith)).toEqual({ + _aws: { + Timestamp: new Date('2022-01-02').valueOf(), + CloudWatchMetrics: [ + { + Namespace: 'namespace', + Dimensions: [['Environment', 'Client ID', 'Request ID']], + Metrics: [ + { + Name: 'metric', + Unit: 'Count', + StorageResolution: 60, + }, + ], + }, + ], + }, + metric: 47, + Environment: 'internal-dev', + 'Client ID': 'vaccs', + 'Request ID': '123', + }); +}); diff --git a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts index 83bd09538..7d3d38fbf 100644 --- a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts +++ b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts @@ -8,9 +8,14 @@ import { randomInt, randomUUID } from 'node:crypto'; import { mockClient } from 'aws-sdk-client-mock'; import { Logger } from 'logger'; import { EventPublisher, EventPublisherDependencies } from 'event-publisher'; +import { MetricHandler } from 'cloudwatch/metric-handler'; const eventBridgeMock = mockClient(EventBridgeClient); const sqsMock = mockClient(SQSClient); +const metricHandlerMock: MetricHandler = { + addMetrics: jest.fn(), + getChildMetricHandler: jest.fn(), +} as unknown as MetricHandler; const mockLogger: Logger = { info: jest.fn(), @@ -27,6 +32,7 @@ const testConfig: EventPublisherDependencies = { logger: mockLogger, sqsClient: sqsMock as unknown as SQSClient, eventBridgeClient: eventBridgeMock as unknown as EventBridgeClient, + metricHandler: metricHandlerMock, }; const event: TestEvent = { @@ -57,12 +63,13 @@ describe('Event Publishing', () => { expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(0); expect(sqsMock.calls()).toHaveLength(0); + expect(metricHandlerMock.addMetrics).not.toHaveBeenCalled(); }); test('should send valid events to EventBridge', async () => { eventBridgeMock.on(PutEventsCommand).resolves({ FailedEntryCount: 0, - Entries: [{ EventId: 'event-1' }], + Entries: [{ EventId: 'event-1' }, { EventId: 'event-2' }], }); const publisher = new EventPublisher(testConfig); @@ -71,6 +78,17 @@ describe('Event Publishing', () => { expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); expect(sqsMock.calls()).toHaveLength(0); + // event and event2 have different types, so each produces its own metric + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'uk.nhs.notify.digital.letters.sent.v1_published', + 'Count', + 1, + ]); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'uk.nhs.notify.digital.letters.sent.v2_published', + 'Count', + 1, + ]); const eventBridgeCall = eventBridgeMock.calls()[0]; expect(eventBridgeCall.args[0].input).toEqual({ @@ -152,6 +170,17 @@ describe('Event Publishing', () => { const sqsInput = sqsCall.args[0].input as any; expect(sqsInput.Entries).toHaveLength(1); expect(sqsInput.Entries[0].MessageBody).toBe(JSON.stringify(event)); + // event (sent.v1) fails, event2 (sent.v2) succeeds — each produces its own metric + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'uk.nhs.notify.digital.letters.sent.v2_published', + 'Count', + 1, + ]); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'uk.nhs.notify.digital.letters.sent.v1_not_published', + 'Count', + 1, + ]); }); test('should handle EventBridge send error and send all events to DLQ', async () => { @@ -453,7 +482,143 @@ describe('Event Publishing', () => { ), ), ); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalled(); + }); +}); + +const TRANSITIONED_EVENT_TYPE = + 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1'; + +const makeTransitionedEvent = (id: string, status: string) => ({ + id, + source: '/nhs/england/notify/production/primary/digital-letters', + type: TRANSITIONED_EVENT_TYPE, + data: { status }, +}); + +describe('Metric recording for print.letter.transitioned.v1', () => { + beforeEach(() => { + eventBridgeMock.reset(); + sqsMock.reset(); + jest.clearAllMocks(); }); + + test('should record separate metrics per status when all transitioned events publish successfully', async () => { + const accepted1 = makeTransitionedEvent('t-001', 'ACCEPTED'); + const accepted2 = makeTransitionedEvent('t-002', 'ACCEPTED'); + const rejected = makeTransitionedEvent('t-003', 'REJECTED'); + + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 0, + Entries: [{ EventId: 'e-1' }, { EventId: 'e-2' }, { EventId: 'e-3' }], + }); + + const publisher = new EventPublisher(testConfig); + await publisher.sendEvents([accepted1, accepted2, rejected], () => true); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(2); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_ACCEPTED_published`, + 'Count', + 2, + ]); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_REJECTED_published`, + 'Count', + 1, + ]); + }); + + test('should record separate published and not_published metrics for mixed batch outcome', async () => { + const accepted1 = makeTransitionedEvent('t-001', 'ACCEPTED'); + const accepted2 = makeTransitionedEvent('t-002', 'ACCEPTED'); + const rejected = makeTransitionedEvent('t-003', 'REJECTED'); + + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 1, + Entries: [ + { EventId: 'e-1' }, + { EventId: 'e-2' }, + { ErrorCode: 'AccessDenied', ErrorMessage: 'Access denied' }, + ], + }); + sqsMock.on(SendMessageBatchCommand).resolves({ Successful: [] }); + + const publisher = new EventPublisher(testConfig); + await publisher.sendEvents([accepted1, accepted2, rejected], () => true); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(2); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_ACCEPTED_published`, + 'Count', + 2, + ]); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_REJECTED_not_published`, + 'Count', + 1, + ]); + }); + + const ALL_STATUSES = [ + 'ACCEPTED', + 'REJECTED', + 'PRINTED', + 'DISPATCHED', + 'FAILED', + 'RETURNED', + 'PENDING', + 'ENCLOSED', + 'CANCELLED', + 'FORWARDED', + 'DELIVERED', + ] as const; + + test.each(ALL_STATUSES)( + 'should record %s_published metric when event with status %s is sent successfully', + async (status) => { + const transitionedEvent = makeTransitionedEvent('t-001', status); + + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 0, + Entries: [{ EventId: 'e-1' }], + }); + + const publisher = new EventPublisher(testConfig); + await publisher.sendEvents([transitionedEvent], () => true); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(1); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_${status}_published`, + 'Count', + 1, + ]); + }, + ); + + test.each(ALL_STATUSES)( + 'should record %s_not_published metric when event with status %s is permanently rejected', + async (status) => { + const transitionedEvent = makeTransitionedEvent('t-001', status); + + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 1, + Entries: [{ ErrorCode: 'AccessDenied', ErrorMessage: 'Access denied' }], + }); + sqsMock.on(SendMessageBatchCommand).resolves({ Successful: [] }); + + const publisher = new EventPublisher(testConfig); + await publisher.sendEvents([transitionedEvent], () => true); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(1); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_${status}_not_published`, + 'Count', + 1, + ]); + }, + ); }); describe('EventPublisher Class', () => { diff --git a/utils/utils/src/cloudwatch/index.ts b/utils/utils/src/cloudwatch/index.ts new file mode 100644 index 000000000..e11c8872b --- /dev/null +++ b/utils/utils/src/cloudwatch/index.ts @@ -0,0 +1 @@ +export * from './metric-handler'; diff --git a/utils/utils/src/cloudwatch/metric-handler.ts b/utils/utils/src/cloudwatch/metric-handler.ts new file mode 100644 index 000000000..d0c523443 --- /dev/null +++ b/utils/utils/src/cloudwatch/metric-handler.ts @@ -0,0 +1,100 @@ +import type { Dimension } from '@aws-sdk/client-cloudwatch'; + +export type { Dimension as MetricDimension } from '@aws-sdk/client-cloudwatch'; + +export type MetricUnit = + | 'Seconds' + | 'Microseconds' + | 'Milliseconds' + | 'Bytes' + | 'Kilobytes' + | 'Megabytes' + | 'Gigabytes' + | 'Terabytes' + | 'Bits' + | 'Kilobits' + | 'Megabits' + | 'Gigabits' + | 'Terabits' + | 'Percent' + | 'Count' + | 'Bytes/Second' + | 'Kilobytes/Second' + | 'Megabytes/Second' + | 'Gigabytes/Second' + | 'Terabytes/Second' + | 'Bits/Second' + | 'Kilobits/Second' + | 'Megabits/Second' + | 'Gigabits/Second' + | 'Terabits/Second' + | 'Count/Second' + | 'None'; + +type Metric = [name: string, unit: MetricUnit, value: number]; + +export class MetricHandler { + // Used in add metric calls so that all dimensions can be present in a namespace to simplify aggregation + public static readonly DIMENSION_NOT_APPLICABLE = 'not_applicable'; + + constructor( + private readonly namespace: string, + private readonly dimensions: Dimension[], + ) {} + + public addMetrics( + metricOrMetrics: Metric | Metric[], + options: { + timestamp?: Date; + extraDimensions?: Dimension[]; + storageResolution?: number; + } = {}, + ) { + const { + extraDimensions = [], + storageResolution = 60, + timestamp = new Date(), + } = options; + + const metrics = ( + Array.isArray(metricOrMetrics) && Array.isArray(metricOrMetrics[0]) + ? metricOrMetrics + : [metricOrMetrics] + ) as Metric[]; + + const dimensions: Record = {}; + + for (const dimension of [...this.dimensions, ...extraDimensions]) { + dimensions[dimension.Name as string] = dimension.Value as string; + } + + const metric = { + _aws: { + Timestamp: timestamp.valueOf(), + CloudWatchMetrics: [ + { + Namespace: this.namespace, + Dimensions: [Object.keys(dimensions)], + Metrics: metrics.map(([name, unit]) => ({ + Name: name, + Unit: unit, + StorageResolution: storageResolution, + })), + }, + ], + }, + ...dimensions, + ...Object.fromEntries(metrics.map(([name, , value]) => [name, value])), + }; + process.stdout.write(JSON.stringify(metric)); + } + + public getChildMetricHandler( + childMetricHandlerDimensions: Dimension[], + ): MetricHandler { + return new MetricHandler(this.namespace, [ + ...this.dimensions, + ...childMetricHandlerDimensions, + ]); + } +} diff --git a/utils/utils/src/event-publisher/event-publisher.ts b/utils/utils/src/event-publisher/event-publisher.ts index e4fdc20c9..a30e570f5 100644 --- a/utils/utils/src/event-publisher/event-publisher.ts +++ b/utils/utils/src/event-publisher/event-publisher.ts @@ -5,17 +5,22 @@ import { import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs'; import { randomUUID } from 'node:crypto'; import { Logger } from '../logger'; +import { MetricHandler } from '../cloudwatch/metric-handler'; type DlqReason = 'INVALID_EVENT' | 'EVENTBRIDGE_FAILURE'; const MAX_BATCH_SIZE = 10; +const PRINT_LETTER_TRANSITIONED_EVENT_TYPE = + 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1'; + export interface EventPublisherDependencies { eventBusArn: string; dlqUrl: string; logger: Logger; sqsClient: SQSClient; eventBridgeClient: EventBridgeClient; + metricHandler: MetricHandler; } type PublishableEvent = { id: string; source: string; type: string }; @@ -31,6 +36,8 @@ export class EventPublisher { private readonly logger: Logger; + private readonly metricHandler: MetricHandler; + constructor(config: EventPublisherDependencies) { if (!config.eventBusArn) { throw new Error('eventBusArn has not been specified'); @@ -47,11 +54,15 @@ export class EventPublisher { if (!config.eventBridgeClient) { throw new Error('eventBridgeClient has not been provided'); } + if (!config.metricHandler) { + throw new Error('metricHandler has not been provided'); + } this.config = config; this.logger = config.logger; this.eventBridge = config.eventBridgeClient; this.sqs = config.sqsClient; + this.metricHandler = config.metricHandler; } private async sendToEventBridge( @@ -82,26 +93,32 @@ export class EventPublisher { new PutEventsCommand({ Entries: entries }), ); + const failedEntryCount = response.FailedEntryCount || 0; + const successfulCount = batch.length - failedEntryCount; this.logger.info({ description: 'EventBridge batch sent', batchSize: batch.length, - failedEntryCount: response.FailedEntryCount || 0, - successfulCount: batch.length - (response.FailedEntryCount || 0), + failedEntryCount, + successfulCount, }); - if (response.FailedEntryCount && response.Entries) { - for (const [idx, entry] of response.Entries.entries()) { - if (entry.ErrorCode) { - this.logger.warn({ - description: 'Event failed to send to EventBridge', - errorCode: entry.ErrorCode, - errorMessage: entry.ErrorMessage, - eventId: batch[idx].id, - }); - failedEvents.push(batch[idx]); - } + const failedBatchEvents: T[] = []; + const successfulBatchEvents: T[] = []; + for (const [idx, entry] of (response.Entries ?? []).entries()) { + if (entry.ErrorCode) { + this.logger.warn({ + description: 'Event failed to send to EventBridge', + errorCode: entry.ErrorCode, + errorMessage: entry.ErrorMessage, + eventId: batch[idx].id, + }); + failedBatchEvents.push(batch[idx]); + } else { + successfulBatchEvents.push(batch[idx]); } } + this.recordMetrics(successfulBatchEvents, failedBatchEvents); + failedEvents.push(...failedBatchEvents); } catch (error) { this.logger.warn({ description: 'EventBridge send error', @@ -115,6 +132,42 @@ export class EventPublisher { return failedEvents; } + private recordMetrics( + successfulEvents: T[], + failedEvents: T[], + ): void { + this.recordMetricsForOutcome(successfulEvents, 'published'); + this.recordMetricsForOutcome(failedEvents, 'not_published'); + } + + private recordMetricsForOutcome( + events: T[], + outcome: 'published' | 'not_published', + ): void { + if (events.length === 0) return; + + const counts = new Map(); + for (const event of events) { + let key = event.type; + if (event.type === PRINT_LETTER_TRANSITIONED_EVENT_TYPE) { + const status = (event as Record).data?.status as + | string + | undefined; + if (status) key = `${event.type}_${status}`; + } + counts.set(key, (counts.get(key) ?? 0) + 1); + } + + // Changing metrics names will affect existing alarms and dashboards, please check with Platform / ITOC + for (const [metricKey, count] of counts) { + this.metricHandler.addMetrics([ + `${metricKey}_${outcome}`, + 'Count', + count, + ]); + } + } + private async sendToDLQ( events: T[], reason: DlqReason, diff --git a/utils/utils/src/index.ts b/utils/utils/src/index.ts index 4b9333bc9..b8571fbee 100644 --- a/utils/utils/src/index.ts +++ b/utils/utils/src/index.ts @@ -16,3 +16,4 @@ export * from './key-generation-utils'; export * from './schema-utils'; export * from './pdm-client'; export * from './reporting'; +export * from './cloudwatch'; From c6dd8b362917cccf999d46dba5afbad342507c0d Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Thu, 7 May 2026 12:29:23 +0100 Subject: [PATCH 2/5] adding the record metric in the finally --- .../event-publisher/event-publisher.test.ts | 33 +++++++++++++++++++ .../src/event-publisher/event-publisher.ts | 10 ++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts index 7d3d38fbf..ed7ed74f9 100644 --- a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts +++ b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts @@ -199,6 +199,17 @@ describe('Event Publishing', () => { expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); expect(sqsMock.calls()).toHaveLength(1); + // failure metrics must be recorded even when EventBridge throws (catch path) + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'uk.nhs.notify.digital.letters.sent.v1_not_published', + 'Count', + 1, + ]); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'uk.nhs.notify.digital.letters.sent.v2_not_published', + 'Count', + 1, + ]); }); test('should return failed events when DLQ also fails', async () => { @@ -619,6 +630,28 @@ describe('Metric recording for print.letter.transitioned.v1', () => { ]); }, ); + + test.each(ALL_STATUSES)( + 'should record %s_not_published metric when EventBridge throws for event with status %s', + async (status) => { + const transitionedEvent = makeTransitionedEvent('t-001', status); + + eventBridgeMock + .on(PutEventsCommand) + .rejects(new Error('EventBridge connection error')); + sqsMock.on(SendMessageBatchCommand).resolves({ Successful: [] }); + + const publisher = new EventPublisher(testConfig); + await publisher.sendEvents([transitionedEvent], () => true); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(1); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${TRANSITIONED_EVENT_TYPE}_${status}_not_published`, + 'Count', + 1, + ]); + }, + ); }); describe('EventPublisher Class', () => { diff --git a/utils/utils/src/event-publisher/event-publisher.ts b/utils/utils/src/event-publisher/event-publisher.ts index a30e570f5..78449cf0b 100644 --- a/utils/utils/src/event-publisher/event-publisher.ts +++ b/utils/utils/src/event-publisher/event-publisher.ts @@ -81,6 +81,9 @@ export class EventPublisher { batchSize: batch.length, }); + const failedBatchEvents: T[] = []; + const successfulBatchEvents: T[] = []; + try { const entries = batch.map((event) => ({ Source: event.source, @@ -102,8 +105,6 @@ export class EventPublisher { successfulCount, }); - const failedBatchEvents: T[] = []; - const successfulBatchEvents: T[] = []; for (const [idx, entry] of (response.Entries ?? []).entries()) { if (entry.ErrorCode) { this.logger.warn({ @@ -117,7 +118,7 @@ export class EventPublisher { successfulBatchEvents.push(batch[idx]); } } - this.recordMetrics(successfulBatchEvents, failedBatchEvents); + failedEvents.push(...failedBatchEvents); } catch (error) { this.logger.warn({ @@ -125,7 +126,10 @@ export class EventPublisher { err: error, batchSize: batch.length, }); + failedBatchEvents.push(...batch); failedEvents.push(...batch); + } finally { + this.recordMetrics(successfulBatchEvents, failedBatchEvents); } } From 9f77ad935debca568bd7b0c7da4c68d0aed34579 Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Mon, 11 May 2026 16:57:56 +0100 Subject: [PATCH 3/5] CCM-17116: review comments --- .../apis/scheduled-event-handler.test.ts | 8 ++--- .../src/apis/scheduled-event-handler.ts | 2 +- .../dl_utils/__tests__/test_metric_client.py | 32 +++++++++++++++++++ utils/py-utils/dl_utils/metric_client.py | 4 +-- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts b/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts index cd143a7d5..200ca1044 100644 --- a/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts +++ b/lambdas/report-scheduler/src/__tests__/apis/scheduled-event-handler.test.ts @@ -80,7 +80,7 @@ describe('scheduled-event-handler', () => { expect(events).toHaveLength(3); expect(validator).toBeDefined(); expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ - 'TotalSenders', + 'dl-report-scheduler-total-senders', 'Count', 3, ]); @@ -130,7 +130,7 @@ describe('scheduled-event-handler', () => { expect(() => validateGenerateReport(event, mockLogger)).not.toThrow(); expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ - 'TotalSenders', + 'dl-report-scheduler-total-senders', 'Count', 1, ]); @@ -151,7 +151,7 @@ describe('scheduled-event-handler', () => { const [[events]] = mockEventPublisher.sendEvents.mock.calls; expect(events).toHaveLength(0); expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ - 'TotalSenders', + 'dl-report-scheduler-total-senders', 'Count', 0, ]); @@ -196,7 +196,7 @@ describe('scheduled-event-handler', () => { expect(new Set(eventIds).size).toBe(eventIds.length); expect(mockMetricHandler.addMetrics).toHaveBeenCalledWith([ - 'TotalSenders', + 'dl-report-scheduler-total-senders', 'Count', 2, ]); diff --git a/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts b/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts index d85d37635..2ba28f6fa 100644 --- a/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts +++ b/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts @@ -45,6 +45,6 @@ export const createHandler = ({ })), validateGenerateReport, ); - metricHandler.addMetrics(['TotalSenders', 'Count', senders.length]); + metricHandler.addMetrics(['dl-report-scheduler-total-senders', 'Count', senders.length]); }; }; diff --git a/utils/py-utils/dl_utils/__tests__/test_metric_client.py b/utils/py-utils/dl_utils/__tests__/test_metric_client.py index 6d22cf0a4..ee5a202de 100644 --- a/utils/py-utils/dl_utils/__tests__/test_metric_client.py +++ b/utils/py-utils/dl_utils/__tests__/test_metric_client.py @@ -35,3 +35,35 @@ def test_metric(mock_print): "Environment": "de-test1", "Test_alarm_1": 56, } + + +@patch('builtins.print') +@patch('time.time', Mock(return_value=1234567890)) +def test_metric_record_name_override(mock_print): + + m = Metric(name='default_name', namespace='test_namespace', dimensions={"Environment": 'test-env'}) + m.record(10, name='override_name') + + mock_print.assert_called_once() + + arg = mock_print.call_args[0][0] + + assert json.loads(arg) == { + "_aws": { + "Timestamp": 1234567890000, + "CloudWatchMetrics": [{ + "Namespace": "test_namespace", + "Dimensions": [ + ["Environment"] + ], + "Metrics": [ + { + "Name": "override_name", + "Unit": "Count", + } + ] + }], + }, + "Environment": "test-env", + "override_name": 10, + } diff --git a/utils/py-utils/dl_utils/metric_client.py b/utils/py-utils/dl_utils/metric_client.py index 0c7f5a5cd..81ed3276c 100644 --- a/utils/py-utils/dl_utils/metric_client.py +++ b/utils/py-utils/dl_utils/metric_client.py @@ -17,11 +17,11 @@ def __init__(self, **kwargs): self.unit = kwargs.get("unit", 'Count') def record(self, value, **kwargs): - - metric_name = kwargs.get('name', self.name) """ method for reporting metric """ + + metric_name = kwargs.get('name', self.name) print(json.dumps({ "_aws": { "Timestamp": int(time.time() * 1000), From 907d6904f6dc3f9bae9280a17df184b1d30b991b Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Mon, 11 May 2026 17:30:51 +0100 Subject: [PATCH 4/5] Address review comments --- .../src/__tests__/container.test.ts | 54 ++++++++++ lambdas/print-status-handler/src/container.ts | 7 ++ .../src/apis/scheduled-event-handler.ts | 6 +- .../event-publisher/event-publisher.test.ts | 100 +++++++++++++++++- .../src/event-publisher/event-publisher.ts | 14 +-- 5 files changed, 164 insertions(+), 17 deletions(-) diff --git a/lambdas/print-status-handler/src/__tests__/container.test.ts b/lambdas/print-status-handler/src/__tests__/container.test.ts index 75af881d3..dc902a29a 100644 --- a/lambdas/print-status-handler/src/__tests__/container.test.ts +++ b/lambdas/print-status-handler/src/__tests__/container.test.ts @@ -1,4 +1,6 @@ import { createContainer } from 'container'; +import { EventPublisher } from 'utils'; +import type { PublishableEvent } from 'utils'; jest.mock('infra/config', () => ({ loadConfig: jest.fn(() => ({ @@ -17,9 +19,61 @@ jest.mock('utils', () => ({ sqsClient: {}, })); +const MockedEventPublisher = EventPublisher as jest.MockedClass< + typeof EventPublisher +>; + describe('container', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should create container', () => { const container = createContainer(); expect(container).toBeDefined(); }); + + it('should construct EventPublisher with a metricKeyResolver', () => { + createContainer(); + expect(MockedEventPublisher).toHaveBeenCalledTimes(1); + const [config] = MockedEventPublisher.mock.calls[0]; + expect(typeof config.metricKeyResolver).toBe('function'); + }); +}); + +describe('metricKeyResolver', () => { + const TRANSITIONED_EVENT_TYPE = + 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1'; + + let resolver: (event: PublishableEvent) => string; + + beforeEach(() => { + jest.clearAllMocks(); + createContainer(); + const [config] = MockedEventPublisher.mock.calls[0]; + resolver = config.metricKeyResolver!; + }); + + it('should return type_status when event has data.status', () => { + const event = { + type: TRANSITIONED_EVENT_TYPE, + data: { status: 'ACCEPTED' }, + }; + expect(resolver(event as any)).toBe(`${TRANSITIONED_EVENT_TYPE}_ACCEPTED`); + }); + + it('should return event.type when event has data but no status', () => { + const event = { type: TRANSITIONED_EVENT_TYPE, data: {} }; + expect(resolver(event as any)).toBe(TRANSITIONED_EVENT_TYPE); + }); + + it('should return event.type when event has no data', () => { + const event = { type: TRANSITIONED_EVENT_TYPE }; + expect(resolver(event as any)).toBe(TRANSITIONED_EVENT_TYPE); + }); + + it('should work for arbitrary event types with a status', () => { + const event = { type: 'some.other.event.v1', data: { status: 'FAILED' } }; + expect(resolver(event as any)).toBe('some.other.event.v1_FAILED'); + }); }); diff --git a/lambdas/print-status-handler/src/container.ts b/lambdas/print-status-handler/src/container.ts index 971ebe65b..f0de2914a 100644 --- a/lambdas/print-status-handler/src/container.ts +++ b/lambdas/print-status-handler/src/container.ts @@ -25,6 +25,13 @@ export const createContainer = (): HandlerDependencies => { metricHandler: new MetricHandler(dlMetricsNamespace, [ { Name: 'Environment', Value: environment }, ]), + metricKeyResolver: (event) => { + const status = + (event as Record).data && + (((event as Record).data as Record) + .status as string | undefined); + return status ? `${event.type}_${status}` : event.type; + }, }); return { eventPublisher, logger }; diff --git a/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts b/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts index 2ba28f6fa..155433984 100644 --- a/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts +++ b/lambdas/report-scheduler/src/apis/scheduled-event-handler.ts @@ -45,6 +45,10 @@ export const createHandler = ({ })), validateGenerateReport, ); - metricHandler.addMetrics(['dl-report-scheduler-total-senders', 'Count', senders.length]); + metricHandler.addMetrics([ + 'dl-report-scheduler-total-senders', + 'Count', + senders.length, + ]); }; }; diff --git a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts index ed7ed74f9..38df1abb2 100644 --- a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts +++ b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts @@ -35,6 +35,20 @@ const testConfig: EventPublisherDependencies = { metricHandler: metricHandlerMock, }; +const statusKeyResolver = (e: { type: string }) => { + const status = + (e as Record).data && + (((e as Record).data as Record).status as + | string + | undefined); + return status ? `${e.type}_${status}` : e.type; +}; + +const testConfigWithResolver: EventPublisherDependencies = { + ...testConfig, + metricKeyResolver: statusKeyResolver, +}; + const event: TestEvent = { id: '550e8400-e29b-41d4-a716-446655440001', source: '/nhs/england/notify/production/primary/digital-letters', @@ -525,7 +539,7 @@ describe('Metric recording for print.letter.transitioned.v1', () => { Entries: [{ EventId: 'e-1' }, { EventId: 'e-2' }, { EventId: 'e-3' }], }); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher(testConfigWithResolver); await publisher.sendEvents([accepted1, accepted2, rejected], () => true); expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(2); @@ -556,7 +570,7 @@ describe('Metric recording for print.letter.transitioned.v1', () => { }); sqsMock.on(SendMessageBatchCommand).resolves({ Successful: [] }); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher(testConfigWithResolver); await publisher.sendEvents([accepted1, accepted2, rejected], () => true); expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(2); @@ -596,7 +610,7 @@ describe('Metric recording for print.letter.transitioned.v1', () => { Entries: [{ EventId: 'e-1' }], }); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher(testConfigWithResolver); await publisher.sendEvents([transitionedEvent], () => true); expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(1); @@ -619,7 +633,7 @@ describe('Metric recording for print.letter.transitioned.v1', () => { }); sqsMock.on(SendMessageBatchCommand).resolves({ Successful: [] }); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher(testConfigWithResolver); await publisher.sendEvents([transitionedEvent], () => true); expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(1); @@ -641,7 +655,7 @@ describe('Metric recording for print.letter.transitioned.v1', () => { .rejects(new Error('EventBridge connection error')); sqsMock.on(SendMessageBatchCommand).resolves({ Successful: [] }); - const publisher = new EventPublisher(testConfig); + const publisher = new EventPublisher(testConfigWithResolver); await publisher.sendEvents([transitionedEvent], () => true); expect(metricHandlerMock.addMetrics).toHaveBeenCalledTimes(1); @@ -654,6 +668,82 @@ describe('Metric recording for print.letter.transitioned.v1', () => { ); }); +describe('metricKeyResolver', () => { + beforeEach(() => { + eventBridgeMock.reset(); + sqsMock.reset(); + jest.clearAllMocks(); + }); + + test('should use event.type as metric key when no resolver is provided', async () => { + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 0, + Entries: [{ EventId: 'e-1' }], + }); + + const publisher = new EventPublisher(testConfig); + await publisher.sendEvents([event], () => true); + + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `${event.type}_published`, + 'Count', + 1, + ]); + }); + + test('should use the resolver return value as metric key when a resolver is provided', async () => { + const customResolver = jest.fn(() => 'custom_metric_key'); + const publisherWithResolver = new EventPublisher({ + ...testConfig, + metricKeyResolver: customResolver, + }); + + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 0, + Entries: [{ EventId: 'e-1' }], + }); + + await publisherWithResolver.sendEvents([event], () => true); + + expect(customResolver).toHaveBeenCalledWith(event); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + 'custom_metric_key_published', + 'Count', + 1, + ]); + }); + + test('should call the resolver for each event independently', async () => { + const customResolver = jest.fn( + (e: { type: string }) => `resolved_${e.type}`, + ); + const publisherWithResolver = new EventPublisher({ + ...testConfig, + metricKeyResolver: customResolver, + }); + + eventBridgeMock.on(PutEventsCommand).resolves({ + FailedEntryCount: 0, + Entries: [{ EventId: 'e-1' }, { EventId: 'e-2' }], + }); + + await publisherWithResolver.sendEvents([event, event2], () => true); + + expect(customResolver).toHaveBeenCalledWith(event); + expect(customResolver).toHaveBeenCalledWith(event2); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `resolved_${event.type}_published`, + 'Count', + 1, + ]); + expect(metricHandlerMock.addMetrics).toHaveBeenCalledWith([ + `resolved_${event2.type}_published`, + 'Count', + 1, + ]); + }); +}); + describe('EventPublisher Class', () => { beforeEach(() => { eventBridgeMock.reset(); diff --git a/utils/utils/src/event-publisher/event-publisher.ts b/utils/utils/src/event-publisher/event-publisher.ts index 78449cf0b..a28b9fade 100644 --- a/utils/utils/src/event-publisher/event-publisher.ts +++ b/utils/utils/src/event-publisher/event-publisher.ts @@ -11,9 +11,6 @@ type DlqReason = 'INVALID_EVENT' | 'EVENTBRIDGE_FAILURE'; const MAX_BATCH_SIZE = 10; -const PRINT_LETTER_TRANSITIONED_EVENT_TYPE = - 'uk.nhs.notify.digital.letters.print.letter.transitioned.v1'; - export interface EventPublisherDependencies { eventBusArn: string; dlqUrl: string; @@ -21,9 +18,10 @@ export interface EventPublisherDependencies { sqsClient: SQSClient; eventBridgeClient: EventBridgeClient; metricHandler: MetricHandler; + metricKeyResolver?: (event: PublishableEvent) => string; } -type PublishableEvent = { id: string; source: string; type: string }; +export type PublishableEvent = { id: string; source: string; type: string }; type EventValidationFunction = (event: T, logger: Logger) => void; @@ -152,13 +150,7 @@ export class EventPublisher { const counts = new Map(); for (const event of events) { - let key = event.type; - if (event.type === PRINT_LETTER_TRANSITIONED_EVENT_TYPE) { - const status = (event as Record).data?.status as - | string - | undefined; - if (status) key = `${event.type}_${status}`; - } + const key = this.config.metricKeyResolver?.(event) ?? event.type; counts.set(key, (counts.get(key) ?? 0) + 1); } From d3a43790158e147a52abd43be504a358d16f4391 Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Tue, 12 May 2026 14:50:46 +0100 Subject: [PATCH 5/5] update shard name in IT results --- .github/actions/acceptance-tests/action.yaml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index d6735f77a..02c1adac8 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -77,9 +77,24 @@ runs: TEST_TYPE: ${{ inputs.testType }} ENVIRONMENT: ${{ inputs.targetEnvironment }} PLAYWRIGHT_SHARD: ${{ inputs.shard }} + - name: Extract shard index + id: extract_shard + shell: bash + env: + SHARD: ${{ inputs.shard }} + run: | + shard="${SHARD:-}" + + if [ -z "$shard" ]; then + shard_index="all" + else + shard_index="${shard%%/*}" + fi + + echo "shard_index=$shard_index" >> "$GITHUB_OUTPUT" - name: Archive integration test results if: ${{ inputs.testType == 'integration' }} uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: - name: Integration test report + name: Integration test report - ${{ steps.extract_shard.outputs.shard_index }} path: "tests/playwright/playwright-report"