Public/eventlog/Get-ServiceCrashEvent.ps1

#Requires -Version 5.1

function Get-ServiceCrashEvent {
    <#
    .SYNOPSIS
        Aggregate Service Control Manager crash events per service with counts and exit codes
 
    .DESCRIPTION
        Queries the Windows System event log for Service Control Manager crash/abnormal-stop
        events (IDs 7000, 7009, 7011, 7024, 7031, 7034) over a look-back window and reports one
        row per event with the resolved service name, display name, last exit code, configured
        recovery action, and a per-service crash count aggregated over the window. Local and
        remote targets are dispatched through Invoke-RemoteOrLocal; machines with no matching
        events return nothing and per-machine failures do not stop the remaining targets.
 
    .PARAMETER ComputerName
        One or more computer names to target. Defaults to the local computer.
        Accepts pipeline input by value and by property name.
 
    .PARAMETER Credential
        Optional PSCredential for authenticating to remote machines. Ignored for
        local machine queries.
 
    .PARAMETER Days
        Look-back window in days used to bound the event log scan. StartTime is
        computed as (Get-Date).AddDays(-Days). Valid range is 1 to 3650. Defaults to 7.
 
    .PARAMETER MaxEvents
        Maximum number of Service Control Manager crash events returned per machine,
        newest first. This value is forwarded to Get-WinEvent -MaxEvents and also caps
        the number of rows emitted. Valid range is 1 to 10000. Defaults to 200.
 
    .PARAMETER ServiceName
        Optional filter on the resolved ServiceName. Comparison is case-insensitive
        equality, applied after decoding (the real service name lives in the event
        Properties, not a FilterHashtable key, so it cannot be pushed into the query).
        Empty or absent means no filter.
 
    .EXAMPLE
        Get-ServiceCrashEvent -Days 7
 
        Returns Service Control Manager crash events for the local machine over the
        last 7 days.
 
    .EXAMPLE
        Get-ServiceCrashEvent -ComputerName SRV01 -ServiceName Spooler -Days 30
 
        Returns crash events for the 'Spooler' service on SRV01 over the last 30
        days via WinRM.
 
    .EXAMPLE
        'SRV01','SRV02' | Get-ServiceCrashEvent -MaxEvents 100
 
        Returns up to 100 crash events per machine for SRV01 and SRV02 via pipeline.
 
    .OUTPUTS
        PSWinOps.ServiceCrashEvent
        One object per Service Control Manager crash event (7000, 7009, 7011, 7024,
        7031, 7034), newest first, with resolved service name, exit code, window-level
        crash count and configured recovery action.
 
    .NOTES
        Author: Franck SALLET
        Version: 1.0.0
        Last Modified: 2026-07-06
        Requires: PowerShell 5.1+ / Windows only
        Requires: WinRM enabled on target machines for remote queries
 
    .LINK
        https://github.com/k9fr4n/PSWinOps
 
    .LINK
        Source of truth: Windows System event log (provider 'Service Control Manager', IDs 7000/7009/7011/7024/7031/7034)
    #>

    [CmdletBinding(SupportsShouldProcess = $false, ConfirmImpact = 'None')]
    [OutputType('PSWinOps.ServiceCrashEvent')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

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

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 3650)]
        [int]$Days = 7,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 10000)]
        [int]$MaxEvents = 200,

        [Parameter(Mandatory = $false)]
        [string]$ServiceName = ''
    )

    begin {
        Write-Verbose "[$($MyInvocation.MyCommand)] Starting service crash event query"

        $startTime = (Get-Date).AddDays(-$Days)

        $scriptBlock = {
            param(
                [datetime]$ScanStartTime,
                [int]$MaxEvts,
                [string]$ServiceNameFilter
            )

            $filter = @{
                LogName      = 'System'
                ProviderName = 'Service Control Manager'
                Id           = @(7000, 7009, 7011, 7024, 7031, 7034)
                StartTime    = $ScanStartTime
            }

            $events = @()
            try {
                $events = @(Get-WinEvent -FilterHashtable $filter -MaxEvents $MaxEvts -ErrorAction Stop)
            } catch {
                if ($_.Exception.Message -notmatch 'No events were found') {
                    throw
                }
            }

            $events = @($events | Sort-Object -Property TimeCreated -Descending)

            # Cache DisplayName -> ServiceName resolution once per invocation (best-effort)
            $serviceNameMap = @{}
            try {
                Get-CimInstance -ClassName Win32_Service -ErrorAction Stop | ForEach-Object {
                    if (-not [string]::IsNullOrEmpty($_.DisplayName)) {
                        $serviceNameMap[$_.DisplayName] = $_.Name
                    }
                }
            } catch {
                Write-Verbose "Could not enumerate Win32_Service for name resolution: $_"
            }

            $recoveryActionTypeMap = @{
                0 = 'TakeNoAction'
                1 = 'Restart'
                2 = 'Reboot'
                3 = 'RunProgram'
            }

            # Cache the configured SCM recovery action per distinct resolved ServiceName
            $recoveryActionCache = @{}

            # Resolve only the SCM-configured recovery action from the registry. This value
            # is per-service, so it is safe to cache by name. The per-event fallback code is
            # deliberately NOT handled here: caching it would let one event's (absent) code
            # poison a later event for the same service.
            function Get-ScmConfiguredRecoveryAction {
                param(
                    [string]$Name
                )

                if ([string]::IsNullOrEmpty($Name)) { return '' }
                if ($recoveryActionCache.ContainsKey($Name)) { return $recoveryActionCache[$Name] }

                $resolvedAction = ''
                try {
                    $regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$Name"
                    $bytes = (Get-ItemProperty -Path $regPath -Name 'FailureActions' -ErrorAction Stop).FailureActions
                    if ($bytes -and $bytes.Count -ge 20) {
                        $numActions = [System.BitConverter]::ToUInt32($bytes, 12)
                        if ($numActions -ge 1) {
                            $actionType = [int]([System.BitConverter]::ToUInt32($bytes, 16))
                            $resolvedAction = if ($recoveryActionTypeMap.ContainsKey($actionType)) { $recoveryActionTypeMap[$actionType] } else { "Unknown($actionType)" }
                        }
                    }
                } catch {
                    Write-Verbose "Could not resolve configured recovery action for '$Name': $_"
                }

                $recoveryActionCache[$Name] = $resolvedAction
                return $resolvedAction
            }

            # Combine the cached SCM-configured action with the per-event fallback code.
            function Get-ScmRecoveryAction {
                param(
                    [string]$Name,
                    [int]$FallbackCode
                )

                $resolvedAction = Get-ScmConfiguredRecoveryAction -Name $Name

                if ([string]::IsNullOrEmpty($resolvedAction) -and $FallbackCode -ge 0) {
                    $resolvedAction = if ($recoveryActionTypeMap.ContainsKey($FallbackCode)) { $recoveryActionTypeMap[$FallbackCode] } else { "Unknown($FallbackCode)" }
                }

                return $resolvedAction
            }

            $rows = [System.Collections.Generic.List[psobject]]::new()

            foreach ($evt in $events) {
                $props = $evt.Properties
                $displayName    = ''
                $exitCode       = ''
                $eventActionCode = -1

                switch ($evt.Id) {
                    7000 {
                        # Service failed to start; Properties[0]=display name, Properties[1] may carry an error code
                        $displayName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
                        if ($props.Count -ge 2) {
                            try { $exitCode = [string]([int64]$props[1].Value) } catch { $exitCode = '' }
                        }
                    }
                    7009 {
                        # Start timeout; property order for timeout ms / display name varies by build
                        if ($props.Count -ge 2) {
                            $val0 = [string]$props[0].Value
                            $val1 = [string]$props[1].Value
                            $displayName = if ($val0 -match '^\d+$') { $val1 } else { $val0 }
                        } elseif ($props.Count -ge 1) {
                            $displayName = [string]$props[0].Value
                        }
                    }
                    7011 {
                        # Transaction response timeout; Properties[0]=display name
                        $displayName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
                    }
                    7024 {
                        # Service-specific error / exit code; Properties[0]=display name, Properties[1]=exit code
                        $displayName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
                        if ($props.Count -ge 2) {
                            try { $exitCode = [string]([int64]$props[1].Value) } catch { $exitCode = [string]$props[1].Value }
                        }
                    }
                    7031 {
                        # Unexpected termination; Properties: [0]=display name, [1]=count, [2]=restart window,
                        # [3]=recovery action code, [4]=restart command
                        $displayName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
                        if ($props.Count -ge 4) {
                            try { $eventActionCode = [int]$props[3].Value } catch { $eventActionCode = -1 }
                        }
                    }
                    7034 {
                        # Unexpected termination; Properties: [0]=display name, [1]=count
                        $displayName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
                    }
                }

                $resolvedServiceName = if ($serviceNameMap.ContainsKey($displayName)) { $serviceNameMap[$displayName] } else { $displayName }
                $recoveryAction = Get-ScmRecoveryAction -Name $resolvedServiceName -FallbackCode $eventActionCode

                $rows.Add([PSCustomObject]@{
                    EventTime          = $evt.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss')
                    EventId            = $evt.Id
                    ServiceName        = $resolvedServiceName
                    ServiceDisplayName = $displayName
                    ExitCode           = $exitCode
                    RecoveryAction     = $recoveryAction
                    Message            = $evt.Message
                })
            }

            # Window-level CrashCount tally per distinct ServiceName, computed over ALL read events
            # before the -ServiceName filter is applied.
            $crashTally = @{}
            foreach ($row in $rows) {
                if ($crashTally.ContainsKey($row.ServiceName)) {
                    $crashTally[$row.ServiceName]++
                } else {
                    $crashTally[$row.ServiceName] = 1
                }
            }

            $results = [System.Collections.Generic.List[psobject]]::new()

            foreach ($row in $rows) {
                if ($results.Count -ge $MaxEvts) { break }

                if (-not [string]::IsNullOrWhiteSpace($ServiceNameFilter) -and $row.ServiceName -ne $ServiceNameFilter) {
                    continue
                }

                $results.Add([PSCustomObject]@{
                    PSTypeName         = 'PSWinOps.ServiceCrashEvent'
                    ComputerName       = $env:COMPUTERNAME
                    EventTime          = $row.EventTime
                    EventId            = $row.EventId
                    ServiceName        = $row.ServiceName
                    ServiceDisplayName = $row.ServiceDisplayName
                    ExitCode           = $row.ExitCode
                    CrashCount         = $crashTally[$row.ServiceName]
                    RecoveryAction     = $row.RecoveryAction
                    Message            = $row.Message
                    Timestamp          = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
                })
            }

            $results
        }
    }

    process {
        foreach ($targetComputer in $ComputerName) {
            try {
                Write-Verbose "[$($MyInvocation.MyCommand)] Querying service crash events on '$targetComputer'"
                Invoke-RemoteOrLocal -ComputerName $targetComputer -Credential $Credential `
                    -ScriptBlock $scriptBlock `
                    -ArgumentList @($startTime, $MaxEvents, $ServiceName)
            } catch {
                Write-Error "[$($MyInvocation.MyCommand)] Failed on '$targetComputer': $_"
            }
        }
    }

    end {
        Write-Verbose "[$($MyInvocation.MyCommand)] Completed service crash event query"
    }
}