# .SYNOPSIS Azure AD Connect Sync Prometheus Metrics Exporter .DESCRIPTION Prometheus exporter for Azure AD Connect - sync cycle status, last sync timestamp, connector space object counts, export/import errors, password sync status, staging mode, and auto-upgrade state. Exports metrics as Prometheus-compatible text format. .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER Port HTTP port for http mode (default: 9517) .PARAMETER TextfileDir Directory for textfile collector output (default: C:\ProgramData\node_exporter) .PARAMETER InstallScheduledTask Switch to create a scheduled task for auto-start on system boot .PARAMETER TaskIntervalMinutes Interval in minutes for the scheduled task (default: 2) .NOTES Author: Phil Connor Contact: contact@mylinux.work Website: https://mylinux.work License: MIT Version: 1.0 Metrics Exported: Core Status: - adconnect_up - adconnect_exporter_info{version} Sync Cycle: - adconnect_sync_cycle_status - adconnect_sync_cycle_in_progress - adconnect_last_sync_timestamp - adconnect_next_sync_timestamp - adconnect_sync_interval_seconds Connectors: - adconnect_connector_space_objects{connector} - adconnect_export_errors_total{connector} - adconnect_import_errors_total{connector} Password Sync: - adconnect_password_sync_enabled - adconnect_password_sync_last_success_timestamp Configuration: - adconnect_staging_mode - adconnect_auto_upgrade_state Exporter: - adconnect_exporter_duration_seconds - adconnect_exporter_last_run_timestamp #> param( [ValidateSet('stdout', 'textfile', 'http')] [string]$Mode = 'stdout', [int]$Port = 9517, [string]$TextfileDir = 'C:\ProgramData\node_exporter', [switch]$InstallScheduledTask, [int]$TaskIntervalMinutes = 2 ) # Create a scheduled task to run this script every $TaskIntervalMinutes minutes if ($InstallScheduledTask) { $taskName = "ADConnectSyncExporter" $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if (-not $existingTask) { $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($MyInvocation.MyCommand.Path)`" -Mode textfile" if (-not $TaskIntervalMinutes -or $TaskIntervalMinutes -le 0) { throw "TaskIntervalMinutes must be a positive integer" } $taskTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes $TaskIntervalMinutes) -RepetitionDuration (New-TimeSpan -Days 365) $taskPrincipal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest try { Write-Host "Creating scheduled task: $taskName" Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $taskTrigger -Principal $taskPrincipal -Description "Exports AD Connect sync metrics for Prometheus every $TaskIntervalMinutes minutes" $createdTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue if (-not $createdTask) { throw "Failed to verify scheduled task creation" } Write-Host "Successfully created scheduled task: $taskName" -ForegroundColor Green } catch { Write-Error "Failed to create scheduled task: $($_.Exception.Message)" throw } } else { Write-Host "Scheduled task '$taskName' already exists, skipping creation" } } $ErrorActionPreference = 'SilentlyContinue' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-UnixTimestamp { [int][double]::Parse((Get-Date -UFormat '%s')) } function Format-MetricValue { param([double]$Value, [int]$Decimals = 2) [math]::Round($Value, $Decimals) } # ============================================================================ # AD CONNECT METRICS # ============================================================================ function Get-ADConnectMetrics { $sb = [System.Text.StringBuilder]::new() # Check if ADSync module is available $adSyncAvailable = $false try { Import-Module ADSync -ErrorAction Stop $adSyncAvailable = $true } catch { Write-Warning "Failed to load ADSync module: $_" } # --- adconnect_up --- [void]$sb.AppendLine('# HELP adconnect_up AD Connect service reachability (1=up, 0=down)') [void]$sb.AppendLine('# TYPE adconnect_up gauge') $upVal = if ($adSyncAvailable) { 1 } else { 0 } [void]$sb.AppendLine("adconnect_up $upVal") [void]$sb.AppendLine('') # --- adconnect_exporter_info --- [void]$sb.AppendLine('# HELP adconnect_exporter_info Exporter version information') [void]$sb.AppendLine('# TYPE adconnect_exporter_info gauge') [void]$sb.AppendLine('adconnect_exporter_info{version="1.0"} 1') [void]$sb.AppendLine('') if (-not $adSyncAvailable) { return $sb.ToString() } # --- Sync scheduler metrics --- $scheduler = $null try { $scheduler = Get-ADSyncScheduler -ErrorAction Stop } catch { Write-Warning "Failed to get sync scheduler: $_" } # --- adconnect_sync_cycle_status --- [void]$sb.AppendLine('# HELP adconnect_sync_cycle_status Last sync cycle result (1=completed, 0=error)') [void]$sb.AppendLine('# TYPE adconnect_sync_cycle_status gauge') $cycleStatus = 0 if ($scheduler) { try { $lastRun = Get-ADSyncRunProfileResult -NumberRequested 1 -ErrorAction Stop if ($lastRun -and $lastRun.Result -eq 'success') { $cycleStatus = 1 } } catch {} } [void]$sb.AppendLine("adconnect_sync_cycle_status $cycleStatus") [void]$sb.AppendLine('') # --- adconnect_sync_cycle_in_progress --- [void]$sb.AppendLine('# HELP adconnect_sync_cycle_in_progress Sync cycle currently running (1=yes, 0=no)') [void]$sb.AppendLine('# TYPE adconnect_sync_cycle_in_progress gauge') $inProgress = 0 if ($scheduler -and $scheduler.SyncCycleInProgress) { $inProgress = 1 } [void]$sb.AppendLine("adconnect_sync_cycle_in_progress $inProgress") [void]$sb.AppendLine('') # --- adconnect_last_sync_timestamp --- [void]$sb.AppendLine('# HELP adconnect_last_sync_timestamp Unix timestamp of last completed sync') [void]$sb.AppendLine('# TYPE adconnect_last_sync_timestamp gauge') $lastSyncTs = 0 if ($scheduler -and $scheduler.LastSuccessfulSyncCycleTime) { try { $lastSyncTs = [int][double]::Parse((Get-Date $scheduler.LastSuccessfulSyncCycleTime -UFormat '%s')) } catch {} } [void]$sb.AppendLine("adconnect_last_sync_timestamp $lastSyncTs") [void]$sb.AppendLine('') # --- adconnect_next_sync_timestamp --- [void]$sb.AppendLine('# HELP adconnect_next_sync_timestamp Unix timestamp of next scheduled sync') [void]$sb.AppendLine('# TYPE adconnect_next_sync_timestamp gauge') $nextSyncTs = 0 if ($scheduler -and $scheduler.NextSyncCycleStartTimeInUTC) { try { $nextSyncTs = [int][double]::Parse((Get-Date $scheduler.NextSyncCycleStartTimeInUTC -UFormat '%s')) } catch {} } [void]$sb.AppendLine("adconnect_next_sync_timestamp $nextSyncTs") [void]$sb.AppendLine('') # --- adconnect_sync_interval_seconds --- [void]$sb.AppendLine('# HELP adconnect_sync_interval_seconds Configured sync cycle interval in seconds') [void]$sb.AppendLine('# TYPE adconnect_sync_interval_seconds gauge') $intervalSeconds = 0 if ($scheduler -and $scheduler.CurrentlyEffectiveSyncCycleInterval) { try { $intervalSeconds = [int]$scheduler.CurrentlyEffectiveSyncCycleInterval.TotalSeconds } catch {} } [void]$sb.AppendLine("adconnect_sync_interval_seconds $intervalSeconds") [void]$sb.AppendLine('') # --- Connector metrics --- $connectors = @() try { $connectors = Get-ADSyncConnector -ErrorAction Stop } catch { Write-Warning "Failed to get connectors: $_" } # --- adconnect_connector_space_objects --- [void]$sb.AppendLine('# HELP adconnect_connector_space_objects Object count per connector space') [void]$sb.AppendLine('# TYPE adconnect_connector_space_objects gauge') foreach ($conn in $connectors) { $connName = $conn.Name -replace '[\\"]', '' $objectCount = 0 try { $csStat = Get-ADSyncConnectorStatistics -ConnectorName $conn.Name -ErrorAction Stop if ($csStat) { $objectCount = $csStat.Count } } catch {} [void]$sb.AppendLine("adconnect_connector_space_objects{connector=`"$connName`"} $objectCount") } [void]$sb.AppendLine('') # --- adconnect_export_errors_total --- [void]$sb.AppendLine('# HELP adconnect_export_errors_total Export errors per connector') [void]$sb.AppendLine('# TYPE adconnect_export_errors_total gauge') foreach ($conn in $connectors) { $connName = $conn.Name -replace '[\\"]', '' $exportErrors = 0 try { $lastExportRun = Get-ADSyncRunProfileResult -ConnectorId $conn.Identifier -RunProfileName "Export" -NumberRequested 1 -ErrorAction Stop if ($lastExportRun -and $lastExportRun.CountExportErrors) { $exportErrors = $lastExportRun.CountExportErrors } } catch {} [void]$sb.AppendLine("adconnect_export_errors_total{connector=`"$connName`"} $exportErrors") } [void]$sb.AppendLine('') # --- adconnect_import_errors_total --- [void]$sb.AppendLine('# HELP adconnect_import_errors_total Import errors per connector') [void]$sb.AppendLine('# TYPE adconnect_import_errors_total gauge') foreach ($conn in $connectors) { $connName = $conn.Name -replace '[\\"]', '' $importErrors = 0 try { $lastImportRun = Get-ADSyncRunProfileResult -ConnectorId $conn.Identifier -RunProfileName "Full Import" -NumberRequested 1 -ErrorAction Stop if ($lastImportRun -and $lastImportRun.CountImportErrors) { $importErrors = $lastImportRun.CountImportErrors } } catch {} [void]$sb.AppendLine("adconnect_import_errors_total{connector=`"$connName`"} $importErrors") } [void]$sb.AppendLine('') # --- Password sync metrics --- [void]$sb.AppendLine('# HELP adconnect_password_sync_enabled Password hash sync enabled (1=yes, 0=no)') [void]$sb.AppendLine('# TYPE adconnect_password_sync_enabled gauge') $pwdSyncEnabled = 0 if ($scheduler -and $scheduler.SyncCycleEnabled) { try { foreach ($conn in $connectors) { if ($conn.ConnectorTypeName -eq 'AD') { $pwdSync = $conn.PasswordHashSyncConfiguration if ($pwdSync -and $pwdSync.Enabled) { $pwdSyncEnabled = 1 break } } } } catch {} } [void]$sb.AppendLine("adconnect_password_sync_enabled $pwdSyncEnabled") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP adconnect_password_sync_last_success_timestamp Unix timestamp of last successful password sync') [void]$sb.AppendLine('# TYPE adconnect_password_sync_last_success_timestamp gauge') $pwdSyncLastTs = 0 try { $pwdSyncStatus = Get-ADSyncAADPasswordSyncConfiguration -SourceConnector (($connectors | Where-Object { $_.ConnectorTypeName -eq 'AD' } | Select-Object -First 1).Name) -ErrorAction Stop if ($pwdSyncStatus -and $pwdSyncStatus.LastSuccessfulPingTime) { $pwdSyncLastTs = [int][double]::Parse((Get-Date $pwdSyncStatus.LastSuccessfulPingTime -UFormat '%s')) } } catch {} [void]$sb.AppendLine("adconnect_password_sync_last_success_timestamp $pwdSyncLastTs") [void]$sb.AppendLine('') # --- Staging mode --- [void]$sb.AppendLine('# HELP adconnect_staging_mode Staging mode active (1=yes, 0=no)') [void]$sb.AppendLine('# TYPE adconnect_staging_mode gauge') $stagingMode = 0 if ($scheduler -and $scheduler.StagingModeEnabled) { $stagingMode = 1 } [void]$sb.AppendLine("adconnect_staging_mode $stagingMode") [void]$sb.AppendLine('') # --- Auto-upgrade --- [void]$sb.AppendLine('# HELP adconnect_auto_upgrade_state Auto-upgrade enabled (1=enabled, 0=disabled)') [void]$sb.AppendLine('# TYPE adconnect_auto_upgrade_state gauge') $autoUpgrade = 0 try { $upgradeState = Get-ADSyncAutoUpgrade -ErrorAction Stop if ($upgradeState -eq 'Enabled') { $autoUpgrade = 1 } } catch {} [void]$sb.AppendLine("adconnect_auto_upgrade_state $autoUpgrade") [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # COLLECT ALL METRICS # ============================================================================ function Get-AllMetrics { $scriptStart = Get-Date $sb = [System.Text.StringBuilder]::new() # Collect AD Connect metrics [void]$sb.Append((Get-ADConnectMetrics)) # Exporter runtime $scriptEnd = Get-Date $duration = Format-MetricValue ($scriptEnd - $scriptStart).TotalSeconds $timestamp = Get-UnixTimestamp [void]$sb.AppendLine('# HELP adconnect_exporter_duration_seconds Time to generate all metrics') [void]$sb.AppendLine('# TYPE adconnect_exporter_duration_seconds gauge') [void]$sb.AppendLine("adconnect_exporter_duration_seconds $duration") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP adconnect_exporter_last_run_timestamp Unix timestamp of last successful run') [void]$sb.AppendLine('# TYPE adconnect_exporter_last_run_timestamp gauge') [void]$sb.AppendLine("adconnect_exporter_last_run_timestamp $timestamp") [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # HTTP SERVER MODE # ============================================================================ function Start-HttpServer { param([int]$ListenPort) $prefix = "http://+:$ListenPort/" $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($prefix) try { $listener.Start() Write-Host "Starting AD Connect Sync exporter on port $ListenPort..." -ForegroundColor Green Write-Host "Metrics available at http://localhost:$ListenPort/metrics" while ($listener.IsListening) { $context = $listener.GetContext() $request = $context.Request $response = $context.Response if ($request.Url.AbsolutePath -eq '/metrics') { $metrics = Get-AllMetrics $buffer = [System.Text.Encoding]::UTF8.GetBytes($metrics) $response.ContentType = 'text/plain; version=0.0.4; charset=utf-8' } else { $html = @"