Compare commits

...

1 Commits

Author SHA1 Message Date
Paperclip Deployment Engineer
7ffebe6d91 sentrystr stuff 2026-06-27 20:55:38 +00:00
5 changed files with 191 additions and 22 deletions

View File

@@ -34,6 +34,14 @@ UPSTREAM_API_KEY=your-upstream-api-key
# LOG_LEVEL=INFO
# ENABLE_CONSOLE_LOGGING=true
# SentryStr: mirror logs to Nostr relays (requires `pip install sentrystr`).
# Announcements run on a background worker thread so they never block requests.
# ENABLE_SENTRYSTR=false
# SENTRYSTR_LOG_LEVEL=ERROR # defaults to ERROR; keep >= LOG_LEVEL
# SENTRYSTR_RELAYS="wss://relay.damus.io,wss://nos.lol" # defaults to RELAYS
# SENTRYSTR_NSEC=nsec1... # defaults to NSEC; ephemeral key if unset
# SENTRYSTR_RECIPIENT_NPUB=npub1... # if set, log events are NIP-44 encrypted
# Custom Model Management
# BASE_URL=https://openrouter.ai/api/v1
# MODELS_PATH=models.json

View File

@@ -23,6 +23,11 @@ dependencies = [
"litellm>=1.55.0",
]
[project.optional-dependencies]
# Decentralized log mirroring to Nostr relays. Enable at runtime with
# ENABLE_SENTRYSTR=true (see routstr/core/logging.py).
sentrystr = ["sentrystr>=0.2.0"]
[dependency-groups]
dev = [
"mypy>=1.15.0",

View File

@@ -232,32 +232,47 @@ class SecurityFilter(logging.Filter):
"refund_address",
}
def _scrub(self, message: str) -> str:
"""Redact secrets from a single string."""
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
)
return message
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
message = redact_org_ids(record.getMessage())
record.msg = self._scrub(message)
record.args = ()
# The exception traceback is appended by the *formatter* (e.g. when a
# QueueHandler flattens the record before it is announced to the
# public, permanent SentryStr relays), so the message scrub above
# never sees it. Pre-render and redact it here and cache the result
# in `exc_text` so every formatter reuses the scrubbed version.
if record.exc_info and record.exc_info[0] is not None and not record.exc_text:
record.exc_text = logging.Formatter().formatException(record.exc_info)
if record.exc_text:
record.exc_text = self._scrub(redact_org_ids(record.exc_text))
if record.stack_info:
record.stack_info = self._scrub(redact_org_ids(record.stack_info))
# Structured `extra={...}` fields are emitted by the JSON formatter
# straight from the record dict and never pass through the message
# formatting above. Redact organization IDs from any string-valued
@@ -452,6 +467,120 @@ def setup_logging() -> None:
logging.config.dictConfig(LOGGING_CONFIG)
_install_sentrystr_logging(LOGGING_CONFIG)
# Handle for the background SentryStr logging worker (None when disabled).
_sentrystr_handle: Any = None
# Bound on the in-memory queue feeding the background SentryStr worker. Records
# beyond this are dropped rather than blocking the caller or growing memory
# without limit when a relay is slow or unreachable.
_SENTRYSTR_QUEUE_SIZE = 10_000
# Max seconds to wait for the queued backlog to flush on shutdown before
# abandoning it, so shutdown cannot hang on a slow or unreachable relay.
_SENTRYSTR_SHUTDOWN_TIMEOUT = 10.0
def _install_sentrystr_logging(logging_config: dict[str, Any]) -> None:
"""Mirror routstr application logs to Nostr relays via SentryStr.
Announcing to relays blocks, so SentryStr's installer runs the publish on a
dedicated background worker thread fed by an in-memory queue; the request /
event-loop threads only pay the cost of enqueueing a record. Disabled unless
`ENABLE_SENTRYSTR` is set. Any failure here is swallowed so optional logging
can never take down startup.
"""
global _sentrystr_handle
if _sentrystr_handle is not None:
return
try:
from .settings import settings
except Exception:
return
if not getattr(settings, "enable_sentrystr", False):
return
routstr_logger = logging.getLogger("routstr")
relays = list(settings.sentrystr_relays or settings.relays or [])
if not relays:
routstr_logger.warning(
"SentryStr logging is enabled but no relays are configured; set "
"SENTRYSTR_RELAYS or RELAYS. Skipping SentryStr logging."
)
return
try:
from sentrystr import install_sentrystr_logging
except Exception:
routstr_logger.warning(
"SentryStr logging is enabled but the `sentrystr` package is not "
"installed. Install it with `pip install sentrystr`. Skipping "
"SentryStr logging."
)
return
# Default to ERROR: these events are announced to public, permanent relays,
# so only genuine problems should be mirrored unless the operator opts into
# something more verbose via SENTRYSTR_LOG_LEVEL. (Keep this at or above
# LOG_LEVEL; a lower value is gated out by the routstr loggers' own level.)
level = (settings.sentrystr_log_level or "ERROR").upper()
# routstr application loggers set propagate=False, so the handler must be
# attached to each of them rather than relying on propagation to the root.
routstr_loggers = [
name
for name in logging_config.get("loggers", {})
if name == "routstr" or name.startswith("routstr.")
] or ["routstr"]
try:
_sentrystr_handle = install_sentrystr_logging(
relays=relays,
private_key=(settings.sentrystr_nsec or settings.nsec or None),
level=level,
loggers=routstr_loggers,
recipient_pubkey=(settings.sentrystr_recipient_npub or None),
platform="routstr",
formatter=logging.Formatter("%(message)s"),
# Run the same scrubbing/enrichment filters used by the other
# handlers, on the calling thread, before records are handed to the
# background worker. RequestIdFilter must run here because it reads a
# ContextVar that is only set on the request thread; SecurityFilter
# must run here so secrets are redacted before anything is announced
# to the (public, permanent) relays. Passing them to the installer
# attaches them before the handler goes live, so no record can slip
# through unfiltered during startup.
filters=[RequestIdFilter(), SecurityFilter()],
# Bound the queue so a slow/unreachable relay can't grow memory.
queue_size=_SENTRYSTR_QUEUE_SIZE,
)
except Exception as exc:
routstr_logger.warning("Failed to initialize SentryStr logging: %s", exc)
return
routstr_logger.info(
"SentryStr logging enabled",
extra={"sentrystr_level": level, "sentrystr_relay_count": len(relays)},
)
def shutdown_sentrystr_logging() -> None:
"""Flush and stop the background SentryStr logging worker, if running."""
global _sentrystr_handle
handle = _sentrystr_handle
_sentrystr_handle = None
if handle is not None:
try:
handle.close(timeout=_SENTRYSTR_SHUTDOWN_TIMEOUT)
except Exception:
pass
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance for the given module name."""

View File

@@ -230,6 +230,13 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
extra={"error": str(e), "error_type": type(e).__name__},
)
# Flush and stop the background SentryStr logging worker (no-op when
# SentryStr logging is disabled). Run off the event loop: the drain is
# blocking (though bounded) and must not stall the loop during shutdown.
from .logging import shutdown_sentrystr_logging
await asyncio.to_thread(shutdown_sentrystr_logging)
class _ImmutableStaticFiles(StaticFiles):
"""Static files with long Cache-Control for content-hashed Next.js assets.

