# .SYNOPSIS MSSQL Prometheus Metrics Exporter .DESCRIPTION Prometheus exporter for Microsoft SQL Server. Queries DMVs (Dynamic Management Views) and system catalog views to collect instance health, database sizes, backup freshness, connection counts, wait statistics, buffer cache performance, SQL Agent job status, and TempDB usage. Outputs Prometheus-compatible text format for consumption by windows_exporter textfile collector. .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER SqlInstance SQL Server instance name or hostname (default: localhost) .PARAMETER ConnectionString Full connection string (overrides SqlInstance) .PARAMETER TextfileDir Directory for textfile collector output .PARAMETER HttpPort HTTP port for http mode (default: 9399) .PARAMETER InstallScheduledTask Switch to create a scheduled task for periodic collection .PARAMETER TaskIntervalMinutes Interval in minutes for the scheduled task (default: 5) .NOTES Author: Phil Connor Contact: contact@mylinux.work Website: https://mylinux.work License: MIT Version: 1.0 Metrics Exported: Instance: - mssql_up - mssql_instance_uptime_seconds - mssql_version_info{version,edition} Database Sizes: - mssql_database_size_bytes{database} - mssql_database_log_size_bytes{database} - mssql_database_log_usage_percent{database} Backup Freshness: - mssql_backup_age_full_hours{database} - mssql_backup_age_diff_hours{database} Connections: - mssql_connections_active{database} - mssql_sessions_total - mssql_sessions_blocked Wait Statistics: - mssql_wait_time_seconds{wait_type} - mssql_waiting_tasks{wait_type} Buffer Cache: - mssql_buffer_cache_hit_ratio - mssql_page_life_expectancy_seconds SQL Agent Jobs: - mssql_agent_job_status{job} - mssql_agent_job_duration_seconds{job} TempDB: - mssql_tempdb_size_bytes - mssql_tempdb_usage_bytes Errors: - mssql_error_count Exporter: - mssql_collector_duration_seconds #> param( [ValidateSet('stdout', 'textfile', 'http')] [string]$Mode = 'stdout', [string]$SqlInstance = 'localhost', [string]$ConnectionString, [string]$TextfileDir = 'C:\Program Files\windows_exporter\textfile_inputs', [int]$HttpPort = 9399, [switch]$InstallScheduledTask, [int]$TaskIntervalMinutes = 5 ) # Handle --textfile and --http as positional arguments if ($args -contains '--textfile') { $Mode = 'textfile' } if ($args -contains '--http') { $Mode = 'http' } # ============================================================================ # SCHEDULED TASK INSTALLATION # ============================================================================ if ($InstallScheduledTask) { $taskName = "MSSQLMetricsExporter" $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 MSSQL 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 auto-start task: $($_.Exception.Message)" throw } } else { Write-Host "Scheduled task '$taskName' already exists, skipping creation" } if ($Mode -eq 'stdout') { return } } $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) } function Invoke-SqlQuery { param([string]$Query) try { if ($ConnectionString) { Invoke-Sqlcmd -ConnectionString $ConnectionString -Query $Query -ErrorAction Stop } else { Invoke-Sqlcmd -ServerInstance $SqlInstance -Query $Query -TrustServerCertificate -ErrorAction Stop } } catch { Write-Warning "SQL query failed: $_" return $null } } function Format-Label { param([string]$Value) $Value -replace '"', '' -replace '\\', '' -replace "`n", ' ' -replace "`r", '' } # ============================================================================ # INSTANCE METRICS # ============================================================================ function Get-InstanceMetrics { $sb = [System.Text.StringBuilder]::new() try { # Version info $version = Invoke-SqlQuery "SELECT SERVERPROPERTY('ProductVersion') AS Version, SERVERPROPERTY('Edition') AS Edition" if ($version) { $ver = Format-Label $version.Version $ed = Format-Label $version.Edition [void]$sb.AppendLine('# HELP mssql_version_info SQL Server version information (always 1)') [void]$sb.AppendLine('# TYPE mssql_version_info gauge') [void]$sb.AppendLine("mssql_version_info{version=`"$ver`",edition=`"$ed`"} 1") [void]$sb.AppendLine('') } # Uptime $uptime = Invoke-SqlQuery "SELECT DATEDIFF(SECOND, sqlserver_start_time, GETDATE()) AS uptime_seconds FROM sys.dm_os_sys_info" if ($uptime) { [void]$sb.AppendLine('# HELP mssql_instance_uptime_seconds Seconds since SQL Server instance started') [void]$sb.AppendLine('# TYPE mssql_instance_uptime_seconds gauge') [void]$sb.AppendLine("mssql_instance_uptime_seconds $($uptime.uptime_seconds)") [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect instance metrics: $_" } $sb.ToString() } # ============================================================================ # DATABASE SIZE METRICS # ============================================================================ function Get-DatabaseSizeMetrics { $sb = [System.Text.StringBuilder]::new() try { $sizes = Invoke-SqlQuery " SELECT d.name AS database_name, SUM(CASE WHEN mf.type = 0 THEN mf.size END) * 8 * 1024 AS data_size_bytes, SUM(CASE WHEN mf.type = 1 THEN mf.size END) * 8 * 1024 AS log_size_bytes FROM sys.databases d JOIN sys.master_files mf ON d.database_id = mf.database_id WHERE d.state = 0 GROUP BY d.name" if ($sizes) { [void]$sb.AppendLine('# HELP mssql_database_size_bytes Data file size per database in bytes') [void]$sb.AppendLine('# TYPE mssql_database_size_bytes gauge') foreach ($row in $sizes) { $dbName = Format-Label $row.database_name $dataSize = if ($row.data_size_bytes) { $row.data_size_bytes } else { 0 } [void]$sb.AppendLine("mssql_database_size_bytes{database=`"$dbName`"} $dataSize") } [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP mssql_database_log_size_bytes Log file size per database in bytes') [void]$sb.AppendLine('# TYPE mssql_database_log_size_bytes gauge') foreach ($row in $sizes) { $dbName = Format-Label $row.database_name $logSize = if ($row.log_size_bytes) { $row.log_size_bytes } else { 0 } [void]$sb.AppendLine("mssql_database_log_size_bytes{database=`"$dbName`"} $logSize") } [void]$sb.AppendLine('') } # Log usage percentage $logUsage = Invoke-SqlQuery "DBCC SQLPERF(LOGSPACE) WITH NO_INFOMSGS" if ($logUsage) { [void]$sb.AppendLine('# HELP mssql_database_log_usage_percent Log file usage percentage per database') [void]$sb.AppendLine('# TYPE mssql_database_log_usage_percent gauge') foreach ($row in $logUsage) { $dbName = Format-Label $row.'Database Name' $usage = Format-MetricValue $row.'Log Space Used (%)' [void]$sb.AppendLine("mssql_database_log_usage_percent{database=`"$dbName`"} $usage") } [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect database size metrics: $_" } $sb.ToString() } # ============================================================================ # BACKUP FRESHNESS METRICS # ============================================================================ function Get-BackupMetrics { $sb = [System.Text.StringBuilder]::new() try { # Full backup age $fullBackups = Invoke-SqlQuery " SELECT d.name AS database_name, CASE WHEN MAX(b.backup_finish_date) IS NULL THEN -1 ELSE DATEDIFF(HOUR, MAX(b.backup_finish_date), GETDATE()) END AS hours_since_backup FROM sys.databases d LEFT JOIN msdb.dbo.backupset b ON d.name = b.database_name AND b.type = 'D' WHERE d.database_id > 4 AND d.state = 0 GROUP BY d.name" if ($fullBackups) { [void]$sb.AppendLine('# HELP mssql_backup_age_full_hours Hours since last full backup per database (-1 = no backup)') [void]$sb.AppendLine('# TYPE mssql_backup_age_full_hours gauge') foreach ($row in $fullBackups) { $dbName = Format-Label $row.database_name [void]$sb.AppendLine("mssql_backup_age_full_hours{database=`"$dbName`"} $($row.hours_since_backup)") } [void]$sb.AppendLine('') } # Differential backup age $diffBackups = Invoke-SqlQuery " SELECT d.name AS database_name, CASE WHEN MAX(b.backup_finish_date) IS NULL THEN -1 ELSE DATEDIFF(HOUR, MAX(b.backup_finish_date), GETDATE()) END AS hours_since_backup FROM sys.databases d LEFT JOIN msdb.dbo.backupset b ON d.name = b.database_name AND b.type = 'I' WHERE d.database_id > 4 AND d.state = 0 GROUP BY d.name" if ($diffBackups) { [void]$sb.AppendLine('# HELP mssql_backup_age_diff_hours Hours since last differential backup per database (-1 = no backup)') [void]$sb.AppendLine('# TYPE mssql_backup_age_diff_hours gauge') foreach ($row in $diffBackups) { $dbName = Format-Label $row.database_name [void]$sb.AppendLine("mssql_backup_age_diff_hours{database=`"$dbName`"} $($row.hours_since_backup)") } [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect backup metrics: $_" } $sb.ToString() } # ============================================================================ # CONNECTION METRICS # ============================================================================ function Get-ConnectionMetrics { $sb = [System.Text.StringBuilder]::new() try { # Active connections per database $connections = Invoke-SqlQuery " SELECT DB_NAME(database_id) AS database_name, COUNT(*) AS active_connections FROM sys.dm_exec_sessions WHERE is_user_process = 1 AND database_id > 0 GROUP BY database_id" if ($connections) { [void]$sb.AppendLine('# HELP mssql_connections_active Active connections per database') [void]$sb.AppendLine('# TYPE mssql_connections_active gauge') foreach ($row in $connections) { $dbName = Format-Label $row.database_name [void]$sb.AppendLine("mssql_connections_active{database=`"$dbName`"} $($row.active_connections)") } [void]$sb.AppendLine('') } # Total active sessions $totalSessions = Invoke-SqlQuery "SELECT COUNT(*) AS total FROM sys.dm_exec_sessions WHERE is_user_process = 1" if ($totalSessions) { [void]$sb.AppendLine('# HELP mssql_sessions_total Total active user sessions') [void]$sb.AppendLine('# TYPE mssql_sessions_total gauge') [void]$sb.AppendLine("mssql_sessions_total $($totalSessions.total)") [void]$sb.AppendLine('') } # Blocked sessions $blocked = Invoke-SqlQuery "SELECT COUNT(*) AS blocked FROM sys.dm_exec_requests WHERE blocking_session_id > 0" if ($blocked) { [void]$sb.AppendLine('# HELP mssql_sessions_blocked Currently blocked sessions') [void]$sb.AppendLine('# TYPE mssql_sessions_blocked gauge') [void]$sb.AppendLine("mssql_sessions_blocked $($blocked.blocked)") [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect connection metrics: $_" } $sb.ToString() } # ============================================================================ # WAIT STATISTICS # ============================================================================ function Get-WaitMetrics { $sb = [System.Text.StringBuilder]::new() try { $waits = Invoke-SqlQuery " SELECT TOP 10 wait_type, wait_time_ms / 1000.0 AS wait_time_seconds, waiting_tasks_count FROM sys.dm_os_wait_stats WHERE wait_type NOT IN ( 'SLEEP_TASK', 'BROKER_TO_FLUSH', 'SQLTRACE_BUFFER_FLUSH', 'CLR_AUTO_EVENT', 'CLR_MANUAL_EVENT', 'LAZYWRITER_SLEEP', 'CHECKPOINT_QUEUE', 'WAITFOR', 'XE_TIMER_EVENT', 'BROKER_EVENTHANDLER', 'FT_IFTS_SCHEDULER_IDLE_WAIT', 'XE_DISPATCHER_WAIT', 'SQLTRACE_INCREMENTAL_FLUSH_SLEEP', 'HADR_FILESTREAM_IOMGR_IOCOMPLETION', 'DIRTY_PAGE_POLL', 'SP_SERVER_DIAGNOSTICS_SLEEP', 'BROKER_TASK_STOP', 'HADR_LOGCAPTURE_WAIT', 'ONDEMAND_TASK_QUEUE', 'DBMIRROR_EVENTS_QUEUE', 'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', 'QDS_ASYNC_QUEUE', 'QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP', 'DISPATCHER_QUEUE_SEMAPHORE', 'REQUEST_FOR_DEADLOCK_SEARCH', 'HADR_TIMER_TASK', 'BROKER_RECEIVE_WAITFOR', 'PREEMPTIVE_XE_GETTARGETSTATE', 'PREEMPTIVE_XE_SESSIONCOMMIT', 'SLEEP_BPOOL_FLUSH', 'SLEEP_DBSTARTUP', 'SLEEP_DCOMSTARTUP', 'SLEEP_MASTERDBREADY', 'SLEEP_MASTERMDREADY', 'SLEEP_MASTERUPGRADED', 'SLEEP_MSDBSTARTUP', 'SLEEP_SYSTEMTASK', 'SLEEP_TEMPDBSTARTUP', 'SNI_HTTP_ACCEPT', 'WAIT_XTP_OFFLINE_CKPT_NEW_LOG' ) AND wait_time_ms > 0 ORDER BY wait_time_ms DESC" if ($waits) { [void]$sb.AppendLine('# HELP mssql_wait_time_seconds Cumulative wait time by wait type') [void]$sb.AppendLine('# TYPE mssql_wait_time_seconds gauge') foreach ($row in $waits) { $waitType = Format-Label $row.wait_type $waitTime = Format-MetricValue $row.wait_time_seconds [void]$sb.AppendLine("mssql_wait_time_seconds{wait_type=`"$waitType`"} $waitTime") } [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP mssql_waiting_tasks Waiting task count by wait type') [void]$sb.AppendLine('# TYPE mssql_waiting_tasks gauge') foreach ($row in $waits) { $waitType = Format-Label $row.wait_type [void]$sb.AppendLine("mssql_waiting_tasks{wait_type=`"$waitType`"} $($row.waiting_tasks_count)") } [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect wait metrics: $_" } $sb.ToString() } # ============================================================================ # BUFFER CACHE METRICS # ============================================================================ function Get-BufferCacheMetrics { $sb = [System.Text.StringBuilder]::new() try { # Buffer cache hit ratio $hitRatio = Invoke-SqlQuery " SELECT CAST( (SELECT cntr_value FROM sys.dm_os_performance_counters WHERE counter_name = 'Buffer cache hit ratio' AND object_name LIKE '%Buffer Manager%') * 100.0 / NULLIF((SELECT cntr_value FROM sys.dm_os_performance_counters WHERE counter_name = 'Buffer cache hit ratio base' AND object_name LIKE '%Buffer Manager%'), 0) AS DECIMAL(5,2)) AS hit_ratio" if ($hitRatio -and $hitRatio.hit_ratio) { [void]$sb.AppendLine('# HELP mssql_buffer_cache_hit_ratio Buffer cache hit ratio (0-100)') [void]$sb.AppendLine('# TYPE mssql_buffer_cache_hit_ratio gauge') [void]$sb.AppendLine("mssql_buffer_cache_hit_ratio $(Format-MetricValue $hitRatio.hit_ratio)") [void]$sb.AppendLine('') } # Page life expectancy $ple = Invoke-SqlQuery " SELECT cntr_value AS ple_seconds FROM sys.dm_os_performance_counters WHERE counter_name = 'Page life expectancy' AND object_name LIKE '%Buffer Manager%'" if ($ple) { [void]$sb.AppendLine('# HELP mssql_page_life_expectancy_seconds Page life expectancy in seconds') [void]$sb.AppendLine('# TYPE mssql_page_life_expectancy_seconds gauge') [void]$sb.AppendLine("mssql_page_life_expectancy_seconds $($ple.ple_seconds)") [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect buffer cache metrics: $_" } $sb.ToString() } # ============================================================================ # SQL AGENT JOB METRICS # ============================================================================ function Get-AgentJobMetrics { $sb = [System.Text.StringBuilder]::new() try { $jobs = Invoke-SqlQuery " SELECT j.name AS job_name, CASE h.run_status WHEN 1 THEN 1 ELSE 0 END AS last_run_succeeded, CASE WHEN h.run_duration IS NULL THEN 0 ELSE (h.run_duration / 10000) * 3600 + ((h.run_duration / 100) % 100) * 60 + (h.run_duration % 100) END AS duration_seconds FROM msdb.dbo.sysjobs j OUTER APPLY ( SELECT TOP 1 run_status, run_duration FROM msdb.dbo.sysjobhistory WHERE job_id = j.job_id AND step_id = 0 ORDER BY run_date DESC, run_time DESC ) h WHERE j.enabled = 1" if ($jobs) { [void]$sb.AppendLine('# HELP mssql_agent_job_status Last run outcome per job (1=success, 0=fail)') [void]$sb.AppendLine('# TYPE mssql_agent_job_status gauge') foreach ($row in $jobs) { $jobName = Format-Label $row.job_name [void]$sb.AppendLine("mssql_agent_job_status{job=`"$jobName`"} $($row.last_run_succeeded)") } [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP mssql_agent_job_duration_seconds Last run duration per job in seconds') [void]$sb.AppendLine('# TYPE mssql_agent_job_duration_seconds gauge') foreach ($row in $jobs) { $jobName = Format-Label $row.job_name [void]$sb.AppendLine("mssql_agent_job_duration_seconds{job=`"$jobName`"} $($row.duration_seconds)") } [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect SQL Agent job metrics: $_" } $sb.ToString() } # ============================================================================ # TEMPDB METRICS # ============================================================================ function Get-TempDbMetrics { $sb = [System.Text.StringBuilder]::new() try { $tempdb = Invoke-SqlQuery " SELECT SUM(size) * 8 * 1024 AS total_size_bytes, SUM(FILEPROPERTY(name, 'SpaceUsed')) * 8 * 1024 AS used_bytes FROM tempdb.sys.database_files WHERE type = 0" if ($tempdb) { [void]$sb.AppendLine('# HELP mssql_tempdb_size_bytes TempDB total data file size in bytes') [void]$sb.AppendLine('# TYPE mssql_tempdb_size_bytes gauge') $totalSize = if ($tempdb.total_size_bytes) { $tempdb.total_size_bytes } else { 0 } [void]$sb.AppendLine("mssql_tempdb_size_bytes $totalSize") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP mssql_tempdb_usage_bytes TempDB space used in bytes') [void]$sb.AppendLine('# TYPE mssql_tempdb_usage_bytes gauge') $usedBytes = if ($tempdb.used_bytes) { $tempdb.used_bytes } else { 0 } [void]$sb.AppendLine("mssql_tempdb_usage_bytes $usedBytes") [void]$sb.AppendLine('') } } catch { Write-Warning "Failed to collect TempDB metrics: $_" } $sb.ToString() } # ============================================================================ # ERROR COUNT # ============================================================================ function Get-ErrorMetrics { $sb = [System.Text.StringBuilder]::new() try { $errors = Invoke-SqlQuery " SELECT COUNT(*) AS error_count FROM sys.dm_exec_requests WHERE status = 'running' AND total_elapsed_time > 0 AND sql_handle IS NOT NULL" [void]$sb.AppendLine('# HELP mssql_error_count Active error count from current requests') [void]$sb.AppendLine('# TYPE mssql_error_count gauge') $count = if ($errors -and $errors.error_count) { $errors.error_count } else { 0 } [void]$sb.AppendLine("mssql_error_count $count") [void]$sb.AppendLine('') } catch { Write-Warning "Failed to collect error metrics: $_" } $sb.ToString() } # ============================================================================ # COLLECT ALL METRICS # ============================================================================ function Get-AllMetrics { $scriptStart = Get-Date $sb = [System.Text.StringBuilder]::new() # Test connectivity $testResult = Invoke-SqlQuery "SELECT 1 AS connected" $isUp = if ($testResult) { 1 } else { 0 } [void]$sb.AppendLine('# HELP mssql_up SQL Server reachable (1=up, 0=down)') [void]$sb.AppendLine('# TYPE mssql_up gauge') [void]$sb.AppendLine("mssql_up $isUp") [void]$sb.AppendLine('') if ($isUp -eq 1) { [void]$sb.Append((Get-InstanceMetrics)) [void]$sb.Append((Get-DatabaseSizeMetrics)) [void]$sb.Append((Get-BackupMetrics)) [void]$sb.Append((Get-ConnectionMetrics)) [void]$sb.Append((Get-WaitMetrics)) [void]$sb.Append((Get-BufferCacheMetrics)) [void]$sb.Append((Get-AgentJobMetrics)) [void]$sb.Append((Get-TempDbMetrics)) [void]$sb.Append((Get-ErrorMetrics)) } # Collector duration $scriptEnd = Get-Date $duration = Format-MetricValue ($scriptEnd - $scriptStart).TotalSeconds [void]$sb.AppendLine('# HELP mssql_collector_duration_seconds Time to generate all metrics') [void]$sb.AppendLine('# TYPE mssql_collector_duration_seconds gauge') [void]$sb.AppendLine("mssql_collector_duration_seconds $duration") [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 MSSQL metrics 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 = @"