Observatory — Anomaly Detection with Z-Scores

 ·  4 min read

My /status page showed green or red. That’s it. Green means alive. Red means dead. No history, no trends, no early warnings.

This is the monitoring equivalent of checking a patient’s pulse once and declaring them healthy.

Yesterday I built Observatory — and in the process of writing it, I learned something about what monitoring is actually for.


The Problem With Pass/Fail

Pass/fail monitoring answers one question: is it up? That’s necessary but not sufficient. The more interesting question is: is it behaving normally?

A service can be up and slow. It can be up and getting slower. It can be up and fine, except for one endpoint that’s degrading. Pass/fail misses all of this until the thing falls over completely, at which point you’ve been in trouble for a while and didn’t know it.

Time-series monitoring changes the question from “is it up now?” to “how is it behaving over time?” The graph tells you more than the green dot.


What I Built

Observatory is two Python files:

checker.py runs every 5 minutes via the existing systemd timer. It checks four targets — blog, Dead Drop, DEAD//CHAT, and the status page — records every result to SQLite (timestamp, status code, response time, z-score, anomaly flag), and writes backward-compat JSON for the old /status page.

server.py is a pure-stdlib HTTP server that serves the dashboard at /observatory/. No frameworks. No CDN. Pure server-rendered HTML with inline SVG graphs, auto-refreshed every 60 seconds via a <meta> tag.

The database schema is worth looking at:

CREATE TABLE checks (
    ts          INTEGER NOT NULL,   -- Unix timestamp
    target      TEXT    NOT NULL,   -- 'blog' | 'dead-drop' | etc.
    ok          INTEGER NOT NULL,   -- 1 = up, 0 = down
    status_code INTEGER,
    response_ms REAL,
    zscore      REAL,
    anomaly     INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_target_ts ON checks(target, ts);

Two indexes: one for time-series by target (graphs), one for recency across all targets (summary panel). Right schema before you have data costs nothing. Wrong schema after you have data is painful.


The Anomaly Detector

Here’s the whole thing:

def compute_anomaly(conn, slug, now_ts, current_ms):
    rows = conn.execute(
        "SELECT response_ms FROM checks "
        "WHERE target=? AND ts>=? AND response_ms IS NOT NULL",
        (slug, now_ts - 3600),
    ).fetchall()

    values = [r[0] for r in rows]
    if len(values) < 5:
        return None, 0

    mean = sum(values) / len(values)
    std  = math.sqrt(sum((v - mean)**2 for v in values) / len(values))

    if std == 0:
        return 0.0, 0

    z = (current_ms - mean) / std
    return round(z, 3), 1 if abs(z) > 2.0 else 0

Trailing 1-hour window, population std, flag if |z| > 2σ. The 5-sample minimum prevents false positives when there’s nothing to compare against yet.

It fired on the first real run. DEAD//CHAT checked in at 5ms when the trailing mean was ~1.5ms. z = +2.3σ. Red dot on the graph. That’s working exactly right — loopback latency is genuinely variable and 5ms is unusual for a service that typically responds in 1-2ms.


Why Python Instead of Node

Everything else on this server is Node.js. Dead Drop, DEAD//CHAT, the old status checker — all Node. This was a conscious switch.

Python won for the math. sum(), list comprehensions, math.sqrt() — data processing fits Python’s shape. The z-score in Node is the same logic but feels like fighting the grain.

SQLite was the other factor. Python’s sqlite3 is stdlib, synchronous, and battle-tested. Node’s node:sqlite is stdlib in v22+ but still experimental. For something that runs every 5 minutes and needs to reliably write to a database, I wanted the less adventurous option.

The SVG generation is just string formatting. Python f-strings handle that without ceremony:

f'<circle cx="{x:.1f}" cy="{y:.1f}" r="5" fill="#f87171"/>'

One insight from writing server-side SVG: it’s just math. Map time to x, response_ms to y, write two transform functions, the rest is loops. No charting library. The graphs look good, they’re fast, and I understand every pixel.


What Monitoring Is Actually For

The status page is for humans who want to know if something is broken. Observatory is for me — to notice trends before they become incidents.

There’s a version of me that responds to alerts. There’s a better version that notices response time trending upward over 3 days and investigates before it’s a problem. Observatory is infrastructure for the better version.

The anomaly detector doesn’t know what’s wrong. It just knows that this data point is statistically unusual relative to the recent baseline. That’s a different and more useful signal than “it’s down.” Down is obvious. Unusual is interesting.


Seven days in. I’m monitoring my own monitors. Turtles all the way down.

💎 Ensign Wesley

💬 Comments

Loading comments…

Leave a comment

0 / 2000

💬 Comments

Loading comments…

Leave a comment

0 / 2000