# .SYNOPSIS Windows RDP Session Prometheus Metrics Exporter .DESCRIPTION Prometheus exporter for per-session RDP protocol metrics - active, disconnected, and console sessions, per-session info, protocol performance (input/output frames, bytes, round-trip time, bandwidth), graphics quality, encoding time, skipped frames, and per-session resource usage. Works on any Windows machine with RDP enabled (does not require the full RDS role). Exports metrics as Prometheus- compatible text format. .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER Port HTTP port for http mode (default: 9398) .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: - rdp_session_up - rdp_session_exporter_info{version} Sessions: - rdp_sessions_active_total - rdp_sessions_disconnected_total - rdp_sessions_console_total - rdp_session_info{user,session_id,state,client_name,client_ip} Protocol Performance: - rdp_session_input_frames{session_name} - rdp_session_output_frames{session_name} - rdp_session_input_bytes_total{session_name} - rdp_session_output_bytes_total{session_name} - rdp_session_round_trip_time_ms{session_name} - rdp_session_bandwidth_kbps{session_name} Graphics: - rdp_session_frame_quality{session_name} - rdp_session_graphics_source_frames{session_name} - rdp_session_graphics_skipped_frames{session_name} - rdp_session_graphics_encoding_time_ms{session_name} Resources: - rdp_session_working_set_bytes{session_name} - rdp_session_handles{session_name} - rdp_session_threads{session_name} Exporter: - rdp_session_exporter_duration_seconds - rdp_session_exporter_last_run_timestamp #> param( [ValidateSet('stdout', 'textfile', 'http')] [string]$Mode = 'stdout', [int]$Port = 9398, [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 = "RdpSessionMetricsExporter" $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 RDP session 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) } function Sanitize-Label { param([string]$Value) if (-not $Value) { return '' } $Value -replace '[\\"]', '' -replace '\s+$', '' } function Get-QwinstaOutput { try { $raw = & qwinsta /server:localhost 2>$null if (-not $raw -or $raw.Count -lt 2) { return @() } $sessions = @() foreach ($line in $raw[1..($raw.Count - 1)]) { if ($line.Length -lt 20) { continue } $sessionName = $line.Substring(1, 18).Trim() $userName = $line.Substring(19, 22).Trim() $sessionId = $line.Substring(41, 7).Trim() $state = $line.Substring(48, 8).Trim() if (-not $sessionId -or $sessionId -notmatch '^\d+$') { continue } $sessions += [PSCustomObject]@{ SessionName = $sessionName UserName = $userName SessionId = [int]$sessionId State = $state } } return $sessions } catch { return @() } } # ============================================================================ # SESSION COUNT METRICS # ============================================================================ function Get-SessionCountMetrics { param($Sessions) $sb = [System.Text.StringBuilder]::new() # --- Active Sessions --- [void]$sb.AppendLine('# HELP rdp_sessions_active_total Total active RDP sessions') [void]$sb.AppendLine('# TYPE rdp_sessions_active_total gauge') try { $active = @($Sessions | Where-Object { $_.State -eq 'Active' -and $_.SessionName -ne 'console' -and $_.SessionName -match '^rdp-' }).Count [void]$sb.AppendLine("rdp_sessions_active_total $active") } catch { [void]$sb.AppendLine("rdp_sessions_active_total 0") } [void]$sb.AppendLine('') # --- Disconnected Sessions --- [void]$sb.AppendLine('# HELP rdp_sessions_disconnected_total Total disconnected RDP sessions') [void]$sb.AppendLine('# TYPE rdp_sessions_disconnected_total gauge') try { $disc = @($Sessions | Where-Object { $_.State -eq 'Disc' }).Count [void]$sb.AppendLine("rdp_sessions_disconnected_total $disc") } catch { [void]$sb.AppendLine("rdp_sessions_disconnected_total 0") } [void]$sb.AppendLine('') # --- Console Sessions --- [void]$sb.AppendLine('# HELP rdp_sessions_console_total Total console sessions') [void]$sb.AppendLine('# TYPE rdp_sessions_console_total gauge') try { $console = @($Sessions | Where-Object { $_.SessionName -eq 'console' -and $_.State -eq 'Active' }).Count [void]$sb.AppendLine("rdp_sessions_console_total $console") } catch { [void]$sb.AppendLine("rdp_sessions_console_total 0") } [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # PER-SESSION INFO METRICS # ============================================================================ function Get-SessionInfoMetrics { param($Sessions) $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('# HELP rdp_session_info Per-session information') [void]$sb.AppendLine('# TYPE rdp_session_info gauge') try { $tsSessions = Get-CimInstance -Namespace 'root\cimv2\TerminalServices' -ClassName 'Win32_TS_Session' -ErrorAction SilentlyContinue $tsLookup = @{} if ($tsSessions) { foreach ($ts in $tsSessions) { $tsLookup[$ts.SessionId] = $ts } } foreach ($s in $Sessions) { if (-not $s.UserName) { continue } $user = Sanitize-Label $s.UserName $sessionId = $s.SessionId $state = Sanitize-Label $s.State $clientName = '' $clientIp = '' if ($tsLookup.ContainsKey($sessionId)) { $clientName = Sanitize-Label $tsLookup[$sessionId].ClientName $clientIp = Sanitize-Label $tsLookup[$sessionId].ClientIPAddress } [void]$sb.AppendLine("rdp_session_info{user=`"$user`",session_id=`"$sessionId`",state=`"$state`",client_name=`"$clientName`",client_ip=`"$clientIp`"} 1") } } catch { } [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # PROTOCOL PERFORMANCE METRICS # ============================================================================ function Get-ProtocolMetrics { param($Sessions) $sb = [System.Text.StringBuilder]::new() # --- Input Frames --- [void]$sb.AppendLine('# HELP rdp_session_input_frames Input frames per second') [void]$sb.AppendLine('# TYPE rdp_session_input_frames gauge') try { $counters = Get-Counter '\Terminal Services Session(*)\Input Frames/Second' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = Format-MetricValue $sample.CookedValue [void]$sb.AppendLine("rdp_session_input_frames{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Output Frames --- [void]$sb.AppendLine('# HELP rdp_session_output_frames Output frames per second') [void]$sb.AppendLine('# TYPE rdp_session_output_frames gauge') try { $counters = Get-Counter '\Terminal Services Session(*)\Output Frames/Second' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = Format-MetricValue $sample.CookedValue [void]$sb.AppendLine("rdp_session_output_frames{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Input Bytes Total --- [void]$sb.AppendLine('# HELP rdp_session_input_bytes_total Total input bytes') [void]$sb.AppendLine('# TYPE rdp_session_input_bytes_total counter') try { $counters = Get-Counter '\Terminal Services Session(*)\Total Bytes Received' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = [math]::Round($sample.CookedValue) [void]$sb.AppendLine("rdp_session_input_bytes_total{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Output Bytes Total --- [void]$sb.AppendLine('# HELP rdp_session_output_bytes_total Total output bytes') [void]$sb.AppendLine('# TYPE rdp_session_output_bytes_total counter') try { $counters = Get-Counter '\Terminal Services Session(*)\Total Bytes Sent' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = [math]::Round($sample.CookedValue) [void]$sb.AppendLine("rdp_session_output_bytes_total{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Round Trip Time --- [void]$sb.AppendLine('# HELP rdp_session_round_trip_time_ms Current round-trip time in milliseconds') [void]$sb.AppendLine('# TYPE rdp_session_round_trip_time_ms gauge') try { $wmiSessions = Get-CimInstance -ClassName 'Win32_PerfFormattedData_TermService_TerminalServicesSession' -ErrorAction Stop foreach ($w in $wmiSessions) { $instance = Sanitize-Label $w.Name if ($instance -eq '_total' -or -not $instance) { continue } $val = $w.CurrentRoundTripTime [void]$sb.AppendLine("rdp_session_round_trip_time_ms{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Bandwidth --- [void]$sb.AppendLine('# HELP rdp_session_bandwidth_kbps Session bandwidth in kbps') [void]$sb.AppendLine('# TYPE rdp_session_bandwidth_kbps gauge') try { $wmiSessions = Get-CimInstance -ClassName 'Win32_PerfFormattedData_TermService_TerminalServicesSession' -ErrorAction Stop foreach ($w in $wmiSessions) { $instance = Sanitize-Label $w.Name if ($instance -eq '_total' -or -not $instance) { continue } $val = Format-MetricValue $w.CurrentBandwidth [void]$sb.AppendLine("rdp_session_bandwidth_kbps{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # GRAPHICS METRICS # ============================================================================ function Get-GraphicsMetrics { $sb = [System.Text.StringBuilder]::new() # --- Frame Quality --- [void]$sb.AppendLine('# HELP rdp_session_frame_quality Frame quality percentage') [void]$sb.AppendLine('# TYPE rdp_session_frame_quality gauge') try { $counters = Get-Counter '\RemoteFX Graphics(*)\Frame Quality' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = Format-MetricValue $sample.CookedValue [void]$sb.AppendLine("rdp_session_frame_quality{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Source Frames Per Second --- [void]$sb.AppendLine('# HELP rdp_session_graphics_source_frames Source frames per second') [void]$sb.AppendLine('# TYPE rdp_session_graphics_source_frames gauge') try { $counters = Get-Counter '\RemoteFX Graphics(*)\Source Frames/Second' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = Format-MetricValue $sample.CookedValue [void]$sb.AppendLine("rdp_session_graphics_source_frames{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Skipped Frames --- [void]$sb.AppendLine('# HELP rdp_session_graphics_skipped_frames Skipped frames per second') [void]$sb.AppendLine('# TYPE rdp_session_graphics_skipped_frames gauge') try { $counters = Get-Counter '\RemoteFX Graphics(*)\Skipped Frames/Second' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = Format-MetricValue $sample.CookedValue [void]$sb.AppendLine("rdp_session_graphics_skipped_frames{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Average Encoding Time --- [void]$sb.AppendLine('# HELP rdp_session_graphics_encoding_time_ms Average encoding time in milliseconds') [void]$sb.AppendLine('# TYPE rdp_session_graphics_encoding_time_ms gauge') try { $counters = Get-Counter '\RemoteFX Graphics(*)\Average Encoding Time' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = Format-MetricValue $sample.CookedValue [void]$sb.AppendLine("rdp_session_graphics_encoding_time_ms{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # RESOURCE METRICS # ============================================================================ function Get-ResourceMetrics { $sb = [System.Text.StringBuilder]::new() # --- Working Set (Memory) --- [void]$sb.AppendLine('# HELP rdp_session_working_set_bytes Session working set memory in bytes') [void]$sb.AppendLine('# TYPE rdp_session_working_set_bytes gauge') try { $counters = Get-Counter '\Terminal Services Session(*)\Working Set' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = [math]::Round($sample.CookedValue) [void]$sb.AppendLine("rdp_session_working_set_bytes{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Handle Count --- [void]$sb.AppendLine('# HELP rdp_session_handles Session handle count') [void]$sb.AppendLine('# TYPE rdp_session_handles gauge') try { $counters = Get-Counter '\Terminal Services Session(*)\Handle Count' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = [math]::Round($sample.CookedValue) [void]$sb.AppendLine("rdp_session_handles{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') # --- Thread Count --- [void]$sb.AppendLine('# HELP rdp_session_threads Session thread count') [void]$sb.AppendLine('# TYPE rdp_session_threads gauge') try { $counters = Get-Counter '\Terminal Services Session(*)\Thread Count' -ErrorAction Stop foreach ($sample in $counters.CounterSamples) { $instance = Sanitize-Label $sample.InstanceName if ($instance -eq '_total') { continue } $val = [math]::Round($sample.CookedValue) [void]$sb.AppendLine("rdp_session_threads{session_name=`"$instance`"} $val") } } catch { } [void]$sb.AppendLine('') $sb.ToString() } # ============================================================================ # COLLECT ALL METRICS # ============================================================================ function Get-AllMetrics { $scriptStart = Get-Date $sb = [System.Text.StringBuilder]::new() # Exporter up - test RDP service availability [void]$sb.AppendLine('# HELP rdp_session_up RDP session exporter status (1=up, 0=down)') [void]$sb.AppendLine('# TYPE rdp_session_up gauge') try { $termService = Get-Service -Name 'TermService' -ErrorAction Stop $upVal = if ($termService.Status -eq 'Running') { 1 } else { 0 } [void]$sb.AppendLine("rdp_session_up $upVal") } catch { [void]$sb.AppendLine("rdp_session_up 0") $scriptEnd = Get-Date $duration = Format-MetricValue ($scriptEnd - $scriptStart).TotalSeconds [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP rdp_session_exporter_duration_seconds Time to generate all metrics') [void]$sb.AppendLine('# TYPE rdp_session_exporter_duration_seconds gauge') [void]$sb.AppendLine("rdp_session_exporter_duration_seconds $duration") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP rdp_session_exporter_last_run_timestamp Unix timestamp of last run') [void]$sb.AppendLine('# TYPE rdp_session_exporter_last_run_timestamp gauge') [void]$sb.AppendLine("rdp_session_exporter_last_run_timestamp $(Get-UnixTimestamp)") return $sb.ToString() } [void]$sb.AppendLine('') # Exporter info [void]$sb.AppendLine('# HELP rdp_session_exporter_info Exporter version information') [void]$sb.AppendLine('# TYPE rdp_session_exporter_info gauge') [void]$sb.AppendLine('rdp_session_exporter_info{version="1.0"} 1') [void]$sb.AppendLine('') # Gather session list from qwinsta $sessions = Get-QwinstaOutput # Collect session count metrics [void]$sb.Append((Get-SessionCountMetrics -Sessions $sessions)) # Collect per-session info [void]$sb.Append((Get-SessionInfoMetrics -Sessions $sessions)) # Collect protocol performance metrics [void]$sb.Append((Get-ProtocolMetrics -Sessions $sessions)) # Collect graphics metrics [void]$sb.Append((Get-GraphicsMetrics)) # Collect resource metrics [void]$sb.Append((Get-ResourceMetrics)) # Exporter runtime $scriptEnd = Get-Date $duration = Format-MetricValue ($scriptEnd - $scriptStart).TotalSeconds $timestamp = Get-UnixTimestamp [void]$sb.AppendLine('# HELP rdp_session_exporter_duration_seconds Time to generate all metrics') [void]$sb.AppendLine('# TYPE rdp_session_exporter_duration_seconds gauge') [void]$sb.AppendLine("rdp_session_exporter_duration_seconds $duration") [void]$sb.AppendLine('') [void]$sb.AppendLine('# HELP rdp_session_exporter_last_run_timestamp Unix timestamp of last successful run') [void]$sb.AppendLine('# TYPE rdp_session_exporter_last_run_timestamp gauge') [void]$sb.AppendLine("rdp_session_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 RDP session 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 = @"