mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
refactor: unify logs and usage implementation
This commit is contained in:
+125
-129
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .log_search import search_logs
|
||||
|
||||
__all__ = ["search_logs"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user