Files
routstr-core/routstr/core/logging.py
T
9qeklajc c2b807bb20 fix: keep spendable credentials out of the structured logs
The dated JSON log files were a credential store. Structured `extra`
fields bypass the message-level regex scrubbing entirely — the JSON
formatter reads them straight off the record dict — and the only pass
they received was organization-ID redaction. So a full Cashu refund
token, and the `hashed_key` that `sk-<hashed_key>` auth accepts as a
live reusable API key, were written verbatim, as were all request query
values. Anyone able to read a log file could spend from it.

Fixed at both ends so neither alone is load-bearing. The call sites stop
handing over the values: balance logs a token length and an eight-char
key prefix, and the request middleware logs query parameter names
without their values. The SecurityFilter then refuses to emit them
anyway, walking every extra recursively and stripping both secret-shaped
keys and secret-shaped values (Cashu tokens, bearer values, `sk-` keys,
nsec keys, full SHA-256 hashes, secret-ish query parameters) wherever
they are nested. The walk is depth-limited and cycle-safe so a malformed
payload degrades to `[REDACTED]` instead of failing the log call, and
numeric values are never touched, which keeps the usage-analytics fields
the dashboard parses intact.

Retention is also wired up: `backupCount` never expired anything here
because the base filename moves with the date, so the inherited rollover
found no siblings and every day of logs was kept forever. Rollover now
prunes explicitly.
2026-08-24 01:48:16 +02:00

459 lines
16 KiB
Python

