← Complete Projects

阅读中文版本

Build a Self-Hosted Uptime Monitor Service

A small service that periodically checks configured endpoints, records up and down transitions with timestamps, exposes a status endpoint, and prints a running summary.

intermediate~5 hnodejsmonitoringstate-machineservice

Project goal

Build a service that polls a list of endpoints on an interval, tracks per-target status and consecutive failures, opens and closes incidents on state changes, and serves a JSON status endpoint.

Final features

  • Poll each configured target on a fixed interval with a timeout.
  • Track consecutive failures and open an incident after a threshold.
  • Close an incident when a target recovers and record the duration.
  • Serve a JSON status endpoint summarising every target.
  • Persist incident history to disk so restarts keep the record.

Architecture

A scheduler timers periodic checks; a checker classifies each response; a state machine tracks per-target status and transitions; an incident log persists transitions; and the HTTP layer serves the current state. The clock is injected so interval and threshold behaviour is tested deterministically.

Prerequisites

  • Node.js 20 LTS or later
  • Understanding of timers and state machines
  • Basic HTTP server skills

Dependencies

  • node >=20 — Timers, fetch, HTTP server, and persistence
  • vitest ^2.1.0 — State machine and transition tests with a fake clock

Directory structure

uptime-monitor/
├── src/
│   ├── checker.js
│   ├── state.js
│   ├── incidents.js
│   ├── service.js
│   └── cli.js
├── test/
│   ├── state.test.js
│   └── transitions.test.js
└── package.json

Phases

1. Checker

Perform one check against a target with a timeout and classify the outcome.

Files: src/checker.js

export async function checkTarget(target, { fetchImpl = fetch, timeoutMs = 5000, at = Date.now() } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  const started = Date.now();
  try {
    const res = await fetchImpl(target.url, { signal: controller.signal });
    return { ok: res.status >= 200 && res.status < 400, status: res.status, latencyMs: Date.now() - started, at: at() };
  } catch (err) {
    return { ok: false, status: 0, reason: err.name, latencyMs: Date.now() - started, at: at() };
  } finally {
    clearTimeout(timer);
  }
}

Expected behavior: A 200 is ok with a measured latency, a 500 is not ok, and a timeout returns ok false without throwing.

2. Per-target state machine

Consume outcomes and maintain status, consecutive failures, and transitions with a threshold.

Files: src/state.js

export function initialState() {
  return { status: 'up', consecutiveFailures: 0, since: null };
}

export function applyCheck(state, outcome, { threshold = 3 } = {}) {
  const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
  if (state.status === 'up' && next.consecutiveFailures >= threshold) {
    return { next: { ...next, status: 'down', since: outcome.at }, transition: { type: 'opened', at: outcome.at } };
  }
  if (state.status === 'down' && outcome.ok) {
    return { next: { ...next, status: 'up', since: outcome.at }, transition: { type: 'closed', at: outcome.at } };
  }
  return { next, transition: null };
}

Expected behavior: The third consecutive failure opens an incident and the first success closes it, while two failures leave the status up.

3. Incident log

Append transitions to a persisted log and compute incident durations from open and close pairs.

Files: src/incidents.js

import { readFile, writeFile } from 'node:fs/promises';

export async function appendIncident(path, incident) {
  let log = [];
  try { log = JSON.parse(await readFile(path, 'utf8')); } catch { log = []; }
  log.push(incident);
  await writeFile(path, JSON.stringify(log, null, 2));
  return log;
}

export function durations(log) {
  const open = new Map();
  const out = [];
  for (const entry of log) {
    if (entry.type === 'opened') open.set(entry.target, entry.at);
    else if (entry.type === 'closed' && open.has(entry.target)) {
      out.push({ target: entry.target, downMs: entry.at - open.get(entry.target) });
      open.delete(entry.target);
    }
  }
  return out;
}

Expected behavior: Appending preserves earlier entries, and a matched open and close pair yields a duration equal to the difference of the timestamps.

4. Scheduler

Run checks for every target on an interval with an injectable timer so tests need no real waiting.

Files: src/scheduler.js

export function createScheduler({ intervalMs, check, onOutcome, setIntervalImpl = setInterval }) {
  let timer = null;
  async function tick() {
    const outcomes = await Promise.all(check());
    for (const outcome of outcomes) onOutcome(outcome);
  }
  return {
    start() { timer = setIntervalImpl(tick, intervalMs); return timer; },
    stop() { if (timer) clearInterval(timer); },
    tick,
  };
}

Expected behavior: Starting installs one interval, stopping clears it, and calling tick directly runs one round of checks synchronously under the test.

5. Status service

Serve a JSON status document summarising every target from the current state map.

Files: src/service.js

import { createServer } from 'node:http';

