Track estimated Anthropic API costs per request across all Claude API call sites (recommender, analyze, artist-dive, generate-playlist, crate, rabbit-hole, playlist-fix, timeline, compatibility). Log token usage and estimated cost to the app logger. Aggregate costs in admin stats endpoint and display total/today costs and token usage in the admin dashboard.
181 lines
6.1 KiB
Python
181 lines
6.1 KiB
Python
import logging
|
|
import io
|
|
import re
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user
|
|
from app.models.user import User
|
|
from app.models.playlist import Playlist
|
|
from app.models.track import Track
|
|
from app.models.recommendation import Recommendation
|
|
|
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
|
|
ADMIN_EMAILS = ["chris.ryan@deepcutsai.com"]
|
|
|
|
# In-memory log buffer for admin viewing
|
|
LOG_BUFFER_SIZE = 500
|
|
_log_buffer: list[dict] = []
|
|
|
|
|
|
class AdminLogHandler(logging.Handler):
|
|
"""Captures log records into an in-memory buffer for the admin UI."""
|
|
|
|
def emit(self, record):
|
|
entry = {
|
|
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
|
|
"level": record.levelname,
|
|
"logger": record.name,
|
|
"message": self.format(record),
|
|
}
|
|
_log_buffer.append(entry)
|
|
if len(_log_buffer) > LOG_BUFFER_SIZE:
|
|
_log_buffer.pop(0)
|
|
|
|
|
|
# Attach handler to root logger and uvicorn
|
|
_handler = AdminLogHandler()
|
|
_handler.setLevel(logging.INFO)
|
|
_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
logging.getLogger().addHandler(_handler)
|
|
logging.getLogger("uvicorn.access").addHandler(_handler)
|
|
logging.getLogger("uvicorn.error").addHandler(_handler)
|
|
logging.getLogger("app").addHandler(_handler)
|
|
|
|
# Create app logger for explicit logging
|
|
app_logger = logging.getLogger("app")
|
|
app_logger.setLevel(logging.INFO)
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_stats(
|
|
user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if user.email not in ADMIN_EMAILS:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
week_ago = now - timedelta(days=7)
|
|
month_ago = now - timedelta(days=30)
|
|
|
|
# User stats
|
|
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
|
|
pro_users = (await db.execute(select(func.count(User.id)).where(User.is_pro == True))).scalar() or 0
|
|
|
|
# Playlist stats
|
|
total_playlists = (await db.execute(select(func.count(Playlist.id)))).scalar() or 0
|
|
total_tracks = (await db.execute(select(func.count(Track.id)))).scalar() or 0
|
|
|
|
# Recommendation stats
|
|
total_recs = (await db.execute(select(func.count(Recommendation.id)))).scalar() or 0
|
|
recs_today = (await db.execute(
|
|
select(func.count(Recommendation.id)).where(Recommendation.created_at >= today)
|
|
)).scalar() or 0
|
|
recs_this_week = (await db.execute(
|
|
select(func.count(Recommendation.id)).where(Recommendation.created_at >= week_ago)
|
|
)).scalar() or 0
|
|
recs_this_month = (await db.execute(
|
|
select(func.count(Recommendation.id)).where(Recommendation.created_at >= month_ago)
|
|
)).scalar() or 0
|
|
|
|
saved_recs = (await db.execute(
|
|
select(func.count(Recommendation.id)).where(Recommendation.saved == True)
|
|
)).scalar() or 0
|
|
disliked_recs = (await db.execute(
|
|
select(func.count(Recommendation.id)).where(Recommendation.disliked == True)
|
|
)).scalar() or 0
|
|
|
|
# Per-user breakdown
|
|
user_stats_result = await db.execute(
|
|
select(
|
|
User.id, User.name, User.email, User.is_pro, User.created_at,
|
|
func.count(Recommendation.id).label("rec_count"),
|
|
)
|
|
.outerjoin(Recommendation, Recommendation.user_id == User.id)
|
|
.group_by(User.id)
|
|
.order_by(User.id)
|
|
)
|
|
user_breakdown = [
|
|
{
|
|
"id": row.id,
|
|
"name": row.name,
|
|
"email": row.email,
|
|
"is_pro": row.is_pro,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"recommendation_count": row.rec_count,
|
|
}
|
|
for row in user_stats_result.all()
|
|
]
|
|
|
|
# Calculate API costs from log buffer
|
|
total_cost = 0.0
|
|
today_cost = 0.0
|
|
total_tokens_in = 0
|
|
total_tokens_out = 0
|
|
for log in _log_buffer:
|
|
if 'API_COST' in log.get('message', ''):
|
|
msg = log['message']
|
|
cost_match = re.search(r'cost=\$([0-9.]+)', msg)
|
|
if cost_match:
|
|
c = float(cost_match.group(1))
|
|
total_cost += c
|
|
# Check if today
|
|
if log['timestamp'][:10] == now.strftime('%Y-%m-%d'):
|
|
today_cost += c
|
|
input_match = re.search(r'input=(\d+)', msg)
|
|
output_match = re.search(r'output=(\d+)', msg)
|
|
if input_match:
|
|
total_tokens_in += int(input_match.group(1))
|
|
if output_match:
|
|
total_tokens_out += int(output_match.group(1))
|
|
|
|
return {
|
|
"users": {
|
|
"total": total_users,
|
|
"pro": pro_users,
|
|
"free": total_users - pro_users,
|
|
},
|
|
"playlists": {
|
|
"total": total_playlists,
|
|
"total_tracks": total_tracks,
|
|
},
|
|
"recommendations": {
|
|
"total": total_recs,
|
|
"today": recs_today,
|
|
"this_week": recs_this_week,
|
|
"this_month": recs_this_month,
|
|
"saved": saved_recs,
|
|
"disliked": disliked_recs,
|
|
},
|
|
"api_costs": {
|
|
"total_estimated": round(total_cost, 4),
|
|
"today_estimated": round(today_cost, 4),
|
|
"total_input_tokens": total_tokens_in,
|
|
"total_output_tokens": total_tokens_out,
|
|
},
|
|
"user_breakdown": user_breakdown,
|
|
}
|
|
|
|
|
|
@router.get("/logs")
|
|
async def get_logs(
|
|
user: User = Depends(get_current_user),
|
|
level: str = Query("ALL", description="Filter by level: ALL, ERROR, WARNING, INFO"),
|
|
limit: int = Query(100, description="Number of log entries"),
|
|
):
|
|
if user.email not in ADMIN_EMAILS:
|
|
raise HTTPException(status_code=403, detail="Admin only")
|
|
|
|
logs = _log_buffer[-limit:]
|
|
if level != "ALL":
|
|
logs = [l for l in logs if l["level"] == level.upper()]
|
|
|
|
return {"logs": list(reversed(logs)), "total": len(_log_buffer)}
|