Private/Kinds/Companion.ps1

# The Companion Kind: a helper program that lives off another program, and what that
# other program was doing each time the helper crashed.
#
# A telephony client ships a small program that reads contacts out of Outlook. It crashes,
# the User sees an error window, and the crash list says a .NET exception in a component
# nobody has heard of. Read beside what Outlook did in the same seconds, it is one story:
# observed 2026-10-13 on a workstation, four crashes in thirty days - twice Outlook itself
# had crashed within nine seconds, once an Office update shut Outlook down four seconds
# later, once Outlook was starting. The helper does not survive its host going away. That
# is a fault of the helper and nothing set wrong on the machine, and it is not what makes
# Outlook crash - which is the sentence a Technician needs before taking Outlook apart.
#
# The crashes are the Stability Check's, copied across as the App Kind copies them. What
# the host did comes from two places: its own crashes, from the same rows, and the events
# a Check Definition names for "it was shut down" and "it started" - Windows logs neither
# for a program in general, but for Outlook the Restart Manager logs the one and Outlook
# itself the other.

# How far apart a crash of the helper and something the host did may lie to be read
# together. Observed: four to fourteen seconds.
$script:CompanionWindowSeconds = 60

function Get-CompanionSetting {
    # One field of an entry of a Check Definition, which arrives as ConvertFrom-Json made
    # it or as a hashtable. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Entry, [Parameter(Mandatory)][string]$Name)

    if ($null -eq $Entry) { return $null }
    if ($Entry -is [System.Collections.IDictionary]) { if ($Entry.Contains($Name)) { return $Entry[$Name] } else { return $null } }
    $property = $Entry.PSObject.Properties[$Name]
    if ($property) { return $property.Value }
    $null
}

# What Get-CompanionData hands its Judge. See Private/DataShape.ps1.
$script:CompanionDataShape = @{
    App               = 'Value'
    Title             = 'Value'
    ProcessPattern    = 'Value'
    HostPattern       = 'Value'
    HostTitle         = 'Value'
    StabilityObserved = 'Answered'
    ObservedCrashes   = 'List'
    ObservedFaults    = 'List'
    Days              = 'Value'
    HostEvents        = 'List'
    HostEventsRead    = 'Answered'
}

function Get-CompanionData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [hashtable]$Parameters = @{},
        [AllowNull()][hashtable]$Observed = @{}
    )

    $stability = Get-ObservedKindData -Observed $Observed -Kind 'Stability'
    $days = ConvertTo-Number (Get-DataProperty $stability 'Days')
    if ($null -eq $days -or $days -le 0) { $days = 30 }
    $since = (Get-Date).AddDays(-$days)

    # What the host did, as the Check Definition says where Windows logs it. Kept as
    # what it means and when; the text of an event does not leave the log.
    $hostEvents = New-Object 'System.Collections.Generic.List[psobject]'
    $read = $true
    foreach ($entry in @(Get-Parameter $Parameters 'HostEvents' @())) {
        $provider = "$(Get-CompanionSetting $entry 'Provider')".Trim()
        $ids      = @(@(Get-CompanionSetting $entry 'Id') | ForEach-Object { ConvertTo-Number $_ } | Where-Object { $null -ne $_ } | ForEach-Object { [int]$_ })
        $means    = "$(Get-CompanionSetting $entry 'Means')".Trim()
        if (-not $provider -or -not $ids.Count -or -not $means) { continue }
        $log = "$(Get-CompanionSetting $entry 'Log')".Trim()
        if (-not $log) { $log = 'Application' }
        $match = "$(Get-CompanionSetting $entry 'Match')".Trim()
        try {
            $found = @(Get-WinEvent -FilterHashtable @{ LogName = $log; ProviderName = $provider; Id = $ids; StartTime = $since } -ErrorAction Stop)
            foreach ($one in $found) {
                if ($match) { try { if ("$($one.Message)" -notmatch $match) { continue } } catch { continue } }
                $hostEvents.Add([pscustomobject]@{ Time = $one.TimeCreated; Means = $means })
            }
        }
        catch {
            # "No events" is an answer. Anything else is a log that was not read.
            if ("$($_.FullyQualifiedErrorId)" -notmatch 'NoMatchingEventsFound') { $read = $false }
        }
    }

    [pscustomobject]@{
        PSTypeName        = 'Gutcheck.Data.Companion'
        App               = "$(Get-Parameter $Parameters 'App' '')"
        Title             = "$(Get-Parameter $Parameters 'Title' '')"
        ProcessPattern    = "$(Get-Parameter $Parameters 'Process' '')"
        HostPattern       = "$(Get-Parameter $Parameters 'Host' '')"
        HostTitle         = "$(Get-Parameter $Parameters 'HostTitle' '')"
        # Absent when the Stability Check did not run: not the same fact as no crash.
        StabilityObserved = $null -ne $stability
        ObservedCrashes   = Get-DataCollection $stability 'Crashes'
        ObservedFaults    = Get-DataCollection $stability 'RuntimeFaults'
        Days              = $days
        HostEvents        = @($hostEvents)
        HostEventsRead    = $read
    }
}

