Private/Kinds/GroupPolicy.ps1

# The GroupPolicy Kind: how long Group Policy holds up a boot and a logon, and why.
#
# On a domain-joined machine the desktop does not appear until the user's policy has been
# applied, and the machine's policy runs before anyone can log on at all. A drive mapping
# that waits for a file server that is gone, a printer deployment that installs a driver at
# every logon, a domain controller reachable only through a VPN that is not up yet: each is
# a minute a Customer spends looking at "Willkommen", and each is recorded in the Group
# Policy log with its duration.
#
# Every selection is by event id, and every payload field is read positionally as the
# provider's manifest defines it, for the reason Private/Kinds/Stability.ps1 gives. The
# templates, verified against Microsoft-Windows-GroupPolicy's manifest:
#
# 8000-8007 PolicyElaspedTimeInSeconds, ErrorCode, PrincipalSamName, IsMachine,
# IsConnectivityFailure (8000 boot, 8001 logon)
# 5016/6016/7016 CSEElaspedTimeInMilliSeconds, ErrorCode, CSEExtensionName, CSEExtensionId
# (one extension finished: success / warning / error)
# 5314 BandwidthInkbps, IsSlowLink, ThresholdInkbps, ...
# System log 1030-1129 SupportInfo1, SupportInfo2, ProcessingMode,
# ProcessingTimeInMilliseconds, ErrorCode, ErrorDescription, DCName, ...
# (1085: ... DCName, ExtensionName, ExtensionId)
#
# ("Elasped" is Microsoft's spelling, in the manifest itself.)

# The Operational channel is readable only with admin rights.
$script:GroupPolicyNeedsAdmin = $true

$script:GroupPolicyLog       = 'Microsoft-Windows-GroupPolicy/Operational'
$script:GroupPolicyProvider  = 'Microsoft-Windows-GroupPolicy'

# The Operational log records every extension of every background refresh - every 90
# minutes, a dozen extensions each - so a month of it is thousands of events. The newest
# are the ones that describe the machine as it is.
$script:GroupPolicyMaxExtensionEvents = 2000

# The System-log failures, by what they mean for the Technician.
$script:GroupPolicyConnectivityIds = @(1054, 1129)
$script:GroupPolicyErrorIds        = @(1030, 1053, 1055, 1058, 1096)
$script:GroupPolicyExtensionFailId = 1085

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

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

    $partOfDomain = $null
    try { $partOfDomain = [bool](Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).PartOfDomain } catch { }

    $unreadable = @{}

    $processing = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id (8000..8007) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Time                  = $_.TimeCreated
                Id                    = $_.Id
                Seconds               = Get-EventPayload -LogEntry $_ -Index 0
                ErrorCode             = Get-EventPayload -LogEntry $_ -Index 1
                Principal             = Get-EventPayload -LogEntry $_ -Index 2
                IsConnectivityFailure = Get-EventPayload -LogEntry $_ -Index 4
            }
        })

    $extensions = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5016, 6016, 7016) -Since $since `
            -Unreadable $unreadable -MaxEvents $script:GroupPolicyMaxExtensionEvents |
        ForEach-Object {
            [pscustomobject]@{
                Time         = $_.TimeCreated
                Id           = $_.Id
                Milliseconds = Get-EventPayload -LogEntry $_ -Index 0
                ErrorCode    = Get-EventPayload -LogEntry $_ -Index 1
                Extension    = Get-EventPayload -LogEntry $_ -Index 2
            }
        })

    $links = @(Get-GroupPolicyEvent -Log $script:GroupPolicyLog -Id @(5314) -Since $since -Unreadable $unreadable |
        ForEach-Object {
            [pscustomobject]@{
                Time           = $_.TimeCreated
                BandwidthKbps  = Get-EventPayload -LogEntry $_ -Index 0
                IsSlowLink     = Get-EventPayload -LogEntry $_ -Index 1
                ThresholdKbps  = Get-EventPayload -LogEntry $_ -Index 2
            }
        })

    $failureIds = @($script:GroupPolicyConnectivityIds) + @($script:GroupPolicyErrorIds) + @($script:GroupPolicyExtensionFailId)
    $failureEvents = @(Get-GroupPolicyEvent -Log 'System' -Provider $script:GroupPolicyProvider -Id $failureIds `
            -Since $since -Unreadable $unreadable)
    $failures = @($failureEvents | ForEach-Object {
        [pscustomobject]@{
            Time             = $_.TimeCreated
            Id               = $_.Id
            ErrorCode        = Get-EventPayload -LogEntry $_ -Index 4
            ErrorDescription = Get-EventPayload -LogEntry $_ -Index 5
            Extension        = $(if ($_.Id -eq $script:GroupPolicyExtensionFailId) { Get-EventPayload -LogEntry $_ -Index 7 } else { $null })
        }
    })

    # How far back the log reaches, and since when the machine runs: where the log holds
    # no start and no sign-in, these say why. The log is of a fixed size and overwrites
    # its oldest; on a domain machine every background refresh adds to it.
    $logOldest = $null
    if (-not $unreadable.ContainsKey($script:GroupPolicyLog)) {
        try { $logOldest = (Get-WinEvent -LogName $script:GroupPolicyLog -MaxEvents 1 -Oldest -ErrorAction Stop).TimeCreated } catch { }
    }
    $lastBoot = $null
    try { $lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime } catch { }

    [pscustomobject]@{
        PSTypeName     = 'Gutcheck.Data.GroupPolicy'
        GatheredAt     = Get-Date
        LogOldest      = $logOldest
        LastBoot       = $lastBoot
        Days           = $days
        PartOfDomain   = $partOfDomain
        Processing     = $processing
        Extensions     = $extensions
        Links          = $links
        Failures       = $failures
        UnreadableLogs = @($unreadable.Keys | Sort-Object)
        Events         = @($failureEvents | ForEach-Object { ConvertTo-EventRow -LogEntry $_ -Tag 'Group Policy' })
    }
}

