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 } ) } |