Sunday, 11pm. Your side project has been asleep for three weeks. You find out it broke when a friend texts you.
That is the whole problem. You do not need a monitoring platform. You need one honest sentence before Monday.
This is a build log for that sentence. It fits in one evening.
The scope cut comes first
Every side project dies from scope, not from code. So decide what you are not building before you open an editor.
| Keep | Skip |
|---|---|
| Three URLs, one check every 10 minutes | Multi-region probes |
| Append-only JSONL on disk | A time-series database |
| One model summary per day, failures only | Paging, on-call, escalation |
| A cron line on a free server | Dashboards and status pages |
| A test that breaks a route on purpose | Synthetic browsers |
The right column is not "later". It is out of scope, permanently, until the left column actually pages you.
Step 1: Write down the one question
Your question is not "is my app healthy". That is unmeasurable.
Your question is: did my three endpoints return the status I expected, in the last ten minutes?
Three endpoints. Named. With expected codes. Write them down before you write code, because this list becomes your test fixture later.
Step 2: Probe with a timeout, not with hope
A probe without a timeout hangs forever when the socket hangs. That turns a monitor into a second outage.
Node 20 ships fetch and AbortController, so you need no dependencies. Save this as probe.mjs.
// probe.mjs — Node 20+, zero dependencies. import { appendFile } from "node:fs/promises"; const TARGETS = [ { name: "api", url: "https://api.example.dev/health", expect: 200 }, { name: "web", url: "https://example.dev/", expect: 200 }, { name: "cdn", url: "https://cdn.example.dev/pixel.gif", expect: 200 }, ]; async function probe(t) { const started = Date.now(); const ctl = new AbortController(); const timer = setTimeout(() => ctl.abort(), 4000); try { const res = await fetch(t.url, { signal: ctl.signal, redirect: "follow" }); return { name: t.name, ok: res.status === t.expect, status: res.status, ms: Date.now() - started }; } catch (err) { return { name: t.name, ok: false, status: 0, ms: Date.now() - started, err: String(err.name) }; } finally { clearTimeout(timer); } } const run = { at: new Date().toISOString(), checks: await Promise.all(TARGETS.map(probe)) }; await appendFile("probes.jsonl", JSON.stringify(run) + "\n"); console.log(run.checks.map((c) => `${c.name}:${c.ok ? "ok" : "FAIL"}`).join(" ")); Two details matter more than they look. The timeout cancels the request, not the process. And the catch block records status: 0, so a DNS failure and a 502 stay distinguishable in your log.
Step 3: Store one line per run, and rotate it
Append-only JSONL survives crashes and needs no schema. It also grows silently.
At one run per ten minutes you write 144 lines a day. That is fine. A forgotten disk is not fine, so truncate weekly in the same script that reads the file.
# keep roughly the last 48 hours before summarizing wc -l probes.jsonl tail -n 288 probes.jsonl > probes.tmp && mv probes.tmp probes.jsonl The number 288 is not magic. It is 48 hours of ten-minute runs, and it is the only tuning knob in this project.
Step 4: Add exactly one model call, and only on failure
The cheap mistake is summarizing every run. Most runs are all-green and say nothing.
Summarize only when the last window contains a failure. The script below is a generic OpenAI-compatible chat request; confirm the exact base URL and path in your provider's docs before you run it.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
This is also where free model access does real work. The summary is low-volume, short, and forgiving of latency, which is exactly the shape a free allowance handles well. I am describing the operator's current offer, not a guarantee, so read the live terms before you depend on them.
// digest.mjs — one call per day, failures only. import { readFile } from "node:fs/promises"; const BASE = process.env.MODEL_BASE_URL; // from your provider's docs const KEY = process.env.MODEL_API_KEY; const MODEL = process.env.MODEL_NAME; const lines = (await readFile("probes.jsonl", "utf8")).trim().split("\n"); const fails = lines .map((l) => JSON.parse(l)) .flatMap((r) => r.checks.filter((c) => !c.ok).map((c) => ({ at: r.at, ...c }))); if (fails.length === 0) { console.log("nothing to report"); process.exit(0); } const prompt = "Summarize these uptime probe results. Facts only. No advice, no guessing.\n" + fails.map((f) => `${f.at} ${f.name} status=${f.status} ms=${f.ms} err=${f.err ?? "none"}`).join("\n"); const res = await fetch(`${BASE}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${KEY}` }, body: JSON.stringify({ model: MODEL, messages: [{ role: "user", content: prompt }], max_tokens: 200 }), }); console.log((await res.json()).choices?.[0]?.message?.content ?? "summary failed"); Note the guard clause. If nothing failed, you spend zero tokens and print four words. That guard is the difference between a tool and a subscription.
Also note what the prompt forbids. Asking for "facts only" keeps the model from inventing a root cause it cannot see.
Step 5: Put it on a free server with two cron lines
A free server option is enough here. The workload is tiny, stateless, and tolerant of a restart.
Copy the two files up, create a .env with the three model variables, and install one crontab.
# crontab -e */10 * * * * cd /srv/health && /usr/bin/node probe.mjs >> probe.log 2>&1 30 7 * * * cd /srv/health && set -a && . ./.env && set +a && /usr/bin/node digest.mjs >> digest.log 2>&1 set -a exports the file into the environment for that command only. Do not hardcode the key in the script, and do not commit .env.
Step 6: Break it on purpose before you trust it
An untested monitor is a decoration. Start a local server that always fails and point one target at it.
node -e "require('http').createServer((_,r)=>{r.writeHead(500);r.end('nope')}).listen(5999)" & Then add { name: "fake", url: "http://127.0.0.1:5999/", expect: 200 } to TARGETS and run:
node probe.mjs tail -n 1 probes.jsonl | jq '.checks[] | select(.ok == false)' You should see one FAIL on stdout and one object with status: 500 in the log. Now test the silent path:
node -e "require('http').createServer((_,r)=>{r.writeHead(200);r.end('ok')}).listen(5998)" & Point fake at port 5998, run both scripts, and confirm digest.mjs prints nothing to report without calling the model. Kill both test servers when done.
That is the full test plan. Two commands, two paths, no test framework.
What I skipped, and who should skip this entirely
I skipped paging. Nothing here wakes you up, and that is a choice, not a gap.
I skipped history retention beyond 48 hours. If you need a monthly uptime number, you need a real store.
I skipped auth on the probe target. Never point this at an endpoint that needs a secret, because the script logs response metadata to disk.
Do not use this approach if any of these are true:
- A missed failure costs money, trust, or safety.
- You need contractual uptime reporting.
- Your endpoints live in several regions and you must distinguish them.
- You cannot tolerate a free tier that changes its terms.
For a side project with three routes and one friend who texts you, it is enough. You get a log you can jq, and one paragraph on Monday morning that says what actually broke.
If you want to try it without wiring up billing, free model access plus a free server option covers this build. Check the current terms first, then spend your evening on the probe, not on the platform.

