From c2e995b7fb3548dedf568c8c156ddf569c7e1c47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:28:59 +0000 Subject: [PATCH 1/7] Initial plan From 0db302cd8386c23687a6722d172b197b689b6b0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:36:38 +0000 Subject: [PATCH 2/7] Migrate AI Gateway monitoring to native OpenTelemetry exporter Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- IMPLEMENTATION_NOTES.md | 40 ++++++++ infra/ai-gateway/main.bicep | 23 ++--- infra/main.bicep | 3 + infra/scripts/configure-ai-gateway.ps1 | 120 ++++++++++++++++++++++ infra/scripts/configure-ai-gateway.sh | 137 +++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 14 deletions(-) diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index a60c90f..7b32b28 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -309,6 +309,46 @@ Each model registration includes: The deployment name, not the backing catalog model name, is the OpenAI `model` value sent through AI Gateway. +## AI Gateway monitoring + +AI Gateway monitoring uses the native OTLP/OpenTelemetry exporter contract, not +the legacy `applicationInsights` exporter kind. The Bicep template creates the +workspace-based Application Insights component with +`AzureMonitorWorkspaceIngestionMode: Enabled`, which causes Azure Monitor to +generate a managed Data Collection Rule (DCR), Data Collection Endpoint (DCE), +and OTLP metrics/logs/traces ingestion endpoints for that component. Those +endpoints are generated asynchronously after the component is created, so +Bicep cannot create the exporter itself in the same deployment. + +Instead, the `postprovision` hook +(`infra/scripts/configure-ai-gateway.sh` / `.ps1`): + +- Polls the Application Insights component until its OTLP metrics and logs + ingestion endpoints are populated (the traces endpoint may lag behind and is + not required; metrics and logs are sufficient for the portal dashboard + eligibility check). +- Creates the `telemetryExporters` resource with `kind: OpenTelemetry`, + referencing the Application Insights resource ID, the metrics/logs/traces + OTLP endpoints, and managed-identity authentication with audience + `https://monitor.azure.com`. +- Assigns the AI Gateway system-assigned identity the **Monitoring Metrics + Publisher** role (`3913510d-42f4-4e42-8a64-420c390055eb`) at the generated + DCR scope. This assignment is made with a direct `az role assignment + create` call from the hook rather than a nested Bicep role assignment: the + DCR is generated inside a system-managed resource group that carries a deny + assignment blocking role-assignment writes performed as part of a nested + ARM/Bicep deployment, while a direct role-assignment call from the + postprovision hook succeeds. + +The `--prepare-bicep` step (run from `preprovision`) also: + +- Registers the `Microsoft.Monitor` resource provider, which is required for + Application Insights to generate the managed DCR/DCE and OTLP endpoints. +- Deletes any existing legacy `applicationInsights` exporter before Bicep + provisions again. The exporter `kind` is immutable once created, so + migrating an environment that still has the legacy exporter requires + deleting and recreating it; this cannot be done as an in-place update. + ## Regions and model defaults The default split is: diff --git a/infra/ai-gateway/main.bicep b/infra/ai-gateway/main.bicep index 01e7068..d631a33 100644 --- a/infra/ai-gateway/main.bicep +++ b/infra/ai-gateway/main.bicep @@ -92,19 +92,14 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (enableMoni properties: { Application_Type: 'web' WorkspaceResourceId: logAnalyticsWorkspace.id - } -} - -resource telemetryExporter 'Microsoft.ApiManagement/service/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 - } + // Enables the Azure Monitor Workspace-based OTLP ingestion endpoints (metrics, + // logs, traces) that the AI Gateway OpenTelemetry exporter requires. The + // managed DCR/DCE and endpoints are generated asynchronously after this + // resource is created, so the telemetry exporter itself cannot be created + // in the same Bicep deployment; the postprovision hook configures it once + // the endpoints are available (see infra/scripts/configure-ai-gateway.sh). + #disable-next-line BCP037 + AzureMonitorWorkspaceIngestionMode: 'Enabled' } } @@ -175,7 +170,7 @@ output appInsightsId string = enableMonitoring ? appInsights.id : '' output appInsightsName string = enableMonitoring ? appInsights.name : '' output logAnalyticsWorkspaceId string = enableMonitoring ? logAnalyticsWorkspace.id : '' output logAnalyticsWorkspaceName string = enableMonitoring ? logAnalyticsWorkspace.name : '' -output telemetryExporterId string = enableMonitoring && deployApimGatewayViaBicep ? telemetryExporter.id : '' +output telemetryExporterName string = telemetryExporterName output runtimeHostname string = deployApimGatewayViaBicep ? 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 a09e599..d843e59 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -268,6 +268,9 @@ output AI_GATEWAY_CONNECTOR_NAMESPACE_RESOURCE_ID string = aiGateway.outputs.con output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_NAME string = aiGateway.outputs.foundryUserRoleAssignmentName output AI_GATEWAY_FOUNDRY_ROLE_ASSIGNMENT_PRINCIPAL_ID string = aiGateway.outputs.gatewayPrincipalId output APPLICATION_INSIGHTS_NAME string = aiGateway.outputs.appInsightsName +output AI_GATEWAY_APPLICATION_INSIGHTS_ID string = aiGateway.outputs.appInsightsId +output AI_GATEWAY_PRINCIPAL_ID string = aiGateway.outputs.gatewayPrincipalId +output AI_GATEWAY_TELEMETRY_EXPORTER_NAME string = aiGateway.outputs.telemetryExporterName output LOG_ANALYTICS_WORKSPACE_ID string = aiGateway.outputs.logAnalyticsWorkspaceId output LOG_ANALYTICS_WORKSPACE_NAME string = aiGateway.outputs.logAnalyticsWorkspaceName output AZURE_AI_GATEWAY_ENDPOINT string = 'https://${aiGateway.outputs.runtimeHostname}/' diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 0db0b19..9be0f18 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 = "2026-05-01" $foundryUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d" +$monitoringMetricsPublisherRoleId = "3913510d-42f4-4e42-8a64-420c390055eb" +$monitorManagedIdentityAudience = "https://monitor.azure.com" +$appInsightsOtlpApiVersion = "2024-02-01" $defaultRepository = "microsoft/agent-framework" $githubMcpServer = "https://api.githubcopilot.com/mcp/" $githubMcpTools = "list_pull_requests,list_issues,actions_list" @@ -197,10 +200,125 @@ function Prepare-BicepRbac { } } +function Register-MonitorProvider { + $registrationState = [string](az provider show --namespace Microsoft.Monitor --query registrationState -o tsv 2>$null) + if ($registrationState -eq "Registered") { + return + } + Write-Host "Registering the Microsoft.Monitor resource provider for AI Gateway OpenTelemetry monitoring." + az provider register --namespace Microsoft.Monitor --wait +} + +function Migrate-LegacyTelemetryExporter { + $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")) + $gatewayName = First-Value @($env:AI_GATEWAY_NAME, (Get-AzdValue "AI_GATEWAY_NAME")) + $workspaceName = First-Value @($env:AI_GATEWAY_WORKSPACE_NAME, "default") + $exporterName = First-Value @($env:AI_GATEWAY_TELEMETRY_EXPORTER_NAME, (Get-AzdValue "AI_GATEWAY_TELEMETRY_EXPORTER_NAME"), "appinsights") + if ([string]::IsNullOrWhiteSpace($subscriptionId) -or + [string]::IsNullOrWhiteSpace($resourceGroup) -or + [string]::IsNullOrWhiteSpace($gatewayName)) { + return + } + + $exporterUri = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.ApiManagement/service/$gatewayName/workspaces/$workspaceName/telemetryExporters/${exporterName}?api-version=$aiGatewayApiVersion" + $existingKind = "" + try { + $existingKind = [string](az rest --method get --uri $exporterUri --query properties.kind -o tsv 2>$null) + } catch { + $existingKind = "" + } + if ($existingKind -eq "applicationInsights") { + # The exporter "kind" is immutable, so an existing legacy applicationInsights + # exporter must be deleted before it can be recreated as OpenTelemetry. + Write-Host "Deleting the legacy applicationInsights telemetry exporter; exporter kind is immutable and requires delete/recreate to migrate to OpenTelemetry." + az rest --method delete --uri $exporterUri -o none + } +} + +function Configure-TelemetryExporter($WorkspaceResourceId) { + $appInsightsId = First-Value @($env:AI_GATEWAY_APPLICATION_INSIGHTS_ID, (Get-AzdValue "AI_GATEWAY_APPLICATION_INSIGHTS_ID")) + if ([string]::IsNullOrWhiteSpace($appInsightsId)) { + Write-Host "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter configuration." + return + } + + $exporterName = First-Value @($env:AI_GATEWAY_TELEMETRY_EXPORTER_NAME, (Get-AzdValue "AI_GATEWAY_TELEMETRY_EXPORTER_NAME"), "appinsights") + $principalId = First-Value @($env:AI_GATEWAY_PRINCIPAL_ID, (Get-AzdValue "AI_GATEWAY_PRINCIPAL_ID")) + $appInsightsUri = "https://management.azure.com${appInsightsId}?api-version=$appInsightsOtlpApiVersion" + + Write-Host "Waiting for Application Insights to generate its managed DCR/DCE and OTLP ingestion endpoints." + $metricsEndpoint = "" + $logsEndpoint = "" + $tracesEndpoint = "" + $dcrId = "" + for ($attempt = 1; $attempt -le 30; $attempt++) { + $metricsEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.MetricsIngestionEndpoint || properties.metricsIngestionEndpoint" -o tsv 2>$null) + $logsEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.LogsIngestionEndpoint || properties.logsIngestionEndpoint" -o tsv 2>$null) + $tracesEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.TracesIngestionEndpoint || properties.tracesIngestionEndpoint" -o tsv 2>$null) + $dcrId = [string](az rest --method get --uri $appInsightsUri --query "properties.MetricsIngestionDataCollectionRuleId || properties.metricsIngestionDataCollectionRuleId" -o tsv 2>$null) + if (-not [string]::IsNullOrWhiteSpace($metricsEndpoint) -and -not [string]::IsNullOrWhiteSpace($logsEndpoint)) { + break + } + Write-Host "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=$attempt" + Start-Sleep -Seconds 10 + } + + if ([string]::IsNullOrWhiteSpace($metricsEndpoint) -or [string]::IsNullOrWhiteSpace($logsEndpoint)) { + Write-Warning "Application Insights did not expose OTLP ingestion endpoints in time; leaving the AI Gateway telemetry exporter unconfigured." + return + } + + $openTelemetry = [ordered]@{ + resourceId = $appInsightsId + metricsEndpoint = $metricsEndpoint + logsEndpoint = $logsEndpoint + authentication = @{ + kind = "ManagedIdentity" + managedIdentity = @{ resource = $monitorManagedIdentityAudience } + } + } + if (-not [string]::IsNullOrWhiteSpace($tracesEndpoint)) { + $openTelemetry["tracesEndpoint"] = $tracesEndpoint + } + $exporterBody = @{ + properties = @{ + kind = "OpenTelemetry" + payloadCapture = $false + openTelemetry = $openTelemetry + } + } + + Write-Host "Configuring the AI Gateway OpenTelemetry exporter." + $exporterUri = "https://management.azure.com${WorkspaceResourceId}/telemetryExporters/${exporterName}?api-version=$aiGatewayApiVersion" + Invoke-AzRestPutJson $exporterUri $exporterBody + + if (-not [string]::IsNullOrWhiteSpace($principalId) -and -not [string]::IsNullOrWhiteSpace($dcrId)) { + Write-Host "Assigning the Monitoring Metrics Publisher role to the AI Gateway identity at the generated Data Collection Rule scope." + # The generated DCR lives in a managed resource group with a deny + # assignment that blocks a nested ARM/Bicep role assignment; a direct + # role-assignment call from this postprovision hook works instead. + try { + az role assignment create ` + --assignee-object-id $principalId ` + --assignee-principal-type ServicePrincipal ` + --role $monitoringMetricsPublisherRoleId ` + --scope $dcrId ` + -o none 2>$null + } catch { + Write-Warning "The Monitoring Metrics Publisher role assignment already exists or could not be created; continuing." + } + } else { + Write-Warning "Application Insights did not report the generated Data Collection Rule ID; skipping the Monitoring Metrics Publisher role assignment." + } +} + $mode = $args.Count -gt 0 ? $args[0] : "" if ($mode -eq "--prepare-bicep") { & (Join-Path $PSScriptRoot "manage-ai-gateway-lifecycle.ps1") prepare + Register-MonitorProvider Prepare-BicepRbac + Migrate-LegacyTelemetryExporter exit 0 } if (-not [string]::IsNullOrWhiteSpace($mode)) { @@ -267,6 +385,8 @@ if ($providerAuth -ne "ManagedIdentity") { throw "Bicep did not configure the Foundry provider for managed identity." } +Configure-TelemetryExporter $workspaceResourceId + 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 930929a..2f149e1 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="2026-05-01" FOUNDRY_USER_ROLE_ID="53ca6127-db72-4b80-b1b0-d745d6d5456d" +MONITORING_METRICS_PUBLISHER_ROLE_ID="3913510d-42f4-4e42-8a64-420c390055eb" +MONITOR_MANAGED_IDENTITY_AUDIENCE="https://monitor.azure.com" +APP_INSIGHTS_OTLP_API_VERSION="2024-02-01" DEFAULT_REPOSITORY="microsoft/agent-framework" GITHUB_MCP_SERVER="https://api.githubcopilot.com/mcp/" GITHUB_MCP_TOOLS="list_pull_requests,list_issues,actions_list" @@ -197,9 +200,141 @@ prepare_bicep_rbac() { -o tsv) } +register_monitor_provider() { + local registration_state + registration_state="$(az provider show --namespace Microsoft.Monitor --query registrationState -o tsv 2>/dev/null || true)" + if [ "$registration_state" = "Registered" ]; then + return 0 + fi + echo "Registering the Microsoft.Monitor resource provider for AI Gateway OpenTelemetry monitoring." + az provider register --namespace Microsoft.Monitor --wait +} + +migrate_legacy_telemetry_exporter() { + local subscription_id + local resource_group + local gateway_name + local workspace_name + local exporter_name + local exporter_uri + local existing_kind + + subscription_id="$(first_value "${AZURE_SUBSCRIPTION_ID:-}" "$(azd_value AZURE_SUBSCRIPTION_ID)" "$(az account show --query id -o tsv 2>/dev/null || true)")" + resource_group="$(first_value "${AI_GATEWAY_RESOURCE_GROUP:-}" "$(azd_value AI_GATEWAY_RESOURCE_GROUP)" "${RESOURCE_GROUP:-}" "${AZURE_RESOURCE_GROUP:-}" "$(azd_value RESOURCE_GROUP)" "$(azd_value AZURE_RESOURCE_GROUP)")" + gateway_name="$(first_value "${AI_GATEWAY_NAME:-}" "$(azd_value AI_GATEWAY_NAME)")" + workspace_name="${AI_GATEWAY_WORKSPACE_NAME:-default}" + exporter_name="$(first_value "${AI_GATEWAY_TELEMETRY_EXPORTER_NAME:-}" "$(azd_value AI_GATEWAY_TELEMETRY_EXPORTER_NAME)" "appinsights")" + if [ -z "$subscription_id" ] || [ -z "$resource_group" ] || [ -z "$gateway_name" ]; then + return 0 + fi + + exporter_uri="https://management.azure.com/subscriptions/${subscription_id}/resourceGroups/${resource_group}/providers/Microsoft.ApiManagement/service/${gateway_name}/workspaces/${workspace_name}/telemetryExporters/${exporter_name}?api-version=${AI_GATEWAY_API_VERSION}" + existing_kind="$(az rest --method get --uri "$exporter_uri" --query properties.kind -o tsv 2>/dev/null || true)" + if [ "$existing_kind" = "applicationInsights" ]; then + # The exporter "kind" is immutable, so an existing legacy applicationInsights + # exporter must be deleted before it can be recreated as OpenTelemetry. + echo "Deleting the legacy applicationInsights telemetry exporter; exporter kind is immutable and requires delete/recreate to migrate to OpenTelemetry." + az rest --method delete --uri "$exporter_uri" -o none + fi +} + +configure_telemetry_exporter() { + local app_insights_id + local app_insights_uri + local exporter_name + local principal_id + local metrics_endpoint="" + local logs_endpoint="" + local traces_endpoint="" + local dcr_id="" + local exporter_body_file + local attempt + + app_insights_id="$(first_value "${AI_GATEWAY_APPLICATION_INSIGHTS_ID:-}" "$(azd_value AI_GATEWAY_APPLICATION_INSIGHTS_ID)")" + if [ -z "$app_insights_id" ]; then + echo "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter configuration." + return 0 + fi + + exporter_name="$(first_value "${AI_GATEWAY_TELEMETRY_EXPORTER_NAME:-}" "$(azd_value AI_GATEWAY_TELEMETRY_EXPORTER_NAME)" "appinsights")" + principal_id="$(first_value "${AI_GATEWAY_PRINCIPAL_ID:-}" "$(azd_value AI_GATEWAY_PRINCIPAL_ID)")" + app_insights_uri="https://management.azure.com${app_insights_id}?api-version=${APP_INSIGHTS_OTLP_API_VERSION}" + + echo "Waiting for Application Insights to generate its managed DCR/DCE and OTLP ingestion endpoints." + for attempt in $(seq 1 30); do + metrics_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.MetricsIngestionEndpoint || properties.metricsIngestionEndpoint" -o tsv 2>/dev/null || true)" + logs_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.LogsIngestionEndpoint || properties.logsIngestionEndpoint" -o tsv 2>/dev/null || true)" + traces_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.TracesIngestionEndpoint || properties.tracesIngestionEndpoint" -o tsv 2>/dev/null || true)" + dcr_id="$(az rest --method get --uri "$app_insights_uri" --query "properties.MetricsIngestionDataCollectionRuleId || properties.metricsIngestionDataCollectionRuleId" -o tsv 2>/dev/null || true)" + if [ -n "$metrics_endpoint" ] && [ -n "$logs_endpoint" ]; then + break + fi + echo "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=${attempt}" + sleep 10 + done + + if [ -z "$metrics_endpoint" ] || [ -z "$logs_endpoint" ]; then + echo "Application Insights did not expose OTLP ingestion endpoints in time; leaving the AI Gateway telemetry exporter unconfigured." >&2 + return 0 + fi + + exporter_body_file="$(mktemp)" + python3 - "$app_insights_id" "$metrics_endpoint" "$logs_endpoint" "$traces_endpoint" "$MONITOR_MANAGED_IDENTITY_AUDIENCE" > "$exporter_body_file" <<'PY' +import json +import sys + +resource_id, metrics_endpoint, logs_endpoint, traces_endpoint, audience = sys.argv[1:6] +open_telemetry = { + "resourceId": resource_id, + "metricsEndpoint": metrics_endpoint, + "logsEndpoint": logs_endpoint, + "authentication": { + "kind": "ManagedIdentity", + "managedIdentity": {"resource": audience}, + }, +} +if traces_endpoint: + open_telemetry["tracesEndpoint"] = traces_endpoint + +print(json.dumps({ + "properties": { + "kind": "OpenTelemetry", + "payloadCapture": False, + "openTelemetry": open_telemetry, + } +})) +PY + + echo "Configuring the AI Gateway OpenTelemetry exporter." + az rest \ + --method put \ + --uri "https://management.azure.com${workspace_resource_id}/telemetryExporters/${exporter_name}?api-version=${AI_GATEWAY_API_VERSION}" \ + --body @"$exporter_body_file" \ + -o none + rm -f "$exporter_body_file" + + if [ -n "$principal_id" ] && [ -n "$dcr_id" ]; then + echo "Assigning the Monitoring Metrics Publisher role to the AI Gateway identity at the generated Data Collection Rule scope." + # The generated DCR lives in a managed resource group with a deny + # assignment that blocks a nested ARM/Bicep role assignment; a direct + # role-assignment call from this postprovision hook works instead. + az role assignment create \ + --assignee-object-id "$principal_id" \ + --assignee-principal-type ServicePrincipal \ + --role "$MONITORING_METRICS_PUBLISHER_ROLE_ID" \ + --scope "$dcr_id" \ + -o none 2>/dev/null || + echo "The Monitoring Metrics Publisher role assignment already exists or could not be created; continuing." >&2 + else + echo "Application Insights did not report the generated Data Collection Rule ID; skipping the Monitoring Metrics Publisher role assignment." >&2 + fi +} + if [ "${1:-}" = "--prepare-bicep" ]; then bash "$REPO_ROOT/infra/scripts/manage-ai-gateway-lifecycle.sh" prepare + register_monitor_provider prepare_bicep_rbac + migrate_legacy_telemetry_exporter exit 0 elif [ "$#" -gt 0 ]; then echo "Usage: $0 [--prepare-bicep]" >&2 @@ -283,6 +418,8 @@ if [ "$provider_auth" != "ManagedIdentity" ]; then exit 1 fi +configure_telemetry_exporter + remove_azd_env_values GITHUB_MCP_TOKEN GITHUB_TOKEN github_token="" if command -v gh >/dev/null 2>&1; then From d25ea62efee64f421467d0d67ace3357e3bb1e61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:38:40 +0000 Subject: [PATCH 3/7] Fix workspace resource id scoping and DCR polling in exporter setup Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/scripts/configure-ai-gateway.ps1 | 2 +- infra/scripts/configure-ai-gateway.sh | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 9be0f18..d792315 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -257,7 +257,7 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { $logsEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.LogsIngestionEndpoint || properties.logsIngestionEndpoint" -o tsv 2>$null) $tracesEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.TracesIngestionEndpoint || properties.tracesIngestionEndpoint" -o tsv 2>$null) $dcrId = [string](az rest --method get --uri $appInsightsUri --query "properties.MetricsIngestionDataCollectionRuleId || properties.metricsIngestionDataCollectionRuleId" -o tsv 2>$null) - if (-not [string]::IsNullOrWhiteSpace($metricsEndpoint) -and -not [string]::IsNullOrWhiteSpace($logsEndpoint)) { + if (-not [string]::IsNullOrWhiteSpace($metricsEndpoint) -and -not [string]::IsNullOrWhiteSpace($logsEndpoint) -and -not [string]::IsNullOrWhiteSpace($dcrId)) { break } Write-Host "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=$attempt" diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index 2f149e1..baff820 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -239,6 +239,7 @@ migrate_legacy_telemetry_exporter() { } configure_telemetry_exporter() { + local workspace_resource_id="$1" local app_insights_id local app_insights_uri local exporter_name @@ -255,6 +256,10 @@ configure_telemetry_exporter() { echo "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter configuration." return 0 fi + if [ -z "$workspace_resource_id" ]; then + echo "AI Gateway workspace resource ID is unavailable; skipping OpenTelemetry exporter configuration." >&2 + return 0 + fi exporter_name="$(first_value "${AI_GATEWAY_TELEMETRY_EXPORTER_NAME:-}" "$(azd_value AI_GATEWAY_TELEMETRY_EXPORTER_NAME)" "appinsights")" principal_id="$(first_value "${AI_GATEWAY_PRINCIPAL_ID:-}" "$(azd_value AI_GATEWAY_PRINCIPAL_ID)")" @@ -266,7 +271,7 @@ configure_telemetry_exporter() { logs_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.LogsIngestionEndpoint || properties.logsIngestionEndpoint" -o tsv 2>/dev/null || true)" traces_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.TracesIngestionEndpoint || properties.tracesIngestionEndpoint" -o tsv 2>/dev/null || true)" dcr_id="$(az rest --method get --uri "$app_insights_uri" --query "properties.MetricsIngestionDataCollectionRuleId || properties.metricsIngestionDataCollectionRuleId" -o tsv 2>/dev/null || true)" - if [ -n "$metrics_endpoint" ] && [ -n "$logs_endpoint" ]; then + if [ -n "$metrics_endpoint" ] && [ -n "$logs_endpoint" ] && [ -n "$dcr_id" ]; then break fi echo "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=${attempt}" @@ -418,7 +423,7 @@ if [ "$provider_auth" != "ManagedIdentity" ]; then exit 1 fi -configure_telemetry_exporter +configure_telemetry_exporter "$workspace_resource_id" remove_azd_env_values GITHUB_MCP_TOKEN GITHUB_TOKEN github_token="" From f7ff1a6a75b09bb5ada781999415b0351a947df0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:41:37 +0000 Subject: [PATCH 4/7] Reduce API calls and surface real role-assignment errors in exporter setup Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/scripts/configure-ai-gateway.ps1 | 21 ++++++++++---- infra/scripts/configure-ai-gateway.sh | 38 +++++++++++++++++++++----- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index d792315..ad91df4 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -253,10 +253,14 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { $tracesEndpoint = "" $dcrId = "" for ($attempt = 1; $attempt -le 30; $attempt++) { - $metricsEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.MetricsIngestionEndpoint || properties.metricsIngestionEndpoint" -o tsv 2>$null) - $logsEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.LogsIngestionEndpoint || properties.logsIngestionEndpoint" -o tsv 2>$null) - $tracesEndpoint = [string](az rest --method get --uri $appInsightsUri --query "properties.TracesIngestionEndpoint || properties.tracesIngestionEndpoint" -o tsv 2>$null) - $dcrId = [string](az rest --method get --uri $appInsightsUri --query "properties.MetricsIngestionDataCollectionRuleId || properties.metricsIngestionDataCollectionRuleId" -o tsv 2>$null) + $appInsightsJson = [string](az rest --method get --uri $appInsightsUri -o json 2>$null) + if (-not [string]::IsNullOrWhiteSpace($appInsightsJson)) { + $props = ($appInsightsJson | ConvertFrom-Json).properties + $metricsEndpoint = First-Value @($props.MetricsIngestionEndpoint, $props.metricsIngestionEndpoint) + $logsEndpoint = First-Value @($props.LogsIngestionEndpoint, $props.logsIngestionEndpoint) + $tracesEndpoint = First-Value @($props.TracesIngestionEndpoint, $props.tracesIngestionEndpoint) + $dcrId = First-Value @($props.MetricsIngestionDataCollectionRuleId, $props.metricsIngestionDataCollectionRuleId) + } if (-not [string]::IsNullOrWhiteSpace($metricsEndpoint) -and -not [string]::IsNullOrWhiteSpace($logsEndpoint) -and -not [string]::IsNullOrWhiteSpace($dcrId)) { break } @@ -304,9 +308,14 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { --assignee-principal-type ServicePrincipal ` --role $monitoringMetricsPublisherRoleId ` --scope $dcrId ` - -o none 2>$null + -o none } catch { - Write-Warning "The Monitoring Metrics Publisher role assignment already exists or could not be created; continuing." + $errorText = $_.Exception.Message + if ($errorText -match "RoleAssignmentExists" -or $errorText -match "already exists") { + Write-Host "The Monitoring Metrics Publisher role assignment already exists; continuing." + } else { + throw "Failed to assign the Monitoring Metrics Publisher role: $errorText" + } } } else { Write-Warning "Application Insights did not report the generated Data Collection Rule ID; skipping the Monitoring Metrics Publisher role assignment." diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index baff820..ddb03ba 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -242,6 +242,8 @@ configure_telemetry_exporter() { local workspace_resource_id="$1" local app_insights_id local app_insights_uri + local app_insights_json + local parsed_endpoints local exporter_name local principal_id local metrics_endpoint="" @@ -267,10 +269,21 @@ configure_telemetry_exporter() { echo "Waiting for Application Insights to generate its managed DCR/DCE and OTLP ingestion endpoints." for attempt in $(seq 1 30); do - metrics_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.MetricsIngestionEndpoint || properties.metricsIngestionEndpoint" -o tsv 2>/dev/null || true)" - logs_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.LogsIngestionEndpoint || properties.logsIngestionEndpoint" -o tsv 2>/dev/null || true)" - traces_endpoint="$(az rest --method get --uri "$app_insights_uri" --query "properties.TracesIngestionEndpoint || properties.tracesIngestionEndpoint" -o tsv 2>/dev/null || true)" - dcr_id="$(az rest --method get --uri "$app_insights_uri" --query "properties.MetricsIngestionDataCollectionRuleId || properties.metricsIngestionDataCollectionRuleId" -o tsv 2>/dev/null || true)" + app_insights_json="$(az rest --method get --uri "$app_insights_uri" -o json 2>/dev/null || true)" + if [ -n "$app_insights_json" ]; then + parsed_endpoints="$(python3 -c ' +import json, sys +p = json.load(sys.stdin).get("properties", {}) +fields = [ + p.get("MetricsIngestionEndpoint") or p.get("metricsIngestionEndpoint") or "", + p.get("LogsIngestionEndpoint") or p.get("logsIngestionEndpoint") or "", + p.get("TracesIngestionEndpoint") or p.get("tracesIngestionEndpoint") or "", + p.get("MetricsIngestionDataCollectionRuleId") or p.get("metricsIngestionDataCollectionRuleId") or "", +] +print("\t".join(fields)) +' <<< "$app_insights_json")" + IFS=$'\t' read -r metrics_endpoint logs_endpoint traces_endpoint dcr_id <<< "$parsed_endpoints" + fi if [ -n "$metrics_endpoint" ] && [ -n "$logs_endpoint" ] && [ -n "$dcr_id" ]; then break fi @@ -323,13 +336,24 @@ PY # The generated DCR lives in a managed resource group with a deny # assignment that blocks a nested ARM/Bicep role assignment; a direct # role-assignment call from this postprovision hook works instead. - az role assignment create \ + local role_assignment_error + role_assignment_error="$(az role assignment create \ --assignee-object-id "$principal_id" \ --assignee-principal-type ServicePrincipal \ --role "$MONITORING_METRICS_PUBLISHER_ROLE_ID" \ --scope "$dcr_id" \ - -o none 2>/dev/null || - echo "The Monitoring Metrics Publisher role assignment already exists or could not be created; continuing." >&2 + -o none 2>&1)" && role_assignment_error="" + if [ -n "$role_assignment_error" ]; then + case "$role_assignment_error" in + *RoleAssignmentExists*|*"already exists"*) + echo "The Monitoring Metrics Publisher role assignment already exists; continuing." + ;; + *) + echo "Failed to assign the Monitoring Metrics Publisher role: $role_assignment_error" >&2 + return 1 + ;; + esac + fi else echo "Application Insights did not report the generated Data Collection Rule ID; skipping the Monitoring Metrics Publisher role assignment." >&2 fi From 37d7211db7964f97f8400ad4426f1d95e46ea72e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:43:28 +0000 Subject: [PATCH 5/7] Polish: guard workspace id, document BCP037 suppression, extract poll constants Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/ai-gateway/main.bicep | 2 ++ infra/scripts/configure-ai-gateway.ps1 | 10 ++++++++-- infra/scripts/configure-ai-gateway.sh | 6 ++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/infra/ai-gateway/main.bicep b/infra/ai-gateway/main.bicep index d631a33..8d98075 100644 --- a/infra/ai-gateway/main.bicep +++ b/infra/ai-gateway/main.bicep @@ -98,6 +98,8 @@ resource appInsights 'Microsoft.Insights/components@2020-02-02' = if (enableMoni // resource is created, so the telemetry exporter itself cannot be created // in the same Bicep deployment; the postprovision hook configures it once // the endpoints are available (see infra/scripts/configure-ai-gateway.sh). + // This property is valid at runtime but is not yet reflected in the + // Bicep/ARM type definitions for this API version, hence the suppression. #disable-next-line BCP037 AzureMonitorWorkspaceIngestionMode: 'Enabled' } diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index ad91df4..1d5b244 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -7,6 +7,8 @@ $foundryUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d" $monitoringMetricsPublisherRoleId = "3913510d-42f4-4e42-8a64-420c390055eb" $monitorManagedIdentityAudience = "https://monitor.azure.com" $appInsightsOtlpApiVersion = "2024-02-01" +$otlpPollMaxAttempts = 30 +$otlpPollIntervalSeconds = 10 $defaultRepository = "microsoft/agent-framework" $githubMcpServer = "https://api.githubcopilot.com/mcp/" $githubMcpTools = "list_pull_requests,list_issues,actions_list" @@ -242,6 +244,10 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { Write-Host "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter configuration." return } + if ([string]::IsNullOrWhiteSpace($WorkspaceResourceId)) { + Write-Warning "AI Gateway workspace resource ID is unavailable; skipping OpenTelemetry exporter configuration." + return + } $exporterName = First-Value @($env:AI_GATEWAY_TELEMETRY_EXPORTER_NAME, (Get-AzdValue "AI_GATEWAY_TELEMETRY_EXPORTER_NAME"), "appinsights") $principalId = First-Value @($env:AI_GATEWAY_PRINCIPAL_ID, (Get-AzdValue "AI_GATEWAY_PRINCIPAL_ID")) @@ -252,7 +258,7 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { $logsEndpoint = "" $tracesEndpoint = "" $dcrId = "" - for ($attempt = 1; $attempt -le 30; $attempt++) { + for ($attempt = 1; $attempt -le $otlpPollMaxAttempts; $attempt++) { $appInsightsJson = [string](az rest --method get --uri $appInsightsUri -o json 2>$null) if (-not [string]::IsNullOrWhiteSpace($appInsightsJson)) { $props = ($appInsightsJson | ConvertFrom-Json).properties @@ -265,7 +271,7 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { break } Write-Host "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=$attempt" - Start-Sleep -Seconds 10 + Start-Sleep -Seconds $otlpPollIntervalSeconds } if ([string]::IsNullOrWhiteSpace($metricsEndpoint) -or [string]::IsNullOrWhiteSpace($logsEndpoint)) { diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index ddb03ba..2fc8497 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -8,6 +8,8 @@ FOUNDRY_USER_ROLE_ID="53ca6127-db72-4b80-b1b0-d745d6d5456d" MONITORING_METRICS_PUBLISHER_ROLE_ID="3913510d-42f4-4e42-8a64-420c390055eb" MONITOR_MANAGED_IDENTITY_AUDIENCE="https://monitor.azure.com" APP_INSIGHTS_OTLP_API_VERSION="2024-02-01" +OTLP_POLL_MAX_ATTEMPTS=30 +OTLP_POLL_INTERVAL_SECONDS=10 DEFAULT_REPOSITORY="microsoft/agent-framework" GITHUB_MCP_SERVER="https://api.githubcopilot.com/mcp/" GITHUB_MCP_TOOLS="list_pull_requests,list_issues,actions_list" @@ -268,7 +270,7 @@ configure_telemetry_exporter() { app_insights_uri="https://management.azure.com${app_insights_id}?api-version=${APP_INSIGHTS_OTLP_API_VERSION}" echo "Waiting for Application Insights to generate its managed DCR/DCE and OTLP ingestion endpoints." - for attempt in $(seq 1 30); do + for attempt in $(seq 1 "$OTLP_POLL_MAX_ATTEMPTS"); do app_insights_json="$(az rest --method get --uri "$app_insights_uri" -o json 2>/dev/null || true)" if [ -n "$app_insights_json" ]; then parsed_endpoints="$(python3 -c ' @@ -288,7 +290,7 @@ print("\t".join(fields)) break fi echo "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=${attempt}" - sleep 10 + sleep "$OTLP_POLL_INTERVAL_SECONDS" done if [ -z "$metrics_endpoint" ] || [ -z "$logs_endpoint" ]; then From 3ea5efc25754d3fb5a4ba4e9bf9d60ffecd634f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:44:43 +0000 Subject: [PATCH 6/7] Use approved PowerShell verb and clarify error-handling assumption Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/scripts/configure-ai-gateway.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 1d5b244..d7a722f 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -211,7 +211,7 @@ function Register-MonitorProvider { az provider register --namespace Microsoft.Monitor --wait } -function Migrate-LegacyTelemetryExporter { +function Remove-LegacyTelemetryExporter { $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")) $gatewayName = First-Value @($env:AI_GATEWAY_NAME, (Get-AzdValue "AI_GATEWAY_NAME")) @@ -308,6 +308,8 @@ function Configure-TelemetryExporter($WorkspaceResourceId) { # The generated DCR lives in a managed resource group with a deny # assignment that blocks a nested ARM/Bicep role assignment; a direct # role-assignment call from this postprovision hook works instead. + # A non-zero exit from az becomes a terminating error here because of + # the script-level $ErrorActionPreference / $PSNativeCommandUseErrorActionPreference. try { az role assignment create ` --assignee-object-id $principalId ` @@ -333,7 +335,7 @@ if ($mode -eq "--prepare-bicep") { & (Join-Path $PSScriptRoot "manage-ai-gateway-lifecycle.ps1") prepare Register-MonitorProvider Prepare-BicepRbac - Migrate-LegacyTelemetryExporter + Remove-LegacyTelemetryExporter exit 0 } if (-not [string]::IsNullOrWhiteSpace($mode)) { From 91359564fc739c35380bea77efcc03af69abd664 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:47:25 +0000 Subject: [PATCH 7/7] Rename Configure-TelemetryExporter and fix temp file cleanup on failure Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- infra/scripts/configure-ai-gateway.ps1 | 4 ++-- infra/scripts/configure-ai-gateway.sh | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index d7a722f..c32bbe8 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -238,7 +238,7 @@ function Remove-LegacyTelemetryExporter { } } -function Configure-TelemetryExporter($WorkspaceResourceId) { +function Set-TelemetryExporter($WorkspaceResourceId) { $appInsightsId = First-Value @($env:AI_GATEWAY_APPLICATION_INSIGHTS_ID, (Get-AzdValue "AI_GATEWAY_APPLICATION_INSIGHTS_ID")) if ([string]::IsNullOrWhiteSpace($appInsightsId)) { Write-Host "AI Gateway monitoring is disabled; skipping OpenTelemetry exporter configuration." @@ -402,7 +402,7 @@ if ($providerAuth -ne "ManagedIdentity") { throw "Bicep did not configure the Foundry provider for managed identity." } -Configure-TelemetryExporter $workspaceResourceId +Set-TelemetryExporter $workspaceResourceId Remove-AzdEnvValues @("GITHUB_MCP_TOKEN", "GITHUB_TOKEN") $githubToken = "" diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index 2fc8497..d24138b 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -252,7 +252,6 @@ configure_telemetry_exporter() { local logs_endpoint="" local traces_endpoint="" local dcr_id="" - local exporter_body_file local attempt app_insights_id="$(first_value "${AI_GATEWAY_APPLICATION_INSIGHTS_ID:-}" "$(azd_value AI_GATEWAY_APPLICATION_INSIGHTS_ID)")" @@ -332,6 +331,7 @@ PY --body @"$exporter_body_file" \ -o none rm -f "$exporter_body_file" + exporter_body_file="" if [ -n "$principal_id" ] && [ -n "$dcr_id" ]; then echo "Assigning the Monitoring Metrics Publisher role to the AI Gateway identity at the generated Data Collection Rule scope." @@ -373,8 +373,9 @@ elif [ "$#" -gt 0 ]; then fi tool_server_body="" +exporter_body_file="" cleanup() { - rm -f "$tool_server_body" + rm -f "$tool_server_body" "$exporter_body_file" } trap cleanup EXIT