Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions IMPLEMENTATION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 11 additions & 14 deletions infra/ai-gateway/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,16 @@ 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).
// 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'
}
}

Expand Down Expand Up @@ -175,7 +172,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 : ''
Expand Down
3 changes: 3 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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}/'
Expand Down
137 changes: 137 additions & 0 deletions infra/scripts/configure-ai-gateway.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ $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"
$otlpPollMaxAttempts = 30
$otlpPollIntervalSeconds = 10
$defaultRepository = "microsoft/agent-framework"
$githubMcpServer = "https://api.githubcopilot.com/mcp/"
$githubMcpTools = "list_pull_requests,list_issues,actions_list"
Expand Down Expand Up @@ -197,10 +202,140 @@ 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 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"))
$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 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."
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"))
$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 $otlpPollMaxAttempts; $attempt++) {
$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
}
Write-Host "Waiting for Application Insights managed DCR/DCE and OTLP endpoints, attempt=$attempt"
Start-Sleep -Seconds $otlpPollIntervalSeconds
}

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.
# 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 `
--assignee-principal-type ServicePrincipal `
--role $monitoringMetricsPublisherRoleId `
--scope $dcrId `
-o none
} catch {
$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."
}
}

$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
Remove-LegacyTelemetryExporter
exit 0
}
if (-not [string]::IsNullOrWhiteSpace($mode)) {
Expand Down Expand Up @@ -267,6 +402,8 @@ if ($providerAuth -ne "ManagedIdentity") {
throw "Bicep did not configure the Foundry provider for managed identity."
}

Set-TelemetryExporter $workspaceResourceId

Remove-AzdEnvValues @("GITHUB_MCP_TOKEN", "GITHUB_TOKEN")
$githubToken = ""
if ($null -ne (Get-Command gh -ErrorAction SilentlyContinue)) {
Expand Down
Loading