Private/Kinds/Stability.ps1

# The Stability Kind: what the event logs remember about this machine.
#
# Everything else Gutcheck does describes the machine as it is now. This describes what it
# has been doing, which is usually the Check that answers "it crashes sometimes" - a
# complaint no reading taken at rest can address.
#
# Every selection here is by provider name and numeric event id, never by message text. A
# German Windows returns German messages and invariant ids, and the entire estate this was
# built for is German. Payload fields are read positionally, the way the Windows event
# schema defines them, for the same reason.

# How far back a Run looks. A Check Definition parameter, because a Customer with a machine
# that misbehaves monthly and one that misbehaves hourly want different windows.
$script:StabilityDefaultDays = 30

# Which providers and ids make up each scan. Data rather than code so the selection can be
# read at a glance and so a provider that was renamed is one line to fix.
$script:StabilityScans = @(
    @{ Key = 'UnexpectedShutdowns'; Tag = 'Unexpected shutdown'; Log = 'System'
       Provider = @('Microsoft-Windows-Kernel-Power'); Id = @(41) }
    @{ Key = 'BlueScreenEvents';    Tag = 'Blue screen';         Log = 'System'
       Provider = @('Microsoft-Windows-WER-SystemErrorReporting'); Id = @(1001) }
    @{ Key = 'HardwareErrors';      Tag = 'WHEA hardware error'; Log = 'System'
       Provider = @('Microsoft-Windows-WHEA-Logger'); Id = @() }
    @{ Key = 'FirmwareThrottles';   Tag = 'Firmware CPU limit';  Log = 'System'
       Provider = @('Microsoft-Windows-Kernel-Processor-Power'); Id = @(37) }
    @{ Key = 'DiskDeviceErrors';    Tag = 'Disk / file system';  Log = 'System'
       Provider = @('disk', 'stornvme', 'storahci', 'iaStorAC', 'iaStorAVC', 'iaStorVD')
       Id = @(7, 11, 51, 129, 153) }
    @{ Key = 'NtfsErrors';          Tag = 'Disk / file system';  Log = 'System'
       Provider = @('Microsoft-Windows-Ntfs', 'Ntfs'); Id = @(55, 98) }
    @{ Key = 'DisplayResets';       Tag = 'Display driver reset'; Log = 'System'
       Provider = @('Display'); Id = @(4101) }
    @{ Key = 'ResourceExhaustion';  Tag = 'Low memory';          Log = 'System'
       Provider = @('Microsoft-Windows-Resource-Exhaustion-Detector'); Id = @(2004) }
    @{ Key = 'ServiceFailureEvents'; Tag = 'Service crash';      Log = 'System'
       Provider = @('Service Control Manager'); Id = @(7031, 7034) }
    @{ Key = 'CrashEvents';         Tag = 'Application crash';   Log = 'Application'
       Provider = @('Application Error'); Id = @(1000) }
    @{ Key = 'HangEvents';          Tag = 'Application hang';    Log = 'Application'
       Provider = @('Application Hang'); Id = @(1002) }
    @{ Key = 'RuntimeFaultEvents';  Tag = '.NET crash';          Log = 'Application'
       Provider = @('.NET Runtime'); Id = @(1026) }
    # Gathered but deliberately untagged: Windows Error Reporting 1001 covers every report
    # WER has ever filed, thousands on an ordinary machine, and only the LiveKernelEvent
    # entries are a Finding. Tagging the lot would bury the blue screens in events.csv
    # under the noise a Technician opened the file to see past.
    @{ Key = 'ErrorReports';        Tag = '';                    Log = 'Application'
       Provider = @('Windows Error Reporting'); Id = @(1001) }
)

