Private/Kinds/StabilityIncident.ps1

# Crash incidents: a part of the Stability Kind, and no Kind.
#
# One program dying writes several events - the .NET runtime's, Windows Error Reporting's,
# the Application Error - and a hang its own. Here they become the one incident they were,
# which every crash Finding counts.

# How many seconds apart the events of one program may be and still report one crash. The
# .NET runtime logs its 1026 about a second before Windows logs the 1000 for the same
# death; ten seconds leaves room for a slow machine and none for a second start.
$script:StabilityIncidentWindowSeconds = 10

function Get-IncidentWindowSetting {
    <#
    .SYNOPSIS
        How many seconds apart two events of one program may be and still be one crash, as
        the Check Definition says or as the Kind does unasked. Pure.
    #>

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

    $window = Get-NumberParameter $Parameters 'IncidentWindowSeconds' $script:StabilityIncidentWindowSeconds
    if ($window -lt 0) { return [double]$script:StabilityIncidentWindowSeconds }
    $window
}

# A time that arrives as text is read by ConvertTo-DataTime (Private/Data.ps1), which
# began here as this Kind's own and is every Kind's now: never by a free parse.
function ConvertTo-CrashIncident {
    <#
    .SYNOPSIS
        Merges the event rows of the Application log into the crashes behind them. Pure.
    .DESCRIPTION
        One crash is logged up to three times: by the .NET runtime (1026), by Windows
        (Application Error 1000) and, when the program stopped answering first, as an
        Application Hang 1002. Events of one program that lie within the window are one
        incident; an event with no partner is an incident on its own.
 
        The window is measured from the first event of an incident, not from the one
        before: a program that a service restarts every eight seconds crashes many times,
        and a chain of events must not make one incident of an afternoon.
 
        What an incident says comes from the 1000 where there is one - version, exception
        code, faulting module, program path - and from the 1026 for the exception types and
        the method. What no event of the incident said is an empty string, or an empty
        list, so that whoever reads the row need not ask first whether it is there.
 
        Whether a path is on the network is not decided here: the Gatherer asked the
        machine and hands over the paths that are.
 
        An event without a program name or without a time cannot be placed beside any
        other, and is an incident on its own.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()][AllowEmptyCollection()]$Crash,
        [AllowNull()][AllowEmptyCollection()]$RuntimeFault,
        [AllowNull()][AllowEmptyCollection()]$Hang,
        # Untyped: it may come from a Check Definition, and "x" must not throw here.
        $WindowSeconds = $script:StabilityIncidentWindowSeconds,
        [AllowNull()][AllowEmptyCollection()][string[]]$RemotePath = @()
    )

    $WindowSeconds = Get-IncidentWindowSetting -Parameters @{ IncidentWindowSeconds = $WindowSeconds }

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

    $sources = @(
        @{ Kind = 'Crash'; Rows = $Crash }
        @{ Kind = 'Fault'; Rows = $RuntimeFault }
        @{ Kind = 'Hang';  Rows = $Hang }
    )
    foreach ($source in $sources) {
        foreach ($row in @(@($source.Rows) | Where-Object { $null -ne $_ })) {
            $entry = [pscustomobject]@{
                Kind = $source.Kind
                Row  = $row
                Time = ConvertTo-DataTime (Get-DataProperty $row 'Time')
                App  = "$(Get-DataProperty $row 'App')".Trim()
            }
            if (-not $entry.App -or $null -eq $entry.Time) {
                $groups.Add([pscustomobject]@{ Start = $entry.Time; Members = @($entry) })
                continue
            }
            # Windows writes OUTLOOK.EXE in one event and outlook.exe in the next.
            $key = $entry.App.ToLowerInvariant()
            if (-not $placed.ContainsKey($key)) { $placed[$key] = [System.Collections.Generic.List[psobject]]::new() }
            $placed[$key].Add($entry)
        }
    }

    foreach ($key in @($placed.Keys)) {
        $current = $null
        foreach ($entry in @($placed[$key] | Sort-Object Time)) {
            if ($null -ne $current -and ($entry.Time - $current.Start).TotalSeconds -le $WindowSeconds) {
                $current.Members = @($current.Members) + $entry
                continue
            }
            $current = [pscustomobject]@{ Start = $entry.Time; Members = @($entry) }
            $groups.Add($current)
        }
    }

    $incidents = foreach ($group in $groups) {
        $members = @($group.Members)
        $crashed = @($members | Where-Object { $_.Kind -eq 'Crash' } | ForEach-Object { $_.Row })
        $faulted = @($members | Where-Object { $_.Kind -eq 'Fault' } | ForEach-Object { $_.Row })
        $first   = $null
        if ($crashed.Count) { $first = $crashed[0] }

        $types  = [System.Collections.Generic.List[string]]::new()
        $method = ''
        foreach ($row in $faulted) {
            foreach ($type in (Get-DataCollection $row 'ExceptionTypes')) {
                if ("$type" -and -not $types.Contains("$type")) { $types.Add("$type") }
            }
            if (-not $method) { $method = "$(Get-DataProperty $row 'Method')" }
        }

        # The name as the Application Error wrote it, where there is one: that is the
        # event the path and the version come from.
        $app = $members[0].App
        if ($first -and "$(Get-DataProperty $first 'App')".Trim()) { $app = "$(Get-DataProperty $first 'App')".Trim() }

        $path = "$(Get-DataProperty $first 'AppPath')"

        [pscustomobject]@{
            Time           = $group.Start
            App            = $app
            Version        = "$(Get-DataProperty $first 'Version')"
            Exception      = "$(Get-DataProperty $first 'Exception')"
            Module         = "$(Get-DataProperty $first 'Module')"
            ModuleVer      = "$(Get-DataProperty $first 'ModuleVer')"
            AppPath        = $path
            Remote         = [bool]($path -and @($RemotePath) -contains $path)
            ExceptionTypes = [string[]]$types.ToArray()
            Method         = $method
            Hang           = [bool]@($members | Where-Object { $_.Kind -eq 'Hang' }).Count
            Events         = $members.Count
            # How many of the events say the program died: the 1000s and the 1026s. What
            # tells a crash from a hang, whatever else the events did or did not say.
            CrashEvents    = $crashed.Count + $faulted.Count
        }
    }

    # Newest first, as the log returns its events. One that has no time comes last.
    @($incidents | Sort-Object @{ Expression = { if ($null -ne $_.Time) { $_.Time } else { [datetime]::MinValue } } } -Descending)
}

