Private/Kinds/Memory.ps1

# The Memory Kind: how much of the memory is in use, and what uses it.
#
# Get-MemoryData talks to the Target Machine and decides nothing.
# ConvertTo-MemoryFinding is pure: data and parameters in, Findings out. Every threshold
# lives there as a named parameter, defaulted to the value the Gutcheck script used.

# How many readings the Memory Check averages across. The wait between them is shared
# with every other Kind - see Private/Sampling.ps1.
$script:MemorySampleCount           = 3

# What Get-MemoryData hands its Judge. See Private/DataShape.ps1.
$script:MemoryDataShape = @{
    FreePhysicalMemoryKB       = 'Answered'
    TotalVisibleMemoryKB       = 'Answered'
    PercentCommittedBytesInUse = 'Answered'
    PagesInputPerSecond        = 'List'
    PageFiles                  = 'List'
    TopProcessesByMemory       = 'List'
    TopProgramsByMemory        = 'List'
    # Left out of data from before the Run told its own processes apart.
    OwnProcesses               = 'Optional'
}

function Get-GutcheckOwnProcessId {
    <#
    .SYNOPSIS
        The processes of this Run: this one, the Elevated Part, and what either started.
        Part of the Gatherer; reads, and decides nothing.
    .DESCRIPTION
        Seen: "pwsh 10,5 GB (2 Prozesse)" first among the programs filling the memory -
        the Run itself, which a User closes by closing Gutcheck. Never the process the Run
        was started from: that PowerShell may be the User's own, open all day, and what it
        holds is the User's.
    .PARAMETER ElevatedPartProcessId
        The process of the Elevated Part, as the Run started it; none where it started none.
    #>

    [CmdletBinding()]
    [OutputType([int[]])]
    param([AllowNull()]$ElevatedPartProcessId)

    $ids = @($PID)
    if ($ElevatedPartProcessId) { $ids += [int]$ElevatedPartProcessId }
    foreach ($parent in @($ids)) {
        try {
            foreach ($child in @(Get-CimInstance Win32_Process -Filter "ParentProcessId=$parent" -ErrorAction Stop)) {
                if ($child.ProcessId) { $ids += [int]$child.ProcessId }
            }
        }
        catch { }
    }
    @($ids | Select-Object -Unique)
}

