claude-code-tracker/app/api/tool_calls.py
Ryan Malloy bec1606c86 Add comprehensive documentation system and tool call tracking
## Documentation System
- Create complete documentation hub at /dashboard/docs with:
  - Getting Started guide with quick setup and troubleshooting
  - Hook Setup Guide with platform-specific configurations
  - API Reference with all endpoints and examples
  - FAQ with searchable questions and categories
- Add responsive design with interactive features
- Update navigation in base template

## Tool Call Tracking
- Add ToolCall model for tracking Claude Code tool usage
- Create /api/tool-calls endpoints for recording and analytics
- Add tool_call hook type with auto-session detection
- Include tool calls in project statistics and recalculation
- Track tool names, parameters, execution time, and success rates

## Project Enhancements
- Add project timeline and statistics pages (fix 404 errors)
- Create recalculation script for fixing zero statistics
- Update project stats to include tool call counts
- Enhance session model with tool call relationships

## Infrastructure
- Switch from requirements.txt to pyproject.toml/uv.lock
- Add data import functionality for claude.json files
- Update database connection to include all new models
- Add comprehensive API documentation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 05:58:27 -06:00

254 lines
8.5 KiB
Python

"""
Tool call tracking API endpoints.
"""
import json
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field
from app.database.connection import get_db
from app.models.session import Session
from app.models.tool_call import ToolCall
router = APIRouter()
class ToolCallRequest(BaseModel):
"""Request schema for recording a tool call."""
session_id: Optional[str] = Field(None, description="Session ID (auto-detected if not provided)")
tool_name: str = Field(..., description="Name of the tool being called")
parameters: Optional[dict] = Field(None, description="Tool parameters as JSON object")
result_status: Optional[str] = Field("success", description="Result status: success, error, timeout")
error_message: Optional[str] = Field(None, description="Error message if failed")
execution_time_ms: Optional[int] = Field(None, description="Execution time in milliseconds")
timestamp: Optional[datetime] = Field(None, description="Timestamp of the tool call")
class ToolCallResponse(BaseModel):
"""Response schema for tool call operations."""
id: int
session_id: str
tool_name: str
result_status: str
timestamp: datetime
message: str
class ToolUsageStats(BaseModel):
"""Statistics about tool usage."""
tool_name: str
total_calls: int
success_calls: int
error_calls: int
avg_execution_time_ms: Optional[float]
success_rate: float
async def get_current_session_id() -> Optional[str]:
"""Get the current session ID from the temporary file."""
try:
with open("/tmp/claude-session-id", "r") as f:
return f.read().strip()
except (OSError, FileNotFoundError):
return None
@router.post("/tool-calls", response_model=ToolCallResponse, status_code=status.HTTP_201_CREATED)
async def record_tool_call(
request: ToolCallRequest,
db: AsyncSession = Depends(get_db)
):
"""
Record a tool call made during a Claude Code session.
This endpoint is called by Claude Code hooks when tools are used.
It automatically detects the current session if not provided.
"""
try:
# Get session ID
session_id = request.session_id
if not session_id:
session_id = await get_current_session_id()
if not session_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No active session found. Please provide session_id or ensure a session is running."
)
# Verify session exists
result = await db.execute(
select(Session).where(Session.id == int(session_id))
)
session = result.scalars().first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
# Create tool call record
tool_call = ToolCall(
session_id=str(session_id),
tool_name=request.tool_name,
parameters=json.dumps(request.parameters) if request.parameters else None,
result_status=request.result_status or "success",
error_message=request.error_message,
execution_time_ms=request.execution_time_ms,
timestamp=request.timestamp or datetime.utcnow()
)
db.add(tool_call)
await db.commit()
await db.refresh(tool_call)
return ToolCallResponse(
id=tool_call.id,
session_id=tool_call.session_id,
tool_name=tool_call.tool_name,
result_status=tool_call.result_status,
timestamp=tool_call.timestamp,
message=f"Tool call '{request.tool_name}' recorded successfully"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to record tool call: {str(e)}"
)
@router.get("/tool-calls/session/{session_id}", response_model=List[dict])
async def get_session_tool_calls(
session_id: str,
db: AsyncSession = Depends(get_db)
):
"""Get all tool calls for a specific session."""
try:
result = await db.execute(
select(ToolCall)
.where(ToolCall.session_id == session_id)
.order_by(desc(ToolCall.timestamp))
)
tool_calls = result.scalars().all()
return [
{
"id": tc.id,
"tool_name": tc.tool_name,
"parameters": json.loads(tc.parameters) if tc.parameters else None,
"result_status": tc.result_status,
"error_message": tc.error_message,
"execution_time_ms": tc.execution_time_ms,
"timestamp": tc.timestamp
}
for tc in tool_calls
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get tool calls: {str(e)}"
)
@router.get("/tool-calls/stats", response_model=List[ToolUsageStats])
async def get_tool_usage_stats(
project_id: Optional[int] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
db: AsyncSession = Depends(get_db)
):
"""Get tool usage statistics with optional filtering."""
try:
# Base query
query = select(
ToolCall.tool_name,
func.count(ToolCall.id).label("total_calls"),
func.sum(func.case([(ToolCall.result_status == "success", 1)], else_=0)).label("success_calls"),
func.sum(func.case([(ToolCall.result_status == "error", 1)], else_=0)).label("error_calls"),
func.avg(ToolCall.execution_time_ms).label("avg_execution_time_ms")
)
# Apply filters
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
if start_date:
query = query.where(ToolCall.timestamp >= start_date)
if end_date:
query = query.where(ToolCall.timestamp <= end_date)
# Group by tool name
query = query.group_by(ToolCall.tool_name).order_by(desc("total_calls"))
result = await db.execute(query)
rows = result.all()
stats = []
for row in rows:
success_rate = (row.success_calls / row.total_calls * 100) if row.total_calls > 0 else 0
stats.append(ToolUsageStats(
tool_name=row.tool_name,
total_calls=row.total_calls,
success_calls=row.success_calls,
error_calls=row.error_calls,
avg_execution_time_ms=float(row.avg_execution_time_ms) if row.avg_execution_time_ms else None,
success_rate=round(success_rate, 2)
))
return stats
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get tool usage stats: {str(e)}"
)
@router.get("/tool-calls/popular", response_model=List[dict])
async def get_popular_tools(
limit: int = 10,
project_id: Optional[int] = None,
db: AsyncSession = Depends(get_db)
):
"""Get the most frequently used tools."""
try:
query = select(
ToolCall.tool_name,
func.count(ToolCall.id).label("usage_count"),
func.max(ToolCall.timestamp).label("last_used")
)
if project_id:
query = query.join(Session).where(Session.project_id == project_id)
query = query.group_by(ToolCall.tool_name).order_by(desc("usage_count")).limit(limit)
result = await db.execute(query)
rows = result.all()
return [
{
"tool_name": row.tool_name,
"usage_count": row.usage_count,
"last_used": row.last_used
}
for row in rows
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get popular tools: {str(e)}"
)