diff --git a/README.md b/README.md index d7d3183..89cefc8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The platform is designed local-first, allowing full local development and testin - **Provider Abstraction**: Decoupled adapters for Mock (simulation), Ollama (local), OpenAI, Anthropic, and Google Gemini. - **Resilience and Tiered Fallback**: Automated retry classification for transient errors (timeouts, HTTP 429, 503) and tiered fallback to local/mock alternatives. - **Budget Control Guards**: Real-time spend tracking with automated threshold interventions (80% cost optimization, 95% local-only saver, 100% block). -- **Traffic Control Room UI**: Real-time operational interface with seamless dark/light mode switching, featuring live topology graphs, playground inspector, SSE live request stream, visual rules builder, and cost savings simulator. +- **Traffic Control Room UI**: Real-time operational interface with seamless dark/light mode switching, featuring live topology graphs, playground inspector, SSE live request stream, telemetry export as CSV/JSON, visual rules builder, and cost savings simulator. - **Developer CLI**: Terminal diagnostics (`doctor`), routing dry-run (`route`), execution (`run`), model catalog (`models`), and analytics (`analytics`). --- @@ -194,16 +194,35 @@ curl -X POST http://127.0.0.1:8000/api/generate \ curl http://127.0.0.1:8000/api/models ``` +### 4. Export Historical Traffic (`GET /api/traffic/export`) +Export all persisted traffic records for auditing, accounting, or latency analysis: + +```bash +# Export as JSON +curl -OJ "http://127.0.0.1:8000/api/traffic/export?format=json" + +# Export as CSV +curl -OJ "http://127.0.0.1:8000/api/traffic/export?format=csv" +``` + +Each export includes the timestamp, request ID, prompt preview, task type, complexity, selected model, input/output/total tokens, cost saved, and total latency. The `format` query parameter accepts only `csv` or `json`. + +The same export is available in the frontend under **Traffic**. Select `CSV` or `JSON` beside **Export Telemetry**, then click the button to download the complete historical traffic dataset. + --- ## Running Tests -The test suite includes 15 automated unit, integration, and end-to-end tests covering prompt heuristics, candidate pruning, scoring weights, provider execution, error fallbacks, and REST endpoints: +The test suite includes 18 automated unit, integration, and end-to-end tests covering prompt heuristics, candidate pruning, scoring weights, provider execution, error fallbacks, REST endpoints, and CSV/JSON traffic exports: ```bash -# Run backend test suite +# Run the backend test suite from the repository root pytest backend/tests +# Or run it from the backend directory +cd backend +python -m pytest tests + # Run frontend production build test cd frontend npm run build diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 525b71d..c6ce886 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,8 +1,11 @@ import uuid import time import asyncio +import csv +import io from typing import List, Optional, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc @@ -386,6 +389,60 @@ async def get_traffic_feed(limit: int = 50, db: AsyncSession = Depends(get_db)): return res.scalars().all() +@router.get("/api/traffic/export") +async def export_traffic( + format: str = Query("json", pattern="^(csv|json)$"), + db: AsyncSession = Depends(get_db), +): + res = await db.execute(select(RequestRecord).order_by(desc(RequestRecord.timestamp))) + records = res.scalars().all() + fields = [ + "timestamp", + "request_id", + "prompt_preview", + "task_type", + "complexity", + "selected_model", + "input_tokens", + "output_tokens", + "total_tokens", + "cost_saved", + "total_latency_ms", + ] + rows = [ + { + "timestamp": record.timestamp.isoformat() if record.timestamp else "", + "request_id": record.request_id, + "prompt_preview": record.prompt[:200], + "task_type": record.task_type, + "complexity": record.complexity, + "selected_model": record.selected_model, + "input_tokens": record.input_tokens, + "output_tokens": record.output_tokens, + "total_tokens": record.total_tokens, + "cost_saved": record.cost_saved, + "total_latency_ms": record.total_latency_ms, + } + for record in records + ] + + if format == "json": + return JSONResponse( + content=rows, + headers={"Content-Disposition": "attachment; filename=traffic-export.json"}, + ) + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=traffic-export.csv"}, + ) + + @router.get("/api/decisions/{decision_id_or_request_id}") async def get_decision_details(decision_id_or_request_id: str, db: AsyncSession = Depends(get_db)): res_dec = await db.execute( diff --git a/backend/tests/test_e2e.py b/backend/tests/test_e2e.py index cec6a66..603f2d0 100644 --- a/backend/tests/test_e2e.py +++ b/backend/tests/test_e2e.py @@ -64,3 +64,64 @@ async def test_api_analytics(): data = res.json() assert "total_requests" in data assert "savings" in data + + +@pytest.mark.asyncio +async def test_api_traffic_export_json_contains_audit_fields(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + generate_res = await client.post( + "/api/generate", + json={"prompt": "Write a Python function for a traffic export"}, + ) + assert generate_res.status_code == 200 + + export_res = await client.get("/api/traffic/export?format=json") + + assert export_res.status_code == 200 + assert export_res.headers["content-type"].startswith("application/json") + assert "attachment" in export_res.headers["content-disposition"] + records = export_res.json() + assert records + assert { + "timestamp", + "request_id", + "prompt_preview", + "task_type", + "complexity", + "selected_model", + "input_tokens", + "output_tokens", + "total_tokens", + "cost_saved", + "total_latency_ms", + }.issubset(records[0]) + + +@pytest.mark.asyncio +async def test_api_traffic_export_csv_returns_downloadable_rows(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + generate_res = await client.post( + "/api/generate", + json={"prompt": "Summarize this traffic record"}, + ) + assert generate_res.status_code == 200 + + export_res = await client.get("/api/traffic/export?format=csv") + + assert export_res.status_code == 200 + assert export_res.headers["content-type"].startswith("text/csv") + assert "traffic-export.csv" in export_res.headers["content-disposition"] + lines = export_res.text.splitlines() + assert lines[0].startswith("timestamp,request_id,prompt_preview,") + assert len(lines) >= 2 + + +@pytest.mark.asyncio +async def test_api_traffic_export_rejects_unknown_format(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + export_res = await client.get("/api/traffic/export?format=xml") + + assert export_res.status_code == 422 diff --git a/frontend/src/pages/Traffic.tsx b/frontend/src/pages/Traffic.tsx index 9d1065f..e273c68 100644 --- a/frontend/src/pages/Traffic.tsx +++ b/frontend/src/pages/Traffic.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useState } from 'react'; -import { Radio, RefreshCw, Layers, Clock, DollarSign, Cpu } from 'lucide-react'; +import { Download, Radio, RefreshCw } from 'lucide-react'; import { fetchApi } from '../lib/api'; import { TrafficItem } from '../types'; export const Traffic: React.FC = () => { const [traffic, setTraffic] = useState([]); const [loading, setLoading] = useState(false); + const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv'); const loadTraffic = async () => { setLoading(true); @@ -19,6 +20,13 @@ export const Traffic: React.FC = () => { } }; + const exportTraffic = () => { + window.open( + `http://localhost:8000/api/traffic/export?format=${exportFormat}`, + '_blank', + ); + }; + useEffect(() => { loadTraffic(); const eventSource = new EventSource('http://localhost:8000/api/traffic/stream'); @@ -49,12 +57,30 @@ export const Traffic: React.FC = () => {

- +
+ + + +