Skip to content

Deployment monitor

A working availability monitor for the sites on this portfolio. A scheduled Python job performs synthetic HTTP checks, classifies every failure, and an authenticated API validates and stores them; SQL does the rest.

In development · self-initiated professional project

What these numbers are

These are synthetic checks: HTTP requests made on a schedule from a GitHub-hosted runner in a datacenter. What they measure is endpoint latency — how long that server took to answer that runner.

This is availability monitoring, and it is not browser performance measurement. These figures are not LCP, INP or any other Core Web Vital, and they are not a substitute for one: those describe what a real person on a real device experiences while a page renders. Endpoint latency describes one round-trip, from one machine, with no browser involved.

One runner is also one vantage point. A failure here means the endpoint was unreachable from that runner — which is not the same as globally down, so this page never claims it was.

Current status

Every target, over the last 7 days

Uptime is the share of checks that ran and passed. Coverage is the share of checks that were expected and actually happened. They are reported separately on purpose: low coverage means my scheduler missed runs, and never that a site was down.

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

  • Last check passed (HTTP 200)12 min ago

    Uptime
    100.0%

    13/13 passed

    Coverage
    3.9%

    13 of 336 expected

    Latency
    —

    13 samples — too few for a percentile

Over time

Recorded checks across the window

Every bucket in the window is drawn, including the ones in which no check ran. Those appear as a flat tick, not an empty bar — a missing check is missing information about the site, not information that the site was missing.

57 buckets across the window. 4 contain recorded checks, 53 contain none, and 0 contain at least one failed check. Buckets with no checks are drawn as hollow ticks on the baseline: no check ran, which is not the same as the site being unavailable.09-1809-1909-2009-2109-2209-2309-24
passedfailedno check ran3-hour buckets · 4 of 57 contain data

Incidents

What actually failed, and when

An incident is a run of consecutive failed checks, derived in SQL rather than stored. A run ends at a gap: if the scheduler went quiet between two failures, that is reported as two observed incidents with an unknown period between them, not one long outage nobody watched.

No failed checks have been recorded in this window. That is a statement about the checks that ran — see coverage above for how many of them there were.

How it works

Seven stages, and what each one is evidence of

The whole system is two small pieces: a Python package that runs on a schedule, and a Cloudflare Worker in front of a SQL database. Source for both is on GitHub.

  1. 01

    A schedule fires

    A GitHub Actions cron runs the collector every 30 minutes. That scheduler is best-effort: runs arrive late and are sometimes skipped entirely, and GitHub disables scheduled workflows after 60 days without repository activity.

    Rather than hide that, the whole data model is built around it — which is where coverage comes from, and why an incident is not allowed to span a gap.

    Demonstrates: automation, and designing around infrastructure you do not control

  2. 02

    Python checks each target

    Each target gets one HTTP GET with a 10-second timeout, all of them concurrently. Elapsed time is measured with a monotonic clock, so a clock adjustment mid-request cannot produce a nonsensical latency.

    Every check runs inside its own error boundary. One target failing — in any way, including a bug in the checker — can never stop the others from being recorded.

    Demonstrates: Python, async I/O, error isolation

  3. 03

    Failures are classified, not just counted

    “Failed” on its own is a hobby metric. Each failure is classified as dns, connection_refused, tls_error, timeout or http_error, because a hostname that stopped resolving, an expired certificate and a 503 are three different problems with three different fixes.

    Only the transient network kinds are retried, once. A bad certificate and a 500 are real answers from the other end — retrying them would hide the exact thing the monitor exists to catch. A retry stays part of the same check, so one scheduled tick always writes exactly one row.

    Demonstrates: failure handling, and the difference between data and a number

  4. 04

    The batch is posted to an authenticated API

    All results go in one authenticated POST to a Cloudflare Worker. The bearer token is compared in constant time, so it cannot be recovered byte by byte through response timing, and the collector refuses to start if the endpoint is not HTTPS.

    The API then rejects anything that could not have been observed: negative latency, timestamps from the future or the distant past, values outside the status and failure enums, a mismatch between status and failure kind, or a target it does not already know. Malformed payloads get a 400 naming the field. A payload cannot introduce a target by mentioning one.

    Demonstrates: API design, authentication, input validation

  5. 05

    D1 persists it

    Checks land in Cloudflare D1 (SQLite). Every value is a bound parameter; there is no string-concatenated SQL anywhere in the project. Database-level CHECK constraints enforce coherence independently of the validator, so no code path can write a half-populated row.

    There is deliberately no “no data” row. A check that never ran leaves a gap. Two indexes exist and both earn their place: a unique index on (target, time) serves every windowed per-target query and makes a replayed batch idempotent, and an index on time alone serves the bucketed series, which filters on time with no target predicate.

    Demonstrates: schema design, indexing, and constraints as a safety net

  6. 06

    SQL does the aggregation

    Nothing fetches rows and reduces them in JavaScript. Uptime and coverage are computed with conditional aggregation; percentiles rank each target’s successful latencies with ROW_NUMBER() and are withheld entirely below 50 samples, because a p95 drawn from a dozen points is noise wearing a statistic’s clothes.

    Incidents are derived, not stored: a running SUM() over a “does this failure continue the previous one” flag groups consecutive failures into runs. The flag requires both that the previous check failed and that it arrived close enough in time to be the next scheduled one — which is precisely what makes a run terminate at a scheduler gap.

    The time series generates its buckets with a recursive CTE before joining, so a bucket with no checks comes back as zero checks rather than being absent from the result.

    Demonstrates: SQL beyond CRUD: window functions, gap-and-island detection

  7. 07

    A cached endpoint, and this page

    The read endpoint is public and cached for 10 minutes. Checks only arrive every 30 minutes, so a shorter window would re-run the same aggregation over identical rows — and since September 2026 Cloudflare fails D1 queries once the free tier’s daily row limit is reached, which would take the collector’s writes down with it. The TTL is a correctness control as much as a speed one.

    This page is a Server Component that does one fetch against that endpoint, including the chart, which is hand-written SVG rather than a charting library. No database client, driver or credential exists in this site’s repository, and this route adds nothing to the browser bundle.

    Demonstrates: caching strategy, and keeping a boundary clean

Method

Exactly what is and is not recorded

Check interval
every 30 minutes (intended)
Reporting window
7 days
Cache TTL
10 minutes
Percentile gate
50 successful checks
Timeout
10 seconds
Vantage points
one GitHub-hosted runner

Not recorded

  • Anything about real visitors. No analytics, no sessions, no personal data of any kind passes through this system.
  • Browser performance. No page is rendered, so there is no LCP, INP, CLS or any other Core Web Vital here.
  • Response bodies. Only the status code, the elapsed time and — on failure — a classified reason and a short detail string.
  • Checks that did not happen. There is no placeholder row anywhere in the schema, so a gap stays visibly a gap.
  • Security posture. HSTS, CSP, redirect behaviour and certificate expiry dates are not part of this build.

It watches eight of my own deployments from one scheduled runner. It is not observability infrastructure, not enterprise monitoring, and not evidence of SaaS or DevOps experience — it is a small system built carefully, and it is honest about what it can and cannot tell you.

104 checks recorded in this window · window ends 2026-09-24 20:32 UTC · generated 2026-09-24 20:32 UTC · served from a cache up to 10 min old