848f804169
- FastAPI 后端 + Vue 3 前端 - Docker Compose 一键部署 - Casdoor OAuth 认证集成 - LogHive 集中式日志 - 设备批量 CSV 导入/导出 - WebSocket 实时状态推送 - 企业微信告警通知 - fping 高性能并发 Ping 检测 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
138 lines
4.4 KiB
Python
138 lines
4.4 KiB
Python
"""
|
||
LogHive 日志 Handler
|
||
|
||
基于标准 logging.Handler,通过 REST API 将日志异步批量发送到 LogHive。
|
||
不依赖外部包,后台线程发送,失败不影响主业务。
|
||
"""
|
||
|
||
import asyncio
|
||
import atexit
|
||
import json
|
||
import logging
|
||
import queue
|
||
import threading
|
||
import time
|
||
import traceback as tb
|
||
|
||
import httpx
|
||
|
||
|
||
class LogHiveHandler(logging.Handler):
|
||
"""异步批量发送日志到 LogHive"""
|
||
|
||
def __init__(
|
||
self,
|
||
endpoint: str,
|
||
project: str,
|
||
api_key: str,
|
||
level: int = logging.INFO,
|
||
batch_size: int = 50,
|
||
flush_interval: float = 2.0,
|
||
max_retries: int = 3,
|
||
):
|
||
super().__init__(level=level)
|
||
self._endpoint = endpoint.rstrip("/") + "/api/logs/ingest"
|
||
self._project = project
|
||
self._api_key = api_key
|
||
self._batch_size = batch_size
|
||
self._flush_interval = flush_interval
|
||
self._max_retries = max_retries
|
||
|
||
self._queue: queue.Queue = queue.Queue()
|
||
self._client: httpx.Client | None = None
|
||
self._thread: threading.Thread | None = None
|
||
self._running = False
|
||
|
||
def _ensure_client(self):
|
||
if self._client is None:
|
||
self._client = httpx.Client(timeout=10)
|
||
|
||
def _ensure_thread(self):
|
||
if self._thread is None or not self._thread.is_alive():
|
||
self._running = True
|
||
self._thread = threading.Thread(target=self._send_loop, daemon=True)
|
||
self._thread.start()
|
||
|
||
def emit(self, record: logging.LogRecord):
|
||
"""接收日志记录,放入队列"""
|
||
if not self._api_key:
|
||
return
|
||
self._ensure_thread()
|
||
try:
|
||
entry = {
|
||
"level": record.levelname.lower(),
|
||
"message": self.format(record),
|
||
"logger": record.name,
|
||
"timestamp": record.created,
|
||
}
|
||
if record.exc_info and record.exc_info[1]:
|
||
entry["exception"] = "".join(
|
||
tb.format_exception(*record.exc_info)
|
||
)
|
||
self._queue.put_nowait(entry)
|
||
except Exception:
|
||
pass # 日志发送失败不能影响主业务
|
||
|
||
def _send_loop(self):
|
||
"""后台线程:定时批量发送"""
|
||
while self._running:
|
||
batch = []
|
||
deadline = time.monotonic() + self._flush_interval
|
||
|
||
while len(batch) < self._batch_size:
|
||
try:
|
||
remaining = max(0, deadline - time.monotonic())
|
||
batch.append(self._queue.get(timeout=remaining))
|
||
except queue.Empty:
|
||
break
|
||
|
||
if batch:
|
||
payload = {"project": self._project, "entries": batch}
|
||
for attempt in range(self._max_retries):
|
||
try:
|
||
self._ensure_client()
|
||
resp = self._client.post(
|
||
self._endpoint,
|
||
json=payload,
|
||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||
)
|
||
if resp.status_code < 500:
|
||
break
|
||
except Exception:
|
||
if attempt == self._max_retries - 1:
|
||
pass # 最终丢弃
|
||
else:
|
||
time.sleep(0.5 * (attempt + 1))
|
||
|
||
def close(self):
|
||
"""关闭 handler,flush 剩余日志"""
|
||
self._running = False
|
||
if self._thread and self._thread.is_alive():
|
||
self._thread.join(timeout=5)
|
||
if self._client:
|
||
self._client.close()
|
||
super().close()
|
||
|
||
|
||
class AsyncLogHiveHandler:
|
||
"""
|
||
用于 asyncio 事件循环的异步 handler。
|
||
在独立的线程中运行同步 LogHiveHandler,通过 asyncio 队列桥接。
|
||
"""
|
||
|
||
def __init__(self, **kwargs):
|
||
self._handler = LogHiveHandler(**kwargs)
|
||
self._loop: asyncio.AbstractEventLoop | None = None
|
||
|
||
def setup(self, loop: asyncio.AbstractEventLoop):
|
||
self._loop = loop
|
||
atexit.register(self._handler.close)
|
||
|
||
async def emit(self, record: logging.LogRecord):
|
||
"""异步安全地提交日志记录"""
|
||
# LogHiveHandler.emit 已经把日志放入内部队列,这里只需确保线程运行
|
||
self._handler.emit(record)
|
||
|
||
def close(self):
|
||
self._handler.close()
|