mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
This commit introduces new admin dashboard pages for viewing system logs and usage statistics. It includes backend API endpoints for fetching log data, usage metrics, error details, and revenue by model. The frontend has been updated with new UI components and pages to display this information. Additionally, important log messages used for analytics have been marked to prevent accidental modification. Co-authored-by: db2002dominic <db2002dominic@gmail.com>
555 lines
16 KiB
Python
555 lines
16 KiB
Python
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,
|
|
}
|