174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
"""蓝图生成过程日志:控制台 + 落盘,便于对账。
|
||
|
||
环境变量:
|
||
- GENERATE_LOG_DIR:日志目录(默认仓库 .runtime/logs/generate)
|
||
- GENERATE_LOG_LEVEL:DEBUG/INFO/WARNING(默认 INFO)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import threading
|
||
import uuid
|
||
from contextvars import ContextVar
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
_root = Path(__file__).resolve().parents[1]
|
||
_LOGGER_NAME = "aijianzhan.generate"
|
||
_setup_lock = threading.Lock()
|
||
_configured = False
|
||
|
||
_current: ContextVar["GenerateTrace | None"] = ContextVar("generate_trace", default=None)
|
||
|
||
|
||
def _log_dir() -> Path:
|
||
raw = (os.getenv("GENERATE_LOG_DIR") or "").strip()
|
||
if raw:
|
||
return Path(raw).expanduser().resolve()
|
||
return (_root / ".runtime" / "logs" / "generate").resolve()
|
||
|
||
|
||
def _level() -> int:
|
||
name = (os.getenv("GENERATE_LOG_LEVEL") or "INFO").strip().upper()
|
||
return getattr(logging, name, logging.INFO)
|
||
|
||
|
||
def ensure_logging() -> logging.Logger:
|
||
"""幂等配置:stdout + 按日滚动的 generate.log。"""
|
||
global _configured
|
||
logger = logging.getLogger(_LOGGER_NAME)
|
||
if _configured:
|
||
return logger
|
||
with _setup_lock:
|
||
if _configured:
|
||
return logger
|
||
logger.setLevel(_level())
|
||
logger.propagate = False
|
||
fmt = logging.Formatter(
|
||
"%(asctime)s [%(levelname)s] %(message)s",
|
||
datefmt="%Y-%m-%d %H:%M:%S",
|
||
)
|
||
sh = logging.StreamHandler()
|
||
sh.setLevel(_level())
|
||
sh.setFormatter(fmt)
|
||
logger.addHandler(sh)
|
||
try:
|
||
d = _log_dir()
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
fh = logging.FileHandler(d / "generate.log", encoding="utf-8")
|
||
fh.setLevel(_level())
|
||
fh.setFormatter(fmt)
|
||
logger.addHandler(fh)
|
||
except OSError as e:
|
||
logger.warning("无法写入生成日志目录: %s", e)
|
||
_configured = True
|
||
return logger
|
||
|
||
|
||
class GenerateTrace:
|
||
"""单次 /apps/generate 请求的对账轨迹。"""
|
||
|
||
def __init__(self, run_id: str | None = None) -> None:
|
||
self.run_id = run_id or uuid.uuid4().hex[:10]
|
||
self.started_at = datetime.now(timezone.utc).isoformat()
|
||
self.lines: list[dict[str, Any]] = []
|
||
self._token = None
|
||
self._logger = ensure_logging()
|
||
self._run_file: Path | None = None
|
||
try:
|
||
d = _log_dir()
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
self._run_file = d / f"run_{self.run_id}.log"
|
||
except OSError:
|
||
self._run_file = None
|
||
|
||
def __enter__(self) -> "GenerateTrace":
|
||
self._token = _current.set(self)
|
||
self.info("generate.start", run_id=self.run_id)
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc, tb) -> None:
|
||
if exc_type is not None:
|
||
self.error(f"generate.failed: {exc}", error=str(exc))
|
||
else:
|
||
self.info("generate.done")
|
||
if self._token is not None:
|
||
_current.reset(self._token)
|
||
return False
|
||
|
||
def _emit(self, level: str, message: str, **fields: Any) -> None:
|
||
ts = datetime.now(timezone.utc).isoformat()
|
||
entry = {"ts": ts, "level": level, "message": message}
|
||
if fields:
|
||
# 仅保留可 JSON 序列化的简单字段
|
||
clean: dict[str, Any] = {}
|
||
for k, v in fields.items():
|
||
if v is None:
|
||
continue
|
||
if isinstance(v, (str, int, float, bool)):
|
||
clean[k] = v
|
||
elif isinstance(v, (list, tuple)) and all(
|
||
isinstance(x, (str, int, float, bool)) for x in v
|
||
):
|
||
clean[k] = list(v)
|
||
else:
|
||
clean[k] = str(v)[:500]
|
||
entry.update(clean)
|
||
self.lines.append(entry)
|
||
|
||
extra = " ".join(f"{k}={v}" for k, v in entry.items() if k not in {"ts", "level", "message"})
|
||
line = f"[{self.run_id}] {message}" + (f" | {extra}" if extra else "")
|
||
log_fn = getattr(self._logger, level if level in {"debug", "info", "warning", "error"} else "info")
|
||
log_fn(line)
|
||
if self._run_file is not None:
|
||
try:
|
||
with self._run_file.open("a", encoding="utf-8") as f:
|
||
f.write(f"{ts} [{level.upper()}] {line}\n")
|
||
except OSError:
|
||
pass
|
||
|
||
def debug(self, message: str, **fields: Any) -> None:
|
||
self._emit("debug", message, **fields)
|
||
|
||
def info(self, message: str, **fields: Any) -> None:
|
||
self._emit("info", message, **fields)
|
||
|
||
def warning(self, message: str, **fields: Any) -> None:
|
||
self._emit("warning", message, **fields)
|
||
|
||
def error(self, message: str, **fields: Any) -> None:
|
||
self._emit("error", message, **fields)
|
||
|
||
def stage(self, name: str, detail: str = "", **fields: Any) -> None:
|
||
msg = f"stage.{name}" + (f": {detail}" if detail else "")
|
||
self.info(msg, stage=name, **fields)
|
||
|
||
def note(self, text: str) -> None:
|
||
"""把 warnings/notes 同步进日志。"""
|
||
t = (text or "").strip()
|
||
if t:
|
||
self.info(t)
|
||
|
||
def notes(self, items: list[str] | None) -> None:
|
||
for n in items or []:
|
||
self.note(str(n))
|
||
|
||
def as_payload(self) -> dict[str, Any]:
|
||
return {
|
||
"run_id": self.run_id,
|
||
"started_at": self.started_at,
|
||
"log_file": str(self._run_file) if self._run_file else "",
|
||
"lines": list(self.lines),
|
||
}
|
||
|
||
|
||
def get_trace() -> GenerateTrace | None:
|
||
return _current.get()
|
||
|
||
|
||
def glog() -> GenerateTrace | None:
|
||
"""当前请求轨迹;无上下文时返回 None(调用方需判空)。"""
|
||
return get_trace()
|