function Get-CompanionIncident {
    <#
    .SYNOPSIS
        Each crash of the helper with what its host did in the same seconds. Pure.
    .DESCRIPTION
        One of four: the host crashed too, the host was shut down, the host was starting,
        or nothing of the host was seen. The first that applies, in that order - a host
        that crashed and was then restarted by an update crashed.
    #>

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

    $window = ConvertTo-Number (Get-Parameter $Parameters 'WindowSeconds' $script:CompanionWindowSeconds)
    if ($null -eq $window -or $window -le 0) { $window = $script:CompanionWindowSeconds }

    $crashes = Get-DataCollection $Data 'ObservedCrashes'
    $faults  = Get-DataCollection $Data 'ObservedFaults'
    $mine = @(ConvertTo-CrashIncident `
            -Crash (Select-AppRow -Row $crashes -Pattern "$(Get-DataProperty $Data 'ProcessPattern')") `
            -RuntimeFault (Select-AppRow -Row $faults -Pattern "$(Get-DataProperty $Data 'ProcessPattern')") -Hang @() |
        Where-Object { $_.CrashEvents -gt 0 -and $null -ne $_.Time })
    $hosts = @(ConvertTo-CrashIncident `
            -Crash (Select-AppRow -Row $crashes -Pattern "$(Get-DataProperty $Data 'HostPattern')") `
            -RuntimeFault (Select-AppRow -Row $faults -Pattern "$(Get-DataProperty $Data 'HostPattern')") -Hang @() |
        Where-Object { $_.CrashEvents -gt 0 -and $null -ne $_.Time } | ForEach-Object { $_.Time })
    $events = @((Get-DataCollection $Data 'HostEvents') | ForEach-Object {
        [pscustomobject]@{ Time = ConvertTo-DataTime (Get-DataProperty $_ 'Time'); Means = "$(Get-DataProperty $_ 'Means')" } } | Where-Object { $null -ne $_.Time })

    $near = { param($At, $Times) [bool]@($Times | Where-Object { [math]::Abs(($_ - $At).TotalSeconds) -le $window }).Count }
    foreach ($one in $mine) {
        $with = 'Alone'
        if     (& $near $one.Time $hosts) { $with = 'HostCrashed' }
        elseif (& $near $one.Time @($events | Where-Object { $_.Means -eq 'stopped' } | ForEach-Object { $_.Time })) { $with = 'HostStopped' }
        elseif (& $near $one.Time @($events | Where-Object { $_.Means -eq 'started' } | ForEach-Object { $_.Time })) { $with = 'HostStarted' }
        [pscustomobject]@{
            Time           = $one.Time
            With           = $with
            ExceptionTypes = @($one.ExceptionTypes)
            Method         = "$($one.Method)"
            Exception      = "$($one.Exception)"
            Module         = "$($one.Module)"
            Version        = "$($one.Version)"
        }
    }
}

function Get-CompanionWithText {
    # What the host did, as the Report says it. Pure.
    [CmdletBinding()]
    param([AllowNull()][string]$With, [AllowNull()][string]$HostTitle)

    switch ("$With") {
        'HostCrashed' { return (Get-Text 'Value.Companion.With.HostCrashed') -f $HostTitle }
        'HostStopped' { return (Get-Text 'Value.Companion.With.HostStopped') -f $HostTitle }
        'HostStarted' { return (Get-Text 'Value.Companion.With.HostStarted') -f $HostTitle }
    }
    (Get-Text 'Value.Companion.With.Alone') -f $HostTitle
}

