ScreenshotNeo

BlogHow-to

How to Write Windows Server Monitoring Scripts

Build reliable PowerShell monitoring scripts for Windows Server: discover counters, collect events, sample remotely, retain history, and troubleshoot safely.

By the ScreenshotNeo team1 October 20268 min read

Use PowerShell performance counters for resource and performance questions, and Windows event logs for recorded system or application events. A useful monitoring script discovers counter paths on the target server, collects samples at one second or slower, writes timestamped history, and reports failures without stopping the whole run.

What to monitor and which data source to use

Question Use Typical command
Is CPU, memory, disk, or network usage changing? Performance counters Get-Counter
Did a service, driver, or application record an error? Windows event logs Get-WinEvent
Do you need history across an incident? Data collector log logman.exe
Do you need sub-second application profiling? ETW or a direct profiling API Outside the normal performance-counter workflow

Microsoft describes Windows Performance Counters as optimized for administrative and diagnostic discovery and collection. They are not intended for high-frequency profiling; keep collection at one second or slower unless you have a different telemetry design.

Prerequisites and safe defaults

  • Run PowerShell with an account allowed to query the local or remote computer.
  • Confirm that the required counter sets exist on the target host. Counter names are localized, so an English path may not exist on a non-English installation.
  • Choose a sample interval of at least one second. Five or ten seconds is often sufficient for operational monitoring and creates less data.
  • Write output to a directory with enough space and rotate or archive old files.
  • Use a bounded run for scheduled checks. Use continuous collection only when a long-lived process and log rotation are planned.

Discover counter sets and exact paths

Always discover and validate paths on the server where the script will run.

# List every counter set available on this server
Get-Counter -ListSet *

# Inspect paths in the Memory counter set
(Get-Counter -ListSet Memory).Paths

# Inspect processor paths, including per-core instances
(Get-Counter -ListSet Processor).Paths

# Test one path before putting it into a scheduled script
Get-Counter -Counter '\\Processor(_Total)\\% Processor Time' -SampleInterval 5 -MaxSamples 1

If a path fails, copy the path returned by Get-Counter -ListSet instead of guessing its spelling. On localized systems, discover the local name or use a counter set and path available in that installation.

Write a bounded local monitoring script

This script samples CPU, available memory, and the system disk for twelve samples, then writes one CSV row per sample. Adjust paths after discovery.

param(
    [int]$SampleIntervalSeconds = 5,
    [int]$MaxSamples = 12,
    [string]$OutputDirectory = 'C:\\ProgramData\\ServerMonitor'
)

$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null

$counters = @(
    '\\Processor(_Total)\\% Processor Time',
    '\\Memory\\Available MBytes',
    '\\LogicalDisk(_Total)\\% Free Space'
)

$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$outputPath = Join-Path $OutputDirectory "performance-$timestamp.csv"

try {
    $samples = Get-Counter -Counter $counters `
        -SampleInterval $SampleIntervalSeconds `
        -MaxSamples $MaxSamples

    $rows = foreach ($sample in $samples) {
        $values = @{}
        foreach ($counterSample in $sample.CounterSamples) {
            $values[$counterSample.Path] = [math]::Round($counterSample.CookedValue, 2)
        }

        [pscustomobject]@{
            Computer       = $env:COMPUTERNAME
            Timestamp      = $sample.Timestamp
            CpuPercent     = $values['\\Processor(_Total)\\% Processor Time']
            AvailableMB    = $values['\\Memory\\Available MBytes']
            DiskFreePct    = $values['\\LogicalDisk(_Total)\\% Free Space']
        }
    }

    $rows | Export-Csv -Path $outputPath -NoTypeInformation
    Write-Host "Wrote $($rows.Count) samples to $outputPath"
}
catch {
    Write-Error "Performance collection failed: $($_.Exception.Message)"
    exit 1
}

-SampleInterval controls the delay between samples and -MaxSamples bounds the run. Without a bound, a scheduled task can continue indefinitely if the script is changed or called incorrectly.

