SCRIPT LIBRARY · POWERSHELL
A Ping Monitor That Logs Every Outage to CSV
Ping a handful of hosts on a timer, log every reply and every up/down change to CSV, and get a tidy list of outages with start, end and duration when you stop it.
- What it does
- Pings one or more hosts every few seconds, appends each result (time, host, up or down, latency, state change) to a CSV, prints outages and recoveries as they happen, and returns a per-host summary with loss, latency and every outage when it stops.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- ICMP echo allowed through any firewalls between you and the targets
- Permissions
- None. Any user can send pings and write to their own folders.
- Runs on
- Windows 10/11, Windows Server 2016+, and PowerShell 7 on macOS or Linux
- Tested
- Parse-checked and run in PowerShell 7.4 on Linux against 127.0.0.1, an unroutable 192.0.2.10 and a name that doesn't resolve; outage and recovery logic run against a scripted up/down sequence; Q and Ctrl+C both tested in a terminal and still returned the summary
"The connection keeps dropping." When? For how long? Just the NAS, or the router too? Nobody knows, because nobody was watching at 2:14 in the afternoon when it happened. A continuous ping in a spare window helps a little, until you scroll back and try to piece together a timeline from a thousand lines of "Reply from".
This started as a tiny personal loop that pinged one address every second and wrote "Success" or "Fail" to a text file forever. It did the job, sort of. This version watches several hosts at once, records latency, only calls something down after a couple of misses in a row (so one lost packet on Wi-Fi doesn't count), and writes a CSV you can open in Excel.
The best part is the ending. Press Ctrl+C or Q, or let -Duration run out, and it hands you one summary per host with every outage listed: when it started, when it came back, and how long it lasted. That's the bit you paste into the ticket or show the ISP.
<#
.SYNOPSIS
Pings one or more hosts on an interval, logs every reply to CSV, and summarizes the outages when you stop it.
.DESCRIPTION
Sends one ICMP echo to each target every -IntervalSeconds and appends a row per ping to a CSV:
timestamp, target, Up or Down, latency, and whether the target just changed state. A target is
only marked down after -DownAfter misses in a row, so one dropped packet on Wi-Fi doesn't count
as an outage. Up/down changes are also written to the console as they happen.
Runs until -Duration is up, or until you press Ctrl+C or Q. Either way it finishes cleanly and
returns one summary object per target, including every outage with its start, end and length.
.PARAMETER ComputerName
Host names or IP addresses to watch.
.PARAMETER IntervalSeconds
Seconds between rounds of pings. Default: 5.
.PARAMETER Duration
How long to run, as a timespan ('00:30:00', '8:00:00' or New-TimeSpan -Hours 8). Default: until stopped.
.PARAMETER TimeoutMs
How long to wait for each reply, in milliseconds. Default: 1000.
.PARAMETER DownAfter
Consecutive misses before a target counts as down. Default: 2.
.PARAMETER LogPath
CSV file to append to. Default: PingLog-<date>-<time>.csv in the current folder.
.EXAMPLE
.\Watch-PingTarget.ps1 -ComputerName 192.0.2.10, NAS01 -Duration 8:00:00
.EXAMPLE
.\Watch-PingTarget.ps1 -ComputerName gateway.contoso.com -IntervalSeconds 1 -DownAfter 3 -LogPath .\gateway.csv
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('CN', 'Target', 'IPAddress')]
[ValidateNotNullOrEmpty()]
[string[]]$ComputerName,
[ValidateRange(1, 3600)]
[int]$IntervalSeconds = 5,
[timespan]$Duration = [timespan]::Zero,
[ValidateRange(100, 60000)]
[int]$TimeoutMs = 1000,
[ValidateRange(1, 100)]
[int]$DownAfter = 2,
[string]$LogPath = (Join-Path (Get-Location) ('PingLog-{0:yyyyMMdd-HHmmss}.csv' -f (Get-Date)))
)
begin { $targets = [System.Collections.Generic.List[string]]::new() }
process { foreach ($name in $ComputerName) { if (-not $targets.Contains($name)) { $targets.Add($name) } } }
end {
$LogPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($LogPath)
$logDir = Split-Path -Path $LogPath -Parent
if ($logDir -and -not (Test-Path -LiteralPath $logDir)) { $null = New-Item -ItemType Directory -Path $logDir -Force }
# Per-target running state.
$state = [ordered]@{}
foreach ($t in $targets) {
$state[$t] = [pscustomobject]@{
Name = $t; IsDown = $false; Misses = 0; FirstMiss = $null; Sent = 0; Received = 0
TotalMs = 0L; MaxMs = 0L; Outages = [System.Collections.Generic.List[object]]::new()
}
}
# Ctrl+C normally kills the script mid-loop. Treating it as a keypress lets us stop cleanly and still print the summary.
$trapKeys = $false
if (-not [Console]::IsInputRedirected) {
try { [Console]::TreatControlCAsInput = $true; $trapKeys = $true } catch { $trapKeys = $false }
}
if (-not $trapKeys) { Write-Verbose 'No interactive console, so Ctrl+C ends the script without a summary. Use -Duration.' }
$pinger = [System.Net.NetworkInformation.Ping]::new()
$started = Get-Date
$stopAt = if ($Duration -gt [timespan]::Zero) { $started + $Duration } else { [datetime]::MaxValue }
$stop = $false
Write-Host ("Watching {0} every {1}s. Logging to {2}. Press Ctrl+C or Q to stop." -f ($targets -join ', '), $IntervalSeconds, $LogPath)
try {
while (-not $stop -and (Get-Date) -lt $stopAt) {
$roundStart = Get-Date
foreach ($t in $targets) {
$s = $state[$t]
$now = Get-Date
$latency = $null; $status = 'Down'; $detail = $null
try {
$reply = $pinger.Send($t, $TimeoutMs)
if ($reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) { $status = 'Up'; $latency = $reply.RoundtripTime }
else { $detail = [string]$reply.Status }
}
catch {
# Usually a name that won't resolve. Log it as a miss rather than stopping the whole watch.
$detail = $_.Exception.GetBaseException().Message
}
$s.Sent++
$change = $null
if ($status -eq 'Up') {
$s.Received++; $s.TotalMs += $latency
if ($latency -gt $s.MaxMs) { $s.MaxMs = $latency }
if ($s.IsDown) {
$outage = $s.Outages[$s.Outages.Count - 1]
$outage.Ended = $now
$outage.Duration = $now - $outage.Started
$change = 'CameBack'
Write-Host ("{0:HH:mm:ss} {1} is back after {2:hh\:mm\:ss}" -f $now, $t, $outage.Duration) -ForegroundColor Green
}
$s.IsDown = $false; $s.Misses = 0; $s.FirstMiss = $null
}
else {
if ($s.Misses -eq 0) { $s.FirstMiss = $now }
$s.Misses++
if (-not $s.IsDown -and $s.Misses -ge $DownAfter) {
$s.IsDown = $true
$s.Outages.Add([pscustomobject]@{ ComputerName = $t; Started = $s.FirstMiss; Ended = $null; Duration = $null; Reason = $detail })
$change = 'WentDown'
Write-Host ("{0:HH:mm:ss} {1} is DOWN ({2})" -f $now, $t, $(if ($detail) { $detail } else { 'no reply' })) -ForegroundColor Red
}
}
$row = [pscustomobject]@{
Timestamp = $now.ToString('yyyy-MM-dd HH:mm:ss')
ComputerName = $t
Status = $status
LatencyMs = $latency
Change = $change
Detail = $detail
}
try { $row | Export-Csv -LiteralPath $LogPath -Append -NoTypeInformation -Encoding utf8 -ErrorAction Stop }
catch { Write-Warning "Couldn't write to ${LogPath}: $($_.Exception.Message)" }
}
# Sleep in small slices so a keypress is noticed quickly.
$wakeAt = $roundStart.AddSeconds($IntervalSeconds)
while (-not $stop -and (Get-Date) -lt $wakeAt -and (Get-Date) -lt $stopAt) {
if ($trapKeys -and [Console]::KeyAvailable) {
$key = [Console]::ReadKey($true)
if ($key.Key -eq 'Q' -or ($key.Key -eq 'C' -and ($key.Modifiers -band [ConsoleModifiers]::Control))) { $stop = $true }
}
Start-Sleep -Milliseconds 200
}
}
}
finally {
if ($trapKeys) { [Console]::TreatControlCAsInput = $false }
$pinger.Dispose()
}
$ended = Get-Date
foreach ($s in $state.Values) {
# An outage still open at the end runs to the moment we stopped.
foreach ($o in $s.Outages | Where-Object { -not $_.Ended }) { $o.Duration = $ended - $o.Started }
$down = [timespan]::Zero
foreach ($o in $s.Outages) { $down += $o.Duration }
[pscustomobject]@{
ComputerName = $s.Name
Sent = $s.Sent
Received = $s.Received
LossPercent = if ($s.Sent) { [math]::Round(100 * ($s.Sent - $s.Received) / $s.Sent, 1) } else { 0 }
AvgMs = if ($s.Received) { [math]::Round($s.TotalMs / $s.Received, 1) } else { $null }
MaxMs = if ($s.Received) { $s.MaxMs } else { $null }
Outages = $s.Outages.Count
TotalDowntime = $down
LongestOutage = ($s.Outages | Sort-Object Duration -Descending | Select-Object -First 1).Duration
StillDown = $s.IsDown
OutageList = $s.Outages.ToArray()
LogPath = $LogPath
}
}
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-ComputerName | string[] | — | Host names or IP addresses to watch. Required. Takes pipeline input. |
-IntervalSeconds | int | 5 | Seconds between rounds of pings. |
-Duration | timespan | until stopped | How long to run, like '00:30:00' or '8:00:00'. Leave it off and it runs until you press Ctrl+C or Q. |
-TimeoutMs | int | 1000 | How long to wait for each reply before calling it a miss. |
-DownAfter | int | 2 | Misses in a row before a host counts as down. Set it to 1 if every lost packet matters. |
-LogPath | string | PingLog-<date>-<time>.csv | The CSV to append to. The folder is created if it doesn't exist. |
Run it
Watch the gateway and the NAS through the workday.
.\Watch-PingTarget.ps1 -ComputerName 192.0.2.1, NAS01 -Duration 8:00:00Every second, with a hair trigger, to a named log.
.\Watch-PingTarget.ps1 -ComputerName gateway.contoso.com -IntervalSeconds 1 -DownAfter 1 -LogPath .\gateway.csvKeep the summary and list every outage afterwards.
$r = .\Watch-PingTarget.ps1 -ComputerName 192.0.2.10, NAS01 -Duration 1:00:00; $r.OutageList | Format-TableChart latency later by pulling the CSV back in.
Import-Csv .\gateway.csv | Where-Object Status -eq 'Up' | Measure-Object LatencyMs -Average -MaximumWhat you'll see
Watching 192.0.2.1, NAS01 every 5s. Logging to C:\Temp\PingLog-20260929-090000.csv. Press Ctrl+C or Q to stop.
14:14:05 NAS01 is DOWN (TimedOut)
14:16:40 NAS01 is back after 00:02:35
16:02:11 192.0.2.1 is DOWN (TimedOut)
16:02:21 192.0.2.1 is back after 00:00:10
ComputerName : 192.0.2.1
Sent : 5760
Received : 5758
LossPercent : 0
AvgMs : 1.2
MaxMs : 18
Outages : 1
TotalDowntime : 00:00:10.0421337
LongestOutage : 00:00:10.0421337
StillDown : False
ComputerName : NAS01
Sent : 5760
Received : 5728
LossPercent : 0.6
AvgMs : 0.9
MaxMs : 41
Outages : 1
TotalDowntime : 00:02:35.1180452
LongestOutage : 00:02:35.1180452
StillDown : False
How it works
- Collect the targets. Names come in from the parameter or the pipeline, and duplicates are dropped, so
Get-Content hosts.txt | .\Watch-PingTarget.ps1works fine. - Take over Ctrl+C. Normally Ctrl+C kills a script mid-loop and whatever it was about to print is lost. Setting
[Console]::TreatControlCAsInputturns it into an ordinary keypress, which the loop checks for between rounds (along with Q). If there's no real console, it skips this and says so with-Verbose. - Ping each host once per round. It uses .NET's
Pingclass instead ofTest-Connection, which behaves differently between Windows PowerShell and PowerShell 7. A reply gives a latency; a timeout, an unreachable host or a name that won't resolve is a miss, with the reason kept for the log. - Debounce the outages. A host only goes down after
-DownAftermisses in a row, and the outage is backdated to the first miss. The first good reply closes it and prints how long it lasted. - Log every ping. Each result becomes a CSV row with
Changeset toWentDownorCameBackon the rows where the state flipped, so filtering the file for outages is oneWhere-Object. - Summarize. When it stops, any outage still open is closed at the stop time, and you get one object per host: sent, received, loss, average and worst latency, total downtime, the longest outage, and the full
OutageList.
If the drops line up with a server restarting rather than the network, find out why it rebooted before you blame the switch.
Take it further
- Run it unattended. Register it as a scheduled task with
-Duration 23:59:00and a daily trigger, and you get one CSV per day without leaving a window open. - Alert when something drops. Swap the
Write-Hostin theWentDownbranch for a Teams or Slack webhook call, and you'll know about the outage while it's still happening. - Test more than ICMP. For a service that blocks ping,
Test-NetConnection -ComputerName NAS01 -Port 445checks the port that actually matters. The same loop works with that in place of the ping.
Things that'll trip you up
- No reply doesn't always mean down. Windows Firewall blocks inbound ping by default on a lot of machines, and plenty of servers and cloud hosts drop ICMP on purpose. Check a host answers at all before you trust a day of "Down".
- Ctrl+C only gives you the summary in a real console. The script tells the console to treat Ctrl+C as a keypress so it can stop cleanly. In the ISE, a scheduled task or anything with redirected input that isn't possible, so Ctrl+C just ends it without the summary. Use -Duration there. The CSV is written as it goes either way.
- Slow timeouts stretch the interval. Each round pings the hosts one after another, so three dead hosts with a 1000 ms timeout add three seconds to every round. Lower -TimeoutMs or raise -IntervalSeconds if the timestamps start drifting.
- Don't point it at someone else's server every second. A ping a second from one machine is nothing. From a script you left running on fifty machines against a public host, it starts to look like abuse. Your own gear is fair game.