Private/Kinds/BootPerformance.ps1

# The BootPerformance Kind: how long this machine takes to become usable.
#
# The number a Customer actually complains about, and the one Windows already measures for
# itself and never shows anyone. The list of what slowed the last boots down comes with it,
# because the duration alone tells a Technician there is a problem and not which one.

$script:BootPerformanceNeedsAdmin = $true

$script:BootPerformanceLog          = 'Microsoft-Windows-Diagnostics-Performance/Operational'
$script:BootPerformanceBootEventId  = 100
$script:BootPerformanceDegradationIds = @(101, 102, 103, 104, 105, 106, 107, 108, 109, 110)

# How many recent boots to average. One boot after a feature update is not this machine's
# normal, and a single reading would report it as though it were.
$script:BootPerformanceSampleCount = 5

# Where the fields are in each event's payload, as the provider's manifest orders them.
# Event 100 opens with BootTsVersion, BootStartTime, BootEndTime and two instance counters
# before BootTime, the duration in milliseconds. Events 101-110 open with StartTime and a
# length before Name, the file, and carry FriendlyName two fields later. Reading field 0
# of either gives a version number and a timestamp, which judged every machine as booting
# in two milliseconds.
$script:BootPerformanceBootTimeIndex     = 5
# What event 100 says the time was spent on, by position (manifest, versions 1 and 2):
# the way to the desktop and the settling after it, and within the first the User Profile
# and the shell; how many programs start with the user; and, in version 2 only, how long
# the machine stood at the sign-in screen, which is nobody's fault and is in the total.
$script:BootPerformanceMainPathIndex    = 6
$script:BootPerformanceProfileIndex     = 15
$script:BootPerformanceExplorerIndex    = 17
$script:BootPerformanceStartupAppsIndex = 18
$script:BootPerformancePostBootIndex    = 19
$script:BootPerformanceLogonWaitIndex   = 43
$script:BootPerformanceNameIndex         = 2
$script:BootPerformanceFriendlyNameIndex = 4

# What Get-BootPerformanceData hands its Judge. See Private/DataShape.ps1.
$script:BootPerformanceDataShape = @{
    Days          = 'Answered'
    Boots         = 'List'
    Degradations  = 'List'
    LogUnreadable = 'Value'
    IsServer      = 'Value'
}