"""
Logging configuration for Routstr.
CRITICAL LOG MESSAGES FOR USAGE STATISTICS:
===========================================
The following log messages are parsed by the usage tracking system
(routstr/core/usage_analytics_store.py and routstr/core/log_manager.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. "Calculated token-based cost" (INFO) - routstr/auth.py
- Used to track successful completions and revenue
- The 'token_cost', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
3. "Max cost payment finalized" (INFO) - routstr/auth.py
- Used as the successful completion fallback when token usage is unavailable
- The 'charged_amount', 'model', 'input_tokens', and 'output_tokens' fields are extracted for dashboard metrics
4. "Payment processed successfully" (INFO) - routstr/auth.py
- Used to count successful payment processing events
- Tracks payment-related metrics
5. "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
6. 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/usage_analytics_store.py
- routstr/core/log_manager.py
"""
import logging.config
import logging.handlers
import os
import re
import sys
import tomllib
from datetime import datetime
from pathlib import Path
from typing import Any
from pythonjsonlogger import jsonlogger
from rich.console import Console
from rich.logging import RichHandler
from .redaction import redact_field, redact_org_ids
# Only use RichHandler when stdout is a real TTY. In non-TTY contexts
# (docker logs, pipes, CI) Rich pads every line to width and wraps long
# records, producing visually-empty trailing whitespace and split records.
# A plain StreamHandler avoids both problems.
_stdout_is_tty = sys.stdout.isatty()
_console = Console(soft_wrap=True) if _stdout_is_tty else None
# Define custom TRACE level
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
def trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
"""Log with TRACE level"""
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, message, args, **kwargs)
# Add the trace method to Logger class
setattr(logging.Logger, "trace", trace)
class DailyRotatingFileHandler(logging.handlers.TimedRotatingFileHandler):
"""Custom TimedRotatingFileHandler that creates date-based filenames."""
def __init__(self, filename: str, **kwargs: Any) -> None:
"""Initialize with a base filename pattern."""
self.base_dir = os.path.dirname(filename)
self.base_name = os.path.basename(filename).replace(".log", "")
today = datetime.now().strftime("%Y-%m-%d")
self.current_date = today
dated_filename = os.path.join(self.base_dir, f"{self.base_name}_{today}.log")
super().__init__(dated_filename, **kwargs)
def doRollover(self) -> None:
"""Override rollover to create new date-based filename."""
if self.stream:
self.stream.close()
new_date = datetime.now().strftime("%Y-%m-%d")
new_filename = os.path.join(self.base_dir, f"{self.base_name}_{new_date}.log")
self.baseFilename = new_filename
self.current_date = new_date
# `backupCount` alone never prunes these files: the base filename moves
# with the date, so the inherited rollover finds no siblings to expire
# and every day of logged credentials is retained indefinitely.
self._cleanup_old_files()
if not self.delay:
self.stream = self._open()
def _cleanup_old_files(self) -> None:
"""Remove old log files beyond backupCount."""
if self.backupCount > 0:
log_files = []
if os.path.exists(self.base_dir):
for file in os.listdir(self.base_dir):
if file.startswith(f"{self.base_name}_") and file.endswith(".log"):
file_path = os.path.join(self.base_dir, file)
log_files.append((file_path, os.path.getmtime(file_path)))
log_files.sort(key=lambda x: x[1], reverse=True)
for file_path, _ in log_files[self.backupCount :]:
try:
os.remove(file_path)
except OSError:
pass
def get_package_version() -> str:
"""Read the package version from pyproject.toml."""
try:
# Find project root by looking for pyproject.toml
current_path = Path(__file__).parent
while current_path != current_path.parent:
pyproject_path = current_path / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
pyproject_data = tomllib.load(f)
version = pyproject_data.get("project", {}).get("version", "unknown")
return version
current_path = current_path.parent
# Fallback: try the simple path resolution (3 levels up for routstr/logging/logging_config.py)
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
with open(pyproject_path, "rb") as f:
pyproject_data = tomllib.load(f)
version = pyproject_data.get("project", {}).get("version", "unknown")
return version
return "unknown"
except Exception:
return "unknown"
class VersionFilter(logging.Filter):
"""Filter to add package version to all log records."""
def __init__(self) -> None:
super().__init__()
self.version = get_package_version()
def filter(self, record: logging.LogRecord) -> bool:
"""Add version information to the log record."""
record.version = self.version
return True
class RequestIdFilter(logging.Filter):
"""Filter to add request ID to all log records."""
def filter(self, record: logging.LogRecord) -> bool:
"""Add request ID to the log record if available."""
try:
# Import here to avoid circular imports
from .middleware import request_id_context
request_id = request_id_context.get(None)
record.request_id = request_id if request_id else "no-request-id"
except ImportError:
# If middleware isn't available yet, just use default
record.request_id = "no-request-id"
return True
# Standard ``LogRecord`` attributes that are never user-supplied ``extra``
# fields; skipped when redacting structured extras (``msg``/``message`` are
# handled separately above).
_NON_EXTRA_RECORD_ATTRS = frozenset(
{
"name",
"msg",
"args",
"levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"taskName",
"message",
}
)
class SecurityFilter(logging.Filter):
"""Filter to remove sensitive information from logs."""
SENSITIVE_KEYS = {
"authorization",
"x-cashu",
"bearer",
"token",
"key",
"secret",
"password",
"cashu_token",
"bearer_key",
"api_key",
"nsec",
"upstream_api_key",
"refund_address",
}
def filter(self, record: logging.LogRecord) -> bool:
"""Filter out sensitive information from log records."""
try:
message = record.getMessage()
message = redact_org_ids(message)
standalone_patterns = [
r"Bearer\s+([a-zA-Z0-9_\-\.]{10,})", # Bearer token (must be 10 characters or more to reduce false-positives)
r"cashu[A-Z]+([a-zA-Z0-9_\-\.=/+]+)", # Cashu tokens
r"nsec[a-z0-9]+", # Nostr Public / Private Key
]
for pattern in standalone_patterns:
message = re.sub(pattern, "[REDACTED]", message, flags=re.IGNORECASE)
for key in self.SENSITIVE_KEYS:
if key in message.lower():
key_patterns = [
rf"{key}\s*[:=]\s*([a-zA-Z0-9_\-\.=/+]+)", # key:value or key=value (including any variant with spaces)
rf'{key}\s*[:=]\s*["\']([^"\']+)["\']', # key:"value" or key='value' (including any variant with spaces)
]
for pattern in key_patterns:
message = re.sub(
pattern, f"{key}: [REDACTED]", message, flags=re.IGNORECASE
)
record.msg = message
record.args = ()
# Structured `extra={...}` fields are emitted by the JSON formatter
# straight from the record dict and never pass through the message
# formatting above, so they need their own recursive pass.
for attr, value in list(record.__dict__.items()):
if attr in _NON_EXTRA_RECORD_ATTRS:
continue
record.__dict__[attr] = redact_field(attr, value)
except Exception:
pass
return True
def get_log_level() -> str:
"""Get log level from environment variable."""
try:
from .settings import settings
level = settings.log_level.upper()
except Exception:
level = os.environ.get("LOG_LEVEL", "INFO").upper()
# Validate log level - if invalid, default to INFO
valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if level not in valid_levels:
level = "INFO"
return level
def should_enable_console_logging() -> bool:
"""Check if console logging should be enabled."""
try:
from .settings import settings
return bool(settings.enable_console_logging)
except Exception:
return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in (
"true",
"1",
"yes",
)
def setup_logging() -> None:
"""Configure centralized logging for the application."""
log_level = get_log_level()
console_enabled = should_enable_console_logging()
# Determine which handlers to use
handlers = ["file"]
if console_enabled:
handlers.append("console")
if _stdout_is_tty:
console_handler: dict[str, Any] = {
"()": RichHandler,
"level": log_level,
"show_time": False,
"show_path": False,
"rich_tracebacks": True,
"markup": True,
"console": _console,
"filters": ["request_id_filter", "security_filter"],
}
else:
console_handler = {
"class": "logging.StreamHandler",
"level": log_level,
"formatter": "plain",
"stream": "ext://sys.stdout",
"filters": ["request_id_filter", "security_filter"],
}
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": jsonlogger.JsonFormatter,
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d %(version)s %(request_id)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"plain": {
"format": "%(asctime)s %(levelname)-7s %(name)s %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"version_filter": {"()": VersionFilter},
"request_id_filter": {"()": RequestIdFilter},
"security_filter": {"()": SecurityFilter},
},
"handlers": {
"console": console_handler,
"file": {
"()": DailyRotatingFileHandler,
"level": log_level,
"formatter": "json",
"filename": "logs/app.log",
"when": "midnight", # Rotate at midnight each day
"interval": 1, # Every 1 day
"backupCount": 30, # Keep 30 days of logs
"atTime": None, # Rotate at midnight (00:00)
"filters": ["version_filter", "request_id_filter", "security_filter"],
},
},
"loggers": {
"routstr": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.payment": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.proxy": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.auth": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.payment.models": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.core.exceptions": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"routstr.core.middleware": {
"level": log_level,
"handlers": ["file"],
"propagate": False,
},
# Suppress verbose third-party logging
"httpx": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"openai": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"httpcore": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
"websockets": {
"level": "WARNING",
"handlers": [],
"propagate": False,
},
"uvicorn.access": {
"level": "WARNING",
"handlers": ["file"],
"propagate": False,
},
"uvicorn.error": {
"level": log_level,
"handlers": handlers,
"propagate": False,
},
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
"aiosqlite": {"level": "ERROR", "handlers": [], "propagate": False},
"alembic": {
"level": "WARNING",
"handlers": ["console"] if console_enabled else [],
"propagate": False,
},
},
"root": {
"level": log_level,
"handlers": ["console"] if console_enabled else [],
},
}
os.makedirs("logs", exist_ok=True)
logging.config.dictConfig(LOGGING_CONFIG)
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance for the given module name."""
return logging.getLogger(name)