""" FastAPI backend:将 ERROR 日志写入 Redis List(仅保留最近 N 条)。 实现与 trading_system/redis_log_handler.py 保持一致(避免跨目录导入带来的 PYTHONPATH 问题)。 """ from __future__ import annotations import json import logging import os import socket import time import traceback from dataclasses import dataclass from datetime import datetime, timezone, timedelta from typing import Any, Dict, Optional def _beijing_time_str(ts: float) -> str: beijing_tz = timezone(timedelta(hours=8)) return datetime.fromtimestamp(ts, tz=beijing_tz).strftime("%Y-%m-%d %H:%M:%S") def _safe_json_loads(s: str) -> Optional[Dict[str, Any]]: try: obj = json.loads(s) if isinstance(obj, dict): return obj except Exception: return None return None @dataclass(frozen=True) class RedisLogConfig: redis_url: str list_key: str = "ats:logs:error" max_len: int = 2000 dedupe_consecutive: bool = True service: str = "backend" hostname: str = socket.gethostname() connect_timeout_sec: float = 1.0 socket_timeout_sec: float = 1.0 username: Optional[str] = None password: Optional[str] = None use_tls: bool = False ssl_cert_reqs: str = "required" ssl_ca_certs: Optional[str] = None class RedisErrorLogHandler(logging.Handler): def __init__(self, cfg: RedisLogConfig): super().__init__() self.cfg = cfg self._redis = None self._redis_ok = False self._last_connect_attempt_ts = 0.0 def _connection_kwargs(self) -> Dict[str, Any]: kwargs: Dict[str, Any] = { "decode_responses": True, "socket_connect_timeout": self.cfg.connect_timeout_sec, "socket_timeout": self.cfg.socket_timeout_sec, } if self.cfg.username: kwargs["username"] = self.cfg.username if self.cfg.password: kwargs["password"] = self.cfg.password if self.cfg.redis_url.startswith("rediss://") or self.cfg.use_tls: kwargs["ssl_cert_reqs"] = self.cfg.ssl_cert_reqs if self.cfg.ssl_ca_certs: kwargs["ssl_ca_certs"] = self.cfg.ssl_ca_certs if self.cfg.ssl_cert_reqs == "none": kwargs["ssl_check_hostname"] = False elif self.cfg.ssl_cert_reqs == "required": kwargs["ssl_check_hostname"] = True else: kwargs["ssl_check_hostname"] = False return kwargs def _get_redis(self): now = time.time() if self._redis_ok and self._redis is not None: return self._redis if now - self._last_connect_attempt_ts < 5: return None self._last_connect_attempt_ts = now try: import redis # type: ignore except Exception: self._redis = None self._redis_ok = False return None try: client = redis.from_url(self.cfg.redis_url, **self._connection_kwargs()) client.ping() self._redis = client self._redis_ok = True return self._redis except Exception: self._redis = None self._redis_ok = False return None def _build_entry(self, record: logging.LogRecord) -> Dict[str, Any]: msg = record.getMessage() exc_text = None exc_type = None if record.exc_info: exc_type = getattr(record.exc_info[0], "__name__", None) exc_text = "".join(traceback.format_exception(*record.exc_info)) signature = f"{self.cfg.service}|{record.levelname}|{record.name}|{record.pathname}:{record.lineno}|{msg}|{exc_type or ''}" return { "ts": int(record.created * 1000), "time": _beijing_time_str(record.created), "service": self.cfg.service, "level": record.levelname, "logger": record.name, "message": msg, "pathname": record.pathname, "lineno": record.lineno, "funcName": record.funcName, "process": record.process, "thread": record.thread, "hostname": self.cfg.hostname, "exc_type": exc_type, "exc_text": exc_text, "signature": signature, "count": 1, } def emit(self, record: logging.LogRecord) -> None: try: client = self._get_redis() if client is None: return entry = self._build_entry(record) list_key = os.getenv("REDIS_LOG_LIST_KEY", self.cfg.list_key).strip() or self.cfg.list_key max_len = int(os.getenv("REDIS_LOG_LIST_MAX_LEN", str(self.cfg.max_len)) or self.cfg.max_len) if max_len <= 0: max_len = self.cfg.max_len if self.cfg.dedupe_consecutive: try: head_raw = client.lindex(list_key, 0) except Exception: head_raw = None if isinstance(head_raw, str): head = _safe_json_loads(head_raw) else: head = None if head and head.get("signature") == entry["signature"]: head["count"] = int(head.get("count", 1)) + 1 head["ts"] = entry["ts"] head["time"] = entry["time"] if entry.get("exc_text"): head["exc_text"] = entry.get("exc_text") head["exc_type"] = entry.get("exc_type") try: pipe = client.pipeline() pipe.lset(list_key, 0, json.dumps(head, ensure_ascii=False)) pipe.ltrim(list_key, 0, max_len - 1) pipe.execute() return except Exception: pass try: pipe = client.pipeline() pipe.lpush(list_key, json.dumps(entry, ensure_ascii=False)) pipe.ltrim(list_key, 0, max_len - 1) pipe.execute() except Exception: return except Exception: return