PowerShell Process CPU and Memory Monitoring Over Time
─□✕

PowerShell Process CPU and Memory Monitoring Over Time

PowerShell Tips Editor 4 min read
PowerShell Process CPU and Memory Monitoring Over Time

A single Get-Process call shows you what is running right now, but it cannot tell you whether a service gradually consumed 4 GB of memory over the last two hours, or whether a scheduled task spiked CPU to 95% for 30 seconds every five minutes. Catching those patterns requires time-series sampling: collecting measurements at regular intervals, storing the trend data, and triggering an alert when a threshold is crossed. This guide builds that monitoring loop from scratch in PowerShell.

Quick Answer

Sample Get-Process in a loop with Start-Sleep, calculate CPU delta between samples (because the CPU property is cumulative total seconds, not a percentage), track WorkingSet64 for memory trend, and write each sample row to a CSV for offline analysis.

Sampling Get-Process at Configurable Intervals

The monitoring loop structure is straightforward: define target processes and sampling parameters, then enter a timed loop that captures a snapshot on each iteration.

param (
    [string[]] $ProcessNames  = @("sqlservr", "w3wp", "svchost"),
    [int]      $IntervalSec   = 10,
    [int]      $DurationMin   = 30,
    [string]   $OutputCsv     = "ProcessTrend-$(Get-Date -Format yyyyMMdd-HHmm).csv"
)

$samples      = [System.Collections.Generic.List[PSCustomObject]]::new()
$endTime      = (Get-Date).AddMinutes($DurationMin)
$sampleNumber = 0

Write-Host "Monitoring started. Duration: $DurationMin min, Interval: $IntervalSec sec"
Write-Host "Output: $OutputCsv"
Write-Host "Press Ctrl+C to stop early."

while ((Get-Date) -lt $endTime) {
    $sampleNumber++
    $timestamp = Get-Date

    # Capture snapshot for target processes
    $snapshot = Get-Process -Name $ProcessNames -ErrorAction SilentlyContinue

    # ... processing happens in subsequent sections
    Start-Sleep -Seconds $IntervalSec
}

Calculating CPU Percentage with Process.CPU Delta

The CPU property on a Process object is the total processor time consumed since the process started — measured in seconds. To derive a meaningful CPU percentage for the sampling interval, you must subtract the previous sample’s CPU value and divide by elapsed time.

$prevSnapshot = @{}   # keyed by process ID

while ((Get-Date) -lt $endTime) {
    $timestamp = Get-Date
    $snapshot  = Get-Process -Name $ProcessNames -ErrorAction SilentlyContinue

    foreach ($proc in $snapshot) {
        $cpuDelta   = 0
        $prevCpuSec = $prevSnapshot[$proc.Id]

        if ($null -ne $prevCpuSec -and $proc.CPU -ne $null) {
            $elapsed  = $IntervalSec   # seconds between samples
            $cpuDelta = [math]::Round(
                (($proc.CPU - $prevCpuSec) / $elapsed) * 100, 1
            )
        }

        $prevSnapshot[$proc.Id] = $proc.CPU
    }
    Start-Sleep -Seconds $IntervalSec
}

Divide by the number of logical processors ([Environment]::ProcessorCount) if you want per-core percentage rather than total across all cores.

Tracking Memory Growth with WorkingSet64 Over Time

WorkingSet64 is the process’s current private working set in bytes — the most useful single memory figure for leak detection. Track it across samples and flag processes whose usage grows monotonically without bounds.

$memBaseline = @{}   # keyed by process ID, stores first-sample WorkingSet64