function Get-GroupPolicyEvent {
    <#
    .SYNOPSIS
        Reads events by id out of one log, newest first. Records a log it cannot open.
    #>

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

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

    $filter = @{ LogName = $Log; Id = $Id; StartTime = $Since }
    if ($Provider) { $filter.ProviderName = $Provider }

    # Not asked to stop: "no events matched" is an error to Get-WinEvent, and stopping on
    # it writes a TerminatingError line into the transcript for a log that was merely quiet.
    $problems  = @()
    $arguments = @{ FilterHashtable = $filter; ErrorAction = 'SilentlyContinue'; ErrorVariable = 'problems' }
    if ($MaxEvents -gt 0) { $arguments.MaxEvents = $MaxEvents }

    try { Get-WinEvent @arguments }
    catch { $problems = @($_) }

    foreach ($problem in @($problems)) {
        # "No events matched" is raised as an error as well; only a refusal is a gap.
        if (Test-AccessDenied -ErrorRecord $problem) { $Unreadable[$Log] = $true }
    }
}

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

    if ((Get-DataProperty $Data 'PartOfDomain') -eq $false) {
        # A workgroup or Entra-only machine has no domain policy to wait for. Said once,
        # so the Report shows the Check was considered rather than forgotten.
        return New-Finding -Category Startup -Check (Get-Text 'Check.GroupPolicy.GroupPolicy') -Severity INFO `
            -Value (Get-Text 'Value.GroupPolicy.NotInDomain')
    }

    $unreadable = Get-DataCollection $Data 'UnreadableLogs'
    if ($unreadable -contains $script:GroupPolicyLog) {
        New-Finding -Category Startup -Check (Get-Text 'Check.GroupPolicy.GroupPolicy') -Severity INFO `
            -Value (Get-Text 'Value.Shared.NeedsAdmin') `
            -Hint (Get-Text 'Hint.GroupPolicy.RerunWithAdmin')
    }
    else {
        New-GroupPolicyDurationFinding -Data $Data -Id 8000 -Check (Get-Text 'Check.GroupPolicy.Boot') `
            -Warn (Get-Parameter $Parameters 'GpBootWarnAboveSeconds' 30) `
            -Fail (Get-Parameter $Parameters 'GpBootFailAboveSeconds' 90)
        New-GroupPolicyDurationFinding -Data $Data -Id 8001 -Check (Get-Text 'Check.GroupPolicy.Logon') `
            -Warn (Get-Parameter $Parameters 'GpLogonWarnAboveSeconds' 15) `
            -Fail (Get-Parameter $Parameters 'GpLogonFailAboveSeconds' 60)
        New-GroupPolicySlowExtensionFinding -Data $Data -Parameters $Parameters
        New-GroupPolicySlowLinkFinding      -Data $Data -Parameters $Parameters
    }

    if ($unreadable -contains 'System') { return }
    New-GroupPolicyConnectivityFinding     -Data $Data -Parameters $Parameters
    New-GroupPolicyErrorFinding            -Data $Data -Parameters $Parameters
    New-GroupPolicyExtensionFailureFinding -Data $Data -Parameters $Parameters
}

function New-GroupPolicyDurationFinding {
    <#
    .SYNOPSIS
        Judges how long boot or logon policy processing took across the window.
    .DESCRIPTION
        Judged on the median rather than the slowest: the first boot after a Windows update
        or a new GPO is slow on every machine, and a Customer's complaint is about the
        typical morning. The slowest is still in the Value, for the Technician to see.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()]$Data,
        [Parameter(Mandatory)][int]$Id,
        [Parameter(Mandatory)][string]$Check,
        [Parameter(Mandatory)][double]$Warn,
        [Parameter(Mandatory)][double]$Fail
    )

    $rows    = @((Get-DataCollection $Data 'Processing') | Where-Object { $_.Id -eq $Id })
    $seconds = @($rows | ForEach-Object { ConvertTo-Number $_.Seconds } | Where-Object { $null -ne $_ })

    if (-not $seconds.Count) { return New-GroupPolicyNoneFinding -Data $Data -Id $Id -Check $Check }

    $median  = Get-SamplePercentile -Sample $seconds -Percentile 50
    $maximum = ($seconds | Measure-Object -Maximum).Maximum

    New-Finding -Category Startup -Check $Check -Severity (Get-Severity $median $Warn $Fail) `
        -Value ((Get-Text 'Value.GroupPolicy.Duration') -f $median, $maximum, $seconds.Count) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowProcessing')
}

