feat: 引入 Loguru

This commit is contained in:
2026-09-29 00:47:57 +08:00
parent 0fdde8c3e6
commit 437fcd74b1
8 changed files with 139 additions and 31 deletions
+7 -4
View File
@@ -1,19 +1,17 @@
"""业务异常与全局 API 异常处理"""
import logging
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from openai import APIError, APITimeoutError
from starlette.exceptions import HTTPException
from app.common.response import error_content
from app.exceptions import AppError, DecisionError, ImportProcessError
logger = logging.getLogger(__name__)
class BizException(AppError):
"""可安全返回给调用方的业务错误"""
@@ -54,7 +52,12 @@ async def handle_unexpected_exception(
_request: Request, exc: Exception
) -> JSONResponse:
# 详细异常仅记录在服务端,不将堆栈或内部错误内容返回给前端
logger.error("未处理的服务器异常", exc_info=(type(exc), exc, exc.__traceback__))
logger.bind(thread_id=getattr(exc, "_agent_thread_id", "-")).opt(
exception=exc
).error("未处理的服务器异常")
exc._logged_by_api = True
# 节点包装异常时沿原始原因查找,区分上游服务故障与内部错误
cause = exc
while isinstance(cause, ImportProcessError):
+62
View File
@@ -0,0 +1,62 @@
"""统一配置应用、Uvicorn 和第三方库日志"""
import logging
import sys
from pathlib import Path
from loguru import logger
LOG_PATH = Path(__file__).resolve().parents[2] / "logs" / "app.log"
LOG_FORMAT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {extra[thread_id]} | {extra[node]} | {message}"
_initialized = False
class InterceptHandler(logging.Handler):
"""将标准 logging 转发到 Loguru,兼容第三方库"""
def emit(self, record: logging.LogRecord) -> None:
# FastAPI 已记录的异常不再由 Uvicorn 重复打印堆栈
if record.exc_info and getattr(record.exc_info[1], "_logged_by_api", False):
return
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
logger.opt(exception=record.exc_info).log(level, record.getMessage())
def setup_logging() -> None:
"""应用启动或独立测试入口调用,多次调用不会重复添加输出"""
global _initialized
if _initialized:
return
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
logger.remove()
logger.configure(extra={"thread_id": "-", "node": "-"})
options = {
"level": "INFO",
"format": LOG_FORMAT,
"enqueue": True,
"diagnose": False,
"backtrace": False,
}
logger.add(sys.stderr, colorize=True, **options)
logger.add(
LOG_PATH,
rotation="20 MB",
retention="14 days",
compression="zip",
encoding="utf-8",
**options,
)
# 清理已有输出处理器,让标准日志只经过根处理器输出一次
logging.basicConfig(handlers=[InterceptHandler()], level=logging.INFO, force=True)
for name in list(logging.root.manager.loggerDict):
existing = logging.getLogger(name)
existing.handlers.clear()
existing.propagate = True
_initialized = True
+7 -1
View File
@@ -4,13 +4,16 @@ import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from loguru import logger
from app.api.agent import router
from app.core.exceptions import register_exception_handlers
from app.core.logging import setup_logging
@asynccontextmanager
async def lifespan(application: FastAPI):
setup_logging()
from app.clients.decision import close_decision_engine
from app.observability.tracing import flush_tracing
from app.processor.graph import build_graph
@@ -24,7 +27,10 @@ async def lifespan(application: FastAPI):
try:
await close_decision_engine()
finally:
await asyncio.to_thread(flush_tracing)
try:
await asyncio.to_thread(flush_tracing)
finally:
await logger.complete()
def create_app() -> FastAPI:
+6 -7
View File
@@ -6,16 +6,15 @@
from __future__ import annotations
import logging
import os
from asyncio import CancelledError
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from app.config import settings
from loguru import logger
logger = logging.getLogger("app.observability")
from app.config import settings
# 面板 Trace Name 列用的稳定名字,不要拼 thread_id 或用户原文
TRACE_NAME = "screen-instruct"
@@ -52,8 +51,8 @@ def flush_tracing() -> None:
return
try:
client.flush()
except Exception:
logger.warning("Langfuse flush 失败", exc_info=True)
except Exception as exc: # noqa: BLE001 追踪故障不阻断业务
logger.opt(exception=exc).warning("Langfuse flush 失败")
async def ainvoke_screen(
@@ -232,9 +231,9 @@ def _ensure_client() -> Any:
try:
client = Langfuse()
except Exception:
except Exception as exc: # noqa: BLE001 追踪故障不阻断业务
_unavailable = True
logger.warning("Langfuse 不可用,本进程跳过追踪", exc_info=True)
logger.opt(exception=exc).warning("Langfuse 不可用,本进程跳过追踪")
return None
_client = client
return client
+12 -16
View File
@@ -1,10 +1,10 @@
import inspect
import logging
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, TypeVar
from langgraph.errors import GraphInterrupt
from loguru import logger
from pydantic import ValidationError
if TYPE_CHECKING:
@@ -20,7 +20,7 @@ class BaseNode(ABC):
def __init__(self, config: "Settings | None" = None):
"""保存显式配置;全局配置在节点实际使用时才加载。"""
self._config = config
self.logger = logging.getLogger(f"import.{self.name}")
self.logger = logger.bind(node=self.name)
@property
def config(self) -> "Settings":
@@ -35,24 +35,22 @@ class BaseNode(ABC):
if inspect.iscoroutinefunction(self.process):
return self._acall(state)
start_time = time.perf_counter()
self.logger.info("--- %s 开始 ---", self.name)
self.logger.info("节点开始")
try:
result = self.process(state)
except GraphInterrupt:
raise
except (AppError, ValidationError):
self.logger.exception("%s 执行失败", self.name)
self.logger.error("节点执行失败")
raise
except Exception as e:
self.logger.exception("%s 执行失败", self.name)
self.logger.error("节点执行失败")
raise ImportProcessError(str(e), node_name=self.name) from e
else:
self.logger.info("--- %s 完成 ---", self.name)
self.logger.info("节点完成")
return result
finally:
self.logger.info(
"%s 耗时 %.3f 秒", self.name, time.perf_counter() - start_time
)
self.logger.info(f"节点耗时 {time.perf_counter() - start_time:.3f} 秒")
@abstractmethod
def process(self, state: T) -> T:
@@ -61,24 +59,22 @@ class BaseNode(ABC):
async def _acall(self, state: T) -> T:
"""异步节点等待执行完成后再记录结果和耗时"""
start_time = time.perf_counter()
self.logger.info("--- %s 开始 ---", self.name)
self.logger.info("节点开始")
try:
result = await self.process(state)
except GraphInterrupt:
raise
except (AppError, ValidationError):
self.logger.exception("%s 执行失败", self.name)
self.logger.error("节点执行失败")
raise
except Exception as error:
self.logger.exception("%s 执行失败", self.name)
self.logger.error("节点执行失败")
raise ImportProcessError(str(error), node_name=self.name) from error
else:
self.logger.info("--- %s 完成 ---", self.name)
self.logger.info("节点完成")
return result
finally:
self.logger.info(
"%s 耗时 %.3f 秒", self.name, time.perf_counter() - start_time
)
self.logger.info(f"节点耗时 {time.perf_counter() - start_time:.3f} 秒")
def log_step(self, step_name: str, message: str = ""):
"""记录步骤日志。"""
+20 -3
View File
@@ -3,6 +3,7 @@
from uuid import uuid4
from langgraph.types import Command
from loguru import logger
from app.core.exceptions import BizException
from app.observability.tracing import ainvoke_screen
@@ -68,10 +69,26 @@ class AgentService:
self._active.discard(thread_id)
async def _execute(self, thread_id, payload, session_id) -> AgentResult:
state = await ainvoke_screen(
self.graph, payload, thread_id=thread_id, session_id=session_id
)
# 上下文只作用于当前异步任务,节点自动继承任务标识
with logger.contextualize(thread_id=thread_id):
logger.info("任务执行开始")
try:
state = await ainvoke_screen(
self.graph, payload, thread_id=thread_id, session_id=session_id
)
except Exception as exc:
# API 异常处理发生在上下文退出后,保留任务标识用于定位
exc._agent_thread_id = thread_id
raise
logger.info(
"任务执行结束,状态 {}",
"waiting_for_input"
if state.get("__interrupt__")
else state.get("status"),
)
waiting = bool(state.get("__interrupt__"))
# 只交付校验通过的完整动作批次,失败草稿留在后端
return AgentResult(
thread_id=thread_id,