Skip to content

About

Tracks your network every 2 seconds and tells you exactly where the connection failed (your Wi-Fi radio, your router, DNS, or your ISP) so you know if it's your problem or theirs. Keeps every sample permanently and generates evidence for an ISP support ticket.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

127 Commits

Folders and files

Repository files navigation

NetWatcher

A macOS menu bar app that checks your connection every 2 seconds and tells you which part of it broke: your Wi-Fi, your router, DNS, or your ISP.

CI License: MIT Platform: macOS 15+

Everything stays on your machine. Nothing is uploaded, nothing is fetched, and there are no dependencies to resolve: it builds against Apple's system frameworks and the system SQLite.

The connection path from your Mac through the Wi-Fi router and ISP to the internet, with the four probe lanes showing how far each reaches and three points where a break can occur.

Why not just a ping script

A ping in a loop tells you that something broke. It cannot tell you what, and "what" is the only part you can act on.

NetWatcher probes several points along the same chain on every tick: the Wi-Fi link, the router, DNS, the public internet, and a captive-portal check, so the point where the lanes stop passing is the location of the fault. Link down: the radio. Link up, router silent: your router. Router answering, internet silent: your ISP, and there is nothing on your side to fix. Everything passing but the answers wrong: you are behind a captive portal.

That is the difference between rebooting a router at 9pm and knowing you don't have to. It is also the difference between a support call you lose and one you win. The report NetWatcher writes can state, of a single outage, that the router answered 2,700 of 2,700 measured ticks and the internet answered 0 of 2,700, timestamped, measured every two seconds, and quoted from raw samples that were never rewritten to agree with the verdict. That is a claim an ISP cannot wave away.

What it was built for

This was not a hypothetical. It was written to diagnose a real problem on a new Optimum line, after the escalation tier had already looked at it and not found the cause. Sampling the chain continuously showed the break was consistently past the router rather than at it (the local segment kept answering while the path beyond it did not), which pointed at the line outside the house rather than anything on the network inside it. That was specific enough for the technician who came out to look at the pole, where the fault turned out to be, and to fix it.

The point of the tool is that last step. "My internet is bad" gets you a router reboot. "The router answered every tick and the internet answered none, for this long, on these dates" gets you someone looking at the right thing.

What it looks like

A menu bar agent that samples continuously, and a dashboard with five panes: the live view, the timeline behind it, the outage table, the insights those outages add up to, and the raw log with CSV and JSON Lines export. Plus the report.

The first page of a generated connection report: 99.71% uptime over 10 days 14 hours of samples, a breakdown showing 5 of 6 outages were beyond the local network, a cause table, and a status band with sleep periods hatched.

The Timeline pane over seven days: 99.6% up over 134h 41m observed, with 33h 49m marked not watching and excluded, a status band of green uptime with hatched sleep periods and red outage marks, and a latency band beneath sharing the same axis.

The Insights pane: an hour-of-week outage heatmap that leaves never-watched hours blank rather than drawing them as quiet, and a finding stating that signal strength does not predict this connection's outages.

The ISP report is the artifact the rest of it exists to produce: one self-contained HTML file stating the uptime and the time it was measured over, listing every outage with the probe results behind its classification, and explaining how the numbers were arrived at. It loads nothing from the internet, contains no scripts, and leaves access point addresses out unless you explicitly ask for them. Attach it to a support ticket, or print it to PDF.

Quick start

git clone https://github.com/RajParmar2003/NetWatcher.git
cd NetWatcher
make run

That builds the release binary, assembles dist/NetWatcher.app, ad-hoc signs it, and opens it. The icon appears in the menu bar: by default there is no Dock icon and no window until you ask for one, which is the right shape for something that runs all day. If you would rather have it in the Dock, Diagnostics › Appearance › Show in Dock switches the app to a normal Dock icon and ⌘-Tab entry; the menu bar icon stays either way, closing the dashboard window keeps it collecting, and the choice is remembered across launches. First run creates ~/Library/Application Support/NetWatcher/netwatcher.sqlite, and sampling starts immediately.

The menu bar icon is a stack of four bars widening upward: the WiFi radio at the bottom, the router above it, the path out through DNS and the ISP above that, and the wider internet at the top. The solid bars are how far up that chain the connection still reaches, so a router that has stopped answering (one solid bar) looks different from an ISP outage (two) without opening anything. All four solid is a healthy line; three is degraded. It is a template image, so it works on a light or dark menu bar and carries no state in colour, which also means it still says the same thing to a colour-blind user. Samples failing to reach the database replaces the mark with a storage symbol, because that is data being lost rather than a reading to re-take.

Click it for the current verdict and today's uptime, or open the dashboard from there. Quit from that menu rather than force-quitting, so the session is closed cleanly and the next launch records a quit gap rather than a crash.

Command What it does
make run Build dist/NetWatcher.app and open it
make app Assemble and ad-hoc sign the bundle only
make install Copy the bundle to /Applications
make demo-db Write a reproducible demo database to demo.sqlite
make run-cli Headless collector in the terminal, same engine
make test Run the test suite
swift run netwatcher-cli report --out report.html Write an ISP report without opening the app

