Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from server_api.auth.database import get_db
from server_api.auth.router import get_current_user
from server_api.ehtool import router as ehtool_router
from server_api.workflow import router as workflow_router

from fastapi.staticfiles import StaticFiles
import os
Expand Down Expand Up @@ -77,6 +78,7 @@ def _ensure_chatbot():

app.include_router(auth_router.router)
app.include_router(ehtool_router.router, prefix="/eh", tags=["ehtool"])
app.include_router(workflow_router)

app.add_middleware(
CORSMiddleware,
Expand Down
3 changes: 3 additions & 0 deletions server_api/workflow/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .router import router

__all__ = ["router"]
77 changes: 77 additions & 0 deletions server_api/workflow/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from __future__ import annotations

from collections import Counter
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional


def _parse_timestamp(raw: Any) -> Optional[datetime]:
if not isinstance(raw, str) or not raw.strip():
return None
normalized = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt


def _isoformat_z(dt: Optional[datetime]) -> Optional[str]:
if dt is None:
return None
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")


def compute_workflow_metrics(events: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
event_list: List[Dict[str, Any]] = list(events)

event_type_counts: Counter[str] = Counter()
transition_counts: Counter[str] = Counter()
timestamps: List[datetime] = []
approvals = 0
rejections = 0

for event in event_list:
event_type = event.get("type")
if isinstance(event_type, str) and event_type:
event_type_counts[event_type] += 1

outcome = event.get("outcome")
if outcome == "approved":
approvals += 1
elif outcome == "rejected":
rejections += 1

from_stage = event.get("from_stage")
to_stage = event.get("to_stage")
if isinstance(from_stage, str) and from_stage and isinstance(to_stage, str) and to_stage:
transition_counts[f"{from_stage}->{to_stage}"] += 1

parsed_ts = _parse_timestamp(event.get("timestamp"))
if parsed_ts is not None:
timestamps.append(parsed_ts)

decisions_total = approvals + rejections
approval_rate = approvals / decisions_total if decisions_total else 0.0
rejection_rate = rejections / decisions_total if decisions_total else 0.0

first_ts = min(timestamps) if timestamps else None
last_ts = max(timestamps) if timestamps else None

return {
"event_counts_by_type": dict(sorted(event_type_counts.items())),
"approvals": {
"count": approvals,
"rate": approval_rate,
},
"rejections": {
"count": rejections,
"rate": rejection_rate,
},
"stage_transitions": dict(sorted(transition_counts.items())),
"first_event_timestamp": _isoformat_z(first_ts),
"last_event_timestamp": _isoformat_z(last_ts),
"total_events": len(event_list),
}
19 changes: 19 additions & 0 deletions server_api/workflow/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from __future__ import annotations

from fastapi import APIRouter, Request

from server_api.workflow.metrics import compute_workflow_metrics

router = APIRouter(prefix="/api/workflows", tags=["workflow"])


@router.get("/{workflow_id}/metrics")
def get_workflow_metrics(workflow_id: str, request: Request):
store = getattr(request.app.state, "workflow_events_store", {})
events = store.get(workflow_id, []) if isinstance(store, dict) else []
if not isinstance(events, list):
events = []
return {
"workflow_id": workflow_id,
"metrics": compute_workflow_metrics(events),
}
104 changes: 104 additions & 0 deletions tests/test_workflow_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import unittest

from fastapi.testclient import TestClient

from fastapi import FastAPI
from server_api.workflow.router import router as workflow_router


class WorkflowMetricsEndpointTests(unittest.TestCase):
def setUp(self):
self.app = FastAPI()
self.app.include_router(workflow_router)
self.client = TestClient(self.app)
self.app.state.workflow_events_store = {}

def test_endpoint_exists_and_returns_stable_schema(self):
response = self.client.get('/api/workflows/wf-1/metrics')

self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload['workflow_id'], 'wf-1')
self.assertIn('metrics', payload)

metrics = payload['metrics']
self.assertEqual(
set(metrics.keys()),
{
'event_counts_by_type',
'approvals',
'rejections',
'stage_transitions',
'first_event_timestamp',
'last_event_timestamp',
'total_events',
},
)

def test_empty_workflow_events_use_safe_defaults(self):
self.app.state.workflow_events_store = {'empty': []}

response = self.client.get('/api/workflows/empty/metrics')
self.assertEqual(response.status_code, 200)

metrics = response.json()['metrics']
self.assertEqual(metrics['event_counts_by_type'], {})
self.assertEqual(metrics['approvals'], {'count': 0, 'rate': 0.0})
self.assertEqual(metrics['rejections'], {'count': 0, 'rate': 0.0})
self.assertEqual(metrics['stage_transitions'], {})
self.assertIsNone(metrics['first_event_timestamp'])
self.assertIsNone(metrics['last_event_timestamp'])
self.assertEqual(metrics['total_events'], 0)

def test_metrics_align_with_seeded_events(self):
self.app.state.workflow_events_store = {
'wf-seeded': [
{
'type': 'stage_transition',
'from_stage': 'draft',
'to_stage': 'review',
'timestamp': '2026-01-01T00:00:00Z',
},
{
'type': 'agent_action',
'outcome': 'approved',
'timestamp': '2026-01-01T00:02:00Z',
},
{
'type': 'agent_action',
'outcome': 'rejected',
'timestamp': '2026-01-01T00:03:00Z',
},
{
'type': 'stage_transition',
'from_stage': 'review',
'to_stage': 'approved',
'timestamp': '2026-01-01T00:04:00Z',
},
{
'type': 'stage_transition',
'from_stage': 'draft',
'to_stage': 'review',
'timestamp': '2026-01-01T00:05:00Z',
},
]
}

response = self.client.get('/api/workflows/wf-seeded/metrics')
self.assertEqual(response.status_code, 200)
metrics = response.json()['metrics']

self.assertEqual(metrics['event_counts_by_type'], {'agent_action': 2, 'stage_transition': 3})
self.assertEqual(metrics['approvals'], {'count': 1, 'rate': 0.5})
self.assertEqual(metrics['rejections'], {'count': 1, 'rate': 0.5})
self.assertEqual(
metrics['stage_transitions'],
{'draft->review': 2, 'review->approved': 1},
)
self.assertEqual(metrics['first_event_timestamp'], '2026-01-01T00:00:00Z')
self.assertEqual(metrics['last_event_timestamp'], '2026-01-01T00:05:00Z')
self.assertEqual(metrics['total_events'], 5)


if __name__ == '__main__':
unittest.main()
Loading