Skip to content

Commit ad543d9

Browse files
committed
feat: add logs feature with internationalization support
- Introduced a new logs view for admin users, allowing real-time monitoring of backend logs. - Implemented a log entry streaming mechanism using Socket.IO. - Added filtering and searching capabilities for log entries. - Created localized log messages in Spanish, French, Hungarian, Italian, Japanese, Korean, Polish, Portuguese, Romanian, Russian, Simplified Chinese, and Traditional Chinese. - Updated router and sidebar components to include the new logs route. - Enhanced user interface with tooltips and buttons for copying and downloading logs.
1 parent fcccb5d commit ad543d9

47 files changed

Lines changed: 1235 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/endpoints/logs.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from fastapi import Request
2+
3+
from decorators.auth import protected_route
4+
from endpoints.responses.log import LogEntrySchema
5+
from endpoints.sockets.logs import get_recent_logs
6+
from handler.auth.constants import Scope
7+
from utils.router import APIRouter
8+
9+
router = APIRouter(
10+
prefix="/logs",
11+
tags=["logs"],
12+
)
13+
14+
# Largest backfill the buffer holds (mirrors LOG_BUFFER_SIZE).
15+
MAX_LOG_LIMIT = 1000
16+
17+
18+
@protected_route(router.get, "", [Scope.USERS_WRITE])
19+
async def get_logs(
20+
request: Request, limit: int = MAX_LOG_LIMIT
21+
) -> list[LogEntrySchema]:
22+
"""Return the most recent backend log lines for backfill (admin only).
23+
24+
Args:
25+
request (Request): FastAPI Request object.
26+
limit (int): Maximum number of recent lines to return.
27+
28+
Returns:
29+
list[LogEntrySchema]: Recent log lines, oldest-first.
30+
"""
31+
bounded = max(1, min(limit, MAX_LOG_LIMIT))
32+
entries = await get_recent_logs(bounded)
33+
return [LogEntrySchema(**entry) for entry in entries]

backend/endpoints/responses/log.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from endpoints.responses.base import BaseModel
2+
3+
4+
class LogEntrySchema(BaseModel):
5+
"""A single backend log line streamed to the admin log viewer."""
6+
7+
ts: int # epoch milliseconds
8+
level: str
9+
module: str
10+
message: str

