Adds JSON-based tracing system for local development that captures
the full request flow through Tatlock's multi-agent architecture.
- Trace/Span dataclasses with automatic timing and nesting
- Context-var based propagation for async-safe tracing
- trace_span async context manager for clean instrumentation
- Traces written to logs/traces/{trace_id}.json
- REST API for listing and retrieving traces (/traces)
- Standalone HTML viewer with timeline visualization
Enabled via DEBUG=true environment variable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
154 lines
4.6 KiB
Python
154 lines
4.6 KiB
Python
"""
|
|
Trace viewer router.
|
|
|
|
Serves the trace viewer UI and trace files when tracing is enabled.
|
|
Only available when DEBUG=true.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
from src.core.config import config
|
|
from src.core.logging_config import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter(prefix="/traces", tags=["traces"])
|
|
|
|
TRACES_DIR = Path("logs/traces")
|
|
VIEWER_PATH = TRACES_DIR / "viewer.html"
|
|
|
|
|
|
def tracing_enabled() -> bool:
|
|
"""Check if tracing is enabled."""
|
|
return config.DEBUG
|
|
|
|
|
|
@router.get("", response_class=HTMLResponse)
|
|
async def get_trace_viewer():
|
|
"""
|
|
Serve the trace viewer UI.
|
|
|
|
Returns the standalone HTML viewer for browsing traces.
|
|
"""
|
|
if not tracing_enabled():
|
|
raise HTTPException(status_code=404, detail="Tracing not enabled")
|
|
|
|
if not VIEWER_PATH.exists():
|
|
raise HTTPException(status_code=404, detail="Viewer not found")
|
|
|
|
return HTMLResponse(content=VIEWER_PATH.read_text())
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_traces(
|
|
limit: int = 50,
|
|
since_minutes: int | None = None,
|
|
status: str | None = None,
|
|
search: str | None = None,
|
|
):
|
|
"""
|
|
List available trace files.
|
|
|
|
Returns most recent traces first, with basic metadata.
|
|
|
|
Args:
|
|
limit: Maximum number of traces to return (default 50)
|
|
since_minutes: Only return traces from the last N minutes
|
|
status: Filter by status (completed, error, streaming)
|
|
search: Search in request preview text
|
|
"""
|
|
if not tracing_enabled():
|
|
raise HTTPException(status_code=404, detail="Tracing not enabled")
|
|
|
|
if not TRACES_DIR.exists():
|
|
return {"traces": [], "total": 0}
|
|
|
|
import json
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
# Calculate cutoff time if filtering by time
|
|
cutoff_time = None
|
|
if since_minutes:
|
|
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=since_minutes)
|
|
|
|
# Get all trace files, sorted by modification time (newest first)
|
|
trace_files = sorted(
|
|
TRACES_DIR.glob("trace_*.json"),
|
|
key=lambda p: p.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
|
|
traces = []
|
|
for path in trace_files:
|
|
if len(traces) >= limit:
|
|
break
|
|
|
|
try:
|
|
with open(path) as f:
|
|
data = json.load(f)
|
|
|
|
# Parse timestamp for filtering
|
|
trace_timestamp = data.get("timestamp")
|
|
if cutoff_time and trace_timestamp:
|
|
try:
|
|
ts = datetime.fromisoformat(trace_timestamp.replace('Z', '+00:00'))
|
|
if ts < cutoff_time:
|
|
continue
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Filter by status
|
|
trace_status = data.get("status", "")
|
|
if status and trace_status != status:
|
|
continue
|
|
|
|
# Filter by search text
|
|
request_preview = data.get("request", {}).get("input_preview", "")
|
|
if search and search.lower() not in request_preview.lower():
|
|
continue
|
|
|
|
traces.append({
|
|
"trace_id": data.get("trace_id"),
|
|
"timestamp": trace_timestamp,
|
|
"user": data.get("user"),
|
|
"status": trace_status,
|
|
"total_duration_ms": data.get("total_duration_ms"),
|
|
"span_count": len(data.get("spans", [])),
|
|
"request_preview": request_preview[:100],
|
|
})
|
|
except Exception as e:
|
|
logger.warning("trace_list_parse_error", path=str(path), error=str(e))
|
|
|
|
return {"traces": traces, "total": len(traces)}
|
|
|
|
|
|
@router.get("/{trace_id}")
|
|
async def get_trace(trace_id: str):
|
|
"""
|
|
Get a specific trace by ID.
|
|
|
|
Returns the full trace JSON.
|
|
"""
|
|
if not tracing_enabled():
|
|
raise HTTPException(status_code=404, detail="Tracing not enabled")
|
|
|
|
# Sanitize trace_id to prevent path traversal
|
|
if not trace_id.startswith("trace_") or "/" in trace_id or "\\" in trace_id:
|
|
raise HTTPException(status_code=400, detail="Invalid trace ID")
|
|
|
|
trace_path = TRACES_DIR / f"{trace_id}.json"
|
|
|
|
if not trace_path.exists():
|
|
raise HTTPException(status_code=404, detail="Trace not found")
|
|
|
|
try:
|
|
import json
|
|
with open(trace_path) as f:
|
|
data = json.load(f)
|
|
return JSONResponse(content=data)
|
|
except Exception as e:
|
|
logger.error("trace_read_error", trace_id=trace_id, error=str(e))
|
|
raise HTTPException(status_code=500, detail="Failed to read trace")
|