Collect from a remote server

Get-Counter can query another computer. Test connectivity and permissions first, then use the exact paths discovered on the target.

$computer = 'Server01'
$counter = '\\Processor(_Total)\\% Processor Time'

Get-Counter -ComputerName $computer `
    -Counter $counter `
    -SampleInterval 5 `
    -MaxSamples 12

For several hosts, collect each host independently so one unavailable machine does not discard successful results.

$computers = 'Server01','Server02','Server03'
$counters = '\\Processor(_Total)\\% Processor Time'

foreach ($computer in $computers) {
    try {
        Get-Counter -ComputerName $computer -Counter $counters `
            -SampleInterval 5 -MaxSamples 12 |
            Select-Object -ExpandProperty CounterSamples |
            Select-Object @{Name='Computer';Expression={$computer}}, Path, CookedValue, Timestamp
    }
    catch {
        Write-Warning "$computer failed: $($_.Exception.Message)"
    }
}

Monitor Windows event logs

Use Get-WinEvent when the question concerns events rather than continuously changing resource values. Filter by log, time, level, provider, or event ID.

$since = (Get-Date).AddHours(-1)

Get-WinEvent -FilterHashtable @{
    LogName   = 'System'
    StartTime = $since
    Level     = 1,2,3 # Critical, Error, Warning
} |
Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message |
Export-Csv 'C:\\ProgramData\\ServerMonitor\\system-events.csv' -NoTypeInformation

Remote event retrieval is also supported. Use -ComputerName where the account and remoting configuration permit it.

Get-WinEvent -ComputerName 'Server01' -FilterHashtable @{
    LogName = 'Application'
    Id      = 1000
    StartTime = (Get-Date).AddDays(-1)
}

Run a live stream with Continuous

Use -Continuous only for an intentionally long-running console or service. Stop it with Ctrl+C and plan output handling separately.

Get-Counter '\\Memory\\Available MBytes' `
    -SampleInterval 10 `
    -Continuous

For an incident that may last hours, a Performance Monitor data collector is usually easier to retain and analyze than redirecting an endless console stream.

Capture a durable incident log with logman

Microsoft documents logman.exe for creating, starting, and stopping a counter collector. The following is a template; choose the counters, interval, directory, and maximum file size for your environment.

$setName = 'IncidentCapture'
$logFile = 'C:\\PERFLOGS\\IncidentCapture'

New-Item -ItemType Directory -Path 'C:\\PERFLOGS' -Force | Out-Null

