Add traffic telemetry export

This commit is contained in:
lionkingchuja
2026-09-11 02:57:58 +05:30
parent cd6f7def83
commit 1f20c725cd
4 changed files with 173 additions and 10 deletions
+22 -3
View File
@@ -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
+57
View File
@@ -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(
+61
View File
@@ -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
+27 -1
View File
@@ -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<TrafficItem[]>([]);
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,6 +57,23 @@ export const Traffic: React.FC = () => {
</p>
</div>
<div className="flex items-center gap-2">
<select
value={exportFormat}
onChange={(event) => setExportFormat(event.target.value as 'csv' | 'json')}
aria-label="Export format"
className="rounded-lg border border-border-subtle bg-bg-card px-2 py-2 text-xs font-mono text-text-muted outline-none"
>
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
<button
onClick={exportTraffic}
className="p-2 rounded-lg border border-border-subtle bg-bg-card text-text-muted hover:text-text-primary font-mono text-xs flex items-center gap-1.5"
title="Export telemetry"
>
<Download className="w-3.5 h-3.5" /> Export Telemetry
</button>
<button
onClick={loadTraffic}
className="p-2 rounded-lg border border-border-subtle bg-bg-card text-text-muted hover:text-text-primary font-mono text-xs flex items-center gap-1.5"
@@ -56,6 +81,7 @@ export const Traffic: React.FC = () => {
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Refresh
</button>
</div>
</div>
<div className="rounded-xl border border-border-subtle bg-bg-card p-5 space-y-4 overflow-x-auto">
<table className="w-full text-left font-mono text-xs">