This commit is contained in:
薇薇安 2026-01-18 20:28:23 +08:00
parent 2c6a239973
commit 46062e442b
9 changed files with 960 additions and 0 deletions

View File

@ -90,6 +90,32 @@ def setup_logging():
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# 追加:将 ERROR 日志写入 Redis不影响现有文件/控制台日志)
try:
from api.redis_log_handler import RedisErrorLogHandler, RedisLogConfig
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
redis_use_tls = os.getenv("REDIS_USE_TLS", "False").lower() == "true"
redis_username = os.getenv("REDIS_USERNAME", None)
redis_password = os.getenv("REDIS_PASSWORD", None)
ssl_cert_reqs = os.getenv("REDIS_SSL_CERT_REQS", "required")
ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
redis_cfg = RedisLogConfig(
redis_url=redis_url,
use_tls=redis_use_tls,
username=redis_username,
password=redis_password,
ssl_cert_reqs=ssl_cert_reqs,
ssl_ca_certs=ssl_ca_certs,
service="backend",
)
redis_handler = RedisErrorLogHandler(redis_cfg)
redis_handler.setLevel(logging.ERROR)
root_logger.addHandler(redis_handler)
except Exception:
pass
# 设置第三方库的日志级别
logging.getLogger('uvicorn').setLevel(logging.WARNING)
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)

View File

@ -0,0 +1,186 @@
"""
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

View File

@ -12,6 +12,123 @@ logger = logging.getLogger(__name__)
# 路由统一挂在 /api/system 下,前端直接调用 /api/system/...
router = APIRouter(prefix="/api/system")
def _get_redis_client_for_logs():
"""
获取 Redis 客户端优先复用 config_manager 的连接失败则自行创建
返回redis.Redis None
"""
# 1) 复用 config_manager避免重复连接
try:
import config_manager # backend/config_manager.py已负责加载 .env
cm = getattr(config_manager, "config_manager", None)
if cm is not None:
redis_client = getattr(cm, "_redis_client", None)
redis_connected = getattr(cm, "_redis_connected", False)
if redis_client is not None and redis_connected:
try:
redis_client.ping()
return redis_client
except Exception:
pass
except Exception:
pass
# 2) 自行创建
try:
import redis # type: ignore
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379")
redis_use_tls = os.getenv("REDIS_USE_TLS", "False").lower() == "true"
redis_username = os.getenv("REDIS_USERNAME", None)
redis_password = os.getenv("REDIS_PASSWORD", None)
ssl_cert_reqs = os.getenv("REDIS_SSL_CERT_REQS", "required")
ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
kwargs: Dict[str, Any] = {
"decode_responses": True,
"username": redis_username,
"password": redis_password,
"socket_connect_timeout": 1,
"socket_timeout": 1,
}
if redis_url.startswith("rediss://") or redis_use_tls:
kwargs["ssl_cert_reqs"] = ssl_cert_reqs
if ssl_ca_certs:
kwargs["ssl_ca_certs"] = ssl_ca_certs
if ssl_cert_reqs == "none":
kwargs["ssl_check_hostname"] = False
elif ssl_cert_reqs == "required":
kwargs["ssl_check_hostname"] = True
else:
kwargs["ssl_check_hostname"] = False
client = redis.from_url(redis_url, **kwargs)
client.ping()
return client
except Exception:
return None
@router.get("/logs")
async def get_logs(
limit: int = 200,
service: Optional[str] = None,
level: Optional[str] = None,
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
) -> Dict[str, Any]:
"""
Redis List 读取最新日志默认 ats:logs:error
参数
- limit: 返回条数最大 2000
- service: 过滤backend / trading_system
- level: 过滤ERROR / CRITICAL ...
"""
_require_admin(os.getenv("SYSTEM_CONTROL_TOKEN", "").strip(), x_admin_token)
if limit <= 0:
limit = 200
if limit > 2000:
limit = 2000
list_key = os.getenv("REDIS_LOG_LIST_KEY", "ats:logs:error").strip() or "ats:logs:error"
client = _get_redis_client_for_logs()
if client is None:
raise HTTPException(status_code=503, detail="Redis 不可用,无法读取日志")
try:
raw_items = client.lrange(list_key, 0, limit - 1)
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取 Redis 日志失败: {e}")
items: list[Dict[str, Any]] = []
for raw in raw_items or []:
try:
obj = raw
if isinstance(raw, bytes):
obj = raw.decode("utf-8", errors="ignore")
if isinstance(obj, str):
parsed = __import__("json").loads(obj)
else:
continue
if not isinstance(parsed, dict):
continue
if service and str(parsed.get("service")) != service:
continue
if level and str(parsed.get("level")) != level:
continue
items.append(parsed)
except Exception:
continue
return {
"key": list_key,
"count": len(items),
"items": items,
}
def _require_admin(token: Optional[str], provided: Optional[str]) -> None:
"""

View File