logman create counter $setName `
  -c '\\Processor(_Total)\\% Processor Time' '\\Memory\\Available MBytes' `
  -si 00:00:01 `
  -o $logFile `
  -f bincirc `
  -max 2048

logman start $setName
Write-Host 'Collector started. Run logman stop IncidentCapture after the incident.'

# When finished:
# logman stop IncidentCapture

The documented example uses a one-second interval and a 2 GB maximum file size. Those are example settings, not universal requirements. Binary circular logs help cap disk usage, while a longer interval reduces collection overhead.

Choose thresholds with context

A threshold is a workload decision, not a universal health rule. Compare samples with the server’s normal baseline, request latency, queue length, scheduled jobs, and business impact. A short CPU spike during a backup is different from sustained saturation with user-visible delay.

Server Manager documents default alert values of 85% CPU and 2 MB available memory. Treat these as that interface’s defaults, not as generally correct limits for every server. A script should make thresholds configurable:

param(
    [double]$CpuAlertPercent = 85,
    [double]$AvailableMemoryAlertMB = 1024
)

$sample = Get-Counter @(
    '\\Processor(_Total)\\% Processor Time',
    '\\Memory\\Available MBytes'
) -SampleInterval 5 -MaxSamples 1

foreach ($item in $sample.CounterSamples) {
    if ($item.Path -like '*Processor(_Total)*' -and $item.CookedValue -ge $CpuAlertPercent) {
        Write-Warning "CPU is $([math]::Round($item.CookedValue,2))%"
    }
    if ($item.Path -like '*Memory\\Available MBytes' -and $item.CookedValue -le $AvailableMemoryAlertMB) {
        Write-Warning "Available memory is $([math]::Round($item.CookedValue,2)) MB"
    }
}

Schedule and retain the data

  1. Save the script outside user profile directories, such as C:\ProgramData\ServerMonitor.
  2. Run it with Task Scheduler under an account that can read the counters and write the output directory.
  3. Use a bounded sample count for recurring snapshots, or start and stop a logman collector around an incident.
  4. Include the computer name and UTC or clearly labeled local timestamps in every row.
  5. Compress or delete old files according to your retention policy.

A single sample can confirm a current condition but cannot show an intermittent pattern. Retain enough history to compare healthy and degraded periods, while avoiding indefinite growth.

Performance, reliability, and cost considerations

  • Sampling overhead: Windows counters are designed for administrative and diagnostic collection, not high-frequency profiling. Use one second or slower intervals.
  • Remote collection: Network access, firewall rules, permissions, and the remote counter service can all fail independently. Record per-host errors and continue with other hosts.
  • Storage: CSV is easy to inspect but larger than binary logs. Use bounded runs, rotation, or circular collectors.
  • Accuracy: Counter values are samples and aggregates. Correlate them with events and workload timing before changing capacity or alert limits.
  • Failure handling: Catch exceptions around each host or data source. Emit an explicit failure record so a missing sample is not mistaken for a healthy zero.
  • Profiling: If you need sub-second traces or application call stacks, use ETW or a profiling API instead of increasing counter frequency.

Troubleshooting common errors

Error or symptom Cause Fix
The specified counter path could not be found The path is misspelled, unavailable, or localized. Run Get-Counter -ListSet * and copy a path from the target server.
Access is denied The account lacks rights to query the remote host or read the log. Use an approved administrative account and verify the server’s remoting and performance-monitoring permissions.
Remote computer cannot be contacted Name resolution, firewall, RPC, or service configuration prevents access. Test DNS and connectivity, confirm required Windows services and firewall rules, then retry a single counter.
CSV contains no rows The command failed before export or -MaxSamples was invalid. Run the counter command interactively, check the exception, and validate that the output directory is writable.
CPU appears stuck at an unexpected value The wrong instance was selected or the workload is being averaged. Inspect all returned CounterSamples, compare _Total with per-core instances, and correlate with process data.
Memory alert fires constantly The threshold does not match the server’s workload or available-memory counter behavior. Establish a baseline, consider sustained duration instead of one sample, and make the limit configurable.
Long capture fills the disk Unbounded text or binary output has no retention policy. Use circular logging, a maximum file size, rotation, and a scheduled cleanup job.
Events are missing The wrong log, time range, provider, or event level was filtered. Start with a broad time-bounded query, inspect returned fields, then narrow the filter.

Or skip the browser setup

ScreenshotNeo is a website screenshot API, so it is separate from Windows Server telemetry. If your monitoring workflow also needs visual snapshots of a status page, dashboard, or incident report, one request returns an image or PDF without maintaining browser automation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

See the ScreenshotNeo API documentation for options. Cookie banners, newsletter popups, and chat widgets are removed before the shot; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, with verdict and billing headers on each response. Its MCP server lets AI agents take screenshots, and 1,000 screenshots a month are free with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Should I use Get-Counter or Get-WinEvent?

Use Get-Counter for changing resource measurements and Get-WinEvent for recorded events. Many incident investigations use both.

How often should a script sample?

One second is the documented lower boundary for normal performance-counter collection. Use a larger interval when you need lower overhead or longer retention.

Can I hard-code English counter names?

Do not assume they exist everywhere. Discover paths on the target installation because counter names are localized.

Is one sample enough for an alert?

It can identify an immediate condition, but sustained duration and a baseline usually produce fewer false alarms.

When should I use logman?

Use it when you need a durable capture across an incident that can be opened and analyzed later, especially when an interactive PowerShell window is unsuitable.