backend/endpoints/sockets/logs.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Real-time backend log streaming over Socket.IO (admin only).
2+
3+
Pieces:
4+
- ``connect`` handler on the main socket server: resolves the session user and,
5+
if they are an admin, joins them to the ``admin`` room. It never rejects a
6+
connection, so the existing scan/sync sockets keep working for everyone.
7+
- ``start_log_forwarder``: a single background task (Redis-lock guarded) that
8+
subscribes to the ``romm:logs`` pub/sub channel — fed by ``LogStreamHandler``
9+
in every backend process — and relays each line to the ``admin`` room.
10+
- ``get_recent_logs``: reads the capped ring buffer for backfill on view open.
11+
"""
12+
13+
import asyncio
14+
import uuid
15+
from http.cookies import SimpleCookie
16+
from typing import Any, Final
17+
18+
import socketio # type: ignore
19+
20+
from config import REDIS_URL
21+
from handler.database import db_user_handler
22+
from handler.redis_handler import async_cache
23+
from handler.socket_handler import socket_handler
24+
from logger.log_stream_handler import LOG_BUFFER_KEY, LOG_CHANNEL
25+
from logger.logger import log
26+
from models.user import Role
27+
from utils import json_module
28+
29+
ADMIN_ROOM: Final = "admin"
30+
FORWARDER_LOCK_KEY: Final = "romm:logs:forwarder"
31+
FORWARDER_LOCK_TTL: Final = 30 # seconds
32+
# Session cookie name configured on RedisSessionMiddleware in main.py.
33+
SESSION_COOKIE_NAME: Final = "romm_session"
34+
35+
36+
async def _session_from_environ(environ: dict[str, Any]) -> dict[str, Any]:
37+
"""Resolve the auth session for a socket handshake.
38+
39+
Tries the session the middleware attached to the ASGI scope first; if it's
40+
absent (scope not propagated to the mounted socket app), falls back to
41+
parsing the session cookie and reading the session straight from Redis —
42+
the same lookup RedisSessionMiddleware performs.
43+
"""
44+
scope = environ.get("asgi.scope", {})
45+
session = scope.get("session")
46+
if session:
47+
return session
48+
49+
raw_cookie = environ.get("HTTP_COOKIE", "")
50+
if not raw_cookie:
51+
return {}
52+
53+
cookie: SimpleCookie = SimpleCookie()
54+
cookie.load(raw_cookie)
55+
morsel = cookie.get(SESSION_COOKIE_NAME)
56+
if morsel is None:
57+
return {}
58+
59+
session_data = await async_cache.get(f"session:{morsel.value}")
60+
if not session_data:
61+
return {}
62+
try:
63+
return json_module.loads(session_data)
64+
except Exception: # noqa: BLE001 - malformed session is "no session"
65+
return {}
66+
67+
68+
@socket_handler.socket_server.on("connect") # type: ignore
69+
async def connect(sid: str, environ: dict[str, Any], auth: Any = None) -> None:
70+
"""Join admin users to the log-streaming room on socket connect.
71+
72+
Always returns ``None`` (accepts the connection) — only the room membership
73+
is gated, so the existing scan/sync sockets keep working for everyone.
74+
"""
75+
try:
76+
session = await _session_from_environ(environ)
77+
if session.get("iss") != "romm:auth":
78+
return
79+
80+
username = session.get("sub")
81+
if not username:
82+
return
83+
84+
user = db_user_handler.get_user_by_username(username)
85+
if user and user.enabled and user.role == Role.ADMIN:
86+
await socket_handler.socket_server.enter_room(sid, ADMIN_ROOM)
87+
except Exception: # noqa: BLE001 - never let auth resolution refuse a socket
88+
log.exception("Failed to resolve admin for log stream connect")
89+
90+
91+
async def get_recent_logs(limit: int) -> list[dict[str, Any]]:
92+
"""Return the most recent buffered log lines in chronological order."""
93+
raw = await async_cache.lrange(LOG_BUFFER_KEY, 0, max(0, limit - 1))
94+
entries: list[dict[str, Any]] = []
95+
for item in raw:
96+
try:
97+
entries.append(json_module.loads(item))
98+
except Exception: # noqa: BLE001 - skip any malformed buffer entry
99+
continue
100+
# The buffer is newest-first (LPUSH); callers want oldest-first.
101+
entries.reverse()
102+
return entries
103+
104+
105+
async def start_log_forwarder() -> None:
106+
"""Relay log lines from Redis pub/sub to admin Socket.IO clients.
107+
108+
Guarded by a Redis lock so that, when more than one web worker is running
109+
(WEB_SERVER_CONCURRENCY > 1), exactly one forwards and clients don't receive
110+
duplicate lines.
111+
"""
112+
lock_id = str(uuid.uuid4())
113+
pubsub = None
114+
# Write-only manager for emitting — the same proven path scan/sync use. It
115+
# publishes the room emit to Redis; the main socket server's read-side
116+
# manager resolves `admin` room membership and delivers. Created inside the
117+
# running loop (like sync.py) rather than at import time.
118+
socket_manager = socketio.AsyncRedisManager(REDIS_URL, write_only=True)
119+
try:
120+
while True:
121+
got_lock = await async_cache.set(
122+
FORWARDER_LOCK_KEY, lock_id, nx=True, ex=FORWARDER_LOCK_TTL
123+
)
124+
if not got_lock:
125+
# Another worker owns the forwarder; check back periodically in
126+
# case it dies and the lock expires.
127+
await asyncio.sleep(FORWARDER_LOCK_TTL / 2)
128+
continue
129+
130+
pubsub = async_cache.pubsub()
131+
await pubsub.subscribe(LOG_CHANNEL)
132+
log.info("Log stream forwarder started")
133+
try:
134+
while True:
135+
# Heartbeat the lock so a healthy forwarder keeps ownership.
136+
await async_cache.set(
137+
FORWARDER_LOCK_KEY, lock_id, xx=True, ex=FORWARDER_LOCK_TTL
138+
)
139+
message = await pubsub.get_message(
140+
ignore_subscribe_messages=True,
141+
timeout=FORWARDER_LOCK_TTL / 3,
142+
)
143+
if not message:
144+
continue
145+
data = message.get("data")
146+
if not data:
147+
continue
148+
# One bad message or emit must not kill the forwarder —
149+
# swallow per-line and keep relaying.
150+
try:
151+
payload = json_module.loads(data)
152+
await socket_manager.emit(
153+
"logs:entry", payload, room=ADMIN_ROOM
154+
)
155+
except Exception: # noqa: BLE001 - skip; keep forwarding
156+
continue
157+
finally:
158+
await pubsub.unsubscribe(LOG_CHANNEL)
159+
await pubsub.aclose() # type: ignore[attr-defined]
160+
pubsub = None
161+
except asyncio.CancelledError:
162+
if pubsub is not None:
163+
await pubsub.aclose() # type: ignore[attr-defined]
164+
# Release the lock on shutdown so a restart (e.g. uvicorn --reload)
165+
# resumes forwarding immediately instead of waiting out the TTL.
166+
await _release_lock(lock_id)
167+
raise
168+
except Exception: # noqa: BLE001 - never let the forwarder kill the app
169+
log.exception("Log forwarder crashed")
170+
await _release_lock(lock_id)
171+
172+
173+
async def _release_lock(lock_id: str) -> None:
174+
"""Delete the forwarder lock only if we still own it."""
175+
try:
176+
current = await async_cache.get(FORWARDER_LOCK_KEY)
177+
if current == lock_id:
178+
await async_cache.delete(FORWARDER_LOCK_KEY)
179+
except Exception: # noqa: BLE001 - best-effort cleanup
180+
pass
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import logging
2+
import re
3+
from typing import Final
4+
5+
# Redis keys shared between the logging handler (producer, runs in every
6+
# process), the forwarder (relays pub/sub to Socket.IO) and the REST backfill
7+
# endpoint.
8+
LOG_CHANNEL: Final = "romm:logs"
9+
LOG_BUFFER_KEY: Final = "romm:logs:buffer"
10+
LOG_BUFFER_SIZE: Final = 1000
11+
12+
# Strips ANSI SGR escapes (colors) that the `highlight()` helper embeds in log
13+
# messages — they render as colors on a terminal but as garbage in a browser.
14+
_ANSI_RE: Final = re.compile(r"\x1b\[[0-9;]*m")
15+
16+
17+
class LogStreamHandler(logging.Handler):
18+
"""Logging handler that mirrors records to Redis for real-time streaming.
19+
20+
Each record is serialized to a small JSON payload, appended to a capped
21+
Redis ring buffer (for backfill on view open) and published to a pub/sub
22+
channel that a single forwarder in the main app relays to admin Socket.IO
23+
clients.
24+
25+
The handler is attached to the ``romm`` logger, so it runs in every process
26+
that imports it (main app, RQ workers, scheduler, watchers) and the stream
27+
covers the whole backend.
28+
29+
Failures are swallowed **silently** — stdout is the source-of-truth log, so
30+
a Redis hiccup (or the metadata package not being importable yet during the
31+
first few boot lines) must neither raise into the app nor spam tracebacks
32+
via ``handleError``.
33+
"""
34+
35+
def __init__(self) -> None:
36+
super().__init__()
37+
# The redaction regex lives in handler.metadata.base_handler, which
38+
# pulls a heavy import chain (and, during early boot, a partially
39+
# initialized handler.redis_handler). Resolve it lazily and cache it
40+
# once available; until then, skip redaction — exactly like the
41+
# Formatter does for the first few boot lines.
42+
self._redactor: re.Pattern[str] | None = None
43+
44+
def _redact(self, message: str) -> str:
45+
if self._redactor is None:
46+
try:
47+
from handler.metadata.base_handler import SENSITIVE_KEYS_REGEX
48+
49+
self._redactor = SENSITIVE_KEYS_REGEX
50+
except Exception: # noqa: BLE001 - not importable yet during boot
51+
return message
52+
return self._redactor.sub(r"\1=***", message)
53+
54+
def emit(self, record: logging.LogRecord) -> None:
55+
try:
56+
from handler.redis_handler import redis_client
57+
from utils import json_module
58+
59+
module = getattr(record, "module_name", record.module)
60+
payload = json_module.dumps(
61+
{
62+
"ts": int(record.created * 1000),
63+
"level": record.levelname,
64+
"module": str(module).lower(),
65+
"message": self._redact(_ANSI_RE.sub("", record.getMessage())),
66+
}
67+
)
68+
69+
# A single pipeline keeps buffer write + publish to one round-trip.
70+
pipe = redis_client.pipeline()
71+
pipe.lpush(LOG_BUFFER_KEY, payload)
72+
pipe.ltrim(LOG_BUFFER_KEY, 0, LOG_BUFFER_SIZE - 1)
73+
pipe.publish(LOG_CHANNEL, payload)
74+
pipe.execute()
75+
except Exception: # noqa: BLE001 - best-effort mirror; never raise/spam
76+
pass

backend/logger/logger.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import logging
22
import sys
33

4-
from config import LOGLEVEL
4+
from config import IS_PYTEST_RUN, LOGLEVEL
55
from logger.formatter import Formatter
6+
from logger.log_stream_handler import LogStreamHandler
67

78
# Set up logger
89
log = logging.getLogger("romm")
@@ -14,6 +15,13 @@
1415
stdout_handler.setFormatter(Formatter())
1516
log.addHandler(stdout_handler)
1617

18+
# Mirror records to Redis for the real-time admin log viewer. Skipped under
19+
# pytest, where there is no live Redis to stream to.
20+
if not IS_PYTEST_RUN:
21+
stream_handler = LogStreamHandler()
22+
stream_handler.setLevel(LOGLEVEL)
23+
log.addHandler(stream_handler)
24+
1725
# Hush passlib warnings
1826
logging.getLogger("passlib").setLevel(logging.ERROR)
1927

backend/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from starlette.middleware.authentication import AuthenticationMiddleware
1414
from startup import main
1515

16+
import endpoints.sockets.logs # noqa
1617
import endpoints.sockets.netplay # noqa
1718
import endpoints.sockets.scan # noqa
1819
import endpoints.sockets.sync # noqa
@@ -34,6 +35,7 @@
3435
from endpoints.feeds import router as feeds_router
3536
from endpoints.firmware import router as firmware_router
3637
from endpoints.heartbeat import router as heartbeat_router
38+
from endpoints.logs import router as logs_router
3739
from endpoints.netplay import router as netplay_router
3840
from endpoints.platform import router as platform_router
3941
from endpoints.play_sessions import router as play_sessions_router
@@ -68,7 +70,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
6870
async with initialize_context():
6971
app.state.aiohttp_session = ctx_aiohttp_session.get()
7072
app.state.httpx_client = ctx_httpx_client.get()
71-
yield
73+
74+
# Relay backend log lines to admin Socket.IO clients in real time.
75+
log_forwarder_task: asyncio.Task[None] | None = None
76+
if not IS_PYTEST_RUN:
77+
from endpoints.sockets.logs import start_log_forwarder
78+
79+
log_forwarder_task = asyncio.create_task(start_log_forwarder())
80+
81+
try:
82+
yield
83+
finally:
84+
if log_forwarder_task is not None:
85+
log_forwarder_task.cancel()
7286

7387

7488
sentry_sdk.init(
@@ -140,6 +154,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
140154
app.include_router(feeds_router, prefix="/api")
141155
app.include_router(configs_router, prefix="/api")
142156
app.include_router(stats_router, prefix="/api")
157+
app.include_router(logs_router, prefix="/api")
143158
app.include_router(raw_router, prefix="/api")
144159
app.include_router(screenshots_router, prefix="/api")
145160
app.include_router(firmware_router, prefix="/api")

frontend/src/locales/bg_BG/common.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,6 @@
152152
"changelog-view-all": "Вижте всички издания в GitHub",
153153
"visibility": "Видимост",
154154
"server-offline-retrying": "Server connection lost · retrying…",
155-
"server-reconnected": "Reconnected to the server"
155+
"server-reconnected": "Reconnected to the server",
156+
"logs": "Logs"
156157
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"level-all": "All levels",
3+
"level-filter": "Filter by level",
4+
"search-placeholder": "Search logs",
5+
"pause": "Pause",
6+
"resume": "Resume",
7+
"copy": "Copy to clipboard",
8+
"download": "Download",
9+
"clear": "Clear",
10+
"empty": "No logs yet",
11+
"no-matches": "No logs match your filters",
12+
"jump-to-latest": "Jump to latest",
13+
"load-error": "Failed to load logs",
14+
"copied": "Logs copied to clipboard",
15+
"copy-error": "Failed to copy logs",
16+
"click-to-copy": "Click to copy to clipboard",
17+
"line-copied": "Line copied to clipboard"
18+
}

0 commit comments

Comments
 (0)