foreach ($proc in $snapshot) {
    if (-not $memBaseline.ContainsKey($proc.Id)) {
        $memBaseline[$proc.Id] = $proc.WorkingSet64
    }

    $memGrowthMB = [math]::Round(
        ($proc.WorkingSet64 - $memBaseline[$proc.Id]) / 1MB, 1
    )

    $sample = [PSCustomObject]@{
        SampleNo    = $sampleNumber
        Timestamp   = $timestamp.ToString("o")
        ProcessName = $proc.Name
        PID         = $proc.Id
        CPU_Pct     = $cpuDelta
        WorkingSet_MB = [math]::Round($proc.WorkingSet64 / 1MB, 1)
        MemGrowth_MB  = $memGrowthMB
    }
    $samples.Add($sample)
}

Storing Samples in a Growing Array or CSV

For short runs, an in-memory list is fine. For long-running monitors, stream each sample directly to CSV to avoid accumulating large objects in RAM.

# Streaming append to CSV — safe for hours-long runs
$sampleHeader = -not (Test-Path $OutputCsv)

$sample | Export-Csv -Path $OutputCsv `
                     -Append `
                     -NoTypeInformation `
                     -Encoding UTF8

if ($sampleHeader) {
    Write-Host "CSV created: $OutputCsv"
}

The -Append flag on Export-Csv adds rows without rewriting the header. This means the file grows incrementally and remains readable even if the script is stopped early.

Detecting Processes Exceeding Defined Thresholds

Evaluate each sample against configurable thresholds and mark the row as an anomaly.

param (
    [double] $CpuAlertPct   = 85.0,
    [double] $MemAlertMB    = 2048.0,
    [double] $GrowthAlertMB = 500.0
)

$isAlert = (
    $sample.CPU_Pct       -gt $CpuAlertPct    -or
    $sample.WorkingSet_MB -gt $MemAlertMB     -or
    $sample.MemGrowth_MB  -gt $GrowthAlertMB
)

$sample | Add-Member -MemberType NoteProperty -Name Alert -Value $isAlert -Force

if ($isAlert) {
    Write-Warning "ALERT: $($sample.ProcessName) (PID $($sample.PID)) — CPU: $($sample.CPU_Pct)%, Mem: $($sample.WorkingSet_MB) MB, Growth: $($sample.MemGrowth_MB) MB"
}

Alerting When a Runaway Process Is Detected

Persist alerts separately and optionally send an email or write to the Windows event log.

if ($isAlert) {
    $alertMessage = "Process alert on $env:COMPUTERNAME at $($sample.Timestamp): " +
                    "$($sample.ProcessName) PID $($sample.PID) — " +
                    "CPU $($sample.CPU_Pct)%, Memory $($sample.WorkingSet_MB) MB"

    # Write to Application event log
    Write-EventLog -LogName Application `
                   -Source "PSProcessMonitor" `
                   -EventId 9001 `
                   -EntryType Warning `
                   -Message $alertMessage `
                   -ErrorAction SilentlyContinue

    # Send email alert (requires SMTP relay)
    Send-MailMessage -To "[email protected]" `
                     -From "[email protected]" `
                     -Subject "Process Alert: $($sample.ProcessName)" `
                     -Body $alertMessage `
                     -SmtpServer "smtp.corp.local" `
                     -ErrorAction SilentlyContinue
}

Common Errors

  • CPU property is total seconds since process start, not a rate: Displaying $proc.CPU directly as a percentage is incorrect — it will grow unboundedly throughout the process lifetime. Always delta between two consecutive samples and divide by elapsed seconds as shown above.
  • Get-Process returns CPU as $null for some system processes: The System Idle Process and certain kernel processes may return $null for the CPU property. Guard all CPU delta calculations with a $null -ne $proc.CPU check to prevent divide-by-zero or NullReferenceException errors.

Related Cmdlets / See Also

Wrapping Up

Point-in-time snapshots miss the gradual leaks and transient spikes that cause real incidents. Sample at short intervals, delta the CPU property between samples, stream directly to CSV for long runs, and set threshold alerts for early warning. The resulting time-series CSV gives you the evidence trail needed to justify a service restart or escalate to a developer.

Send-Item -To