The problem
When Payme stops taking payments in Uzbekistan, the first thing everyone does is ask a group chat: “is it down, or is it just me?” Nobody knows. The services themselves rarely say anything, and when they do it lands on a social account most people do not follow. The result is thousands of people independently retrying a dead endpoint and blaming their own phone.
There is no shared status page for the services this country actually runs on — payments, the tax portal, the marketplaces, the banks. I built one.
Users and context
- A person at a till whose payment failed, on a phone, in a hurry. They need one word: up or down. Nothing else on the screen matters.
- Support and ops staff who need to know whether to open an incident or wait it out, and want yesterday's history to argue with.
- Developers integrating with these services, who want the data as JSON so their own dashboards can consume it.
Constraints and tradeoffs
Probing from outside, honestly
I have no access to anyone's infrastructure. Everything is measured the way a user experiences it: an HTTP request from outside, with a timeout. That means the monitor can report a network path problem as a service problem — and I decided that is correct for this audience. If the user cannot reach it, it is down for the user.
One dead target must not kill the cycle
The naive version runs probes in a loop and dies on the first hung connection. Every target gets its own timeout, and a failure is a result, not an exception that escapes:
A probe that throws returns Result.down(target, cause). The evaluator never
propagates — the cycle always completes, and “we could not check” is recorded
as its own state, distinct from “we checked and it was down”.
History that does not grow forever
Checking a few dozen targets every minute produces millions of rows a year. Raw points are kept only for the recent window; a rollup job folds older data into hourly and daily aggregates and deletes the originals. The heatmap and the reliability table read the aggregates, never the raw table.
What I traded away
- No alerting. Notifications need identity, preferences and a delivery channel. That is a second product; the first one has to be useful without it.
- No JavaScript app. Pages are rendered server-side with Thymeleaf. On a bad mobile connection — exactly when people check a status page — a server-rendered page wins.
- Minute resolution, not seconds. Higher frequency means more load on services that never agreed to be probed. Being a polite guest was worth the lost precision.
Architecture
Screens and states
Normal. The answer is the first thing on the page — no chrome, no login, no cookie banner.
Incident. Not just a red dot — when it started, how long, and how confident the monitor is.
History. A heatmap reads faster than a number: the eye finds the bad day before it reads the percentage.
API. Open, no key. The data is only useful if other people can build on it.
Results and lessons
What I would tell myself at the start
- Model “unknown” from day one. The first version had up and down. The state that actually matters during an incident is “we could not reach it to find out”, and retrofitting a third state touched every layer.
- Retention is a feature, not cleanup. Deciding what the page shows at 90 days decides the schema. I designed the schema first and paid for it.
- Integration tests with a real database earn their runtime. Testcontainers makes
mvn verifyslow and honest. Every bug it caught was a bug about SQL, which is exactly the class of bug unit tests with mocks never see. - Pin the JDK. The build breaks on a newer JDK than the one it targets, so
JAVA_HOMEis fixed inrun.shon purpose — a comment explains why, because otherwise someone helpfully deletes it.