function Get-StabilityData {
    <#
    .SYNOPSIS
        Reads the event logs and returns what is in them. Judges none of it.
    .DESCRIPTION
        A log this Run's rights could not open is recorded by name rather than swallowed,
        because a Technician must be able to tell an empty log from a log nobody read.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $days  = [int](Get-Parameter $Parameters 'Days' $script:StabilityDefaultDays)
    $since = (Get-Date).AddDays(-$days)

    $unreadable = @{}
    $gathered   = @{}
    # Not List[object]: @($x) throws on one of those. See Public/Invoke-Gutcheck.ps1.
    $events     = New-Object System.Collections.Generic.List[psobject]

    foreach ($scan in $script:StabilityScans) {
        $found = @(Get-StabilityEvent -Log $scan.Log -Provider $scan.Provider -Id $scan.Id `
                                      -Since $since -Unreadable $unreadable)
        $gathered[$scan.Key] = $found
        if ($scan.Tag) {
            foreach ($entry in $found) { $events.Add((ConvertTo-EventRow -LogEntry $entry -Tag $scan.Tag)) }
        }
    }

    # The driver hangs Windows recovered from, picked out of everything WER reported.
    $liveKernel = @($gathered['ErrorReports'] | Where-Object {
        @($_.Properties).Count -gt 2 -and "$($_.Properties[2].Value)" -match 'LiveKernelEvent'
    })
    foreach ($entry in $liveKernel) { $events.Add((ConvertTo-EventRow -LogEntry $entry -Tag 'LiveKernelEvent')) }

    # Materialised before it is sorted: piping a hashtable's live KeyCollection straight
    # into Sort-Object throws inside the enumerable binder when the hashtable is empty,
    # which is exactly the case a clean machine produces.
    $unreadableLogs = @($unreadable.Keys)

    $reliability = $null
    try {
        $metric = Get-CimInstance Win32_ReliabilityStabilityMetrics -ErrorAction Stop |
            Sort-Object TimeGenerated -Descending | Select-Object -First 1
        if ($metric) { $reliability = [math]::Round($metric.SystemStabilityIndex, 1) }
    }
    catch { }

    [pscustomobject]@{
        PSTypeName          = 'Gutcheck.Data.Stability'
        Days                = $days
        UnexpectedShutdowns = @($gathered['UnexpectedShutdowns'] | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        BlueScreens         = @($gathered['BlueScreenEvents']    | ForEach-Object { ConvertTo-BlueScreenRow -LogEntry $_ })
        HardwareErrors      = @($gathered['HardwareErrors']      | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        FirmwareThrottles   = @($gathered['FirmwareThrottles']   | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        DiskErrors          = @(@($gathered['DiskDeviceErrors']) + @($gathered['NtfsErrors']) |
                                Where-Object { $_ } | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        DisplayResets       = @($gathered['DisplayResets']       | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        ResourceExhaustion  = @($gathered['ResourceExhaustion']  | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        Crashes             = @($gathered['CrashEvents']         | ForEach-Object { ConvertTo-CrashRow -LogEntry $_ })
        Hangs               = @($gathered['HangEvents']          | ForEach-Object { ConvertTo-HangRow -LogEntry $_ })
        RuntimeFaults       = @($gathered['RuntimeFaultEvents']  | ForEach-Object { ConvertTo-RuntimeFaultRow -LogEntry $_ })
        LiveKernelEvents    = @($liveKernel                      | ForEach-Object { ConvertTo-EventRow -LogEntry $_ })
        ServiceFailures     = @($gathered['ServiceFailureEvents'] | ForEach-Object { ConvertTo-ServiceFailureRow -LogEntry $_ })
        ReliabilityIndex    = $reliability
        UnreadableLogs      = @($unreadableLogs | Sort-Object)
        Events              = @($events)
    }
}

function Get-StabilityEvent {
    <#
    .SYNOPSIS
        Reads one provider-and-id selection out of one log. Records a log it cannot open.
    .DESCRIPTION
        Get-WinEvent throws rather than returning nothing when a filter matches no events,
        which is indistinguishable from a real failure by its type alone. A log that was
        refused is recorded by name so the Judge can say so; a log that simply held nothing
        is not, because that is an answer rather than a gap.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Log,
        [AllowEmptyCollection()][string[]]$Provider = @(),
        [AllowEmptyCollection()][int[]]$Id = @(),
        [Parameter(Mandatory)][datetime]$Since,
        [Parameter(Mandatory)][hashtable]$Unreadable
    )

    if (-not (Test-EventLogReadable -Log $Log -Unreadable $Unreadable)) { return }

    $result   = @()
    $searches = @(if ($Provider.Count) { $Provider } else { $null })

    foreach ($name in $searches) {
        $filter = @{ LogName = $Log; StartTime = $Since }
        if ($name)     { $filter.ProviderName = $name }
        if ($Id.Count) { $filter.Id = $Id }

        try { $result += @(Get-WinEvent -FilterHashtable $filter -ErrorAction Stop) }
        catch {
            if (Test-AccessDenied -ErrorRecord $_) { $Unreadable[$Log] = $true }
            # Anything else is "no events matched", which Get-WinEvent also raises as an
            # error. Reporting that as a gap would make every clean machine look unread.
        }
    }
    $result
}

function Test-EventLogReadable {
    <#
    .SYNOPSIS
        Whether this Run's rights may read a log. Records a log it may not.
    .DESCRIPTION
        Asked by name, before any filtered query, because a filtered query never says it
        was refused: Get-WinEvent -FilterHashtable drops a log the caller may not read and
        reports that no events matched - exactly what an empty log reports. Only reading
        the log by name raises the refusal. Without this, a Run without admin rights reads
        Diagnostics-Performance or the Group Policy log as a machine with nothing in it.
 
        Asked once per log per Gatherer: the answer is recorded in $Unreadable, and a log
        already recorded there is not asked about again.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$Log,
        [Parameter(Mandatory)][hashtable]$Unreadable
    )

    if ($Unreadable.ContainsKey($Log)) { return $false }
    try { $null = Get-WinEvent -LogName $Log -MaxEvents 1 -ErrorAction Stop }
    catch {
        # An empty log raises "no events" here too; only a refusal makes it unreadable.
        if (Test-AccessDenied -ErrorRecord $_) { $Unreadable[$Log] = $true; return $false }
    }
    $true
}

function Test-AccessDenied {
    <#
    .SYNOPSIS
        Whether an error record says the caller was not allowed, rather than not lucky.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)]$ErrorRecord)

    $exception = $ErrorRecord.Exception
    while ($exception) {
        if ($exception -is [UnauthorizedAccessException]) { return $true }
        $exception = $exception.InnerException
    }

    # The message is matched only as a fallback, and in both languages the estate runs,
    # because the typed exception is not always what surfaces.
    "$($ErrorRecord.FullyQualifiedErrorId) $($ErrorRecord.Exception.Message)" -match
        'Unauthorized|unauthori|Zugriff|nicht autorisiert'
}

function ConvertTo-EventRow {
    <#
    .SYNOPSIS
        One event, reduced to what a Report and a Technician need from it.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry, [string]$Tag)

    $message = "$($LogEntry.Message)"
    if ($message) {
        $message = $message -replace '\s+', ' '
        if ($message.Length -gt 500) { $message = $message.Substring(0, 500) }
    }

    $row = [ordered]@{
        Time     = $LogEntry.TimeCreated
        Provider = "$($LogEntry.ProviderName)"
        Id       = $LogEntry.Id
        Message  = $message
    }
    # Tagged rows are the ones bound for events.csv, where the tag is the column that lets
    # a Technician find the blue screens among four hundred lines.
    if ($Tag) { $row.Insert(1, 'Category', $Tag) }
    [pscustomobject]$row
}

function Get-EventPayload {
    <#
    .SYNOPSIS
        One positional field out of an event's payload, or $null when it is not there.
    .DESCRIPTION
        Positional because the names are localised and the positions are not. Bounds are
        checked because a real machine logs events with short payloads, and a Judge handed
        a row built by blind indexing would throw on data Windows considers valid.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry, [Parameter(Mandatory)][int]$Index)

    $properties = @($LogEntry.Properties)
    if ($Index -lt 0 -or $Index -ge $properties.Count) { return $null }
    "$($properties[$Index].Value)"
}

function ConvertTo-BlueScreenRow {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)

    # The bugcheck line reads "0x00000133 (0x0000...)"; only the stop code is the lead.
    $code = Get-EventPayload -LogEntry $LogEntry -Index 0
    if ($code) { $code = $code -replace '\s*\(.*$', '' }

    [pscustomobject]@{ Time = $LogEntry.TimeCreated; StopCode = $code }
}

function ConvertTo-CrashRow {
    <#
    .SYNOPSIS
        An Application Error 1000 payload, in the order the schema defines it.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)

    [pscustomobject]@{
        Time      = $LogEntry.TimeCreated
        App       = Get-EventPayload -LogEntry $LogEntry -Index 0
        Version   = Get-EventPayload -LogEntry $LogEntry -Index 1
        Module    = Get-EventPayload -LogEntry $LogEntry -Index 3
        ModuleVer = Get-EventPayload -LogEntry $LogEntry -Index 4
        Exception = Get-EventPayload -LogEntry $LogEntry -Index 6
        AppPath   = Get-EventPayload -LogEntry $LogEntry -Index 10
    }
}

function ConvertTo-HangRow {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)
    [pscustomobject]@{ Time = $LogEntry.TimeCreated; App = Get-EventPayload -LogEntry $LogEntry -Index 0 }
}

function ConvertTo-RuntimeFaultRow {
    <#
    .SYNOPSIS
        A .NET Runtime 1026, whose payload is one blob of text rather than named fields.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)

    # The executable is the only thing in it a later Check can match on, and it is only
    # ever found by pattern. A message that does not name one leaves the App absent.
    $app = $null
    if ("$($LogEntry.Message)" -match '([^\\/\s:]+\.exe)') { $app = $Matches[1] }
    [pscustomobject]@{ Time = $LogEntry.TimeCreated; App = $app }
}

function ConvertTo-ServiceFailureRow {
    [CmdletBinding()]
    param([Parameter(Mandatory)]$LogEntry)
    [pscustomobject]@{ Time = $LogEntry.TimeCreated; Service = Get-EventPayload -LogEntry $LogEntry -Index 0 }
}

function ConvertTo-StabilityFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    $days = Get-DataProperty $Data 'Days'
    if ($null -eq $days) { $days = $script:StabilityDefaultDays }

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'UnexpectedShutdowns' `
        -Check (Get-Text 'Check.Stability.UnexpectedShutdownsKernelPower') -WarnParameter 'UnexpectedShutdownWarnAbove' `
        -FailParameter 'UnexpectedShutdownFailAbove' -WarnAbove 0 -FailAbove 2 `
        -Hint (Get-Text 'Hint.Stability.FreezesHardResetsPowerLoss')

    New-BlueScreenFinding -Data $Data -Parameters $Parameters

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'HardwareErrors' `
        -Check (Get-Text 'Check.Stability.WHEAHardwareErrors') -WarnParameter 'HardwareErrorWarnAbove' `
        -FailParameter 'HardwareErrorFailAbove' -WarnAbove 0 -FailAbove 10 `
        -Hint (Get-Text 'Hint.Stability.CPURAMPCIeErrorsReported')

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'FirmwareThrottles' `
        -Check (Get-Text 'Check.Stability.CPUSpeedLimitedBy') -WarnParameter 'FirmwareThrottleWarnAbove' `
        -FailParameter 'FirmwareThrottleFailAbove' -WarnAbove 0 -FailAbove ([double]::MaxValue) `
        -Hint (Get-Text 'Hint.Stability.ThermalPowerThrottlingHappenedIn')

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'DiskErrors' `
        -Check (Get-Text 'Check.Stability.DiskControllerNTFSErrors') -WarnParameter 'DiskErrorWarnAbove' `
        -FailParameter 'DiskErrorFailAbove' -WarnAbove 0 -FailAbove 5 `
        -Hint (Get-Text 'Hint.Stability.DiskResetsBadBlocksOr')

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'DisplayResets' `
        -Check (Get-Text 'Check.Stability.GraphicsDriverResetsTDR') -WarnParameter 'DisplayResetWarnAbove' `
        -FailParameter 'DisplayResetFailAbove' -WarnAbove 0 -FailAbove ([double]::MaxValue) `
        -Hint (Get-Text 'Hint.Stability.UpdateGraphicsDriverVendorOEM')

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'ResourceExhaustion' `
        -Check (Get-Text 'Check.Stability.LowVirtualMemoryWarnings') -WarnParameter 'ResourceExhaustionWarnAbove' `
        -FailParameter 'ResourceExhaustionFailAbove' -WarnAbove 0 -FailAbove 0 `
        -Hint (Get-Text 'Hint.Stability.RAMPageFileExhaustedPrograms')

    New-ApplicationCrashFinding -Data $Data -Parameters $Parameters

    New-EventCountFinding -Data $Data -Parameters $Parameters -Property 'LiveKernelEvents' `
        -Check (Get-Text 'Check.Stability.LiveKernelEventsDriver') -WarnParameter 'LiveKernelEventWarnAbove' `
        -FailParameter 'LiveKernelEventFailAbove' -WarnAbove 0 -FailAbove ([double]::MaxValue) `
        -Hint (Get-Text 'Hint.Stability.DriverHangsWindowsRecoveredFrom')

    New-ServiceFailureFinding  -Data $Data -Parameters $Parameters
    New-ReliabilityFinding     -Data $Data -Parameters $Parameters
    New-UnreadableLogFinding   -Data $Data -Parameters $Parameters
}

function New-EventCountFinding {
    <#
    .SYNOPSIS
        The Finding shape shared by every scan judged purely on how many events there were.
    .DESCRIPTION
        Eight of these Checks differ only in their name, their thresholds and their Hint.
        Writing them out eight times would make eight places for a threshold to stop being
        a parameter, which is the thing this rebuild exists to prevent.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data, [hashtable]$Parameters,
        [Parameter(Mandatory)][string]$Property,
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][string]$WarnParameter,
        [Parameter(Mandatory)][string]$FailParameter,
        [Parameter(Mandatory)][double]$WarnAbove,
        [Parameter(Mandatory)][double]$FailAbove,
        [string]$Hint = ''
    )

    $warn  = Get-Parameter $Parameters $WarnParameter $WarnAbove
    $fail  = Get-Parameter $Parameters $FailParameter $FailAbove
    $count = (Get-DataCollection $Data $Property).Count

    New-Finding -Category Stability -Check $Check -Severity (Get-Severity $count $warn $fail) `
        -Value $count -Hint $Hint
}

function New-BlueScreenFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # Any blue screen at all is a FAIL, and always has been: one is a machine that stopped.
    $warn = Get-Parameter $Parameters 'BlueScreenWarnAbove' 0
    $fail = Get-Parameter $Parameters 'BlueScreenFailAbove' 0

    $screens = (Get-DataCollection $Data 'BlueScreens')

    $value = $screens.Count
    if ($screens.Count) {
        # The stop code is what a Technician looks up, so it leads rather than the count.
        $codes = $screens | Where-Object { $_.StopCode } | Group-Object StopCode |
            Sort-Object Count -Descending | ForEach-Object { '{0} x{1}' -f $_.Name, $_.Count }
        $value = '{0}: {1}' -f $screens.Count, ($codes -join ', ')
    }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.BlueScreensBugCheck') `
        -Severity (Get-Severity $screens.Count $warn $fail) -Value $value `
        -Hint (Get-Text 'Hint.Stability.LookUpTheStopCodes')
}

function New-ApplicationCrashFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'ApplicationCrashWarnAbove' 5
    $fail = Get-Parameter $Parameters 'ApplicationCrashFailAbove' 20

    $crashes = (Get-DataCollection $Data 'Crashes')

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ApplicationCrashesAllApps') `
        -Severity (Get-Severity $crashes.Count $warn $fail) -Value $crashes.Count `
        -Hint (Get-Text 'Hint.Stability.CrashingApplicationsSeeTheSection')
}

function New-ServiceFailureFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'ServiceFailureWarnAbove' 10

    $failures = (Get-DataCollection $Data 'ServiceFailures')
    if (-not $failures.Count) {
        return New-Finding -Category Stability -Check (Get-Text 'Check.Stability.CrashedServices') -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $named = $failures | Where-Object { $_.Service } | Group-Object Service |
        Sort-Object Count -Descending | Select-Object -First 5 |
        ForEach-Object { '{0} x{1}' -f $_.Name, $_.Count }

    # A handful of service restarts is ordinary on Windows and the script never failed on
    # them; only an unusual number is worth a Technician's attention.
    $severity = 'INFO'
    if ($failures.Count -gt $warn) { $severity = 'WARN' }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.CrashedServices') -Severity $severity `
        -Value ($named -join ', ') `
        -Hint (Get-Text 'Hint.Stability.AServiceThatKeepsDying')
}

function New-ReliabilityFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warnBelow = Get-Parameter $Parameters 'ReliabilityIndexWarnBelow' 6
    $failBelow = Get-Parameter $Parameters 'ReliabilityIndexFailBelow' 3

    $index = ConvertTo-Number (Get-DataProperty $Data 'ReliabilityIndex')
    if ($null -eq $index) {
        # Absent on a machine whose Reliability Monitor task has never run, which says
        # nothing about how stable the machine is.
        return New-UnavailableFinding -Category Stability -Check (Get-Text 'Check.Stability.ReliabilityIndex110') `
            -Hint (Get-Text 'Hint.Stability.WindowsHasNotComputedA')
    }

    New-Finding -Category Stability -Check (Get-Text 'Check.Stability.ReliabilityIndex110') `
        -Severity (Get-SeverityBelow $index $warnBelow $failBelow) -Value $index `
        -Hint (Get-Text 'Hint.Stability.OpenPerfmonRelForThe')
}