function Get-BootPerformanceData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

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

    $unreadable = @{}

    $boots = @(Get-StabilityEvent -Log $script:BootPerformanceLog -Provider @() `
        -Id @($script:BootPerformanceBootEventId) -Since $since -Unreadable $unreadable |
        Sort-Object TimeCreated -Descending | Select-Object -First $script:BootPerformanceSampleCount |
        ForEach-Object {
            [pscustomobject]@{
                Time            = $_.TimeCreated
                BootMilliseconds = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceBootTimeIndex
                MainPathMs       = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceMainPathIndex
                PostBootMs       = Get-EventPayload -LogEntry $_ -Index $script:BootPerformancePostBootIndex
                ProfileMs        = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceProfileIndex
                ExplorerMs       = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceExplorerIndex
                StartupApps      = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceStartupAppsIndex
                LogonWaitMs      = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceLogonWaitIndex
            }
        })

    $degradations = @(Get-StabilityEvent -Log $script:BootPerformanceLog -Provider @() `
        -Id $script:BootPerformanceDegradationIds -Since $since -Unreadable $unreadable |
        ForEach-Object {
            $name = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceFriendlyNameIndex
            if (-not $name) { $name = Get-EventPayload -LogEntry $_ -Index $script:BootPerformanceNameIndex }
            [pscustomobject]@{ Time = $_.TimeCreated; Item = $name }
        })

    [pscustomobject]@{
        PSTypeName    = 'Gutcheck.Data.BootPerformance'
        Days          = $days
        Boots         = $boots
        Degradations  = $degradations
        LogUnreadable = $unreadable.ContainsKey($script:BootPerformanceLog)
        # A server does not record how long it took to start: nothing found there is
        # nothing missing.
        IsServer      = Test-WindowsServer
    }
}

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

    $warn = Get-Parameter $Parameters 'BootDurationWarnAboveSeconds'  90
    $fail = Get-Parameter $Parameters 'BootDurationFailAboveSeconds' 180

    if (Get-DataProperty $Data 'LogUnreadable') {
        return New-Finding -Category System -Check (Get-Text 'Check.BootPerformance.BootDurationAvgLast') -Severity INFO `
            -Value (Get-Text 'Value.Shared.NeedsAdmin') `
            -Hint (Get-Text 'Hint.BootPerformance.RerunAndAllowTheAdmin')
    }

    $durations = @((Get-DataCollection $Data 'Boots') |
        ForEach-Object { ConvertTo-Number $_.BootMilliseconds } |
        Where-Object { $null -ne $_ -and $_ -gt 0 })

    if (-not $durations.Count -and [bool](Get-DataProperty $Data 'IsServer')) {
        return New-Finding -Category System -Check (Get-Text 'Check.BootPerformance.BootDurationAvgLast') -Severity INFO `
            -Value (Get-Text 'Value.BootPerformance.ServerNotRecorded') `
            -Meaning (Get-Text 'Meaning.BootPerformance.ServerNotRecorded') -Hint (Get-Text 'Hint.BootPerformance.ServerNotRecorded')
    }
    if (-not $durations.Count) {
        # A machine that has not been restarted in the period, or one whose diagnostics
        # task has never run. Neither is a slow boot.
        return New-UnavailableFinding -Category System -Check (Get-Text 'Check.BootPerformance.BootDurationAvgLast') `
            -Hint (Get-Text 'Hint.BootPerformance.WindowsHasRecordedNoBoot')
    }

    $seconds = ($durations | Measure-Object -Average).Average / 1000

    # What the last start was spent on, where Windows says: one number tells a Technician
    # that there is a problem, and the parts which.
    $value = @('{0:N0} s' -f $seconds)
    $split = Get-BootPerformanceSplitText -Boot @((Get-DataCollection $Data 'Boots') | ForEach-Object { $_ })[0]
    if ($split) { $value += $split }
    $meaning   = ''
    $reference = @((New-Reference -Section 'Title.BootPerformance.ItemsThatSlowedDown'), (New-Reference -Section 'Title.Startup.StartupEntries'))
    if ($split) {
        $meaning   = Get-Text 'Meaning.BootPerformance.Split'
        $reference = @(New-Reference -Section 'Title.BootPerformance.Boots') + $reference
    }

    New-Finding -Category System -Check (Get-Text 'Check.BootPerformance.BootDurationAvgLast') `
        -Severity (Get-Severity $seconds $warn $fail) -Value ($value -join ' | ') `
        -Meaning $meaning `
        -Hint (Get-Text 'Hint.BootPerformance.SlowBootCheckStartupPrograms') `
        -Reference $reference
}

function Get-BootPerformanceSplitText {
    <#
    .SYNOPSIS
        What a start was spent on, as a statement of the Finding: the way to the desktop,
        the settling after it, and how long of it the machine stood at the sign-in
        screen. Empty where the record does not say. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Boot)

    $main = ConvertTo-Number (Get-DataProperty $Boot 'MainPathMs')
    $post = ConvertTo-Number (Get-DataProperty $Boot 'PostBootMs')
    if ($null -eq $main -or $null -eq $post -or $main -le 0) { return '' }

    $text = (Get-Text 'Value.BootPerformance.Split') -f ([double]$main / 1000), ([double]$post / 1000)
    $wait = ConvertTo-Number (Get-DataProperty $Boot 'LogonWaitMs')
    if ($null -ne $wait -and $wait -ge 1000) { $text = '{0}; {1}' -f $text, ((Get-Text 'Value.BootPerformance.LogonWait') -f ([double]$wait / 1000)) }
    $text
}

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

    # Each of the last starts with what it was spent on. Data from before that was
    # gathered has the total only, and no table.
    $boots = @((Get-DataCollection $Data 'Boots') | Where-Object { $null -ne (ConvertTo-Number (Get-DataProperty $_ 'MainPathMs')) })
    $seconds = { param($Value) $number = ConvertTo-Number $Value; if ($null -eq $number) { '' } else { [math]::Round([double]$number / 1000, 1) } }
    New-Section -Title (Get-Text 'Title.BootPerformance.Boots') -Help (Get-Text 'Help.BootPerformance.Boots') -Row @(
        $boots | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.NetworkDisruption.Time')]      = Get-DataProperty $_ 'Time'
            $row[(Get-Text 'Column.BootPerformance.Total')]       = & $seconds (Get-DataProperty $_ 'BootMilliseconds')
            $row[(Get-Text 'Column.BootPerformance.MainPath')]    = & $seconds (Get-DataProperty $_ 'MainPathMs')
            $row[(Get-Text 'Column.BootPerformance.Profile')]     = & $seconds (Get-DataProperty $_ 'ProfileMs')
            $row[(Get-Text 'Column.BootPerformance.Explorer')]    = & $seconds (Get-DataProperty $_ 'ExplorerMs')
            $row[(Get-Text 'Column.BootPerformance.PostBoot')]    = & $seconds (Get-DataProperty $_ 'PostBootMs')
            $row[(Get-Text 'Column.BootPerformance.LogonWait')]   = & $seconds (Get-DataProperty $_ 'LogonWaitMs')
            $row[(Get-Text 'Column.BootPerformance.StartupApps')] = "$(Get-DataProperty $_ 'StartupApps')"
            [pscustomobject]$row
        }
    )

    $degradations = Get-DataCollection $Data 'Degradations'
    if (-not $degradations.Count) { return }

    New-Section -Title (Get-Text 'Title.BootPerformance.ItemsThatSlowedDown') -Help (Get-Text 'Help.BootPerformance.ItemsThatSlowedDown') -Row @(
        $degradations | Where-Object { $_.Item } | Group-Object Item |
            Sort-Object Count -Descending | Select-Object -First 15 | ForEach-Object {
                $row = [ordered]@{}
                $row[(Get-Text 'Column.BootPerformance.Count')] = $_.Count
                $row[(Get-Text 'Column.BootPerformance.Item')]  = $_.Name
                [pscustomobject]$row
            }
    )
}