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