function Get-CompanionName {
    # What the helper and its host are called, with what stands in where a Check
    # Definition names neither. Pure.
    [CmdletBinding()]
    param([AllowNull()]$Data)

    $title = "$(Get-DataProperty $Data 'Title')".Trim()
    if (-not $title) { $title = "$(Get-DataProperty $Data 'ProcessPattern')".Trim() }
    $hostTitle = "$(Get-DataProperty $Data 'HostTitle')".Trim()
    if (-not $hostTitle) { $hostTitle = "$(Get-DataProperty $Data 'HostPattern')".Trim() }
    [pscustomobject]@{ Title = $title; Host = $hostTitle }
}

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

    $name  = Get-CompanionName -Data $Data
    $check = (Get-Text 'Check.Companion.Crashes') -f $name.Title
    if (-not (Get-DataProperty $Data 'StabilityObserved')) {
        return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.App.NoEventDataReachedThis')
    }

    $days = Get-DataProperty $Data 'Days'
    $incidents = @(Get-CompanionIncident -Data $Data -Parameters $Parameters | Sort-Object Time -Descending)
    if (-not $incidents.Count) {
        return New-Finding -Category Apps -Check $check -Severity OK -Value ((Get-Text 'Value.App.NoneLoggedInDays') -f $days)
    }

    $warnFrom = ConvertTo-Number (Get-Parameter $Parameters 'CrashWarnFrom' 3)
    if ($null -eq $warnFrom) { $warnFrom = 3 }

    $value = @((Get-Text 'Value.Companion.Crashes') -f $incidents.Count, $days, $incidents[0].Time)
    # In the order of how much each explains, and only what happened.
    $kinds = @('HostCrashed', 'HostStopped', 'HostStarted', 'Alone' | ForEach-Object {
        $kind = $_
        $count = @($incidents | Where-Object { $_.With -eq $kind }).Count
        if ($count) { '{0} {1}x' -f (Get-CompanionWithText -With $kind -HostTitle $name.Host), $count }
    })
    $value += (Get-Text 'Value.Companion.When') -f ($kinds -join '; ')
    if ((Get-DataProperty $Data 'HostEventsRead') -eq $false) { $value += (Get-Text 'Value.Companion.HostEventsNotRead') -f $name.Host }

    # What it died of: the exception and where it was thrown, each once with its count.
    $causes = @($incidents | ForEach-Object {
        $type = @($_.ExceptionTypes | Where-Object { $_ })[0]
        if ($type -and $_.Method) { (Get-Text 'Value.Companion.Cause') -f $type, $_.Method }
        elseif ($type)            { "$type" }
        elseif ($_.Module)        { (Get-Text 'Value.Companion.CauseModule') -f $_.Module, (Get-ExceptionCodeText $_.Exception) }
    } | Where-Object { $_ } | Group-Object | Sort-Object Count -Descending | Select-Object -First 4 | ForEach-Object { '{0} {1}x' -f $_.Name, $_.Count })
    if ($causes.Count) { $value += (Get-Text 'Value.Companion.Causes') -f ($causes -join '; ') }

    $alone = @($incidents | Where-Object { $_.With -eq 'Alone' }).Count
    $tied  = $incidents.Count - $alone
    $severity = 'INFO'
    if ($alone -or $incidents.Count -ge $warnFrom) { $severity = 'WARN' }

    $app = "$(Get-DataProperty $Data 'App')".Trim()
    if (-not $app) { $app = $name.Title }
    $section = (Get-Text 'Title.Companion.Incidents') -f $name.Title
    if ($tied -ge $alone) {
        $meaning = (Get-Text 'Meaning.Companion.Tied') -f $name.Title, $name.Host
        $hint    = (Get-Text 'Hint.Companion.Tied') -f $section, $app, $name.Host
    }
    else {
        $meaning = (Get-Text 'Meaning.Companion.Alone') -f $name.Title, $name.Host
        $hint    = (Get-Text 'Hint.Companion.Alone') -f $section, $app
    }
    New-Finding -Category Apps -Check $check -Severity $severity -Value ($value -join ' | ') -Meaning $meaning -Hint $hint `
        -LastSeen $incidents[0].Time
}

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

    $incidents = @(Get-CompanionIncident -Data $Data | Sort-Object Time -Descending)
    if (-not $incidents.Count) { return }
    $name = Get-CompanionName -Data $Data
    New-Section -Title ((Get-Text 'Title.Companion.Incidents') -f $name.Title) -Help (Get-Text 'Help.Companion.Incidents') -Row @(
        $incidents | Select-Object -First 30 | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.App.Time')]           = $_.Time
            $row[(Get-Text 'Column.Companion.With')]     = Get-CompanionWithText -With $_.With -HostTitle $name.Host
            $row[(Get-Text 'Column.Companion.Type')]     = @($_.ExceptionTypes | Where-Object { $_ }) -join ', '
            $row[(Get-Text 'Column.Companion.Method')]   = $_.Method
            $row[(Get-Text 'Column.App.Module')]         = $_.Module
            # The code with what it means behind it: a code is never shown bare.
            $row[(Get-Text 'Column.App.Exception')]      = Get-ExceptionCodeText $_.Exception
            $row[(Get-Text 'Column.App.Version')]        = $_.Version
            [pscustomobject]$row
        }
    )
}