From e66ce5e09af3677d0070f52415bf7b6c43e6fc62 Mon Sep 17 00:00:00 2001 From: Adam Gohain <68021524+akgohain@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:03:34 -0400 Subject: [PATCH] Add workflow metrics endpoint and analytics tests --- server_api/workflow/__init__.py | 3 + server_api/workflow/metrics.py | 72 ++++++++++++++++++++ server_api/workflow/router.py | 25 +++++++ tests/test_workflow_metrics.py | 117 ++++++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 server_api/workflow/__init__.py create mode 100644 server_api/workflow/metrics.py create mode 100644 server_api/workflow/router.py create mode 100644 tests/test_workflow_metrics.py diff --git a/server_api/workflow/__init__.py b/server_api/workflow/__init__.py new file mode 100644 index 00000000..50cc966b --- /dev/null +++ b/server_api/workflow/__init__.py @@ -0,0 +1,3 @@ +from server_api.workflow.router import router + +__all__ = ["router"] diff --git a/server_api/workflow/metrics.py b/server_api/workflow/metrics.py new file mode 100644 index 00000000..12318c58 --- /dev/null +++ b/server_api/workflow/metrics.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime +from typing import Any + + +def _to_iso8601(value: str | None) -> str | None: + if value is None: + return None + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed.isoformat().replace("+00:00", "Z") + + +def compute_workflow_metrics(events: list[dict[str, Any]]) -> dict[str, Any]: + """Compute aggregate workflow metrics from a list of event dicts.""" + + event_type_counts: Counter[str] = Counter() + stage_transition_counts: Counter[str] = Counter() + + approval_count = 0 + rejection_count = 0 + timestamps: list[datetime] = [] + + for event in events: + event_type = str(event.get("type") or "unknown") + event_type_counts[event_type] += 1 + + ts = event.get("timestamp") + if isinstance(ts, str): + timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00"))) + + action = str(event.get("action") or "").lower() + outcome = str(event.get("outcome") or "").lower() + if event_type == "approval" or action == "approve" or outcome == "approved": + approval_count += 1 + if ( + event_type == "rejection" + or action == "reject" + or outcome == "rejected" + ): + rejection_count += 1 + + from_stage = event.get("from_stage") + to_stage = event.get("to_stage") + if from_stage and to_stage: + transition_key = f"{from_stage}->{to_stage}" + stage_transition_counts[transition_key] += 1 + + total_decisions = approval_count + rejection_count + approval_rate = (approval_count / total_decisions) if total_decisions else 0.0 + rejection_rate = (rejection_count / total_decisions) if total_decisions else 0.0 + + first_timestamp = min(timestamps).isoformat().replace("+00:00", "Z") if timestamps else None + last_timestamp = max(timestamps).isoformat().replace("+00:00", "Z") if timestamps else None + + return { + "event_counts": dict(event_type_counts), + "decision_metrics": { + "approvals": approval_count, + "rejections": rejection_count, + "approval_rate": approval_rate, + "rejection_rate": rejection_rate, + "total_decisions": total_decisions, + }, + "stage_transition_counts": dict(stage_transition_counts), + "timestamps": { + "first_event_at": _to_iso8601(first_timestamp), + "last_event_at": _to_iso8601(last_timestamp), + }, + "total_events": len(events), + } diff --git a/server_api/workflow/router.py b/server_api/workflow/router.py new file mode 100644 index 00000000..94d78fc1 --- /dev/null +++ b/server_api/workflow/router.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter + +from server_api.workflow.metrics import compute_workflow_metrics + +router = APIRouter(prefix="/api/workflows", tags=["workflow"]) + +_WORKFLOW_EVENTS: dict[str, list[dict[str, Any]]] = {} + + +def seed_workflow_events(workflow_id: str, events: list[dict[str, Any]]) -> None: + _WORKFLOW_EVENTS[str(workflow_id)] = events + + +@router.get("/{workflow_id}/metrics") +def get_workflow_metrics(workflow_id: str): + events = _WORKFLOW_EVENTS.get(str(workflow_id), []) + metrics = compute_workflow_metrics(events) + return { + "workflow_id": str(workflow_id), + "metrics": metrics, + } diff --git a/tests/test_workflow_metrics.py b/tests/test_workflow_metrics.py new file mode 100644 index 00000000..8bffe3f0 --- /dev/null +++ b/tests/test_workflow_metrics.py @@ -0,0 +1,117 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from server_api.workflow.router import router, seed_workflow_events + + +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +def test_workflow_metrics_empty_events_returns_safe_defaults(): + workflow_id = "wf-empty" + seed_workflow_events(workflow_id, []) + + response = client.get(f"/api/workflows/{workflow_id}/metrics") + + assert response.status_code == 200 + payload = response.json() + metrics = payload["metrics"] + + assert payload["workflow_id"] == workflow_id + assert metrics["event_counts"] == {} + assert metrics["decision_metrics"] == { + "approvals": 0, + "rejections": 0, + "approval_rate": 0.0, + "rejection_rate": 0.0, + "total_decisions": 0, + } + assert metrics["stage_transition_counts"] == {} + assert metrics["timestamps"] == {"first_event_at": None, "last_event_at": None} + assert metrics["total_events"] == 0 + + +def test_workflow_metrics_matches_seeded_event_analytics(): + workflow_id = "wf-123" + seed_workflow_events( + workflow_id, + [ + { + "type": "stage_transition", + "from_stage": "draft", + "to_stage": "review", + "timestamp": "2026-04-13T10:00:00Z", + }, + { + "type": "approval", + "action": "approve", + "outcome": "approved", + "timestamp": "2026-04-13T10:05:00Z", + }, + { + "type": "stage_transition", + "from_stage": "review", + "to_stage": "done", + "timestamp": "2026-04-13T10:10:00Z", + }, + { + "type": "rejection", + "action": "reject", + "outcome": "rejected", + "timestamp": "2026-04-13T10:15:00Z", + }, + { + "type": "agent_action", + "action": "annotate", + "timestamp": "2026-04-13T10:20:00Z", + }, + ], + ) + + response = client.get(f"/api/workflows/{workflow_id}/metrics") + + assert response.status_code == 200 + payload = response.json() + metrics = payload["metrics"] + + assert payload["workflow_id"] == workflow_id + assert metrics["event_counts"] == { + "stage_transition": 2, + "approval": 1, + "rejection": 1, + "agent_action": 1, + } + assert metrics["decision_metrics"] == { + "approvals": 1, + "rejections": 1, + "approval_rate": 0.5, + "rejection_rate": 0.5, + "total_decisions": 2, + } + assert metrics["stage_transition_counts"] == { + "draft->review": 1, + "review->done": 1, + } + assert metrics["timestamps"] == { + "first_event_at": "2026-04-13T10:00:00Z", + "last_event_at": "2026-04-13T10:20:00Z", + } + assert metrics["total_events"] == 5 + + +def test_workflow_metrics_endpoint_uses_stable_schema_for_missing_workflow(): + response = client.get("/api/workflows/does-not-exist/metrics") + + assert response.status_code == 200 + payload = response.json() + + assert payload.keys() == {"workflow_id", "metrics"} + assert payload["metrics"].keys() == { + "event_counts", + "decision_metrics", + "stage_transition_counts", + "timestamps", + "total_events", + }