Public/iis/Get-IISAppPoolHistory.ps1

#Requires -Version 5.1
function Get-IISAppPoolHistory {
    <#
        .SYNOPSIS
            Reconstructs the lifecycle history (recycles, rapid-fail shutdowns, crashes, start/stop, identity changes) of IIS application pools from Windows event logs.
 
        .DESCRIPTION
            Mines the System (Microsoft-Windows-WAS), Application (W3SVC-WP, WAS) and
            optionally the Microsoft-Windows-IIS-W3SVC-WP/Operational event logs on one
            or more servers, then classifies every relevant entry into a typed event
            object enriched with the owning application pool, worker PID and a
            normalised reason code. Provides the operational timeline IISAdministration
            does not expose, with server-side filtering via Get-WinEvent -FilterHashtable
            for performance.
 
        .PARAMETER ComputerName
            One or more computer names to query. Defaults to the local machine.
            Accepts pipeline input by value and by property name (aliases: CN, Server,
            MachineName). Use $env:COMPUTERNAME, localhost, or . for the local machine.
 
        .PARAMETER Credential
            Optional PSCredential for authenticating to remote computers.
            Not used for local queries.
 
        .PARAMETER AppPoolName
            Restrict results to events whose parsed application pool name matches one
            or more patterns. Wildcards accepted via -like. Applied as a post-filter
            after event parsing because not all event IDs expose the pool name in a
            single InsertionString slot.
 
        .PARAMETER After
            Return only events with TimeCreated on or after this value.
            Forwarded as StartTime in Get-WinEvent -FilterHashtable for server-side
            filtering.
 
        .PARAMETER Before
            Return only events with TimeCreated on or before this value.
            Forwarded as EndTime in Get-WinEvent -FilterHashtable for server-side
            filtering.
 
        .PARAMETER Category
            Restrict results to one or more event categories. Valid values:
            Recycle, RapidFail, Crash, Start, Stop, IdentityChange, ConfigChange,
            OrphanWP, Other. When combined with -EventId the two ID sets are unioned.
 
        .PARAMETER EventId
            Query specific event IDs instead of (or in addition to) the default set
            derived from -Category. IDs not present in the built-in map are routed to
            the Operational log channel when -IncludeOperationalLog is also specified.
 
        .PARAMETER IncludeOperationalLog
            Also query the Microsoft-Windows-IIS-W3SVC-WP/Operational channel.
            This admin-only log contains additional ISAPI / FastCGI crash detail.
            A warning is emitted and the channel is skipped if it is absent or
            disabled; no terminating error is thrown.
 
        .PARAMETER MaxEvents
            Maximum number of events to retrieve per log channel per target machine.
            Forwarded as -MaxEvents to each Get-WinEvent call. Default: 1000.
 
        .PARAMETER Tail
            Keep only the most recent N events (applied after merging all log channels
            and post-filtering). Results are still returned in chronological order
            (oldest first).
 
        .EXAMPLE
            Get-IISAppPoolHistory -After (Get-Date).AddHours(-24) -Category Recycle,RapidFail
 
            Returns all recycle and rapid-fail events from the last 24 hours on the
            local machine.
 
        .EXAMPLE
            'WEB01','WEB02' | Get-IISAppPoolHistory -AppPoolName 'api-*' -Tail 20
 
            Returns the 20 most recent history events for application pools matching
            'api-*' across two web servers.
 
        .EXAMPLE
            Get-IISHealth -ComputerName WEB01 | Get-IISAppPoolHistory -After (Get-Date).AddDays(-7)
 
            Pipeline from Get-IISHealth to retrieve a week of app pool history.
 
        .EXAMPLE
            Get-IISAppPoolHistory -ComputerName WEB01 -Category Crash -IncludeOperationalLog -After (Get-Date).AddDays(-1)
 
            Includes the admin Operational channel for additional ISAPI / FastCGI crash
            detail over the last 24 hours.
 
        .OUTPUTS
            PSCustomObject (PSTypeName='PSWinOps.IISAppPoolHistoryEvent')
 
        .NOTES
            Author: Franck SALLET
            Version: 1.0.0
            Last Modified: 2026-05-16
            Requires: PowerShell 5.1+ / Windows only
            Requires: Web-Server (IIS) role
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/iis/manage/provisioning-and-managing-iis/troubleshooting-application-pool-issues
    #>

    [CmdletBinding()]
    [OutputType('PSWinOps.IISAppPoolHistoryEvent')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Server', 'MachineName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        [Parameter(Mandatory = $false, ValueFromPipelineByPropertyName = $true)]
        [string[]]$AppPoolName,

        [Parameter(Mandatory = $false)]
        [datetime]$After,

        [Parameter(Mandatory = $false)]
        [datetime]$Before,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Recycle', 'RapidFail', 'Crash', 'Start', 'Stop', 'IdentityChange', 'ConfigChange', 'OrphanWP', 'Other')]
        [string[]]$Category,

        [Parameter(Mandatory = $false)]
        [int[]]$EventId,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeOperationalLog,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 2147483647)]
        [int]$MaxEvents = 1000,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 2147483647)]
        [int]$Tail
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        # ── Static event-ID classification table ────────────────────────────
        # Passed via ArgumentList so the remote scriptblock classifies events
        # without any top-level module state.
        $eventIdMap = @{
            # ── WAS Recycle family (System log) ─────────────────────────────
            5074 = @{ Category = 'Recycle';        ReasonCode = 'ConfigChange';         Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5076 = @{ Category = 'Recycle';        ReasonCode = 'ScheduleTime';         Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5077 = @{ Category = 'Recycle';        ReasonCode = 'NumberOfRequests';     Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5079 = @{ Category = 'Recycle';        ReasonCode = 'Memory';               Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5080 = @{ Category = 'Recycle';        ReasonCode = 'PrivateMemory';        Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            # ── Rapid-fail protection (System log) ──────────────────────────
            5117 = @{ Category = 'RapidFail';      ReasonCode = 'RapidFailProtection';  Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5021 = @{ Category = 'IdentityChange'; ReasonCode = 'IdentityChange';       Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5022 = @{ Category = 'RapidFail';      ReasonCode = 'RapidFailProtection';  Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            # ── Worker-process crash (Application log) ───────────────────────
            5009 = @{ Category = 'Crash';          ReasonCode = 'ProcessTerminated';    Log = 'Application'; PoolIdx = 1; PidIdx = 0 }
            5010 = @{ Category = 'Crash';          ReasonCode = 'ISAPI';                Log = 'Application'; PoolIdx = 1; PidIdx = 0 }
            5011 = @{ Category = 'Crash';          ReasonCode = 'PingFailure';          Log = 'Application'; PoolIdx = 1; PidIdx = 0 }
            5013 = @{ Category = 'Crash';          ReasonCode = 'ShutdownTimeLimit';    Log = 'Application'; PoolIdx = 1; PidIdx = 0 }
            # ── Pool start / stop / orphan (System log) ──────────────────────
            5057 = @{ Category = 'Start';          ReasonCode = 'PoolStarted';          Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5059 = @{ Category = 'Stop';           ReasonCode = 'PoolStopped';          Log = 'System';      PoolIdx = 0; PidIdx = -1 }
            5168 = @{ Category = 'OrphanWP';       ReasonCode = 'OrphanWorkerProcess';  Log = 'System';      PoolIdx = 0; PidIdx = 1  }
            5186 = @{ Category = 'Stop';           ReasonCode = 'OnDemandStop';         Log = 'System';      PoolIdx = 0; PidIdx = -1 }
        }

        # Category -> default EventId set
        $categoryEventIds = @{
            Recycle        = @(5074, 5076, 5077, 5079, 5080)
            RapidFail      = @(5117, 5022)
            Crash          = @(5009, 5010, 5011, 5013)
            Start          = @(5057)
            Stop           = @(5059, 5186)
            IdentityChange = @(5021)
            ConfigChange   = @(5074)
            OrphanWP       = @(5168)
            Other          = @()
        }

        $hasCategory = $PSBoundParameters.ContainsKey('Category')
        $hasEventId  = $PSBoundParameters.ContainsKey('EventId')

        # Resolve which EventIds to query from System / Application logs
        $resolvedIds = [System.Collections.Generic.HashSet[int]]::new()

        if ($hasCategory) {
            foreach ($cat in $Category) {
                foreach ($cid in $categoryEventIds[$cat]) {
                    $null = $resolvedIds.Add($cid)
                }
            }
        }
        if ($hasEventId) {
            foreach ($eid in $EventId) {
                if ($eventIdMap.ContainsKey($eid)) {
                    $null = $resolvedIds.Add($eid)
                }
            }
        }
        # Default: query all known IDs when neither -Category nor -EventId is supplied
        if ($resolvedIds.Count -eq 0 -and -not $hasCategory -and -not $hasEventId) {
            foreach ($eid in $eventIdMap.Keys) {
                $null = $resolvedIds.Add($eid)
            }
        }

        # Partition by log channel for server-side filtering
        $systemIds      = [int[]]@($resolvedIds | Where-Object { $eventIdMap[$_]['Log'] -eq 'System'      })
        $applicationIds = [int[]]@($resolvedIds | Where-Object { $eventIdMap[$_]['Log'] -eq 'Application' })

        # Operational channel: user-specified IDs not in the static map
        $operationalQueryIds = [int[]]@(
            if ($hasEventId) { $EventId | Where-Object { -not $eventIdMap.ContainsKey($_) } }
        )

        # ── Remote-capable scriptblock ────────────────────────────────────
        $scriptBlock = {
            param(
                [hashtable] $EventIdMap,
                [int[]]     $SysIds,
                [int[]]     $AppIds,
                [int[]]     $OpIds,
                [string[]]  $FilterAppPool,
                [datetime]  $FilterAfter,
                [bool]      $HasAfter,
                [datetime]  $FilterBefore,
                [bool]      $HasBefore,
                [bool]      $IncludeOp,
                [int]       $MaxEvt,
                [int]       $TailN
            )

            $rawEvents = @()

            # ── 1. Query System log ──────────────────────────────────────────
            if ($SysIds -and $SysIds.Count -gt 0) {
                $fht = @{ LogName = 'System'; Id = $SysIds }
                if ($HasAfter)  { $fht['StartTime'] = $FilterAfter  }
                if ($HasBefore) { $fht['EndTime']   = $FilterBefore }
                $gwp = @{ FilterHashtable = $fht; ErrorAction = 'Stop' }
                if ($MaxEvt -gt 0) { $gwp['MaxEvents'] = $MaxEvt }
                try {
                    $rawEvents += @(Get-WinEvent @gwp)
                }
                catch {
                    $errMsg = $_.Exception.Message
                    if ($errMsg -notmatch 'No events were found|There are no more files') {
                        Write-Warning "[$env:COMPUTERNAME] System log query failed: $errMsg"
                    }
                }
            }

            # ── 2. Query Application log ─────────────────────────────────────
            if ($AppIds -and $AppIds.Count -gt 0) {
                $fht = @{ LogName = 'Application'; Id = $AppIds }
                if ($HasAfter)  { $fht['StartTime'] = $FilterAfter  }
                if ($HasBefore) { $fht['EndTime']   = $FilterBefore }
                $gwp = @{ FilterHashtable = $fht; ErrorAction = 'Stop' }
                if ($MaxEvt -gt 0) { $gwp['MaxEvents'] = $MaxEvt }
                try {
                    $rawEvents += @(Get-WinEvent @gwp)
                }
                catch {
                    $errMsg = $_.Exception.Message
                    if ($errMsg -notmatch 'No events were found|There are no more files') {
                        Write-Warning "[$env:COMPUTERNAME] Application log query failed: $errMsg"
                    }
                }
            }

            # ── 3. Query Operational log (optional) ──────────────────────────
            if ($IncludeOp) {
                $opLog = 'Microsoft-Windows-IIS-W3SVC-WP/Operational'
                $fht   = @{ LogName = $opLog }
                if ($OpIds -and $OpIds.Count -gt 0) { $fht['Id'] = $OpIds }
                if ($HasAfter)  { $fht['StartTime'] = $FilterAfter  }
                if ($HasBefore) { $fht['EndTime']   = $FilterBefore }
                $gwp = @{ FilterHashtable = $fht; ErrorAction = 'Stop' }
                if ($MaxEvt -gt 0) { $gwp['MaxEvents'] = $MaxEvt }
                try {
                    $rawEvents += @(Get-WinEvent @gwp)
                }
                catch {
                    $errMsg = $_.Exception.Message
                    if ($errMsg -match 'channel .* is disabled|not found|does not exist|cannot be opened|The specified channel') {
                        Write-Warning "[$env:COMPUTERNAME] Operational log '$opLog' is unavailable or disabled. Skipping."
                    }
                    elseif ($errMsg -notmatch 'No events were found|There are no more files') {
                        Write-Warning "[$env:COMPUTERNAME] Operational log query failed: $errMsg"
                    }
                }
            }

            if ($rawEvents.Count -eq 0) { return }

            # ── 4. Parse events into typed rows ──────────────────────────────
            $parsed = [System.Collections.Generic.List[hashtable]]::new()

            foreach ($evt in $rawEvents) {
                $id   = [int]$evt.Id
                $meta = if ($EventIdMap.ContainsKey($id)) { $EventIdMap[$id] } else { $null }

                $poolName  = $null
                $workerPid = $null

                try {
                    $props = $evt.Properties
                    if ($meta) {
                        $poolIdx = [int]$meta['PoolIdx']
                        $pidIdx  = [int]$meta['PidIdx']

                        if ($poolIdx -ge 0 -and $props.Count -gt $poolIdx) {
                            $v = [string]$props[$poolIdx].Value
                            if (-not [string]::IsNullOrWhiteSpace($v)) { $poolName = $v }
                        }
                        if ($pidIdx -ge 0 -and $props.Count -gt $pidIdx) {
                            $pv = $props[$pidIdx].Value
                            if ($null -ne $pv) {
                                $intVal = 0
                                if ([int]::TryParse($pv.ToString(), [ref]$intVal)) { $workerPid = $intVal }
                            }
                        }
                    }
                    elseif ($props.Count -gt 0) {
                        $v = [string]$props[0].Value
                        if (-not [string]::IsNullOrWhiteSpace($v)) { $poolName = $v }
                    }
                }
                catch { $null = $_ }

                $category   = if ($meta) { [string]$meta['Category'] }  else { 'Other' }
                $reasonCode = if ($meta) { [string]$meta['ReasonCode'] } else { $null   }

                $reason = $null
                try {
                    $msgRaw = $evt.Message
                    if (-not [string]::IsNullOrWhiteSpace($msgRaw)) {
                        $reason = ($msgRaw -replace '\r?\n', ' ' -replace '\s{2,}', ' ').Trim()
                        if ($reason.Length -gt 200) { $reason = $reason.Substring(0, 200) + '...' }
                    }
                }
                catch { $null = $_ }

                $tcUtc   = [datetime]::SpecifyKind($evt.TimeCreated.ToUniversalTime(), [System.DateTimeKind]::Utc)
                $tcLocal = $evt.TimeCreated

                $parsed.Add(@{
                    TimeCreated      = $tcUtc
                    TimeCreatedLocal = $tcLocal
                    AppPoolName      = $poolName
                    Category         = $category
                    EventId          = $id
                    WorkerPid        = $workerPid
                    ReasonCode       = $reasonCode
                    Reason           = $reason
                    ProviderName     = $evt.ProviderName
                    LogName          = $evt.LogName
                    RecordId         = $evt.RecordId
                    MachineName      = $evt.MachineName
                })
            }

            # ── 5. Post-filter: AppPoolName wildcard ─────────────────────────
            if ($FilterAppPool -and $FilterAppPool.Count -gt 0) {
                $keep = [System.Collections.Generic.List[hashtable]]::new()
                foreach ($row in $parsed) {
                    $pool = $row['AppPoolName']
                    if ($null -ne $pool) {
                        foreach ($pat in $FilterAppPool) {
                            if ($pool -like $pat) { $keep.Add($row); break }
                        }
                    }
                }
                $parsed = $keep
            }

            if ($parsed.Count -eq 0) { return }

            # ── 6. Tail: keep most recent N, then output chronologically ──────
            if ($TailN -gt 0 -and $parsed.Count -gt $TailN) {
                $sorted = @($parsed | Sort-Object { [datetime]$_['TimeCreated'] } -Descending)
                $tail   = [System.Collections.Generic.List[hashtable]]::new()
                for ($i = 0; $i -lt $TailN -and $i -lt $sorted.Count; $i++) {
                    $tail.Add($sorted[$i])
                }
                $parsed = $tail
            }

            # ── 7. Chronological sort for final output ────────────────────────
            @($parsed | Sort-Object { [datetime]$_['TimeCreated'] })
        }
    }

    process {
        foreach ($cn in $ComputerName) {
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Querying '$cn'"

            try {
                $invokeParams = @{
                    ComputerName = $cn
                    ScriptBlock  = $scriptBlock
                    ArgumentList = @(
                        $eventIdMap,
                        $systemIds,
                        $applicationIds,
                        $operationalQueryIds,
                        $AppPoolName,
                        $(if ($PSBoundParameters.ContainsKey('After'))  { $After  } else { [datetime]::MinValue }),
                        $PSBoundParameters.ContainsKey('After'),
                        $(if ($PSBoundParameters.ContainsKey('Before')) { $Before } else { [datetime]::MaxValue }),
                        $PSBoundParameters.ContainsKey('Before'),
                        [bool]$IncludeOperationalLog.IsPresent,
                        [int]$MaxEvents,
                        $(if ($PSBoundParameters.ContainsKey('Tail')) { [int]$Tail } else { [int]0 })
                    )
                }
                if ($PSBoundParameters.ContainsKey('Credential')) {
                    $invokeParams['Credential'] = $Credential
                }

                $rawResults = Invoke-RemoteOrLocal @invokeParams
            }
            catch {
                Write-Error -Message "[$($MyInvocation.MyCommand)] Failed to query '$cn': $($_.Exception.Message)"
                continue
            }

            if ($null -eq $rawResults) { continue }

            foreach ($row in $rawResults) {
                [PSCustomObject]@{
                    PSTypeName       = 'PSWinOps.IISAppPoolHistoryEvent'
                    TimeCreated      = $row['TimeCreated']
                    TimeCreatedLocal = $row['TimeCreatedLocal']
                    ComputerName     = $cn
                    AppPoolName      = $row['AppPoolName']
                    Category         = $row['Category']
                    EventId          = $row['EventId']
                    WorkerPid        = $row['WorkerPid']
                    ReasonCode       = $row['ReasonCode']
                    Reason           = $row['Reason']
                    ProviderName     = $row['ProviderName']
                    LogName          = $row['LogName']
                    RecordId         = $row['RecordId']
                    MachineName      = $row['MachineName']
                    Timestamp        = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                }
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Done"
    }
}