++
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Per-simulation capture of the same logs shown in the backend terminal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Context variables are inherited by asyncio tasks, including the concurrent
|
||||
# LLM calls created during a simulation. This keeps concurrent simulations from
|
||||
# writing each other's records to the same file.
|
||||
CURRENT_SIMULATION_ID: ContextVar[str | None] = ContextVar(
|
||||
"current_simulation_id", default=None
|
||||
)
|
||||
|
||||
PROJECT_LOGS_DIR = Path(__file__).resolve().parents[3] / "logs"
|
||||
|
||||
|
||||
class ChineseSimulationFormatter(logging.Formatter):
|
||||
"""Readable Chinese metadata while preserving the original message."""
|
||||
|
||||
LEVELS = {
|
||||
"DEBUG": "调试",
|
||||
"INFO": "信息",
|
||||
"WARNING": "警告",
|
||||
"ERROR": "错误",
|
||||
"CRITICAL": "严重错误",
|
||||
}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
timestamp = f"{self.formatTime(record, '%Y-%m-%d %H:%M:%S')},{int(record.msecs):03d}"
|
||||
level = self.LEVELS.get(record.levelname, record.levelname)
|
||||
line = f"{timestamp} [模拟日志] [{level}] [{record.name}] {record.getMessage()}"
|
||||
if record.exc_info:
|
||||
line += "\n" + self.formatException(record.exc_info)
|
||||
return line
|
||||
|
||||
|
||||
class SimulationLogHandler(logging.Handler):
|
||||
"""Write terminal-formatted records to ``logs/<simulation_id>.log``."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(level=logging.INFO)
|
||||
self._write_lock = threading.Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
simulation_id = CURRENT_SIMULATION_ID.get()
|
||||
if not simulation_id:
|
||||
return
|
||||
|
||||
try:
|
||||
PROJECT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = PROJECT_LOGS_DIR / f"{simulation_id}.log"
|
||||
line = self.format(record)
|
||||
with self._write_lock:
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except Exception:
|
||||
# Logging must never break a simulation if the disk is unavailable.
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
def install_simulation_log_handler() -> None:
|
||||
"""Install the capture handler once on the process root logger."""
|
||||
root = logging.getLogger()
|
||||
if any(isinstance(handler, SimulationLogHandler) for handler in root.handlers):
|
||||
return
|
||||
|
||||
handler = SimulationLogHandler()
|
||||
handler.setFormatter(ChineseSimulationFormatter())
|
||||
root.addHandler(handler)
|
||||
|
||||
|
||||
def bind_simulation(simulation_id: str):
|
||||
"""Bind a simulation ID to the current asyncio task."""
|
||||
return CURRENT_SIMULATION_ID.set(simulation_id)
|
||||
|
||||
|
||||
def unbind_simulation(token) -> None:
|
||||
CURRENT_SIMULATION_ID.reset(token)
|
||||
Reference in New Issue
Block a user