function New-UnreadableLogFinding {
    <#
    .SYNOPSIS
        One Finding per log this Run's rights could not open.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    foreach ($log in (Get-DataCollection $Data 'UnreadableLogs')) {
        New-Finding -Category Access -Check ((Get-Text 'Check.Stability.EventLog') -f $log) -Severity INFO `
            -Value (Get-Text 'Value.Stability.NotReadableWithRights') `
            -Hint (Get-Text 'Hint.Stability.NeedsAdminRightsThisLog')
    }
}

function ConvertTo-StabilitySection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $crashes = (Get-DataCollection $Data 'Crashes')
    if ($crashes.Count) {
        New-Section -Title (Get-Text 'Title.Stability.ApplicationCrashesByApp') -Row @(
            $crashes | Group-Object App | Sort-Object Count -Descending | ForEach-Object {
                [pscustomobject]@{
                    Count = $_.Count
                    Name  = $_.Name
                    Last  = (@($_.Group | Sort-Object Time -Descending)[0]).Time
                    'Top faulting module / exception' = (
                        $_.Group | Group-Object { '{0} / {1}' -f $_.Module, $_.Exception } |
                            Sort-Object Count -Descending | Select-Object -First 3 |
                            ForEach-Object { '{0} x{1}' -f $_.Name, $_.Count }
                    ) -join '; '
                }
            }
        )
    }

    $hangs = (Get-DataCollection $Data 'Hangs')
    if ($hangs.Count) {
        New-Section -Title (Get-Text 'Title.Stability.ApplicationHangsByApp') -Row @(
            $hangs | Group-Object App | Sort-Object Count -Descending |
                Select-Object Count, Name
        )
    }
}

function ConvertTo-StabilityEvent {
    <#
    .SYNOPSIS
        The events this Check gathered, for the Report's events.csv.
    .DESCRIPTION
        The optional fourth half of the Kind interface. A Kind that reads events returns
        them here and the Run collects them; nothing is written to shared state and no
        later Check reaches back into this one.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    (Get-DataCollection $Data 'Events')
}