refactor: unify logs and usage implementation

This commit is contained in:
Cursor Agent
2025-11-23 21:51:36 +00:00
parent 2438f3231f
commit eb83b3c51a
17 changed files with 1424 additions and 1065 deletions
+125 -129
View File
@@ -1,6 +1,7 @@
import json
import secrets
from datetime import datetime, timezone
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -10,7 +11,6 @@ from sqlmodel import select
from ..payment.models import _row_to_model, list_models
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..search import search_logs
from ..wallet import (
fetch_all_balances,
get_proofs_per_mint_and_unit,
@@ -20,8 +20,8 @@ from ..wallet import (
)
from .db import ApiKey, ModelRow, UpstreamProviderRow, create_session
from .logging import get_logger
from .log_manager import log_manager
from .settings import SettingsService, settings
from .usage_metrics import UsageMetricsService, list_metric_definitions
logger = get_logger(__name__)
@@ -170,38 +170,6 @@ async def get_balances_api(request: Request) -> list[dict[str, object]]:
return [dict(d) for d in balance_details]
@admin_router.get(
"/api/usage-metrics/definitions", dependencies=[Depends(require_admin_api)]
)
async def get_usage_metric_definitions() -> list[dict[str, str]]:
return list_metric_definitions()
@admin_router.get("/api/usage-metrics", dependencies=[Depends(require_admin_api)])
async def get_usage_metrics(
metrics: str | None = Query(
default=None,
description="Comma-separated list of usage metric identifiers to include",
),
bucket_minutes: int = Query(default=15, ge=1, le=24 * 60),
hours: int = Query(default=24, ge=1, le=24 * 7),
) -> dict[str, object]:
metric_list = (
[item.strip() for item in metrics.split(",") if item.strip()]
if metrics
else []
)
try:
result = await UsageMetricsService.collect(
metrics=metric_list,
bucket_minutes=bucket_minutes,
hours=hours,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result.to_dict()
@admin_router.get("/api/settings", dependencies=[Depends(require_admin_api)])
async def get_settings(request: Request) -> dict:
data = settings.dict()
@@ -860,8 +828,8 @@ async def view_logs(request: Request, request_id: str) -> str:
except Exception as e:
logger.error(f"Error reading log file {log_file}: {e}")
# Sort entries by timestamp if available (newest first)
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
# Sort entries by timestamp if available
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=False)
# Format log entries for display
formatted_logs = []
@@ -942,72 +910,6 @@ async def view_logs(request: Request, request_id: str) -> str:
)
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
async def get_logs_api(
request: Request,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict[str, object]:
"""
Get filtered log entries.
Args:
date: Filter by specific date (YYYY-MM-DD)
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
logs_dir = Path("logs")
# Use the search module for log filtering
log_entries = search_logs(
logs_dir=logs_dir,
date=date,
level=level,
request_id=request_id,
search_text=search,
limit=limit,
)
return {
"logs": log_entries,
"total": len(log_entries),
"date": date,
"level": level,
"request_id": request_id,
"search": search,
"limit": limit,
}
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
async def get_log_dates_api(request: Request) -> dict[str, object]:
logs_dir = Path("logs")
dates = []
if logs_dir.exists():
log_files = sorted(
logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True
)
for log_file in log_files[:30]:
try:
filename = log_file.name
date_str = filename.replace("app_", "").replace(".log", "")
dates.append(date_str)
except Exception:
continue
return {"dates": dates}
@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)])
async def withdraw(
request: Request, withdraw_request: WithdrawRequest
@@ -2504,32 +2406,6 @@ def upstream_providers_page() -> str:
)
def logs_page() -> str:
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logs - Admin Dashboard</title>
<script>
window.location.href = '/logs';
</script>
</head>
<body>
<p>Redirecting to logs page...</p>
</body>
</html>
"""
@admin_router.get("/logs", response_class=HTMLResponse)
async def admin_logs(request: Request) -> str:
if is_admin_authenticated(request):
return logs_page()
return admin_auth()
@admin_router.get("/upstream-providers", response_class=HTMLResponse)
async def admin_upstream_providers(request: Request) -> str:
if is_admin_authenticated(request):
@@ -2991,3 +2867,123 @@ h1 { color: #333; }
.no-logs { text-align: center; color: #666; padding: 40px; }
.request-id-display { background-color: #e9ecef; padding: 10px; border-radius: 4px; margin-bottom: 20px; font-family: monospace; }
"""
@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
async def get_usage_metrics(
request: Request,
interval: int = Query(
default=15, ge=1, le=1440, description="Time interval in minutes"
),
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
) -> dict:
"""Get usage metrics aggregated by time interval."""
return log_manager.get_usage_metrics(interval=interval, hours=hours)
@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
async def get_usage_summary(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
) -> dict:
"""Get summary statistics for the specified time period."""
return log_manager.get_usage_summary(hours=hours)
@admin_router.get("/api/usage/error-details", dependencies=[Depends(require_admin_api)])
async def get_error_details(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
limit: int = Query(
default=100, ge=1, le=1000, description="Maximum number of errors to return"
),
) -> dict:
"""Get detailed error information."""
return log_manager.get_error_details(hours=hours, limit=limit)
@admin_router.get(
"/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
)
async def get_revenue_by_model(
request: Request,
hours: int = Query(
default=24, ge=1, le=168, description="Hours of history to analyze"
),
limit: int = Query(
default=20, ge=1, le=100, description="Maximum number of models to return"
),
) -> dict:
"""
Get revenue breakdown by model.
"""
return log_manager.get_revenue_by_model(hours=hours, limit=limit)
@admin_router.get("/api/logs", dependencies=[Depends(require_admin_api)])
async def get_logs_api(
request: Request,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search: str | None = None,
limit: int = 100,
) -> dict[str, object]:
"""
Get filtered log entries.
Args:
date: Filter by specific date (YYYY-MM-DD)
level: Filter by log level
request_id: Filter by request ID
search: Search text in message and name fields (case-insensitive)
limit: Maximum number of entries to return
Returns:
Dict containing logs and filter metadata
"""
log_entries = log_manager.search_logs(
date=date,
level=level,
request_id=request_id,
search_text=search,
limit=limit,
)
return {
"logs": log_entries,
"total": len(log_entries),
"date": date,
"level": level,
"request_id": request_id,
"search": search,
"limit": limit,
}
@admin_router.get("/api/logs/dates", dependencies=[Depends(require_admin_api)])
async def get_log_dates_api(request: Request) -> dict[str, object]:
logs_dir = Path("logs")
dates = []
if logs_dir.exists():
log_files = sorted(
logs_dir.glob("app_*.log"), key=lambda x: x.stat().st_mtime, reverse=True
)
for log_file in log_files[:30]:
try:
filename = log_file.name
date_str = filename.replace("app_", "").replace(".log", "")
dates.append(date_str)
except Exception:
continue
return {"dates": dates}
+434
View File
@@ -0,0 +1,434 @@
import json
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Generator, Iterator
from .logging import get_logger
logger = get_logger(__name__)
class LogManager:
def __init__(self, logs_dir: Path = Path("logs")):
self.logs_dir = logs_dir
def _yield_log_entries(
self,
hours_back: int | None = None,
specific_date: str | None = None,
reverse_files: bool = False
) -> Iterator[dict[str, Any]]:
"""
Yields log entries from files.
Args:
hours_back: specific number of hours to look back.
specific_date: specific date string (YYYY-MM-DD) to look at.
reverse_files: if True, process files in reverse order (newest first).
"""
if not self.logs_dir.exists():
return
log_files = []
cutoff_date = None
if specific_date:
log_file = self.logs_dir / f"app_{specific_date}.log"
if log_file.exists():
log_files.append(log_file)
else:
log_files = sorted(self.logs_dir.glob("app_*.log"))
if reverse_files:
log_files.reverse()
# If we only care about hours back, we can optimize file selection
if hours_back is not None:
cutoff_date = datetime.now(timezone.utc) - timedelta(hours=hours_back)
filtered_files = []
for f in log_files:
try:
file_date_str = f.stem.split("_")[1]
file_date = datetime.strptime(file_date_str, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
# Include file if it's from the same day or after the cutoff day
if file_date >= cutoff_date.replace(hour=0, minute=0, second=0, microsecond=0):
filtered_files.append(f)
except Exception:
continue
log_files = filtered_files
for log_file in log_files:
try:
with open(log_file, "r") as f:
# For reverse search, we might want to read lines in reverse?
# But usually logs are append-only.
# If reverse_files is True, we iterate files newest to oldest.
# But lines within file are still oldest to newest unless we reverse them.
lines = f.readlines()
if reverse_files:
lines.reverse()
for line in lines:
try:
entry = json.loads(line.strip())
if cutoff_date:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
if log_time < cutoff_date:
continue
yield entry
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Error processing log file {log_file}: {e}")
continue
def search_logs(
self,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
Search through log files and return matching entries.
"""
log_entries: list[dict[str, Any]] = []
# Use reverse=True to get newest logs first by default
# If date is specified, we only look at that file
search_text_lower = search_text.lower() if search_text else None
# We iterate efficiently
iterator = self._yield_log_entries(specific_date=date, reverse_files=True if not date else False)
# If we are searching globally (no date), we might want to limit how far back we go?
# PR 228 did: "glob("app_*.log") sorted by mtime reverse [:7]" (last 7 files)
# My _yield_log_entries with reverse_files=True does all files.
# Let's rely on limit to stop us.
files_processed = 0
# Optimization: if we are not searching by date, maybe limit to last 7 files inside _yield?
# For now, let's just iterate.
for log_data in iterator:
if not self._matches_filters(log_data, level, request_id, search_text_lower):
continue
log_entries.append(log_data)
if len(log_entries) >= limit:
break
# Sort by time descending (newest first)
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
return log_entries
def _matches_filters(
self,
log_data: dict[str, Any],
level: str | None,
request_id: str | None,
search_text_lower: str | None,
) -> bool:
if level and log_data.get("levelname", "").upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
pathname = str(log_data.get("pathname", "")).lower()
if (
search_text_lower not in message
and search_text_lower not in name
and search_text_lower not in pathname
):
return False
return True
def get_usage_summary(self, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._calculate_summary_stats(entries)
def get_usage_metrics(self, interval: int = 15, hours: int = 24) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
return self._aggregate_metrics_by_time(entries, interval, hours)
def get_error_details(self, hours: int = 24, limit: int = 100) -> dict:
errors: list[dict] = []
# Iterate newest to oldest for errors?
# yield_log_entries sorts files by name (date) ascending by default.
# usage stats logic usually expects ascending time for aggregation (though dictionaries don't care).
# For error details "last N errors", we probably want newest first.
# Using list() loads everything into memory, which is what PR 229 did.
# For optimization, we could use reverse iterator.
# Let's just stick to PR 229 logic which filters 'ERROR' level.
entries = self._yield_log_entries(hours_back=hours) # oldest to newest
for entry in entries:
if entry.get("levelname", "").upper() == "ERROR":
timestamp_str = entry.get("asctime", "")
errors.append({
"timestamp": timestamp_str,
"message": entry.get("message", ""),
"error_type": entry.get("error_type", "unknown"),
"pathname": entry.get("pathname", ""),
"lineno": entry.get("lineno", 0),
"request_id": entry.get("request_id", ""),
})
# Sort reverse time
errors.sort(key=lambda x: x["timestamp"], reverse=True)
return {"errors": errors[:limit], "total_count": len(errors)}
def get_revenue_by_model(self, hours: int = 24, limit: int = 20) -> dict:
entries = list(self._yield_log_entries(hours_back=hours))
model_stats: dict[str, dict[str, int | float]] = defaultdict(
lambda: {
"revenue_msats": 0,
"refunds_msats": 0,
"requests": 0,
"successful": 0,
"failed": 0,
}
)
for entry in entries:
try:
model = entry.get("model", "unknown")
if not isinstance(model, str):
model = "unknown"
message = entry.get("message", "").lower()
if "received proxy request" in message:
model_stats[model]["requests"] += 1
if "token adjustment completed" in message:
model_stats[model]["successful"] += 1
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
model_stats[model]["revenue_msats"] += actual_cost
if "revert payment" in message or "upstream request failed" in message:
model_stats[model]["failed"] += 1
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
model_stats[model]["refunds_msats"] += max_cost
except Exception:
continue
models = []
total_revenue = 0.0
for model, stats in model_stats.items():
revenue_msats = float(stats["revenue_msats"])
refunds_msats = float(stats["refunds_msats"])
revenue_sats = revenue_msats / 1000
refunds_sats = refunds_msats / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_revenue += net_revenue_sats
requests = int(stats["requests"])
successful = int(stats["successful"])
models.append({
"model": model,
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_sats": net_revenue_sats,
"requests": requests,
"successful": successful,
"failed": int(stats["failed"]),
"avg_revenue_per_request": (
revenue_sats / successful if successful > 0 else 0
),
})
models.sort(key=lambda x: float(x["net_revenue_sats"]), reverse=True)
return {
"models": models[:limit],
"total_revenue_sats": total_revenue,
"total_models": len(models),
}
def _calculate_summary_stats(self, entries: list[dict]) -> dict:
stats: dict[str, Any] = {
"total_entries": 0,
"total_requests": 0,
"successful_chat_completions": 0,
"failed_requests": 0,
"total_errors": 0,
"total_warnings": 0,
"payment_processed": 0,
"upstream_errors": 0,
"unique_models": set(),
"error_types": defaultdict(int),
"revenue_msats": 0.0,
"refunds_msats": 0.0,
}
for entry in entries:
try:
stats["total_entries"] += 1
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if level == "ERROR":
stats["total_errors"] += 1
if "error_type" in entry:
stats["error_types"][str(entry["error_type"])] += 1
elif level == "WARNING":
stats["total_warnings"] += 1
if "received proxy request" in message:
stats["total_requests"] += 1
if "token adjustment completed" in message:
stats["successful_chat_completions"] += 1
if "upstream request failed" in message or "revert payment" in message:
stats["failed_requests"] += 1
if "payment processed successfully" in message:
stats["payment_processed"] += 1
if "upstream" in message and level == "ERROR":
stats["upstream_errors"] += 1
if "model" in entry:
model = entry["model"]
if isinstance(model, str) and model != "unknown":
stats["unique_models"].add(model)
if "token adjustment completed" in message:
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
stats["revenue_msats"] += float(actual_cost)
if "revert payment" in message:
max_cost = entry.get("max_cost_for_model", 0)
if isinstance(max_cost, (int, float)) and max_cost > 0:
stats["refunds_msats"] += float(max_cost)
except Exception:
continue
revenue_sats = stats["revenue_msats"] / 1000
refunds_sats = stats["refunds_msats"] / 1000
net_revenue_sats = revenue_sats - refunds_sats
total_requests = stats["total_requests"]
successful = stats["successful_chat_completions"]
return {
"total_entries": stats["total_entries"],
"total_requests": total_requests,
"successful_chat_completions": successful,
"failed_requests": stats["failed_requests"],
"total_errors": stats["total_errors"],
"total_warnings": stats["total_warnings"],
"payment_processed": stats["payment_processed"],
"upstream_errors": stats["upstream_errors"],
"unique_models_count": len(stats["unique_models"]),
"unique_models": sorted(list(stats["unique_models"])),
"error_types": dict(stats["error_types"]),
"success_rate": (successful / total_requests * 100) if total_requests > 0 else 0,
"revenue_msats": stats["revenue_msats"],
"refunds_msats": stats["refunds_msats"],
"revenue_sats": revenue_sats,
"refunds_sats": refunds_sats,
"net_revenue_msats": stats["revenue_msats"] - stats["refunds_msats"],
"net_revenue_sats": net_revenue_sats,
"avg_revenue_per_request_msats": (
stats["revenue_msats"] / successful if successful > 0 else 0
),
"refund_rate": (
(stats["failed_requests"] / total_requests * 100) if total_requests > 0 else 0
),
}
def _aggregate_metrics_by_time(
self, entries: list[dict], interval_minutes: int, hours_back: int
) -> dict:
time_buckets = defaultdict(lambda: {
"requests": 0,
"errors": 0,
"revenue_msats": 0.0
})
for entry in entries:
try:
timestamp_str = entry.get("asctime", "")
if not timestamp_str:
continue
log_time = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S")
log_time = log_time.replace(tzinfo=timezone.utc)
# Round down to nearest interval
minutes = log_time.minute
rounded_minutes = (minutes // interval_minutes) * interval_minutes
bucket_time = log_time.replace(minute=rounded_minutes, second=0, microsecond=0)
bucket_key = bucket_time.strftime("%Y-%m-%d %H:%M:%S")
bucket = time_buckets[bucket_key]
message = entry.get("message", "").lower()
level = entry.get("levelname", "").upper()
if "received proxy request" in message:
bucket["requests"] += 1
if level == "ERROR":
bucket["errors"] += 1
if "token adjustment completed" in message:
cost_data = entry.get("cost_data")
if isinstance(cost_data, dict):
actual_cost = cost_data.get("total_msats", 0)
if isinstance(actual_cost, (int, float)) and actual_cost > 0:
bucket["revenue_msats"] += float(actual_cost)
except Exception:
continue
result = []
for bucket_key in sorted(time_buckets.keys()):
result.append({"timestamp": bucket_key, **time_buckets[bucket_key]})
return {
"metrics": result,
"interval_minutes": interval_minutes,
"hours_back": hours_back,
"total_buckets": len(result),
}
log_manager = LogManager()
+37
View File
@@ -1,3 +1,40 @@
"""
Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system (routstr/core/admin.py).
DO NOT modify or remove these messages without updating the usage tracking logic:
1. "Received proxy request" (INFO) - routstr/proxy.py
- Used to count total incoming requests
- Includes model information in context
2. "Token adjustment completed for streaming" (INFO) - routstr/upstream/base.py
"Token adjustment completed for non-streaming" (INFO) - routstr/upstream/base.py
- Used to track successful completions and revenue
- The 'cost_data.total_msats' field is extracted for revenue calculation
- Must include 'cost_data' in extra dict
3. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
4. "Upstream request failed, revert payment" (WARNING) - routstr/proxy.py
- Used to track failed requests and refunds
- The 'max_cost_for_model' field is extracted for refund calculation
- Must include 'max_cost_for_model' in extra dict
5. Any ERROR level logs with "upstream" in the message
- Used to count upstream provider errors
- Helps identify service reliability issues
If you need to modify these messages, ensure you also update the parsing logic in:
- routstr/core/admin.py:_aggregate_metrics_by_time()
- routstr/core/admin.py:_get_summary_stats()
- routstr/core/admin.py:get_revenue_by_model()
"""
import logging.config
import logging.handlers
import os
+9
View File
@@ -273,6 +273,15 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def redirect_logs_index_txt() -> RedirectResponse:
return RedirectResponse("/logs")
@app.get("/usage", include_in_schema=False)
async def serve_usage_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "usage" / "index.html")
# Add explicit route for /usage/index.txt to redirect to /usage
@app.get("/usage/index.txt", include_in_schema=False)
async def redirect_usage_index_txt() -> RedirectResponse:
return RedirectResponse("/usage")
@app.get("/unauthorized", include_in_schema=False)
async def serve_unauthorized_ui() -> FileResponse:
return FileResponse(UI_DIST_PATH / "unauthorized" / "index.html")
-264
View File
@@ -1,264 +0,0 @@
from __future__ import annotations
import asyncio
import json
import math
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable, Literal, cast
UsageMetricName = Literal["errors", "chat_completions_success"]
@dataclass(frozen=True)
class MetricDefinition:
name: UsageMetricName
label: str
description: str
matcher: Callable[[dict[str, object]], bool]
@dataclass(frozen=True)
class UsageMetricPoint:
bucket_start: datetime
count: int
@dataclass(frozen=True)
class UsageMetricSeries:
name: UsageMetricName
label: str
description: str
total: int
points: list[UsageMetricPoint]
@dataclass(frozen=True)
class UsageMetricsComputation:
bucket_minutes: int
bucket_count: int
start: datetime
end: datetime
series: list[UsageMetricSeries]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def _is_error(record: dict[str, object]) -> bool:
level = record.get("levelname")
if not isinstance(level, str):
return False
return level.upper() in {"ERROR", "CRITICAL"}
def _coerce_int(value: object) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return None
return None
def _is_successful_chat_completion(record: dict[str, object]) -> bool:
message = record.get("message")
if not isinstance(message, str) or message != "Received upstream response":
return False
status_code = _coerce_int(record.get("status_code"))
if status_code != 200:
return False
path = record.get("path")
return isinstance(path, str) and path.endswith("chat/completions")
METRIC_DEFINITIONS: dict[UsageMetricName, MetricDefinition] = {
"errors": MetricDefinition(
name="errors",
label="Errors",
description="Log entries emitted at ERROR or CRITICAL level",
matcher=_is_error,
),
"chat_completions_success": MetricDefinition(
name="chat_completions_success",
label="200 chat/completions",
description="Successful upstream responses for chat/completions",
matcher=_is_successful_chat_completion,
),
}
def list_metric_definitions() -> list[dict[str, str]]:
return [
{
"name": definition.name,
"label": definition.label,
"description": definition.description,
}
for definition in METRIC_DEFINITIONS.values()
]
def default_metric_names() -> list[UsageMetricName]:
return list(METRIC_DEFINITIONS.keys())
class UsageMetricsService:
@classmethod
async def collect(
cls,
metrics: list[str],
bucket_minutes: int,
hours: int,
log_dir: Path | None = None,
now: datetime | None = None,
) -> UsageMetricsComputation:
return await asyncio.to_thread(
cls._collect_sync, metrics, bucket_minutes, hours, log_dir, now
)
@classmethod
def _collect_sync(
cls,
metrics: list[str],
bucket_minutes: int,
hours: int,
log_dir: Path | None,
now: datetime | None,
) -> UsageMetricsComputation:
metric_list = cls._sanitize_metrics(metrics)
bucket_minutes = cls._clamp(bucket_minutes, minimum=1, maximum=24 * 60)
hours = cls._clamp(hours, minimum=1, maximum=24 * 7)
now_dt = now or datetime.now()
start = now_dt - timedelta(hours=hours)
bucket_seconds = bucket_minutes * 60
bucket_count = max(1, math.ceil((hours * 3600) / bucket_seconds))
bucket_starts = [
start + timedelta(seconds=bucket_seconds * index)
for index in range(bucket_count)
]
counts: dict[UsageMetricName, list[int]] = {
name: [0] * bucket_count for name in metric_list
}
for log_file in cls._iter_log_files(log_dir):
try:
with log_file.open("r", encoding="utf-8") as handle:
for line in handle:
record = cls._parse_record(line)
if not record:
continue
timestamp = cls._parse_timestamp(record.get("asctime"))
if timestamp is None or timestamp < start or timestamp > now_dt:
continue
bucket_index = cls._bucket_index(
timestamp, start, bucket_seconds, bucket_count
)
if bucket_index is None:
continue
for metric_name in metric_list:
definition = METRIC_DEFINITIONS[metric_name]
if definition.matcher(record):
counts[metric_name][bucket_index] += 1
except (OSError, UnicodeDecodeError):
continue
series = [
UsageMetricSeries(
name=metric_name,
label=METRIC_DEFINITIONS[metric_name].label,
description=METRIC_DEFINITIONS[metric_name].description,
total=sum(counts[metric_name]),
points=[
UsageMetricPoint(bucket_start=bucket_starts[index], count=count)
for index, count in enumerate(counts[metric_name])
],
)
for metric_name in metric_list
]
return UsageMetricsComputation(
bucket_minutes=bucket_minutes,
bucket_count=bucket_count,
start=start,
end=now_dt,
series=series,
)
@staticmethod
def _sanitize_metrics(metrics: list[str]) -> list[UsageMetricName]:
requested = [metric.strip() for metric in metrics if metric.strip()]
unique: list[UsageMetricName] = []
for normalized in requested:
if normalized not in METRIC_DEFINITIONS:
continue
metric_name = cast(UsageMetricName, normalized)
if metric_name not in unique:
unique.append(metric_name)
if unique:
return unique
if requested:
raise ValueError("No valid usage metrics requested")
return default_metric_names()
@staticmethod
def _clamp(value: int, *, minimum: int, maximum: int) -> int:
return max(minimum, min(maximum, value))
@staticmethod
def _iter_log_files(log_dir: Path | None) -> list[Path]:
directory = log_dir or Path("logs")
if not directory.exists():
return []
log_files = sorted(
directory.glob("*.log"),
key=UsageMetricsService._safe_mtime,
reverse=True,
)
return log_files
@staticmethod
def _safe_mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
@staticmethod
def _parse_record(line: str) -> dict[str, object] | None:
stripped = line.strip()
if not stripped:
return None
try:
data = json.loads(stripped)
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
@staticmethod
def _parse_timestamp(value: object) -> datetime | None:
if not isinstance(value, str):
return None
try:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
@staticmethod
def _bucket_index(
timestamp: datetime,
start: datetime,
bucket_seconds: int,
bucket_count: int,
) -> int | None:
delta_seconds = (timestamp - start).total_seconds()
if delta_seconds < 0:
return None
index = int(delta_seconds // bucket_seconds)
if index >= bucket_count:
index = bucket_count - 1
return index
-3
View File
@@ -1,3 +0,0 @@
from .log_search import search_logs
__all__ = ["search_logs"]
-108
View File
@@ -1,108 +0,0 @@
import json
from pathlib import Path
from typing import Any
def search_logs(
logs_dir: Path,
date: str | None = None,
level: str | None = None,
request_id: str | None = None,
search_text: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
"""
Search through log files and return matching entries.
Args:
logs_dir: Path to the logs directory
date: Filter by specific date (YYYY-MM-DD format)
level: Filter by log level (INFO, WARNING, ERROR, etc.)
request_id: Filter by exact request ID match
search_text: Search in message and name fields (case-insensitive)
limit: Maximum number of entries to return
Returns:
List of log entries matching the criteria
"""
log_entries: list[dict[str, Any]] = []
if not logs_dir.exists():
return log_entries
log_files = []
if date:
log_file = logs_dir / f"app_{date}.log"
if log_file.exists():
log_files.append(log_file)
else:
log_files = sorted(
logs_dir.glob("app_*.log"),
key=lambda x: x.stat().st_mtime,
reverse=True,
)[:7]
search_text_lower = search_text.lower() if search_text else None
for log_file in log_files:
try:
with open(log_file, "r") as f:
for line in f:
try:
log_data = json.loads(line.strip())
if not _matches_filters(
log_data, level, request_id, search_text_lower
):
continue
log_entries.append(log_data)
if len(log_entries) >= limit:
break
except json.JSONDecodeError:
continue
if len(log_entries) >= limit:
break
except Exception:
continue
log_entries.sort(key=lambda x: x.get("asctime", ""), reverse=True)
return log_entries
def _matches_filters(
log_data: dict[str, Any],
level: str | None,
request_id: str | None,
search_text_lower: str | None,
) -> bool:
"""
Check if a log entry matches the given filters.
Args:
log_data: The log entry to check
level: Log level filter (if any)
request_id: Request ID filter (if any)
search_text_lower: Lowercase search text (if any)
Returns:
True if the log entry matches all filters, False otherwise
"""
if level and log_data.get("levelname", "").upper() != level.upper():
return False
if request_id and log_data.get("request_id") != request_id:
return False
if search_text_lower:
message = str(log_data.get("message", "")).lower()
name = str(log_data.get("name", "")).lower()
if search_text_lower not in message and search_text_lower not in name:
return False
return True
-79
View File
@@ -1,79 +0,0 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from routstr.core.usage_metrics import UsageMetricsService
def _write_records(path: Path, records: list[dict[str, object]]) -> None:
with path.open("w", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record) + "\n")
@pytest.mark.asyncio
async def test_usage_metrics_counts(tmp_path: Path) -> None:
log_dir = tmp_path / "logs"
log_dir.mkdir()
now = datetime(2025, 1, 1, 12, 0, 0)
records = [
{
"asctime": (now - timedelta(minutes=5)).strftime("%Y-%m-%d %H:%M:%S"),
"levelname": "ERROR",
"message": "Proxy failure",
},
{
"asctime": (now - timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S"),
"levelname": "INFO",
"message": "Received upstream response",
"status_code": 200,
"path": "chat/completions",
},
{
"asctime": (now - timedelta(minutes=20)).strftime("%Y-%m-%d %H:%M:%S"),
"levelname": "INFO",
"message": "Received upstream response",
"status_code": 500,
"path": "chat/completions",
},
]
_write_records(log_dir / "app_2025-01-01.log", records)
result = await UsageMetricsService.collect(
metrics=["errors", "chat_completions_success"],
bucket_minutes=15,
hours=1,
log_dir=log_dir,
now=now,
)
errors_series = next(series for series in result.series if series.name == "errors")
completions_series = next(
series
for series in result.series
if series.name == "chat_completions_success"
)
assert errors_series.total == 1
assert sum(point.count for point in errors_series.points) == 1
assert completions_series.total == 1
assert any(point.count == 1 for point in completions_series.points)
@pytest.mark.asyncio
async def test_usage_metrics_invalid_metric(tmp_path: Path) -> None:
log_dir = tmp_path / "logs"
log_dir.mkdir()
now = datetime(2025, 1, 1, 12, 0, 0)
with pytest.raises(ValueError):
await UsageMetricsService.collect(
metrics=["unknown"],
bucket_minutes=15,
hours=1,
log_dir=log_dir,
now=now,
)
+15 -19
View File
@@ -10,7 +10,6 @@ import { TemporaryBalances } from '@/components/temporary-balances';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { DisplayUnit } from '@/lib/types/units';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { UsageTracking } from '@/components/usage-tracking';
export default function Page() {
const [displayUnit, setDisplayUnit] = useState<DisplayUnit>('sat');
@@ -66,25 +65,22 @@ export default function Page() {
</div>
</div>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<UsageTracking />
</div>
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
refreshInterval={30000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
<div className='col-span-full'>
<TemporaryBalances
refreshInterval={60000}
displayUnit={displayUnit}
usdPerSat={usdPerSat}
/>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
+302
View File
@@ -0,0 +1,302 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { UsageMetricsChart } from '@/components/usage-metrics-chart';
import { UsageSummaryCards } from '@/components/usage-summary-cards';
import { ErrorDetailsTable } from '@/components/error-details-table';
import { RevenueByModelTable } from '@/components/revenue-by-model-table';
import { AdminService } from '@/lib/api/services/admin';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { RefreshCw } from 'lucide-react';
export default function UsagePage() {
const [timeRange, setTimeRange] = useState('24');
const [interval, setInterval] = useState('15');
const {
data: metricsData,
isLoading: metricsLoading,
refetch: refetchMetrics,
} = useQuery({
queryKey: ['usage-metrics', interval, timeRange],
queryFn: () =>
AdminService.getUsageMetrics(parseInt(interval), parseInt(timeRange)),
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: summaryData,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useQuery({
queryKey: ['usage-summary', timeRange],
queryFn: () => AdminService.getUsageSummary(parseInt(timeRange)),
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: errorData,
isLoading: errorLoading,
refetch: refetchErrors,
} = useQuery({
queryKey: ['usage-errors', timeRange],
queryFn: () => AdminService.getErrorDetails(parseInt(timeRange), 100),
refetchInterval: 60_000,
staleTime: 30_000,
});
const {
data: revenueByModelData,
isLoading: revenueByModelLoading,
refetch: refetchRevenueByModel,
} = useQuery({
queryKey: ['revenue-by-model', timeRange],
queryFn: () => AdminService.getRevenueByModel(parseInt(timeRange), 20),
refetchInterval: 60_000,
staleTime: 30_000,
});
const handleRefresh = () => {
refetchMetrics();
refetchSummary();
refetchErrors();
refetchRevenueByModel();
};
return (
<SidebarProvider>
<AppSidebar variant='inset' />
<SidebarInset className='p-0'>
<SiteHeader />
<div className='container max-w-7xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>
Usage Tracking
</h1>
<p className='text-muted-foreground mt-2'>
Monitor system usage, requests, and errors over time
</p>
</div>
<div className='flex items-center gap-4'>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select time range' />
</SelectTrigger>
<SelectContent>
<SelectItem value='1'>Last Hour</SelectItem>
<SelectItem value='6'>Last 6 Hours</SelectItem>
<SelectItem value='24'>Last 24 Hours</SelectItem>
<SelectItem value='72'>Last 3 Days</SelectItem>
<SelectItem value='168'>Last Week</SelectItem>
</SelectContent>
</Select>
<Select value={interval} onValueChange={setInterval}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder='Select interval' />
</SelectTrigger>
<SelectContent>
<SelectItem value='5'>5 Minutes</SelectItem>
<SelectItem value='15'>15 Minutes</SelectItem>
<SelectItem value='30'>30 Minutes</SelectItem>
<SelectItem value='60'>1 Hour</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleRefresh} variant='outline' size='icon'>
<RefreshCw className='h-4 w-4' />
</Button>
</div>
</div>
<div className='space-y-6'>
{summaryLoading ? (
<div className='text-center py-8'>Loading summary...</div>
) : summaryData ? (
<UsageSummaryCards summary={summaryData} />
) : null}
<div className='grid gap-6 lg:grid-cols-2'>
{metricsLoading ? (
<div className='text-center py-8 col-span-2'>
Loading metrics...
</div>
) : metricsData && metricsData.metrics.length > 0 ? (
<>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Request Volume'
dataKeys={[
{
key: 'total_requests',
name: 'Total Requests',
color: '#3b82f6',
},
{
key: 'successful_chat_completions',
name: 'Successful',
color: '#10b981',
},
{
key: 'failed_requests',
name: 'Failed',
color: '#ef4444',
},
]}
/>
<UsageMetricsChart
data={metricsData.metrics.map((m) => ({
...m,
revenue_sats: m.revenue_msats / 1000,
refunds_sats: m.refunds_msats / 1000,
net_revenue_sats:
(m.revenue_msats - m.refunds_msats) / 1000,
})) as Array<Record<string, unknown> & { timestamp: string }>}
title='Revenue Over Time (sats)'
dataKeys={[
{
key: 'revenue_sats',
name: 'Revenue',
color: '#10b981',
},
{
key: 'refunds_sats',
name: 'Refunds',
color: '#ef4444',
},
{
key: 'net_revenue_sats',
name: 'Net Revenue',
color: '#059669',
},
]}
/>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Error Tracking'
dataKeys={[
{
key: 'errors',
name: 'Errors',
color: '#f97316',
},
{
key: 'warnings',
name: 'Warnings',
color: '#eab308',
},
{
key: 'upstream_errors',
name: 'Upstream Errors',
color: '#ef4444',
},
]}
/>
<UsageMetricsChart
data={metricsData.metrics as Array<Record<string, unknown> & { timestamp: string }>}
title='Payment Activity'
dataKeys={[
{
key: 'payment_processed',
name: 'Payments Processed',
color: '#6366f1',
},
]}
/>
</>
) : (
<Card className='col-span-2'>
<CardHeader>
<CardTitle>No Data Available</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground'>
No metrics data found for the selected time range. This
could be because no requests have been logged yet or the
log files are not available.
</p>
</CardContent>
</Card>
)}
</div>
{revenueByModelLoading ? (
<div className='text-center py-8'>Loading revenue by model...</div>
) : revenueByModelData && revenueByModelData.models.length > 0 ? (
<RevenueByModelTable
models={revenueByModelData.models}
totalRevenue={revenueByModelData.total_revenue_sats}
/>
) : null}
{errorLoading ? (
<div className='text-center py-8'>Loading errors...</div>
) : errorData ? (
<ErrorDetailsTable errors={errorData.errors} />
) : null}
{summaryData && summaryData.unique_models.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Active Models</CardTitle>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{summaryData.unique_models.map((model) => (
<span
key={model}
className='bg-secondary text-secondary-foreground inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold'
>
{model}
</span>
))}
</div>
</CardContent>
</Card>
)}
{summaryData &&
summaryData.error_types &&
Object.keys(summaryData.error_types).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Error Types Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-2'>
{Object.entries(summaryData.error_types)
.sort(([, a], [, b]) => b - a)
.map(([type, count]) => (
<div
key={type}
className='flex items-center justify-between'
>
<span className='text-sm font-medium'>{type}</span>
<span className='text-muted-foreground text-sm'>
{count}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
+12 -6
View File
@@ -2,8 +2,9 @@
import * as React from 'react';
import {
DatabaseIcon,
ActivityIcon,
FileTextIcon,
DatabaseIcon,
LayoutDashboardIcon,
ServerIcon,
SettingsIcon,
@@ -35,6 +36,16 @@ const data = {
url: '/',
icon: LayoutDashboardIcon,
},
{
title: 'Usage',
url: '/usage',
icon: ActivityIcon,
},
{
title: 'Logs',
url: '/logs',
icon: FileTextIcon,
},
{
title: 'Models',
url: '/model',
@@ -45,11 +56,6 @@ const data = {
url: '/providers',
icon: ServerIcon,
},
{
title: 'Logs',
url: '/logs',
icon: FileTextIcon,
},
{
title: 'Settings',
url: '/settings',
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { ErrorDetail } from '@/lib/api/services/admin';
interface ErrorDetailsTableProps {
errors: ErrorDetail[];
}
export function ErrorDetailsTable({ errors }: ErrorDetailsTableProps) {
if (errors.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors</CardTitle>
</CardHeader>
<CardContent>
<p className='text-muted-foreground text-center py-8'>
No errors found in the selected time period
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Errors ({errors.length})</CardTitle>
</CardHeader>
<CardContent>
<div className='max-h-[400px] overflow-y-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Location</TableHead>
<TableHead>Request ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{errors.map((error, index) => (
<TableRow key={index}>
<TableCell className='font-mono text-xs'>
{new Date(error.timestamp).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant='destructive'>{error.error_type}</Badge>
</TableCell>
<TableCell className='max-w-md truncate'>
{error.message}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.pathname}:{error.lineno}
</TableCell>
<TableCell className='font-mono text-xs'>
{error.request_id || '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ModelRevenueData } from '@/lib/api/services/admin';
interface RevenueByModelTableProps {
models: ModelRevenueData[];
totalRevenue: number;
}
export function RevenueByModelTable({
models,
totalRevenue,
}: RevenueByModelTableProps) {
return (
<Card>
<CardHeader>
<CardTitle>Revenue by Model</CardTitle>
<p className="text-sm text-muted-foreground">
Total Revenue: {totalRevenue.toLocaleString(undefined, { maximumFractionDigits: 2 })} sats
</p>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Successful</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead className="text-right">Revenue (sats)</TableHead>
<TableHead className="text-right">Refunds (sats)</TableHead>
<TableHead className="text-right">Net Revenue (sats)</TableHead>
<TableHead className="text-right">Avg/Request</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No model data available
</TableCell>
</TableRow>
) : (
models.map((model) => (
<TableRow key={model.model}>
<TableCell className="font-medium">{model.model}</TableCell>
<TableCell className="text-right">{model.requests}</TableCell>
<TableCell className="text-right text-green-600">{model.successful}</TableCell>
<TableCell className="text-right text-red-600">{model.failed}</TableCell>
<TableCell className="text-right">
{model.revenue_sats.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right text-red-500">
{model.refunds_sats.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right font-semibold">
{model.net_revenue_sats.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{model.avg_revenue_per_request.toLocaleString(undefined, { maximumFractionDigits: 3 })}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Legend,
CartesianGrid,
} from 'recharts';
interface UsageMetricsChartProps {
data: Array<Record<string, unknown> & { timestamp: string }>;
title: string;
dataKeys: Array<{
key: string;
name: string;
color: string;
}>;
}
export function UsageMetricsChart({
data,
title,
dataKeys,
}: UsageMetricsChartProps) {
const formattedData = data.map((item) => ({
...item,
time: new Date(item.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}));
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width='100%' height={300}>
<LineChart data={formattedData}>
<CartesianGrid strokeDasharray='3 3' className='stroke-muted' />
<XAxis
dataKey='time'
className='text-xs'
tick={{ fill: 'currentColor' }}
/>
<YAxis className='text-xs' tick={{ fill: 'currentColor' }} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
{dataKeys.map((dataKey) => (
<Line
key={dataKey.key}
type='monotone'
dataKey={dataKey.key}
stroke={dataKey.color}
name={dataKey.name}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageSummary } from '@/lib/api/services/admin';
import {
CheckCircle2,
XCircle,
AlertTriangle,
Activity,
Database,
CreditCard,
TrendingUp,
DollarSign,
TrendingDown,
Coins,
} from 'lucide-react';
interface UsageSummaryCardsProps {
summary: UsageSummary;
}
export function UsageSummaryCards({ summary }: UsageSummaryCardsProps) {
const cards = [
{
title: 'Total Requests',
value: summary.total_requests.toLocaleString(),
icon: Activity,
color: 'text-blue-500',
},
{
title: 'Successful Completions',
value: summary.successful_chat_completions.toLocaleString(),
icon: CheckCircle2,
color: 'text-green-500',
},
{
title: 'Revenue (sats)',
value: summary.revenue_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
icon: Coins,
color: 'text-green-600',
},
{
title: 'Net Revenue (sats)',
value: summary.net_revenue_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
icon: DollarSign,
color: 'text-emerald-600',
},
{
title: 'Refunds (sats)',
value: summary.refunds_sats.toLocaleString(undefined, {
maximumFractionDigits: 2,
}),
icon: TrendingDown,
color: 'text-red-500',
},
{
title: 'Avg Revenue/Request',
value: `${(summary.avg_revenue_per_request_msats / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 })} sats`,
icon: CreditCard,
color: 'text-cyan-500',
},
{
title: 'Success Rate',
value: `${summary.success_rate.toFixed(1)}%`,
icon: TrendingUp,
color: 'text-emerald-500',
},
{
title: 'Refund Rate',
value: `${summary.refund_rate.toFixed(1)}%`,
icon: XCircle,
color: 'text-orange-500',
},
{
title: 'Failed Requests',
value: summary.failed_requests.toLocaleString(),
icon: XCircle,
color: 'text-red-400',
},
{
title: 'Errors',
value: summary.total_errors.toLocaleString(),
icon: AlertTriangle,
color: 'text-orange-500',
},
{
title: 'Unique Models',
value: summary.unique_models_count.toLocaleString(),
icon: Database,
color: 'text-purple-500',
},
{
title: 'Upstream Errors',
value: summary.upstream_errors.toLocaleString(),
icon: AlertTriangle,
color: 'text-yellow-500',
},
];
return (
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>{card.title}</CardTitle>
<card.icon className={`h-4 w-4 ${card.color}`} />
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>{card.value}</div>
</CardContent>
</Card>
))}
</div>
);
}
-417
View File
@@ -1,417 +0,0 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Activity,
AlertCircle,
BarChart3,
RefreshCw,
TrendingDown,
TrendingUp,
} from 'lucide-react';
import {
AdminService,
UsageMetricDefinition,
UsageMetricName,
} from '@/lib/api/services/admin';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
const bucketOptions = [
{ label: '15 minutes', value: 15 },
{ label: '1 hour', value: 60 },
];
const rangeOptions = [
{ label: 'Last 6 hours', value: 6 },
{ label: 'Last 24 hours', value: 24 },
{ label: 'Last 72 hours', value: 72 },
];
const palette = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
];
const defaultMetrics: UsageMetricName[] = [
'errors',
'chat_completions_success',
];
export function UsageTracking() {
const [bucketMinutes, setBucketMinutes] = useState(15);
const [hours, setHours] = useState(24);
const [selectedMetrics, setSelectedMetrics] =
useState<UsageMetricName[]>(defaultMetrics);
const {
data: metricDefinitions,
isLoading: definitionsLoading,
isError: definitionsError,
} = useQuery({
queryKey: ['usage-metric-definitions'],
queryFn: async () => AdminService.getUsageMetricDefinitions(),
staleTime: 10 * 60 * 1000,
});
useEffect(() => {
if (!metricDefinitions || !metricDefinitions.length) {
return;
}
setSelectedMetrics((current) => {
const filtered = current.filter((metric) =>
metricDefinitions.some((definition) => definition.name === metric)
) as UsageMetricName[];
if (filtered.length) {
return filtered;
}
return metricDefinitions.map(
(definition) => definition.name as UsageMetricName
);
});
}, [metricDefinitions]);
const metricsKey = useMemo(
() => [...selectedMetrics].sort().join(','),
[selectedMetrics]
);
const {
data: metricsData,
isLoading: metricsLoading,
isFetching: metricsFetching,
isError: metricsError,
error: metricsErrorObject,
refetch: refetchMetrics,
} = useQuery({
queryKey: ['usage-metrics', metricsKey, bucketMinutes, hours],
queryFn: async () =>
AdminService.getUsageMetrics({
metrics: selectedMetrics,
bucket_minutes: bucketMinutes,
hours,
}),
enabled: selectedMetrics.length > 0,
refetchInterval: 60_000,
keepPreviousData: true,
});
const colorMap = useMemo(() => {
const map: Partial<Record<UsageMetricName, string>> = {};
metricDefinitions?.forEach((definition, index) => {
map[definition.name] = palette[index % palette.length];
});
return map;
}, [metricDefinitions]);
const chartConfig = useMemo<ChartConfig>(() => {
const config: ChartConfig = {};
metricDefinitions?.forEach((definition) => {
const color = colorMap[definition.name];
config[definition.name] = {
label: definition.label,
color,
};
});
return config;
}, [metricDefinitions, colorMap]);
const chartData = useMemo(() => {
if (!metricsData || !metricsData.series.length) {
return [];
}
const referenceSeries = metricsData.series[0];
return referenceSeries.points.map((point, index) => {
const base: Record<string, string | number> = {
bucketStart: point.bucket_start,
label: formatBucketLabel(point.bucket_start, hours),
};
metricsData.series.forEach((series) => {
base[series.name] = series.points[index]?.count ?? 0;
});
return base;
});
}, [metricsData, hours]);
const summary = useMemo(() => {
if (!metricsData) {
return [];
}
return metricsData.series.map((series) => ({
name: series.name,
label: series.label,
total: series.total,
latest: series.points.at(-1)?.count ?? 0,
averagePerHour: series.total / Math.max(1, hours),
}));
}, [metricsData, hours]);
const handleMetricToggle = (metric: UsageMetricName) => {
setSelectedMetrics((current) => {
if (current.includes(metric)) {
if (current.length === 1) {
return current;
}
return current.filter((item) => item !== metric);
}
return [...current, metric];
});
};
const renderSummaryCard = (definition: UsageMetricDefinition) => {
const stat = summary.find((item) => item.name === definition.name);
const Icon = definition.name === 'errors' ? AlertCircle : Activity;
return (
<div
key={definition.name}
className='rounded-lg border bg-muted/20 p-4'
style={{
borderColor: colorMap[definition.name] ?? 'hsl(var(--border))',
}}
>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Icon className='h-5 w-5' />
<span className='text-sm font-medium'>{definition.label}</span>
</div>
<Badge variant='outline'>
{bucketMinutes >= 60
? `${bucketMinutes / 60}h buckets`
: `${bucketMinutes}m buckets`}
</Badge>
</div>
<div className='mt-3 flex items-end justify-between'>
<div>
<div className='text-3xl font-bold'>
{stat ? stat.total.toLocaleString() : '—'}
</div>
<p className='text-muted-foreground text-xs'>
total events in range
</p>
</div>
{stat && (
<div className='text-right text-xs'>
<div className='flex items-center justify-end gap-1 text-muted-foreground'>
<TrendingUp className='h-3 w-3' />
<span>{stat.latest} latest bucket</span>
</div>
<div className='flex items-center justify-end gap-1 text-muted-foreground'>
<TrendingDown className='h-3 w-3' />
<span>{stat.averagePerHour.toFixed(2)} avg/hr</span>
</div>
</div>
)}
</div>
</div>
);
};
const isLoading =
definitionsLoading ||
metricsLoading ||
(selectedMetrics.length > 0 && !metricsData);
return (
<Card className='shadow-sm'>
<CardHeader className='pb-4'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div>
<CardTitle className='flex items-center gap-2 text-xl'>
<BarChart3 className='h-5 w-5' />
Usage Tracking
</CardTitle>
<CardDescription>
Monitor errors and successful upstream traffic over time
</CardDescription>
</div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
<Select
value={hours.toString()}
onValueChange={(value) => setHours(Number(value))}
>
<SelectTrigger className='w-[160px]'>
<SelectValue placeholder='Time range' />
</SelectTrigger>
<SelectContent>
{rangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={bucketMinutes.toString()}
onValueChange={(value) => setBucketMinutes(Number(value))}
>
<SelectTrigger className='w-[140px]'>
<SelectValue placeholder='Bucket size' />
</SelectTrigger>
<SelectContent>
{bucketOptions.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant='ghost'
size='icon'
onClick={() => refetchMetrics()}
disabled={isLoading || metricsFetching}
className='h-9 w-9'
>
<RefreshCw
className={cn(
'h-4 w-4',
(metricsFetching || metricsLoading) && 'animate-spin'
)}
/>
<span className='sr-only'>Refresh usage metrics</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent className='space-y-6'>
{definitionsError ? (
<div className='bg-destructive/10 text-destructive flex items-center gap-2 rounded-md p-4 text-sm'>
<AlertCircle className='h-5 w-5' />
Failed to load metric definitions.
</div>
) : (
<div className='flex flex-wrap gap-2'>
{metricDefinitions?.map((definition) => (
<Button
key={definition.name}
variant={
selectedMetrics.includes(
definition.name as UsageMetricName
)
? 'default'
: 'outline'
}
size='sm'
onClick={() =>
handleMetricToggle(definition.name as UsageMetricName)
}
className='text-xs'
>
{definition.label}
</Button>
))}
</div>
)}
{metricsError && (
<div className='bg-destructive/10 text-destructive flex items-center gap-2 rounded-md p-4 text-sm'>
<AlertCircle className='h-5 w-5' />
{metricsErrorObject instanceof Error
? metricsErrorObject.message
: 'Failed to load usage metrics.'}
</div>
)}
{isLoading ? (
<Skeleton className='h-64 w-full' />
) : (
metricsData &&
summary.length > 0 && (
<div className='grid gap-4 md:grid-cols-2'>
{metricDefinitions
?.filter((definition) =>
selectedMetrics.includes(definition.name as UsageMetricName)
)
.map((definition) => renderSummaryCard(definition))}
</div>
)
)}
{isLoading ? (
<Skeleton className='h-72 w-full' />
) : chartData.length > 0 ? (
<ChartContainer
config={chartConfig}
className='h-[360px] w-full text-xs'
>
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray='3 3' vertical={false} />
<XAxis dataKey='label' tickLine={false} axisLine={false} />
<ChartTooltip
cursor={{ strokeDasharray: '3 3' }}
content={
<ChartTooltipContent
labelFormatter={(value) => value as string}
/>
}
/>
{metricsData?.series.map((series) => (
<Area
key={series.name}
type='monotone'
dataKey={series.name}
stroke={`var(--color-${series.name})`}
fill={`var(--color-${series.name})`}
fillOpacity={0.2}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
))}
</AreaChart>
</ChartContainer>
) : (
<div className='text-muted-foreground flex flex-col items-center gap-2 rounded-md border border-dashed p-10 text-sm'>
<AlertCircle className='h-6 w-6' />
<p>No data available for the selected window.</p>
</div>
)}
</CardContent>
</Card>
);
}
function formatBucketLabel(value: string | undefined, rangeHours: number) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
if (rangeHours <= 24) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
});
}
+137 -40
View File
@@ -797,33 +797,63 @@ export class AdminService {
return await apiClient.post<{ ok: boolean }>('/admin/api/logout', {});
}
static async getLogs(
date?: string,
level?: string,
requestId?: string,
search?: string,
limit: number = 100
): Promise<LogResponse> {
const params = new URLSearchParams();
if (date) params.append('date', date);
if (level) params.append('level', level);
if (requestId) params.append('request_id', requestId);
if (search) params.append('search', search);
params.append('limit', limit.toString());
return await apiClient.get<LogResponse>(`/admin/api/logs?${params.toString()}`);
}
static async getLogDates(): Promise<{ dates: string[] }> {
return await apiClient.get<{ dates: string[] }>('/admin/api/logs/dates');
}
static async getTemporaryBalances(): Promise<TemporaryBalance[]> {
return await apiClient.get<TemporaryBalance[]>(
'/admin/api/temporary-balances'
);
}
static async getUsageMetricDefinitions(): Promise<UsageMetricDefinition[]> {
return await apiClient.get<UsageMetricDefinition[]>(
'/admin/api/usage-metrics/definitions'
static async getUsageMetrics(
interval: number = 15,
hours: number = 24
): Promise<UsageMetrics> {
return await apiClient.get<UsageMetrics>(
`/admin/api/usage/metrics?interval=${interval}&hours=${hours}`
);
}
static async getUsageMetrics(params: {
metrics: UsageMetricName[];
bucket_minutes: number;
hours: number;
}): Promise<UsageMetricsResponse> {
const query: Record<string, string | number> = {
bucket_minutes: params.bucket_minutes,
hours: params.hours,
};
if (params.metrics.length > 0) {
query.metrics = params.metrics.join(',');
}
return await apiClient.get<UsageMetricsResponse>(
'/admin/api/usage-metrics',
query
static async getUsageSummary(hours: number = 24): Promise<UsageSummary> {
return await apiClient.get<UsageSummary>(
`/admin/api/usage/summary?hours=${hours}`
);
}
static async getErrorDetails(
hours: number = 24,
limit: number = 100
): Promise<ErrorDetails> {
return await apiClient.get<ErrorDetails>(
`/admin/api/usage/error-details?hours=${hours}&limit=${limit}`
);
}
static async getRevenueByModel(
hours: number = 24,
limit: number = 20
): Promise<RevenueByModel> {
return await apiClient.get<RevenueByModel>(
`/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
);
}
}
@@ -839,31 +869,98 @@ export const TemporaryBalanceSchema = z.object({
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
export type UsageMetricName = 'errors' | 'chat_completions_success';
export interface UsageMetricDefinition {
name: UsageMetricName;
label: string;
description: string;
export interface UsageMetricData {
timestamp: string;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
errors: number;
warnings: number;
payment_processed: number;
upstream_errors: number;
revenue_msats: number;
refunds_msats: number;
[key: string]: unknown;
}
export interface UsageMetricPoint {
bucket_start: string;
count: number;
export interface UsageMetrics {
metrics: UsageMetricData[];
interval_minutes: number;
hours_back: number;
total_buckets: number;
}
export interface UsageMetricSeries {
name: UsageMetricName;
label: string;
description: string;
export interface UsageSummary {
total_entries: number;
total_requests: number;
successful_chat_completions: number;
failed_requests: number;
total_errors: number;
total_warnings: number;
payment_processed: number;
upstream_errors: number;
unique_models_count: number;
unique_models: string[];
error_types: Record<string, number>;
success_rate: number;
revenue_msats: number;
refunds_msats: number;
revenue_sats: number;
refunds_sats: number;
net_revenue_msats: number;
net_revenue_sats: number;
avg_revenue_per_request_msats: number;
refund_rate: number;
}
export interface ErrorDetail {
timestamp: string;
message: string;
error_type: string;
pathname: string;
lineno: number;
request_id: string;
}
export interface ErrorDetails {
errors: ErrorDetail[];
total_count: number;
}
export interface ModelRevenueData {
model: string;
revenue_sats: number;
refunds_sats: number;
net_revenue_sats: number;
requests: number;
successful: number;
failed: number;
avg_revenue_per_request: number;
}
export interface RevenueByModel {
models: ModelRevenueData[];
total_revenue_sats: number;
total_models: number;
}
export interface LogEntry {
asctime: string;
name: string;
levelname: string;
message: string;
pathname?: string;
lineno?: number;
request_id?: string;
[key: string]: unknown;
}
export interface LogResponse {
logs: LogEntry[];
total: number;
points: UsageMetricPoint[];
}
export interface UsageMetricsResponse {
bucket_minutes: number;
bucket_count: number;
start: string;
end: string;
series: UsageMetricSeries[];
date: string | null;
level: string | null;
request_id: string | null;
search: string | null;
limit: number;
}