Skip to content
Draft
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
36 changes: 34 additions & 2 deletions IMPLEMENTATION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand All @@ -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`
Expand Down
26 changes: 26 additions & 0 deletions infra/ai-gateway/README.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 5 additions & 19 deletions infra/ai-gateway/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -81,28 +80,16 @@ 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'
tags: tags
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'
}
}

Expand Down Expand Up @@ -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 : ''
Expand Down
9 changes: 8 additions & 1 deletion infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ''

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions infra/scripts/configure-ai-gateway.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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)) {
Expand Down
Loading