function New-GroupPolicyNoneFinding {
    <#
    .SYNOPSIS
        The Finding where the log holds no start, or no sign-in, to judge: why there is
        none, as far as that is known. Pure.
    .DESCRIPTION
        "Keine Eintraege in 30 Tagen" under both left a Technician guessing whether that
        was fine or a gap. There are three reasons and they are told apart: the log no
        longer reaches back to the last start (the usual one on a domain machine: the
        log is of a fixed size and every background refresh adds to it); the machine has
        run for longer than the period, so there was no start to measure; or the log
        reaches back over the whole period and holds none. Information in each case:
        nothing was measured, which is no fault of the machine.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data, [Parameter(Mandatory)][int]$Id, [Parameter(Mandatory)][string]$Check)

    $days     = ConvertTo-Number (Get-DataProperty $Data 'Days')
    $oldest   = ConvertTo-DataTime (Get-DataProperty $Data 'LogOldest')
    $lastBoot = ConvertTo-DataTime (Get-DataProperty $Data 'LastBoot')
    $gathered = ConvertTo-DataTime (Get-DataProperty $Data 'GatheredAt')
    $since    = $null
    if ($null -ne $gathered -and $null -ne $days) { $since = $gathered.AddDays(-$days) }

    $none = (Get-Text 'Value.GroupPolicy.NoneInPeriod') -f $days

    # At startup: the machine has run for longer than the period.
    if ($Id -eq 8000 -and $null -ne $lastBoot -and $null -ne $since -and $lastBoot -lt $since) {
        return New-Finding -Category Startup -Check $Check -Severity INFO `
            -Value ((Get-Text 'Value.GroupPolicy.NoBootInPeriod') -f $days, $lastBoot) `
            -Meaning (Get-Text 'Meaning.GroupPolicy.NoBootInPeriod') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart')
    }

    if ($null -eq $oldest -or $null -eq $since) {
        return New-Finding -Category Startup -Check $Check -Severity INFO -Value $none `
            -Meaning (Get-Text 'Meaning.GroupPolicy.NoneReachUnknown') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart')
    }

    # The log is shorter than the period: what was looked for may have been in it once.
    if ($oldest -gt $since) {
        $value = (Get-Text 'Value.GroupPolicy.LogTooShort') -f $oldest
        if ($Id -eq 8000 -and $null -ne $lastBoot -and $lastBoot -lt $oldest) {
            $value = (Get-Text 'Value.GroupPolicy.LogTooShortBoot') -f $lastBoot, $oldest
        }
        return New-Finding -Category Startup -Check $Check -Severity INFO -Value $value `
            -Meaning (Get-Text 'Meaning.GroupPolicy.LogTooShort') -Hint (Get-Text 'Hint.GroupPolicy.LogTooShort')
    }

    New-Finding -Category Startup -Check $Check -Severity INFO -Value $none `
        -Meaning (Get-Text 'Meaning.GroupPolicy.NoneInPeriod') -Hint (Get-Text 'Hint.GroupPolicy.MeasureByRestart')
}

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

    $warn = Get-Parameter $Parameters 'GpExtensionWarnAboveSeconds' 10
    $fail = Get-Parameter $Parameters 'GpExtensionFailAboveSeconds' 60

    $check = Get-Text 'Check.GroupPolicy.SlowestExtensions'
    $slowest = @((Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension |
        ForEach-Object {
            $ms = @($_.Group | ForEach-Object { ConvertTo-Number $_.Milliseconds } | Where-Object { $null -ne $_ })
            if ($ms.Count) {
                [pscustomobject]@{ Extension = $_.Name; MaxSeconds = ($ms | Measure-Object -Maximum).Maximum / 1000 }
            }
        } | Sort-Object MaxSeconds -Descending)

    if (-not $slowest.Count) {
        return New-Finding -Category Startup -Check $check -Severity INFO `
            -Value ((Get-Text 'Value.GroupPolicy.NoneInPeriod') -f (Get-DataProperty $Data 'Days'))
    }

    $top = ($slowest | Select-Object -First 3 | ForEach-Object { '{0} {1:N0} s' -f $_.Extension, $_.MaxSeconds }) -join ', '

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $slowest[0].MaxSeconds $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.SlowestExtensions') -f $top) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowExtension')
}

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

    $warn = Get-Parameter $Parameters 'GpSlowLinkWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpSlowLinkFailAbove' ([double]::MaxValue)

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

    # Read as text: the payload of a boolean field arrives as "True" or "False", and as
    # "true" after the Elevated Part's round trip on some editions.
    $slow = @($links | Where-Object { "$($_.IsSlowLink)" -eq 'true' })

    $check = Get-Text 'Check.GroupPolicy.SlowLink'
    if (-not $slow.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK `
            -Value ((Get-Text 'Value.GroupPolicy.SlowLinkNone') -f $links.Count)
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $slow.Count $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.SlowLink') -f $slow.Count, $links.Count) `
        -Hint (Get-Text 'Hint.GroupPolicy.SlowLink')
}

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

    $warn = Get-Parameter $Parameters 'GpConnectivityFailureWarnAbove' 5
    $fail = Get-Parameter $Parameters 'GpConnectivityFailureFailAbove' ([double]::MaxValue)

    # Both records of the same fact: the System log's "no domain controller" events, and
    # a processing run that ended flagged as a connectivity failure.
    $system     = @((Get-DataCollection $Data 'Failures') | Where-Object { $_.Id -in $script:GroupPolicyConnectivityIds })
    $processing = @((Get-DataCollection $Data 'Processing') | Where-Object { "$($_.IsConnectivityFailure)" -eq 'true' })
    $count = [math]::Max($system.Count, $processing.Count)

    $check = Get-Text 'Check.GroupPolicy.DomainController'
    $days  = Get-DataProperty $Data 'Days'
    if (-not $count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value ((Get-Text 'Value.GroupPolicy.AlwaysReached') -f $days)
    }

    $severity = Get-Severity $count $warn $fail
    if ($severity -eq 'OK') { $severity = 'INFO' }

    New-Finding -Category Startup -Check $check -Severity $severity `
        -Value ((Get-Text 'Value.GroupPolicy.NotReached') -f $count, $days) `
        -Hint (Get-Text 'Hint.GroupPolicy.DomainControllerUnreachable')
}

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

    $warn = Get-Parameter $Parameters 'GpErrorWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpErrorFailAbove' 10

    $errors = @((Get-DataCollection $Data 'Failures') | Where-Object { $_.Id -in $script:GroupPolicyErrorIds })
    $check  = Get-Text 'Check.GroupPolicy.Errors'
    if (-not $errors.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    # Each id with what it means, and behind it the error Windows gave with it: 1058 says
    # that a policy file could not be read, and the error number says why.
    $byId = ($errors | Group-Object Id | Sort-Object Count -Descending | ForEach-Object {
        $id = Format-CodeWithMeaning -Code ('ID {0}' -f $_.Name) -Meaning (Get-GroupPolicyEventIdText -Id $_.Name)
        Format-GroupPolicyCount -Name $id -Row $_.Group
    }) -join ' | '

    # 1058 and 1096 are policy files that could not be read from SYSVOL; the rest are a
    # user or computer name that could not be resolved, or processing that failed outright.
    $hint = Get-Text 'Hint.GroupPolicy.Errors'
    if (@($errors | Where-Object { $_.Id -in 1058, 1096 }).Count -eq $errors.Count) {
        $hint = Get-Text 'Hint.GroupPolicy.SysvolUnreadable'
    }

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $errors.Count $warn $fail) `
        -Value ((Get-Text 'Value.GroupPolicy.Errors') -f $errors.Count, (Get-DataProperty $Data 'Days'), $byId) `
        -Hint $hint
}

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

    $warn = Get-Parameter $Parameters 'GpExtensionFailureWarnAbove' 0
    $fail = Get-Parameter $Parameters 'GpExtensionFailureFailAbove' ([double]::MaxValue)

    # An extension that failed is recorded twice when both logs were readable: 1085 in the
    # System log and 7016 with an error code in the Operational log. Counted per source
    # and the larger taken, per extension, so one failure is not reported as two.
    $system = @((Get-DataCollection $Data 'Failures') |
        Where-Object { $_.Id -eq $script:GroupPolicyExtensionFailId -and $_.Extension })
    $operational = @((Get-DataCollection $Data 'Extensions') |
        Where-Object { $_.Id -eq 7016 -and $_.Extension })

    $names = @(@($system) + @($operational) | ForEach-Object { $_.Extension } | Sort-Object -Unique)
    $check = Get-Text 'Check.GroupPolicy.FailedExtensions'
    if (-not $names.Count) {
        return New-Finding -Category Startup -Check $check -Severity OK -Value (Get-Text 'Value.Shared.None')
    }

    $counted = @(foreach ($name in $names) {
        $count = [math]::Max(@($system | Where-Object { $_.Extension -eq $name }).Count,
                             @($operational | Where-Object { $_.Extension -eq $name }).Count)
        [pscustomobject]@{
            Extension = $name
            Count     = $count
            Rows      = @(@($system) + @($operational) | Where-Object { $_.Extension -eq $name })
        }
    })
    $total = ($counted | Measure-Object Count -Sum).Sum
    $described = ($counted | Sort-Object Count -Descending |
        ForEach-Object { Format-GroupPolicyCount -Name $_.Extension -Count $_.Count -Row $_.Rows }) -join ' | '

    New-Finding -Category Startup -Check $check -Severity (Get-Severity $total $warn $fail) `
        -Value $described `
        -Hint (Get-Text 'Hint.GroupPolicy.ExtensionFailed')
}

