logs_websocket.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. """
  2. Logs WebSocket - 实时推送后端日志到前端
  3. """
  4. import asyncio
  5. import logging
  6. from ipaddress import ip_address
  7. from typing import Set
  8. from fastapi import APIRouter, WebSocket, WebSocketDisconnect
  9. from datetime import datetime
  10. router = APIRouter(prefix="/api/logs", tags=["logs"])
  11. LOCAL_DEVELOPMENT_ORIGINS = frozenset({
  12. "http://127.0.0.1:3000",
  13. "http://localhost:3000",
  14. })
  15. # 存储所有连接的WebSocket客户端
  16. _clients: Set[WebSocket] = set()
  17. def is_local_websocket_origin(websocket: WebSocket) -> bool:
  18. """日志流可能含敏感调试信息,只允许本地前端连接。"""
  19. return websocket.headers.get("origin") in LOCAL_DEVELOPMENT_ORIGINS
  20. def is_loopback_websocket_peer(websocket: WebSocket) -> bool:
  21. """Reject absent, non-IP, and non-loopback ASGI peers."""
  22. host = websocket.client.host if websocket.client else None
  23. if not host:
  24. return False
  25. try:
  26. return ip_address(host).is_loopback
  27. except ValueError:
  28. return False
  29. class WebSocketLogHandler(logging.Handler):
  30. """自定义日志处理器,将日志推送到WebSocket客户端"""
  31. def emit(self, record: logging.LogRecord):
  32. """发送日志记录到所有连接的客户端"""
  33. try:
  34. log_entry = self.format(record)
  35. # 构造日志消息
  36. message = {
  37. "timestamp": datetime.now().isoformat(),
  38. "level": record.levelname,
  39. "name": record.name,
  40. "message": log_entry,
  41. }
  42. # uvicorn 热重载线程等非主线程打印日志时,可能没有 running loop
  43. try:
  44. loop = asyncio.get_running_loop()
  45. # 如果能在当前循环找到,说明在协程中,安全地抛出一个 task
  46. loop.create_task(broadcast_log(message))
  47. except RuntimeError:
  48. # 找不到 running loop 说明不是在 async 上下文下,我们需要新开一个临时事件循环发消息
  49. # 或者更简单的方式是:在没循环时就暂时丢弃/降级,因为没人监听的时候发了也没意义。
  50. # 由于这只是实时看日志的前端需求,如果在纯同步上下文中可以采用 run_coroutine_threadsafe 交给一个已知的 loop(但这里拿不到全局 loop)。
  51. # 我们这里使用兼容写法:短暂新开 loop 去触发一次网络写入(或者最稳妥的是:当前线程没有客户端也就没必要强求广播了)。
  52. if _clients:
  53. asyncio.run(broadcast_log(message))
  54. except Exception:
  55. self.handleError(record)
  56. async def broadcast_log(message: dict):
  57. """广播日志消息到所有连接的客户端"""
  58. disconnected = set()
  59. for client in _clients:
  60. try:
  61. await client.send_json(message)
  62. except Exception:
  63. disconnected.add(client)
  64. # 移除断开连接的客户端
  65. for client in disconnected:
  66. _clients.discard(client)
  67. @router.websocket("/watch")
  68. async def logs_websocket(websocket: WebSocket):
  69. """
  70. 日志WebSocket端点
  71. 客户端连接后,实时接收后端日志
  72. """
  73. if not is_loopback_websocket_peer(websocket):
  74. await websocket.close(code=1008, reason="Loopback client required")
  75. return
  76. if not is_local_websocket_origin(websocket):
  77. await websocket.close(code=1008, reason="Local development origin required")
  78. return
  79. await websocket.accept()
  80. _clients.add(websocket)
  81. try:
  82. # 发送欢迎消息
  83. await websocket.send_json({
  84. "timestamp": datetime.now().isoformat(),
  85. "level": "INFO",
  86. "name": "logs_websocket",
  87. "message": "Connected to logs stream",
  88. })
  89. # 保持连接,等待客户端断开
  90. while True:
  91. # 接收客户端消息(用于保持连接)
  92. await websocket.receive_text()
  93. except WebSocketDisconnect:
  94. pass
  95. finally:
  96. _clients.discard(websocket)
  97. def setup_websocket_logging(level=logging.INFO):
  98. """
  99. 设置WebSocket日志处理器
  100. 将根日志器的日志推送到WebSocket客户端
  101. """
  102. handler = WebSocketLogHandler()
  103. handler.setLevel(level)
  104. # 设置日志格式
  105. formatter = logging.Formatter(
  106. "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  107. datefmt="%Y-%m-%d %H:%M:%S"
  108. )
  109. handler.setFormatter(formatter)
  110. # 添加到根日志器
  111. root_logger = logging.getLogger()
  112. root_logger.addHandler(handler)
  113. return handler