export function createStatusServer({ getState }) {
  return createServer((req, res) => {
    if (req.url === '/status') {
      const targets = Object.entries(getState()).map(([url, s]) => ({ url, status: s.status, since: s.since }));
      const down = targets.filter((t) => t.status === 'down').length;
      res.writeHead(down ? 503 : 200, { 'content-type': 'application/json' });
      return res.end(JSON.stringify({ down, targets }));
    }
    res.writeHead(404, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ error: 'not_found' }));
  });
}

Expected behavior: The status endpoint returns 200 with zero down targets and 503 with one, listing each target with its current status.

6. CLI and tests

Load a config, start the scheduler and status server, and cover transitions and durations with tests.

Files: test/state.test.js

import { initialState, applyCheck } from '../src/state.js';

const fail = (at) => ({ ok: false, at });
const ok = (at) => ({ ok: true, at });

test('opens at the threshold and closes on first success', () => {
  let state = initialState();
  state = applyCheck(state, fail(1)).next;
  state = applyCheck(state, fail(2)).next;
  expect(state.status).toBe('up');
  const third = applyCheck(state, fail(3));
  expect(third.next.status).toBe('down');
  expect(third.transition.type).toBe('opened');
  const recovered = applyCheck(third.next, ok(4));
  expect(recovered.transition.type).toBe('closed');
});

Expected behavior: The transition suite passes for the threshold boundary and recovery, and the configured interval drives real checks in production.

Testing

Run npx vitest. State tests drive a sequence of outcomes and assert that an incident opens exactly at the threshold and closes on the first success. A transitions test asserts the incident duration is computed from open and close timestamps using a fake clock.

Troubleshooting

Alerts fire constantly for a flapping endpoint

Require a threshold of consecutive failures before opening an incident and a single success to close it; the asymmetry prevents noise from a single dropped request.

Restarting the service loses incident history

Append transitions to a persisted log rather than keeping them in memory; the on-disk log survives restarts and supports post-hoc analysis.

The status endpoint always reports up

Serve the state map that the scheduler updates rather than a value captured at boot; a stale reference is the usual reason the endpoint never changes.

Deployment

Run the service under a supervisor with the incident log on a mounted volume, and scrape the status endpoint from an external monitor that alerts on a 503. In a multi-instance setup, one instance owns the scheduler and the others read shared state.

Known limitations

  • A single process performs all checks, so a very large target list needs sharding.
  • There is no built-in notification channel; alerting is left to whatever scrapes the status endpoint.

Project overview

Uptime monitoring is a state machine wrapped around a periodic check. The interesting part is not the request but the transitions: when does a flapping endpoint count as down, and when does it count as recovered?

This service tracks consecutive failures, opens an incident only after a threshold, and records each transition with a timestamp, so the incident history is meaningful rather than noisy.

The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.

The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.

It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.

The state machine consumes one check outcome at a time and returns the next state plus any transition, which keeps it pure and testable:

export function applyCheck(state, outcome, { threshold = 3 } = {}) {
  const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
  if (state.status === 'up' && next.consecutiveFailures >= threshold) {
    return { next: { ...next, status: 'down' }, transition: { type: 'opened', at: outcome.at } };
  }
  if (state.status === 'down' && outcome.ok) {
    return { next: { ...next, status: 'up' }, transition: { type: 'closed', at: outcome.at } };
  }
  return { next, transition: null };
}

Returning a transition object instead of mutating shared history is what lets the incident log be an append-only record of facts.

Setup

No runtime dependencies are needed; timers, fetch, and the HTTP server all come from the standard library.

Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.

Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.

Dependency Version Why it is here
node >=20 Timers, fetch, HTTP server, and persistence
vitest ^2.1.0 State machine and transition tests with a fake clock

Create the project and install:

mkdir uptime-monitor && cd uptime-monitor
npm init -y
npm install -D vitest@^2.1.0

Configure the service and tests in package.json:

{
  "name": "uptime-monitor",
  "type": "module",
  "scripts": {
    "start": "node src/cli.js",
    "test": "vitest run"
  }
}

The poll interval and failure threshold come from a config file so tests can drive transitions without waiting for real intervals.

Phase 1: Checker

Perform one check against a target with a timeout and classify the outcome. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.

The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.

export async function checkTarget(target, { fetchImpl = fetch, timeoutMs = 5000, at = Date.now() } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  const started = Date.now();
  try {
    const res = await fetchImpl(target.url, { signal: controller.signal });
    return { ok: res.status >= 200 && res.status < 400, status: res.status, latencyMs: Date.now() - started, at: at() };
  } catch (err) {
    return { ok: false, status: 0, reason: err.name, latencyMs: Date.now() - started, at: at() };
  } finally {
    clearTimeout(timer);
  }
}

Acceptance for this phase: A 200 is ok with a measured latency, a 500 is not ok, and a timeout returns ok false without throwing.