function Get-GroupPolicyEventIdText {
    <#
    .SYNOPSIS
        What a failure event of Group Policy in the System log means. Nothing for one not
        known here, which is then shown as the number it is. Pure.
    .DESCRIPTION
        As Microsoft's own messages for these events say it, shortened: 1006 could not
        bind to Active Directory, 1030 processing failed and another event says why, 1053
        and 1055 the user's or the computer's name could not be resolved, 1054 and 1129 no
        domain controller, 1058 the policy file (gpt.ini) on SYSVOL could not be read,
        1085 an extension failed, 1096 registry.pol could not be applied.
    #>

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

    switch ("$Id".Trim()) {
        '1006' { return (Get-Text 'Value.GroupPolicy.EventId.1006') }
        '1030' { return (Get-Text 'Value.GroupPolicy.EventId.1030') }
        '1053' { return (Get-Text 'Value.GroupPolicy.EventId.1053') }
        '1054' { return (Get-Text 'Value.GroupPolicy.EventId.1054') }
        '1055' { return (Get-Text 'Value.GroupPolicy.EventId.1055') }
        '1058' { return (Get-Text 'Value.GroupPolicy.EventId.1058') }
        '1085' { return (Get-Text 'Value.GroupPolicy.EventId.1085') }
        '1096' { return (Get-Text 'Value.GroupPolicy.EventId.1096') }
        '1129' { return (Get-Text 'Value.GroupPolicy.EventId.1129') }
    }
    ''
}