If something looks wrong, the app records what happened at launch:

log show --style compact --last 10m --predicate 'subsystem == "app.netwatcher.NetWatcher"'

Requirements

Xcode command line tools with a Swift 6 toolchain, and macOS 15 or later, the floor CI actually builds, tests and bundles on (macOS 15 and macOS 26; development is on macOS 26.6 with Swift 6.3). The macOS 14 caveat is below.

The demo database

An empty database is a poor way to judge a tool that diagnoses outages, and a real one takes days to become interesting. So there is a generated one:

make demo-db                                          # writes demo.sqlite, 14 days
open dist/NetWatcher.app --args --db "$PWD/demo.sqlite"

Same seed, same database, every time: fourteen days of samples with real outages, overnight sleep gaps, degraded stretches and two fictional networks. It is what the screenshots above were taken against, because a real database names the machine, dates every night its owner slept, lists every network they have joined, and (on a healthy line) contains no outages at all.

--db works for any database, not just the demo: one copied over from another machine, or a second one you want to keep separate. A path that can't be opened fails visibly rather than quietly falling back to the default, and whenever the database in use isn't the default one its path is named in Diagnostics and, in a small unobtrusive line, on the Now view too.

How it works

Every 2 seconds NetWatcher probes the connection at several distances down the same chain. It isn't guessing; it is finding the boundary between the lanes that still work and the ones that don't.

Three details the diagram flattens slightly:

Lanes 1 and 2 reach the same box but prove different things. Lane 1 is radio-level: your Mac is associated with the access point, with a signal strength. Lane 2 is IP-level: the router will actually answer packets. You can absolutely have 1 pass while 2 fails (strong bars, dead router), and that combination is precisely case B.

Lane 3's endpoint is a simplification. Your DNS resolver is usually the router itself, forwarding upstream to the ISP. It's drawn terminating at the ISP because that's the furthest it depends on: a DNS failure means something in that whole forwarding chain broke, which is why lane 3 dying while lane 4 lives is the signature of "DNS is the problem, the connection is fine."

There's a fourth case the path can't show: everything passes but the answer is wrong. A captive portal (hotel, airport, café) lets every lane succeed while silently intercepting your traffic. That's why lane 4 checks the content of the response from captive.apple.com rather than just whether bytes came back: a 200 with the wrong body means "sign in", not "outage".

Cadence, and why not everything runs every tick

Lane Cadence
link, gateway, internet every tick (2 s)
dns, captive portal every 30 s, or every tick while something is failing
destination checks every 5 min, or every 30 s while the internet lane is failing

Every 2 seconds is 43,200 requests a day per install. ICMP at that rate is ordinary monitoring traffic. Recursive DNS queries and HTTPS requests to third-party servers at that rate are not: the predictable result is rate-limiting, and NetWatcher would then report someone else's rate limiter as your outage. A monitoring tool must not provoke the failure it measures. So the slow tiers idle and escalate to full resolution the instant anything fails, exactly when the answers matter, and never when they don't.

Nothing here depends on one company being up

The DNS check asks for a random label under a random nonexistent top-level domain (a7f3k2z9xkqp.mvqhtdzblnrw), which no cache can answer and no domain owner pays for. The root servers reply "no such name", and that reply is the proof your resolver can reach the outside world.

The internet check pings three public resolvers run by unrelated operators: Cloudflare, Quad9 and Google, and counts as failed only when every one goes quiet, so one company deprioritising ping traffic never shows up in your report as your packet loss.

Pings prove packets reach public infrastructure. They do not prove the places you actually go are reachable, and "I can ping 1.1.1.1 but Google won't load" is a real and confusing state. So NetWatcher also asks google.com, bing.com and duckduckgo.com for a response over HTTPS. Any response counts, a redirect or an error page included: the question is whether the destination answers, not what it says.

Outage alerts

When an outage is confirmed (three consecutive down samples, not one dropped tick), the app tells you, and it tells you again when the connection comes back, naming the cause and how long it lasted. Three independent channels, each switchable in the dashboard's Outage alerts section:

Channel Availability
System notification Once you allow the prompt on first launch
Sound Always
NetWatcher's own alert panel Always (a floating panel the app draws itself)

Notifications work. A freshly cloned, ad-hoc signed build raises the standard "NetWatcher would like to send you notifications" prompt on first launch, and allowing it is all there is to it: no certificate, no configuration, no workaround. Verified on macOS 26, from /Applications and from a build directory alike.

The default is a sound plus a system notification, with the app's own panel filling in only when a system notification cannot be delivered, so an outage is still announced on a machine where the prompt was declined, and never announced twice on one where it wasn't.

The panel is a non-activating floating window: it appears top-right where a notification would have, over full-screen apps, without taking focus from what you were typing into, and it leaves on its own after twenty seconds. Not an NSAlert: a modal dialog for an event you cannot act on is a punishment, not a notice.

Every alert is written to the log with the channels it actually used, so "did it fire, and how" is a question with an answer:

log show --last 1h --predicate 'subsystem == "app.netwatcher.NetWatcher"' | grep alert:
# alert: channels=panel,sound title=Internet down body=ISP outage; your equipment is fine
# alert: channels=panel,sound title=Back online body=ISP outage; down for 24 seconds.