Phase 2: Per-target state machine

Consume outcomes and maintain status, consecutive failures, and transitions with a threshold. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.

This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.

export function initialState() {
  return { status: 'up', consecutiveFailures: 0, since: null };
}

export function applyCheck(state, outcome, { threshold = 3 } = {}) {
  const next = { ...state, consecutiveFailures: outcome.ok ? 0 : state.consecutiveFailures + 1 };
  if (state.status === 'up' && next.consecutiveFailures >= threshold) {
    return { next: { ...next, status: 'down', since: outcome.at }, transition: { type: 'opened', at: outcome.at } };
  }
  if (state.status === 'down' && outcome.ok) {
    return { next: { ...next, status: 'up', since: outcome.at }, transition: { type: 'closed', at: outcome.at } };
  }
  return { next, transition: null };
}

Acceptance for this phase: The third consecutive failure opens an incident and the first success closes it, while two failures leave the status up.

Phase 3: Incident log

Append transitions to a persisted log and compute incident durations from open and close pairs. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.

The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.

import { readFile, writeFile } from 'node:fs/promises';

export async function appendIncident(path, incident) {
  let log = [];
  try { log = JSON.parse(await readFile(path, 'utf8')); } catch { log = []; }
  log.push(incident);
  await writeFile(path, JSON.stringify(log, null, 2));
  return log;
}

export function durations(log) {
  const open = new Map();
  const out = [];
  for (const entry of log) {
    if (entry.type === 'opened') open.set(entry.target, entry.at);
    else if (entry.type === 'closed' && open.has(entry.target)) {
      out.push({ target: entry.target, downMs: entry.at - open.get(entry.target) });
      open.delete(entry.target);
    }
  }
  return out;
}

Acceptance for this phase: Appending preserves earlier entries, and a matched open and close pair yields a duration equal to the difference of the timestamps.

Phase 4: Scheduler

Run checks for every target on an interval with an injectable timer so tests need no real waiting. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.

Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.

export function createScheduler({ intervalMs, check, onOutcome, setIntervalImpl = setInterval }) {
  let timer = null;
  async function tick() {
    const outcomes = await Promise.all(check());
    for (const outcome of outcomes) onOutcome(outcome);
  }
  return {
    start() { timer = setIntervalImpl(tick, intervalMs); return timer; },
    stop() { if (timer) clearInterval(timer); },
    tick,
  };
}

Acceptance for this phase: Starting installs one interval, stopping clears it, and calling tick directly runs one round of checks synchronously under the test.

Phase 5: Status service

Serve a JSON status document summarising every target from the current state map. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.

The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.

import { createServer } from 'node:http';

export function createStatusServer({ getState }) {
  return createServer((req, res) => {
    if (req.url === '/status') {
      const targets = Object.entries(getState()).map(([url, s]) => ({ url, status: s.status, since: s.since }));
      const down = targets.filter((t) => t.status === 'down').length;
      res.writeHead(down ? 503 : 200, { 'content-type': 'application/json' });
      return res.end(JSON.stringify({ down, targets }));
    }
    res.writeHead(404, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ error: 'not_found' }));
  });
}

Acceptance for this phase: The status endpoint returns 200 with zero down targets and 503 with one, listing each target with its current status.

Phase 6: CLI and tests

Load a config, start the scheduler and status server, and cover transitions and durations with tests. Keeping each phase self-contained is what makes the whole project reviewable one commit at a time.

The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.

import { initialState, applyCheck } from '../src/state.js';

const fail = (at) => ({ ok: false, at });
const ok = (at) => ({ ok: true, at });

test('opens at the threshold and closes on first success', () => {
  let state = initialState();
  state = applyCheck(state, fail(1)).next;
  state = applyCheck(state, fail(2)).next;
  expect(state.status).toBe('up');
  const third = applyCheck(state, fail(3));
  expect(third.next.status).toBe('down');
  expect(third.transition.type).toBe('opened');
  const recovered = applyCheck(third.next, ok(4));
  expect(recovered.transition.type).toBe('closed');
});

Acceptance for this phase: The transition suite passes for the threshold boundary and recovery, and the configured interval drives real checks in production.

Design trade-offs

Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.

Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.

Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.

Testing strategy

Run npx vitest. State tests drive a sequence of outcomes and assert that an incident opens exactly at the threshold and closes on the first success. A transitions test asserts the incident duration is computed from open and close timestamps using a fake clock.

Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.

Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.

Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.

The threshold test is the anti-flap guard: two failures must not open an incident, and the third must, which is the boundary that decides whether the alerts are trustworthy.

Extending the project

Multiple check types, alerting channels, and response-time percentiles are additions around the checker and incident log. Because the scheduler only calls the checker, new check types do not touch the state machine.

The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.