function Get-GroupPolicyProcessingIdText {
    <#
    .SYNOPSIS
        Which run of policy processing an event 8000-8007 is the end of. Nothing for
        another id. Pure.
    .DESCRIPTION
        As Microsoft documents the Operational log: the even ids are the computer's
        policy and the odd ones the user's; 8000 and 8001 end the run at startup and at
        logon, 8002 and 8003 the one after a network change, 8004 and 8005 a manual
        refresh (gpupdate), 8006 and 8007 the periodic one in the background.
    #>

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

    switch ("$Id".Trim()) {
        '8000' { return (Get-Text 'Value.GroupPolicy.Processing.8000') }
        '8001' { return (Get-Text 'Value.GroupPolicy.Processing.8001') }
        '8002' { return (Get-Text 'Value.GroupPolicy.Processing.8002') }
        '8003' { return (Get-Text 'Value.GroupPolicy.Processing.8003') }
        '8004' { return (Get-Text 'Value.GroupPolicy.Processing.8004') }
        '8005' { return (Get-Text 'Value.GroupPolicy.Processing.8005') }
        '8006' { return (Get-Text 'Value.GroupPolicy.Processing.8006') }
        '8007' { return (Get-Text 'Value.GroupPolicy.Processing.8007') }
    }
    ''
}

