cronkit
A local tracker for batch cron jobs, like a small Sentry. cronkit handles locking and run tracking, records each Item your job processes and every error it hits, and reports success ratios, recurring errors, Stuck Items and Resolved failures from a SQLite database on the host.
cronkit sends nothing itself. It gives you Alerts and Digests as data, and you deliver them however you like (SMTP, HTTP, Slack, …).
Terms are defined in CONTEXT.md.
Installation
pip install -e /path/to/cronkit
Python 3.11+, Linux, no runtime dependencies.
Quickstart
#!/usr/bin/env python3
import sys
from cronkit import cron_runner, item, record_error
from cronkit.errors import Skip, SoftFail, HardFail
JOB = "sync_orders"
def worker(run_id: int) -> None:
orders = fetch_pending_orders()
if not orders:
raise Skip("nothing to process") # whole Run skipped
for order in orders:
with item(order.id): # one Item per retryable unit
if not order.ready:
raise SoftFail("upstream not ready") # expected to clear later
if order.corrupt:
raise HardFail("bad payload") # needs a human
process(order) # any other exception → hard
if __name__ == "__main__":
sys.exit(cron_runner(JOB, worker, schedule="*/5 * * * *"))
item() records the outcome and swallows the exception, so the loop continues with the next order.
Several errors on one Item
When one Item contains nested work (e.g. several sessions in one record), record each problem as its own Error so each one gets its own Fingerprint:
with item(record.id):
for session in record.sessions:
try:
handle(session)
except Exception as e:
record_error(e) # attached to the current Item
... # then raise SoftFail/HardFail for the Item's outcome
Outcomes
| Raised | Item outcome | Counts as failure | Alerted |
|---|---|---|---|
| nothing | ok | — | — |
Skip |
skip | no | no |
SoftFail |
soft | yes | no (unless Stuck and escalated) |
HardFail |
hard | yes | yes |
| other | hard | yes | yes |
If an exception escapes outside any Item, the Run is Crashed, which is always alerted. If the previous Run still holds the lock, the new Run is recorded as Locked out and exits 0.
Alerts
Pass a callback. It runs once at the end of any Run that Crashed or had hard failures:
import smtplib
from email.message import EmailMessage
def send_alert(alert) -> None:
msg = EmailMessage()
msg["Subject"] = f"[{alert.env}] {alert.job}: {len(alert.items)} hard failure(s)"
msg["From"] = "cron@example.com"
msg["To"] = "ops@example.com"
msg.set_content(alert.render())
with smtplib.SMTP("localhost") as s:
s.send_message(msg)
sys.exit(cron_runner(JOB, worker, on_alert=send_alert))
Digests
A Digest is data (build_digest) plus a template (render_digest). To email one daily or weekly, run it as an ordinary cronkit Job:
from cronkit import build_digest, cron_runner, render_digest
def worker(run_id: int) -> None:
send_mail("Weekly cron digest", render_digest(build_digest(days=7)))
sys.exit(cron_runner("weekly_digest", worker, schedule="0 9 * * 1"))
Retries
from cronkit import with_retry
with_retry(lambda: call_external_api(), retries=5, backoff=1.5)
Each retried attempt is logged and stored against the current Item; the Item only fails if the last attempt does.
CLI
cronkit list # known Jobs
cronkit summary [job] [--days N] # Item counts and success ratio
cronkit failures [job] [--days N]
cronkit dangling [job] [--days N] # errors recorded outside any Item
cronkit errors [job] [--show FP] # recurring errors by Fingerprint
cronkit retries [job] # retried attempts, rescued vs exhausted
cronkit latest [job]
cronkit stuck [job]
cronkit digest [job] [--days N] # print the Digest
Every command accepts --json.
Configuration
| Variable | Default | Description |
|---|---|---|
APP_ENV |
dev |
Included in Alerts/Digests |
CRONKIT_TZ |
UTC |
Display timezone |
CRON_TRACKER_DB |
/var/log/cronkit/cron_tracker.db |
SQLite tracker path |
CRON_LOG_DIR |
/var/log/cronkit |
Log file directory |
CRON_LOCK_DIR |
/var/lock/cronkit |
Lock file directory |
CRON_LOG_SOURCE |
1 |
0 hides the logger name (Job / cronkit.* / library) in log lines |
The default paths require root. Create them in advance and chown them to the cron user:
sudo mkdir -p /var/log/cronkit /var/lock/cronkit
sudo chown $USER:$USER /var/log/cronkit /var/lock/cronkit
Logging
cronkit sets up logging automatically. Logs go to stderr and to one file per ISO week, {CRON_LOG_DIR}/{job}.{YYYY}-W{WW}.log (e.g. sync_orders.2026-W39.log). Timestamps and week boundaries are UTC. Each process picks its file when it starts and never renames it, so a Run's lines always stay in one file. cronkit never deletes log files; clean them up with your host's usual policy (logrotate, find -mtime, …). In your job:
import logging
logger = logging.getLogger(JOB)
Metadata
Release files for cronkit 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cronkit-0.2.0.tar.gz | 30.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cronkit-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 57.6 kB
Release files / cronkit-0.2.0.tar.gz
| Download URL | cronkit-0.2.0.tar.gz |
|---|---|
| Size | 30.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ca9bef0fc74d0486600066c8103f062e28d689c7de69f08cb6f1e78f7e1e5b03
|
|
BLAKE2b-256 checksum How to use checksums |
1f15c6c4efe58f8ee25477bdce7825315b58ee7dc026c09722f248b9390c26de
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / cronkit-0.2.0-py3-none-any.whl
| Download URL | cronkit-0.2.0-py3-none-any.whl |
|---|---|
| Size | 27.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
65171968037a568c7ce9f7dbaafa0debdef8e980462af5abdb4a0e2a500d3eb7
|
|
BLAKE2b-256 checksum How to use checksums |
38e9ac6c174323b4015197fd4bd64442059de8a0515bf53bc8182a0e2df59148
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|