function Get-MemoryData {
    <#
    .SYNOPSIS
        How much of the memory is in use, and what uses it. Reads, and decides nothing.
    .PARAMETER Observed
        What the Run knows: under 'RunProcesses' the process of the Elevated Part it started
        (ElevatedPartProcessId), which is the Run's own.
    #>

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

    # From the counters and not through WMI. See Private/Counter.ps1.
    $committedPath = '\Memory\% Committed Bytes In Use'
    $pagesPath     = '\Memory\Pages Input/sec'
    $counter = Open-PerformanceCounter -Path $committedPath, $pagesPath
    $samples = @(1..$script:MemorySampleCount | ForEach-Object {
        Write-CheckProgress -Status ((Get-Text 'Console.Progress.Sample') -f $_, $script:MemorySampleCount) `
            -Step ($_ - 1) -Of $script:MemorySampleCount
        Wait-Sample
        Read-PerformanceCounter -Counter $counter
    })
    Close-PerformanceCounter -Counter $counter
    $last = $samples | Select-Object -Last 1
    $os   = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue

    $pageFiles = @(Get-CimInstance Win32_PageFileUsage -ErrorAction SilentlyContinue | ForEach-Object {
        [pscustomobject]@{
            Name                = $_.Name
            AllocatedBaseSizeMB = $_.AllocatedBaseSize
            PeakUsageMB         = $_.PeakUsage
        }
    })

    $processes = @(Get-Process -ErrorAction SilentlyContinue)

    # The Run's own processes apart: what Gutcheck holds is not what fills the User's memory.
    $elevatedPart = Get-DataProperty (Get-ObservedKindData -Observed $Observed -Kind 'RunProcesses') 'ElevatedPartProcessId'
    $ownIds = @(Get-GutcheckOwnProcessId -ElevatedPartProcessId $elevatedPart)
    $own = @($processes | Where-Object { $ownIds -contains $_.Id } | ForEach-Object {
        [pscustomobject]@{
            Name         = $_.Name
            Id           = $_.Id
            PrivateMB    = [math]::Round($_.PrivateMemorySize64 / 1MB)
            WorkingSetMB = [math]::Round($_.WorkingSet64 / 1MB)
        }
    })

    # The same by program: a browser is forty processes of a hundred megabytes each, and
    # none of them is among the largest processes although the browser is the largest
    # program. Summed by name, so that "what fills the memory" names what a User can close.
    $programs = @($processes | Where-Object { $ownIds -notcontains $_.Id } | Group-Object Name | ForEach-Object {
        [pscustomobject]@{
            Name         = $_.Name
            Processes    = $_.Count
            PrivateMB    = [math]::Round((($_.Group | Measure-Object PrivateMemorySize64 -Sum).Sum) / 1MB)
            WorkingSetMB = [math]::Round((($_.Group | Measure-Object WorkingSet64 -Sum).Sum) / 1MB)
        }
    } | Sort-Object PrivateMB -Descending | Select-Object -First 12)

    $top = @($processes |
        Sort-Object PrivateMemorySize64 -Descending | Select-Object -First 12 | ForEach-Object {
            [pscustomobject]@{
                Name         = $_.Name
                Id           = $_.Id
                PrivateMB    = [math]::Round($_.PrivateMemorySize64 / 1MB)
                WorkingSetMB = [math]::Round($_.WorkingSet64 / 1MB)
                Handles      = $_.HandleCount
            }
        })

    [pscustomobject]@{
        PSTypeName                 = 'Gutcheck.Data.Memory'
        FreePhysicalMemoryKB       = $os.FreePhysicalMemory
        TotalVisibleMemoryKB       = $os.TotalVisibleMemorySize
        PercentCommittedBytesInUse = $(if ($last) { $last[$committedPath] })
        PagesInputPerSecond        = @($samples | ForEach-Object { $_[$pagesPath] } | Where-Object { $null -ne $_ })
        PageFiles                  = $pageFiles
        TopProcessesByMemory       = $top
        TopProgramsByMemory        = $programs
        OwnProcesses               = $own
    }
}

function Get-MemoryTopProgram {
    <#
    .SYNOPSIS
        The programs that hold the most memory, the largest first. Pure.
    .DESCRIPTION
        From what the Gatherer summed by program. Data from before it did has the largest
        processes only, and those are summed here: less than the truth for a program of
        many small processes, and still the largest that are known.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data, [int]$First = 5)

    $programs = @((Get-DataCollection $Data 'TopProgramsByMemory') | Where-Object { $_ })
    if (-not $programs.Count) {
        # Without the Run's own processes, as the Gatherer sums the programs.
        $own = @((Get-DataCollection $Data 'OwnProcesses') | ForEach-Object { "$(Get-DataProperty $_ 'Id')" })
        $programs = @((Get-DataCollection $Data 'TopProcessesByMemory') | Where-Object { $_ -and $own -notcontains "$(Get-DataProperty $_ 'Id')" } | Group-Object { "$(Get-DataProperty $_ 'Name')" } | ForEach-Object {
            [pscustomobject]@{
                Name      = $_.Name
                Processes = $_.Count
                PrivateMB = ($_.Group | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'PrivateMB') } | Measure-Object -Sum).Sum
            }
        })
    }
    @($programs | Where-Object { "$(Get-DataProperty $_ 'Name')" -and (ConvertTo-Number (Get-DataProperty $_ 'PrivateMB')) -gt 0 } |
        Sort-Object @{ Expression = { ConvertTo-Number (Get-DataProperty $_ 'PrivateMB') }; Descending = $true } | Select-Object -First $First)
}

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

    New-RamInUseFinding     -Data $Data -Parameters $Parameters
    New-CommitChargeFinding -Data $Data -Parameters $Parameters
    New-PageFaultFinding    -Data $Data -Parameters $Parameters
    New-PageFileFinding     -Data $Data -Parameters $Parameters
}

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

    New-Section -Title (Get-Text 'Title.Memory.TopProcessesByMemory') -Help (Get-Text 'Help.Memory.TopProcessesByMemory') -Row @(
        (Get-DataCollection $Data 'TopProcessesByMemory') | ForEach-Object {
            $row = [ordered]@{}
            $row[(Get-Text 'Column.Memory.Process')]      = Get-DataProperty $_ 'Name'
            $row[(Get-Text 'Column.Memory.Id')]           = Get-DataProperty $_ 'Id'
            $row[(Get-Text 'Column.Memory.PrivateMB')]    = Get-DataProperty $_ 'PrivateMB'
            $row[(Get-Text 'Column.Memory.WorkingSetMB')] = Get-DataProperty $_ 'WorkingSetMB'
            $row[(Get-Text 'Column.Memory.Handles')]      = Get-DataProperty $_ 'Handles'
            [pscustomobject]$row
        }
    )
}

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

    $warn = Get-Parameter $Parameters 'RamInUseWarnPercent' 80
    $fail = Get-Parameter $Parameters 'RamInUseFailPercent' 90

    $free  = ConvertTo-Number (Get-DataProperty $Data 'FreePhysicalMemoryKB')
    $total = ConvertTo-Number (Get-DataProperty $Data 'TotalVisibleMemoryKB')

    if ($null -eq $free -or $null -eq $total -or $total -le 0) {
        return New-UnavailableFinding -Category Memory -Check (Get-Text 'Check.Memory.RAMInUse') `
            -Hint (Get-Text 'Hint.Memory.TheOperatingSystemDidNot')
    }

    # What the Run itself holds in memory is left out: Gutcheck goes when it is done, and
    # a Run that fills the memory is no User's program to close. By its working set, the
    # part of it that is in the memory now - what "in use" and "free" are of - and counted
    # as free beside the percentage, so that the two say the same.
    $ownMB = (@((Get-DataCollection $Data 'OwnProcesses') | ForEach-Object { ConvertTo-Number (Get-DataProperty $_ 'WorkingSetMB') } |
        Where-Object { $null -ne $_ }) | Measure-Object -Sum).Sum
    if (-not $ownMB) { $ownMB = 0 }
    $ownKB  = [math]::Min($ownMB * 1024, [math]::Max(0, $total - $free))
    $usedKB = $total - $free - $ownKB

    $usedPercent = $usedKB / $total * 100
    $severity = Get-Severity $usedPercent $warn $fail
    $value = @((Get-Text 'Value.Memory.PercentWithFree') -f $usedPercent, (($free + $ownKB) / 1MB))
    if ($ownMB -gt 0) { $value += Get-Text 'Value.Memory.OwnLeftOut' ($ownMB / 1024) }

    # "Full" is no place to start. What fills it is: said with the Finding, where the
    # memory is short, so that nobody has to go and look for the table first.
    if ($severity -ne 'OK') {
        $largest = @(Get-MemoryTopProgram -Data $Data -First ([int](Get-Parameter $Parameters 'TopProgramCount' 5)) | ForEach-Object {
            $count = [int](ConvertTo-Number (Get-DataProperty $_ 'Processes'))
            $size  = (ConvertTo-Number (Get-DataProperty $_ 'PrivateMB')) / 1024
            if ($count -gt 1) { (Get-Text 'Value.Memory.ProgramMany') -f "$(Get-DataProperty $_ 'Name')", $size, $count }
            else              { (Get-Text 'Value.Memory.Program') -f "$(Get-DataProperty $_ 'Name')", $size }
        })
        if ($largest.Count) { $value += (Get-Text 'Value.Memory.Largest') -f ($largest -join '; ') }
    }

    New-Finding -Category Memory -Check (Get-Text 'Check.Memory.RAMInUse') -Severity $severity `
        -Value ($value -join ' | ') -Meaning (Get-Text 'Meaning.Memory.RamFull') -Hint (Get-Text 'Hint.Memory.RamFull') `
        -Reference @(New-Reference -Section 'Title.Memory.TopProcessesByMemory')
}

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

    $warn = Get-Parameter $Parameters 'CommitWarnPercent' 80
    $fail = Get-Parameter $Parameters 'CommitFailPercent' 90

    $committed = ConvertTo-Number (Get-DataProperty $Data 'PercentCommittedBytesInUse')
    if ($null -eq $committed) {
        return New-UnavailableFinding -Category Memory -Check (Get-Text 'Check.Memory.CommitCharge') `
            -Hint (Get-Text 'Hint.Memory.TheMemoryPerformanceCountersDid')
    }

    New-Finding -Category Memory -Check (Get-Text 'Check.Memory.CommitCharge') -Severity (Get-Severity $committed $warn $fail) `
        -Value ((Get-Text 'Value.Memory.PercentOfLimit') -f $committed) `
        -Hint (Get-Text 'Hint.Memory.NearTheCommitLimitPrograms')
}

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

    $warn = Get-Parameter $Parameters 'HardPageFaultsWarnPerSecond' 100
    $fail = Get-Parameter $Parameters 'HardPageFaultsFailPerSecond' 300

    # Judged as the average across the sample window: one spike is not sustained pressure.
    $average = Get-SampleAverage (Get-DataProperty $Data 'PagesInputPerSecond')
    if ($null -eq $average) {
        return New-UnavailableFinding -Category Memory -Check (Get-Text 'Check.Memory.HardPageFaultsPages') `
            -Hint (Get-Text 'Hint.Memory.TheMemoryPerformanceCountersDid2')
    }

    New-Finding -Category Memory -Check (Get-Text 'Check.Memory.HardPageFaultsPages') `
        -Severity (Get-Severity $average $warn $fail) -Value ('{0:N0}' -f $average) `
        -Hint (Get-Text 'Hint.Memory.SustainedHighValuesNotEnough')
}

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

    $pageFiles = (Get-DataCollection $Data 'PageFiles')
    if (-not $pageFiles.Count) {
        return New-Finding -Category Memory -Check (Get-Text 'Check.Memory.PageFile') -Value (Get-Text 'Value.Shared.None') -Severity WARN `
            -Hint (Get-Text 'Hint.Memory.NoPageFileProgramsCrash')
    }

    $described = $pageFiles | ForEach-Object {
        Get-Text 'Value.Memory.PageFile' $_.Name $_.AllocatedBaseSizeMB $_.PeakUsageMB
    }
    New-Finding -Category Memory -Check (Get-Text 'Check.Memory.PageFile') -Value ($described -join ', ') -Severity INFO
}