Push Log Summary Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Make each hub push write a concise, timestamped summary to push.log instead of the full JSON payload, and stop the install cron from dumping the payload.
Architecture: Add a pure, testable summarize_push(...) -> str to the existing push_to_hub management command; call it on the real push path (stdout on success, stderr on failure) in place of the payload dump. Drop --json from the cron command in bin/install/cron.sh. No rotation, no settings.LOGGING change (deferred to a later logging pass).
Tech Stack: Django management command, pytest/pytest-django, stdlib only.
Design doc: docs/plans/2026-08-06-push-log-summary-design.md
Reference before starting:
apps/alerts/management/commands/push_to_hub.py— the command. Key spots:handle()(the--json/success/failure branches around lines 123–153),_result_to_alert()(severity/status mapping),send_to_hub().apps/alerts/_tests/commands/test_push_to_hub.py— existing test style:override_settings(HUB_URL=..., HUB_API_KEY=...),@patch(...CHECKER_REGISTRY),@patch(...safe_urlopen),call_command(..., stdout=StringIO()).bin/install/cron.sh— the push cron line (128) and log-path echoes (138, 159).
Conventions: absolute imports; line length 100; black + ruff clean; 100% branch coverage on changed lines; never log secrets/payloads (AGENTS.md).
Alert dict shape (from _result_to_alert)
Each alert has: name ("<checker>: <message>"), status ("resolved" | "firing"), severity ("info" | "warning" | "critical"), and labels["checker"]. The summary derives counts and the firing list from these fields. |
okcount = alerts withstatus == "resolved".warningcount = alerts withstatus == "firing"andseverity == "warning".criticalcount = alerts withstatus == "firing"andseverity == "critical".firing:list = everystatus == "firing"alert renderedlabels["checker"](severity), critical-first then warning.
Task 1: summarize_push — success summary
Files:
- Modify:
apps/alerts/management/commands/push_to_hub.py - Test:
apps/alerts/_tests/commands/test_push_to_hub.py
Step 1: Write the failing test
Add a new test class:
from apps.alerts.management.commands.push_to_hub import summarize_push
class SummarizePushTests(TestCase):
def _alerts(self):
return [
{"name": "cpu: OK", "status": "resolved", "severity": "info",
"labels": {"checker": "cpu"}},
{"name": "disk_linux: high", "status": "firing", "severity": "warning",
"labels": {"checker": "disk_linux"}},
{"name": "raid: degraded", "status": "firing", "severity": "critical",
"labels": {"checker": "raid"}},
]
def test_success_summary_has_counts_http_and_firing(self):
text = summarize_push(
hub_url="https://hub.example.com",
alerts=self._alerts(),
http_status=202,
duration_ms=312,
ok=True,
)
self.assertIn("push OK", text)
self.assertIn("hub=https://hub.example.com", text)
self.assertIn("ok=1 warning=1 critical=1 -> 3 alerts", text)
self.assertIn("HTTP 202", text)
self.assertIn("(312ms)", text)
# firing line, critical first
self.assertIn("firing: raid(critical), disk_linux(warning)", text)
def test_all_ok_has_no_firing_line(self):
text = summarize_push(
hub_url="https://hub.example.com",
alerts=[{"name": "cpu: OK", "status": "resolved", "severity": "info",
"labels": {"checker": "cpu"}}],
http_status=202, duration_ms=5, ok=True,
)
self.assertIn("ok=1 warning=0 critical=0 -> 1 alerts", text)
self.assertNotIn("firing:", text)
Step 2: Run test to verify it fails
Run: uv run pytest apps/alerts/_tests/commands/test_push_to_hub.py::SummarizePushTests -v Expected: FAIL — cannot import summarize_push.
Step 3: Write minimal implementation
Add near the top of push_to_hub.py (after send_to_hub), importing datetime/timezone (already imported):
def summarize_push(
*,
hub_url: str,
alerts: list[dict],
http_status: int | None,
duration_ms: int | None,
ok: bool,
error: str | None = None,
) -> str:
"""Build the concise push.log summary block. Pure — no I/O, no secrets.
Never includes the payload/metrics or the API key; only the non-secret
hub_url, counts, HTTP status, and the firing checker names.
"""
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if not ok:
detail = f"HTTP {http_status}" if http_status is not None else f"unreachable: {error}"
return f"{ts} push FAILED hub={hub_url} {detail}"
firing = [a for a in alerts if a.get("status") == "firing"]
n_ok = sum(1 for a in alerts if a.get("status") == "resolved")
n_warn = sum(1 for a in firing if a.get("severity") == "warning")
n_crit = sum(1 for a in firing if a.get("severity") == "critical")
dur = f" ({duration_ms}ms)" if duration_ms is not None else ""
lines = [
f"{ts} push OK hub={hub_url}",
f" ok={n_ok} warning={n_warn} critical={n_crit} -> {len(alerts)} alerts, "
f"HTTP {http_status}{dur}",
]
if firing:
order = {"critical": 0, "warning": 1}
firing_sorted = sorted(firing, key=lambda a: order.get(a.get("severity"), 2))
parts = [f"{a['labels']['checker']}({a['severity']})" for a in firing_sorted]
lines.append(" firing: " + ", ".join(parts))
return "\n".join(lines)
Step 4: Run test to verify it passes
Run: uv run pytest apps/alerts/_tests/commands/test_push_to_hub.py::SummarizePushTests -v Expected: PASS.
Step 5: Commit
git add apps/alerts/management/commands/push_to_hub.py apps/alerts/_tests/commands/test_push_to_hub.py
git commit -m "feat(alerts): add summarize_push for concise push.log lines"
Task 2: summarize_push — failure summary + redaction tests
Files:
- Modify:
apps/alerts/_tests/commands/test_push_to_hub.py
Step 1: Write the failing/needed tests
def test_failed_http_summary(self):
text = summarize_push(
hub_url="https://hub.example.com", alerts=[],
http_status=500, duration_ms=None, ok=False,
)
self.assertIn("push FAILED", text)
self.assertIn("hub=https://hub.example.com", text)
self.assertIn("HTTP 500", text)
def test_failed_unreachable_summary(self):
text = summarize_push(
hub_url="https://hub.example.com", alerts=[],
http_status=None, duration_ms=None, ok=False, error="timed out",
)
self.assertIn("unreachable: timed out", text)
def test_summary_never_leaks_payload_or_key(self):
alerts = [{"name": "cpu: OK", "status": "resolved", "severity": "info",
"labels": {"checker": "cpu"},
"metrics": {"secret_metric": "sensitive-value-XYZ"}}]
text = summarize_push(
hub_url="https://hub.example.com", alerts=alerts,
http_status=202, duration_ms=1, ok=True,
)
self.assertNotIn("sensitive-value-XYZ", text)
self.assertNotIn("metrics", text)
Step 2: Run to verify
Run: uv run pytest apps/alerts/_tests/commands/test_push_to_hub.py::SummarizePushTests -v Expected: PASS (Task 1 implementation already satisfies these). If any FAIL, adjust summarize_push minimally.
Step 3: Adjust implementation if needed — none expected; summarize_push reads only status/severity/labels.checker, never metrics.
Step 4: Run to verify it passes — Expected: PASS.
Step 5: Commit
git add apps/alerts/_tests/commands/test_push_to_hub.py
git commit -m "test(alerts): failure + redaction cases for summarize_push"
Task 3: Wire the summary into handle() (success path)
Files:
- Modify:
apps/alerts/management/commands/push_to_hub.py - Test:
apps/alerts/_tests/commands/test_push_to_hub.py
Step 1: Write the failing test
@override_settings(HUB_URL="https://hub.example.com", HUB_API_KEY="tok123")
@patch("apps.alerts.management.commands.push_to_hub.CHECKER_REGISTRY")
@patch("apps.alerts.management.commands.push_to_hub.safe_urlopen")
def test_default_output_is_summary_not_payload(self, mock_urlopen, mock_registry):
mock_checker_cls = MagicMock()
mock_checker_cls.return_value.run.return_value = CheckResult(
status=CheckStatus.WARNING, message="CPU at 75%",
metrics={"cpu_percent": 75.0}, checker_name="cpu",
)
mock_registry.items.return_value = [("cpu", mock_checker_cls)]
resp = MagicMock()
resp.status = 202
resp.read.return_value = b"ok"
mock_urlopen.return_value.__enter__.return_value = resp
out = StringIO()
call_command("push_to_hub", stdout=out)
output = out.getvalue()
self.assertIn("push OK", output)
self.assertIn("HTTP 202", output)
self.assertIn("firing: cpu(warning)", output)
# payload/metrics must NOT be dumped
self.assertNotIn("cpu_percent", output)
self.assertNotIn("\"metrics\"", output)
This test is placed in the existing PushToHubTests class (reuses its imports/patterns).
Step 2: Run to verify it fails
Run: uv run pytest apps/alerts/_tests/commands/test_push_to_hub.py::PushToHubTests::test_default_output_is_summary_not_payload -v Expected: FAIL — current success path prints "Hub accepted: HTTP 202" (no firing list) and the --json path would dump metrics; the new assertions on firing: fail.
Step 3: Implement — time the call and print the summary
In handle(), replace the success/--json block. Add import time at top. Around the send_to_hub call:
start = time.perf_counter()
try:
status, resp_body = send_to_hub(hub_url, api_key, payload)
except URLNotAllowedError:
self.stderr.write(summarize_push(
hub_url=hub_url, alerts=alerts, http_status=None,
duration_ms=None, ok=False, error="URL not allowed by security policy",
))
raise CommandError("HUB_URL not allowed by security policy")
except Exception as e:
self.stderr.write(summarize_push(
hub_url=hub_url, alerts=alerts, http_status=None,
duration_ms=None, ok=False, error=str(e),
))
raise CommandError(f"Failed to reach hub at {hub_url}: {e}")
duration_ms = int((time.perf_counter() - start) * 1000)
if status in (200, 201, 202):
if options["json_output"]:
self.stdout.write(json.dumps(payload, indent=2, default=str))
else:
self.stdout.write(summarize_push(
hub_url=hub_url, alerts=alerts, http_status=status,
duration_ms=duration_ms, ok=True,
))
else:
self.stderr.write(summarize_push(
hub_url=hub_url, alerts=alerts, http_status=status,
duration_ms=duration_ms, ok=False,
))
raise CommandError(f"Hub returned HTTP {status}: {resp_body}")
Also remove the now-redundant early self.stdout.write(f"Pushing {len(alerts)} alert(s)...") line (lines 131–132) so the default output is exactly the summary. Keep --json (still dumps payload) and --dry-run untouched.
Step 4: Run to verify it passes
Run: uv run pytest apps/alerts/_tests/commands/test_push_to_hub.py -v Expected: PASS. Update any existing assertion that expected the old "Hub accepted: HTTP ..." wording or the "Pushing N alert(s)" line.
Step 5: Commit
git add apps/alerts/management/commands/push_to_hub.py apps/alerts/_tests/commands/test_push_to_hub.py
git commit -m "feat(alerts): push_to_hub prints concise summary, not payload"
Task 4: Failure path + --json regression tests
Files:
- Modify:
apps/alerts/_tests/commands/test_push_to_hub.py
Step 1: Write the tests
@override_settings(HUB_URL="https://hub.example.com", HUB_API_KEY="tok123")
@patch("apps.alerts.management.commands.push_to_hub.CHECKER_REGISTRY")
@patch("apps.alerts.management.commands.push_to_hub.safe_urlopen")
def test_failed_http_writes_summary_to_stderr_and_raises(self, mock_urlopen, mock_registry):
mock_checker_cls = MagicMock()
mock_checker_cls.return_value.run.return_value = CheckResult(
status=CheckStatus.OK, message="OK", metrics={}, checker_name="cpu",
)
mock_registry.items.return_value = [("cpu", mock_checker_cls)]
resp = MagicMock()
resp.status = 500
resp.read.return_value = b"boom"
mock_urlopen.return_value.__enter__.return_value = resp
err = StringIO()
with self.assertRaises(CommandError):
call_command("push_to_hub", stderr=err)
self.assertIn("push FAILED", err.getvalue())
self.assertIn("HTTP 500", err.getvalue())
@override_settings(HUB_URL="https://hub.example.com", HUB_API_KEY="tok123")
@patch("apps.alerts.management.commands.push_to_hub.CHECKER_REGISTRY")
@patch("apps.alerts.management.commands.push_to_hub.safe_urlopen")
def test_json_flag_still_dumps_payload(self, mock_urlopen, mock_registry):
mock_checker_cls = MagicMock()
mock_checker_cls.return_value.run.return_value = CheckResult(
status=CheckStatus.OK, message="OK", metrics={"cpu_percent": 10.0},
checker_name="cpu",
)
mock_registry.items.return_value = [("cpu", mock_checker_cls)]
resp = MagicMock()
resp.status = 202
resp.read.return_value = b"ok"
mock_urlopen.return_value.__enter__.return_value = resp
out = StringIO()
call_command("push_to_hub", "--json", stdout=out)
self.assertIn("cpu_percent", out.getvalue()) # payload preserved for debug
Step 2: Run to verify
Run: uv run pytest apps/alerts/_tests/commands/test_push_to_hub.py -v Expected: PASS with Task 3 implementation.
Step 3: Adjust if needed — ensure the unreachable branch is also covered; if coverage misses the except Exception path, add a test where safe_urlopen raises URLError/generic exception and assert unreachable: in stderr.
Step 4: Run full command test file — Expected: PASS.
Step 5: Commit
git add apps/alerts/_tests/commands/test_push_to_hub.py
git commit -m "test(alerts): failure-to-stderr + --json payload regression"
Task 5: Drop --json from the install cron
Files:
- Modify:
bin/install/cron.sh
Step 1: Edit the push command (line 128)
Change:
PUSH_CMD="cd $PROJECT_DIR && $UV_PATH run python manage.py push_to_hub --json >> ${LOG_DIR:-$PROJECT_DIR/logs}/push.log 2>&1"
to (drop --json; keep the redirect):
PUSH_CMD="cd $PROJECT_DIR && $UV_PATH run python manage.py push_to_hub >> ${LOG_DIR:-$PROJECT_DIR/logs}/push.log 2>&1"
Step 2: Verify shell syntax
Run: bash -n bin/install/cron.sh Expected: no output (syntax OK).
Step 3: Sanity-check no other --json push references remain
Run: grep -n "push_to_hub --json" bin/ Expected: no matches.
Step 4: Commit
git add bin/install/cron.sh
git commit -m "chore(install): cron push writes summary to push.log, not payload"
Task 6: Coverage, lint, docs, verification
Step 1: Branch coverage on changed code
Run:
uv run coverage run -m pytest apps/alerts/_tests/commands/test_push_to_hub.py
uv run coverage report -m --include="*/apps/alerts/management/commands/push_to_hub.py"
Expected: 100% on the changed lines. Add targeted tests for any uncovered branch (e.g. the duration_ms is None path in summarize_push, the URLNotAllowedError branch).
Step 2: Format + lint + security
Run:
uv run black apps/alerts/management/commands/push_to_hub.py apps/alerts/_tests/commands/test_push_to_hub.py
uv run ruff check apps/alerts/management/commands/push_to_hub.py apps/alerts/_tests/commands/test_push_to_hub.py --fix
uv run bandit -r apps/alerts/management/commands/push_to_hub.py -c pyproject.toml
Expected: clean.
Step 3: Docs
- If
bin/AGENTS.mdordocs/Installation.mddocuments the push cron /push.logcontents, update the description to “concise per-push summary (no payload)”. Grep first:grep -rn "push.log\|push_to_hub --json" docs/ bin/AGENTS.md.
Step 4: Full suite regression
Run: uv run pytest apps/alerts/_tests/ -q Expected: PASS (no regressions; confirm no other test relied on the old "Hub accepted" / "Pushing N alert(s)" wording).
Step 5: Manual check (optional, on a host with HUB configured)
Run: uv run python manage.py push_to_hub and confirm stdout is the summary block; --json still prints the payload; --dry-run unchanged.
Step 6: Commit
git add -A
git commit -m "docs(install): document concise push.log summary output"
Acceptance criteria
- Default
push_to_huboutput (and cron output) is the concise timestamped summary; no payload, no secrets. --jsonstill dumps the full payload;--dry-rununchanged.- Failure paths (non-2xx, unreachable, SSRF-blocked) write a
push FAILEDsummary to stderr and exit non-zero. - 100% branch coverage on changed lines;
black/ruff/bandit/pytestclean. - No rotation and no
settings.LOGGINGchange (deferred).