Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCEWatch-PingTarget.ps1
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.

Watch-PingTarget.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-ComputerNamestring[]—Host names or IP addresses to watch. Required. Takes pipeline input.
-IntervalSecondsint5Seconds between rounds of pings.
-Durationtimespanuntil stoppedHow long to run, like '00:30:00' or '8:00:00'. Leave it off and it runs until you press Ctrl+C or Q.
-TimeoutMsint1000How long to wait for each reply before calling it a miss.
-DownAfterint2Misses in a row before a host counts as down. Set it to 1 if every lost packet matters.
-LogPathstringPingLog-<date>-<time>.csvThe 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:00

Every second, with a hair trigger, to a named log.

.\Watch-PingTarget.ps1 -ComputerName gateway.contoso.com -IntervalSeconds 1 -DownAfter 1 -LogPath .\gateway.csv

Keep the summary and list every outage afterwards.

$r = .\Watch-PingTarget.ps1 -ComputerName 192.0.2.10, NAS01 -Duration 1:00:00; $r.OutageList | Format-Table

Chart latency later by pulling the CSV back in.

Import-Csv .\gateway.csv | Where-Object Status -eq 'Up' | Measure-Object LatencyMs -Average -Maximum

What you'll see

Example outputvalues are illustrative
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

  1. Collect the targets. Names come in from the parameter or the pipeline, and duplicates are dropped, so Get-Content hosts.txt | .\Watch-PingTarget.ps1 works fine.
  2. Take over Ctrl+C. Normally Ctrl+C kills a script mid-loop and whatever it was about to print is lost. Setting [Console]::TreatControlCAsInput turns 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.
  3. Ping each host once per round. It uses .NET's Ping class instead of Test-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.
  4. Debounce the outages. A host only goes down after -DownAfter misses in a row, and the outage is backdated to the first miss. The first good reply closes it and prints how long it lasted.
  5. Log every ping. Each result becomes a CSV row with Change set to WentDown or CameBack on the rows where the state flipped, so filtering the file for outages is one Where-Object.
  6. 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:00 and a daily trigger, and you get one CSV per day without leaving a window open.
  • Alert when something drops. Swap the Write-Host in the WentDown branch 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 445 checks 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.