From 6d43837d74a1685e86d8c4aec28a2e6592e1d706 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Fri, 6 Nov 2026 18:32:19 +0100 Subject: [PATCH 1/3] Show how to read the logs and metrics of the App Configuration sample - query-logs-and-metrics.sh (Python and .NET) calls the web app, lists the diagnostic settings of the web app, the store, the vault and the server, queries their tables in the Log Analytics workspace with bounded retries, and prints their platform metrics; run-samples.sh runs it for the six entries of the sample. - The Key Vault and PostgreSQL diagnostic settings write to resource-specific tables (AZKVAuditLogs, PGSQLServerLogs) in all three deployment methods. - The Bicep modules drop the retention policy of their diagnostic settings, which Azure no longer applies. - Both READMEs gain a "Logs and metrics" section: the tables each resource fills, Azure CLI queries with their output, the Azure Portal Emulator pages, and delivery times on the emulator and on Azure. Co-Authored-By: Claude Opus 5.5 --- run-samples.sh | 12 +- .../dotnet/README.md | 114 +++++++ .../dotnet/bicep/README.md | 7 +- .../bicep/modules/app-configuration.bicep | 14 - .../bicep/modules/app-service-plan.bicep | 14 - .../dotnet/bicep/modules/key-vault.bicep | 20 +- .../modules/postgresql-flexible-server.bicep | 20 +- .../bicep/modules/virtual-network.bicep | 18 - .../dotnet/bicep/modules/web-app.bicep | 14 - .../dotnet/scripts/README.md | 9 +- .../dotnet/scripts/deploy.sh | 8 +- .../dotnet/scripts/query-logs-and-metrics.sh | 319 ++++++++++++++++++ .../dotnet/terraform/README.md | 4 +- .../terraform/modules/key_vault/main.tf | 7 +- .../terraform/modules/key_vault/variables.tf | 11 + .../modules/postgres_flexible_server/main.tf | 7 +- .../postgres_flexible_server/variables.tf | 11 + .../python/README.md | 114 +++++++ .../python/bicep/README.md | 7 +- .../bicep/modules/app-configuration.bicep | 14 - .../bicep/modules/app-service-plan.bicep | 14 - .../python/bicep/modules/key-vault.bicep | 20 +- .../modules/postgresql-flexible-server.bicep | 20 +- .../bicep/modules/virtual-network.bicep | 18 - .../python/bicep/modules/web-app.bicep | 14 - .../python/scripts/README.md | 9 +- .../python/scripts/deploy.sh | 8 +- .../python/scripts/query-logs-and-metrics.sh | 319 ++++++++++++++++++ .../python/terraform/README.md | 4 +- .../terraform/modules/key_vault/main.tf | 7 +- .../terraform/modules/key_vault/variables.tf | 11 + .../modules/postgres_flexible_server/main.tf | 7 +- .../postgres_flexible_server/variables.tf | 11 + 33 files changed, 1004 insertions(+), 202 deletions(-) create mode 100755 samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh create mode 100755 samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh diff --git a/run-samples.sh b/run-samples.sh index 2164524..b37a540 100755 --- a/run-samples.sh +++ b/run-samples.sh @@ -48,8 +48,8 @@ SAMPLES=( "samples/web-app-mysql-flexible-server/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-postgresql-flexible-server/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-postgresql-flexible-server/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" - "samples/web-app-app-configuration/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" - "samples/web-app-app-configuration/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" + "samples/web-app-app-configuration/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh && bash scripts/query-logs-and-metrics.sh" + "samples/web-app-app-configuration/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh && bash scripts/query-logs-and-metrics.sh" "samples/web-app-custom-image/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/web-app-custom-image/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh" "samples/aci-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh" @@ -78,8 +78,8 @@ TERRAFORM_SAMPLES=( "samples/web-app-mysql-flexible-server/dotnet/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/terraform|bash deploy.sh" "samples/web-app-postgresql-flexible-server/dotnet/terraform|bash deploy.sh" - "samples/web-app-app-configuration/python/terraform|bash deploy.sh" - "samples/web-app-app-configuration/dotnet/terraform|bash deploy.sh" + "samples/web-app-app-configuration/python/terraform|bash deploy.sh|bash ../scripts/query-logs-and-metrics.sh" + "samples/web-app-app-configuration/dotnet/terraform|bash deploy.sh|bash ../scripts/query-logs-and-metrics.sh" "samples/web-app-custom-image/python/terraform|bash deploy.sh|bash ../scripts/validate.sh && bash ../scripts/call-web-app.sh" "samples/web-app-custom-image/dotnet/terraform|bash deploy.sh|bash ../scripts/validate.sh && bash ../scripts/call-web-app.sh" "samples/aci-blob-storage/python/terraform|bash deploy.sh" @@ -108,8 +108,8 @@ BICEP_SAMPLES=( "samples/web-app-mysql-flexible-server/dotnet/bicep|bash deploy.sh" "samples/web-app-postgresql-flexible-server/python/bicep|bash deploy.sh" "samples/web-app-postgresql-flexible-server/dotnet/bicep|bash deploy.sh" - "samples/web-app-app-configuration/python/bicep|bash deploy.sh" - "samples/web-app-app-configuration/dotnet/bicep|bash deploy.sh" + "samples/web-app-app-configuration/python/bicep|bash deploy.sh|bash ../scripts/query-logs-and-metrics.sh" + "samples/web-app-app-configuration/dotnet/bicep|bash deploy.sh|bash ../scripts/query-logs-and-metrics.sh" "samples/web-app-custom-image/python/bicep|bash deploy.sh|bash ../scripts/validate.sh && bash ../scripts/call-web-app.sh" "samples/web-app-custom-image/dotnet/bicep|bash deploy.sh|bash ../scripts/validate.sh && bash ../scripts/call-web-app.sh" "samples/aci-blob-storage/python/bicep|bash deploy.sh" diff --git a/samples/web-app-app-configuration/dotnet/README.md b/samples/web-app-app-configuration/dotnet/README.md index efc9bec..cc2e7f5 100644 --- a/samples/web-app-app-configuration/dotnet/README.md +++ b/samples/web-app-app-configuration/dotnet/README.md @@ -140,6 +140,120 @@ Loaded 5 settings from App Configuration https://local-appconfig-test.azure.loca PostgreSQL schema initialized ``` +## Logs and metrics + +The web app, its App Service plan, the App Configuration store, the key vault, the PostgreSQL server, the VNet and the two NSGs each have a diagnostic setting that sends their resource logs, their platform metrics (`AllMetrics`) or both to the `-log-analytics-` Log Analytics workspace. The four resources the application uses record what it does: + +| Resource | Log categories | Tables in the workspace | What the application leaves there | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Web App | `AppServiceHTTPLogs`, `AppServiceConsoleLogs`, `AppServicePlatformLogs`, `AppServiceAppLogs`, `AppServiceAuditLogs`, `AppServiceIPSecAuditLogs`, `AppServiceAuthenticationLogs` | One table per category, named after it | The requests it serves, its console output (a `VacationPlanner.Requests` line per request and the application's log lines) and the start of its container | +| App Configuration store | `HttpRequest`, `Audit` | `AACHttpRequest`, `AACAudit` | The `GET /kv?key=PG_*` with which the application loads its settings, made by its managed identity | +| Key Vault | `AuditEvent`, `AzurePolicyEvaluationDetails` | `AZKVAuditLogs`, `AZKVPolicyEvaluationDetailsLogs` | The `SecretGet` of `pg-user` and `pg-password` that resolves the two Key Vault references | +| PostgreSQL flexible server | `PostgreSQLLogs` | `PGSQLServerLogs` | The connections of the `testuser` role | + +Their platform metrics land in the `AzureMetrics` table. The settings of the key vault and of the PostgreSQL server write to the resource-specific tables, which Microsoft recommends over the shared `AzureDiagnostics` table: the Azure CLI script passes `--export-to-resource-specific true`, the Bicep modules set `logAnalyticsDestinationType: 'Dedicated'` and the Terraform modules `log_analytics_destination_type = "Dedicated"`. App Service and App Configuration write to resource-specific tables only. The App Service plan sends its metrics too, and the VNet and the two NSGs their network logs; on the emulator the network categories stay empty, because no traffic crosses an emulated network security group. + +### Azure CLI + +The query command comes from the `log-analytics` extension of the Azure CLI: + +```bash +az extension add --name log-analytics +``` + +The query API identifies a workspace by its workspace id (`customerId`): + +```bash +WORKSPACE_ID=$(az monitor log-analytics workspace show \ + --resource-group local-rg \ + --workspace-name local-log-analytics-test \ + --query customerId \ + --output tsv) + +az monitor log-analytics query \ + --workspace "$WORKSPACE_ID" \ + --analytics-query "AppServiceHTTPLogs | project TimeGenerated, CsMethod, CsUriStem, ScStatus, TimeTaken | order by TimeGenerated desc | take 5" \ + --output table +``` + +```text +CsMethod CsUriStem ScStatus TableName TimeGenerated TimeTaken +---------- ----------- ---------- ------------- --------------------------- ----------- +GET / 200 PrimaryResult 2026-11-06T16:05:45.368091Z 51 +GET / 200 PrimaryResult 2026-11-06T16:05:45.283727Z 51 +GET / 200 PrimaryResult 2026-11-06T16:05:45.188826Z 55 +... +``` + +On Azure the table also records the requests of the zip deployment to the app's Kudu site, such as `POST /api/publish` and `GET /api/deployments/latest`. The other tables answer the same way. A few queries to try: + +```kusto +// The line the application writes once it has loaded its settings +AppServiceConsoleLogs +| where ResultDescription has "App Configuration" +| project TimeGenerated, ResultDescription + +// The requests that loaded the settings, with the principal id of the web app's managed identity (ClientObjectId) +AACHttpRequest +| project TimeGenerated, Method, StatusCode, HitCount, ClientObjectId, RequestURI + +// The reads of the two secrets behind the Key Vault references +AZKVAuditLogs +| where OperationName == "SecretGet" +| project TimeGenerated, OperationName, ResultSignature, RequestUri + +// The connections of the application role (on Azure the server also logs those of its maintenance role, azuresu) +PGSQLServerLogs +| where Message has "connection authorized" and Message has "testuser" +| project TimeGenerated, Message + +// The requests per minute, which the Logs page of the portal draws as a chart +AppServiceHTTPLogs +| summarize Requests = count() by bin(TimeGenerated, 1m) +| render columnchart +``` + +The platform metrics need no diagnostic setting: the Azure Monitor metrics API serves them directly. + +```bash +WEB_APP_ID=$(az webapp show \ + --name local-webapp-test \ + --resource-group local-rg \ + --query id \ + --output tsv) + +az monitor metrics list \ + --resource "$WEB_APP_ID" \ + --metrics Requests Http2xx BytesSent \ + --interval PT1M \ + --aggregation Total \ + --query "value[].{Metric:name.value,Total:sum(timeseries[].data[].total)}" \ + --output table +``` + +```text +Metric Total +--------- -------- +Requests 8.0 +Http2xx 8.0 +BytesSent 171720.0 +``` + +[`scripts/query-logs-and-metrics.sh`](./scripts/query-logs-and-metrics.sh) does all of this in one go: it calls the web app, lists the diagnostic settings of the four resources, runs queries like these (plus the platform events of the web app and the exported `AzureMetrics`) until their records arrive, and prints the metrics of the web app, the store, the vault and the server. It exits with a non-zero code when the web app does not answer, or when a query still lacks its records after `RETRY_COUNT` attempts (30 by default) made `SLEEP` seconds apart (10 by default). + +### Azure Portal Emulator + +The emulator serves an emulated Azure portal at `http://localhost:4566/_localstack/portal/` (see [Azure Portal Emulator](https://docs.localstack.cloud/azure/developer-tools/portal/)): + +1. Open the portal and select **Sign in**: the sign-in is a mock and asks for no credentials. +2. Select **Resource groups**, then `local-rg`, the `local-log-analytics-test` workspace and **Logs**. Type a query, for example the `render columnchart` query above, and select **Run**: the **Results** tab lists the records and the **Chart** tab draws them. +3. Open the `local-webapp-test` web app. Under **Monitoring**, **Metrics** charts its platform metrics: select the **Requests** metric, which the chart shows with its default **Sum** aggregation. **Logs** runs queries over the records of the web app only, and **Diagnostic settings** lists its diagnostic setting. +4. The App Configuration store, the key vault and the PostgreSQL server have the same **Metrics**, **Logs** and **Diagnostic settings** pages. + +### Delivery times + +On the emulator the records are queryable within seconds, and the aggregated ones (`AACHttpRequest` and `AzureMetrics`) once their one-minute window is over. On Azure the records take minutes, and the first records of a new diagnostic setting can take [up to 90 minutes](https://learn.microsoft.com/azure/azure-monitor/data-collection/diagnostic-settings#expected-latency-after-creating-a-diagnostic-setting): run the queries again later, or raise `RETRY_COUNT` for the script. A new setting can also take a while before it delivers anything, and on Azure the records of what the application did while it started, such as its start-up console lines or its secret reads in the key vault, can then be missing for good: once the workspace receives records, `az webapp restart --name local-webapp-test --resource-group local-rg` starts the application again, and the records of the new start arrive. + ## Troubleshooting - **The app logs `App Configuration load failed ... 403` or `Forbidden` and restarts.** The identity's role assignments have not propagated yet (up to 10 minutes on Azure), or one of them is missing. Check `az role assignment list --assignee --all` for App Configuration Data Reader on the store and Key Vault Secrets User on the vault; the app keeps retrying and App Service restarts it until the load succeeds. diff --git a/samples/web-app-app-configuration/dotnet/bicep/README.md b/samples/web-app-app-configuration/dotnet/bicep/README.md index e4c77e7..2aae0a8 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/README.md +++ b/samples/web-app-app-configuration/dotnet/bicep/README.md @@ -30,7 +30,7 @@ The [`deploy.sh`](deploy.sh) script creates the resource group while the Bicep m 6. Three [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) (`private-dns-zone.bicep`), `privatelink.postgres.database.azure.com`, `privatelink.azconfig.io` and `privatelink.vaultcore.azure.net`, each linked to the VNet as `link-to-vnet`. 7. Three [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (`private-endpoint.bicep`), groups `postgresqlServer`, `configurationStores` and `vault`, each with a `default` DNS zone group. 8. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview) and one [Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) per subnet (inside `virtual-network.bicep`). -9. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) (`log-analytics.bicep`). +9. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) (`log-analytics.bicep`). The modules of the web app, the plan, the store, the vault, the server and the VNet also deploy the diagnostic settings (named `default`) of their resources; the ones of the Key Vault and of the PostgreSQL server set `logAnalyticsDestinationType: 'Dedicated'`, so their logs land in the resource-specific tables `AZKVAuditLogs`, `AZKVPolicyEvaluationDetailsLogs` and `PGSQLServerLogs`. 10. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) (`app-service-plan.bicep`). 11. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) (`web-app.bicep`) with regional VNet integration into *app-subnet*, the user-assigned identity, and the app settings `Endpoints__AppConfiguration` (the store endpoint) and `AZURE_CLIENT_ID` (the identity client id) next to `LOGIN_NAME`, `WEBSITES_PORT` and the Oryx build flags. The template sets **no** `PG_*` app setting: the app loads the five settings from the store. @@ -124,8 +124,13 @@ az keyvault secret list --vault-name local-keyvault-test --query "[].name" --out az webapp config appsettings list --name local-webapp-test --resource-group local-rg \ --query "[?starts_with(name, 'PG_')].name" --output tsv bash ../scripts/validate.sh + +# read the logs and metrics the diagnostic settings sent to the workspace +bash ../scripts/query-logs-and-metrics.sh ``` +See [Logs and metrics](../README.md#logs-and-metrics) for the queries the script runs and for the pages of the Azure Portal Emulator that show the same records. + `` is `5432` in real Azure, or the port suffix of the server's FQDN in LocalStack: ```bash diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep index e270893..19d8dd5 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/app-configuration.bicep @@ -69,12 +69,6 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the resource tags.') param tags object @@ -88,20 +82,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/app-service-plan.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/app-service-plan.bicep index 7608762..3fab18c 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/modules/app-service-plan.bicep +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/app-service-plan.bicep @@ -96,12 +96,6 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the resource id of the Log Analytics workspace.') param workspaceId string @@ -116,20 +110,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/key-vault.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/key-vault.bicep index de11e56..b251cf3 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/modules/key-vault.bicep +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/key-vault.bicep @@ -113,11 +113,12 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 +@description('Specifies where the diagnostic settings write the logs in the workspace: Dedicated writes each category to its resource-specific table (AZKVAuditLogs, AZKVPolicyEvaluationDetailsLogs), AzureDiagnostics to the shared AzureDiagnostics table.') +@allowed([ + 'AzureDiagnostics' + 'Dedicated' +]) +param logAnalyticsDestinationType string = 'Dedicated' @description('Specifies the resource tags.') param tags object @@ -134,20 +135,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] @@ -222,6 +215,7 @@ resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-pr scope: keyVault properties: { workspaceId: workspaceId + logAnalyticsDestinationType: logAnalyticsDestinationType logs: logs metrics: metrics } diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/postgresql-flexible-server.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/postgresql-flexible-server.bicep index 154d276..2f8bb39 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/modules/postgresql-flexible-server.bicep +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/postgresql-flexible-server.bicep @@ -109,11 +109,12 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 +@description('Specifies where the diagnostic settings write the logs in the workspace: Dedicated writes the server logs to the resource-specific PGSQLServerLogs table that Microsoft recommends, AzureDiagnostics to the shared AzureDiagnostics table.') +@allowed([ + 'AzureDiagnostics' + 'Dedicated' +]) +param logAnalyticsDestinationType string = 'Dedicated' @description('Specifies the tags to be applied to the resources.') param tags object = {} @@ -124,18 +125,10 @@ param tags object = {} var logs = [for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] var metrics = [for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] //******************************************** @@ -203,6 +196,7 @@ resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-pr scope: server properties: { workspaceId: workspaceId + logAnalyticsDestinationType: logAnalyticsDestinationType logs: logs metrics: metrics } diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/virtual-network.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/virtual-network.bicep index c5b8b68..481579b 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/modules/virtual-network.bicep +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/virtual-network.bicep @@ -109,12 +109,6 @@ param vnetMetricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the resource tags.') param tags object @@ -124,26 +118,14 @@ param tags object var nsgLogs = [for category in nsgLogCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] var vnetLogs = [for category in vnetLogCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] var vnetMetrics = [for category in vnetMetricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] //******************************************** diff --git a/samples/web-app-app-configuration/dotnet/bicep/modules/web-app.bicep b/samples/web-app-app-configuration/dotnet/bicep/modules/web-app.bicep index 3d0ce3d..98b2e0b 100644 --- a/samples/web-app-app-configuration/dotnet/bicep/modules/web-app.bicep +++ b/samples/web-app-app-configuration/dotnet/bicep/modules/web-app.bicep @@ -130,12 +130,6 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the username for the application.') param username string = 'paolo' @@ -153,20 +147,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] diff --git a/samples/web-app-app-configuration/dotnet/scripts/README.md b/samples/web-app-app-configuration/dotnet/scripts/README.md index 893220c..734d934 100644 --- a/samples/web-app-app-configuration/dotnet/scripts/README.md +++ b/samples/web-app-app-configuration/dotnet/scripts/README.md @@ -37,10 +37,10 @@ brew install localstack/tap/lstk # or: npm install -g @localstack/lstk 14. A separate application role (`testuser`) created via `psql`, with the minimum schema privileges on `PlannerDB`, the `activities` table and the seeded rows. 15. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). 16. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration into *app-subnet*, forced tunneling, the user-assigned managed identity (`az webapp create --assign-identity `), and the app settings `Endpoints__AppConfiguration` (the store endpoint), `AZURE_CLIENT_ID` (the identity client id), `LOGIN_NAME`, `WEBSITES_PORT`, `SCM_DO_BUILD_DURING_DEPLOYMENT` and `ENABLE_ORYX_BUILD`. No `PG_*` setting: the app loads them from the store. -17. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of the web app, the plan, the PostgreSQL server, the App Configuration store, the Key Vault, the VNet and the two NSGs. +17. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of the web app, the plan, the PostgreSQL server, the App Configuration store, the Key Vault, the VNet and the two NSGs. The settings of the Key Vault and of the PostgreSQL server write their logs to the resource-specific tables (`--export-to-resource-specific true`). 18. The zip deployment of the application code (`az webapp deploy --type zip`). -The Web App uses `testuser`; neither the server-admin login nor the application credentials are written into the Web App's app settings. Use [`validate.sh`](validate.sh) after `deploy.sh` to inspect each Azure resource: it lists the store, its key-values with their content types, the vault and its secret names, the identity, the two role assignments, the three Private DNS Zones, links, Private Endpoints and zone groups, and it exits with a non-zero code if the Web App has any `PG_*` app setting or lacks `Endpoints__AppConfiguration` or `AZURE_CLIENT_ID`. +The Web App uses `testuser`; neither the server-admin login nor the application credentials are written into the Web App's app settings. Use [`validate.sh`](validate.sh) after `deploy.sh` to inspect each Azure resource: it lists the store, its key-values with their content types, the vault and its secret names, the identity, the two role assignments, the three Private DNS Zones, links, Private Endpoints and zone groups, and it exits with a non-zero code if the Web App has any `PG_*` app setting or lacks `Endpoints__AppConfiguration` or `AZURE_CLIENT_ID`. Use [`query-logs-and-metrics.sh`](query-logs-and-metrics.sh) to read what the diagnostic settings sent to the Log Analytics workspace: it calls the Web App, queries its HTTP, console and platform logs, the App Configuration requests, the Key Vault secret reads, the PostgreSQL server log and the exported metrics until their records arrive, and prints the platform metrics of the four resources (see [Logs and metrics](../README.md#logs-and-metrics)). ## Usage @@ -56,6 +56,9 @@ bash deploy.sh # inspect what was deployed bash validate.sh + +# read the logs and metrics the diagnostic settings sent to the workspace +bash query-logs-and-metrics.sh ``` `deploy.sh` and `validate.sh` accept the following environment overrides: @@ -74,6 +77,8 @@ bash validate.sh | `LOGIN_NAME` | `paolo` | User whose activities the app shows | | `DEPLOY_APP` | `1` | Set to `0` to skip the zip deployment step | +`query-logs-and-metrics.sh` takes the same `PREFIX` and `SUFFIX`, plus `WEB_APP_CALLS` (the requests it makes to the Web App, `5`), `RETRY_COUNT` (the attempts of each query, `30`) and `SLEEP` (the seconds between attempts, `10`). Raise `RETRY_COUNT` on Azure, where the first records of a new diagnostic setting can take up to 90 minutes. + The script uses [`call-web-app.sh`](call-web-app.sh) (unchanged from the source sample) to demonstrate four ways of hitting the Web App from outside the emulator. ## Related Documentation diff --git a/samples/web-app-app-configuration/dotnet/scripts/deploy.sh b/samples/web-app-app-configuration/dotnet/scripts/deploy.sh index f152df7..00fd63e 100755 --- a/samples/web-app-app-configuration/dotnet/scripts/deploy.sh +++ b/samples/web-app-app-configuration/dotnet/scripts/deploy.sh @@ -1604,11 +1604,13 @@ if [[ $? != 0 ]]; then echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." - # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace + # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace, + # in the resource-specific PGSQLServerLogs table that Microsoft recommends over the shared AzureDiagnostics table az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$POSTGRES_SERVER_ID" \ --workspace "$LOG_ANALYTICS_NAME" \ + --export-to-resource-specific true \ --logs '[ {"category": "PostgreSQLLogs", "enabled": true} ]' \ @@ -1673,11 +1675,13 @@ if [[ $? != 0 ]]; then echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault..." - # Create the diagnostic settings for the key vault to send logs to the Log Analytics workspace + # Create the diagnostic settings for the key vault to send logs to the Log Analytics workspace, in the + # resource-specific AZKVAuditLogs and AZKVPolicyEvaluationDetailsLogs tables rather than AzureDiagnostics az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$KEY_VAULT_ID" \ --workspace "$LOG_ANALYTICS_NAME" \ + --export-to-resource-specific true \ --logs '[ {"category": "AuditEvent", "enabled": true}, {"category": "AzurePolicyEvaluationDetails", "enabled": true} diff --git a/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh b/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh new file mode 100755 index 0000000..761433f --- /dev/null +++ b/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh @@ -0,0 +1,319 @@ +#!/bin/bash + +# Usage: query-logs-and-metrics.sh +# +# Calls the web app, then reads back what the diagnostic settings of the sample sent to its Log Analytics workspace: +# the HTTP requests, console output and platform events of the web app, the App Configuration requests and the Key +# Vault reads with which the app loads its settings, the PostgreSQL server log and the exported platform metrics. It +# ends with the same platform metrics read from the Azure Monitor metrics API. +# +# The records reach the workspace within seconds on the emulator, and the aggregated ones (App Configuration requests, +# metrics) once their one-minute window is over. On Azure they take minutes, and the first records of a new +# diagnostic setting can take up to 90 minutes, so every query is retried: raise RETRY_COUNT there. + +# Variables (the same defaults and environment overrides as deploy.sh) +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +APP_CONFIG_NAME="${PREFIX}-appconfig-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-keyvault-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +POSTGRES_SERVER_NAME="${PREFIX}-pgflex-${SUFFIX}" +WEB_APP_CALLS="${WEB_APP_CALLS:-5}" +RETRY_COUNT="${RETRY_COUNT:-30}" +SLEEP="${SLEEP:-10}" +EXIT_CODE=0 + +# Run a KQL query on the Log Analytics workspace and print the records it returns as a table, retrying while the +# records are still on their way to the workspace +# $1: what the query reads +# $2: the KQL query +# $3: the JMESPath projection that names and orders the columns of the table +# $4: the number of records the query must return (1 when not given) +query_workspace() { + local description=$1 + local query=$2 + local columns=$3 + local minimum_records=${4:-1} + local records + local attempt=1 + + echo -e "\n$description:\n" + while [ $attempt -le $RETRY_COUNT ]; do + records=$(az monitor log-analytics query \ + --workspace "$WORKSPACE_ID" \ + --analytics-query "$query" \ + --query "length(@)" \ + --output tsv \ + --only-show-errors) + + if [[ $? == 0 && $records -ge $minimum_records ]]; then + az monitor log-analytics query \ + --workspace "$WORKSPACE_ID" \ + --analytics-query "$query" \ + --query "$columns" \ + --output table \ + --only-show-errors + return 0 + else + if [ $attempt -lt $RETRY_COUNT ]; then + echo "Attempt $attempt of $RETRY_COUNT: [${records:-0}] of the [$minimum_records] records expected so far. Waiting [$SLEEP] seconds before retry..." + sleep $SLEEP + fi + attempt=$((attempt + 1)) + fi + done + + echo "ERROR: the [$LOG_ANALYTICS_NAME] Log Analytics workspace returned [${records:-0}] of the [$minimum_records] records expected after $RETRY_COUNT attempts." + echo "On Azure the first records of a new diagnostic setting can take up to 90 minutes: run the script again later or raise RETRY_COUNT." + EXIT_CODE=1 +} + +# Show the diagnostic settings of a resource with the log and metric categories they send to the workspace +# $1: the resource id +# $2: the resource description +show_diagnostic_settings() { + local resource_id=$1 + local description=$2 + + echo -e "\nDiagnostic settings of the $description:\n" + az monitor diagnostic-settings list \ + --resource "$resource_id" \ + --query "[].{Name:name,Logs:join(', ', logs[?enabled].category),Metrics:join(', ', metrics[?enabled].category)}" \ + --output table \ + --only-show-errors +} + +# Show the totals of the last hour of the platform metrics of a resource, read from the Azure Monitor metrics API +# $1: the resource id +# $2: the resource description +# $3...: the metric names +show_metrics() { + local resource_id=$1 + local description=$2 + shift 2 + + echo -e "\nPlatform metrics of the $description in the last hour:\n" + az monitor metrics list \ + --resource "$resource_id" \ + --metrics "$@" \ + --interval PT1M \ + --aggregation Total \ + --offset 1h \ + --query "value[].{Metric:name.value,Unit:unit,Total:sum(timeseries[].data[].total)}" \ + --output table \ + --only-show-errors +} + +# Check whether the log-analytics extension of the Azure CLI, which provides the az monitor log-analytics query +# command, is installed +echo "Checking if the [log-analytics] extension of the Azure CLI is installed..." +az extension show --name log-analytics --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [log-analytics] extension of the Azure CLI is actually installed" + echo "Installing the [log-analytics] extension of the Azure CLI..." + + # Install the log-analytics extension + az extension add \ + --name log-analytics \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[log-analytics] extension of the Azure CLI successfully installed" + else + echo "Failed to install the [log-analytics] extension of the Azure CLI" + exit 1 + fi +else + echo "[log-analytics] extension of the Azure CLI already installed" +fi + +# Get the workspace id (customer id) of the Log Analytics workspace, which identifies it to the query API +echo "Retrieving the workspace id of the [$LOG_ANALYTICS_NAME] Log Analytics workspace..." +WORKSPACE_ID=$(az monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --query customerId \ + --output tsv \ + --only-show-errors) + +if [[ -n $WORKSPACE_ID ]]; then + echo "Workspace id [$WORKSPACE_ID] of the [$LOG_ANALYTICS_NAME] Log Analytics workspace successfully retrieved" +else + echo "Failed to retrieve the workspace id of the [$LOG_ANALYTICS_NAME] Log Analytics workspace" + exit 1 +fi + +# Get the resource id of the web app +echo "Retrieving the resource id of the [$WEB_APP_NAME] web app..." +WEB_APP_ID=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_ID ]]; then + echo "Resource id [$WEB_APP_ID] of the [$WEB_APP_NAME] web app successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Get the default host name of the web app +echo "Retrieving the default host name of the [$WEB_APP_NAME] web app..." +WEB_APP_HOST_NAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query defaultHostName \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_HOST_NAME ]]; then + echo "Default host name [$WEB_APP_HOST_NAME] of the [$WEB_APP_NAME] web app successfully retrieved" +else + echo "Failed to retrieve the default host name of the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Get the resource id of the App Configuration store +echo "Retrieving the resource id of the [$APP_CONFIG_NAME] App Configuration store..." +APP_CONFIG_ID=$(az appconfig show \ + --name "$APP_CONFIG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $APP_CONFIG_ID ]]; then + echo "Resource id [$APP_CONFIG_ID] of the [$APP_CONFIG_NAME] App Configuration store successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$APP_CONFIG_NAME] App Configuration store" + exit 1 +fi + +# Get the resource id of the key vault +echo "Retrieving the resource id of the [$KEY_VAULT_NAME] key vault..." +KEY_VAULT_ID=$(az keyvault show \ + --name "$KEY_VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $KEY_VAULT_ID ]]; then + echo "Resource id [$KEY_VAULT_ID] of the [$KEY_VAULT_NAME] key vault successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$KEY_VAULT_NAME] key vault" + exit 1 +fi + +# Get the resource id of the PostgreSQL flexible server +echo "Retrieving the resource id of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +POSTGRES_SERVER_ID=$(az postgres flexible-server show \ + --name "$POSTGRES_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $POSTGRES_SERVER_ID ]]; then + echo "Resource id [$POSTGRES_SERVER_ID] of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + exit 1 +fi + +# Show where the diagnostic settings send the logs and metrics of the web app and of the three services it reads +show_diagnostic_settings "$WEB_APP_ID" "[$WEB_APP_NAME] web app" +show_diagnostic_settings "$APP_CONFIG_ID" "[$APP_CONFIG_NAME] App Configuration store" +show_diagnostic_settings "$KEY_VAULT_ID" "[$KEY_VAULT_NAME] key vault" +show_diagnostic_settings "$POSTGRES_SERVER_ID" "[$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + +# Wait until the web app answers: on Azure the asynchronous zip deployment can still be building the app +echo -e "\nWaiting for the [$WEB_APP_NAME] web app to answer at [https://$WEB_APP_HOST_NAME/]..." +ATTEMPT=1 +while [ $ATTEMPT -le $RETRY_COUNT ]; do + STATUS_CODE=$(curl --max-time 30 --silent --output /dev/null --write-out "%{http_code}" "https://$WEB_APP_HOST_NAME/") + + if [[ $STATUS_CODE == 200 ]]; then + echo "The [$WEB_APP_NAME] web app answered with status code [$STATUS_CODE]" + break + else + if [ $ATTEMPT -lt $RETRY_COUNT ]; then + echo "Attempt $ATTEMPT of $RETRY_COUNT: the [$WEB_APP_NAME] web app answered with status code [$STATUS_CODE]. Waiting [$SLEEP] seconds before retry..." + sleep $SLEEP + fi + ATTEMPT=$((ATTEMPT + 1)) + fi +done + +if [[ $STATUS_CODE != 200 ]]; then + echo "ERROR: the [$WEB_APP_NAME] web app did not answer with status code [200] after $RETRY_COUNT attempts" + EXIT_CODE=1 +fi + +# Call the web app, so that the request logs and metrics hold the requests of this run +echo "Calling the [$WEB_APP_NAME] web app [$WEB_APP_CALLS] times..." +for CALL in $(seq 1 "$WEB_APP_CALLS"); do + STATUS_CODE=$(curl --max-time 30 --silent --output /dev/null --write-out "%{http_code}" "https://$WEB_APP_HOST_NAME/") + echo "Call $CALL of $WEB_APP_CALLS to the [$WEB_APP_NAME] web app answered with status code [$STATUS_CODE]" +done + +# The requests the web app served (AppServiceHTTPLogs) +query_workspace \ + "Latest requests served by the [$WEB_APP_NAME] web app (AppServiceHTTPLogs)" \ + "AppServiceHTTPLogs | project TimeGenerated, CsMethod, CsUriStem, ScStatus, TimeTaken | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Method:CsMethod,Path:CsUriStem,Status:ScStatus,TimeTakenMs:TimeTaken}" + +# The console output of the web app: the request lines of its server and the application's own log lines +# (AppServiceConsoleLogs). On Azure the lines a container writes while it starts can be missing when it started shortly +# after the diagnostic setting was created, so the query reads the latest lines, which the calls above produced. +query_workspace \ + "Latest console output of the [$WEB_APP_NAME] web app (AppServiceConsoleLogs)" \ + "AppServiceConsoleLogs | project TimeGenerated, ResultDescription | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Line:ResultDescription}" + +# The container events of the web app (AppServicePlatformLogs) +query_workspace \ + "Latest platform events of the [$WEB_APP_NAME] web app (AppServicePlatformLogs)" \ + "AppServicePlatformLogs | project TimeGenerated, Level, Message | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Level:Level,Message:Message}" + +# The requests to the store (AACHttpRequest), each record aggregating the requests of one method and status code: among +# them the GET with which the app reads its key-values, made by the managed identity of the web app (ClientObjectId) +query_workspace \ + "Requests to the [$APP_CONFIG_NAME] App Configuration store (AACHttpRequest)" \ + "AACHttpRequest | project TimeGenerated, Method, StatusCode, HitCount, ClientObjectId, RequestURI | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Method:Method,Status:StatusCode,Hits:HitCount,Caller:ClientObjectId,Uri:RequestURI}" + +# The reads of the pg-user and pg-password secrets that resolved the two Key Vault references (AZKVAuditLogs) +query_workspace \ + "Secret reads in the [$KEY_VAULT_NAME] key vault (AZKVAuditLogs)" \ + "AZKVAuditLogs | where OperationName == 'SecretGet' | project TimeGenerated, OperationName, ResultSignature, RequestUri | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Operation:OperationName,Result:ResultSignature,Uri:RequestUri}" \ + 2 + +# The server log of the PostgreSQL flexible server, with the connections of the app (PGSQLServerLogs) +query_workspace \ + "Latest server log lines of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server (PGSQLServerLogs)" \ + "PGSQLServerLogs | project TimeGenerated, ErrorLevel, Message | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Level:ErrorLevel,Message:Message}" + +# The platform metrics the AllMetrics category of the diagnostic settings exports to the workspace (AzureMetrics), one +# record per metric and minute, exported once the minute is over. On Azure each resource's metrics start arriving at +# their own pace, so the query waits for the first of them and shows those that arrived. +query_workspace \ + "Platform metrics exported to the [$LOG_ANALYTICS_NAME] Log Analytics workspace (AzureMetrics)" \ + "AzureMetrics | where MetricName in ('Requests', 'HttpIncomingRequestCount', 'ServiceApiHit', 'connections_succeeded') | summarize Total = sum(Total) by ResourceProvider, Resource, MetricName | order by ResourceProvider asc" \ + "[].{Provider:ResourceProvider,Resource:Resource,Metric:MetricName,Total:Total}" + +# The same platform metrics, read from the Azure Monitor metrics API: they need no diagnostic setting +show_metrics "$WEB_APP_ID" "[$WEB_APP_NAME] web app" Requests Http2xx Http4xx Http5xx BytesReceived BytesSent +show_metrics "$APP_CONFIG_ID" "[$APP_CONFIG_NAME] App Configuration store" HttpIncomingRequestCount +show_metrics "$KEY_VAULT_ID" "[$KEY_VAULT_NAME] key vault" ServiceApiHit ServiceApiResult +show_metrics "$POSTGRES_SERVER_ID" "[$POSTGRES_SERVER_NAME] PostgreSQL flexible server" connections_succeeded + +exit $EXIT_CODE diff --git a/samples/web-app-app-configuration/dotnet/terraform/README.md b/samples/web-app-app-configuration/dotnet/terraform/README.md index cd3e26f..9c6af8f 100644 --- a/samples/web-app-app-configuration/dotnet/terraform/README.md +++ b/samples/web-app-app-configuration/dotnet/terraform/README.md @@ -32,7 +32,7 @@ The Terraform configuration uses three providers: [azurerm](https://registry.ter 8. Three [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone), `privatelink.postgres.database.azure.com`, `privatelink.azconfig.io` and `privatelink.vaultcore.azure.net`, each linked to the VNet as `link-to-vnet`. 9. Three [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview), groups `postgresqlServer`, `configurationStores` and `vault`, each with a `default` DNS zone group. 10. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview) and one [Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) per subnet. -11. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of every resource, the store and the vault included. +11. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of every resource, the store and the vault included. The settings of the Key Vault and of the PostgreSQL server set `log_analytics_destination_type = "Dedicated"`, so their logs land in the resource-specific tables `AZKVAuditLogs`, `AZKVPolicyEvaluationDetailsLogs` and `PGSQLServerLogs`. 12. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): public-access mode, Burstable `Standard_B1ms`, version 16, 32 GiB, HA disabled, the permissive `AllowAllIPs` firewall rule and the `PlannerDB` [database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers). 13. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). 14. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration, the user-assigned identity (`identity { type = "UserAssigned" }`), and the app settings `Endpoints__AppConfiguration` (the store endpoint) and `AZURE_CLIENT_ID` (the identity client id) next to `LOGIN_NAME`, `WEBSITES_PORT` and the Oryx build flags. **No `PG_*` app setting** is written by Terraform or by `deploy.sh`: the app loads the five settings from the store. @@ -68,6 +68,8 @@ azurerm discovers every endpoint from `ARM_METADATA_HOSTNAME`; AzAPI has no meta - Prints the key-values with their content types and the Web App's app settings. - Zips the source under `../src` and deploys it via `az webapp deploy`. +To read the logs and metrics the diagnostic settings sent to the workspace, run `bash ../scripts/query-logs-and-metrics.sh` with the same `PREFIX` and `SUFFIX` (see [Logs and metrics](../README.md#logs-and-metrics)). + `deploy.sh` accepts the environment overrides `PREFIX`, `SUFFIX`, `LOCATION`, `PG_ADMIN_USER`, `PG_ADMIN_PASSWORD`, `PG_APP_USER`, `PG_APP_PASSWORD`, `RBAC_PROPAGATION_DELAY` and `DEPLOY_APP`. To prove idempotency, run `terraform plan -detailed-exitcode` with the same variables and environment after the apply: the key-values, secrets and role assignments show no diff. To re-run the configuration from an empty state after `az group delete`, purge the soft-deleted Key Vault and App Configuration store first (`az keyvault purge` and `az appconfig purge`, see the cleanup notes in the main README): otherwise the azurerm provider recovers the soft-deleted vault together with its secrets (`recover_soft_deleted_key_vaults`, enabled by default) and `azurerm_key_vault_secret` refuses to adopt the existing `pg-user` and `pg-password` secrets (`A resource with the ID ... already exists - to be managed via Terraform this resource needs to be imported into the State`). This is the provider's behaviour on Azure and on the emulator alike; the Azure CLI and Bicep variants recover the vault and upsert the secrets, so they can be re-run without a purge. On the emulator a few inherited resources still show read-back drift (NAT gateway zones, App Service plan SKU, some web app and diagnostic-setting attributes), a limitation of the emulator, not of this configuration. ### Alternative: seeding the key-values from deploy.sh diff --git a/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/main.tf b/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/main.tf index ee7c2b8..a5b7510 100644 --- a/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/main.tf +++ b/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/main.tf @@ -51,9 +51,10 @@ resource "azurerm_key_vault_secret" "secret" { } resource "azurerm_monitor_diagnostic_setting" "settings" { - name = var.diagnostic_setting_name - target_resource_id = azurerm_key_vault.vault.id - log_analytics_workspace_id = var.log_analytics_workspace_id + name = var.diagnostic_setting_name + target_resource_id = azurerm_key_vault.vault.id + log_analytics_workspace_id = var.log_analytics_workspace_id + log_analytics_destination_type = var.log_analytics_destination_type dynamic "enabled_log" { for_each = toset(var.log_categories) diff --git a/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/variables.tf b/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/variables.tf index 7ff2250..2dcc1f9 100644 --- a/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/variables.tf +++ b/samples/web-app-app-configuration/dotnet/terraform/modules/key_vault/variables.tf @@ -135,6 +135,17 @@ variable "metric_categories" { default = ["AllMetrics"] } +variable "log_analytics_destination_type" { + description = "(Optional) Specifies where the diagnostic setting writes the logs in the workspace: Dedicated writes each category to its resource-specific table (AZKVAuditLogs, AZKVPolicyEvaluationDetailsLogs), AzureDiagnostics to the shared AzureDiagnostics table." + type = string + default = "Dedicated" + + validation { + condition = contains(["AzureDiagnostics", "Dedicated"], var.log_analytics_destination_type) + error_message = "The log_analytics_destination_type must be AzureDiagnostics or Dedicated." + } +} + variable "tags" { description = "(Optional) Specifies the tags of the Key Vault and its secrets." type = map(any) diff --git a/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/main.tf b/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/main.tf index 6ca6673..4ad9c4f 100644 --- a/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/main.tf +++ b/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/main.tf @@ -42,9 +42,10 @@ resource "azurerm_postgresql_flexible_server_firewall_rule" "allow_all" { } resource "azurerm_monitor_diagnostic_setting" "this" { - name = var.diagnostic_setting_name - target_resource_id = azurerm_postgresql_flexible_server.this.id - log_analytics_workspace_id = var.log_analytics_workspace_id + name = var.diagnostic_setting_name + target_resource_id = azurerm_postgresql_flexible_server.this.id + log_analytics_workspace_id = var.log_analytics_workspace_id + log_analytics_destination_type = var.log_analytics_destination_type dynamic "enabled_log" { for_each = toset(var.log_categories) diff --git a/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/variables.tf b/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/variables.tf index 0d33e3e..2203245 100644 --- a/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/variables.tf +++ b/samples/web-app-app-configuration/dotnet/terraform/modules/postgres_flexible_server/variables.tf @@ -119,6 +119,17 @@ variable "metric_categories" { default = ["AllMetrics"] } +variable "log_analytics_destination_type" { + description = "(Optional) Specifies where the diagnostic setting writes the logs in the workspace: Dedicated writes the server logs to the resource-specific PGSQLServerLogs table that Microsoft recommends, AzureDiagnostics to the shared AzureDiagnostics table." + type = string + default = "Dedicated" + + validation { + condition = contains(["AzureDiagnostics", "Dedicated"], var.log_analytics_destination_type) + error_message = "The log_analytics_destination_type must be AzureDiagnostics or Dedicated." + } +} + variable "tags" { description = "(Optional) Specifies the tags of the PostgreSQL flexible server." type = map(string) diff --git a/samples/web-app-app-configuration/python/README.md b/samples/web-app-app-configuration/python/README.md index 26ec454..4f4e638 100644 --- a/samples/web-app-app-configuration/python/README.md +++ b/samples/web-app-app-configuration/python/README.md @@ -137,6 +137,120 @@ Loaded 5 settings from App Configuration https://local-appconfig-test.azure.loca PostgreSQL schema initialized ``` +## Logs and metrics + +The web app, its App Service plan, the App Configuration store, the key vault, the PostgreSQL server, the VNet and the two NSGs each have a diagnostic setting that sends their resource logs, their platform metrics (`AllMetrics`) or both to the `-log-analytics-` Log Analytics workspace. The four resources the application uses record what it does: + +| Resource | Log categories | Tables in the workspace | What the application leaves there | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Web App | `AppServiceHTTPLogs`, `AppServiceConsoleLogs`, `AppServicePlatformLogs`, `AppServiceAppLogs`, `AppServiceAuditLogs`, `AppServiceIPSecAuditLogs`, `AppServiceAuthenticationLogs` | One table per category, named after it | The requests it serves, its console output (the gunicorn access log and the application's log lines) and the start of its container | +| App Configuration store | `HttpRequest`, `Audit` | `AACHttpRequest`, `AACAudit` | The `GET /kv?key=PG_*` with which the application loads its settings, made by its managed identity | +| Key Vault | `AuditEvent`, `AzurePolicyEvaluationDetails` | `AZKVAuditLogs`, `AZKVPolicyEvaluationDetailsLogs` | The `SecretGet` of `pg-user` and `pg-password` that resolves the two Key Vault references | +| PostgreSQL flexible server | `PostgreSQLLogs` | `PGSQLServerLogs` | The connections of the `testuser` role | + +Their platform metrics land in the `AzureMetrics` table. The settings of the key vault and of the PostgreSQL server write to the resource-specific tables, which Microsoft recommends over the shared `AzureDiagnostics` table: the Azure CLI script passes `--export-to-resource-specific true`, the Bicep modules set `logAnalyticsDestinationType: 'Dedicated'` and the Terraform modules `log_analytics_destination_type = "Dedicated"`. App Service and App Configuration write to resource-specific tables only. The App Service plan sends its metrics too, and the VNet and the two NSGs their network logs; on the emulator the network categories stay empty, because no traffic crosses an emulated network security group. + +### Azure CLI + +The query command comes from the `log-analytics` extension of the Azure CLI: + +```bash +az extension add --name log-analytics +``` + +The query API identifies a workspace by its workspace id (`customerId`): + +```bash +WORKSPACE_ID=$(az monitor log-analytics workspace show \ + --resource-group local-rg \ + --workspace-name local-log-analytics-test \ + --query customerId \ + --output tsv) + +az monitor log-analytics query \ + --workspace "$WORKSPACE_ID" \ + --analytics-query "AppServiceHTTPLogs | project TimeGenerated, CsMethod, CsUriStem, ScStatus, TimeTaken | order by TimeGenerated desc | take 5" \ + --output table +``` + +```text +CsMethod CsUriStem ScStatus TableName TimeGenerated TimeTaken +---------- ----------- ---------- ------------- --------------------------- ----------- +GET / 200 PrimaryResult 2026-11-06T16:05:45.368091Z 51 +GET / 200 PrimaryResult 2026-11-06T16:05:45.283727Z 51 +GET / 200 PrimaryResult 2026-11-06T16:05:45.188826Z 55 +... +``` + +On Azure the table also records the requests of the zip deployment to the app's Kudu site, such as `POST /api/publish` and `GET /api/deployments/latest`. The other tables answer the same way. A few queries to try: + +```kusto +// The line the application writes once it has loaded its settings +AppServiceConsoleLogs +| where ResultDescription has "App Configuration" +| project TimeGenerated, ResultDescription + +// The requests that loaded the settings, with the principal id of the web app's managed identity (ClientObjectId) +AACHttpRequest +| project TimeGenerated, Method, StatusCode, HitCount, ClientObjectId, RequestURI + +// The reads of the two secrets behind the Key Vault references +AZKVAuditLogs +| where OperationName == "SecretGet" +| project TimeGenerated, OperationName, ResultSignature, RequestUri + +// The connections of the application role (on Azure the server also logs those of its maintenance role, azuresu) +PGSQLServerLogs +| where Message has "connection authorized" and Message has "testuser" +| project TimeGenerated, Message + +// The requests per minute, which the Logs page of the portal draws as a chart +AppServiceHTTPLogs +| summarize Requests = count() by bin(TimeGenerated, 1m) +| render columnchart +``` + +The platform metrics need no diagnostic setting: the Azure Monitor metrics API serves them directly. + +```bash +WEB_APP_ID=$(az webapp show \ + --name local-webapp-test \ + --resource-group local-rg \ + --query id \ + --output tsv) + +az monitor metrics list \ + --resource "$WEB_APP_ID" \ + --metrics Requests Http2xx BytesSent \ + --interval PT1M \ + --aggregation Total \ + --query "value[].{Metric:name.value,Total:sum(timeseries[].data[].total)}" \ + --output table +``` + +```text +Metric Total +--------- -------- +Requests 8.0 +Http2xx 8.0 +BytesSent 171720.0 +``` + +[`scripts/query-logs-and-metrics.sh`](./scripts/query-logs-and-metrics.sh) does all of this in one go: it calls the web app, lists the diagnostic settings of the four resources, runs queries like these (plus the platform events of the web app and the exported `AzureMetrics`) until their records arrive, and prints the metrics of the web app, the store, the vault and the server. It exits with a non-zero code when the web app does not answer, or when a query still lacks its records after `RETRY_COUNT` attempts (30 by default) made `SLEEP` seconds apart (10 by default). + +### Azure Portal Emulator + +The emulator serves an emulated Azure portal at `http://localhost:4566/_localstack/portal/` (see [Azure Portal Emulator](https://docs.localstack.cloud/azure/developer-tools/portal/)): + +1. Open the portal and select **Sign in**: the sign-in is a mock and asks for no credentials. +2. Select **Resource groups**, then `local-rg`, the `local-log-analytics-test` workspace and **Logs**. Type a query, for example the `render columnchart` query above, and select **Run**: the **Results** tab lists the records and the **Chart** tab draws them. +3. Open the `local-webapp-test` web app. Under **Monitoring**, **Metrics** charts its platform metrics: select the **Requests** metric, which the chart shows with its default **Sum** aggregation. **Logs** runs queries over the records of the web app only, and **Diagnostic settings** lists its diagnostic setting. +4. The App Configuration store, the key vault and the PostgreSQL server have the same **Metrics**, **Logs** and **Diagnostic settings** pages. + +### Delivery times + +On the emulator the records are queryable within seconds, and the aggregated ones (`AACHttpRequest` and `AzureMetrics`) once their one-minute window is over. On Azure the records take minutes, and the first records of a new diagnostic setting can take [up to 90 minutes](https://learn.microsoft.com/azure/azure-monitor/data-collection/diagnostic-settings#expected-latency-after-creating-a-diagnostic-setting): run the queries again later, or raise `RETRY_COUNT` for the script. A new setting can also take a while before it delivers anything, and on Azure the records of what the application did while it started, such as its start-up console lines or its secret reads in the key vault, can then be missing for good: once the workspace receives records, `az webapp restart --name local-webapp-test --resource-group local-rg` starts the application again, and the records of the new start arrive. + ## Troubleshooting - **The app logs `App Configuration load failed ... 403` or `Forbidden` and restarts.** The identity's role assignments have not propagated yet (up to 10 minutes on Azure), or one of them is missing. Check `az role assignment list --assignee --all` for App Configuration Data Reader on the store and Key Vault Secrets User on the vault; the app keeps retrying and App Service restarts it until the load succeeds. diff --git a/samples/web-app-app-configuration/python/bicep/README.md b/samples/web-app-app-configuration/python/bicep/README.md index a3a72bb..b638e65 100644 --- a/samples/web-app-app-configuration/python/bicep/README.md +++ b/samples/web-app-app-configuration/python/bicep/README.md @@ -30,7 +30,7 @@ The [`deploy.sh`](deploy.sh) script creates the resource group while the Bicep m 6. Three [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone) (`private-dns-zone.bicep`), `privatelink.postgres.database.azure.com`, `privatelink.azconfig.io` and `privatelink.vaultcore.azure.net`, each linked to the VNet as `link-to-vnet`. 7. Three [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview) (`private-endpoint.bicep`), groups `postgresqlServer`, `configurationStores` and `vault`, each with a `default` DNS zone group. 8. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview) and one [Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) per subnet (inside `virtual-network.bicep`). -9. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) (`log-analytics.bicep`). +9. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) (`log-analytics.bicep`). The modules of the web app, the plan, the store, the vault, the server and the VNet also deploy the diagnostic settings (named `default`) of their resources; the ones of the Key Vault and of the PostgreSQL server set `logAnalyticsDestinationType: 'Dedicated'`, so their logs land in the resource-specific tables `AZKVAuditLogs`, `AZKVPolicyEvaluationDetailsLogs` and `PGSQLServerLogs`. 10. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) (`app-service-plan.bicep`). 11. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) (`web-app.bicep`) with regional VNet integration into *app-subnet*, the user-assigned identity, and the app settings `Endpoints__AppConfiguration` (the store endpoint) and `AZURE_CLIENT_ID` (the identity client id) next to `LOGIN_NAME`, `WEBSITES_PORT` and the Oryx build flags. The template sets **no** `PG_*` app setting: the app loads the five settings from the store. @@ -124,8 +124,13 @@ az keyvault secret list --vault-name local-keyvault-test --query "[].name" --out az webapp config appsettings list --name local-webapp-test --resource-group local-rg \ --query "[?starts_with(name, 'PG_')].name" --output tsv bash ../scripts/validate.sh + +# read the logs and metrics the diagnostic settings sent to the workspace +bash ../scripts/query-logs-and-metrics.sh ``` +See [Logs and metrics](../README.md#logs-and-metrics) for the queries the script runs and for the pages of the Azure Portal Emulator that show the same records. + `` is `5432` in real Azure, or the port suffix of the server's FQDN in LocalStack: ```bash diff --git a/samples/web-app-app-configuration/python/bicep/modules/app-configuration.bicep b/samples/web-app-app-configuration/python/bicep/modules/app-configuration.bicep index e270893..19d8dd5 100644 --- a/samples/web-app-app-configuration/python/bicep/modules/app-configuration.bicep +++ b/samples/web-app-app-configuration/python/bicep/modules/app-configuration.bicep @@ -69,12 +69,6 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the resource tags.') param tags object @@ -88,20 +82,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] diff --git a/samples/web-app-app-configuration/python/bicep/modules/app-service-plan.bicep b/samples/web-app-app-configuration/python/bicep/modules/app-service-plan.bicep index 7608762..3fab18c 100644 --- a/samples/web-app-app-configuration/python/bicep/modules/app-service-plan.bicep +++ b/samples/web-app-app-configuration/python/bicep/modules/app-service-plan.bicep @@ -96,12 +96,6 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the resource id of the Log Analytics workspace.') param workspaceId string @@ -116,20 +110,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] diff --git a/samples/web-app-app-configuration/python/bicep/modules/key-vault.bicep b/samples/web-app-app-configuration/python/bicep/modules/key-vault.bicep index de11e56..b251cf3 100644 --- a/samples/web-app-app-configuration/python/bicep/modules/key-vault.bicep +++ b/samples/web-app-app-configuration/python/bicep/modules/key-vault.bicep @@ -113,11 +113,12 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 +@description('Specifies where the diagnostic settings write the logs in the workspace: Dedicated writes each category to its resource-specific table (AZKVAuditLogs, AZKVPolicyEvaluationDetailsLogs), AzureDiagnostics to the shared AzureDiagnostics table.') +@allowed([ + 'AzureDiagnostics' + 'Dedicated' +]) +param logAnalyticsDestinationType string = 'Dedicated' @description('Specifies the resource tags.') param tags object @@ -134,20 +135,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] @@ -222,6 +215,7 @@ resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-pr scope: keyVault properties: { workspaceId: workspaceId + logAnalyticsDestinationType: logAnalyticsDestinationType logs: logs metrics: metrics } diff --git a/samples/web-app-app-configuration/python/bicep/modules/postgresql-flexible-server.bicep b/samples/web-app-app-configuration/python/bicep/modules/postgresql-flexible-server.bicep index 154d276..2f8bb39 100644 --- a/samples/web-app-app-configuration/python/bicep/modules/postgresql-flexible-server.bicep +++ b/samples/web-app-app-configuration/python/bicep/modules/postgresql-flexible-server.bicep @@ -109,11 +109,12 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 +@description('Specifies where the diagnostic settings write the logs in the workspace: Dedicated writes the server logs to the resource-specific PGSQLServerLogs table that Microsoft recommends, AzureDiagnostics to the shared AzureDiagnostics table.') +@allowed([ + 'AzureDiagnostics' + 'Dedicated' +]) +param logAnalyticsDestinationType string = 'Dedicated' @description('Specifies the tags to be applied to the resources.') param tags object = {} @@ -124,18 +125,10 @@ param tags object = {} var logs = [for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] var metrics = [for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] //******************************************** @@ -203,6 +196,7 @@ resource diagnosticSettings 'Microsoft.Insights/diagnosticSettings@2021-05-01-pr scope: server properties: { workspaceId: workspaceId + logAnalyticsDestinationType: logAnalyticsDestinationType logs: logs metrics: metrics } diff --git a/samples/web-app-app-configuration/python/bicep/modules/virtual-network.bicep b/samples/web-app-app-configuration/python/bicep/modules/virtual-network.bicep index c5b8b68..481579b 100644 --- a/samples/web-app-app-configuration/python/bicep/modules/virtual-network.bicep +++ b/samples/web-app-app-configuration/python/bicep/modules/virtual-network.bicep @@ -109,12 +109,6 @@ param vnetMetricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the resource tags.') param tags object @@ -124,26 +118,14 @@ param tags object var nsgLogs = [for category in nsgLogCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] var vnetLogs = [for category in vnetLogCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] var vnetMetrics = [for category in vnetMetricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } }] //******************************************** diff --git a/samples/web-app-app-configuration/python/bicep/modules/web-app.bicep b/samples/web-app-app-configuration/python/bicep/modules/web-app.bicep index 3d0ce3d..98b2e0b 100644 --- a/samples/web-app-app-configuration/python/bicep/modules/web-app.bicep +++ b/samples/web-app-app-configuration/python/bicep/modules/web-app.bicep @@ -130,12 +130,6 @@ param metricCategories array = [ 'AllMetrics' ] -@description('Specifies whether the retention policy of the diagnostic settings is enabled.') -param retentionPolicyEnabled bool = true - -@description('Specifies the retention of the diagnostic settings in days (0 keeps the data as long as the workspace does).') -param retentionPolicyDays int = 0 - @description('Specifies the username for the application.') param username string = 'paolo' @@ -153,20 +147,12 @@ var logs = [ for category in logCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] var metrics = [ for category in metricCategories: { category: category enabled: true - retentionPolicy: { - enabled: retentionPolicyEnabled - days: retentionPolicyDays - } } ] diff --git a/samples/web-app-app-configuration/python/scripts/README.md b/samples/web-app-app-configuration/python/scripts/README.md index 8ed7305..a60a05c 100644 --- a/samples/web-app-app-configuration/python/scripts/README.md +++ b/samples/web-app-app-configuration/python/scripts/README.md @@ -37,10 +37,10 @@ brew install localstack/tap/lstk # or: npm install -g @localstack/lstk 14. A separate application role (`testuser`) created via `psql`, with the minimum schema privileges on `PlannerDB`, the `activities` table and the seeded rows. 15. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). 16. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration into *app-subnet*, forced tunneling, the user-assigned managed identity (`az webapp create --assign-identity `), and the app settings `Endpoints__AppConfiguration` (the store endpoint), `AZURE_CLIENT_ID` (the identity client id), `LOGIN_NAME`, `WEBSITES_PORT`, `SCM_DO_BUILD_DURING_DEPLOYMENT` and `ENABLE_ORYX_BUILD`. No `PG_*` setting: the app loads them from the store. -17. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of the web app, the plan, the PostgreSQL server, the App Configuration store, the Key Vault, the VNet and the two NSGs. +17. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of the web app, the plan, the PostgreSQL server, the App Configuration store, the Key Vault, the VNet and the two NSGs. The settings of the Key Vault and of the PostgreSQL server write their logs to the resource-specific tables (`--export-to-resource-specific true`). 18. The zip deployment of the application code (`az webapp deploy --type zip`). -The Web App uses `testuser`; neither the server-admin login nor the application credentials are written into the Web App's app settings. Use [`validate.sh`](validate.sh) after `deploy.sh` to inspect each Azure resource: it lists the store, its key-values with their content types, the vault and its secret names, the identity, the two role assignments, the three Private DNS Zones, links, Private Endpoints and zone groups, and it exits with a non-zero code if the Web App has any `PG_*` app setting or lacks `Endpoints__AppConfiguration` or `AZURE_CLIENT_ID`. +The Web App uses `testuser`; neither the server-admin login nor the application credentials are written into the Web App's app settings. Use [`validate.sh`](validate.sh) after `deploy.sh` to inspect each Azure resource: it lists the store, its key-values with their content types, the vault and its secret names, the identity, the two role assignments, the three Private DNS Zones, links, Private Endpoints and zone groups, and it exits with a non-zero code if the Web App has any `PG_*` app setting or lacks `Endpoints__AppConfiguration` or `AZURE_CLIENT_ID`. Use [`query-logs-and-metrics.sh`](query-logs-and-metrics.sh) to read what the diagnostic settings sent to the Log Analytics workspace: it calls the Web App, queries its HTTP, console and platform logs, the App Configuration requests, the Key Vault secret reads, the PostgreSQL server log and the exported metrics until their records arrive, and prints the platform metrics of the four resources (see [Logs and metrics](../README.md#logs-and-metrics)). ## Usage @@ -56,6 +56,9 @@ bash deploy.sh # inspect what was deployed bash validate.sh + +# read the logs and metrics the diagnostic settings sent to the workspace +bash query-logs-and-metrics.sh ``` `deploy.sh` and `validate.sh` accept the following environment overrides: @@ -74,6 +77,8 @@ bash validate.sh | `LOGIN_NAME` | `paolo` | User whose activities the app shows | | `DEPLOY_APP` | `1` | Set to `0` to skip the zip deployment step | +`query-logs-and-metrics.sh` takes the same `PREFIX` and `SUFFIX`, plus `WEB_APP_CALLS` (the requests it makes to the Web App, `5`), `RETRY_COUNT` (the attempts of each query, `30`) and `SLEEP` (the seconds between attempts, `10`). Raise `RETRY_COUNT` on Azure, where the first records of a new diagnostic setting can take up to 90 minutes. + The script uses [`call-web-app.sh`](call-web-app.sh) (unchanged from the source sample) to demonstrate four ways of hitting the Web App from outside the emulator. ## Related Documentation diff --git a/samples/web-app-app-configuration/python/scripts/deploy.sh b/samples/web-app-app-configuration/python/scripts/deploy.sh index 99dec68..ee463b2 100644 --- a/samples/web-app-app-configuration/python/scripts/deploy.sh +++ b/samples/web-app-app-configuration/python/scripts/deploy.sh @@ -1604,11 +1604,13 @@ if [[ $? != 0 ]]; then echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." - # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace + # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace, + # in the resource-specific PGSQLServerLogs table that Microsoft recommends over the shared AzureDiagnostics table az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$POSTGRES_SERVER_ID" \ --workspace "$LOG_ANALYTICS_NAME" \ + --export-to-resource-specific true \ --logs '[ {"category": "PostgreSQLLogs", "enabled": true} ]' \ @@ -1673,11 +1675,13 @@ if [[ $? != 0 ]]; then echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault..." - # Create the diagnostic settings for the key vault to send logs to the Log Analytics workspace + # Create the diagnostic settings for the key vault to send logs to the Log Analytics workspace, in the + # resource-specific AZKVAuditLogs and AZKVPolicyEvaluationDetailsLogs tables rather than AzureDiagnostics az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$KEY_VAULT_ID" \ --workspace "$LOG_ANALYTICS_NAME" \ + --export-to-resource-specific true \ --logs '[ {"category": "AuditEvent", "enabled": true}, {"category": "AzurePolicyEvaluationDetails", "enabled": true} diff --git a/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh b/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh new file mode 100755 index 0000000..761433f --- /dev/null +++ b/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh @@ -0,0 +1,319 @@ +#!/bin/bash + +# Usage: query-logs-and-metrics.sh +# +# Calls the web app, then reads back what the diagnostic settings of the sample sent to its Log Analytics workspace: +# the HTTP requests, console output and platform events of the web app, the App Configuration requests and the Key +# Vault reads with which the app loads its settings, the PostgreSQL server log and the exported platform metrics. It +# ends with the same platform metrics read from the Azure Monitor metrics API. +# +# The records reach the workspace within seconds on the emulator, and the aggregated ones (App Configuration requests, +# metrics) once their one-minute window is over. On Azure they take minutes, and the first records of a new +# diagnostic setting can take up to 90 minutes, so every query is retried: raise RETRY_COUNT there. + +# Variables (the same defaults and environment overrides as deploy.sh) +PREFIX="${PREFIX:-local}" +SUFFIX="${SUFFIX:-test}" +RESOURCE_GROUP_NAME="${PREFIX}-rg" +LOG_ANALYTICS_NAME="${PREFIX}-log-analytics-${SUFFIX}" +APP_CONFIG_NAME="${PREFIX}-appconfig-${SUFFIX}" +KEY_VAULT_NAME="${PREFIX}-keyvault-${SUFFIX}" +WEB_APP_NAME="${PREFIX}-webapp-${SUFFIX}" +POSTGRES_SERVER_NAME="${PREFIX}-pgflex-${SUFFIX}" +WEB_APP_CALLS="${WEB_APP_CALLS:-5}" +RETRY_COUNT="${RETRY_COUNT:-30}" +SLEEP="${SLEEP:-10}" +EXIT_CODE=0 + +# Run a KQL query on the Log Analytics workspace and print the records it returns as a table, retrying while the +# records are still on their way to the workspace +# $1: what the query reads +# $2: the KQL query +# $3: the JMESPath projection that names and orders the columns of the table +# $4: the number of records the query must return (1 when not given) +query_workspace() { + local description=$1 + local query=$2 + local columns=$3 + local minimum_records=${4:-1} + local records + local attempt=1 + + echo -e "\n$description:\n" + while [ $attempt -le $RETRY_COUNT ]; do + records=$(az monitor log-analytics query \ + --workspace "$WORKSPACE_ID" \ + --analytics-query "$query" \ + --query "length(@)" \ + --output tsv \ + --only-show-errors) + + if [[ $? == 0 && $records -ge $minimum_records ]]; then + az monitor log-analytics query \ + --workspace "$WORKSPACE_ID" \ + --analytics-query "$query" \ + --query "$columns" \ + --output table \ + --only-show-errors + return 0 + else + if [ $attempt -lt $RETRY_COUNT ]; then + echo "Attempt $attempt of $RETRY_COUNT: [${records:-0}] of the [$minimum_records] records expected so far. Waiting [$SLEEP] seconds before retry..." + sleep $SLEEP + fi + attempt=$((attempt + 1)) + fi + done + + echo "ERROR: the [$LOG_ANALYTICS_NAME] Log Analytics workspace returned [${records:-0}] of the [$minimum_records] records expected after $RETRY_COUNT attempts." + echo "On Azure the first records of a new diagnostic setting can take up to 90 minutes: run the script again later or raise RETRY_COUNT." + EXIT_CODE=1 +} + +# Show the diagnostic settings of a resource with the log and metric categories they send to the workspace +# $1: the resource id +# $2: the resource description +show_diagnostic_settings() { + local resource_id=$1 + local description=$2 + + echo -e "\nDiagnostic settings of the $description:\n" + az monitor diagnostic-settings list \ + --resource "$resource_id" \ + --query "[].{Name:name,Logs:join(', ', logs[?enabled].category),Metrics:join(', ', metrics[?enabled].category)}" \ + --output table \ + --only-show-errors +} + +# Show the totals of the last hour of the platform metrics of a resource, read from the Azure Monitor metrics API +# $1: the resource id +# $2: the resource description +# $3...: the metric names +show_metrics() { + local resource_id=$1 + local description=$2 + shift 2 + + echo -e "\nPlatform metrics of the $description in the last hour:\n" + az monitor metrics list \ + --resource "$resource_id" \ + --metrics "$@" \ + --interval PT1M \ + --aggregation Total \ + --offset 1h \ + --query "value[].{Metric:name.value,Unit:unit,Total:sum(timeseries[].data[].total)}" \ + --output table \ + --only-show-errors +} + +# Check whether the log-analytics extension of the Azure CLI, which provides the az monitor log-analytics query +# command, is installed +echo "Checking if the [log-analytics] extension of the Azure CLI is installed..." +az extension show --name log-analytics --only-show-errors &>/dev/null + +if [[ $? != 0 ]]; then + echo "No [log-analytics] extension of the Azure CLI is actually installed" + echo "Installing the [log-analytics] extension of the Azure CLI..." + + # Install the log-analytics extension + az extension add \ + --name log-analytics \ + --only-show-errors 1>/dev/null + + if [[ $? == 0 ]]; then + echo "[log-analytics] extension of the Azure CLI successfully installed" + else + echo "Failed to install the [log-analytics] extension of the Azure CLI" + exit 1 + fi +else + echo "[log-analytics] extension of the Azure CLI already installed" +fi + +# Get the workspace id (customer id) of the Log Analytics workspace, which identifies it to the query API +echo "Retrieving the workspace id of the [$LOG_ANALYTICS_NAME] Log Analytics workspace..." +WORKSPACE_ID=$(az monitor log-analytics workspace show \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --workspace-name "$LOG_ANALYTICS_NAME" \ + --query customerId \ + --output tsv \ + --only-show-errors) + +if [[ -n $WORKSPACE_ID ]]; then + echo "Workspace id [$WORKSPACE_ID] of the [$LOG_ANALYTICS_NAME] Log Analytics workspace successfully retrieved" +else + echo "Failed to retrieve the workspace id of the [$LOG_ANALYTICS_NAME] Log Analytics workspace" + exit 1 +fi + +# Get the resource id of the web app +echo "Retrieving the resource id of the [$WEB_APP_NAME] web app..." +WEB_APP_ID=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_ID ]]; then + echo "Resource id [$WEB_APP_ID] of the [$WEB_APP_NAME] web app successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Get the default host name of the web app +echo "Retrieving the default host name of the [$WEB_APP_NAME] web app..." +WEB_APP_HOST_NAME=$(az webapp show \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query defaultHostName \ + --output tsv \ + --only-show-errors) + +if [[ -n $WEB_APP_HOST_NAME ]]; then + echo "Default host name [$WEB_APP_HOST_NAME] of the [$WEB_APP_NAME] web app successfully retrieved" +else + echo "Failed to retrieve the default host name of the [$WEB_APP_NAME] web app" + exit 1 +fi + +# Get the resource id of the App Configuration store +echo "Retrieving the resource id of the [$APP_CONFIG_NAME] App Configuration store..." +APP_CONFIG_ID=$(az appconfig show \ + --name "$APP_CONFIG_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $APP_CONFIG_ID ]]; then + echo "Resource id [$APP_CONFIG_ID] of the [$APP_CONFIG_NAME] App Configuration store successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$APP_CONFIG_NAME] App Configuration store" + exit 1 +fi + +# Get the resource id of the key vault +echo "Retrieving the resource id of the [$KEY_VAULT_NAME] key vault..." +KEY_VAULT_ID=$(az keyvault show \ + --name "$KEY_VAULT_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $KEY_VAULT_ID ]]; then + echo "Resource id [$KEY_VAULT_ID] of the [$KEY_VAULT_NAME] key vault successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$KEY_VAULT_NAME] key vault" + exit 1 +fi + +# Get the resource id of the PostgreSQL flexible server +echo "Retrieving the resource id of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." +POSTGRES_SERVER_ID=$(az postgres flexible-server show \ + --name "$POSTGRES_SERVER_NAME" \ + --resource-group "$RESOURCE_GROUP_NAME" \ + --query id \ + --output tsv \ + --only-show-errors) + +if [[ -n $POSTGRES_SERVER_ID ]]; then + echo "Resource id [$POSTGRES_SERVER_ID] of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server successfully retrieved" +else + echo "Failed to retrieve the resource id of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + exit 1 +fi + +# Show where the diagnostic settings send the logs and metrics of the web app and of the three services it reads +show_diagnostic_settings "$WEB_APP_ID" "[$WEB_APP_NAME] web app" +show_diagnostic_settings "$APP_CONFIG_ID" "[$APP_CONFIG_NAME] App Configuration store" +show_diagnostic_settings "$KEY_VAULT_ID" "[$KEY_VAULT_NAME] key vault" +show_diagnostic_settings "$POSTGRES_SERVER_ID" "[$POSTGRES_SERVER_NAME] PostgreSQL flexible server" + +# Wait until the web app answers: on Azure the asynchronous zip deployment can still be building the app +echo -e "\nWaiting for the [$WEB_APP_NAME] web app to answer at [https://$WEB_APP_HOST_NAME/]..." +ATTEMPT=1 +while [ $ATTEMPT -le $RETRY_COUNT ]; do + STATUS_CODE=$(curl --max-time 30 --silent --output /dev/null --write-out "%{http_code}" "https://$WEB_APP_HOST_NAME/") + + if [[ $STATUS_CODE == 200 ]]; then + echo "The [$WEB_APP_NAME] web app answered with status code [$STATUS_CODE]" + break + else + if [ $ATTEMPT -lt $RETRY_COUNT ]; then + echo "Attempt $ATTEMPT of $RETRY_COUNT: the [$WEB_APP_NAME] web app answered with status code [$STATUS_CODE]. Waiting [$SLEEP] seconds before retry..." + sleep $SLEEP + fi + ATTEMPT=$((ATTEMPT + 1)) + fi +done + +if [[ $STATUS_CODE != 200 ]]; then + echo "ERROR: the [$WEB_APP_NAME] web app did not answer with status code [200] after $RETRY_COUNT attempts" + EXIT_CODE=1 +fi + +# Call the web app, so that the request logs and metrics hold the requests of this run +echo "Calling the [$WEB_APP_NAME] web app [$WEB_APP_CALLS] times..." +for CALL in $(seq 1 "$WEB_APP_CALLS"); do + STATUS_CODE=$(curl --max-time 30 --silent --output /dev/null --write-out "%{http_code}" "https://$WEB_APP_HOST_NAME/") + echo "Call $CALL of $WEB_APP_CALLS to the [$WEB_APP_NAME] web app answered with status code [$STATUS_CODE]" +done + +# The requests the web app served (AppServiceHTTPLogs) +query_workspace \ + "Latest requests served by the [$WEB_APP_NAME] web app (AppServiceHTTPLogs)" \ + "AppServiceHTTPLogs | project TimeGenerated, CsMethod, CsUriStem, ScStatus, TimeTaken | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Method:CsMethod,Path:CsUriStem,Status:ScStatus,TimeTakenMs:TimeTaken}" + +# The console output of the web app: the request lines of its server and the application's own log lines +# (AppServiceConsoleLogs). On Azure the lines a container writes while it starts can be missing when it started shortly +# after the diagnostic setting was created, so the query reads the latest lines, which the calls above produced. +query_workspace \ + "Latest console output of the [$WEB_APP_NAME] web app (AppServiceConsoleLogs)" \ + "AppServiceConsoleLogs | project TimeGenerated, ResultDescription | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Line:ResultDescription}" + +# The container events of the web app (AppServicePlatformLogs) +query_workspace \ + "Latest platform events of the [$WEB_APP_NAME] web app (AppServicePlatformLogs)" \ + "AppServicePlatformLogs | project TimeGenerated, Level, Message | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Level:Level,Message:Message}" + +# The requests to the store (AACHttpRequest), each record aggregating the requests of one method and status code: among +# them the GET with which the app reads its key-values, made by the managed identity of the web app (ClientObjectId) +query_workspace \ + "Requests to the [$APP_CONFIG_NAME] App Configuration store (AACHttpRequest)" \ + "AACHttpRequest | project TimeGenerated, Method, StatusCode, HitCount, ClientObjectId, RequestURI | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Method:Method,Status:StatusCode,Hits:HitCount,Caller:ClientObjectId,Uri:RequestURI}" + +# The reads of the pg-user and pg-password secrets that resolved the two Key Vault references (AZKVAuditLogs) +query_workspace \ + "Secret reads in the [$KEY_VAULT_NAME] key vault (AZKVAuditLogs)" \ + "AZKVAuditLogs | where OperationName == 'SecretGet' | project TimeGenerated, OperationName, ResultSignature, RequestUri | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Operation:OperationName,Result:ResultSignature,Uri:RequestUri}" \ + 2 + +# The server log of the PostgreSQL flexible server, with the connections of the app (PGSQLServerLogs) +query_workspace \ + "Latest server log lines of the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server (PGSQLServerLogs)" \ + "PGSQLServerLogs | project TimeGenerated, ErrorLevel, Message | order by TimeGenerated desc | take 10" \ + "[].{Time:TimeGenerated,Level:ErrorLevel,Message:Message}" + +# The platform metrics the AllMetrics category of the diagnostic settings exports to the workspace (AzureMetrics), one +# record per metric and minute, exported once the minute is over. On Azure each resource's metrics start arriving at +# their own pace, so the query waits for the first of them and shows those that arrived. +query_workspace \ + "Platform metrics exported to the [$LOG_ANALYTICS_NAME] Log Analytics workspace (AzureMetrics)" \ + "AzureMetrics | where MetricName in ('Requests', 'HttpIncomingRequestCount', 'ServiceApiHit', 'connections_succeeded') | summarize Total = sum(Total) by ResourceProvider, Resource, MetricName | order by ResourceProvider asc" \ + "[].{Provider:ResourceProvider,Resource:Resource,Metric:MetricName,Total:Total}" + +# The same platform metrics, read from the Azure Monitor metrics API: they need no diagnostic setting +show_metrics "$WEB_APP_ID" "[$WEB_APP_NAME] web app" Requests Http2xx Http4xx Http5xx BytesReceived BytesSent +show_metrics "$APP_CONFIG_ID" "[$APP_CONFIG_NAME] App Configuration store" HttpIncomingRequestCount +show_metrics "$KEY_VAULT_ID" "[$KEY_VAULT_NAME] key vault" ServiceApiHit ServiceApiResult +show_metrics "$POSTGRES_SERVER_ID" "[$POSTGRES_SERVER_NAME] PostgreSQL flexible server" connections_succeeded + +exit $EXIT_CODE diff --git a/samples/web-app-app-configuration/python/terraform/README.md b/samples/web-app-app-configuration/python/terraform/README.md index bf9a52f..b0684a9 100644 --- a/samples/web-app-app-configuration/python/terraform/README.md +++ b/samples/web-app-app-configuration/python/terraform/README.md @@ -32,7 +32,7 @@ The Terraform configuration uses three providers: [azurerm](https://registry.ter 8. Three [Azure Private DNS Zones](https://learn.microsoft.com/azure/dns/private-dns-privatednszone), `privatelink.postgres.database.azure.com`, `privatelink.azconfig.io` and `privatelink.vaultcore.azure.net`, each linked to the VNet as `link-to-vnet`. 9. Three [Azure Private Endpoints](https://learn.microsoft.com/azure/private-link/private-endpoint-overview), groups `postgresqlServer`, `configurationStores` and `vault`, each with a `default` DNS zone group. 10. [Azure NAT Gateway](https://learn.microsoft.com/azure/nat-gateway/nat-overview) and one [Network Security Group](https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview) per subnet. -11. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of every resource, the store and the vault included. +11. [Azure Log Analytics Workspace](https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview) and the diagnostic settings of every resource, the store and the vault included. The settings of the Key Vault and of the PostgreSQL server set `log_analytics_destination_type = "Dedicated"`, so their logs land in the resource-specific tables `AZKVAuditLogs`, `AZKVPolicyEvaluationDetailsLogs` and `PGSQLServerLogs`. 12. [Azure Database for PostgreSQL flexible server](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/overview): public-access mode, Burstable `Standard_B1ms`, version 16, 32 GiB, HA disabled, the permissive `AllowAllIPs` firewall rule and the `PlannerDB` [database](https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-servers). 13. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans). 14. [Azure Web App](https://learn.microsoft.com/en-us/azure/app-service/overview) with regional VNet integration, the user-assigned identity (`identity { type = "UserAssigned" }`), and the app settings `Endpoints__AppConfiguration` (the store endpoint) and `AZURE_CLIENT_ID` (the identity client id) next to `LOGIN_NAME`, `WEBSITES_PORT` and the Oryx build flags. **No `PG_*` app setting** is written by Terraform or by `deploy.sh`: the app loads the five settings from the store. @@ -68,6 +68,8 @@ azurerm discovers every endpoint from `ARM_METADATA_HOSTNAME`; AzAPI has no meta - Prints the key-values with their content types and the Web App's app settings. - Zips the source under `../src` and deploys it via `az webapp deploy`. +To read the logs and metrics the diagnostic settings sent to the workspace, run `bash ../scripts/query-logs-and-metrics.sh` with the same `PREFIX` and `SUFFIX` (see [Logs and metrics](../README.md#logs-and-metrics)). + `deploy.sh` accepts the environment overrides `PREFIX`, `SUFFIX`, `LOCATION`, `PG_ADMIN_USER`, `PG_ADMIN_PASSWORD`, `PG_APP_USER`, `PG_APP_PASSWORD`, `RBAC_PROPAGATION_DELAY` and `DEPLOY_APP`. To prove idempotency, run `terraform plan -detailed-exitcode` with the same variables and environment after the apply: the key-values, secrets and role assignments show no diff. To re-run the configuration from an empty state after `az group delete`, purge the soft-deleted Key Vault and App Configuration store first (`az keyvault purge` and `az appconfig purge`, see the cleanup notes in the main README): otherwise the azurerm provider recovers the soft-deleted vault together with its secrets (`recover_soft_deleted_key_vaults`, enabled by default) and `azurerm_key_vault_secret` refuses to adopt the existing `pg-user` and `pg-password` secrets (`A resource with the ID ... already exists - to be managed via Terraform this resource needs to be imported into the State`). This is the provider's behaviour on Azure and on the emulator alike; the Azure CLI and Bicep variants recover the vault and upsert the secrets, so they can be re-run without a purge. On the emulator a few inherited resources still show read-back drift (NAT gateway zones, App Service plan SKU, some web app and diagnostic-setting attributes), a limitation of the emulator, not of this configuration. ### Alternative: seeding the key-values from deploy.sh diff --git a/samples/web-app-app-configuration/python/terraform/modules/key_vault/main.tf b/samples/web-app-app-configuration/python/terraform/modules/key_vault/main.tf index ee7c2b8..a5b7510 100644 --- a/samples/web-app-app-configuration/python/terraform/modules/key_vault/main.tf +++ b/samples/web-app-app-configuration/python/terraform/modules/key_vault/main.tf @@ -51,9 +51,10 @@ resource "azurerm_key_vault_secret" "secret" { } resource "azurerm_monitor_diagnostic_setting" "settings" { - name = var.diagnostic_setting_name - target_resource_id = azurerm_key_vault.vault.id - log_analytics_workspace_id = var.log_analytics_workspace_id + name = var.diagnostic_setting_name + target_resource_id = azurerm_key_vault.vault.id + log_analytics_workspace_id = var.log_analytics_workspace_id + log_analytics_destination_type = var.log_analytics_destination_type dynamic "enabled_log" { for_each = toset(var.log_categories) diff --git a/samples/web-app-app-configuration/python/terraform/modules/key_vault/variables.tf b/samples/web-app-app-configuration/python/terraform/modules/key_vault/variables.tf index 7ff2250..2dcc1f9 100644 --- a/samples/web-app-app-configuration/python/terraform/modules/key_vault/variables.tf +++ b/samples/web-app-app-configuration/python/terraform/modules/key_vault/variables.tf @@ -135,6 +135,17 @@ variable "metric_categories" { default = ["AllMetrics"] } +variable "log_analytics_destination_type" { + description = "(Optional) Specifies where the diagnostic setting writes the logs in the workspace: Dedicated writes each category to its resource-specific table (AZKVAuditLogs, AZKVPolicyEvaluationDetailsLogs), AzureDiagnostics to the shared AzureDiagnostics table." + type = string + default = "Dedicated" + + validation { + condition = contains(["AzureDiagnostics", "Dedicated"], var.log_analytics_destination_type) + error_message = "The log_analytics_destination_type must be AzureDiagnostics or Dedicated." + } +} + variable "tags" { description = "(Optional) Specifies the tags of the Key Vault and its secrets." type = map(any) diff --git a/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/main.tf b/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/main.tf index 6ca6673..4ad9c4f 100644 --- a/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/main.tf +++ b/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/main.tf @@ -42,9 +42,10 @@ resource "azurerm_postgresql_flexible_server_firewall_rule" "allow_all" { } resource "azurerm_monitor_diagnostic_setting" "this" { - name = var.diagnostic_setting_name - target_resource_id = azurerm_postgresql_flexible_server.this.id - log_analytics_workspace_id = var.log_analytics_workspace_id + name = var.diagnostic_setting_name + target_resource_id = azurerm_postgresql_flexible_server.this.id + log_analytics_workspace_id = var.log_analytics_workspace_id + log_analytics_destination_type = var.log_analytics_destination_type dynamic "enabled_log" { for_each = toset(var.log_categories) diff --git a/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/variables.tf b/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/variables.tf index 0d33e3e..2203245 100644 --- a/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/variables.tf +++ b/samples/web-app-app-configuration/python/terraform/modules/postgres_flexible_server/variables.tf @@ -119,6 +119,17 @@ variable "metric_categories" { default = ["AllMetrics"] } +variable "log_analytics_destination_type" { + description = "(Optional) Specifies where the diagnostic setting writes the logs in the workspace: Dedicated writes the server logs to the resource-specific PGSQLServerLogs table that Microsoft recommends, AzureDiagnostics to the shared AzureDiagnostics table." + type = string + default = "Dedicated" + + validation { + condition = contains(["AzureDiagnostics", "Dedicated"], var.log_analytics_destination_type) + error_message = "The log_analytics_destination_type must be AzureDiagnostics or Dedicated." + } +} + variable "tags" { description = "(Optional) Specifies the tags of the PostgreSQL flexible server." type = map(string) From ac454c32574d7b0acfc63d0fc6a41f7a18049f9a Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Fri, 6 Nov 2026 18:54:36 +0100 Subject: [PATCH 2/3] Update the Key Vault and PostgreSQL diagnostic settings an earlier run created deploy.sh skipped a diagnostic setting that already existed, so a re-run over a deployment made before the settings moved to resource-specific tables kept their logs in AzureDiagnostics. It now reads the setting's destination type and runs the create, which updates a setting of the same name, unless it is Dedicated. Co-Authored-By: Claude Opus 5.5 --- .../dotnet/scripts/deploy.sh | 32 ++++++++++++------- .../python/scripts/deploy.sh | 32 ++++++++++++------- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/samples/web-app-app-configuration/dotnet/scripts/deploy.sh b/samples/web-app-app-configuration/dotnet/scripts/deploy.sh index 00fd63e..a28e3c2 100755 --- a/samples/web-app-app-configuration/dotnet/scripts/deploy.sh +++ b/samples/web-app-app-configuration/dotnet/scripts/deploy.sh @@ -1593,18 +1593,22 @@ else echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan already exist" fi -# Check whether the diagnostic settings for the PostgreSQL flexible server already exist +# Check whether the diagnostic settings for the PostgreSQL flexible server already exist and send its logs to resource-specific +# tables: a setting an earlier version of this script created sends them to AzureDiagnostics, and the create below +# updates it echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server already exist..." -az monitor diagnostic-settings show \ +POSTGRES_DESTINATION_TYPE=$(az monitor diagnostic-settings show \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$POSTGRES_SERVER_ID" \ - --only-show-errors &>/dev/null + --query logAnalyticsDestinationType \ + --output tsv \ + --only-show-errors 2>/dev/null) -if [[ $? != 0 ]]; then - echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exist" +if [[ $POSTGRES_DESTINATION_TYPE != "Dedicated" ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server that send its logs to resource-specific tables actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." - # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace, + # Create (or update) the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace, # in the resource-specific PGSQLServerLogs table that Microsoft recommends over the shared AzureDiagnostics table az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ @@ -1664,18 +1668,22 @@ else echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_CONFIG_NAME] App Configuration store already exist" fi -# Check whether the diagnostic settings for the key vault already exist +# Check whether the diagnostic settings for the key vault already exist and send its logs to resource-specific +# tables: a setting an earlier version of this script created sends them to AzureDiagnostics, and the create below +# updates it echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault already exist..." -az monitor diagnostic-settings show \ +KEY_VAULT_DESTINATION_TYPE=$(az monitor diagnostic-settings show \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$KEY_VAULT_ID" \ - --only-show-errors &>/dev/null + --query logAnalyticsDestinationType \ + --output tsv \ + --only-show-errors 2>/dev/null) -if [[ $? != 0 ]]; then - echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault actually exist" +if [[ $KEY_VAULT_DESTINATION_TYPE != "Dedicated" ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault that send its logs to resource-specific tables actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault..." - # Create the diagnostic settings for the key vault to send logs to the Log Analytics workspace, in the + # Create (or update) the diagnostic settings for the key vault to send logs to the Log Analytics workspace, in the # resource-specific AZKVAuditLogs and AZKVPolicyEvaluationDetailsLogs tables rather than AzureDiagnostics az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ diff --git a/samples/web-app-app-configuration/python/scripts/deploy.sh b/samples/web-app-app-configuration/python/scripts/deploy.sh index ee463b2..793351f 100644 --- a/samples/web-app-app-configuration/python/scripts/deploy.sh +++ b/samples/web-app-app-configuration/python/scripts/deploy.sh @@ -1593,18 +1593,22 @@ else echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_SERVICE_PLAN_NAME] app service plan already exist" fi -# Check whether the diagnostic settings for the PostgreSQL flexible server already exist +# Check whether the diagnostic settings for the PostgreSQL flexible server already exist and send its logs to resource-specific +# tables: a setting an earlier version of this script created sends them to AzureDiagnostics, and the create below +# updates it echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server already exist..." -az monitor diagnostic-settings show \ +POSTGRES_DESTINATION_TYPE=$(az monitor diagnostic-settings show \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$POSTGRES_SERVER_ID" \ - --only-show-errors &>/dev/null + --query logAnalyticsDestinationType \ + --output tsv \ + --only-show-errors 2>/dev/null) -if [[ $? != 0 ]]; then - echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server actually exist" +if [[ $POSTGRES_DESTINATION_TYPE != "Dedicated" ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server that send its logs to resource-specific tables actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$POSTGRES_SERVER_NAME] PostgreSQL flexible server..." - # Create the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace, + # Create (or update) the diagnostic settings for the PostgreSQL flexible server to send logs to the Log Analytics workspace, # in the resource-specific PGSQLServerLogs table that Microsoft recommends over the shared AzureDiagnostics table az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ @@ -1664,18 +1668,22 @@ else echo "[$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$APP_CONFIG_NAME] App Configuration store already exist" fi -# Check whether the diagnostic settings for the key vault already exist +# Check whether the diagnostic settings for the key vault already exist and send its logs to resource-specific +# tables: a setting an earlier version of this script created sends them to AzureDiagnostics, and the create below +# updates it echo "Checking if [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault already exist..." -az monitor diagnostic-settings show \ +KEY_VAULT_DESTINATION_TYPE=$(az monitor diagnostic-settings show \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ --resource "$KEY_VAULT_ID" \ - --only-show-errors &>/dev/null + --query logAnalyticsDestinationType \ + --output tsv \ + --only-show-errors 2>/dev/null) -if [[ $? != 0 ]]; then - echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault actually exist" +if [[ $KEY_VAULT_DESTINATION_TYPE != "Dedicated" ]]; then + echo "No [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault that send its logs to resource-specific tables actually exist" echo "Creating [$DIAGNOSTIC_SETTINGS_NAME] diagnostic settings for the [$KEY_VAULT_NAME] key vault..." - # Create the diagnostic settings for the key vault to send logs to the Log Analytics workspace, in the + # Create (or update) the diagnostic settings for the key vault to send logs to the Log Analytics workspace, in the # resource-specific AZKVAuditLogs and AZKVPolicyEvaluationDetailsLogs tables rather than AzureDiagnostics az monitor diagnostic-settings create \ --name "$DIAGNOSTIC_SETTINGS_NAME" \ From 4f5d72e2d5a18aa89577e160372279f760ede8a9 Mon Sep 17 00:00:00 2001 From: Paolo Salvatori Date: Fri, 6 Nov 2026 18:54:36 +0100 Subject: [PATCH 3/3] Fail query-logs-and-metrics.sh when a command that shows its results fails The tables of records, the diagnostic settings and the platform metrics were shown without checking the status of the command that read them, so the script could exit 0 without showing them. Each failure now prints an error and makes the script exit 1. Co-Authored-By: Claude Opus 5.5 --- .../dotnet/scripts/query-logs-and-metrics.sh | 15 +++++++++++++++ .../python/scripts/query-logs-and-metrics.sh | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh b/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh index 761433f..6950d70 100755 --- a/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh +++ b/samples/web-app-app-configuration/dotnet/scripts/query-logs-and-metrics.sh @@ -55,6 +55,11 @@ query_workspace() { --query "$columns" \ --output table \ --only-show-errors + + if [[ $? != 0 ]]; then + echo "ERROR: the [$LOG_ANALYTICS_NAME] Log Analytics workspace did not answer the query that shows the records" + EXIT_CODE=1 + fi return 0 else if [ $attempt -lt $RETRY_COUNT ]; then @@ -83,6 +88,11 @@ show_diagnostic_settings() { --query "[].{Name:name,Logs:join(', ', logs[?enabled].category),Metrics:join(', ', metrics[?enabled].category)}" \ --output table \ --only-show-errors + + if [[ $? != 0 ]]; then + echo "ERROR: failed to read the diagnostic settings of the $description" + EXIT_CODE=1 + fi } # Show the totals of the last hour of the platform metrics of a resource, read from the Azure Monitor metrics API @@ -104,6 +114,11 @@ show_metrics() { --query "value[].{Metric:name.value,Unit:unit,Total:sum(timeseries[].data[].total)}" \ --output table \ --only-show-errors + + if [[ $? != 0 ]]; then + echo "ERROR: failed to read the platform metrics of the $description" + EXIT_CODE=1 + fi } # Check whether the log-analytics extension of the Azure CLI, which provides the az monitor log-analytics query diff --git a/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh b/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh index 761433f..6950d70 100755 --- a/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh +++ b/samples/web-app-app-configuration/python/scripts/query-logs-and-metrics.sh @@ -55,6 +55,11 @@ query_workspace() { --query "$columns" \ --output table \ --only-show-errors + + if [[ $? != 0 ]]; then + echo "ERROR: the [$LOG_ANALYTICS_NAME] Log Analytics workspace did not answer the query that shows the records" + EXIT_CODE=1 + fi return 0 else if [ $attempt -lt $RETRY_COUNT ]; then @@ -83,6 +88,11 @@ show_diagnostic_settings() { --query "[].{Name:name,Logs:join(', ', logs[?enabled].category),Metrics:join(', ', metrics[?enabled].category)}" \ --output table \ --only-show-errors + + if [[ $? != 0 ]]; then + echo "ERROR: failed to read the diagnostic settings of the $description" + EXIT_CODE=1 + fi } # Show the totals of the last hour of the platform metrics of a resource, read from the Azure Monitor metrics API @@ -104,6 +114,11 @@ show_metrics() { --query "value[].{Metric:name.value,Unit:unit,Total:sum(timeseries[].data[].total)}" \ --output table \ --only-show-errors + + if [[ $? != 0 ]]; then + echo "ERROR: failed to read the platform metrics of the $description" + EXIT_CODE=1 + fi } # Check whether the log-analytics extension of the Azure CLI, which provides the az monitor log-analytics query