View File

@@ -16,7 +16,12 @@ class Settings(BaseSettings):
@classmethod
def parse_env_var(cls, field_name: str, raw_value: str) -> Any: # type: ignore[override]
if field_name in {"cashu_mints", "cors_origins", "relays"}:
if field_name in {
"cashu_mints",
"cors_origins",
"relays",
"sentrystr_relays",
}:
v = str(raw_value).strip()
if v == "":
return []
@@ -103,6 +108,21 @@ class Settings(BaseSettings):
log_level: str = Field(default="INFO", env="LOG_LEVEL")
enable_console_logging: bool = Field(default=True, env="ENABLE_CONSOLE_LOGGING")
# SentryStr (decentralized logging to Nostr relays). When enabled, routstr
# application logs at `sentrystr_log_level` and above are mirrored to Nostr
# relays on a background worker thread (see routstr/core/logging.py).
enable_sentrystr: bool = Field(default=False, env="ENABLE_SENTRYSTR")
# Minimum level forwarded to relays. Empty -> defaults to ERROR. Should be
# at or above `log_level`, since the routstr loggers gate lower records.
sentrystr_log_level: str = Field(default="", env="SENTRYSTR_LOG_LEVEL")
# Relays to announce to. Empty -> fall back to the discovery `relays`.
sentrystr_relays: list[str] = Field(default_factory=list, env="SENTRYSTR_RELAYS")
# Secret key (nsec or hex) used to sign log events. Empty -> fall back to
# `nsec`; if that is also empty an ephemeral key is generated per process.
sentrystr_nsec: str = Field(default="", env="SENTRYSTR_NSEC")
# Optional recipient npub. When set, log events are NIP-44 encrypted to it.
sentrystr_recipient_npub: str = Field(default="", env="SENTRYSTR_RECIPIENT_NPUB")
# Other
chat_completions_api_version: str = Field(
default="", env="CHAT_COMPLETIONS_API_VERSION"