function Get-StabilityIncident {
    <#
    .SYNOPSIS
        The incidents in gathered data, whether the Gatherer merged them or not. Pure.
    .DESCRIPTION
        Data gathered before incidents existed - an older fixture, an Elevated Part running
        an older module - carries the three lists of event rows and nothing else. The same
        merge is then done here, from the same rows. What it cannot know is whether a
        drive letter is mapped: that was never asked. A UNC path says so itself.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [AllowNull()]$Data,
        # Untyped: it may come from a Check Definition. See ConvertTo-CrashIncident.
        $WindowSeconds = $script:StabilityIncidentWindowSeconds
    )

    $carried = $false
    if ($Data -is [hashtable]) { $carried = $Data.ContainsKey('Incidents') }
    elseif ($null -ne $Data)   { $carried = [bool]$Data.PSObject.Properties['Incidents'] }
    if ($carried) { return , (Get-DataCollection $Data 'Incidents') }

    $crashes = (Get-DataCollection $Data 'Crashes')
    $shares  = @($crashes | ForEach-Object { "$(Get-DataProperty $_ 'AppPath')" } | Where-Object { $_ -match '^\\\\' } |
        Select-Object -Unique)

    , @(ConvertTo-CrashIncident -Crash $crashes -RuntimeFault (Get-DataCollection $Data 'RuntimeFaults') `
            -Hang (Get-DataCollection $Data 'Hangs') -WindowSeconds $WindowSeconds -RemotePath $shares)
}

function Select-CrashIncident {
    <#
    .SYNOPSIS
        The incidents in which a program died, leaving out those in which it only stopped
        answering. Pure.
    .DESCRIPTION
        A hang is the application stopping; a crash is the application dying. An incident
        that holds nothing but an Application Hang is listed with the others, and is not
        counted as a crash: Windows logs one each time somebody closes a window that was
        slow to answer.
 
        An incident that holds an Application Error 1000 or a .NET Runtime 1026 is a crash,
        whatever else it holds and however little those events said: a 1026 whose text
        named no exception type, beside a hang, is a program that died. The incident says
        how many such events it holds. One merged before incidents said so is read as it
        was then: a hang, and nothing a crash event would have left behind.
    #>

    [CmdletBinding()]
    [OutputType([object[]])]
    param([AllowNull()][AllowEmptyCollection()]$Incident)

    , @(@($Incident) | Where-Object { $null -ne $_ -and -not (Test-HangOnlyIncident -Incident $_) })
}

function Test-HangOnlyIncident {
    <#
    .SYNOPSIS
        Whether an incident is made of hangs alone. Pure. See Select-CrashIncident.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()]$Incident)

    if (-not [bool](Get-DataProperty $Incident 'Hang')) { return $false }

    $crashEvents = ConvertTo-Number (Get-DataProperty $Incident 'CrashEvents')
    if ($null -ne $crashEvents) { return ($crashEvents -le 0) }

    -not "$(Get-DataProperty $Incident 'Exception')" -and -not "$(Get-DataProperty $Incident 'Module')" -and
        -not "$(Get-DataProperty $Incident 'Method')" -and -not (Get-DataCollection $Incident 'ExceptionTypes').Count
}