diff --git a/routstr/auth.py b/routstr/auth.py
index b1e16987..ec7274ea 100644
--- a/routstr/auth.py
+++ b/routstr/auth.py
@@ -370,6 +370,7 @@ async def pay_for_request(
await session.refresh(key)
+ # IMPORTANT: Do not modify this log string; usage analytics relies on it.
logger.info(
"Payment processed successfully",
extra={
@@ -399,6 +400,7 @@ async def revert_pay_for_request(
result = await session.exec(stmt) # type: ignore[call-overload]
await session.commit()
if result.rowcount == 0:
+ # IMPORTANT: Do not modify this log string; analytics rely on it to flag refund failures.
logger.error(
"Failed to revert payment - insufficient reserved balance",
extra={
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index 3baad78b..dadb7850 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -3,11 +3,20 @@ import secrets
from datetime import datetime, timezone
from pathlib import Path
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from sqlmodel import select
+from ..logs import (
+ LogSearchFilters,
+ collect_error_details,
+ get_available_log_dates,
+ revenue_by_model as aggregate_revenue_by_model,
+ search_logs,
+ summarize_usage,
+ usage_metrics,
+)
from ..payment.models import _row_to_model, list_models
from ..proxy import refresh_model_maps, reinitialize_upstreams
from ..wallet import (
@@ -908,6 +917,96 @@ 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]:
+ logs_dir = Path("logs")
+ limit = max(1, min(limit, 1000))
+ filters = LogSearchFilters(
+ date=None if not date or date.lower() == "all" else date,
+ level=None if not level or level.lower() == "all" else level,
+ request_id=request_id or None,
+ search_text=search or None,
+ )
+ log_entries = search_logs(logs_dir, filters, 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, list[str]]:
+ logs_dir = Path("logs")
+ dates = get_available_log_dates(logs_dir)
+ return {"dates": dates}
+
+
+@admin_router.get("/api/usage/metrics", dependencies=[Depends(require_admin_api)])
+async def get_usage_metrics_api(
+ 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[str, object]:
+ return usage_metrics(Path("logs"), interval, hours)
+
+
+@admin_router.get("/api/usage/summary", dependencies=[Depends(require_admin_api)])
+async def get_usage_summary_api(
+ request: Request,
+ hours: int = Query(
+ default=24, ge=1, le=168, description="Hours of history to analyze"
+ ),
+) -> dict[str, object]:
+ return summarize_usage(Path("logs"), hours)
+
+
+@admin_router.get(
+ "/api/usage/error-details", dependencies=[Depends(require_admin_api)]
+)
+async def get_error_details_api(
+ 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[str, object]:
+ return collect_error_details(Path("logs"), hours, limit)
+
+
+@admin_router.get(
+ "/api/usage/revenue-by-model", dependencies=[Depends(require_admin_api)]
+)
+async def get_revenue_by_model_api(
+ 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[str, object]:
+ return aggregate_revenue_by_model(Path("logs"), hours, limit)
+
+
@admin_router.post("/withdraw", dependencies=[Depends(require_admin_api)])
async def withdraw(
request: Request, withdraw_request: WithdrawRequest
@@ -2404,6 +2503,58 @@ def upstream_providers_page() -> str:
)
+def logs_page() -> str:
+ return """
+
+
+
+
+
+ Logs - Admin Dashboard
+
+
+
+ Redirecting to logs page...
+
+
+ """
+
+
+def usage_page() -> str:
+ return """
+
+
+
+
+
+ Usage - Admin Dashboard
+
+
+
+ Redirecting to usage dashboard...
+
+
+ """
+
+
+@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("/usage", response_class=HTMLResponse)
+async def admin_usage(request: Request) -> str:
+ if is_admin_authenticated(request):
+ return usage_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):
diff --git a/routstr/core/logging.py b/routstr/core/logging.py
index d682b944..5e4466b2 100644
--- a/routstr/core/logging.py
+++ b/routstr/core/logging.py
@@ -1,3 +1,26 @@
+"""
+Logging configuration for Routstr.
+
+CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
+===========================================
+The usage analytics pipeline parses specific log messages emitted by the proxy,
+auth, and upstream layers. If you need to modify any of the following log
+statements, update routstr/logs/service.py accordingly to keep the dashboard
+accurate:
+
+1. "Received proxy request" (INFO) - counts total requests per model.
+2. "Token adjustment completed for streaming|non-streaming" (INFO) - marks
+ successful completions and extracts cost_data.total_msats for revenue.
+3. "Payment processed successfully" (INFO) - tracks payment lifecycle events.
+4. "Upstream request failed, revert payment" (WARNING) and "revert payment"
+ (any level) - mark failed requests and extract max_cost_for_model for refunds.
+5. Any ERROR level log containing the word "upstream" - counted as upstream
+ provider failures.
+
+Keep the wording of these messages stable or adjust the parsers in the log
+service whenever you make changes.
+"""
+
import logging.config
import logging.handlers
import os
diff --git a/routstr/core/main.py b/routstr/core/main.py
index 81212a01..1101a376 100644
--- a/routstr/core/main.py
+++ b/routstr/core/main.py
@@ -264,6 +264,22 @@ if UI_DIST_PATH.exists() and UI_DIST_PATH.is_dir():
async def redirect_transactions_index_txt() -> RedirectResponse:
return RedirectResponse("/transactions")
+ @app.get("/logs", include_in_schema=False)
+ async def serve_logs_ui() -> FileResponse:
+ return FileResponse(UI_DIST_PATH / "logs" / "index.html")
+
+ @app.get("/logs/index.txt", include_in_schema=False)
+ 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")
+
+ @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")
diff --git a/routstr/logs/__init__.py b/routstr/logs/__init__.py
new file mode 100644
index 00000000..57c329f8
--- /dev/null
+++ b/routstr/logs/__init__.py
@@ -0,0 +1,19 @@
+from .service import ( # noqa: F401
+ LogSearchFilters,
+ collect_error_details,
+ get_available_log_dates,
+ search_logs,
+ summarize_usage,
+ usage_metrics,
+ revenue_by_model,
+)
+
+__all__ = [
+ "LogSearchFilters",
+ "collect_error_details",
+ "get_available_log_dates",
+ "search_logs",
+ "summarize_usage",
+ "usage_metrics",
+ "revenue_by_model",
+]
diff --git a/routstr/logs/service.py b/routstr/logs/service.py
new file mode 100644
index 00000000..6ead78db
--- /dev/null
+++ b/routstr/logs/service.py
@@ -0,0 +1,554 @@
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Iterator
+
+LOG_FILE_PREFIX = "app_"
+LOG_FILE_SUFFIX = ".log"
+LOG_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
+DEFAULT_SEARCH_FILE_LIMIT = 7
+
+LogEntry = dict[str, Any]
+
+
+@dataclass(slots=True)
+class LogSearchFilters:
+ date: str | None = None
+ level: str | None = None
+ request_id: str | None = None
+ search_text: str | None = None
+
+
+@dataclass(slots=True)
+class EntryInsights:
+ timestamp: datetime | None
+ level: str
+ model: str | None
+ event_total_request: bool
+ event_success: bool
+ event_fail: bool
+ event_payment_processed: bool
+ event_upstream_error: bool
+ revenue_msats: float
+ refund_msats: float
+ error_type: str | None
+
+
+def search_logs(
+ logs_dir: Path, filters: LogSearchFilters, limit: int = 100
+) -> list[LogEntry]:
+ if limit <= 0 or not logs_dir.exists():
+ return []
+
+ files = _collect_log_files(
+ logs_dir,
+ date=filters.date,
+ limit=None if filters.date else DEFAULT_SEARCH_FILE_LIMIT,
+ )
+
+ search_lower = filters.search_text.lower() if filters.search_text else None
+ normalized_level = filters.level.upper() if filters.level else None
+ entries: list[LogEntry] = []
+
+ for log_file in files:
+ for entry in _stream_log_entries(log_file):
+ if not _matches_filters(
+ entry=entry,
+ level=normalized_level,
+ request_id=filters.request_id,
+ search_lower=search_lower,
+ ):
+ continue
+
+ entries.append(entry)
+ if len(entries) >= limit:
+ break
+
+ if len(entries) >= limit:
+ break
+
+ entries.sort(key=lambda e: e.get("asctime", ""), reverse=True)
+ return entries[:limit]
+
+
+def get_available_log_dates(logs_dir: Path, limit: int = 30) -> list[str]:
+ if not logs_dir.exists():
+ return []
+
+ dates: list[str] = []
+ files = _collect_log_files(logs_dir, limit=limit)
+ for log_file in files:
+ date_str = _extract_date_from_filename(log_file)
+ if date_str:
+ dates.append(date_str)
+
+ return dates
+
+
+def usage_metrics(
+ logs_dir: Path, interval_minutes: int, hours: int
+) -> dict[str, Any]:
+ if not logs_dir.exists():
+ return {
+ "metrics": [],
+ "interval_minutes": interval_minutes,
+ "hours_back": hours,
+ "total_buckets": 0,
+ }
+
+ entries = _load_entries_since(logs_dir, hours)
+ if not entries:
+ return {
+ "metrics": [],
+ "interval_minutes": interval_minutes,
+ "hours_back": hours,
+ "total_buckets": 0,
+ }
+
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
+ buckets: dict[str, dict[str, float]] = {}
+
+ for entry in entries:
+ insights = _analyze_entry(entry)
+ timestamp = insights.timestamp
+ if timestamp is None or timestamp < cutoff:
+ continue
+
+ bucket_time = timestamp.replace(
+ minute=(timestamp.minute // interval_minutes) * interval_minutes,
+ second=0,
+ microsecond=0,
+ )
+ bucket_key = bucket_time.isoformat()
+ bucket = buckets.setdefault(
+ bucket_key,
+ {
+ "total_requests": 0,
+ "successful_chat_completions": 0,
+ "failed_requests": 0,
+ "errors": 0,
+ "warnings": 0,
+ "payment_processed": 0,
+ "upstream_errors": 0,
+ "revenue_msats": 0.0,
+ "refunds_msats": 0.0,
+ },
+ )
+
+ if insights.level == "ERROR":
+ bucket["errors"] += 1
+ elif insights.level == "WARNING":
+ bucket["warnings"] += 1
+
+ if insights.event_total_request:
+ bucket["total_requests"] += 1
+ if insights.event_success:
+ bucket["successful_chat_completions"] += 1
+ bucket["revenue_msats"] += insights.revenue_msats
+ if insights.event_fail:
+ bucket["failed_requests"] += 1
+ bucket["refunds_msats"] += insights.refund_msats
+ if insights.event_payment_processed:
+ bucket["payment_processed"] += 1
+ if insights.event_upstream_error:
+ bucket["upstream_errors"] += 1
+
+ metrics = [
+ {"timestamp": key, **values}
+ for key, values in sorted(buckets.items(), key=lambda item: item[0])
+ ]
+
+ return {
+ "metrics": metrics,
+ "interval_minutes": interval_minutes,
+ "hours_back": hours,
+ "total_buckets": len(metrics),
+ }
+
+
+def summarize_usage(logs_dir: Path, hours: int) -> dict[str, Any]:
+ if not logs_dir.exists():
+ return _empty_summary()
+
+ entries = _load_entries_since(logs_dir, hours)
+ if not entries:
+ return _empty_summary()
+
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
+ total_entries = 0
+ total_requests = 0
+ successful = 0
+ failed = 0
+ total_errors = 0
+ total_warnings = 0
+ payment_processed = 0
+ upstream_errors = 0
+ revenue_msats = 0.0
+ refunds_msats = 0.0
+ unique_models: set[str] = set()
+ error_types: dict[str, int] = {}
+
+ for entry in entries:
+ insights = _analyze_entry(entry)
+ timestamp = insights.timestamp
+ if timestamp is None or timestamp < cutoff:
+ continue
+
+ total_entries += 1
+
+ if insights.level == "ERROR":
+ total_errors += 1
+ if insights.error_type:
+ error_types[insights.error_type] = (
+ error_types.get(insights.error_type, 0) + 1
+ )
+ elif insights.level == "WARNING":
+ total_warnings += 1
+
+ if insights.event_total_request:
+ total_requests += 1
+ if insights.event_success:
+ successful += 1
+ revenue_msats += insights.revenue_msats
+ if insights.event_fail:
+ failed += 1
+ refunds_msats += insights.refund_msats
+ if insights.event_payment_processed:
+ payment_processed += 1
+ if insights.event_upstream_error:
+ upstream_errors += 1
+
+ if insights.model:
+ unique_models.add(insights.model)
+
+ revenue_sats = revenue_msats / 1000
+ refunds_sats = refunds_msats / 1000
+ net_revenue_msats = revenue_msats - refunds_msats
+ net_revenue_sats = net_revenue_msats / 1000
+ success_rate = (successful / total_requests * 100) if total_requests else 0
+ refund_rate = (failed / total_requests * 100) if total_requests else 0
+ avg_revenue_per_request = (
+ revenue_msats / successful if successful else 0
+ )
+
+ return {
+ "total_entries": total_entries,
+ "total_requests": total_requests,
+ "successful_chat_completions": successful,
+ "failed_requests": failed,
+ "total_errors": total_errors,
+ "total_warnings": total_warnings,
+ "payment_processed": payment_processed,
+ "upstream_errors": upstream_errors,
+ "unique_models_count": len(unique_models),
+ "unique_models": sorted(unique_models),
+ "error_types": error_types,
+ "success_rate": success_rate,
+ "revenue_msats": revenue_msats,
+ "refunds_msats": refunds_msats,
+ "revenue_sats": revenue_sats,
+ "refunds_sats": refunds_sats,
+ "net_revenue_msats": net_revenue_msats,
+ "net_revenue_sats": net_revenue_sats,
+ "avg_revenue_per_request_msats": avg_revenue_per_request,
+ "refund_rate": refund_rate,
+ }
+
+
+def collect_error_details(
+ logs_dir: Path, hours: int, limit: int
+) -> dict[str, Any]:
+ if not logs_dir.exists():
+ return {"errors": [], "total_count": 0}
+
+ entries = _load_entries_since(logs_dir, hours)
+ if not entries:
+ return {"errors": [], "total_count": 0}
+
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
+ errors: list[dict[str, Any]] = []
+
+ for entry in entries:
+ level = str(entry.get("levelname", "")).upper()
+ if level != "ERROR":
+ continue
+
+ timestamp = _parse_timestamp(entry)
+ if timestamp is None or timestamp < cutoff:
+ continue
+
+ timestamp_str = str(entry.get("asctime", ""))
+ message = str(entry.get("message", ""))
+ error_type = str(entry.get("error_type", "unknown"))
+ pathname = str(entry.get("pathname", ""))
+ try:
+ lineno = int(entry.get("lineno", 0))
+ except (TypeError, ValueError):
+ lineno = 0
+ request_id = str(entry.get("request_id", ""))
+
+ errors.append(
+ {
+ "timestamp": timestamp_str,
+ "message": message,
+ "error_type": error_type,
+ "pathname": pathname,
+ "lineno": lineno,
+ "request_id": request_id,
+ }
+ )
+
+ errors.sort(key=lambda item: item["timestamp"], reverse=True)
+ return {"errors": errors[:limit], "total_count": len(errors)}
+
+
+def revenue_by_model(
+ logs_dir: Path, hours: int, limit: int
+) -> dict[str, Any]:
+ if not logs_dir.exists():
+ return {"models": [], "total_revenue_sats": 0, "total_models": 0}
+
+ entries = _load_entries_since(logs_dir, hours)
+ if not entries:
+ return {"models": [], "total_revenue_sats": 0, "total_models": 0}
+
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
+ model_stats: dict[str, dict[str, float | int]] = {}
+
+ for entry in entries:
+ insights = _analyze_entry(entry)
+ timestamp = insights.timestamp
+ if timestamp is None or timestamp < cutoff:
+ continue
+
+ model = insights.model or "unknown"
+ stats = model_stats.setdefault(
+ model,
+ {
+ "requests": 0,
+ "successful": 0,
+ "failed": 0,
+ "revenue_msats": 0.0,
+ "refunds_msats": 0.0,
+ },
+ )
+
+ if insights.event_total_request:
+ stats["requests"] += 1
+ if insights.event_success:
+ stats["successful"] += 1
+ stats["revenue_msats"] += insights.revenue_msats
+ if insights.event_fail:
+ stats["failed"] += 1
+ stats["refunds_msats"] += insights.refund_msats
+
+ models: list[dict[str, Any]] = []
+ total_revenue_sats = 0.0
+
+ for model, stats in model_stats.items():
+ revenue_msats = float(stats["revenue_msats"])
+ refunds_msats = float(stats["refunds_msats"])
+ requests = int(stats["requests"])
+ successful = int(stats["successful"])
+ failed = int(stats["failed"])
+
+ revenue_sats = revenue_msats / 1000
+ refunds_sats = refunds_msats / 1000
+ net_revenue_sats = revenue_sats - refunds_sats
+ total_revenue_sats += net_revenue_sats
+
+ avg_revenue = revenue_sats / successful if successful else 0
+ models.append(
+ {
+ "model": model,
+ "revenue_sats": revenue_sats,
+ "refunds_sats": refunds_sats,
+ "net_revenue_sats": net_revenue_sats,
+ "requests": requests,
+ "successful": successful,
+ "failed": failed,
+ "avg_revenue_per_request": avg_revenue,
+ }
+ )
+
+ models.sort(key=lambda item: item["net_revenue_sats"], reverse=True)
+
+ return {
+ "models": models[:limit],
+ "total_revenue_sats": total_revenue_sats,
+ "total_models": len(models),
+ }
+
+
+def _collect_log_files(
+ logs_dir: Path, date: str | None = None, limit: int | None = None
+) -> list[Path]:
+ if date:
+ candidate = logs_dir / f"{LOG_FILE_PREFIX}{date}{LOG_FILE_SUFFIX}"
+ return [candidate] if candidate.exists() else []
+
+ files = sorted(
+ logs_dir.glob(f"{LOG_FILE_PREFIX}*{LOG_FILE_SUFFIX}"),
+ key=lambda path: path.name,
+ reverse=True,
+ )
+ if limit is not None:
+ return files[:limit]
+ return files
+
+
+def _stream_log_entries(log_file: Path) -> Iterator[LogEntry]:
+ try:
+ with log_file.open("r") as handle:
+ for line in handle:
+ stripped = line.strip()
+ if not stripped:
+ continue
+ try:
+ data = json.loads(stripped)
+ if isinstance(data, dict):
+ yield data
+ except json.JSONDecodeError:
+ continue
+ except Exception:
+ return
+
+
+def _matches_filters(
+ entry: LogEntry,
+ level: str | None,
+ request_id: str | None,
+ search_lower: str | None,
+) -> bool:
+ if level and str(entry.get("levelname", "")).upper() != level:
+ return False
+
+ if request_id and entry.get("request_id") != request_id:
+ return False
+
+ if search_lower:
+ message = str(entry.get("message", "")).lower()
+ name = str(entry.get("name", "")).lower()
+ if search_lower not in message and search_lower not in name:
+ return False
+
+ return True
+
+
+def _extract_date_from_filename(log_file: Path) -> str | None:
+ name = log_file.stem
+ if not name.startswith(LOG_FILE_PREFIX):
+ return None
+ return name.replace(LOG_FILE_PREFIX, "", 1)
+
+
+def _parse_timestamp(entry: LogEntry) -> datetime | None:
+ raw = entry.get("asctime")
+ if not raw:
+ return None
+ try:
+ parsed = datetime.strptime(str(raw), LOG_TIME_FORMAT)
+ return parsed.replace(tzinfo=timezone.utc)
+ except ValueError:
+ return None
+
+
+def _load_entries_since(logs_dir: Path, hours: int) -> list[LogEntry]:
+ files = _collect_log_files(logs_dir)
+ if not files:
+ return []
+
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
+ cutoff_floor = cutoff.replace(hour=0, minute=0, second=0, microsecond=0)
+ entries: list[LogEntry] = []
+
+ for log_file in files:
+ file_date = _extract_date_from_filename(log_file)
+ if file_date:
+ try:
+ parsed_date = datetime.strptime(file_date, "%Y-%m-%d").replace(
+ tzinfo=timezone.utc
+ )
+ if parsed_date < cutoff_floor:
+ break
+ except ValueError:
+ pass
+
+ entries.extend(_stream_log_entries(log_file))
+
+ return entries
+
+
+def _analyze_entry(entry: LogEntry) -> EntryInsights:
+ message_raw = str(entry.get("message", ""))
+ message = message_raw.lower()
+ level = str(entry.get("levelname", "")).upper()
+ timestamp = _parse_timestamp(entry)
+ model_value = entry.get("model")
+ model = model_value if isinstance(model_value, str) else None
+ error_type = entry.get("error_type")
+ error_type_str = str(error_type) if error_type else None
+
+ event_total_request = "received proxy request" in message
+ event_success = "token adjustment completed" in message
+ event_fail = (
+ "upstream request failed" in message or "revert payment" in message
+ )
+ event_payment_processed = "payment processed successfully" in message
+ event_upstream_error = level == "ERROR" and "upstream" in message
+
+ revenue_msats = 0.0
+ if event_success:
+ cost_data = entry.get("cost_data")
+ if isinstance(cost_data, dict):
+ total_msats = cost_data.get("total_msats")
+ if isinstance(total_msats, (int, float)):
+ revenue_msats = float(total_msats)
+
+ refund_msats = 0.0
+ if "revert payment" in message:
+ max_cost = entry.get("max_cost_for_model")
+ if isinstance(max_cost, (int, float)):
+ refund_msats = float(max_cost)
+
+ return EntryInsights(
+ timestamp=timestamp,
+ level=level,
+ model=model,
+ event_total_request=event_total_request,
+ event_success=event_success,
+ event_fail=event_fail,
+ event_payment_processed=event_payment_processed,
+ event_upstream_error=event_upstream_error,
+ revenue_msats=revenue_msats,
+ refund_msats=refund_msats,
+ error_type=error_type_str,
+ )
+
+
+def _empty_summary() -> dict[str, Any]:
+ return {
+ "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_count": 0,
+ "unique_models": [],
+ "error_types": {},
+ "success_rate": 0,
+ "revenue_msats": 0,
+ "refunds_msats": 0,
+ "revenue_sats": 0,
+ "refunds_sats": 0,
+ "net_revenue_msats": 0,
+ "net_revenue_sats": 0,
+ "avg_revenue_per_request_msats": 0,
+ "refund_rate": 0,
+ }
diff --git a/routstr/proxy.py b/routstr/proxy.py
index ce558fc5..6bcfbb2d 100644
--- a/routstr/proxy.py
+++ b/routstr/proxy.py
@@ -141,6 +141,7 @@ async def proxy(
"unauthorized", "Unauthorized", 401, request=request
)
+ # IMPORTANT: Do not modify this log string; usage analytics depends on it.
logger.info( # TODO: move to middleware, async
"Received proxy request",
extra={
@@ -222,6 +223,7 @@ async def proxy(
if response.status_code != 200:
await revert_pay_for_request(key, session, max_cost_for_model)
+ # IMPORTANT: Do not modify this log string; analytics parses it for refunds.
logger.warning(
"Upstream request failed, revert payment",
extra={
diff --git a/routstr/upstream/base.py b/routstr/upstream/base.py
index 7af1be85..14784b5b 100644
--- a/routstr/upstream/base.py
+++ b/routstr/upstream/base.py
@@ -452,6 +452,7 @@ class BaseUpstreamProvider:
)
)
usage_finalized = True
+ # IMPORTANT: Do not modify this log string; analytics relies on the wording.
logger.info(
"Token adjustment completed for streaming",
extra={
@@ -555,6 +556,7 @@ class BaseUpstreamProvider:
)
response_json["cost"] = cost_data
+ # IMPORTANT: Do not modify this log string; analytics relies on the wording.
logger.info(
"Token adjustment completed for non-streaming",
extra={
diff --git a/ui/app/logs/log-details-dialog.tsx b/ui/app/logs/log-details-dialog.tsx
new file mode 100644
index 00000000..e92394cb
--- /dev/null
+++ b/ui/app/logs/log-details-dialog.tsx
@@ -0,0 +1,206 @@
+'use client';
+
+import { useState } from 'react';
+import { Copy, Check } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+
+import type { LogEntry } from './types';
+
+interface LogDetailsDialogProps {
+ log: LogEntry | null;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+const getLevelColor = (level: string): string => {
+ switch (level.toUpperCase()) {
+ case 'TRACE':
+ case 'DEBUG':
+ return 'bg-gray-100 text-gray-800 border-gray-200';
+ case 'INFO':
+ return 'bg-blue-100 text-blue-800 border-blue-200';
+ case 'WARNING':
+ return 'bg-yellow-100 text-yellow-800 border-yellow-200';
+ case 'ERROR':
+ return 'bg-red-100 text-red-800 border-red-200';
+ case 'CRITICAL':
+ return 'bg-purple-100 text-purple-800 border-purple-200';
+ default:
+ return 'bg-gray-100 text-gray-800 border-gray-200';
+ }
+};
+
+export function LogDetailsDialog({
+ log,
+ isOpen,
+ onClose,
+}: LogDetailsDialogProps) {
+ const [copiedField, setCopiedField] = useState(null);
+
+ if (!log) return null;
+
+ const copyToClipboard = (text: string, fieldName?: string) => {
+ if (typeof navigator === 'undefined') return;
+ void navigator.clipboard.writeText(text);
+ if (fieldName) {
+ setCopiedField(fieldName);
+ window.setTimeout(() => setCopiedField(null), 2000);
+ }
+ };
+
+ const standardFields = [
+ 'asctime',
+ 'name',
+ 'levelname',
+ 'message',
+ 'pathname',
+ 'lineno',
+ 'version',
+ 'request_id',
+ ];
+
+ const extraFields = Object.keys(log).filter(
+ (key) => key !== 'key' && !standardFields.includes(key)
+ );
+
+ return (
+
+ );
+}
diff --git a/ui/app/logs/log-entry-card.tsx b/ui/app/logs/log-entry-card.tsx
new file mode 100644
index 00000000..c52cd4c4
--- /dev/null
+++ b/ui/app/logs/log-entry-card.tsx
@@ -0,0 +1,117 @@
+'use client';
+
+import { Badge } from '@/components/ui/badge';
+import { Eye } from 'lucide-react';
+
+import type { LogEntry } from './types';
+
+interface LogEntryCardProps {
+ entry: LogEntry;
+ onClick: (entry: LogEntry) => void;
+}
+
+const getLevelColor = (level: string): string => {
+ switch (level.toUpperCase()) {
+ case 'TRACE':
+ case 'DEBUG':
+ return 'bg-gray-100 text-gray-800 border-gray-200';
+ case 'INFO':
+ return 'bg-blue-100 text-blue-800 border-blue-200';
+ case 'WARNING':
+ return 'bg-yellow-100 text-yellow-800 border-yellow-200';
+ case 'ERROR':
+ return 'bg-red-100 text-red-800 border-red-200';
+ case 'CRITICAL':
+ return 'bg-purple-100 text-purple-800 border-purple-200';
+ default:
+ return 'bg-gray-100 text-gray-800 border-gray-200';
+ }
+};
+
+export function LogEntryCard({ entry, onClick }: LogEntryCardProps) {
+ const extraFields = Object.keys(entry).filter(
+ (key) =>
+ ![
+ 'asctime',
+ 'name',
+ 'levelname',
+ 'message',
+ 'pathname',
+ 'lineno',
+ 'version',
+ 'request_id',
+ ].includes(key)
+ );
+
+ return (
+ onClick(entry)}
+ >
+
+
+
+ {entry.levelname ?? 'UNKNOWN'}
+
+
+ {entry.asctime ?? 'unknown'}
+
+
+ {entry.name ?? 'unknown'}
+
+
+
+
+ {entry.pathname ?? 'unknown'}:{entry.lineno ?? '-'}
+
+
+
+
+
+
+ {entry.message}
+
+
+ {entry.request_id && entry.request_id !== 'no-request-id' && (
+
+
+
+
+ Request ID: {entry.request_id}
+
+
+
+
+ )}
+
+ {extraFields.length > 0 && (
+
+
Additional Fields:
+
+ {extraFields.slice(0, 4).map((key) => {
+ const value = entry[key];
+ return (
+
+ {key}:{' '}
+
+ {typeof value === 'object'
+ ? JSON.stringify(value)
+ : String(value ?? '')}
+
+
+ );
+ })}
+ {extraFields.length > 4 && (
+
+ ...and {extraFields.length - 4} more fields
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/ui/app/logs/log-filters.tsx b/ui/app/logs/log-filters.tsx
new file mode 100644
index 00000000..cfa3dc72
--- /dev/null
+++ b/ui/app/logs/log-filters.tsx
@@ -0,0 +1,298 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import type { ChangeEvent, KeyboardEvent } from 'react';
+import { format } from 'date-fns';
+import { CalendarIcon, Filter, X } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Calendar } from '@/components/ui/calendar';
+import { cn } from '@/lib/utils';
+
+interface LogFiltersProps {
+ selectedDate: string;
+ selectedLevel: string;
+ requestId: string;
+ searchText: string;
+ limit: number;
+ onDateChange: (date: string) => void;
+ onLevelChange: (level: string) => void;
+ onRequestIdChange: (requestId: string) => void;
+ onSearchTextChange: (searchText: string) => void;
+ onLimitChange: (limit: number) => void;
+ onClearFilters: () => void;
+}
+
+const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
+const PRESET_LIMITS = ['25', '50', '100', '200', '500', '1000'];
+
+export function LogFilters({
+ selectedDate,
+ selectedLevel,
+ requestId,
+ searchText,
+ limit,
+ onDateChange,
+ onLevelChange,
+ onRequestIdChange,
+ onSearchTextChange,
+ onLimitChange,
+ onClearFilters,
+}: LogFiltersProps) {
+ const isPreset = PRESET_LIMITS.includes(String(limit));
+
+ const [customLimit, setCustomLimit] = useState(
+ isPreset ? '' : String(limit)
+ );
+ const [isCustom, setIsCustom] = useState(!isPreset);
+ const [date, setDate] = useState(
+ selectedDate && selectedDate !== 'all' ? new Date(selectedDate) : undefined
+ );
+
+ useEffect(() => {
+ const preset = PRESET_LIMITS.includes(String(limit));
+ setIsCustom(!preset);
+ if (!preset) {
+ setCustomLimit(String(limit));
+ }
+ }, [limit]);
+
+ useEffect(() => {
+ if (!selectedDate || selectedDate === 'all') {
+ setDate(undefined);
+ return;
+ }
+ try {
+ setDate(new Date(selectedDate));
+ } catch {
+ setDate(undefined);
+ }
+ }, [selectedDate]);
+
+ const handleLimitChange = (value: string) => {
+ if (value === 'custom') {
+ setIsCustom(true);
+ setCustomLimit(String(limit));
+ } else {
+ setIsCustom(false);
+ setCustomLimit('');
+ onLimitChange(Number(value));
+ }
+ };
+
+ const handleCustomLimitChange = (event: ChangeEvent) => {
+ setCustomLimit(event.target.value);
+ };
+
+ const handleCustomLimitApply = () => {
+ const parsed = parseInt(customLimit, 10);
+ if (!Number.isNaN(parsed) && parsed > 0) {
+ onLimitChange(parsed);
+ } else {
+ setIsCustom(false);
+ setCustomLimit('');
+ onLimitChange(100);
+ }
+ };
+
+ const handleCustomLimitKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ handleCustomLimitApply();
+ }
+ };
+
+ const handleDateSelect = (selected: Date | undefined) => {
+ setDate(selected);
+ if (selected) {
+ onDateChange(format(selected, 'yyyy-MM-dd'));
+ } else {
+ onDateChange('all');
+ }
+ };
+
+ return (
+
+
+
+
+ Filters
+
+
+ Filter logs by date, level, request ID, text search, and limit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {date && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ onRequestIdChange(event.target.value)}
+ />
+
+
+
+
+ onSearchTextChange(event.target.value)}
+ />
+
+
+
+
+ {isCustom ? (
+
+
+
+
+ ) : (
+
+ )}
+ {!isCustom && !isPreset && (
+
Custom: {limit}
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/app/logs/page.tsx b/ui/app/logs/page.tsx
new file mode 100644
index 00000000..0cdee136
--- /dev/null
+++ b/ui/app/logs/page.tsx
@@ -0,0 +1,178 @@
+'use client';
+
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { FileText, RefreshCw } from 'lucide-react';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { AdminService } from '@/lib/api/services/admin';
+
+import type { LogEntry } from './types';
+import { LogFilters } from './log-filters';
+import { LogEntryCard } from './log-entry-card';
+import { LogDetailsDialog } from './log-details-dialog';
+
+export default function LogsPage() {
+ const [selectedDate, setSelectedDate] = useState('all');
+ const [selectedLevel, setSelectedLevel] = useState('all');
+ const [requestId, setRequestId] = useState('');
+ const [searchText, setSearchText] = useState('');
+ const [limit, setLimit] = useState(100);
+ const [selectedLog, setSelectedLog] = useState(null);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const {
+ data: logsData,
+ refetch: refetchLogs,
+ isLoading,
+ } = useQuery({
+ queryKey: [
+ 'logs',
+ selectedDate,
+ selectedLevel,
+ requestId,
+ searchText,
+ limit,
+ ],
+ queryFn: () =>
+ AdminService.getLogs({
+ date: selectedDate === 'all' ? undefined : selectedDate,
+ level: selectedLevel === 'all' ? undefined : selectedLevel,
+ requestId: requestId || undefined,
+ search: searchText || undefined,
+ limit,
+ }),
+ refetchInterval: 30_000,
+ });
+
+ const handleClearFilters = () => {
+ setSelectedDate('all');
+ setSelectedLevel('all');
+ setRequestId('');
+ setSearchText('');
+ setLimit(100);
+ };
+
+ const handleLogClick = (entry: LogEntry) => {
+ setSelectedLog(entry);
+ setIsDialogOpen(true);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ System Logs
+
+
+ View and filter application logs
+
+
+
+
+
+
+
+
+
+
+ Log Entries
+ {logsData && (
+
+ {logsData.logs.length} entries
+
+ )}
+
+ {(selectedDate !== 'all' ||
+ selectedLevel !== 'all' ||
+ requestId ||
+ searchText) && (
+
+ Showing logs
+ {selectedDate !== 'all' && ` for ${selectedDate}`}
+ {selectedLevel !== 'all' && ` with level ${selectedLevel}`}
+ {requestId && ` with request ID ${requestId}`}
+ {searchText && ` matching "${searchText}"`}
+
+ )}
+
+
+ {isLoading ? (
+
+
+
+ Loading logs...
+
+
+ ) : logsData?.logs && logsData.logs.length > 0 ? (
+
+
+ {logsData.logs.map((entry, index) => (
+
+ ))}
+
+
+ ) : (
+
+
+
No log entries found
+
+ Try adjusting your filters or check back later
+
+
+ )}
+
+
+
+
setIsDialogOpen(false)}
+ />
+
+
+
+ );
+}
diff --git a/ui/app/logs/types.ts b/ui/app/logs/types.ts
new file mode 100644
index 00000000..5ea97ae4
--- /dev/null
+++ b/ui/app/logs/types.ts
@@ -0,0 +1,3 @@
+import type { LogEntry, LogsResponse } from '@/lib/api/services/admin';
+
+export type { LogEntry, LogsResponse };
diff --git a/ui/app/usage/page.tsx b/ui/app/usage/page.tsx
new file mode 100644
index 00000000..e66123cf
--- /dev/null
+++ b/ui/app/usage/page.tsx
@@ -0,0 +1,267 @@
+'use client';
+
+import { useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { RefreshCw } from 'lucide-react';
+
+import { AppSidebar } from '@/components/app-sidebar';
+import { SiteHeader } from '@/components/site-header';
+import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
+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 { AdminService } from '@/lib/api/services/admin';
+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';
+
+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, 10), parseInt(timeRange, 10)),
+ refetchInterval: 60_000,
+ staleTime: 30_000,
+ });
+
+ const {
+ data: summaryData,
+ isLoading: summaryLoading,
+ refetch: refetchSummary,
+ } = useQuery({
+ queryKey: ['usage-summary', timeRange],
+ queryFn: () => AdminService.getUsageSummary(parseInt(timeRange, 10)),
+ refetchInterval: 60_000,
+ staleTime: 30_000,
+ });
+
+ const {
+ data: errorData,
+ isLoading: errorLoading,
+ refetch: refetchErrors,
+ } = useQuery({
+ queryKey: ['usage-errors', timeRange],
+ queryFn: () => AdminService.getErrorDetails(parseInt(timeRange, 10), 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, 10), 20),
+ refetchInterval: 60_000,
+ staleTime: 30_000,
+ });
+
+ const handleRefresh = () => {
+ refetchMetrics();
+ refetchSummary();
+ refetchErrors();
+ refetchRevenueByModel();
+ };
+
+ return (
+
+
+
+
+
+
+
+
Usage Tracking
+
+ Monitor system usage, requests, and errors over time
+
+
+
+
+
+
+
+
+
+
+ {summaryLoading ? (
+
Loading summary...
+ ) : summaryData ? (
+
+ ) : null}
+
+
+ {metricsLoading ? (
+
Loading metrics...
+ ) : metricsData && metricsData.metrics.length > 0 ? (
+ <>
+
& { 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' },
+ ]}
+ />
+ ({
+ ...metric,
+ revenue_sats: metric.revenue_msats / 1000,
+ refunds_sats: metric.refunds_msats / 1000,
+ net_revenue_sats:
+ (metric.revenue_msats - metric.refunds_msats) / 1000,
+ })) as Array & { 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' },
+ ]}
+ />
+ & { 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' },
+ ]}
+ />
+ & { timestamp: string }>}
+ title='Payment Activity'
+ dataKeys={[
+ {
+ key: 'payment_processed',
+ name: 'Payments Processed',
+ color: '#6366f1',
+ },
+ ]}
+ />
+ >
+ ) : (
+
+
+ No Data Available
+
+
+
+ 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.
+
+
+
+ )}
+
+
+ {revenueByModelLoading ? (
+
Loading revenue by model...
+ ) : revenueByModelData && revenueByModelData.models.length > 0 ? (
+
+ ) : null}
+
+ {errorLoading ? (
+
Loading errors...
+ ) : errorData ? (
+
+ ) : null}
+
+ {summaryData && summaryData.unique_models.length > 0 && (
+
+
+ Active Models
+
+
+
+ {summaryData.unique_models.map((model) => (
+
+ {model}
+
+ ))}
+
+
+
+ )}
+
+ {summaryData &&
+ summaryData.error_types &&
+ Object.keys(summaryData.error_types).length > 0 && (
+
+
+ Error Types Distribution
+
+
+
+ {Object.entries(summaryData.error_types)
+ .sort(([, a], [, b]) => (b as number) - (a as number))
+ .map(([type, count]) => (
+
+ {type}
+
+ {count}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/components/app-sidebar.tsx b/ui/components/app-sidebar.tsx
index 5ec96ef5..73c3f6b4 100644
--- a/ui/components/app-sidebar.tsx
+++ b/ui/components/app-sidebar.tsx
@@ -2,7 +2,9 @@
import * as React from 'react';
import {
+ ActivityIcon,
DatabaseIcon,
+ FileTextIcon,
LayoutDashboardIcon,
ServerIcon,
SettingsIcon,
@@ -39,6 +41,16 @@ const data = {
url: '/model',
icon: DatabaseIcon,
},
+ {
+ title: 'Usage',
+ url: '/usage',
+ icon: ActivityIcon,
+ },
+ {
+ title: 'Logs',
+ url: '/logs',
+ icon: FileTextIcon,
+ },
{
title: 'Providers',
url: '/providers',
diff --git a/ui/components/error-details-table.tsx b/ui/components/error-details-table.tsx
new file mode 100644
index 00000000..5846659e
--- /dev/null
+++ b/ui/components/error-details-table.tsx
@@ -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 (
+
+
+ Recent Errors
+
+
+
+ No errors found in the selected time period
+
+
+
+ );
+ }
+
+ return (
+
+
+ Recent Errors ({errors.length})
+
+
+
+
+
+
+ Timestamp
+ Type
+ Message
+ Location
+ Request ID
+
+
+
+ {errors.map((error, index) => (
+
+
+ {error.timestamp}
+
+
+ {error.error_type}
+
+
+ {error.message}
+
+
+ {error.pathname}:{error.lineno}
+
+
+ {error.request_id || '-'}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/ui/components/revenue-by-model-table.tsx b/ui/components/revenue-by-model-table.tsx
new file mode 100644
index 00000000..c0d0d6d4
--- /dev/null
+++ b/ui/components/revenue-by-model-table.tsx
@@ -0,0 +1,95 @@
+'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 (
+
+
+ Revenue by Model
+
+ Total Revenue:{' '}
+ {totalRevenue.toLocaleString(undefined, {
+ maximumFractionDigits: 2,
+ })}{' '}
+ sats
+
+
+
+
+
+
+ Model
+ Requests
+ Successful
+ Failed
+ Revenue (sats)
+ Refunds (sats)
+ Net Revenue (sats)
+ Avg/Request
+
+
+
+ {models.length === 0 ? (
+
+
+ No model data available
+
+
+ ) : (
+ models.map((model) => (
+
+ {model.model}
+ {model.requests}
+
+ {model.successful}
+
+
+ {model.failed}
+
+
+ {model.revenue_sats.toLocaleString(undefined, {
+ maximumFractionDigits: 2,
+ })}
+
+
+ {model.refunds_sats.toLocaleString(undefined, {
+ maximumFractionDigits: 2,
+ })}
+
+
+ {model.net_revenue_sats.toLocaleString(undefined, {
+ maximumFractionDigits: 2,
+ })}
+
+
+ {model.avg_revenue_per_request.toLocaleString(undefined, {
+ maximumFractionDigits: 3,
+ })}
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/ui/components/ui/calendar.tsx b/ui/components/ui/calendar.tsx
new file mode 100644
index 00000000..eadcef82
--- /dev/null
+++ b/ui/components/ui/calendar.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import * as React from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { DayPicker } from 'react-day-picker';
+
+import { cn } from '@/lib/utils';
+import { buttonVariants } from '@/components/ui/button';
+
+export type CalendarProps = React.ComponentProps;
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ {
+ if (orientation === 'left') {
+ return ;
+ }
+ return ;
+ },
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = 'Calendar';
+
+export { Calendar };
diff --git a/ui/components/usage-metrics-chart.tsx b/ui/components/usage-metrics-chart.tsx
new file mode 100644
index 00000000..294d5e56
--- /dev/null
+++ b/ui/components/usage-metrics-chart.tsx
@@ -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 & { 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 (
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ {dataKeys.map((dataKey) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/ui/components/usage-summary-cards.tsx b/ui/components/usage-summary-cards.tsx
new file mode 100644
index 00000000..e395ebed
--- /dev/null
+++ b/ui/components/usage-summary-cards.tsx
@@ -0,0 +1,121 @@
+'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 (
+
+ {cards.map((card) => (
+
+
+ {card.title}
+
+
+
+ {card.value}
+
+
+ ))}
+
+ );
+}
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts
index 9645a6a1..435393cb 100644
--- a/ui/lib/api/services/admin.ts
+++ b/ui/lib/api/services/admin.ts
@@ -802,6 +802,68 @@ export class AdminService {
'/admin/api/temporary-balances'
);
}
+
+ static async getLogs(params: {
+ date?: string;
+ level?: string;
+ requestId?: string;
+ search?: string;
+ limit?: number;
+ }): Promise {
+ const query: Record = {
+ date: params.date,
+ level: params.level,
+ request_id: params.requestId,
+ search: params.search,
+ limit: params.limit,
+ };
+ Object.keys(query).forEach((key) => {
+ if (query[key] === undefined || query[key] === '') {
+ delete query[key];
+ }
+ });
+ return await apiClient.get('/admin/api/logs', query);
+ }
+
+ static async getLogDates(): Promise {
+ const response = await apiClient.get<{ dates: string[] }>(
+ '/admin/api/logs/dates'
+ );
+ return response.dates;
+ }
+
+ static async getUsageMetrics(
+ interval: number = 15,
+ hours: number = 24
+ ): Promise {
+ return await apiClient.get(
+ `/admin/api/usage/metrics?interval=${interval}&hours=${hours}`
+ );
+ }
+
+ static async getUsageSummary(hours: number = 24): Promise {
+ return await apiClient.get(
+ `/admin/api/usage/summary?hours=${hours}`
+ );
+ }
+
+ static async getErrorDetails(
+ hours: number = 24,
+ limit: number = 100
+ ): Promise {
+ return await apiClient.get(
+ `/admin/api/usage/error-details?hours=${hours}&limit=${limit}`
+ );
+ }
+
+ static async getRevenueByModel(
+ hours: number = 24,
+ limit: number = 20
+ ): Promise {
+ return await apiClient.get(
+ `/admin/api/usage/revenue-by-model?hours=${hours}&limit=${limit}`
+ );
+ }
}
export const TemporaryBalanceSchema = z.object({
@@ -814,3 +876,100 @@ export const TemporaryBalanceSchema = z.object({
});
export type TemporaryBalance = z.infer;
+
+export interface LogEntry {
+ asctime?: string;
+ name?: string;
+ levelname?: string;
+ message?: string;
+ pathname?: string;
+ lineno?: number;
+ version?: string;
+ request_id?: string;
+ [key: string]: unknown;
+}
+
+export interface LogsResponse {
+ logs: LogEntry[];
+ total: number;
+ date: string | null;
+ level: string | null;
+ request_id: string | null;
+ search: string | null;
+ limit: number;
+}
+
+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 UsageMetrics {
+ metrics: UsageMetricData[];
+ interval_minutes: number;
+ hours_back: number;
+ total_buckets: number;
+}
+
+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;
+ 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;
+}