Files
FishServer/fish_api/app/logging_config.py
2026-05-13 09:19:31 +08:00

477 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一以 loguru 持久化日志:
- 控制台 stderr中文 + 颜色(人类可读);
- ``runtime_YYYY-MM-DD.log``:中文人读 + 耗时,按本地日期切分(``rotation="00:00"``),按天保留;
- ``events_YYYY-MM-DD.jsonl``:结构化事件,仅当 ``logger.bind(event=True, ...)`` 时写入,
字段 ``ts / pipeline / step / status / run_id / source / metrics / duration_ms / error``
- 同时把标准 ``logging``uvicorn / fastapi / starlette桥接到 loguru。
业务侧用 ``stage(...)`` 上下文管理器写阶段开始 / 结束(自动埋点耗时与状态),
或 ``bind_pipeline(...)`` 在长流程内统一带上 ``run_id / pipeline / source``。
"""
from __future__ import annotations
import json
import logging
import os
import sys
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator, Mapping, Optional, Union
from loguru import logger
_loguru_console_configured = False
_loguru_file_configured = False
_runtime_sink_id: Optional[int] = None
_events_sink_id: Optional[int] = None
_console_sink_id: Optional[int] = None
_events_sink_obj: Optional["_EventsJsonlSink"] = None
# ---------------------------------------------------------------------------
# JSON helpers
# ---------------------------------------------------------------------------
def format_json_pretty(
data: Any,
*,
indent: int = 2,
max_chars: Optional[int] = 24_000,
) -> str:
"""将对象格式化为带缩进、保留中文的 JSON 字符串,供 loguru 多行输出。
``max_chars`` 用于避免单条日志过大;超长时截断并标注。
"""
try:
s = json.dumps(data, ensure_ascii=False, indent=indent, default=str)
except (TypeError, ValueError):
return repr(data)
if max_chars is not None and len(s) > max_chars:
return s[:max_chars] + "\n... (truncated, max_chars=" + str(max_chars) + ")"
return s
# ---------------------------------------------------------------------------
# logging -> loguru bridge
# ---------------------------------------------------------------------------
class InterceptHandler(logging.Handler):
"""将标准 logging 记录转发到 loguru。"""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name # type: Union[str, int]
except ValueError:
level = record.levelno
logger.opt(depth=6, exception=record.exc_info).log(level, record.getMessage())
# ---------------------------------------------------------------------------
# Sink filters & formatters
# ---------------------------------------------------------------------------
def _is_event_record(record: Mapping[str, Any]) -> bool:
return bool(record["extra"].get("event"))
def _runtime_filter(record: Mapping[str, Any]) -> bool:
"""主 runtime 日志:忽略纯结构化事件(避免与 events JSONL 重复)。"""
# 即使 event=True 也保留 message阶段开始/结束的中文描述对人有用),
# 真正“仅机器读”的事件由 events sink 收集;为减少噪音这里仅排除 status=start
# 的开始事件(结束事件作为完成日志保留),见 stage() 实现。
extra = record["extra"]
if extra.get("event") and extra.get("event_silent"):
return False
return True
def _events_filter(record: Mapping[str, Any]) -> bool:
return _is_event_record(record)
def _runtime_format(record: Mapping[str, Any]) -> str:
"""中文 runtime 文件 / 控制台格式:``时间 | 级别 | [pipeline] message``。"""
extra = record["extra"]
pipeline = extra.get("pipeline") or "-"
parts = [
"{time:YYYY-MM-DD HH:mm:ss.SSS}",
"{level: <8}",
f"[{pipeline}]",
"{message}",
]
fmt = " | ".join(parts)
if record["exception"] is not None:
fmt += "\n{exception}"
return fmt + "\n"
def _runtime_format_colored(record: Mapping[str, Any]) -> str:
extra = record["extra"]
pipeline = extra.get("pipeline") or "-"
fmt = (
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
"<level>{level: <8}</level> | "
f"<cyan>[{pipeline}]</cyan> | "
"<level>{message}</level>"
)
if record["exception"] is not None:
fmt += "\n{exception}"
return fmt + "\n"
def _build_event_payload(record: Mapping[str, Any]) -> dict:
extra = record["extra"]
payload: dict = {
"ts": record["time"].isoformat(),
"level": record["level"].name,
"pipeline": extra.get("pipeline") or "",
"step": extra.get("step") or "",
"status": extra.get("status") or "",
"run_id": extra.get("run_id") or "",
"source": extra.get("source") or "",
"message": record["message"],
"metrics": extra.get("metrics") or {},
}
if "duration_ms" in extra and extra["duration_ms"] is not None:
payload["duration_ms"] = extra["duration_ms"]
if extra.get("fish_id") is not None:
payload["fish_id"] = extra["fish_id"]
if extra.get("error"):
payload["error"] = extra["error"]
if record["exception"] is not None:
payload["exception"] = str(record["exception"])
return payload
class _EventsJsonlSink:
"""以日期滚动写入 ``events_YYYY-MM-DD.jsonl``。
自己负责按本地日期滚动 + 旧文件保留清理;不走 loguru 的 ``format`` 通道,
避免 ``{"ts"}`` 这类 JSON 被当作格式占位符抛出 ``KeyError``。
"""
def __init__(
self,
log_dir: Path,
*,
retention_days: int,
prefix: str = "events_",
suffix: str = ".jsonl",
) -> None:
self._log_dir = log_dir
self._prefix = prefix
self._suffix = suffix
self._retention_days = max(1, int(retention_days))
self._lock = __import__("threading").Lock()
self._current_date: Optional[str] = None
self._fp = None # type: Optional[Any]
def _open_for_today(self) -> None:
today = time.strftime("%Y-%m-%d", time.localtime())
if self._current_date == today and self._fp is not None:
return
if self._fp is not None:
try:
self._fp.close()
except Exception:
pass
self._fp = None
self._log_dir.mkdir(parents=True, exist_ok=True)
path = self._log_dir / f"{self._prefix}{today}{self._suffix}"
self._fp = open(path, "a", encoding="utf-8")
self._current_date = today
self._cleanup_old()
def _cleanup_old(self) -> None:
try:
cutoff = time.time() - self._retention_days * 86400
for f in self._log_dir.glob(f"{self._prefix}*{self._suffix}"):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except OSError:
pass
except Exception:
pass
def __call__(self, message: Any) -> None:
record = message.record
if not record["extra"].get("event"):
return
payload = _build_event_payload(record)
try:
line = json.dumps(payload, ensure_ascii=False, default=str)
except (TypeError, ValueError):
line = json.dumps(
{"ts": payload.get("ts", ""), "message": str(payload)},
ensure_ascii=False,
)
with self._lock:
try:
self._open_for_today()
if self._fp is not None:
self._fp.write(line + "\n")
self._fp.flush()
except OSError:
pass
def close(self) -> None:
with self._lock:
if self._fp is not None:
try:
self._fp.close()
except Exception:
pass
self._fp = None
# ---------------------------------------------------------------------------
# Public setup
# ---------------------------------------------------------------------------
def _default_log_dir() -> Path:
here = Path(__file__).resolve()
return here.parents[1] / ".data" / "logs" / "fish_api"
def setup_logging(
settings: Optional[Any] = None,
*,
force: bool = False,
) -> None:
"""配置 loguru sink并把 uvicorn / fastapi 等 logger 接到 loguru。
- 第一次调用main.py 顶部 import 时):仅装 stderr sink不依赖 Settings
- 第二次调用FastAPI lifespan 启动时,``settings=get_settings()``):装 runtime
与 events 文件 sink``force=True`` 时会先卸载已有同类 sink 再装。
"""
global _loguru_console_configured, _loguru_file_configured
global _runtime_sink_id, _events_sink_id, _console_sink_id, _events_sink_obj
level = (os.environ.get("LOG_LEVEL") or "INFO").upper()
if settings is not None:
level = (getattr(settings, "log_level", None) or level).upper()
if not _loguru_console_configured:
# 第一次 setup清空所有内置 sink只装 stderr。
logger.remove()
_console_sink_id = logger.add(
sys.stderr,
level=level,
colorize=sys.stderr.isatty(),
format=(
_runtime_format_colored if sys.stderr.isatty() else _runtime_format
),
filter=_runtime_filter,
backtrace=False,
diagnose=False,
enqueue=False,
)
_loguru_console_configured = True
# 桥接标准 logging
intercept = InterceptHandler()
logging.root.handlers = [intercept]
logging.root.setLevel(logging.DEBUG)
for name in (
"uvicorn",
"uvicorn.error",
"uvicorn.access",
"uvicorn.asgi",
"fastapi",
"starlette",
"starlette.requests",
):
lg = logging.getLogger(name)
lg.handlers.clear()
lg.propagate = True
if settings is None:
return
# 安装文件 sinkruntime + events
if force or not _loguru_file_configured:
if _runtime_sink_id is not None:
try:
logger.remove(_runtime_sink_id)
except ValueError:
pass
_runtime_sink_id = None
if _events_sink_id is not None:
try:
logger.remove(_events_sink_id)
except ValueError:
pass
_events_sink_id = None
if _events_sink_obj is not None:
try:
_events_sink_obj.close()
except Exception:
pass
_events_sink_obj = None
log_dir = Path(getattr(settings, "log_dir", None) or _default_log_dir())
try:
log_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning(
"[logging] 无法创建日志目录 {}: {}(仅控制台日志生效)", log_dir, e
)
return
rotation = getattr(settings, "log_rotation", None) or "00:00"
retention_days = int(getattr(settings, "log_retention_days", 14) or 14)
retention = f"{retention_days} days"
file_level = (getattr(settings, "log_file_level", None) or level).upper()
runtime_path = str(log_dir / "runtime_{time:YYYY-MM-DD}.log")
_runtime_sink_id = logger.add(
runtime_path,
level=file_level,
format=_runtime_format,
filter=_runtime_filter,
rotation=rotation,
retention=retention,
encoding="utf-8",
enqueue=True,
backtrace=False,
diagnose=False,
)
_events_sink_obj = _EventsJsonlSink(
log_dir, retention_days=retention_days
)
_events_sink_id = logger.add(
_events_sink_obj,
level="DEBUG",
filter=_events_filter,
enqueue=True,
backtrace=False,
diagnose=False,
)
_loguru_file_configured = True
# ---------------------------------------------------------------------------
# Stage helpers
# ---------------------------------------------------------------------------
def new_run_id(pipeline: str = "") -> str:
"""生成短 run_id便于跨日志/事件关联。"""
prefix = (pipeline or "run").replace(" ", "_")[:24]
return f"{prefix}-{uuid.uuid4().hex[:12]}"
@contextmanager
def stage(
name_zh: str,
*,
pipeline: str,
step: str,
run_id: Optional[str] = None,
source: str = "",
fish_id: Optional[Any] = None,
metrics: Optional[Mapping[str, Any]] = None,
log_start: bool = True,
log_success: bool = True,
raise_on_error: bool = True,
) -> Iterator[str]:
"""中文阶段埋点:开始 / 完成 / 失败 自动写 runtime + events。
- ``run_id`` 不传时自动生成;通过 ``yield rid`` 暴露给调用方。
- 失败默认会重新抛出,仅写一条 ``status=fail`` 事件再抛。
- ``raise_on_error=False`` 时吞掉异常(仅记录),调用方需自行恢复。
"""
rid = run_id or new_run_id(pipeline)
src = str(source) if source else ""
base_metrics: dict[str, Any] = dict(metrics or {})
def _bind(extra_metrics: Optional[Mapping[str, Any]] = None, **extra: Any) -> Any:
m = dict(base_metrics)
if extra_metrics:
m.update(extra_metrics)
return logger.bind(
event=True,
pipeline=pipeline,
step=step,
run_id=rid,
source=src,
fish_id=fish_id,
metrics=m,
**extra,
)
t0 = time.perf_counter()
if log_start:
_bind(status="start", event_silent=False).info(
"开始: {} | source={}", name_zh, src or "-"
)
try:
yield rid
except Exception as e:
dt_ms = (time.perf_counter() - t0) * 1000.0
_bind(
status="fail",
duration_ms=round(dt_ms, 3),
error=f"{type(e).__name__}: {e}",
).error("失败: {} | 耗时={:.2f}s | {}: {}", name_zh, dt_ms / 1000.0, type(e).__name__, e)
if raise_on_error:
raise
return
dt_ms = (time.perf_counter() - t0) * 1000.0
if log_success:
_bind(status="success", duration_ms=round(dt_ms, 3)).success(
"完成: {} | 耗时={:.2f}s", name_zh, dt_ms / 1000.0
)
def bind_pipeline(
pipeline: str,
*,
run_id: Optional[str] = None,
source: str = "",
fish_id: Optional[Any] = None,
**extra: Any,
) -> Any:
"""在长流程内绑定 ``pipeline / run_id / source / fish_id``,返回 logger 代理。"""
rid = run_id or new_run_id(pipeline)
return logger.bind(
pipeline=pipeline,
run_id=rid,
source=str(source) if source else "",
fish_id=fish_id,
**extra,
)
def emit_event(
name_zh: str,
*,
pipeline: str,
step: str,
status: str = "info",
run_id: Optional[str] = None,
source: str = "",
fish_id: Optional[Any] = None,
metrics: Optional[Mapping[str, Any]] = None,
duration_ms: Optional[float] = None,
level: str = "INFO",
) -> None:
"""写一条带 ``event=True`` 的 INFO 日志runtime + events 都写)。"""
payload = logger.bind(
event=True,
pipeline=pipeline,
step=step,
status=status,
run_id=run_id or new_run_id(pipeline),
source=str(source) if source else "",
fish_id=fish_id,
metrics=dict(metrics or {}),
duration_ms=duration_ms,
)
payload.log(level.upper(), name_zh)