Unwait

Your download count is lying to you

· 5 min read indie analytics telemetry desktop apps

Our first week showed 9 downloads and 1 install, which reads as a broken product. It was actually crawlers from OVH and AWS inflating the top of the funnel. How we classify bot traffic without an analytics SDK, and why the real fix was changing the denominator.

The first week after Unwait went live, the dashboard said 9 downloads and 1 install. That is an 11 percent activation rate, and 11 percent activation for a desktop app means something is badly broken: the DMG does not open, Gatekeeper blocks it, the app crashes on first run. We started hunting for the bug.

There was no bug. There were data centers. This post is what we found when we looked at who actually downloads a desktop app binary, and the three changes that made the funnel tell the truth, all without an analytics SDK.

Who is downloading your app

Some of the noise is easy to see. Crawlers and link-preview fetchers announce themselves in the User-Agent: every time the link gets posted anywhere, a handful of bots fetch it so chat apps can render a preview card. Those we split out early into their own download_bot bucket, matched by UA. Two details worth copying: we never block the file transfer itself, because a false positive that blocks a human is worse than any miscount, and UA-identified bots skip the geo lookup entirely, because letting crawlers burn a free geolocation API's quota is a self-own.

The UA filter made the numbers better and still wrong. Downloads kept arriving that looked human, and none of them ever became installs.

The tell was location. We were already doing a geo lookup per download for a "downloaded from Bucharest, Romania" style notification, and the lookup response happened to include a field we were ignoring: the network operator. Surfacing it cost nothing, and it flipped the lights on. The suspicious downloads came from Roubaix and Gravelines (OVH), Washington (AWS), and later Hetzner and GHOSTnet. Zero of them ever launched the app. The single real install that week came from a residential ISP.

Residential ISP means a person. Hosting provider means a machine, usually a crawler that forges a browser User-Agent but cannot forge where it lives. One string we were already receiving separated the two:

downloaded from Roubaix, France (OVH SAS)          <- machine
downloaded from Bucharest, Romania (RCS & RDS)     <- person

Classify, do not delete

The tempting move is automatic: operator looks like a data center, drop the event. We deliberately did not do that, because one class of real humans lives behind data-center IPs: VPN users. Privacy-conscious developers are overrepresented in our audience, so filtering by hosting operator would systematically erase exactly the people most likely to buy the product.

So data-center downloads go to their own bucket, download_dc, with the operator name kept in the event metadata and the event still visible in the activity feed. Nothing is discarded and every classification is reversible, which matters the day you discover your heuristic was wrong. The principle that survived the week: erasing one real user from your numbers is worse than counting a few bots.

Count something a bot cannot fake

Classification cleans the top of the funnel, but the deeper problem was that "download" is simply a weak event. It measures a HTTP transfer, not a human decision. The fix was adding a signal that cannot be faked by anything that is not the app actually running: on startup, the app pings /launch, and the server records it once per device.

That one event split an ambiguity that had been driving us in circles. Before it, a user who downloaded and then went silent had two indistinguishable explanations: they never opened the app (a distribution problem: Gatekeeper, a broken DMG, simple disinterest), or they opened it and the app could not reach our server (a product problem: networking, proxy, our bug). Those two failures need fixes in completely different places, and they look identical until you record the moment between them. The funnel is now downloaded → launched → installed, and silence has an address.

Two implementation notes. The app treats telemetry failure as strictly ignorable, because instrumentation that can affect app behavior is a bug with extra steps. And dev builds do not send the signal at all, since launch is once per device and a developer's own machine would permanently occupy a slot in a funnel meant to describe users.

The denominator was the real bug

Here is the same week of data, read two ways:

Same database, opposite diagnosis, and only the second one is true. Bots inflate denominators, and inflated denominators do not just miscount, they point your effort at the wrong work. We moved every activation metric to use launches as the base, because launch is the earliest event in our funnel that only a real, running app can produce.

You do not need an SDK for any of this

The whole system is a few dozen lines on our own server. No analytics vendor, no client-side SDK, no cookies. And it stays inside a privacy posture we can explain in one breath: we never store raw IPs (the geo lookup happens in memory and we keep city and operator name, the same granularity as the notification line), launch is a count of devices rather than a behavioral trail, and append-only tables got a one-time scrub when we found early rows that predated these rules.

The checklist version, if you ship a desktop app:

Unwait is a macOS app that shows learning cards while your AI coding agent works. The funnel described here is how we know who is actually using it, nine downloads at a time.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts