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

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

    # The first read of a formatted-performance class is primed with zeroes; discard it.
    [void](Get-CimInstance Win32_PerfFormattedData_PerfOS_Memory -ErrorAction SilentlyContinue)

    $samples = @(1..$script:MemorySampleCount | ForEach-Object {
        Write-CheckProgress -Status ((Get-Text 'Console.Progress.Sample') -f $_, $script:MemorySampleCount) `
            -Step ($_ - 1) -Of $script:MemorySampleCount
        Wait-Sample
        Get-CimInstance Win32_PerfFormattedData_PerfOS_Memory -ErrorAction SilentlyContinue
    })
    $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 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 | 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 = $last.PercentCommittedBytesInUse
        PagesInputPerSecond        = @($samples | ForEach-Object { $_.PagesInputPersec })
        PageFiles                  = $pageFiles
        TopProcessesByMemory       = $top
        TopProgramsByMemory        = $programs
    }
}

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) {
        $programs = @((Get-DataCollection $Data 'TopProcessesByMemory') | Where-Object { $_ } | 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')
    }

    $usedPercent = 100 - ($free / $total * 100)
    $severity = Get-Severity $usedPercent $warn $fail
    $value = @((Get-Text 'Value.Memory.PercentWithFree') -f $usedPercent, ($free / 1MB))

    # "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 {
        '{0} {1} MB (peak {2} MB)' -f $_.Name, $_.AllocatedBaseSizeMB, $_.PeakUsageMB
    }
    New-Finding -Category Memory -Check (Get-Text 'Check.Memory.PageFile') -Value ($described -join ', ') -Severity INFO
}