diff --git a/CHANGELOG.md b/CHANGELOG.md index aa782e4..e9aa903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ox_health --queue`, so queues with different retention needs can each be pruned with their own `--older-than`. Old schedule ticks are still pruned for every schedule. +- `ox_health --format json` prints the check figures as one JSON object + for container healthchecks and monitoring agents. On a failing check the + object is still printed, and the exit status is unchanged. ### Changed diff --git a/README.md b/README.md index 9a7da61..a6105b4 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600 | Flag | Default | Meaning | | --- | --- | --- | | `--queue` | all queues | Restrict the checks to one queue. | +| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. | | `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. | | `--max-age` | off | Fail when the oldest waiting task has waited longer than this. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--worker-timeout` | off | Fail when no worker has claimed a task within this long. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | diff --git a/docs/configuration.md b/docs/configuration.md index 9dcae2e..a3185fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -195,6 +195,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600 | Flag | Default | Meaning | | --- | --- | --- | | `--queue` | all queues | Restrict the checks to one queue. | +| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. | | `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Tasks deferred to a future `run_after` do not count. | | `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f4c842f..ae3281b 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -404,6 +404,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600 | Flag | Default | Meaning | | --- | --- | --- | | `--queue` | all queues | Restrict the checks to one queue. | +| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. | | `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Tasks deferred to a future `run_after` do not count. | | `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | @@ -2482,6 +2483,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600 | Flag | Default | Meaning | | --- | --- | --- | | `--queue` | all queues | Restrict the checks to one queue. | +| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. | | `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Deferred tasks do not count. | | `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | @@ -3540,6 +3542,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `ox_health --queue`, so queues with different retention needs can each be pruned with their own `--older-than`. Old schedule ticks are still pruned for every schedule. +- `ox_health --format json` prints the check figures as one JSON object + for container healthchecks and monitoring agents. On a failing check the + object is still printed, and the exit status is unchanged. ### Changed diff --git a/docs/monitoring.md b/docs/monitoring.md index 3b78016..63b9259 100644 --- a/docs/monitoring.md +++ b/docs/monitoring.md @@ -62,6 +62,7 @@ python manage.py ox_health --max-backlog 1000 --max-age 600 | Flag | Default | Meaning | | --- | --- | --- | | `--queue` | all queues | Restrict the checks to one queue. | +| `--format` | `text` | `json` prints one object on stdout instead of the `OK:` line: `ok`, `queue`, `backlog`, `oldest_age_seconds`, `last_claim_age_seconds` and `problems`. `queue` is `null` when no `--queue` is given. The figures are `null` when there is nothing to measure or the check could not run, as with an unreachable database or an invalid threshold. The object is printed on failure too, before the same non-zero exit. | | `--max-backlog` | off | Fail when more than this many READY tasks are eligible to run. Deferred tasks do not count. | | `--max-age` | off | Fail when the oldest waiting task has waited longer than this since becoming eligible. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | | `--worker-timeout` | off | Fail when no worker has claimed a task within this long, or no claim was ever recorded. Accepts `7d`, `24h`, `90m`, `45s`, or a plain number of seconds. | diff --git a/src/django_ox/management/commands/ox_health.py b/src/django_ox/management/commands/ox_health.py index e32d863..af31fe9 100644 --- a/src/django_ox/management/commands/ox_health.py +++ b/src/django_ox/management/commands/ox_health.py @@ -1,5 +1,6 @@ +import json from datetime import timedelta -from typing import Any +from typing import Any, NoReturn from django.core.management.base import BaseCommand, CommandError, CommandParser from django.db import DatabaseError @@ -12,6 +13,10 @@ def _seconds(value: timedelta | None) -> str: return "none" if value is None else f"{value.total_seconds():.0f}s" +def _total_seconds(value: timedelta | None) -> float | None: + return None if value is None else value.total_seconds() + + class Command(BaseCommand): help = ( "Check queue health. Exits 0 when every enabled check passes, " @@ -20,6 +25,16 @@ class Command(BaseCommand): ) def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help=( + "Output format. json prints one object with the same figures " + "on stdout, also when a check fails; the exit status is the " + "same either way (default: %(default)s)." + ), + ) parser.add_argument( "--queue", default=None, @@ -63,20 +78,30 @@ def handle(self, *args: Any, **options: Any) -> None: max_backlog: int | None = options["max_backlog"] max_age: float | None = options["max_age"] worker_timeout: float | None = options["worker_timeout"] + queue: str | None = options["queue"] + as_json = options["format"] == "json" + + def _invalid(reason: str) -> NoReturn: + if as_json: + self._write_json(queue, None, None, None, [reason]) + raise CommandError(reason) + if max_backlog is not None and max_backlog < 0: - raise CommandError("--max-backlog must be zero or a positive integer.") + _invalid("--max-backlog must be zero or a positive integer.") if max_age is not None and max_age <= 0: - raise CommandError("--max-age must be a positive number of seconds.") + _invalid("--max-age must be a positive number of seconds.") if worker_timeout is not None and worker_timeout <= 0: - raise CommandError("--worker-timeout must be a positive number of seconds.") + _invalid("--worker-timeout must be a positive number of seconds.") - queue: str | None = options["queue"] try: backlog = stats.ready_count(queue) oldest = stats.oldest_ready_age(queue) claim_age = stats.last_claim_age(queue) except DatabaseError as exc: - raise CommandError(f"Database unreachable: {exc}") from exc + reason = f"Database unreachable: {exc}" + if as_json: + self._write_json(queue, None, None, None, [reason]) + raise CommandError(reason) from exc problems: list[str] = [] if max_backlog is not None and backlog > max_backlog: @@ -100,10 +125,37 @@ def handle(self, *args: Any, **options: Any) -> None: f"last task claim was {_seconds(claim_age)} ago, " f"over --worker-timeout {worker_timeout:g}s" ) + if as_json: + # A monitoring agent wants the figures most when a check fails, so + # the object is printed before the non-zero exit, not instead of it. + self._write_json(queue, backlog, oldest, claim_age, problems) if problems: raise CommandError("; ".join(problems)) + if as_json: + return self.stdout.write( f"OK: backlog={backlog} oldest_age={_seconds(oldest)} " f"last_claim_age={_seconds(claim_age)}" ) + + def _write_json( + self, + queue: str | None, + backlog: int | None, + oldest: timedelta | None, + claim_age: timedelta | None, + problems: list[str], + ) -> None: + self.stdout.write( + json.dumps( + { + "ok": not problems, + "queue": queue, + "backlog": backlog, + "oldest_age_seconds": _total_seconds(oldest), + "last_claim_age_seconds": _total_seconds(claim_age), + "problems": problems, + } + ) + ) diff --git a/tests/test_health.py b/tests/test_health.py index d90ae5a..a10253a 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -1,4 +1,5 @@ import argparse +import json from datetime import timedelta from io import StringIO @@ -47,12 +48,90 @@ def health(*args): return out.getvalue() +def health_json(*args): + out = StringIO() + error = None + try: + call_command("ox_health", "--format", "json", *args, stdout=out) + except CommandError as exc: + error = exc + return json.loads(out.getvalue()), error + + @pytest.mark.django_db class TestHealth: def test_ok_with_no_flags_on_empty_database(self): out = health() assert out.startswith("OK: backlog=0 oldest_age=none last_claim_age=none") + def test_json_ok_reports_the_same_figures(self): + make_ready(seconds_ago=30) + make_claimed(seconds_ago=60) + + report, error = health_json("--max-backlog=5") + + assert error is None + assert report["ok"] is True + assert report["queue"] is None + assert report["backlog"] == 1 + assert report["oldest_age_seconds"] == pytest.approx(30, abs=5) + assert report["last_claim_age_seconds"] == pytest.approx(60, abs=5) + assert report["problems"] == [] + + def test_json_on_empty_database_uses_null_ages(self): + report, error = health_json("--queue", "emails") + + assert error is None + assert report == { + "ok": True, + "queue": "emails", + "backlog": 0, + "oldest_age_seconds": None, + "last_claim_age_seconds": None, + "problems": [], + } + + def test_json_failure_prints_the_object_and_exits_non_zero(self): + make_ready() + make_ready() + + report, error = health_json("--max-backlog=1", "--worker-timeout=60") + + assert isinstance(error, CommandError) + assert report["ok"] is False + assert report["backlog"] == 2 + assert report["problems"] == [ + "backlog is 2, over --max-backlog 1", + "no task claim recorded (--worker-timeout 60s)", + ] + assert str(error) == "; ".join(report["problems"]) + + def test_json_database_unreachable_still_prints_the_object(self, monkeypatch): + def boom(queue_name=None): + raise DatabaseError("connection refused") + + monkeypatch.setattr(ox_health.stats, "ready_count", boom) + report, error = health_json() + + assert isinstance(error, CommandError) + assert report["ok"] is False + assert report["backlog"] is None + assert report["problems"] == ["Database unreachable: connection refused"] + + def test_json_rejects_a_bad_threshold_but_still_prints_the_object(self): + report, error = health_json("--max-backlog=-1") + + assert isinstance(error, CommandError) + assert report == { + "ok": False, + "queue": None, + "backlog": None, + "oldest_age_seconds": None, + "last_claim_age_seconds": None, + "problems": ["--max-backlog must be zero or a positive integer."], + } + assert str(error) == "--max-backlog must be zero or a positive integer." + def test_database_unreachable_fails_with_reason(self, monkeypatch): def boom(queue_name=None): raise DatabaseError("connection refused")