Add traffic telemetry export
This commit is contained in:
@@ -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.
|
- **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.
|
- **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).
|
- **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`).
|
- **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
|
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
|
## 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
|
```bash
|
||||||
# Run backend test suite
|
# Run the backend test suite from the repository root
|
||||||
pytest backend/tests
|
pytest backend/tests
|
||||||
|
|
||||||
|
# Or run it from the backend directory
|
||||||
|
cd backend
|
||||||
|
python -m pytest tests
|
||||||
|
|
||||||
# Run frontend production build test
|
# Run frontend production build test
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
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()
|
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}")
|
@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)):
|
async def get_decision_details(decision_id_or_request_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
res_dec = await db.execute(
|
res_dec = await db.execute(
|
||||||
|
|||||||
@@ -64,3 +64,64 @@ async def test_api_analytics():
|
|||||||
data = res.json()
|
data = res.json()
|
||||||
assert "total_requests" in data
|
assert "total_requests" in data
|
||||||
assert "savings" 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
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
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 { fetchApi } from '../lib/api';
|
||||||
import { TrafficItem } from '../types';
|
import { TrafficItem } from '../types';
|
||||||
|
|
||||||
export const Traffic: React.FC = () => {
|
export const Traffic: React.FC = () => {
|
||||||
const [traffic, setTraffic] = useState<TrafficItem[]>([]);
|
const [traffic, setTraffic] = useState<TrafficItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv');
|
||||||
|
|
||||||
const loadTraffic = async () => {
|
const loadTraffic = async () => {
|
||||||
setLoading(true);
|
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(() => {
|
useEffect(() => {
|
||||||
loadTraffic();
|
loadTraffic();
|
||||||
const eventSource = new EventSource('http://localhost:8000/api/traffic/stream');
|
const eventSource = new EventSource('http://localhost:8000/api/traffic/stream');
|
||||||
@@ -49,12 +57,30 @@ export const Traffic: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={loadTraffic}
|
<select
|
||||||
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"
|
value={exportFormat}
|
||||||
>
|
onChange={(event) => setExportFormat(event.target.value as 'csv' | 'json')}
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
aria-label="Export format"
|
||||||
</button>
|
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"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border-subtle bg-bg-card p-5 space-y-4 overflow-x-auto">
|
<div className="rounded-xl border border-border-subtle bg-bg-card p-5 space-y-4 overflow-x-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user