feat: add lightweight request tracing for debugging
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>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user