feat: improve webhook filtering, worker status startup handling, and timestamp parsing
- Skip half_hour_report events from webhook posts in people_flow - Handle pre-existing stale worker status files during startup gracefully - Make store_dwell_alert timestamp parsing robust against invalid/empty values - Update lessons learned and todo documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,10 @@ def _payload_for_webhook(payload: dict) -> dict:
|
||||
return outbound
|
||||
|
||||
|
||||
def _should_post_webhook(payload: dict) -> bool:
|
||||
return payload.get("event") != "half_hour_report"
|
||||
|
||||
|
||||
def dispatch_json_event(
|
||||
path: str | Path,
|
||||
payload: dict,
|
||||
@@ -22,7 +26,7 @@ def dispatch_json_event(
|
||||
with output_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
|
||||
if not webhook_url.strip():
|
||||
if not webhook_url.strip() or not _should_post_webhook(payload):
|
||||
return
|
||||
|
||||
req = request.Request(
|
||||
|
||||
@@ -66,12 +66,17 @@ def worker_status_stall_reason(
|
||||
now: float | None = None,
|
||||
) -> str | None:
|
||||
current_time = datetime.now().timestamp() if now is None else now
|
||||
age_seconds = worker_status_age_seconds(path, now=current_time)
|
||||
if age_seconds is None:
|
||||
try:
|
||||
stat_result = path.stat()
|
||||
except FileNotFoundError:
|
||||
if current_time - started_at < max_age_seconds:
|
||||
return None
|
||||
return f"rtsp worker status missing path={path}"
|
||||
|
||||
if stat_result.st_mtime < started_at and current_time - started_at < max_age_seconds:
|
||||
return None
|
||||
|
||||
age_seconds = max(0.0, current_time - stat_result.st_mtime)
|
||||
if age_seconds <= max_age_seconds:
|
||||
return None
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
from src.people_flow.webhook import dispatch_json_event
|
||||
|
||||
|
||||
def test_dispatch_json_event_omits_tracks_from_webhook_but_keeps_local_log(
|
||||
def test_dispatch_json_event_does_not_post_half_hour_report_but_keeps_local_log(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
sent: dict[str, object] = {}
|
||||
@@ -44,9 +44,4 @@ def test_dispatch_json_event_omits_tracks_from_webhook_but_keeps_local_log(
|
||||
|
||||
lines = output.read_text(encoding="utf-8").splitlines()
|
||||
assert json.loads(lines[0]) == payload
|
||||
assert sent["url"] == "https://example.test/webhook"
|
||||
assert sent["timeout"] == 7.5
|
||||
assert sent["payload"] == {
|
||||
"event": "half_hour_report",
|
||||
"total_people": 3,
|
||||
}
|
||||
assert sent == {}
|
||||
|
||||
@@ -99,3 +99,29 @@ def test_worker_status_stall_reason_reports_missing_and_stale_status(tmp_path: P
|
||||
assert "status=missing" not in reason
|
||||
assert "phase=tracking_frame" in reason
|
||||
assert "age_seconds=200.0" in reason
|
||||
|
||||
|
||||
def test_worker_status_stall_reason_ignores_preexisting_stale_file_during_startup(
|
||||
tmp_path: Path,
|
||||
):
|
||||
status_path = tmp_path / "worker_status.json"
|
||||
write_worker_status(
|
||||
status_path,
|
||||
"waiting_to_reconnect",
|
||||
source="rtsp://camera/stream",
|
||||
window_index=0,
|
||||
frame_index=0,
|
||||
last_processed_at=None,
|
||||
note="open_failed",
|
||||
)
|
||||
os.utime(status_path, (100.0, 100.0))
|
||||
|
||||
assert (
|
||||
worker_status_stall_reason(
|
||||
status_path,
|
||||
started_at=250.0,
|
||||
max_age_seconds=180.0,
|
||||
now=300.0,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@@ -212,23 +212,25 @@ def _build_summary(ctx: ManageContext) -> dict:
|
||||
continue
|
||||
|
||||
if payload.get("event") == "half_hour_report":
|
||||
last_report_time = _string_value(payload.get("window_end"))
|
||||
active_count = _int_value(payload.get("active_customer_count"))
|
||||
stat = _build_window_stat(payload)
|
||||
window_stats.append(stat)
|
||||
longest_dwell_seconds = max(
|
||||
longest_dwell_seconds,
|
||||
stat["max_wait_seconds"],
|
||||
)
|
||||
queue_level = stat["queue_level"]
|
||||
over_threshold_count = stat["over_threshold_count"]
|
||||
under_threshold_count = stat["under_threshold_count"]
|
||||
status_change = stat["status_change"]
|
||||
|
||||
window_stats.sort(
|
||||
key=lambda item: _parse_timestamp(item["window_end"]),
|
||||
reverse=True,
|
||||
)
|
||||
window_stats.sort(key=lambda item: _sort_timestamp(item["window_end"]), reverse=True)
|
||||
|
||||
for stat in window_stats:
|
||||
if _parse_timestamp(stat["window_end"]) is None:
|
||||
continue
|
||||
last_report_time = stat["window_end"]
|
||||
active_count = stat["active_customer_count"]
|
||||
queue_level = stat["queue_level"]
|
||||
over_threshold_count = stat["over_threshold_count"]
|
||||
under_threshold_count = stat["under_threshold_count"]
|
||||
status_change = stat["status_change"]
|
||||
break
|
||||
|
||||
headline = "No reports yet"
|
||||
if last_report_time:
|
||||
@@ -411,8 +413,20 @@ def _latest_timestamp(*values: str) -> str:
|
||||
return latest_raw
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
def _sort_timestamp(value: str) -> tuple[int, datetime]:
|
||||
parsed = _parse_timestamp(value)
|
||||
if parsed is None:
|
||||
return (0, datetime.min.replace(tzinfo=datetime.now().astimezone().tzinfo))
|
||||
return (1, parsed)
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime | None:
|
||||
if not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=datetime.now().astimezone().tzinfo)
|
||||
return parsed
|
||||
|
||||
@@ -52,7 +52,7 @@ def build_client(project_root: Path):
|
||||
"over_threshold_count": 2,
|
||||
"under_threshold_count": 1,
|
||||
"queue_level": "normal",
|
||||
"previous_queue_level": null,
|
||||
"previous_queue_level": None,
|
||||
"status_change": "initial",
|
||||
},
|
||||
}
|
||||
@@ -149,6 +149,39 @@ def test_get_manage_summary(tmp_path: Path):
|
||||
)
|
||||
|
||||
|
||||
def test_get_manage_summary_ignores_invalid_report_timestamp(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
events_path = tmp_path / "logs" / "events.jsonl"
|
||||
with events_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "half_hour_report",
|
||||
"camera_id": "store_cam_01",
|
||||
"window_start": "2026-04-16T10:00:00+08:00",
|
||||
"window_end": "",
|
||||
"active_customer_count": 1,
|
||||
"queue_metrics": {
|
||||
"queue_level": "normal",
|
||||
"over_threshold_count": 1,
|
||||
"under_threshold_count": 0,
|
||||
"status_change": "unchanged",
|
||||
},
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
response = client.get("/api/manage/summary")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["last_result_time"] == "2026-04-16T10:00:00+08:00"
|
||||
assert (
|
||||
response.json["metrics"]["recent_window_stats"][0]["window_end"]
|
||||
== "2026-04-16T10:00:00+08:00"
|
||||
)
|
||||
|
||||
|
||||
def test_get_manage_windows(tmp_path: Path):
|
||||
client, _ = build_client(tmp_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user