176 lines
4.8 KiB
Python
176 lines
4.8 KiB
Python
"""
|
|
结构化 JSON 日志 - 极简格式
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import json
|
|
import traceback
|
|
from pathlib import Path
|
|
from loguru import logger
|
|
|
|
# Provide logging.Logger compatibility for legacy calls
|
|
if not hasattr(logger, "isEnabledFor"):
|
|
logger.isEnabledFor = lambda _level: True
|
|
|
|
# 日志目录
|
|
DEFAULT_LOG_DIR = Path(__file__).parent.parent.parent / "logs"
|
|
LOG_DIR = Path(os.getenv("LOG_DIR", str(DEFAULT_LOG_DIR)))
|
|
LEGACY_TRAFFIC_LOG = DEFAULT_LOG_DIR.parent / "app_traffic.log"
|
|
_LOG_DIR_READY = False
|
|
|
|
|
|
def _cleanup_legacy_logs() -> None:
|
|
"""清理历史遗留的根目录日志文件。"""
|
|
try:
|
|
if LEGACY_TRAFFIC_LOG.exists() and LEGACY_TRAFFIC_LOG.is_file():
|
|
LEGACY_TRAFFIC_LOG.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _prepare_log_dir() -> bool:
|
|
"""确保日志目录可用"""
|
|
global LOG_DIR, _LOG_DIR_READY
|
|
if _LOG_DIR_READY:
|
|
return True
|
|
try:
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
_cleanup_legacy_logs()
|
|
_LOG_DIR_READY = True
|
|
return True
|
|
except Exception:
|
|
_LOG_DIR_READY = False
|
|
return False
|
|
|
|
|
|
def _format_json(record) -> str:
|
|
"""格式化日志"""
|
|
# ISO8601 时间
|
|
time_str = record["time"].strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
tz = record["time"].strftime("%z")
|
|
if tz:
|
|
time_str += tz[:3] + ":" + tz[3:]
|
|
|
|
log_entry = {
|
|
"time": time_str,
|
|
"level": record["level"].name.lower(),
|
|
"msg": record["message"],
|
|
"caller": f"{record['file'].name}:{record['line']}",
|
|
}
|
|
|
|
# trace 上下文
|
|
extra = record["extra"]
|
|
if extra.get("traceID"):
|
|
log_entry["traceID"] = extra["traceID"]
|
|
if extra.get("spanID"):
|
|
log_entry["spanID"] = extra["spanID"]
|
|
|
|
# 其他 extra 字段
|
|
for key, value in extra.items():
|
|
if key not in ("traceID", "spanID") and not key.startswith("_"):
|
|
log_entry[key] = value
|
|
|
|
# 错误及以上级别添加堆栈跟踪
|
|
if record["level"].no >= 40 and record["exception"]:
|
|
log_entry["stacktrace"] = "".join(
|
|
traceback.format_exception(
|
|
record["exception"].type,
|
|
record["exception"].value,
|
|
record["exception"].traceback,
|
|
)
|
|
)
|
|
|
|
return json.dumps(log_entry, ensure_ascii=False)
|
|
|
|
def _env_flag(name: str, default: bool) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in ("1", "true", "yes", "on", "y")
|
|
|
|
|
|
def _should_enqueue_logs() -> bool:
|
|
"""Serverless 环境禁用 multiprocessing 队列日志"""
|
|
if "VERCEL" in os.environ or "AWS_LAMBDA_FUNCTION_NAME" in os.environ:
|
|
return False
|
|
return _env_flag("LOG_ENQUEUE", True)
|
|
|
|
|
|
def _make_json_sink(output):
|
|
"""创建 JSON sink"""
|
|
|
|
def sink(message):
|
|
json_str = _format_json(message.record)
|
|
print(json_str, file=output, flush=True)
|
|
|
|
return sink
|
|
|
|
|
|
def _file_json_sink(message):
|
|
"""写入日志文件"""
|
|
record = message.record
|
|
json_str = _format_json(record)
|
|
log_file = LOG_DIR / f"app_{record['time'].strftime('%Y-%m-%d')}.log"
|
|
with open(log_file, "a", encoding="utf-8") as f:
|
|
f.write(json_str + "\n")
|
|
|
|
|
|
def setup_logging(
|
|
level: str = "DEBUG",
|
|
json_console: bool = True,
|
|
file_logging: bool | None = None,
|
|
):
|
|
"""设置日志配置"""
|
|
logger.remove()
|
|
if file_logging is None:
|
|
from app.core.config import get_config
|
|
file_logging = bool(get_config("app.app_log_enabled", True))
|
|
file_logging = _env_flag("LOG_FILE_ENABLED", file_logging)
|
|
enqueue_logs = _should_enqueue_logs()
|
|
|
|
# 控制台输出
|
|
if json_console:
|
|
logger.add(
|
|
_make_json_sink(sys.stderr),
|
|
level=level,
|
|
format="{message}",
|
|
colorize=False,
|
|
enqueue=enqueue_logs,
|
|
)
|
|
else:
|
|
logger.add(
|
|
sys.stderr,
|
|
level=level,
|
|
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{file.name}:{line}</cyan> - <level>{message}</level>",
|
|
colorize=True,
|
|
enqueue=enqueue_logs,
|
|
)
|
|
|
|
# 文件输出
|
|
if file_logging:
|
|
if _prepare_log_dir():
|
|
logger.add(
|
|
_file_json_sink,
|
|
level=level,
|
|
format="{message}",
|
|
enqueue=enqueue_logs,
|
|
)
|
|
else:
|
|
logger.warning("File logging disabled: no writable log directory.")
|
|
|
|
return logger
|
|
|
|
|
|
def get_logger(trace_id: str = "", span_id: str = ""):
|
|
"""获取绑定了 trace 上下文的 logger"""
|
|
bound = {}
|
|
if trace_id:
|
|
bound["traceID"] = trace_id
|
|
if span_id:
|
|
bound["spanID"] = span_id
|
|
return logger.bind(**bound) if bound else logger
|
|
|
|
|
|
__all__ = ["logger", "setup_logging", "get_logger", "LOG_DIR"]
|