From 5c2e813cf53acc5ec5932e01912982448c1ca064 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Mon, 29 Dec 2025 15:53:42 +0000 Subject: [PATCH 01/30] wip --- infrastructure/bootstrap/hub.bicep | 1 + .../bootstrap/modules/managedDevopsPool.bicep | 108 +++++++++--------- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/infrastructure/bootstrap/hub.bicep b/infrastructure/bootstrap/hub.bicep index b17eaa81..6c18d65d 100644 --- a/infrastructure/bootstrap/hub.bicep +++ b/infrastructure/bootstrap/hub.bicep @@ -18,6 +18,7 @@ targetScope = 'subscription' +// param devopsInfrastructureId string param devopsSubnetAddressPrefix string param privateEndpointSubnetAddressPrefix string param hubType string // live / nonlive diff --git a/infrastructure/bootstrap/modules/managedDevopsPool.bicep b/infrastructure/bootstrap/modules/managedDevopsPool.bicep index d733b19c..c98322ee 100644 --- a/infrastructure/bootstrap/modules/managedDevopsPool.bicep +++ b/infrastructure/bootstrap/modules/managedDevopsPool.bicep @@ -47,57 +47,57 @@ resource devCenterProject 'Microsoft.DevCenter/projects@2025-02-01' = { } } -resource pool 'microsoft.devopsinfrastructure/pools@2025-09-20' = { - name: poolName - location: location - properties: { - organizationProfile: { - organizations: [ - { - url: 'https://dev.azure.com/${adoOrg}' - parallelism: 1 - } - ] - permissionProfile: { - kind: 'CreatorOnly' - } - kind: 'AzureDevOps' - } - devCenterProjectResourceId: devCenterProject.id - maximumConcurrency: poolSize - agentProfile: { - kind: 'Stateful' // or 'Stateless' - VM creation for each job, which tends to be too slow - maxAgentLifetime: agentProfileMaxAgentLifetime // Only allowed if kind is Stateful - // gracePeriodTimeSpan: '00:30:00' // Only allowed if kind is Stateful - resourcePredictionsProfile: { - kind: 'Automatic' // 'Manual' or 'Automatic' - predictionPreference: 'Balanced' - } - } - fabricProfile: { - sku: { - name: fabricProfileSkuName - } - images: [ - { - aliases: [ - 'ubuntu-22.04' - 'ubuntu-22.04/latest' - ] - wellKnownImageName: 'ubuntu-22.04' - } - ] - osProfile: { - logonType: 'Service' // or Interactive - } - storageProfile: { - osDiskStorageAccountType: 'StandardSSD' // StandardSSD, Standard, or Premium - } - // Remove if you want to use 'Isolated Virtual Network' - networkProfile: { - subnetId: devopsSubnet.id - } - kind: 'Vmss' - } - } -} +// resource pool 'microsoft.devopsinfrastructure/pools@2025-09-20' = { +// name: poolName +// location: location +// properties: { +// organizationProfile: { +// organizations: [ +// { +// url: 'https://dev.azure.com/${adoOrg}' +// parallelism: 1 +// } +// ] +// permissionProfile: { +// kind: 'CreatorOnly' +// } +// kind: 'AzureDevOps' +// } +// devCenterProjectResourceId: devCenterProject.id +// maximumConcurrency: poolSize +// agentProfile: { +// kind: 'Stateful' // or 'Stateless' - VM creation for each job, which tends to be too slow +// maxAgentLifetime: agentProfileMaxAgentLifetime // Only allowed if kind is Stateful +// // gracePeriodTimeSpan: '00:30:00' // Only allowed if kind is Stateful +// resourcePredictionsProfile: { +// kind: 'Automatic' // 'Manual' or 'Automatic' +// predictionPreference: 'Balanced' +// } +// } +// fabricProfile: { +// sku: { +// name: fabricProfileSkuName +// } +// images: [ +// { +// aliases: [ +// 'ubuntu-22.04' +// 'ubuntu-22.04/latest' +// ] +// wellKnownImageName: 'ubuntu-22.04' +// } +// ] +// osProfile: { +// logonType: 'Service' // or Interactive +// } +// storageProfile: { +// osDiskStorageAccountType: 'StandardSSD' // StandardSSD, Standard, or Premium +// } +// // Remove if you want to use 'Isolated Virtual Network' +// networkProfile: { +// subnetId: devopsSubnet.id +// } +// kind: 'Vmss' +// } +// } +// } From 2b0f8a0a31c78644b35241f943a233264115546f Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Tue, 30 Dec 2025 10:32:19 +0000 Subject: [PATCH 02/30] wip --- infrastructure/bootstrap/hub.bicep | 4 + .../bootstrap/modules/managedDevopsPool.bicep | 108 +++++++++--------- 2 files changed, 58 insertions(+), 54 deletions(-) diff --git a/infrastructure/bootstrap/hub.bicep b/infrastructure/bootstrap/hub.bicep index 6c18d65d..ff9810ea 100644 --- a/infrastructure/bootstrap/hub.bicep +++ b/infrastructure/bootstrap/hub.bicep @@ -26,6 +26,10 @@ param region string = 'uksouth' param regionShortName string = 'uks' param vnetAddressPrefixes array param enableSoftDelete bool +<<<<<<< HEAD +======= + +>>>>>>> 93647fb (wip) // removed when generalised var appShortName = 'lungcs' diff --git a/infrastructure/bootstrap/modules/managedDevopsPool.bicep b/infrastructure/bootstrap/modules/managedDevopsPool.bicep index c98322ee..d733b19c 100644 --- a/infrastructure/bootstrap/modules/managedDevopsPool.bicep +++ b/infrastructure/bootstrap/modules/managedDevopsPool.bicep @@ -47,57 +47,57 @@ resource devCenterProject 'Microsoft.DevCenter/projects@2025-02-01' = { } } -// resource pool 'microsoft.devopsinfrastructure/pools@2025-09-20' = { -// name: poolName -// location: location -// properties: { -// organizationProfile: { -// organizations: [ -// { -// url: 'https://dev.azure.com/${adoOrg}' -// parallelism: 1 -// } -// ] -// permissionProfile: { -// kind: 'CreatorOnly' -// } -// kind: 'AzureDevOps' -// } -// devCenterProjectResourceId: devCenterProject.id -// maximumConcurrency: poolSize -// agentProfile: { -// kind: 'Stateful' // or 'Stateless' - VM creation for each job, which tends to be too slow -// maxAgentLifetime: agentProfileMaxAgentLifetime // Only allowed if kind is Stateful -// // gracePeriodTimeSpan: '00:30:00' // Only allowed if kind is Stateful -// resourcePredictionsProfile: { -// kind: 'Automatic' // 'Manual' or 'Automatic' -// predictionPreference: 'Balanced' -// } -// } -// fabricProfile: { -// sku: { -// name: fabricProfileSkuName -// } -// images: [ -// { -// aliases: [ -// 'ubuntu-22.04' -// 'ubuntu-22.04/latest' -// ] -// wellKnownImageName: 'ubuntu-22.04' -// } -// ] -// osProfile: { -// logonType: 'Service' // or Interactive -// } -// storageProfile: { -// osDiskStorageAccountType: 'StandardSSD' // StandardSSD, Standard, or Premium -// } -// // Remove if you want to use 'Isolated Virtual Network' -// networkProfile: { -// subnetId: devopsSubnet.id -// } -// kind: 'Vmss' -// } -// } -// } +resource pool 'microsoft.devopsinfrastructure/pools@2025-09-20' = { + name: poolName + location: location + properties: { + organizationProfile: { + organizations: [ + { + url: 'https://dev.azure.com/${adoOrg}' + parallelism: 1 + } + ] + permissionProfile: { + kind: 'CreatorOnly' + } + kind: 'AzureDevOps' + } + devCenterProjectResourceId: devCenterProject.id + maximumConcurrency: poolSize + agentProfile: { + kind: 'Stateful' // or 'Stateless' - VM creation for each job, which tends to be too slow + maxAgentLifetime: agentProfileMaxAgentLifetime // Only allowed if kind is Stateful + // gracePeriodTimeSpan: '00:30:00' // Only allowed if kind is Stateful + resourcePredictionsProfile: { + kind: 'Automatic' // 'Manual' or 'Automatic' + predictionPreference: 'Balanced' + } + } + fabricProfile: { + sku: { + name: fabricProfileSkuName + } + images: [ + { + aliases: [ + 'ubuntu-22.04' + 'ubuntu-22.04/latest' + ] + wellKnownImageName: 'ubuntu-22.04' + } + ] + osProfile: { + logonType: 'Service' // or Interactive + } + storageProfile: { + osDiskStorageAccountType: 'StandardSSD' // StandardSSD, Standard, or Premium + } + // Remove if you want to use 'Isolated Virtual Network' + networkProfile: { + subnetId: devopsSubnet.id + } + kind: 'Vmss' + } + } +} From bca5238dde8e8e4926aaeb605ddddd0760d7f10d Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Thu, 9 Apr 2026 12:14:51 +0100 Subject: [PATCH 03/30] wip --- docs/infrastructure/create-environment.md | 13 +++++++ infrastructure/bootstrap/hub.bicep | 4 --- .../modules/container-apps/alerts.tf | 35 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 infrastructure/modules/container-apps/alerts.tf diff --git a/docs/infrastructure/create-environment.md b/docs/infrastructure/create-environment.md index d12dd4ae..f75a1d43 100644 --- a/docs/infrastructure/create-environment.md +++ b/docs/infrastructure/create-environment.md @@ -133,3 +133,16 @@ Add the infrastructure secrets to the _inf_ key vault `kv-lungcs-[environment]-i - assign yourself "Key Vault Secrets User" to application key vault to run the terraform code from the CLI inside the AVD when first trying to deploy the application. - assign yourself "Data Blob Reader" to State file storage account to run the terraform code from the CLI inside the AVD when first trying to deploy the application. + +## Connect to Postgres Database + +- Add your user as a memeber to the respective Entra ID group: + - `postgres_lungcs_[environment]_uks_admin` +- Log into the correct ADV for your environment type (either nonlive or live) +- Run the following commands on the CLI to log into the database: - + - `export PGPASSWORD="$(az account get-access-token --resource https://ossrdbms-aad.database.windows.net --query accessToken --output tsv)"` + - `psql "host=postgres-lungcs-[environment]-uks.postgres.database.azure.com \ + port=5432 \ + dbname=[database] \ + user=postgres_lungcs_[environment]_uks_admin \ + sslmode=require"` diff --git a/infrastructure/bootstrap/hub.bicep b/infrastructure/bootstrap/hub.bicep index ff9810ea..6c18d65d 100644 --- a/infrastructure/bootstrap/hub.bicep +++ b/infrastructure/bootstrap/hub.bicep @@ -26,10 +26,6 @@ param region string = 'uksouth' param regionShortName string = 'uks' param vnetAddressPrefixes array param enableSoftDelete bool -<<<<<<< HEAD -======= - ->>>>>>> 93647fb (wip) // removed when generalised var appShortName = 'lungcs' diff --git a/infrastructure/modules/container-apps/alerts.tf b/infrastructure/modules/container-apps/alerts.tf new file mode 100644 index 00000000..29db86db --- /dev/null +++ b/infrastructure/modules/container-apps/alerts.tf @@ -0,0 +1,35 @@ +resource "azurerm_monitor_scheduled_query_rules_alert_v2" "500_error_alert" { + count = var.enable_alerting ? 1 : 0 + + auto_mitigation_enabled = false + description = "An alert triggered by 500 errors logged in code" + enabled = var.enable_alerting + evaluation_frequency = "PT5M" + location = var.region + name = "${var.app_short_name}-500-error-alert" + resource_group_name = azurerm_resource_group.main.name + scopes = [var.log_analytics_workspace_id] + severity = 2 + skip_query_validation = false + window_duration = "PT5M" + workspace_alerts_storage_enabled = false + + action { + action_groups = [var.action_group_id] + } + + criteria { + operator = "GreaterThan" + query = <<-QUERY + ContainerAppConsoleLogs_CL + | where Log_s contains "500" + QUERY + threshold = 0 + time_aggregation_method = "Count" + + failing_periods { + minimum_failing_periods_to_trigger_alert = 1 + number_of_evaluation_periods = 1 + } + } +} From 8fdb97a681824f5b0ffdf88e1eb936699cf57fc0 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 11:41:17 +0100 Subject: [PATCH 04/30] attempt to create custom metrics --- infrastructure/modules/container-apps/main.tf | 6 ++- .../modules/container-apps/variables.tf | 6 ++- infrastructure/modules/infra/output.tf | 4 ++ infrastructure/terraform/spoke/main.tf | 1 + .../questions/models/base.py | 30 ++++++++++- lung_cancer_screening/services/metrics.py | 50 +++++++++++++++++++ lung_cancer_screening/urls.py | 2 - 7 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 lung_cancer_screening/services/metrics.py diff --git a/infrastructure/modules/container-apps/main.tf b/infrastructure/modules/container-apps/main.tf index f5d947f8..fcd6ff77 100644 --- a/infrastructure/modules/container-apps/main.tf +++ b/infrastructure/modules/container-apps/main.tf @@ -36,8 +36,10 @@ module "webapp" { }, var.deploy_database_as_container ? local.container_db_env : local.azure_db_env ) - secret_variables = var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} - is_web_app = true + secret_variables = merge ( + { APPLICATIONINSIGHTS_CONNECTION_STRING = var.app_insights_connection_string }, + var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} is_web_app = true + ) port = 8000 probe_path = "/healthcheck" min_replicas = var.min_replicas diff --git a/infrastructure/modules/container-apps/variables.tf b/infrastructure/modules/container-apps/variables.tf index 5c752459..3d7363e3 100644 --- a/infrastructure/modules/container-apps/variables.tf +++ b/infrastructure/modules/container-apps/variables.tf @@ -144,7 +144,6 @@ variable "app_insights_id" { type = string } - variable "region" { description = "The region to deploy in" type = string @@ -196,6 +195,11 @@ variable "infra_key_vault_rg" { type = string } +variable "app_insights_connection_string" { + description = "The Application Insights connection string." + type = string +} + locals { resource_group_name = "rg-${var.app_short_name}-${var.environment}-container-app-uks" diff --git a/infrastructure/modules/infra/output.tf b/infrastructure/modules/infra/output.tf index 922cf5c1..9aca2dfc 100644 --- a/infrastructure/modules/infra/output.tf +++ b/infrastructure/modules/infra/output.tf @@ -33,3 +33,7 @@ output "postgres_subnet_id" { output "main_subnet_id" { value = module.main_subnet.id } + +output "app_insights_connection_string" { + value = module.app_insights_audit.connection_string +} diff --git a/infrastructure/terraform/spoke/main.tf b/infrastructure/terraform/spoke/main.tf index d3626dd8..8bccee1f 100644 --- a/infrastructure/terraform/spoke/main.tf +++ b/infrastructure/terraform/spoke/main.tf @@ -40,6 +40,7 @@ module "container-apps" { enable_alerting = var.enable_alerting app_key_vault_id = var.deploy_infra ? module.infra[0].app_key_vault_id : data.azurerm_key_vault.app_key_vault[0].id app_short_name = var.app_short_name + app_insights_connection_string = var.deploy_infra ? module.infra[0].app_insights_connection_string : data.azurerm_application_insights.app_insights[0].connection_string app_insights_id = var.deploy_infra ? module.infra[0].app_insights_id : data.azurerm_application_insights.app_insights[0].id container_app_environment_id = var.deploy_infra ? module.infra[0].container_app_environment_id : data.azurerm_container_app_environment.this[0].id default_domain = var.deploy_infra ? module.infra[0].default_domain : data.azurerm_container_app_environment.this[0].default_domain diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py index 478dce63..7917f9bf 100644 --- a/lung_cancer_screening/questions/models/base.py +++ b/lung_cancer_screening/questions/models/base.py @@ -1,14 +1,17 @@ from django.db import models +from .metrics import Metrics +import logging +logger = logging.getLogger(__name__) -class BaseQuerySet(models.QuerySet): +class (models.QuerySet): def get_or_build(self, **kwargs): """ Get an existing object matching the kwargs, or build a new unsaved instance. Returns a tuple of (object, created) where created is True if a new instance was built. """ # Check if any kwargs are unsaved model instances - for key, value in kwargs.items(): + for _, value in kwargs.items(): if isinstance(value, models.Model) and value.pk is None: # If we have an unsaved instance, just build a new one return (self.model(**kwargs), True) @@ -28,6 +31,29 @@ class Meta: objects = BaseQuerySet.as_manager() + @property + def model_name(self) -> str: + return self._meta.label_lower + def save(self, *args, **kwargs): + is_create = self.pk is None + + old_status = None + if not is_create and hasattr(self, "status"): + old_status = ( + self.__class__.objects.filter(pk=self.pk) + .values_list("status", flat=True) + .first() + ) + + self.full_clean() # Validate before saving super().save(*args, **kwargs) + + metrics = Metrics() + + if is_create: + metrics.record_request_created(self.model_name) + + if hasattr(self, "status") and self.status == "submitted" and old_status != "submitted": + metrics.record_request_submitted(self.model_name) diff --git a/lung_cancer_screening/services/metrics.py b/lung_cancer_screening/services/metrics.py new file mode 100644 index 00000000..1f075abe --- /dev/null +++ b/lung_cancer_screening/services/metrics.py @@ -0,0 +1,50 @@ +import logging +import os + +from azure.monitor.opentelemetry.exporter import AzureMonitorMetricExporter +from opentelemetry import metrics +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + +logger = logging.getLogger(__name__) + + +class Metrics: + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + environment = os.getenv("ENVIRONMENT") + logger.debug((f"Initialising Metrics(environment: {environment})")) + + exporter = AzureMonitorMetricExporter( + connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") + ) + metrics.set_meter_provider( + MeterProvider(metric_readers=[PeriodicExportingMetricReader(exporter)]) + ) + self.meter = metrics.get_meter(__name__) + self.environment = environment + + def set_gauge_value(self, metric_name, units, description, value): + logger.debug( + ( + f"Metrics: set_gauge_value(metric_name: {metric_name} " + f"units: {units}, description: {description}, value: {value})" + ) + ) + + # Create gauge metric + gauge = self.meter.create_gauge( + metric_name, unit=units, description=description + ) + + # Set metric value + gauge.set( + value, + {"environment": self.environment}, + ) diff --git a/lung_cancer_screening/urls.py b/lung_cancer_screening/urls.py index df3cebfd..d9ee6257 100644 --- a/lung_cancer_screening/urls.py +++ b/lung_cancer_screening/urls.py @@ -28,14 +28,12 @@ def sha_view(request): return HttpResponse(settings.COMMIT_SHA) - @require_GET @basic_auth_exempt @login_not_required def health_check(request): return HttpResponse("OK") - urlpatterns = [ path('', include( ("lung_cancer_screening.questions.urls", "questions"), From 4ab2ec9ba555bbc530de2edf7ce0cfa84851a1ba Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 12:03:37 +0100 Subject: [PATCH 05/30] testing --- lung_cancer_screening/services/metrics.py | 66 +++++++++++++++++++---- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/lung_cancer_screening/services/metrics.py b/lung_cancer_screening/services/metrics.py index 1f075abe..f4f177fb 100644 --- a/lung_cancer_screening/services/metrics.py +++ b/lung_cancer_screening/services/metrics.py @@ -1,5 +1,6 @@ import logging import os +from threading import Lock from azure.monitor.opentelemetry.exporter import AzureMonitorMetricExporter from opentelemetry import metrics @@ -11,24 +12,71 @@ class Metrics: _instance = None + _lock = Lock() + _initialised = False def __new__(cls, *args, **kwargs): if cls._instance is None: - cls._instance = super().__new__(cls) + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) return cls._instance def __init__(self): - environment = os.getenv("ENVIRONMENT") - logger.debug((f"Initialising Metrics(environment: {environment})")) + if self.__class__._initialised: + return - exporter = AzureMonitorMetricExporter( - connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") + connection_string = os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") + environment = os.getenv("ENVIRONMENT", "unknown") + + if not connection_string: + logger.warning( + "APPLICATIONINSIGHTS_CONNECTION_STRING not set; metrics will be no-op." + ) + self.meter = metrics.get_meter("lungcs.models") + else: + exporter = AzureMonitorMetricExporter( + connection_string=connection_string + ) + provider = MeterProvider( + metric_readers=[PeriodicExportingMetricReader(exporter)] + ) + metrics.set_meter_provider(provider) + self.meter = metrics.get_meter("lungcs.models") + + self.environment = environment + + # Create instruments once + self.requests_created = self.meter.create_counter( + name="requests.created", + unit="1", + description="Number of request records created", ) - metrics.set_meter_provider( - MeterProvider(metric_readers=[PeriodicExportingMetricReader(exporter)]) + self.requests_submitted = self.meter.create_counter( + name="requests.submitted", + unit="1", + description="Number of request records submitted", + ) + + self.__class__._initialised = True + + def record_request_created(self, model_name: str): + self.requests_created.add( + 1, + { + "environment": self.environment, + "model": model_name, + }, + ) + + def record_request_submitted(self, model_name: str): + self.requests_submitted.add( + 1, + { + "environment": self.environment, + "model": model_name, + }, ) - self.meter = metrics.get_meter(__name__) - self.environment = environment def set_gauge_value(self, metric_name, units, description, value): logger.debug( From 02a30d77df20d6326e0d3fc95739f567f8814b35 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 12:20:52 +0100 Subject: [PATCH 06/30] wip --- lung_cancer_screening/questions/models/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py index 7917f9bf..339b7ea8 100644 --- a/lung_cancer_screening/questions/models/base.py +++ b/lung_cancer_screening/questions/models/base.py @@ -4,7 +4,7 @@ logger = logging.getLogger(__name__) -class (models.QuerySet): +class BaseQuerySet(models.QuerySet): def get_or_build(self, **kwargs): """ Get an existing object matching the kwargs, or build a new unsaved instance. From 92dbf2863d3167ce126d9b06d7059e3e9c21e37e Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 12:32:20 +0100 Subject: [PATCH 07/30] service --- lung_cancer_screening/questions/models/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py index 339b7ea8..0c6d0912 100644 --- a/lung_cancer_screening/questions/models/base.py +++ b/lung_cancer_screening/questions/models/base.py @@ -1,5 +1,5 @@ from django.db import models -from .metrics import Metrics +from services.metrics import Metrics import logging logger = logging.getLogger(__name__) From c6d11956f4d3f8502058c1d4e3b00e01403ef62d Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 12:35:24 +0100 Subject: [PATCH 08/30] wip --- lung_cancer_screening/questions/models/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py index 0c6d0912..8bd52a3f 100644 --- a/lung_cancer_screening/questions/models/base.py +++ b/lung_cancer_screening/questions/models/base.py @@ -1,5 +1,5 @@ from django.db import models -from services.metrics import Metrics +from lung_cancer_screening.services.metrics import Metrics import logging logger = logging.getLogger(__name__) From 8ea3d3b176f10e372f771cdd28d0911ca48622c3 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 16:15:59 +0100 Subject: [PATCH 09/30] fix dependencies --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b539d133..5fb80b1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ readme = "README.md" requires-python = ">=3.13, <4.0" dependencies = [ "azure-identity (>=1.23.0,<2.0.0)", + "azure-monitor-opentelemetry-exporter", + "opentelemetry-sdk", "django (>=6.0.3,<7.0.0)", "gunicorn (>=23.0.0,<24.0.0)", "nhsuk-frontend-jinja (>=0.4.1,<0.5.0)", From c87b6f8aae873c1b1c02fa48ff2609939015d6b0 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 16:58:26 +0100 Subject: [PATCH 10/30] wip --- poetry.lock | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 2 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3c50f88a..22fcb229 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "asgiref" @@ -72,6 +72,26 @@ msal = ">=1.35.1" msal-extensions = ">=1.2.0" typing-extensions = ">=4.0.0" +[[package]] +name = "azure-monitor-opentelemetry-exporter" +version = "1.0.0b51" +description = "Microsoft Azure Monitor Opentelemetry Exporter Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_monitor_opentelemetry_exporter-1.0.0b51-py2.py3-none-any.whl", hash = "sha256:6572cac11f96e3b18ae1187cb35cf3b40d0004655dae8048896c41c765bea530"}, + {file = "azure_monitor_opentelemetry_exporter-1.0.0b51.tar.gz", hash = "sha256:a6171c34326bcd6216938bb40d715c15f1f22984ac1986fc97231336d8ac4c3c"}, +] + +[package.dependencies] +azure-core = ">=1.28.0,<2.0.0" +azure-identity = ">=1.17,<2.0" +msrest = ">=0.6.10" +opentelemetry-api = "1.40" +opentelemetry-sdk = "1.40" +psutil = ">=5.9,<8" + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -760,6 +780,30 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=3.4)"] +perf = ["ipython"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] + [[package]] name = "inflection" version = "0.5.1" @@ -772,6 +816,18 @@ files = [ {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, ] +[[package]] +name = "isodate" +version = "0.7.2" +description = "An ISO 8601 date/time/duration parser and formatter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15"}, + {file = "isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6"}, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -963,6 +1019,28 @@ msal = ">=1.29,<2" [package.extras] portalocker = ["portalocker (>=1.4,<4)"] +[[package]] +name = "msrest" +version = "0.7.1" +description = "AutoRest swagger generator Python client runtime." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32"}, + {file = "msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9"}, +] + +[package.dependencies] +azure-core = ">=1.24.0" +certifi = ">=2017.4.17" +isodate = ">=0.6.0" +requests = ">=2.16,<3.0" +requests-oauthlib = ">=0.5.0" + +[package.extras] +async = ["aiodns ; python_version >= \"3.5\"", "aiohttp (>=3.0) ; python_version >= \"3.5\""] + [[package]] name = "nhsuk-frontend-jinja" version = "0.4.1" @@ -978,6 +1056,72 @@ files = [ [package.dependencies] jinja2 = ">=3.1.6,<4.0.0" +[[package]] +name = "oauthlib" +version = "3.3.1" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, + {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +description = "OpenTelemetry Python API" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"}, + {file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"}, +] + +[package.dependencies] +importlib-metadata = ">=6.0,<8.8.0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.0" +description = "OpenTelemetry Python SDK" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"}, + {file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"}, +] + +[package.dependencies] +opentelemetry-api = "1.40.0" +opentelemetry-semantic-conventions = "0.61b0" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +description = "OpenTelemetry Semantic Conventions" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"}, + {file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"}, +] + +[package.dependencies] +opentelemetry-api = "1.40.0" +typing-extensions = ">=4.5.0" + [[package]] name = "packaging" version = "26.0" @@ -1045,6 +1189,41 @@ files = [ greenlet = ">=3.1.1,<4.0.0" pyee = ">=13,<14" +[[package]] +name = "psutil" +version = "7.2.2" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, + {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, + {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, + {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, + {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, + {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, + {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, + {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, + {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, + {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, + {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, + {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, + {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, + {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, + {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] + [[package]] name = "psycopg2-binary" version = "2.9.12" @@ -1211,6 +1390,25 @@ socks = ["PySocks (>=1.5.6,!=1.5.7)"] test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +groups = ["main"] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + [[package]] name = "ruff" version = "0.15.9" @@ -1337,7 +1535,27 @@ files = [ [package.extras] brotli = ["brotli"] +[[package]] +name = "zipp" +version = "3.23.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [metadata] lock-version = "2.1" python-versions = ">=3.13, <4.0" -content-hash = "c20a09db706201ecf0def6048ff41d949dd30fb73e229e61d15876a6f36edcca" +content-hash = "241c115d07e4a3d59e97f09b321b03610cb0bbee87015cdfe54f1d95b343b8c6" From ce5ab9548020ddcd49d6823106c5087628900221 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 17:16:29 +0100 Subject: [PATCH 11/30] wip --- infrastructure/modules/container-apps/alerts.tf | 2 +- infrastructure/modules/container-apps/main.tf | 3 ++- lung_cancer_screening/questions/models/base.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/container-apps/alerts.tf b/infrastructure/modules/container-apps/alerts.tf index 29db86db..1cc25eb7 100644 --- a/infrastructure/modules/container-apps/alerts.tf +++ b/infrastructure/modules/container-apps/alerts.tf @@ -1,4 +1,4 @@ -resource "azurerm_monitor_scheduled_query_rules_alert_v2" "500_error_alert" { +resource "azurerm_monitor_scheduled_query_rules_alert_v2" "five_hundred_error_alert" { count = var.enable_alerting ? 1 : 0 auto_mitigation_enabled = false diff --git a/infrastructure/modules/container-apps/main.tf b/infrastructure/modules/container-apps/main.tf index fcd6ff77..ab5976ae 100644 --- a/infrastructure/modules/container-apps/main.tf +++ b/infrastructure/modules/container-apps/main.tf @@ -38,8 +38,9 @@ module "webapp" { ) secret_variables = merge ( { APPLICATIONINSIGHTS_CONNECTION_STRING = var.app_insights_connection_string }, - var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} is_web_app = true + var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} ) + is_web_app = true port = 8000 probe_path = "/healthcheck" min_replicas = var.min_replicas diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py index 8bd52a3f..3a5b1fd9 100644 --- a/lung_cancer_screening/questions/models/base.py +++ b/lung_cancer_screening/questions/models/base.py @@ -11,7 +11,7 @@ def get_or_build(self, **kwargs): Returns a tuple of (object, created) where created is True if a new instance was built. """ # Check if any kwargs are unsaved model instances - for _, value in kwargs.items(): + for key, value in kwargs.items(): if isinstance(value, models.Model) and value.pk is None: # If we have an unsaved instance, just build a new one return (self.model(**kwargs), True) From f56de27e1709d521eb8b18dabf9bdcb7746fd868 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 17:20:24 +0100 Subject: [PATCH 12/30] wip --- .../modules/container-apps/alerts.tf | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/infrastructure/modules/container-apps/alerts.tf b/infrastructure/modules/container-apps/alerts.tf index 1cc25eb7..c17618ae 100644 --- a/infrastructure/modules/container-apps/alerts.tf +++ b/infrastructure/modules/container-apps/alerts.tf @@ -1,35 +1,35 @@ -resource "azurerm_monitor_scheduled_query_rules_alert_v2" "five_hundred_error_alert" { - count = var.enable_alerting ? 1 : 0 +# resource "azurerm_monitor_scheduled_query_rules_alert_v2" "five_hundred_error_alert" { +# count = var.enable_alerting ? 1 : 0 - auto_mitigation_enabled = false - description = "An alert triggered by 500 errors logged in code" - enabled = var.enable_alerting - evaluation_frequency = "PT5M" - location = var.region - name = "${var.app_short_name}-500-error-alert" - resource_group_name = azurerm_resource_group.main.name - scopes = [var.log_analytics_workspace_id] - severity = 2 - skip_query_validation = false - window_duration = "PT5M" - workspace_alerts_storage_enabled = false +# auto_mitigation_enabled = false +# description = "An alert triggered by 500 errors logged in code" +# enabled = var.enable_alerting +# evaluation_frequency = "PT5M" +# location = var.region +# name = "${var.app_short_name}-500-error-alert" +# resource_group_name = azurerm_resource_group.main.name +# scopes = [var.log_analytics_workspace_id] +# severity = 2 +# skip_query_validation = false +# window_duration = "PT5M" +# workspace_alerts_storage_enabled = false - action { - action_groups = [var.action_group_id] - } +# action { +# action_groups = [var.action_group_id] +# } - criteria { - operator = "GreaterThan" - query = <<-QUERY - ContainerAppConsoleLogs_CL - | where Log_s contains "500" - QUERY - threshold = 0 - time_aggregation_method = "Count" +# criteria { +# operator = "GreaterThan" +# query = <<-QUERY +# ContainerAppConsoleLogs_CL +# | where Log_s contains "500" +# QUERY +# threshold = 0 +# time_aggregation_method = "Count" - failing_periods { - minimum_failing_periods_to_trigger_alert = 1 - number_of_evaluation_periods = 1 - } - } -} +# failing_periods { +# minimum_failing_periods_to_trigger_alert = 1 +# number_of_evaluation_periods = 1 +# } +# } +# } From a658148aad313fc6683fc571afc6f4098cac285c Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Fri, 10 Apr 2026 17:22:04 +0100 Subject: [PATCH 13/30] wip --- .../modules/container-apps/alerts.tf | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/infrastructure/modules/container-apps/alerts.tf b/infrastructure/modules/container-apps/alerts.tf index c17618ae..3d0e4309 100644 --- a/infrastructure/modules/container-apps/alerts.tf +++ b/infrastructure/modules/container-apps/alerts.tf @@ -1,35 +1,35 @@ -# resource "azurerm_monitor_scheduled_query_rules_alert_v2" "five_hundred_error_alert" { -# count = var.enable_alerting ? 1 : 0 +resource "azurerm_monitor_scheduled_query_rules_alert_v2" "five_hundred_error_alert" { + count = var.enable_alerting ? 1 : 0 -# auto_mitigation_enabled = false -# description = "An alert triggered by 500 errors logged in code" -# enabled = var.enable_alerting -# evaluation_frequency = "PT5M" -# location = var.region -# name = "${var.app_short_name}-500-error-alert" -# resource_group_name = azurerm_resource_group.main.name -# scopes = [var.log_analytics_workspace_id] -# severity = 2 -# skip_query_validation = false -# window_duration = "PT5M" -# workspace_alerts_storage_enabled = false + auto_mitigation_enabled = false + description = "An alert triggered by 500 errors logged in code" + enabled = var.enable_alerting + evaluation_frequency = "PT5M" + location = var.region + name = "${var.app_short_name}-500-error-alert" + resource_group_name = azurerm_resource_group.main.name + scopes = [var.action_group_id] + severity = 2 + skip_query_validation = false + window_duration = "PT5M" + workspace_alerts_storage_enabled = false -# action { -# action_groups = [var.action_group_id] -# } + action { + action_groups = [var.action_group_id] + } -# criteria { -# operator = "GreaterThan" -# query = <<-QUERY -# ContainerAppConsoleLogs_CL -# | where Log_s contains "500" -# QUERY -# threshold = 0 -# time_aggregation_method = "Count" + criteria { + operator = "GreaterThan" + query = <<-QUERY + ContainerAppConsoleLogs_CL + | where Log_s contains "500" + QUERY + threshold = 0 + time_aggregation_method = "Count" -# failing_periods { -# minimum_failing_periods_to_trigger_alert = 1 -# number_of_evaluation_periods = 1 -# } -# } -# } + failing_periods { + minimum_failing_periods_to_trigger_alert = 1 + number_of_evaluation_periods = 1 + } + } +} From 30f65625d9e6fb964700821707928ad64b90b431 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Wed, 15 Apr 2026 12:49:28 +0100 Subject: [PATCH 14/30] wip --- lung_cancer_screening/services/metrics.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lung_cancer_screening/services/metrics.py b/lung_cancer_screening/services/metrics.py index f4f177fb..6a008694 100644 --- a/lung_cancer_screening/services/metrics.py +++ b/lung_cancer_screening/services/metrics.py @@ -23,6 +23,10 @@ def __new__(cls, *args, **kwargs): return cls._instance def __init__(self): + + logger.info( + "Going into Metrics class." + ) if self.__class__._initialised: return From 3ca397693b491f5b61cce2d9346842ce8633c6bb Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Wed, 15 Apr 2026 14:20:20 +0100 Subject: [PATCH 15/30] testing out stuff --- infrastructure/modules/container-apps/jobs.tf | 13 +++++ .../mangement/commands/collect_metrics.py | 18 ++++++ lung_cancer_screening/services/metrics.py | 32 ++++++----- .../services/metricsCollector.py | 57 +++++++++++++++++++ 4 files changed, 105 insertions(+), 15 deletions(-) create mode 100644 lung_cancer_screening/mangement/commands/collect_metrics.py create mode 100644 lung_cancer_screening/services/metricsCollector.py diff --git a/infrastructure/modules/container-apps/jobs.tf b/infrastructure/modules/container-apps/jobs.tf index 8770825d..2aed7aab 100644 --- a/infrastructure/modules/container-apps/jobs.tf +++ b/infrastructure/modules/container-apps/jobs.tf @@ -1,3 +1,16 @@ +locals { + scheduled_jobs = { + collect_metrics = { + cron_expression = "*/5 * * * *" + environment_variables = { + ENVIRONMENT = var.environment + } + job_short_name = "clm" + job_container_args = "collect_metrics" + } + } +} + module "db_setup" { source = "../dtos-devops-templates/infrastructure/modules/container-app-job" diff --git a/lung_cancer_screening/mangement/commands/collect_metrics.py b/lung_cancer_screening/mangement/commands/collect_metrics.py new file mode 100644 index 00000000..75f4cdc2 --- /dev/null +++ b/lung_cancer_screening/mangement/commands/collect_metrics.py @@ -0,0 +1,18 @@ +import logging + +from django.core.management.base import BaseCommand, CommandError + +from lung_cancer_screening.services.model_metrics_collector import ModelMetricsCollector + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Collects current model metrics and exports them via OpenTelemetry." + + def handle(self, *args, **options): + try: + ModelMetricsCollector().collect() + except Exception as e: + logger.error(e, exc_info=True) + raise CommandError(e) diff --git a/lung_cancer_screening/services/metrics.py b/lung_cancer_screening/services/metrics.py index 6a008694..520a18a7 100644 --- a/lung_cancer_screening/services/metrics.py +++ b/lung_cancer_screening/services/metrics.py @@ -23,13 +23,11 @@ def __new__(cls, *args, **kwargs): return cls._instance def __init__(self): - - logger.info( - "Going into Metrics class." - ) if self.__class__._initialised: return + logger.info("Going into Metrics class.") + connection_string = os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING") environment = os.getenv("ENVIRONMENT", "unknown") @@ -49,8 +47,8 @@ def __init__(self): self.meter = metrics.get_meter("lungcs.models") self.environment = environment + self._gauges = {} - # Create instruments once self.requests_created = self.meter.create_counter( name="requests.created", unit="1", @@ -84,19 +82,23 @@ def record_request_submitted(self, model_name: str): def set_gauge_value(self, metric_name, units, description, value): logger.debug( - ( - f"Metrics: set_gauge_value(metric_name: {metric_name} " - f"units: {units}, description: {description}, value: {value})" - ) + "Metrics: set_gauge_value(metric_name=%s, units=%s, description=%s, value=%s)", + metric_name, + units, + description, + value, ) - # Create gauge metric - gauge = self.meter.create_gauge( - metric_name, unit=units, description=description - ) + gauge = self._gauges.get(metric_name) + if gauge is None: + gauge = self.meter.create_gauge( + name=metric_name, + unit=units, + description=description, + ) + self._gauges[metric_name] = gauge - # Set metric value - gauge.set( + gauge.record( value, {"environment": self.environment}, ) diff --git a/lung_cancer_screening/services/metricsCollector.py b/lung_cancer_screening/services/metricsCollector.py new file mode 100644 index 00000000..efd9f445 --- /dev/null +++ b/lung_cancer_screening/services/metricsCollector.py @@ -0,0 +1,57 @@ +import logging + +from django.apps import apps +from django.db import models + +from lung_cancer_screening.questions.models.base import BaseModel +from lung_cancer_screening.services.metrics import Metrics + +logger = logging.getLogger(__name__) + + +class ModelMetricsCollector: + """ + Collects current-state metrics for all models inheriting from BaseModel. + + Emits: + - model_records_ + - model_submitted_records_ (only for models with a status field) + """ + + def __init__(self): + self.metrics = Metrics() + + def collect(self): + for model in apps.get_models(): + if not issubclass(model, BaseModel) or model._meta.abstract: + continue + + self._collect_for_model(model) + + def _collect_for_model(self, model: type[models.Model]): + model_name = model._meta.label_lower.replace(".", "_") + + total_count = model.objects.count() + self.metrics.set_gauge_value( + metric_name=f"model_records_{model_name}", + units="records", + description=f"Current number of records for {model._meta.label_lower}", + value=total_count, + ) + + status_field = self._get_status_field(model) + if status_field: + submitted_count = model.objects.filter(status="submitted").count() + self.metrics.set_gauge_value( + metric_name=f"model_submitted_records_{model_name}", + units="records", + description=f"Current number of submitted records for {model._meta.label_lower}", + value=submitted_count, + ) + + @staticmethod + def _get_status_field(model: type[models.Model]): + return next( + (field for field in model._meta.fields if field.name == "status"), + None, + ) From 4802bbc36f86c7b6b750709de05b9a72a58cb35c Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Wed, 15 Apr 2026 14:40:21 +0100 Subject: [PATCH 16/30] wip --- infrastructure/modules/container-apps/jobs.tf | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/infrastructure/modules/container-apps/jobs.tf b/infrastructure/modules/container-apps/jobs.tf index 2aed7aab..43234346 100644 --- a/infrastructure/modules/container-apps/jobs.tf +++ b/infrastructure/modules/container-apps/jobs.tf @@ -38,3 +38,56 @@ module "db_setup" { ] } + +module "scheduled_jobs" { + source = "../dtos-devops-templates/infrastructure/modules/container-app-job" + + for_each = local.scheduled_jobs + + name = "${var.app_short_name}-${each.value.job_short_name}-${var.environment}" + container_app_environment_id = var.container_app_environment_id + resource_group_name = azurerm_resource_group.main.name + + fetch_secrets_from_app_key_vault = var.fetch_secrets_from_app_key_vault + app_key_vault_id = var.app_key_vault_id + + container_command = ["/bin/sh", "-c"] + container_args = [ + "python manage.py ${each.value.job_container_args}" + ] + + docker_image = var.docker_image + replica_retry_limit = 0 + user_assigned_identity_ids = flatten([ + [module.azure_blob_storage_identity.id], + [module.azure_queue_storage_identity.id], + var.deploy_database_as_container ? [] : [module.db_connect_identity[0].id] + ]) + + environment_variables = merge( + local.common_env, + { + "STORAGE_ACCOUNT_NAME" = module.storage.storage_account_name, + "BLOB_MI_CLIENT_ID" = module.azure_blob_storage_identity.client_id, + "QUEUE_MI_CLIENT_ID" = module.azure_queue_storage_identity.client_id + }, + each.value.environment_variables, + var.deploy_database_as_container ? local.container_db_env : local.azure_db_env + ) + secret_variables = merge( + { APPLICATIONINSIGHTS_CONNECTION_STRING = var.app_insights_connection_string }, + var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} + ) + + # alerts + action_group_id = var.action_group_id + enable_alerting = var.enable_alerting + log_analytics_workspace_id = var.log_analytics_workspace_id + + # Ensure RBAC role assignments are created before the job definition finalizes + depends_on = [ + module.blob_storage_role_assignment, + ] + + cron_expression = each.value.cron_expression +} From bd18937d9a24f110f14fe01c746c112b2d10ebbd Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Wed, 15 Apr 2026 14:52:42 +0100 Subject: [PATCH 17/30] wip --- infrastructure/modules/container-apps/jobs.tf | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/infrastructure/modules/container-apps/jobs.tf b/infrastructure/modules/container-apps/jobs.tf index 43234346..f4000f1f 100644 --- a/infrastructure/modules/container-apps/jobs.tf +++ b/infrastructure/modules/container-apps/jobs.tf @@ -69,7 +69,6 @@ module "scheduled_jobs" { { "STORAGE_ACCOUNT_NAME" = module.storage.storage_account_name, "BLOB_MI_CLIENT_ID" = module.azure_blob_storage_identity.client_id, - "QUEUE_MI_CLIENT_ID" = module.azure_queue_storage_identity.client_id }, each.value.environment_variables, var.deploy_database_as_container ? local.container_db_env : local.azure_db_env @@ -82,7 +81,7 @@ module "scheduled_jobs" { # alerts action_group_id = var.action_group_id enable_alerting = var.enable_alerting - log_analytics_workspace_id = var.log_analytics_workspace_id + log_analytics_workspace_id = var.log_analytics_workspace_audit_id # Ensure RBAC role assignments are created before the job definition finalizes depends_on = [ From 13180ab3ebc9470be17fe29cb8855710401e2909 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Wed, 15 Apr 2026 14:55:26 +0100 Subject: [PATCH 18/30] wip --- infrastructure/modules/container-apps/jobs.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/infrastructure/modules/container-apps/jobs.tf b/infrastructure/modules/container-apps/jobs.tf index f4000f1f..0498cd18 100644 --- a/infrastructure/modules/container-apps/jobs.tf +++ b/infrastructure/modules/container-apps/jobs.tf @@ -60,7 +60,6 @@ module "scheduled_jobs" { replica_retry_limit = 0 user_assigned_identity_ids = flatten([ [module.azure_blob_storage_identity.id], - [module.azure_queue_storage_identity.id], var.deploy_database_as_container ? [] : [module.db_connect_identity[0].id] ]) From 82f27beb18e67f915fa49bacfb2476f219a2b03b Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Thu, 16 Apr 2026 13:41:01 +0100 Subject: [PATCH 19/30] wip --- lung_cancer_screening/services/metrics.py | 9 +++++++++ lung_cancer_screening/services/metricsCollector.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lung_cancer_screening/services/metrics.py b/lung_cancer_screening/services/metrics.py index 520a18a7..1c6e29d5 100644 --- a/lung_cancer_screening/services/metrics.py +++ b/lung_cancer_screening/services/metrics.py @@ -63,6 +63,10 @@ def __init__(self): self.__class__._initialised = True def record_request_created(self, model_name: str): + logger.info( + "Metrics: record_request_created(model_name=%s)", + model_name + ) self.requests_created.add( 1, { @@ -72,6 +76,11 @@ def record_request_created(self, model_name: str): ) def record_request_submitted(self, model_name: str): + logger.info( + "Metrics: record_request_submitted(model_name=%s)", + model_name + ) + logger.info("record_request_submitted.") self.requests_submitted.add( 1, { diff --git a/lung_cancer_screening/services/metricsCollector.py b/lung_cancer_screening/services/metricsCollector.py index efd9f445..2c3bf81d 100644 --- a/lung_cancer_screening/services/metricsCollector.py +++ b/lung_cancer_screening/services/metricsCollector.py @@ -19,9 +19,13 @@ class ModelMetricsCollector: """ def __init__(self): + logger.info( + "ModelMetricsCollector: Starting collection of model metrics." ) self.metrics = Metrics() def collect(self): + logger.info( + "ModelMetricsCollector: collect." ) for model in apps.get_models(): if not issubclass(model, BaseModel) or model._meta.abstract: continue @@ -32,6 +36,14 @@ def _collect_for_model(self, model: type[models.Model]): model_name = model._meta.label_lower.replace(".", "_") total_count = model.objects.count() + + logger.info( + "ModelMetricsCollector: _collect_for_model." + " model=%s, total_count=%d", + model._meta.label_lower, + total_count + ) + self.metrics.set_gauge_value( metric_name=f"model_records_{model_name}", units="records", From c4eb95de2f7cc01b4e493c5c598313626fa5eb2b Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Thu, 16 Apr 2026 13:49:05 +0100 Subject: [PATCH 20/30] wip --- infrastructure/modules/container-apps/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/container-apps/main.tf b/infrastructure/modules/container-apps/main.tf index ab5976ae..38ad198d 100644 --- a/infrastructure/modules/container-apps/main.tf +++ b/infrastructure/modules/container-apps/main.tf @@ -31,7 +31,7 @@ module "webapp" { environment_variables = merge( local.common_env, { - ALLOWED_HOSTS = "${local.hostname},${var.app_short_name}-web-${var.environment}.${var.default_domain},localhost", + ALLOWED_HOSTS = "${local.hostname},${var.app_short_name}-web-${var.environment}.${var.default_domain},localhost,*", CSRF_TRUSTED_ORIGINS = "https://${local.hostname}" }, var.deploy_database_as_container ? local.container_db_env : local.azure_db_env From a08c63bd5f761768763df6acbc345e55ef7f80e4 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Mon, 20 Apr 2026 14:07:12 +0100 Subject: [PATCH 21/30] wip --- lung_cancer_screening/mangement/commands/collect_metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lung_cancer_screening/mangement/commands/collect_metrics.py b/lung_cancer_screening/mangement/commands/collect_metrics.py index 75f4cdc2..f9adc8bd 100644 --- a/lung_cancer_screening/mangement/commands/collect_metrics.py +++ b/lung_cancer_screening/mangement/commands/collect_metrics.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError -from lung_cancer_screening.services.model_metrics_collector import ModelMetricsCollector +from lung_cancer_screening.services.metricsCollector import ModelMetricsCollector logger = logging.getLogger(__name__) From 6a81917c892f61ea98c074866c5746a320ecb874 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Mon, 20 Apr 2026 14:42:27 +0100 Subject: [PATCH 22/30] wip --- lung_cancer_screening/mangement/commands/collect_metrics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lung_cancer_screening/mangement/commands/collect_metrics.py b/lung_cancer_screening/mangement/commands/collect_metrics.py index f9adc8bd..62b3a202 100644 --- a/lung_cancer_screening/mangement/commands/collect_metrics.py +++ b/lung_cancer_screening/mangement/commands/collect_metrics.py @@ -11,6 +11,7 @@ class Command(BaseCommand): help = "Collects current model metrics and exports them via OpenTelemetry." def handle(self, *args, **options): + logger.info("Command: collect_metrics.") try: ModelMetricsCollector().collect() except Exception as e: From 865ca1fda271dbb49b3c1b30cdfca9f5b144e695 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Mon, 20 Apr 2026 15:53:58 +0100 Subject: [PATCH 23/30] wip --- .../{mangement => management}/commands/collect_metrics.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lung_cancer_screening/{mangement => management}/commands/collect_metrics.py (100%) diff --git a/lung_cancer_screening/mangement/commands/collect_metrics.py b/lung_cancer_screening/management/commands/collect_metrics.py similarity index 100% rename from lung_cancer_screening/mangement/commands/collect_metrics.py rename to lung_cancer_screening/management/commands/collect_metrics.py From 4c5fb79a00538d15c30363e8602080a0c04053f3 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Mon, 20 Apr 2026 16:18:48 +0100 Subject: [PATCH 24/30] wip --- lung_cancer_screening/management/__init__.py | 0 lung_cancer_screening/management/commands/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 lung_cancer_screening/management/__init__.py create mode 100644 lung_cancer_screening/management/commands/__init__.py diff --git a/lung_cancer_screening/management/__init__.py b/lung_cancer_screening/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lung_cancer_screening/management/commands/__init__.py b/lung_cancer_screening/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b From 8468fa24bbd0fba5c6835faa28713c34d638f0b3 Mon Sep 17 00:00:00 2001 From: Andy Mitchell <326561+Themitchell@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:54:33 +0100 Subject: [PATCH 25/30] PPHA-705: Move metrics commands into questions domain --- lung_cancer_screening/{ => questions}/management/__init__.py | 0 .../{ => questions}/management/commands/__init__.py | 0 .../{ => questions}/management/commands/collect_metrics.py | 2 +- lung_cancer_screening/questions/models/base.py | 2 +- lung_cancer_screening/questions/services/__init__.py | 0 lung_cancer_screening/{ => questions}/services/metrics.py | 0 .../{ => questions}/services/metricsCollector.py | 2 +- 7 files changed, 3 insertions(+), 3 deletions(-) rename lung_cancer_screening/{ => questions}/management/__init__.py (100%) rename lung_cancer_screening/{ => questions}/management/commands/__init__.py (100%) rename lung_cancer_screening/{ => questions}/management/commands/collect_metrics.py (84%) create mode 100644 lung_cancer_screening/questions/services/__init__.py rename lung_cancer_screening/{ => questions}/services/metrics.py (100%) rename lung_cancer_screening/{ => questions}/services/metricsCollector.py (96%) diff --git a/lung_cancer_screening/management/__init__.py b/lung_cancer_screening/questions/management/__init__.py similarity index 100% rename from lung_cancer_screening/management/__init__.py rename to lung_cancer_screening/questions/management/__init__.py diff --git a/lung_cancer_screening/management/commands/__init__.py b/lung_cancer_screening/questions/management/commands/__init__.py similarity index 100% rename from lung_cancer_screening/management/commands/__init__.py rename to lung_cancer_screening/questions/management/commands/__init__.py diff --git a/lung_cancer_screening/management/commands/collect_metrics.py b/lung_cancer_screening/questions/management/commands/collect_metrics.py similarity index 84% rename from lung_cancer_screening/management/commands/collect_metrics.py rename to lung_cancer_screening/questions/management/commands/collect_metrics.py index 62b3a202..f32e5766 100644 --- a/lung_cancer_screening/management/commands/collect_metrics.py +++ b/lung_cancer_screening/questions/management/commands/collect_metrics.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError -from lung_cancer_screening.services.metricsCollector import ModelMetricsCollector +from lung_cancer_screening.questions.services.metricsCollector import ModelMetricsCollector logger = logging.getLogger(__name__) diff --git a/lung_cancer_screening/questions/models/base.py b/lung_cancer_screening/questions/models/base.py index 3a5b1fd9..5a8de010 100644 --- a/lung_cancer_screening/questions/models/base.py +++ b/lung_cancer_screening/questions/models/base.py @@ -1,5 +1,5 @@ from django.db import models -from lung_cancer_screening.services.metrics import Metrics +from lung_cancer_screening.questions.services.metrics import Metrics import logging logger = logging.getLogger(__name__) diff --git a/lung_cancer_screening/questions/services/__init__.py b/lung_cancer_screening/questions/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lung_cancer_screening/services/metrics.py b/lung_cancer_screening/questions/services/metrics.py similarity index 100% rename from lung_cancer_screening/services/metrics.py rename to lung_cancer_screening/questions/services/metrics.py diff --git a/lung_cancer_screening/services/metricsCollector.py b/lung_cancer_screening/questions/services/metricsCollector.py similarity index 96% rename from lung_cancer_screening/services/metricsCollector.py rename to lung_cancer_screening/questions/services/metricsCollector.py index 2c3bf81d..62b306c5 100644 --- a/lung_cancer_screening/services/metricsCollector.py +++ b/lung_cancer_screening/questions/services/metricsCollector.py @@ -4,7 +4,7 @@ from django.db import models from lung_cancer_screening.questions.models.base import BaseModel -from lung_cancer_screening.services.metrics import Metrics +from lung_cancer_screening.questions.services.metrics import Metrics logger = logging.getLogger(__name__) From b52924a97c165e301a5fe837baa6e32db3bea496 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Tue, 21 Apr 2026 11:05:55 +0100 Subject: [PATCH 26/30] reformated --- infrastructure/modules/container-apps/jobs.tf | 2 +- infrastructure/modules/container-apps/main.tf | 12 ++++++------ lung_cancer_screening/questions/services/metrics.py | 3 +++ 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/infrastructure/modules/container-apps/jobs.tf b/infrastructure/modules/container-apps/jobs.tf index 0498cd18..70fe58bb 100644 --- a/infrastructure/modules/container-apps/jobs.tf +++ b/infrastructure/modules/container-apps/jobs.tf @@ -3,7 +3,7 @@ locals { collect_metrics = { cron_expression = "*/5 * * * *" environment_variables = { - ENVIRONMENT = var.environment + ENVIRONMENT = var.environment } job_short_name = "clm" job_container_args = "collect_metrics" diff --git a/infrastructure/modules/container-apps/main.tf b/infrastructure/modules/container-apps/main.tf index 38ad198d..d775e27f 100644 --- a/infrastructure/modules/container-apps/main.tf +++ b/infrastructure/modules/container-apps/main.tf @@ -36,15 +36,15 @@ module "webapp" { }, var.deploy_database_as_container ? local.container_db_env : local.azure_db_env ) - secret_variables = merge ( + secret_variables = merge( { APPLICATIONINSIGHTS_CONNECTION_STRING = var.app_insights_connection_string }, var.deploy_database_as_container ? { DATABASE_PASSWORD = resource.random_password.admin_password[0].result } : {} ) - is_web_app = true - port = 8000 - probe_path = "/healthcheck" - min_replicas = var.min_replicas - memory = var.container_memory + is_web_app = true + port = 8000 + probe_path = "/healthcheck" + min_replicas = var.min_replicas + memory = var.container_memory } module "azurerm_application_insights_standard_web_test" { diff --git a/lung_cancer_screening/questions/services/metrics.py b/lung_cancer_screening/questions/services/metrics.py index 1c6e29d5..1965144d 100644 --- a/lung_cancer_screening/questions/services/metrics.py +++ b/lung_cancer_screening/questions/services/metrics.py @@ -16,6 +16,9 @@ class Metrics: _initialised = False def __new__(cls, *args, **kwargs): + + logger.info("Creating a new instance of Metrics class.") + if cls._instance is None: with cls._lock: if cls._instance is None: From 8e7e4d320715dd3f760e1d11596967dd44739cce Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Tue, 21 Apr 2026 11:44:31 +0100 Subject: [PATCH 27/30] wip --- .../questions/services/metrics.py | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/lung_cancer_screening/questions/services/metrics.py b/lung_cancer_screening/questions/services/metrics.py index 1965144d..0cef4f2f 100644 --- a/lung_cancer_screening/questions/services/metrics.py +++ b/lung_cancer_screening/questions/services/metrics.py @@ -1,9 +1,11 @@ import logging import os from threading import Lock +from typing import Iterable from azure.monitor.opentelemetry.exporter import AzureMonitorMetricExporter from opentelemetry import metrics +from opentelemetry.metrics import Observation, CallbackOptions from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader @@ -16,9 +18,7 @@ class Metrics: _initialised = False def __new__(cls, *args, **kwargs): - logger.info("Creating a new instance of Metrics class.") - if cls._instance is None: with cls._lock: if cls._instance is None: @@ -50,7 +50,11 @@ def __init__(self): self.meter = metrics.get_meter("lungcs.models") self.environment = environment - self._gauges = {} + + # store latest gauge values here + self._gauge_values = {} + self._gauge_lock = Lock() + self._registered_observable_gauges = set() self.requests_created = self.meter.create_counter( name="requests.created", @@ -66,10 +70,7 @@ def __init__(self): self.__class__._initialised = True def record_request_created(self, model_name: str): - logger.info( - "Metrics: record_request_created(model_name=%s)", - model_name - ) + logger.info("Metrics: record_request_created(model_name=%s)", model_name) self.requests_created.add( 1, { @@ -79,11 +80,7 @@ def record_request_created(self, model_name: str): ) def record_request_submitted(self, model_name: str): - logger.info( - "Metrics: record_request_submitted(model_name=%s)", - model_name - ) - logger.info("record_request_submitted.") + logger.info("Metrics: record_request_submitted(model_name=%s)", model_name) self.requests_submitted.add( 1, { @@ -92,6 +89,18 @@ def record_request_submitted(self, model_name: str): }, ) + def _make_gauge_callback(self, metric_name: str): + def callback(options: CallbackOptions) -> Iterable[Observation]: + with self._gauge_lock: + value = self._gauge_values.get(metric_name, 0) + + yield Observation( + value, + {"environment": self.environment}, + ) + + return callback + def set_gauge_value(self, metric_name, units, description, value): logger.debug( "Metrics: set_gauge_value(metric_name=%s, units=%s, description=%s, value=%s)", @@ -101,16 +110,14 @@ def set_gauge_value(self, metric_name, units, description, value): value, ) - gauge = self._gauges.get(metric_name) - if gauge is None: - gauge = self.meter.create_gauge( - name=metric_name, - unit=units, - description=description, - ) - self._gauges[metric_name] = gauge - - gauge.record( - value, - {"environment": self.environment}, - ) + with self._gauge_lock: + self._gauge_values[metric_name] = value + + if metric_name not in self._registered_observable_gauges: + self.meter.create_observable_gauge( + name=metric_name, + callbacks=[self._make_gauge_callback(metric_name)], + unit=units, + description=description, + ) + self._registered_observable_gauges.add(metric_name) From c2500f19ee49557050a1f8f028841fa579afe0c9 Mon Sep 17 00:00:00 2001 From: Andy Mitchell <326561+Themitchell@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:56:21 +0100 Subject: [PATCH 28/30] PPHA-785: Use [ERROR] string for log alerting --- infrastructure/modules/container-apps/alerts.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/container-apps/alerts.tf b/infrastructure/modules/container-apps/alerts.tf index 3d0e4309..855311f5 100644 --- a/infrastructure/modules/container-apps/alerts.tf +++ b/infrastructure/modules/container-apps/alerts.tf @@ -22,7 +22,7 @@ resource "azurerm_monitor_scheduled_query_rules_alert_v2" "five_hundred_error_al operator = "GreaterThan" query = <<-QUERY ContainerAppConsoleLogs_CL - | where Log_s contains "500" + | where Log contains "[ERROR]" QUERY threshold = 0 time_aggregation_method = "Count" From 087aaec239d9629c24137e5a66c708dbe041d795 Mon Sep 17 00:00:00 2001 From: Andy Mitchell <326561+Themitchell@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:06:57 +0100 Subject: [PATCH 29/30] Add nonlne to vale language exceptions --- docs/infrastructure/create-environment.md | 2 +- scripts/config/vale/styles/config/vocabularies/words/accept.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/infrastructure/create-environment.md b/docs/infrastructure/create-environment.md index f75a1d43..e62c336a 100644 --- a/docs/infrastructure/create-environment.md +++ b/docs/infrastructure/create-environment.md @@ -136,7 +136,7 @@ Add the infrastructure secrets to the _inf_ key vault `kv-lungcs-[environment]-i ## Connect to Postgres Database -- Add your user as a memeber to the respective Entra ID group: +- Add your user as a member to the respective Entra ID group: - `postgres_lungcs_[environment]_uks_admin` - Log into the correct ADV for your environment type (either nonlive or live) - Run the following commands on the CLI to log into the database: - diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index d4c2e7b4..3bb4f9d7 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -30,3 +30,4 @@ yaml jq choco CLI +nonlive From b2665b914be66ba2aea1d58cfea211abe978a635 Mon Sep 17 00:00:00 2001 From: Alastair Lock Date: Thu, 23 Apr 2026 17:20:19 +0100 Subject: [PATCH 30/30] Refresh poetry lock file --- poetry.lock | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 22fcb229..edc24f02 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1298,6 +1298,7 @@ files = [ {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe"}, {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915"}, {file = "psycopg2_binary-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10"}, + {file = "psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c"}, ] [[package]] @@ -1558,4 +1559,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.13, <4.0" -content-hash = "241c115d07e4a3d59e97f09b321b03610cb0bbee87015cdfe54f1d95b343b8c6" +content-hash = "77f4bd2fa9ffd86aaa23310ed47c320ec3e3528a90dc17c715b95175d8c2a1be"