@ -5,6 +5,7 @@ import ConfigGuide from './components/ConfigGuide'
import TradeList from './components/TradeList'
import StatsDashboard from './components/StatsDashboard'
import Recommendations from './components/Recommendations'
import LogMonitor from './components/LogMonitor'
import './App.css'
function App() {
@ -19,6 +20,7 @@ function App() {
<Link to="/recommendations">交易推荐</Link>
<Link to="/config">配置</Link>
<Link to="/trades">交易记录</Link>
<Link to="/logs">日志监控</Link>
</div>
</div>
</nav>
@ -30,6 +32,7 @@ function App() {
<Route path="/config" element={<ConfigPanel />} />
<Route path="/config/guide" element={<ConfigGuide />} />
<Route path="/trades" element={<TradeList />} />
<Route path="/logs" element={<LogMonitor />} />
</Routes>
</main>
</div>

View File

@ -0,0 +1,219 @@
.log-monitor {
display: flex;
flex-direction: column;
gap: 16px;
}
.log-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.log-header h2 {
margin: 0;
}
.log-subtitle {
margin-top: 6px;
color: #666;
font-size: 12px;
}
.log-actions .btn {
border: 1px solid #ddd;
padding: 8px 12px;
border-radius: 8px;
background: #fff;
cursor: pointer;
}
.log-actions .btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.log-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-end;
padding: 12px;
border: 1px solid #eee;
border-radius: 10px;
background: #fff;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}
.control.inline {
min-width: auto;
}
.control label {
font-size: 12px;
color: #666;
}
.control input,
.control select {
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px 10px;
outline: none;
}
.log-error {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #f3c4c4;
background: #fff5f5;
color: #b42318;
}
.log-table {
border: 1px solid #eee;
border-radius: 10px;
overflow: hidden;
background: #fff;
}
.log-row {
display: grid;
grid-template-columns: 170px 140px 110px 1fr;
gap: 12px;
padding: 10px 12px;
border-top: 1px solid #f3f3f3;
align-items: start;
}
.log-head {
border-top: none;
background: #fafafa;
font-weight: 600;
color: #333;
}
.log-empty {
padding: 16px 12px;
color: #666;
}
.c-time,
.c-svc,
.c-level {
font-size: 12px;
color: #333;
}
.c-msg {
display: flex;
flex-direction: column;
gap: 6px;
}
.msg-line {
display: flex;
gap: 8px;
align-items: baseline;
}
.msg-text {
font-size: 13px;
color: #111;
word-break: break-word;
}
.msg-count {
font-size: 12px;
color: #666;
}
.msg-meta {
font-size: 12px;
color: #666;
}
.msg-details summary {
cursor: pointer;
font-size: 12px;
color: #333;
}
.stack {
margin: 8px 0 0;
padding: 10px;
border-radius: 8px;
background: #0b1020;
color: #dbeafe;
overflow: auto;
font-size: 12px;
line-height: 1.35;
}
.pill {
display: inline-block;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
border: 1px solid #ddd;
background: #fff;
}
.pill-error {
border-color: #f3c4c4;
background: #fff5f5;
color: #b42318;
}
.pill-critical {
border-color: #f1aeb5;
background: #ffe4e6;
color: #9f1239;
}
.pill-warning {
border-color: #f6d48b;
background: #fffbeb;
color: #92400e;
}
.pill-info {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.pill-debug {
border-color: #e5e7eb;
background: #f9fafb;
color: #374151;
}
@media (max-width: 900px) {
.log-row {
grid-template-columns: 1fr;
}
.log-head {
display: none;
}
.c-time::before {
content: "时间:";
color: #666;
}
.c-svc::before {
content: "服务:";
color: #666;
}
.c-level::before {
content: "级别:";
color: #666;
}
}

View File

@ -0,0 +1,170 @@
import React, { useEffect, useMemo, useState } from 'react'
import { api } from '../services/api'
import './LogMonitor.css'
const LEVELS = ['', 'ERROR', 'CRITICAL', 'WARNING', 'INFO', 'DEBUG']
const SERVICES = ['', 'backend', 'trading_system']
function formatCount(item) {
const c = Number(item?.count || 1)
return c > 1 ? `×${c}` : ''
}
export default function LogMonitor() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [limit, setLimit] = useState(200)
const [service, setService] = useState('')
const [level, setLevel] = useState('')
const [autoRefresh, setAutoRefresh] = useState(true)
const [refreshSec, setRefreshSec] = useState(5)
const params = useMemo(() => {
const p = { limit: String(limit) }
if (service) p.service = service
if (level) p.level = level
return p
}, [limit, service, level])
const load = async () => {
setLoading(true)
setError('')
try {
const res = await api.getSystemLogs(params)
setItems(res?.items || [])
} catch (e) {
setError(e?.message || '获取日志失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params])
useEffect(() => {
if (!autoRefresh) return
const sec = Number(refreshSec)
if (!sec || sec <= 0) return
const t = setInterval(() => load(), sec * 1000)
return () => clearInterval(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoRefresh, refreshSec, params])
return (
<div className="log-monitor">
<div className="log-header">
<div>
<h2>日志监控</h2>
<div className="log-subtitle">来源Redis List只保留最近 N 连续同类会合并计数</div>
</div>
<div className="log-actions">
<button className="btn" onClick={load} disabled={loading}>
刷新
</button>
</div>
</div>
<div className="log-controls">
<div className="control">
<label>条数</label>
<input
type="number"
min="1"
max="2000"
value={limit}
onChange={(e) => setLimit(Number(e.target.value || 200))}
/>
</div>
<div className="control">
<label>服务</label>
<select value={service} onChange={(e) => setService(e.target.value)}>
{SERVICES.map((s) => (
<option key={s} value={s}>
{s || '全部'}
</option>
))}
</select>
</div>
<div className="control">
<label>级别</label>
<select value={level} onChange={(e) => setLevel(e.target.value)}>
{LEVELS.map((l) => (
<option key={l} value={l}>
{l || '全部'}
</option>
))}
</select>
</div>
<div className="control inline">
<label>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
自动刷新
</label>
</div>
<div className="control">
<label>间隔()</label>
<input
type="number"
min="1"
max="60"
value={refreshSec}
onChange={(e) => setRefreshSec(Number(e.target.value || 5))}
disabled={!autoRefresh}
/>
</div>
</div>
{error ? <div className="log-error">{error}</div> : null}
<div className="log-table">
<div className="log-row log-head">
<div className="c-time">时间</div>
<div className="c-svc">服务</div>
<div className="c-level">级别</div>
<div className="c-msg">内容</div>
</div>
{items.length === 0 ? (
<div className="log-empty">{loading ? '加载中...' : '暂无日志'}</div>
) : (
items.map((it, idx) => (
<div className="log-row" key={`${it.ts || ''}-${idx}`}>
<div className="c-time">{it.time || ''}</div>
<div className="c-svc">{it.service || ''}</div>
<div className="c-level">
<span className={`pill pill-${String(it.level || '').toLowerCase()}`}>{it.level}</span>
</div>
<div className="c-msg">
<div className="msg-line">
<span className="msg-text">{it.message || ''}</span>
<span className="msg-count">{formatCount(it)}</span>
</div>
{it.logger ? <div className="msg-meta">{it.logger}</div> : null}
{it.exc_text ? (
<details className="msg-details">
<summary>堆栈</summary>
<pre className="stack">{it.exc_text}</pre>
</details>
) : null}
</div>
</div>
))
)}
</div>
</div>
)
}

View File

@ -297,4 +297,16 @@ export const api = {
}
return response.json();
},
// 日志监控Redis List
getSystemLogs: async (params = {}) => {
const query = new URLSearchParams(params).toString();
const url = query ? `${buildUrl('/api/system/logs')}?${query}` : buildUrl('/api/system/logs');
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: '获取日志失败' }));
throw new Error(error.detail || '获取日志失败');
}
return response.json();
},
};

View File

@ -63,6 +63,29 @@ logging.basicConfig(
handlers=[file_handler, console_handler]
)
# 追加:将 ERROR 日志写入 Redis不影响现有文件/控制台日志)
try:
if __name__ == '__main__':
from redis_log_handler import RedisErrorLogHandler, RedisLogConfig
else:
from .redis_log_handler import RedisErrorLogHandler, RedisLogConfig
redis_cfg = RedisLogConfig(
redis_url=getattr(config, "REDIS_URL", "redis://localhost:6379"),
use_tls=bool(getattr(config, "REDIS_USE_TLS", False)),
ssl_cert_reqs=str(getattr(config, "REDIS_SSL_CERT_REQS", "required") or "required"),
ssl_ca_certs=getattr(config, "REDIS_SSL_CA_CERTS", None),
username=getattr(config, "REDIS_USERNAME", None),
password=getattr(config, "REDIS_PASSWORD", None),
service="trading_system",
)
redis_handler = RedisErrorLogHandler(redis_cfg)
redis_handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(redis_handler)
except Exception:
# Redis handler 仅用于增强监控,失败不影响交易系统启动
pass
logger = logging.getLogger(__name__)

View File

@ -0,0 +1,204 @@
"""
Python logging ERROR 日志写入 Redis List仅保留最近 N
设计目标
- 不影响现有日志文件/控制台Redis 不可用时静默降级
- 只写入 ERROR/CRITICAL handler level 控制
- 支持同类内容去重仅对连续相同 signature的日志做合并计数count++ + 更新时间
Redis 数据结构
- List key默认 ats:logs:errorlist 头部是最新日志
- 每条日志是 JSON 字符串
"""
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 = "trading_system"
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):
"""
将日志写入 Redis List
注意logging handler 是同步的这里使用 redis-py 的同步客户端
"""
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
# TLSrediss:// 或显式开启)
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):
# 失败后做个简单退避,避免每条 ERROR 都去连 Redis
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"]
# 保留最新堆栈(有时第一次没有 exc_text
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:
# 失败则尝试正常 push
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:
# Redis 写失败不应影响业务
return
except Exception:
return