Public/eventlog/Get-DiskErrorEvent.ps1

#Requires -Version 5.1

function Get-DiskErrorEvent {
    <#
    .SYNOPSIS
        Report disk, controller, NTFS, and storage errors from the System log
 
    .DESCRIPTION
        Queries the Windows System event log for known Disk, Ntfs, storahci, and storport
        error events over a configurable look-back window. Named EventData fields are
        parsed from event XML so localized message text does not drive classification.
 
        Results are classified from centralized provider and event-ID mappings, aggregated
        by provider, error type, and device, and returned newest first for each machine.
        Missing event fields remain empty or null, and remote failures do not stop other
        computers from being processed.
 
    .PARAMETER ComputerName
        One or more computer names to target. Defaults to the local computer.
        Accepts pipeline input by value and by property name.
 
    .PARAMETER Days
        Look-back window in days. Valid values are 1 through 3650. Defaults to 7.
 
    .PARAMETER MaxEvents
        Maximum number of System log events read per machine. Valid values are 1 through
        10000. Defaults to 200.
 
    .PARAMETER DiskNumber
        Optional disk number filter. Events without a disk number are excluded when this
        filter is specified.
 
    .PARAMETER CriticalOnly
        Return only events classified as critical or likely to indicate hardware failure.
 
    .PARAMETER Credential
        Optional PSCredential for authenticating to remote machines. Ignored for local
        machine queries.
 
    .EXAMPLE
        Get-DiskErrorEvent
 
        Returns recognized disk and storage errors from the local computer over the last
        seven days.
 
    .EXAMPLE
        Get-DiskErrorEvent -ComputerName 'SRV01' -Days 30 -CriticalOnly
 
        Returns critical storage errors from SRV01 over the last 30 days.
 
    .EXAMPLE
        'SRV01', 'SRV02' | Get-DiskErrorEvent -MaxEvents 500 -DiskNumber 0
 
        Returns disk-zero errors from multiple computers through pipeline input.
 
    .OUTPUTS
        PSWinOps.DiskErrorEvent
        One object per recognized storage event, newest first, with classification and
        an EventCount aggregated over the selected look-back window.
 
    .NOTES
        Author: Franck SALLET
        Version: 1.0.0
        Last Modified: 2026-09-04
        Requires: PowerShell 5.1+ / Windows only
        Requires: Event Log Readers membership for remote or protected System log access
        Requires: WinRM enabled on target machines for remote queries
 
        IsCritical is deterministic: BadBlock, ControllerReset, and FileSystem are critical;
        IoTimeout is non-critical unless the event is mapped to a provider/ID marked critical;
        Unknown is non-critical. Localized Message text is never used for classification.
 
    .LINK
        https://github.com/k9fr4n/PSWinOps
 
    .LINK
        https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.diagnostics/get-winevent
    #>

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

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

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

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 2147483647)]
        [Nullable[int]]$DiskNumber,

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

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

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

        $startTime = (Get-Date).AddDays(-$Days)
        $diskFilter = if ($DiskNumber -ge 0) { $DiskNumber } else { $null }

        $scriptBlock = {
            param(
                [datetime]$ScanStartTime,
                [int]$MaxEvts,
                [Nullable[int]]$DiskNumberFilter,
                [bool]$CriticalOnlyFilter
            )

            $eventDefinitions = @{
                'disk|7'       = [PSCustomObject]@{ ErrorType = 'BadBlock';        IsCritical = $true  }
                'disk|51'      = [PSCustomObject]@{ ErrorType = 'IoTimeout';       IsCritical = $false }
                'disk|154'     = [PSCustomObject]@{ ErrorType = 'IoTimeout';       IsCritical = $true  }
                'ntfs|55'      = [PSCustomObject]@{ ErrorType = 'FileSystem';      IsCritical = $true  }
                'storahci|129' = [PSCustomObject]@{ ErrorType = 'ControllerReset'; IsCritical = $true  }
                'storahci|153' = [PSCustomObject]@{ ErrorType = 'IoTimeout';       IsCritical = $false }
                'storport|129' = [PSCustomObject]@{ ErrorType = 'ControllerReset'; IsCritical = $true  }
                'storport|153' = [PSCustomObject]@{ ErrorType = 'IoTimeout';       IsCritical = $false }
            }

            $filter = @{
                LogName      = 'System'
                ProviderName = @('Disk', 'Ntfs', 'storahci', 'storport')
                Id           = @(7, 51, 55, 129, 153, 154)
                StartTime    = $ScanStartTime
            }

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

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

            function Get-EventDataMap {
                param(
                    [Parameter(Mandatory = $true)]
                    [object]$Event
                )

                $map = @{}
                try {
                    $xml = [xml]$Event.ToXml()
                    foreach ($node in @($xml.SelectNodes("//*[local-name()='Data']"))) {
                        $name = [string]$node.GetAttribute('Name')
                        if ([string]::IsNullOrWhiteSpace($name)) {
                            continue
                        }

                        $map[$name] = [string]$node.InnerText
                    }
                } catch {
                    Write-Verbose "Could not parse event $($Event.Id) as XML: $_"
                }

                return $map
            }

            function Get-EventField {
                param(
                    [Parameter(Mandatory = $true)]
                    [hashtable]$Data,
                    [Parameter(Mandatory = $true)]
                    [string[]]$Names
                )

                foreach ($name in $Names) {
                    if ($Data.ContainsKey($name) -and -not [string]::IsNullOrWhiteSpace([string]$Data[$name])) {
                        return [string]$Data[$name]
                    }
                }

                return ''
            }

            function ConvertTo-DiskNumber {
                param(
                    [string]$Value
                )

                if ([string]::IsNullOrWhiteSpace($Value)) {
                    return $null
                }

                $match = [regex]::Match($Value, '^\s*(\d+)\s*$')
                if ($match.Success) {
                    return [int]$match.Groups[1].Value
                }

                $match = [regex]::Match($Value, '(?i)harddisk(\d+)')
                if ($match.Success) {
                    return [int]$match.Groups[1].Value
                }

                return $null
            }

            $rows = [System.Collections.Generic.List[psobject]]::new()
            foreach ($eventRecord in @($eventRecords | Sort-Object -Property TimeCreated -Descending)) {
                $providerName = [string]$eventRecord.ProviderName
                $eventId = [int]$eventRecord.Id
                $definitionKey = "$($providerName.ToLowerInvariant())|$eventId"
                if (-not $eventDefinitions.ContainsKey($definitionKey)) {
                    continue
                }

                $data = Get-EventDataMap -Event $eventRecord
                $deviceName = Get-EventField -Data $data -Names @('DeviceName', 'Device')
                $devicePath = Get-EventField -Data $data -Names @('DevicePath', 'TargetDevice', 'Path')
                $diskNumberValue = Get-EventField -Data $data -Names @('DiskNumber', 'DeviceNumber', 'TargetDeviceNumber')
                $diskNumberFromData = ConvertTo-DiskNumber -Value $diskNumberValue
                if ($null -eq $diskNumberFromData) {
                    $diskNumberFromData = ConvertTo-DiskNumber -Value $deviceName
                }
                if ($null -eq $diskNumberFromData) {
                    $diskNumberFromData = ConvertTo-DiskNumber -Value $devicePath
                }

                $definition = $eventDefinitions[$definitionKey]
                $errorType = $definition.ErrorType
                $isCritical = [bool]$definition.IsCritical
                if ($data.Count -eq 0) {
                    $errorType = 'Unknown'
                    $isCritical = $false
                }

                $rows.Add([PSCustomObject]@{
                    EventTime    = $eventRecord.TimeCreated
                    ProviderName = $providerName
                    EventId      = $eventId
                    ErrorType    = $errorType
                    IsCritical   = $isCritical
                    DiskNumber   = $diskNumberFromData
                    DeviceName   = $deviceName
                    DevicePath   = $devicePath
                    ErrorCode    = Get-EventField -Data $data -Names @('ErrorCode', 'Error', 'Status')
                    RetryCount   = ConvertTo-DiskNumber -Value (Get-EventField -Data $data -Names @('RetryCount', 'Retries'))
                    Message      = [string]$eventRecord.Message
                })
            }

            $eventTally = @{}
            foreach ($row in $rows) {
                $diskKey = if ($null -eq $row.DiskNumber) { '' } else { [string]$row.DiskNumber }
                $key = '{0}|{1}|{2}|{3}|{4}' -f $row.ProviderName.ToLowerInvariant(), $row.ErrorType, $diskKey, $row.DeviceName, $row.DevicePath
                if ($eventTally.ContainsKey($key)) {
                    $eventTally[$key]++
                } else {
                    $eventTally[$key] = 1
                }
            }

            foreach ($row in ($rows | Sort-Object -Property EventTime -Descending)) {
                if ($null -ne $DiskNumberFilter -and $row.DiskNumber -ne $DiskNumberFilter) {
                    continue
                }
                if ($CriticalOnlyFilter -and -not $row.IsCritical) {
                    continue
                }

                $diskKey = if ($null -eq $row.DiskNumber) { '' } else { [string]$row.DiskNumber }
                $key = '{0}|{1}|{2}|{3}|{4}' -f $row.ProviderName.ToLowerInvariant(), $row.ErrorType, $diskKey, $row.DeviceName, $row.DevicePath

                [PSCustomObject]@{
                    PSTypeName   = 'PSWinOps.DiskErrorEvent'
                    ComputerName = $env:COMPUTERNAME
                    EventTime    = $row.EventTime.ToString('o')
                    ProviderName = $row.ProviderName
                    EventId      = $row.EventId
                    ErrorType    = $row.ErrorType
                    IsCritical   = $row.IsCritical
                    DiskNumber   = $row.DiskNumber
                    DeviceName   = $row.DeviceName
                    DevicePath   = $row.DevicePath
                    ErrorCode    = $row.ErrorCode
                    RetryCount   = $row.RetryCount
                    EventCount   = $eventTally[$key]
                    Message      = $row.Message
                    Timestamp    = Get-Date -Format 'o'
                }
            }
        }
    }

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

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