function Format-GroupPolicyErrorCode {
    <#
    .SYNOPSIS
        The error number of a Group Policy event with what it means behind it. Pure.
    .DESCRIPTION
        The number is a Win32 error. The System-log events carry Windows' own sentence
        for it (ErrorDescription), which is used where it is there; the Operational log
        carries the number alone, and Windows is asked. Zero is no error and is said so
        only where -ShowZero asks for it, for a table that has a cell to fill.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()]$Code, [AllowNull()]$Description, [switch]$ShowZero)

    $text = "$Code".Trim()
    if (-not $text) { return '' }
    if ((ConvertTo-Number $text) -eq 0) {
        if ($ShowZero) { return (Format-CodeWithMeaning -Code $text -Meaning (Get-Text 'Value.GroupPolicy.NoError')) }
        return ''
    }

    $meaning = "$Description".Trim()
    if (-not $meaning) { $meaning = Get-Win32ErrorText -Code $text }
    # Some extensions report an HRESULT where a Win32 error belongs.
    if (-not $meaning) { $meaning = Get-HResultText -Code $text }
    Format-CodeWithMeaning -Code $text -Meaning $meaning
}

function Format-GroupPolicyCount {
    <#
    .SYNOPSIS
        A name, how often it was logged, and the error numbers logged with it, the most
        frequent first and no more than three. Pure.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Name,
        [AllowNull()][AllowEmptyCollection()][object[]]$Row = @(),

        # Where the rows are two records of the same failures and their number is not the count.
        [int]$Count = -1
    )

    $rows = @($Row | Where-Object { $null -ne $_ })
    if ($Count -lt 0) { $Count = $rows.Count }

    $codes = @($rows | ForEach-Object {
        Format-GroupPolicyErrorCode -Code (Get-DataProperty $_ 'ErrorCode') -Description (Get-DataProperty $_ 'ErrorDescription')
    } | Where-Object { $_ } | Group-Object | Sort-Object Count -Descending | Select-Object -First 3 | ForEach-Object { $_.Name })

    if (-not $codes.Count) { return (Get-Text 'Value.GroupPolicy.Count') -f $Name, $Count }
    (Get-Text 'Value.GroupPolicy.CountWithCode') -f $Name, $Count, ($codes -join ', ')
}

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

    New-Section -Title (Get-Text 'Title.GroupPolicy.Processing') -Row @(
        (Get-DataCollection $Data 'Processing') | Sort-Object Time -Descending | Select-Object -First 30 | ForEach-Object {
            # The payload of the boolean field arrives as text; see New-GroupPolicySlowLinkFinding.
            $failed = $null
            if ("$($_.IsConnectivityFailure)" -eq 'true')      { $failed = $true }
            elseif ("$($_.IsConnectivityFailure)" -eq 'false') { $failed = $false }

            $row = [ordered]@{}
            $row[(Get-Text 'Column.GroupPolicy.Time')]                = $_.Time
            $row[(Get-Text 'Column.GroupPolicy.Event')]               = Format-CodeWithMeaning -Code "$($_.Id)" -Meaning (Get-GroupPolicyProcessingIdText -Id $_.Id)
            $row[(Get-Text 'Column.GroupPolicy.Seconds')]             = $_.Seconds
            $row[(Get-Text 'Column.GroupPolicy.ErrorCode')]           = Format-GroupPolicyErrorCode -Code $_.ErrorCode -ShowZero
            $row[(Get-Text 'Column.GroupPolicy.Principal')]           = $_.Principal
            $row[(Get-Text 'Column.GroupPolicy.ConnectivityFailure')] = Get-TriStateText $failed
            [pscustomobject]$row
        }
    )

    $extension = Get-Text 'Column.GroupPolicy.Extension'
    $runs      = Get-Text 'Column.GroupPolicy.Runs'
    $average   = Get-Text 'Column.GroupPolicy.AverageMs'
    $maximum   = Get-Text 'Column.GroupPolicy.MaximumMs'
    $errors    = Get-Text 'Column.GroupPolicy.Errors'
    $codes     = Get-Text 'Column.GroupPolicy.ErrorCodes'

    New-Section -Title (Get-Text 'Title.GroupPolicy.Extensions') -Row @(
        (Get-DataCollection $Data 'Extensions') | Where-Object { $_.Extension } | Group-Object Extension | ForEach-Object {
            $ms = @($_.Group | ForEach-Object { ConvertTo-Number $_.Milliseconds } | Where-Object { $null -ne $_ })
            $row = [ordered]@{}
            $row[$extension] = $_.Name
            $row[$runs]      = $_.Count
            $row[$average]   = $(if ($ms.Count) { [math]::Round(($ms | Measure-Object -Average).Average) } else { $null })
            $row[$maximum]   = $(if ($ms.Count) { ($ms | Measure-Object -Maximum).Maximum } else { $null })
            $row[$errors]    = @($_.Group | Where-Object { $_.Id -eq 7016 }).Count
            # What the runs that did not end well ended with (6016 a warning, 7016 an error).
            $row[$codes]     = @($_.Group | ForEach-Object { Format-GroupPolicyErrorCode -Code $_.ErrorCode } |
                Where-Object { $_ } | Sort-Object -Unique) -join ', '
            [pscustomobject]$row
        } | Sort-Object $maximum -Descending
    )
}

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

    (Get-DataCollection $Data 'Events')
}