What it records

Every 2-second sample is written to SQLite immediately and is never deleted, pruned, or downsampled. A sample holds the result and latency of every lane, the signal readings (RSSI, noise, tx rate, channel), the network name, and the interface actually carrying traffic.

That's 43,200 samples per day, roughly 1.1 GB per year, which NetWatcher discloses in its own UI. Hourly rollups are maintained alongside the raw data purely so a year of history can be charted instantly; they are derived data and can be rebuilt from the raw samples at any time.

The destination checks live in a table of their own rather than in columns on samples: they happen 288 times a day and a sample happens 43,200 times, so a column would be mostly nulls, written and indexed and kept forever. A round that did not run is stored as nothing at all, never as the previous round carried forward.

Periods the app genuinely could not observe (system sleep, or the app being quit) are recorded as explicit gaps rather than invented data, and are excluded from uptime percentages. An overnight sleep must never read as an outage.

The database is location-sensitive. It records the hardware addresses (BSSIDs) of every access point you connect to, and those are geolocatable through public databases, so over time the file becomes a detailed record of where this machine has been. It is created readable only by you, and BSSIDs are redacted by default from any report you share with an ISP. Treat un-redacted exports as personal location data.

Honest limitations

Things this project knows it has not proven, stated rather than left to be discovered.

  • Sleep gap recording has never been observed firing in the wild. The code path that records a sleep gap is covered by tests and by synthetic wake notifications; an actual overnight sleep producing an actual gap row has not been watched happening. Treat it as implemented, not as verified.
  • Notifications depend on a prompt being answered. macOS keeps the authorization record against the bundle identifier, and a first prompt that is dismissed (or that is on screen when the app is quit) becomes a permanent denied that the app can never ask again about. It is not the ad-hoc signature's doing (the same binary under an identifier macOS has no record for prompts and is granted normally), but the effect is the same, and the only remedy afterwards is the switch in System Settings › Notifications, which the dashboard links to directly. This is why notifications are one channel of three rather than the mechanism.
  • macOS 14 is compiled against but never run on. The binary carries a macOS 14.0 deployment target and CI fails if that drifts, so the compiler rejects any API newer than 14.0. That is a real guarantee but a narrower one than it looks: it means nothing here needs macOS 15, not that macOS 14 works. No macOS 14 machine has ever run this. GitHub's macOS 14 runner image entered deprecation in July 2026, so a CI job pinned to it would expire within weeks of being written, and an expired job is a worse claim than an honest sentence. Treat macOS 14 as probably fine, never verified.
  • Reading the network name requires Location permission. Since macOS Sonoma, Apple gates the Wi-Fi SSID behind Location Services. Signal strength, channel, and every probe work regardless; refuse the prompt and the network simply shows as "Wi-Fi (name unavailable)".
  • NetWatcher cannot sample while the Mac is asleep. No user-space process can. Those periods become gaps, not guesses.
  • Turning "Show in Dock" on mid-session leaves the menu bar a beat behind. The Dock icon and the ⌘-Tab entry appear immediately, but macOS hands the newly promoted app the application menu only at the next real activation: click a NetWatcher window or its Dock icon and it is there. Every launch after that starts in the chosen mode with the menu already correct.
  • Launch at login depends on the code signature. A locally built app is ad-hoc signed, and macOS may refuse to register it, particularly from outside /Applications. The app never assumes it worked: the dashboard shows the state macOS actually holds and says what to do if registration was refused. make install is the supported placement, and a login item registered from a build directory will break the moment you run make clean.
  • Upgrading from a build older than 0.4 costs two small things. The bundle identifier changed to app.netwatcher.NetWatcher in 0.4, and macOS keys two per-app records by identifier. Login-item registrations: System Settings › General › Login Items may now list NetWatcher twice, and the stale one has to go with the − button, because SMAppService can only ever speak for the running bundle's own identifier and no public API reaches another's. Preferences: your alert switches return to their defaults, a sound plus a system notification, with the app's own panel filling in when a notification cannot be delivered, which is the combination that works on every machine anyway. Nothing else moves; the database is addressed by path, not by identifier, and is untouched.

Reading the code

docs/ARCHITECTURE.md is the hour-saving tour: the two-target split, what happens to a sample between a probe and the disk, why outages are an interpretation stored beside the raw samples rather than instead of them, and why the samples table is not keyed on the wall clock. docs/DESIGN.md is the detailed document behind it, docs/DESIGN-REVIEW.md is the review that design was put through and what it changed, and CONTRIBUTING.md covers building, testing and the two rules the codebase actually enforces.

CHANGELOG.md is the shorter read: what the software does and the defects worth remembering from building it.

Every push builds, tests and bundles the app on macOS 15 and macOS 26 (.github/workflows/ci.yml).

License

MIT. See LICENSE.

About

Tracks your network every 2 seconds and tells you exactly where the connection failed (your Wi-Fi radio, your router, DNS, or your ISP) so you know if it's your problem or theirs. Keeps every sample permanently and generates evidence for an ISP support ticket.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages