Skip to content

Design Notes

Arian Omrani edited this page Oct 11, 2026 · 5 revisions

Design notes

Language: English · فارسی

How pulsecheck is built, why, and what was learned while making it run on five different places. Useful if you want to contribute or debug.

Map of the code (src/pulsecheck/)

Module Job Used on
config.py Dataclasses for every key, ${ENV} expansion, type checks, message templates everywhere
result.py Result of one check, limit() for max_response_ms everywhere
state.py The up/down state machine (step), SQLite Store, JSON-document BlobStore CLI / serverless
engine.py One run: choose due sites, check them in parallel, decide alerts, save state, export telemetry; status() everywhere
otel.py OTLP/HTTP JSON payloads and the push everywhere
httpnotify.py shoutrrr-style URLs → plain HTTPS requests for 12 services Vercel, Cloudflare
checks.py HTTP (httpx), TCP, ping, TLS expiry CLI, Vercel
notify.py Runs shoutrrr (Docker image or binary), verifies URLs, downloads the binary (SHA-256 checked) CLI, Vercel
setup.py, cli.py Commands, config discovery, .env, init, cron block, update CLI
web.py, app.py (repo root) Vercel: WSGI app, Upstash Redis state Vercel
deploy/cloudflare/ build.py (Build Output), src/entry.py (Worker), deploy.sh Cloudflare

The one idea: inject the I/O

engine.run(cfg, store, check, send, post=...) never touches the network, the disk or a clock it cannot be given. The caller passes:

  • check(site) → Result (CLI: threads running httpx/socket/ping; Cloudflare: fetch),
  • send(urls, text) (CLI: shoutrrr; serverless: HTTP senders),
  • post(url, headers, body) for OpenTelemetry,
  • a store (SQLite file, or one JSON document in Redis / Workers KV).

That is why the same engine runs from cron, GitHub Actions, GitLab CI, Vercel and a Cloudflare Worker, and why most behaviour is tested without any network.

State

  • SQLite (Store): every check is a row (kept 400 days) plus the current state per site. Gives exact p95.
  • JSON document (BlobStore): current state plus daily totals per site (checks, ok, latency sum), kept 90 days, so one small document is read and written per run. No p95. Overlapping runs can lose an update (last write wins); cron spacing makes that rare.
  • Per site the state holds status, consecutive fails, since, last check time and a small meta (when the last alert and the last certificate warning were sent, for reminders).

Decisions worth knowing

  • Alerts only on change. step() is a pure function, unit tested; reminders and certificate warnings are layered on top in the engine.
  • Dataclasses, not pydantic. The Cloudflare Worker must run with no third-party packages (the Pyodide package index was unreachable from some networks, and fewer packages means a faster cold start). config.build() does the type checks and gives errors like config.sites[0].timeout: expected a number.
  • YAML only at the edges. The Worker receives JSON made at build time; ruamel.yaml is imported lazily.
  • One shoutrrr, two ways. The CLI uses the real shoutrrr. Where it cannot run (Cloudflare) or may not exist (Vercel) the built-in HTTP senders cover the common services, and Vercel falls back to downloading the real binary (SHA-256 verified against the release's checksums.txt) into /tmp.
  • Secrets stay out of output. Channels are printed as telegram://***; the Vercel and Cloudflare endpoints compare tokens with hmac.compare_digest; the .env file is created with mode 600; the Vercel cron endpoint refuses everything when CRON_SECRET is unset.
  • init is idempotent. It never overwrites an existing config, and the cron job lives in one marked block that is replaced, never duplicated.

Things learned the hard way (real deploys)

Platform Finding Consequence
Cloudflare cf CLI Beta; its Python dev server package is not published, so it cannot build Python Workers We write the Build Output ourselves and run cf deploy --prebuilt
Python Worker Only the main module is importable (others and data files are not mounted) build.py embeds the package into one entry.py and registers pulsecheck.* modules at import time
Python Worker scheduled(self, controller, env, ctx) receives env=None Use self.env
Python Worker A module-level CONFIG_TEXT = None after the embedded value silently erased it The entry uses globals().get(...); a test builds the bundle and runs it
Cloudflare ntfy.sh Returned 429 to the Worker's shared egress Alert failures are logged and never stop the run
Vercel build .python-version 3.15 fails (No interpreter found) build.env.UV_PYTHON=3.14 in vercel.json
Vercel runtime Functions ran Python 3.12.14, no ping, no Docker, /tmp writable, outbound HTTPS and running a downloaded binary work Download shoutrrr on demand
Vercel deploy A 20 MB binary in the upload made the deploy hang Never ship binaries; download at run time
Upstash on Vercel The account owner must accept Upstash's terms once in the browser; after that vercel integration add upstash/upstash-kv provisions Redis and injects KV_REST_API_URL / KV_REST_API_TOKEN Documented; tested with a real database
Test pitfalls pkill -f matched its own command line and killed the shell; an empty __init__.py is dropped from some uploads; a ping to 192.0.2.1 can succeed behind some networks Avoided in tests and scripts

Tested vs not tested

  • Automated (28 tests, CI on Python 3.12–3.15): state machine, engine flows (down/up/degraded/reminder/certificate/maintenance/tags/interval), config rules, HTTP senders, OpenTelemetry payloads, .env, cron block, init, update command choice, shoutrrr download checksum, the Vercel handler against a fake Redis, the Worker and the generated Worker bundle against fake runtime modules.
  • By hand on real services: the release pipeline (version, tag, GitHub release, PyPI upload, uv tool install pulsecheckcli); a Cloudflare Worker (deploy, cron run, KV state, webhook delivery, logs) and a Vercel function with Upstash Redis (deploy, auth, config load, state kept between runs, /api/status, running shoutrrr).
  • Not tested for real: the Worker's /status and /run endpoints; crontab on a real system (a fake was used); Telegram/Slack/etc. delivery from the built-in HTTP senders (only payload shape and shoutrrr verify of the URLs); PyPI publishing (needs the first release).

Release process

PyPI project pulsecheckcli; the commands are pulsecheck and the alias pck. Releases are automatic and driven by Conventional Commits (feat → minor, fix/perf → patch). After CI passes on main, publish.yml runs python-semantic-release: it bumps version in pyproject.toml, updates CHANGELOG.md, tags vX.Y.Z, creates the GitHub release, then builds and uploads to PyPI with a Trusted Publisher (environment pypi, no token). There is no manual step; Run workflow can also force a bump level. The first release, 0.1.0, was published this way on 2026-10-11. See CONTRIBUTING.md in the repository.

Clone this wiki locally