From 25d09c2d9662322df3b3e085342bfb601dbb3dff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:06:07 +0000 Subject: [PATCH 1/3] Initial plan From a3fc45ca7b56f33958a7b6555f896ddbab4300e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:12:00 +0000 Subject: [PATCH 2/3] Configure AI Gateway native OpenTelemetry monitoring Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- IMPLEMENTATION_NOTES.md | 36 ++++- infra/ai-gateway/README.md | 26 ++++ infra/ai-gateway/main.bicep | 24 +--- infra/main.bicep | 9 +- infra/scripts/configure-ai-gateway.ps1 | 114 ++++++++++++++++ infra/scripts/configure-ai-gateway.sh | 142 ++++++++++++++++++++ tests/test-ai-gateway-model-registration.sh | 50 +++++++ 7 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 infra/ai-gateway/README.md diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index a900863..db1d760 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -26,7 +26,8 @@ Functions application. - `infra/foundry-agents/main.bicep` creates the agent project, storage, Container Registry, monitoring, connections, and RBAC. - `infra/ai-gateway/main.bicep` creates AI Gateway, its Connector Namespace, - model provider, model registrations, runtime key, and monitoring. + model provider, model registrations, runtime key, and workspace-based + Application Insights resource. - `chat.py` is a local console client for the Responses endpoint. The scheduled routine is named `daily-repo-digest`. It runs at 9 AM in the @@ -279,7 +280,6 @@ The AI Gateway module uses the live `2025-09-01-preview` contracts: - `Microsoft.ApiManagement/aigateways/workspaces/modelProviders/models` - `Microsoft.ApiManagement/aigateways/workspaces/aliases` - `Microsoft.ApiManagement/aigateways/workspaces/toolServers` -- `Microsoft.ApiManagement/aigateways/workspaces/telemetryExporters` - `Microsoft.ApiManagement/aigateways/apiKeys` - `Microsoft.Web/connectorGateways@2026-05-01-preview` @@ -291,6 +291,38 @@ The predefined `workspaces/default` resource is the parent of every workspace-scoped child. The postprovision hook waits for the workspace child collection before configuring the GitHub ToolServer. +### Native OpenTelemetry monitoring + +AI Gateway monitoring is enabled by the root `enableMonitoring` parameter +(`ENABLE_MONITORING`, default `true`). The gateway's workspace-based Application +Insights component sets `AzureMonitorWorkspaceIngestionMode: Enabled`; Bicep +suppresses BCP037 for this preview property. The deployment exports +`AI_GATEWAY_APP_INSIGHTS_ID`, `AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID`, and +`AI_GATEWAY_PRINCIPAL_ID` for the postprovision hook. + +The hook registers `Microsoft.Monitor` when needed and polls the Application +Insights resource until its managed DCR/DCE and `IngestionEndpoint`, +`MetricsIngestionEndpoint`, `LogsIngestionEndpoint`, `TracesIngestionEndpoint`, +and `DefaultDataCollectionRuleResourceId` are populated. It grants the Gateway +system identity **Monitoring Metrics Publisher** +(`3913510d-42f4-4e42-8a64-420c390055eb`) at that generated DCR scope. + +The role assignment must be created with a direct ARM role-assignment PUT from +the `azd` postprovision hook. The generated managed resource group has a deny +assignment that blocks creating this nested role assignment through ARM/Bicep. +The hook then configures +`Microsoft.ApiManagement/aigateways/{gatewayName}/telemetryExporters/applicationInsights` +as `kind: OpenTelemetry`, with the Application Insights resource ID, OTLP signal +endpoints, and managed-identity audience `https://monitor.azure.com`. Metrics +and logs are sufficient for portal dashboard eligibility; the preview service +may omit a supplied `tracesEndpoint` from exporter GET responses. + +Exporter `kind` is immutable. When replacing an existing legacy +`applicationInsights` exporter, the hook deletes it before creating the native +OpenTelemetry exporter. Do not restore an inline Bicep exporter: its endpoints +and DCR-scoped authorization are only available after Application Insights has +provisioned its managed resources. + The Foundry model provider uses: - `kind: Foundry` diff --git a/infra/ai-gateway/README.md b/infra/ai-gateway/README.md new file mode 100644 index 0000000..0cea414 --- /dev/null +++ b/infra/ai-gateway/README.md @@ -0,0 +1,26 @@ +# AI Gateway monitoring + +The AI Gateway module creates a workspace-based Application Insights component +with `AzureMonitorWorkspaceIngestionMode: Enabled`. The property is not yet in +the Bicep type for `Microsoft.Insights/components@2020-02-02`, so the template +documents and suppresses BCP037 for that property. + +The `azd` postprovision hook configures native OpenTelemetry monitoring after +Application Insights has created its managed DCR/DCE and OTLP endpoints: + +1. Register `Microsoft.Monitor` if it is not already registered. +2. Wait for the Application Insights ingestion endpoints and managed DCR scope. +3. Assign the Gateway system identity **Monitoring Metrics Publisher** + (`3913510d-42f4-4e42-8a64-420c390055eb`) at the generated DCR scope using a + direct ARM role-assignment PUT. The managed resource group's deny assignment + blocks creating this nested role assignment from ARM/Bicep. +4. Create + `Microsoft.ApiManagement/aigateways/{gatewayName}/telemetryExporters/applicationInsights` + as `kind: OpenTelemetry`, using the Application Insights resource ID, the + metrics/logs/traces OTLP endpoints, and managed-identity audience + `https://monitor.azure.com`. + +Exporter `kind` is immutable. The hook deletes an existing exporter with a +different kind before recreating it as OpenTelemetry. The preview service can +omit `tracesEndpoint` from exporter GET responses; metrics and logs are enough +for the portal dashboard eligibility check. diff --git a/infra/ai-gateway/main.bicep b/infra/ai-gateway/main.bicep index b827825..2b17482 100644 --- a/infra/ai-gateway/main.bicep +++ b/infra/ai-gateway/main.bicep @@ -25,7 +25,6 @@ var connectorGatewayApiVersion = '2026-05-01-preview' var appInsightsApiVersion = '2020-02-02' var logAnalyticsApiVersion = '2023-09-01' var telemetryWorkspaceName = 'default' -var telemetryExporterName = 'appinsights' var normalizedFoundryEndpoint = endsWith(foundryEndpoint, '/') ? foundryEndpoint : '${foundryEndpoint}/' #disable-next-line BCP081 resource aiGateway 'Microsoft.ApiManagement/aigateways@2025-09-01-preview' = if (deployApimGatewayViaBicep) { @@ -81,7 +80,7 @@ resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09 } } -resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (enableMonitoring) { +resource gatewayAppInsights 'Microsoft.Insights/components@2020-02-02' = if (enableMonitoring) { name: appInsightsName location: location kind: 'web' @@ -89,20 +88,8 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (enableMoni properties: { Application_Type: 'web' WorkspaceResourceId: logAnalyticsWorkspace.id - } -} - -#disable-next-line BCP081 -resource telemetryExporter 'Microsoft.ApiManagement/aigateways/workspaces/telemetryExporters@2025-09-01-preview' = if (enableMonitoring && deployApimGatewayViaBicep) { - parent: defaultWorkspace - name: telemetryExporterName - properties: { - kind: 'applicationInsights' - payloadCapture: false - applicationInsights: { - connectionString: appInsights!.properties.ConnectionString - resourceId: appInsights!.id - } + #disable-next-line BCP037 + AzureMonitorWorkspaceIngestionMode: 'Enabled' } } @@ -191,11 +178,10 @@ var modelAliasResourceIds = [for alias in modelAliasesConfig: resourceId('Micros output apimGatewayId string = resourceId('Microsoft.ApiManagement/aigateways', gatewayName) output connectorNamespaceId string = deployApimGatewayViaBicep ? connectorNamespace.id : '' -output appInsightsId string = enableMonitoring ? appInsights.id : '' -output appInsightsName string = enableMonitoring ? appInsights.name : '' +output appInsightsId string = enableMonitoring ? gatewayAppInsights.id : '' +output appInsightsName string = enableMonitoring ? gatewayAppInsights.name : '' output logAnalyticsWorkspaceId string = enableMonitoring ? logAnalyticsWorkspace.id : '' output logAnalyticsWorkspaceName string = enableMonitoring ? logAnalyticsWorkspace.name : '' -output telemetryExporterId string = enableMonitoring && deployApimGatewayViaBicep ? telemetryExporter.id : '' output runtimeHostname string = deployApimGatewayViaBicep ? (aiGateway!.properties.?frontend.?defaultHostname ?? replace(aiGateway!.properties.?gatewayUrl ?? '', 'https://', '')) : '' output runtimeApiKeyId string = deployApimGatewayViaBicep ? runtimeApiKey.id : '' output gatewayPrincipalId string = deployApimGatewayViaBicep ? aiGateway!.identity.principalId : '' diff --git a/infra/main.bicep b/infra/main.bicep index 1d3b743..9e67242 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -69,6 +69,9 @@ param containerRegistryName string = '' @description('Application Insights component name for AI Gateway monitoring. Defaults to a deterministic name.') param appInsightsName string = '' +@description('Whether to configure monitoring for the AI Gateway.') +param enableMonitoring bool = true + @description('Log Analytics workspace name for Application Insights. Defaults to a deterministic name in the application resource group.') param logAnalyticsWorkspaceName string = '' @@ -228,7 +231,7 @@ module aiGateway './ai-gateway/main.bicep' = { gatewayLocation: aiGatewayLocation gatewayName: effectiveAiGatewayName tags: tags - enableMonitoring: true + enableMonitoring: enableMonitoring deployApimGatewayViaBicep: true appInsightsName: effectiveAppInsightsName logAnalyticsWorkspaceName: effectiveLogAnalyticsWorkspaceName @@ -309,9 +312,13 @@ output AI_GATEWAY_DEFAULT_MODEL_ALIAS_NAME string = aiGateway.outputs.defaultMod output AI_GATEWAY_CONNECTOR_NAMESPACE_RESOURCE_ID string = aiGateway.outputs.connectorNamespaceId output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_NAME string = aiGateway.outputs.foundryUserRoleAssignmentName output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_PRINCIPAL_ID string = aiGateway.outputs.gatewayPrincipalId +output AI_GATEWAY_APP_INSIGHTS_ID string = aiGateway.outputs.appInsightsId +output AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID string = aiGateway.outputs.logAnalyticsWorkspaceId +output AI_GATEWAY_PRINCIPAL_ID string = aiGateway.outputs.gatewayPrincipalId output APPLICATION_INSIGHTS_NAME string = aiGateway.outputs.appInsightsName output LOG_ANALYTICS_WORKSPACE_ID string = aiGateway.outputs.logAnalyticsWorkspaceId output LOG_ANALYTICS_WORKSPACE_NAME string = aiGateway.outputs.logAnalyticsWorkspaceName +output ENABLE_MONITORING bool = enableMonitoring output AZURE_AI_GATEWAY_ENDPOINT string = 'https://${aiGateway.outputs.runtimeHostname}/' output AZURE_AI_GATEWAY_MODEL string = modelAliasName output AZURE_AI_GATEWAY_MINI_MODEL string = miniModelAliasName diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 9abbe78..3ee6f32 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -4,6 +4,9 @@ $PSNativeCommandUseErrorActionPreference = $true $aiGatewayApiVersion = "2025-09-01-preview" $legacyAiGatewayApiVersion = "2025-09-01-preview" $foundryUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d" +$monitoringMetricsPublisherRoleId = "3913510d-42f4-4e42-8a64-420c390055eb" +$appInsightsApiVersion = "2020-02-02" +$roleAssignmentsApiVersion = "2022-04-01" $defaultRepository = "microsoft/agent-framework" $githubMcpServer = "https://api.githubcopilot.com/mcp/" $githubMcpTools = "list_pull_requests,list_issues,actions_list" @@ -125,6 +128,114 @@ function Test-GatewayModelRoute($GatewayUrl, $Model, $ApiKey) { throw "AI Gateway model route failed. The route requires an explicit Api-Key header. Last error: $lastError" } +function Configure-GatewayMonitoring($MonitoringEnabled, $GatewayResourceId, $SubscriptionId) { + if ($MonitoringEnabled -notmatch "^(true|1|yes)$") { + Write-Host "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter setup." + return + } + + $gatewayAppInsightsId = Require-Value "AI_GATEWAY_APP_INSIGHTS_ID" (First-Value @($env:AI_GATEWAY_APP_INSIGHTS_ID, (Get-AzdValue "AI_GATEWAY_APP_INSIGHTS_ID"))) + $logAnalyticsWorkspaceId = Require-Value "AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID" (First-Value @($env:AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID, (Get-AzdValue "AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID"))) + $gatewayPrincipalId = Require-Value "AI_GATEWAY_PRINCIPAL_ID" (First-Value @($env:AI_GATEWAY_PRINCIPAL_ID, (Get-AzdValue "AI_GATEWAY_PRINCIPAL_ID"))) + + $providerState = [string](az provider show --namespace Microsoft.Monitor --query registrationState -o tsv 2>$null) + if ($providerState -ne "Registered") { + Write-Host "Registering the Microsoft.Monitor resource provider." + az provider register --namespace Microsoft.Monitor --wait | Out-Null + } + + $appInsightsUri = "https://management.azure.com${gatewayAppInsightsId}?api-version=$appInsightsApiVersion" + $appInsightsProperties = $null + for ($attempt = 1; $attempt -le 60; $attempt++) { + try { + $appInsightsProperties = (az rest --method get --uri $appInsightsUri --query properties -o json 2>$null | ConvertFrom-Json) + if (-not [string]::IsNullOrWhiteSpace($appInsightsProperties.IngestionEndpoint) -and + -not [string]::IsNullOrWhiteSpace($appInsightsProperties.MetricsIngestionEndpoint) -and + -not [string]::IsNullOrWhiteSpace($appInsightsProperties.LogsIngestionEndpoint) -and + -not [string]::IsNullOrWhiteSpace($appInsightsProperties.TracesIngestionEndpoint) -and + -not [string]::IsNullOrWhiteSpace($appInsightsProperties.DefaultDataCollectionRuleResourceId)) { + break + } + } catch { + $appInsightsProperties = $null + } + Write-Host "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=$attempt." + Start-Sleep -Seconds 10 + } + if ($null -eq $appInsightsProperties -or + [string]::IsNullOrWhiteSpace($appInsightsProperties.IngestionEndpoint) -or + [string]::IsNullOrWhiteSpace($appInsightsProperties.MetricsIngestionEndpoint) -or + [string]::IsNullOrWhiteSpace($appInsightsProperties.LogsIngestionEndpoint) -or + [string]::IsNullOrWhiteSpace($appInsightsProperties.TracesIngestionEndpoint) -or + [string]::IsNullOrWhiteSpace($appInsightsProperties.DefaultDataCollectionRuleResourceId)) { + throw "Application Insights did not populate its managed DCR/DCE and OTLP endpoints." + } + if ($appInsightsProperties.WorkspaceResourceId -ne $logAnalyticsWorkspaceId) { + throw "AI Gateway Application Insights is not connected to the expected Log Analytics workspace." + } + + $dcrScope = [string]$appInsightsProperties.DefaultDataCollectionRuleResourceId + $identityBytes = [Text.Encoding]::UTF8.GetBytes("$dcrScope|$gatewayPrincipalId|$monitoringMetricsPublisherRoleId") + $identityHash = [Security.Cryptography.SHA256]::HashData($identityBytes) + $roleAssignmentName = [guid]::new([byte[]]$identityHash[0..15]).ToString() + $roleAssignmentId = "$dcrScope/providers/Microsoft.Authorization/roleAssignments/$roleAssignmentName" + $roleDefinitionId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleDefinitions/$monitoringMetricsPublisherRoleId" + $roleAssignmentBody = @{ + properties = @{ + principalId = $gatewayPrincipalId + principalType = "ServicePrincipal" + roleDefinitionId = $roleDefinitionId + } + } + Write-Host "Assigning Monitoring Metrics Publisher at the Application Insights managed DCR scope." + Invoke-AzRestPutJson "https://management.azure.com${roleAssignmentId}?api-version=$roleAssignmentsApiVersion" $roleAssignmentBody + + $exporterUri = "https://management.azure.com${GatewayResourceId}/telemetryExporters/applicationInsights?api-version=$aiGatewayApiVersion" + $exporterExists = $true + try { + $exporterKind = [string](az rest --method get --uri $exporterUri --query properties.kind -o tsv 2>$null) + } catch { + $exporterExists = $false + $exporterKind = "" + } + if ($exporterExists -and $exporterKind -ne "OpenTelemetry") { + Write-Host "Replacing the legacy Application Insights exporter because exporter kind is immutable." + az rest --method delete --uri $exporterUri -o none + $exporterDeleted = $false + for ($attempt = 1; $attempt -le 30; $attempt++) { + if (-not (Test-AzRestResource $exporterUri)) { + $exporterDeleted = $true + break + } + Write-Host "Waiting for the legacy telemetry exporter deletion, attempt=$attempt." + Start-Sleep -Seconds 2 + } + if (-not $exporterDeleted) { + throw "The legacy telemetry exporter was not deleted before recreation." + } + } + + $exporterBody = @{ + properties = @{ + kind = "OpenTelemetry" + payloadCapture = $false + applicationInsights = @{ + resourceId = $gatewayAppInsightsId + } + openTelemetry = @{ + metricsEndpoint = [string]$appInsightsProperties.MetricsIngestionEndpoint + logsEndpoint = [string]$appInsightsProperties.LogsIngestionEndpoint + tracesEndpoint = [string]$appInsightsProperties.TracesIngestionEndpoint + managedIdentity = @{ + resource = "https://monitor.azure.com" + } + } + } + } + Write-Host "Configuring the native OpenTelemetry exporter for AI Gateway." + Invoke-AzRestPutJson $exporterUri $exporterBody +} + function Prepare-BicepRbac { $subscriptionId = First-Value @($env:AZURE_SUBSCRIPTION_ID, (Get-AzdValue "AZURE_SUBSCRIPTION_ID"), (az account show --query id -o tsv 2>$null)) $resourceGroup = First-Value @($env:AI_GATEWAY_RESOURCE_GROUP, (Get-AzdValue "AI_GATEWAY_RESOURCE_GROUP"), $env:RESOURCE_GROUP, $env:AZURE_RESOURCE_GROUP, (Get-AzdValue "RESOURCE_GROUP"), (Get-AzdValue "AZURE_RESOURCE_GROUP")) @@ -274,6 +385,9 @@ if ($providerAuth -ne "ManagedIdentity") { throw "Bicep did not configure the Foundry provider for managed identity." } +$monitoringEnabled = First-Value @($env:ENABLE_MONITORING, (Get-AzdValue "ENABLE_MONITORING"), "true") +Configure-GatewayMonitoring $monitoringEnabled $gatewayResourceId $subscriptionId + Remove-AzdEnvValues @("GITHUB_MCP_TOKEN", "GITHUB_TOKEN") $githubToken = "" if ($null -ne (Get-Command gh -ErrorAction SilentlyContinue)) { diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index 114ad48..ae00a27 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -5,6 +5,9 @@ umask 077 AI_GATEWAY_API_VERSION="2025-09-01-preview" LEGACY_AI_GATEWAY_API_VERSION="2025-09-01-preview" FOUNDRY_USER_ROLE_ID="53ca6127-db72-4b80-b1b0-d745d6d5456d" +MONITORING_METRICS_PUBLISHER_ROLE_ID="3913510d-42f4-4e42-8a64-420c390055eb" +APP_INSIGHTS_API_VERSION="2020-02-02" +ROLE_ASSIGNMENTS_API_VERSION="2022-04-01" DEFAULT_REPOSITORY="microsoft/agent-framework" GITHUB_MCP_SERVER="https://api.githubcopilot.com/mcp/" GITHUB_MCP_TOOLS="list_pull_requests,list_issues,actions_list" @@ -120,6 +123,142 @@ verify_gateway_model_route() { return 1 } +configure_gateway_monitoring() { + local monitoring_enabled="$1" + local gateway_resource_id="$2" + local subscription_id="$3" + local gateway_app_insights_id + local log_analytics_workspace_id + local gateway_principal_id + local app_insights_json="" + local metrics_endpoint="" + local logs_endpoint="" + local traces_endpoint="" + local dcr_scope="" + local role_assignment_name + local role_assignment_id + local role_definition_id + local role_assignment_body + local exporter_uri + local exporter_kind + local exporter_body + local provider_state + + case "${monitoring_enabled,,}" in + true|1|yes) ;; + *) + echo "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter setup." + return 0 + ;; + esac + + gateway_app_insights_id="$(require_value AI_GATEWAY_APP_INSIGHTS_ID "$(first_value "${AI_GATEWAY_APP_INSIGHTS_ID:-}" "$(azd_value AI_GATEWAY_APP_INSIGHTS_ID)")")" + log_analytics_workspace_id="$(require_value AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID "$(first_value "${AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID:-}" "$(azd_value AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID)")")" + gateway_principal_id="$(require_value AI_GATEWAY_PRINCIPAL_ID "$(first_value "${AI_GATEWAY_PRINCIPAL_ID:-}" "$(azd_value AI_GATEWAY_PRINCIPAL_ID)")")" + + provider_state="$(az provider show --namespace Microsoft.Monitor --query registrationState -o tsv 2>/dev/null || true)" + if [ "$provider_state" != "Registered" ]; then + echo "Registering the Microsoft.Monitor resource provider." + az provider register --namespace Microsoft.Monitor --wait + fi + + for attempt in $(seq 1 60); do + if app_insights_json="$(az rest \ + --method get \ + --uri "https://management.azure.com${gateway_app_insights_id}?api-version=${APP_INSIGHTS_API_VERSION}" \ + --query properties \ + -o json 2>/dev/null)" && + jq -e ' + (.IngestionEndpoint // "") != "" and + (.MetricsIngestionEndpoint // "") != "" and + (.LogsIngestionEndpoint // "") != "" and + (.TracesIngestionEndpoint // "") != "" and + (.DefaultDataCollectionRuleResourceId // "") != "" + ' >/dev/null <<< "$app_insights_json"; then + metrics_endpoint="$(jq -r '.MetricsIngestionEndpoint' <<< "$app_insights_json")" + logs_endpoint="$(jq -r '.LogsIngestionEndpoint' <<< "$app_insights_json")" + traces_endpoint="$(jq -r '.TracesIngestionEndpoint' <<< "$app_insights_json")" + dcr_scope="$(jq -r '.DefaultDataCollectionRuleResourceId' <<< "$app_insights_json")" + break + fi + echo "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=${attempt}." + sleep 10 + done + if [ -z "$dcr_scope" ]; then + echo "Application Insights did not populate its managed DCR/DCE and OTLP endpoints." >&2 + exit 1 + fi + if [ "$(jq -r '.WorkspaceResourceId // ""' <<< "$app_insights_json")" != "$log_analytics_workspace_id" ]; then + echo "AI Gateway Application Insights is not connected to the expected Log Analytics workspace." >&2 + exit 1 + fi + + role_assignment_name="$(python3 - "$dcr_scope" "$gateway_principal_id" "$MONITORING_METRICS_PUBLISHER_ROLE_ID" <<'PY' +import hashlib +import sys +import uuid + +digest = bytearray(hashlib.sha256("|".join(sys.argv[1:]).encode()).digest()[:16]) +digest[6] = (digest[6] & 0x0F) | 0x50 +digest[8] = (digest[8] & 0x3F) | 0x80 +print(uuid.UUID(bytes=bytes(digest))) +PY +)" + role_assignment_id="${dcr_scope}/providers/Microsoft.Authorization/roleAssignments/${role_assignment_name}" + role_definition_id="/subscriptions/${subscription_id}/providers/Microsoft.Authorization/roleDefinitions/${MONITORING_METRICS_PUBLISHER_ROLE_ID}" + role_assignment_body="$(jq -cn \ + --arg principalId "$gateway_principal_id" \ + --arg roleDefinitionId "$role_definition_id" \ + '{properties:{principalId:$principalId,principalType:"ServicePrincipal",roleDefinitionId:$roleDefinitionId}}')" + echo "Assigning Monitoring Metrics Publisher at the Application Insights managed DCR scope." + az rest \ + --method put \ + --uri "https://management.azure.com${role_assignment_id}?api-version=${ROLE_ASSIGNMENTS_API_VERSION}" \ + --body "$role_assignment_body" \ + -o none + + exporter_uri="https://management.azure.com${gateway_resource_id}/telemetryExporters/applicationInsights?api-version=${AI_GATEWAY_API_VERSION}" + if exporter_kind="$(az rest --method get --uri "$exporter_uri" --query properties.kind -o tsv 2>/dev/null)" && + [ "$exporter_kind" != "OpenTelemetry" ]; then + echo "Replacing the legacy Application Insights exporter because exporter kind is immutable." + az rest --method delete --uri "$exporter_uri" -o none + exporter_deleted=false + for attempt in $(seq 1 30); do + if ! az rest --method get --uri "$exporter_uri" -o none 2>/dev/null; then + exporter_deleted=true + break + fi + echo "Waiting for the legacy telemetry exporter deletion, attempt=${attempt}." + sleep 2 + done + if [ "$exporter_deleted" != true ]; then + echo "The legacy telemetry exporter was not deleted before recreation." >&2 + exit 1 + fi + fi + + exporter_body="$(jq -cn \ + --arg resourceId "$gateway_app_insights_id" \ + --arg metricsEndpoint "$metrics_endpoint" \ + --arg logsEndpoint "$logs_endpoint" \ + --arg tracesEndpoint "$traces_endpoint" \ + '{ + properties: { + kind: "OpenTelemetry", + payloadCapture: false, + applicationInsights: {resourceId:$resourceId}, + openTelemetry: { + metricsEndpoint:$metricsEndpoint, + logsEndpoint:$logsEndpoint, + tracesEndpoint:$tracesEndpoint, + managedIdentity:{resource:"https://monitor.azure.com"} + } + } + }')" + echo "Configuring the native OpenTelemetry exporter for AI Gateway." + az rest --method put --uri "$exporter_uri" --body "$exporter_body" -o none +} + prepare_bicep_rbac() { local subscription_id local resource_group @@ -288,6 +427,9 @@ if [ "$provider_auth" != "ManagedIdentity" ]; then exit 1 fi +monitoring_enabled="$(first_value "${ENABLE_MONITORING:-}" "$(azd_value ENABLE_MONITORING)" "true")" +configure_gateway_monitoring "$monitoring_enabled" "$gateway_resource_id" "$subscription_id" + remove_azd_env_values GITHUB_MCP_TOKEN GITHUB_TOKEN github_token="" if command -v gh >/dev/null 2>&1; then diff --git a/tests/test-ai-gateway-model-registration.sh b/tests/test-ai-gateway-model-registration.sh index a34270c..a022cd1 100755 --- a/tests/test-ai-gateway-model-registration.sh +++ b/tests/test-ai-gateway-model-registration.sh @@ -47,6 +47,25 @@ if [ "$gateway_sku" != "Consumption" ]; then exit 1 fi +monitoring_ingestion_mode="$(jq -r ' + .resources[] + | select(.type == "Microsoft.Insights/components") + | .properties.AzureMonitorWorkspaceIngestionMode +' "$compiled_template")" +if [ "$monitoring_ingestion_mode" != "Enabled" ]; then + echo "Gateway Application Insights must enable Azure Monitor workspace ingestion mode." >&2 + echo "Actual: $monitoring_ingestion_mode" >&2 + exit 1 +fi + +if jq -e ' + .resources[] + | select(.type == "Microsoft.ApiManagement/aigateways/workspaces/telemetryExporters") +' "$compiled_template" >/dev/null; then + echo "The legacy Bicep telemetry exporter must be configured by the postprovision hook." >&2 + exit 1 +fi + actual_aliases="$(jq -r ' .resources[] | select(.type == "Microsoft.ApiManagement/aigateways/workspaces/aliases") @@ -82,6 +101,16 @@ az bicep build \ --outfile "$compiled_root_template" \ >/dev/null +for output_name in \ + AI_GATEWAY_APP_INSIGHTS_ID \ + AI_GATEWAY_LOG_ANALYTICS_WORKSPACE_ID \ + AI_GATEWAY_PRINCIPAL_ID; do + if [ "$(jq -r --arg name "$output_name" '.outputs | has($name)' "$compiled_root_template")" != "true" ]; then + echo "Root Bicep must output $output_name for native OpenTelemetry postprovision." >&2 + exit 1 + fi +done + root_alias_input="$(jq -r ' .. | objects | select(has("modelAliasesConfig")) @@ -139,4 +168,25 @@ if [ "$mini_capacity" != "200" ]; then exit 1 fi +for hook in \ + "$repo_root/infra/scripts/configure-ai-gateway.sh" \ + "$repo_root/infra/scripts/configure-ai-gateway.ps1"; do + for token in \ + "Microsoft.Monitor" \ + "DefaultDataCollectionRuleResourceId" \ + "MetricsIngestionEndpoint" \ + "LogsIngestionEndpoint" \ + "TracesIngestionEndpoint" \ + "3913510d-42f4-4e42-8a64-420c390055eb" \ + "OpenTelemetry" \ + "https://monitor.azure.com" \ + "telemetryExporters/applicationInsights"; do + if ! grep -Fq "$token" "$hook"; then + echo "The postprovision hook is missing native OpenTelemetry monitoring configuration: $token" >&2 + echo "Hook: $hook" >&2 + exit 1 + fi + done +done + echo "AI Gateway model registration test passed." From a1bfab35a7e97a367b45982462c5ed63043197bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:13:04 +0000 Subject: [PATCH 3/3] Keep monitoring hook compatible with macOS Bash Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/scripts/configure-ai-gateway.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index ae00a27..49e46f7 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -144,8 +144,8 @@ configure_gateway_monitoring() { local exporter_body local provider_state - case "${monitoring_enabled,,}" in - true|1|yes) ;; + case "$monitoring_enabled" in + true|TRUE|True|1|yes|YES|Yes) ;; *) echo "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter setup." return 0