<# .SYNOPSIS Windows IIS Log Prometheus Exporter .DESCRIPTION Prometheus exporter for IIS log files - parses W3C Extended log format to extract request counts, status code distribution, response times, bandwidth, top URIs, and error rates per IIS site. Exports metrics as Prometheus text. .PARAMETER Mode Output mode: 'stdout' (default), 'textfile', or 'http' .PARAMETER Port HTTP port for http mode (default: 9542) .PARAMETER TextfileDir Directory for textfile collector output (default: C:\ProgramData\node_exporter) .PARAMETER LogDir IIS log directory (default: C:\inetpub\logs\LogFiles) .PARAMETER WindowMinutes Parse logs from the last N minutes (default: 5) .PARAMETER InstallScheduledTask Switch to create a scheduled task for automatic execution .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: - windows_iis_log_up - windows_iis_log_exporter_info{version} Requests: - windows_iis_log_requests_total{site,method,status} - windows_iis_log_requests_by_status_class{site,class} - windows_iis_log_bytes_sent_total{site} - windows_iis_log_bytes_received_total{site} Response Times: - windows_iis_log_time_taken_avg_ms{site} - windows_iis_log_time_taken_max_ms{site} - windows_iis_log_time_taken_p95_ms{site} Errors: - windows_iis_log_errors_total{site} - windows_iis_log_error_rate{site} Sites: - windows_iis_log_sites_total - windows_iis_log_log_file_age_seconds{site} Exporter: - windows_iis_log_exporter_duration_seconds - windows_iis_log_exporter_last_run_timestamp #> param( [ValidateSet('stdout','textfile','http')] [string]$Mode = 'stdout', [int]$Port = 9542, [string]$TextfileDir = 'C:\ProgramData\node_exporter', [string]$LogDir = 'C:\inetpub\logs\LogFiles', [int]$WindowMinutes = 5, [switch]$InstallScheduledTask, [int]$TaskIntervalMinutes = 2 ) $ErrorActionPreference = 'SilentlyContinue' $Version = '1.0' # ============================================================================ # SCHEDULED TASK INSTALLER # ============================================================================ if ($InstallScheduledTask) { $scriptPath = $MyInvocation.MyCommand.Path $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -Mode textfile" $trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes $TaskIntervalMinutes) ` -Once -At (Get-Date) $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5) Register-ScheduledTask -TaskName 'WindowsIISLogExporter' -Action $action -Trigger $trigger ` -Settings $settings -RunLevel Highest -User 'SYSTEM' -Force Write-Host 'Scheduled task "WindowsIISLogExporter" installed successfully.' exit 0 } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ function Get-PrometheusEscape { param([string]$Value) $Value -replace '\\', '\\\\' -replace '"', '\"' -replace "`n", '\n' } function Write-MetricHeader { param([string]$Name, [string]$Type, [string]$Help) "# HELP $Name $Help" "# TYPE $Name $Type" } # ============================================================================ # LOG PARSING # ============================================================================ function Parse-IISLogFile { param([string]$FilePath, [datetime]$Since) $results = @{ Requests = @{} StatusClasses = @{} BytesSent = 0 BytesReceived = 0 TimeTaken = [System.Collections.Generic.List[int]]::new() Errors = 0 Total = 0 } $fieldMap = @{} Get-Content $FilePath -Tail 10000 | ForEach-Object { $line = $_ if ($line.StartsWith('#Fields:')) { $fields = $line.Substring(9).Trim() -split '\s+' for ($i = 0; $i -lt $fields.Count; $i++) { $fieldMap[$fields[$i]] = $i } return } if ($line.StartsWith('#') -or [string]::IsNullOrWhiteSpace($line)) { return } if ($fieldMap.Count -eq 0) { return } $parts = $line -split '\s+' # Parse date/time $dateIdx = $fieldMap['date'] $timeIdx = $fieldMap['time'] if ($null -ne $dateIdx -and $null -ne $timeIdx -and $parts.Count -gt [Math]::Max($dateIdx,$timeIdx)) { try { $entryTime = [datetime]::ParseExact("$($parts[$dateIdx]) $($parts[$timeIdx])", 'yyyy-MM-dd HH:mm:ss', $null) if ($entryTime -lt $Since) { return } } catch { return } } $results.Total++ # Method $methodIdx = $fieldMap['cs-method'] $method = if ($null -ne $methodIdx -and $parts.Count -gt $methodIdx) { $parts[$methodIdx] } else { 'UNKNOWN' } # Status $statusIdx = $fieldMap['sc-status'] $status = if ($null -ne $statusIdx -and $parts.Count -gt $statusIdx) { $parts[$statusIdx] } else { '0' } $key = "${method}_${status}" if (-not $results.Requests.ContainsKey($key)) { $results.Requests[$key] = 0 } $results.Requests[$key]++ # Status class $class = "${status}".Substring(0,1) + "xx" if (-not $results.StatusClasses.ContainsKey($class)) { $results.StatusClasses[$class] = 0 } $results.StatusClasses[$class]++ # Errors (4xx and 5xx) if ($status -match '^[45]') { $results.Errors++ } # Bytes $sentIdx = $fieldMap['sc-bytes'] if ($null -ne $sentIdx -and $parts.Count -gt $sentIdx -and $parts[$sentIdx] -match '^\d+$') { $results.BytesSent += [long]$parts[$sentIdx] } $recvIdx = $fieldMap['cs-bytes'] if ($null -ne $recvIdx -and $parts.Count -gt $recvIdx -and $parts[$recvIdx] -match '^\d+$') { $results.BytesReceived += [long]$parts[$recvIdx] } # Time taken $ttIdx = $fieldMap['time-taken'] if ($null -ne $ttIdx -and $parts.Count -gt $ttIdx -and $parts[$ttIdx] -match '^\d+$') { $results.TimeTaken.Add([int]$parts[$ttIdx]) } } return $results } # ============================================================================ # METRIC COLLECTION # ============================================================================ function Get-IISLogMetrics { $startTime = Get-Date $metrics = [System.Collections.Generic.List[string]]::new() $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_up' 'gauge' 'Exporter status (1=up, 0=down)')) if (-not (Test-Path $LogDir)) { $metrics.Add('windows_iis_log_up 0') return ($metrics -join "`n") } $metrics.Add('windows_iis_log_up 1') $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_exporter_info' 'gauge' 'Exporter version')) $metrics.Add("windows_iis_log_exporter_info{version=`"$Version`"} 1") $since = (Get-Date).AddMinutes(-$WindowMinutes) $siteDirs = Get-ChildItem -Path $LogDir -Directory -ErrorAction SilentlyContinue $siteCount = 0 $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_requests_total' 'gauge' 'Requests by method and status')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_requests_by_status_class' 'gauge' 'Requests by status class')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_bytes_sent_total' 'gauge' 'Total bytes sent')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_bytes_received_total' 'gauge' 'Total bytes received')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_time_taken_avg_ms' 'gauge' 'Average response time in ms')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_time_taken_max_ms' 'gauge' 'Max response time in ms')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_time_taken_p95_ms' 'gauge' '95th percentile response time in ms')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_errors_total' 'gauge' 'Total 4xx and 5xx errors')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_error_rate' 'gauge' 'Error rate (errors/total)')) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_log_file_age_seconds' 'gauge' 'Seconds since log file was last modified')) foreach ($siteDir in $siteDirs) { $siteName = Get-PrometheusEscape $siteDir.Name $logFiles = Get-ChildItem -Path $siteDir.FullName -Filter "*.log" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $logFiles) { continue } $siteCount++ $logFile = $logFiles[0] $fileAge = [math]::Round(((Get-Date) - $logFile.LastWriteTime).TotalSeconds) $metrics.Add("windows_iis_log_log_file_age_seconds{site=`"$siteName`"} $fileAge") $parsed = Parse-IISLogFile -FilePath $logFile.FullName -Since $since if ($null -eq $parsed -or $parsed.Total -eq 0) { continue } # Requests by method+status foreach ($key in $parsed.Requests.Keys) { $parts = $key -split '_' $method = $parts[0] $status = $parts[1] $count = $parsed.Requests[$key] $metrics.Add("windows_iis_log_requests_total{site=`"$siteName`",method=`"$method`",status=`"$status`"} $count") } # Status classes foreach ($class in $parsed.StatusClasses.Keys) { $count = $parsed.StatusClasses[$class] $metrics.Add("windows_iis_log_requests_by_status_class{site=`"$siteName`",class=`"$class`"} $count") } # Bytes $metrics.Add("windows_iis_log_bytes_sent_total{site=`"$siteName`"} $($parsed.BytesSent)") $metrics.Add("windows_iis_log_bytes_received_total{site=`"$siteName`"} $($parsed.BytesReceived)") # Response times if ($parsed.TimeTaken.Count -gt 0) { $sorted = $parsed.TimeTaken | Sort-Object $avg = [math]::Round(($sorted | Measure-Object -Average).Average) $max = $sorted[-1] $p95idx = [math]::Floor($sorted.Count * 0.95) $p95 = $sorted[[math]::Min($p95idx, $sorted.Count - 1)] $metrics.Add("windows_iis_log_time_taken_avg_ms{site=`"$siteName`"} $avg") $metrics.Add("windows_iis_log_time_taken_max_ms{site=`"$siteName`"} $max") $metrics.Add("windows_iis_log_time_taken_p95_ms{site=`"$siteName`"} $p95") } # Errors $metrics.Add("windows_iis_log_errors_total{site=`"$siteName`"} $($parsed.Errors)") $errorRate = if ($parsed.Total -gt 0) { [math]::Round($parsed.Errors / $parsed.Total, 4) } else { 0 } $metrics.Add("windows_iis_log_error_rate{site=`"$siteName`"} $errorRate") } $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_sites_total' 'gauge' 'Total IIS sites with logs')) $metrics.Add("windows_iis_log_sites_total $siteCount") # Duration $duration = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) $timestamp = [math]::Round((Get-Date -UFormat %s), 0) $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_exporter_duration_seconds' 'gauge' 'Script execution time')) $metrics.Add("windows_iis_log_exporter_duration_seconds $duration") $metrics.AddRange([string[]](Write-MetricHeader 'windows_iis_log_exporter_last_run_timestamp' 'gauge' 'Unix timestamp of last run')) $metrics.Add("windows_iis_log_exporter_last_run_timestamp $timestamp") return ($metrics -join "`n") } # ============================================================================ # OUTPUT # ============================================================================ switch ($Mode) { 'stdout' { Get-IISLogMetrics } 'textfile' { if (-not (Test-Path $TextfileDir)) { New-Item -ItemType Directory -Path $TextfileDir -Force | Out-Null } $tempFile = Join-Path $TextfileDir "windows-iis-log-metrics.tmp" $finalFile = Join-Path $TextfileDir "windows-iis-log-metrics.prom" Get-IISLogMetrics | Out-File -FilePath $tempFile -Encoding utf8 -NoNewline Move-Item -Path $tempFile -Destination $finalFile -Force Write-Host "Wrote metrics to $finalFile" } 'http' { $prefix = "http://+:$Port/metrics/" $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($prefix) $listener.Start() Write-Host "Listening on port $Port..." try { while ($listener.IsListening) { $context = $listener.GetContext() $response = $context.Response $metricsOutput = Get-IISLogMetrics $buffer = [System.Text.Encoding]::UTF8.GetBytes($metricsOutput) $response.ContentType = 'text/plain; version=0.0.4; charset=utf-8' $response.ContentLength64 = $buffer.Length $response.OutputStream.Write($buffer, 0, $buffer.Length) $response.OutputStream.Close() } } finally { $listener.Stop() } } }