File size: 12,633 Bytes
b4f2238 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 | """
Django 日志配置模块
提供结构化日志配置
Docker 环境支持:
- 自动检测 Docker 环境
- 优先输出到 stdout/stderr(Docker 日志收集)
- 同时保留文件日志(持久化到 Volume)
"""
import os
import sys
from typing import Any
def _safe_get_config(key: str, default: Any = None) -> Any:
"""安全获取配置,避免循环导入"""
try:
from .config import get_config
return get_config(key, default)
except (ImportError, AttributeError, KeyError):
return default
def _is_docker_environment() -> bool:
"""
检测是否在 Docker 环境中运行
检测方法:
1. 检查 /.dockerenv 文件
2. 检查 /proc/1/cgroup 中是否包含 docker
3. 检查环境变量 DOCKER_CONTAINER
"""
# 方法1: 检查 .dockerenv 文件
if os.path.exists("/.dockerenv"):
return True
# 方法2: 检查 cgroup
try:
with open("/proc/1/cgroup") as f:
if "docker" in f.read():
return True
except (FileNotFoundError, PermissionError):
pass
# 方法3: 检查环境变量
if os.environ.get("DOCKER_CONTAINER") == "true":
return True
# 方法4: 检查常见容器内数据库主机标识
db_host = os.environ.get("DB_HOST", "")
if db_host in {"postgres", "mysql", "mariadb"}:
return True
return False
def get_logging_config(base_dir: Any, debug: bool = True) -> dict[str, Any]:
"""
获取日志配置
从统一配置管理系统获取日志配置参数
Docker 环境特性:
- 优先输出到 stdout(Docker 日志收集)
- 使用 JSON 格式便于日志聚合
- 同时保留文件日志到 Volume
Args:
base_dir: 项目根目录
debug: 是否为调试模式
Returns:
Django LOGGING 配置字典
"""
is_docker = _is_docker_environment()
log_dir = os.path.join(base_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
# 从配置系统获取日志参数
file_max_size = _safe_get_config("logging.file_max_size", 10 * 1024 * 1024) # 10MB
api_backup_count = _safe_get_config("logging.api_backup_count", 5)
error_backup_count = _safe_get_config("logging.error_backup_count", 10)
sql_backup_count = _safe_get_config("logging.sql_backup_count", 3)
# 获取日志级别配置
console_level = _safe_get_config("logging.console_level", "DEBUG" if debug else "INFO")
file_level = _safe_get_config("logging.file_level", "INFO")
error_level = _safe_get_config("logging.error_level", "ERROR")
django_level = _safe_get_config("logging.django_level", "INFO")
request_level = _safe_get_config("logging.request_level", "WARNING")
apps_level = _safe_get_config("logging.apps_level", "DEBUG" if debug else "INFO")
root_level = _safe_get_config("logging.root_level", "WARNING")
# Docker 环境使用 JSON 格式,便于日志聚合
console_formatter = "json" if is_docker and not debug else "simple"
config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "[{asctime}] {levelname} {name} {module}.{funcName}:{lineno} - {message}",
"style": "{",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"simple": {
"format": "[{asctime}] {levelname} - {message}",
"style": "{",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"json": {
"()": "apps.core.infrastructure.logging.JsonFormatter",
},
"docker": {
# Docker 友好格式:包含时间戳、级别、模块信息
"format": "{asctime} | {levelname:8} | {name} | {message}",
"style": "{",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"require_debug_true": {
"()": "django.utils.log.RequireDebugTrue",
},
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
},
"handlers": {
# 控制台输出(Docker 环境下输出到 stdout)
"console": {
"level": console_level,
"class": "logging.StreamHandler",
"stream": sys.stdout, # 明确指定 stdout
"formatter": console_formatter,
},
# 错误输出到 stderr(Docker 环境下便于区分)
"console_error": {
"level": "ERROR",
"class": "logging.StreamHandler",
"stream": sys.stderr, # 错误输出到 stderr
"formatter": console_formatter,
},
"file_api": {
"level": file_level,
"class": "logging.handlers.RotatingFileHandler",
"filename": os.path.join(log_dir, "api.log"),
"maxBytes": file_max_size,
"backupCount": api_backup_count,
"formatter": "verbose",
"encoding": "utf-8",
},
"file_error": {
"level": error_level,
"class": "logging.handlers.RotatingFileHandler",
"filename": os.path.join(log_dir, "error.log"),
"maxBytes": file_max_size,
"backupCount": error_backup_count,
"formatter": "verbose",
"encoding": "utf-8",
},
"file_sql": {
"level": "DEBUG",
"class": "logging.handlers.RotatingFileHandler",
"filename": os.path.join(log_dir, "sql.log"),
"maxBytes": file_max_size,
"backupCount": sql_backup_count,
"formatter": "simple",
"encoding": "utf-8",
"filters": ["require_debug_true"],
},
},
"loggers": {
"django": {
"handlers": ["console", "console_error", "file_error"],
"level": django_level,
"propagate": True,
},
"django.request": {
"handlers": ["console", "console_error", "file_error"],
"level": request_level,
"propagate": False,
},
"django.db.backends": {
"handlers": ["file_sql"] if debug else [],
"level": "DEBUG" if debug else "INFO",
"propagate": False,
},
"api": {
"handlers": ["console", "console_error", "file_api", "file_error"],
"level": apps_level,
"propagate": False,
},
"apps": {
"handlers": ["console", "console_error", "file_api", "file_error"],
"level": apps_level,
"propagate": False,
},
"apps.core.llm": {
"handlers": ["console", "console_error", "file_api", "file_error"],
"level": "INFO",
"propagate": False,
},
"RapidOCR": {
"handlers": ["console", "console_error", "file_api", "file_error"],
"level": "WARNING",
"propagate": False,
},
"rapidocr": {
"handlers": ["console", "console_error", "file_api", "file_error"],
"level": "WARNING",
"propagate": False,
},
"onnxruntime": {
"handlers": ["console", "console_error", "file_api", "file_error"],
"level": "WARNING",
"propagate": False,
},
# Gunicorn 日志(Docker 生产环境)
"gunicorn.error": {
"handlers": ["console", "console_error"],
"level": "INFO",
"propagate": False,
},
"gunicorn.access": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
"root": {
"handlers": ["console", "console_error", "file_error"],
"level": root_level,
},
}
return config
class JsonFormatter:
"""JSON 格式化器,用于结构化日志输出"""
def __init__(self) -> None:
import json
self.json = json
def format(self, record: Any) -> str:
import traceback
from datetime import datetime
log_data = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"level": record.levelname,
"logger": record.name,
"module": record.module,
"function": record.funcName,
"line": record.lineno,
"message": record.getMessage(),
}
# 添加额外字段
if hasattr(record, "path"):
log_data["path"] = record.path
if hasattr(record, "method"):
log_data["method"] = record.method
if hasattr(record, "user"):
log_data["user"] = str(record.user)
if hasattr(record, "errors"):
log_data["errors"] = record.errors
# 添加异常信息
if record.exc_info:
log_data["exception"] = "".join(traceback.format_exception(*record.exc_info))
return self.json.dumps(log_data, ensure_ascii=False)
import logging
import re
from typing import ClassVar
class SensitiveDataFilter(logging.Filter):
"""敏感数据过滤器,自动脱敏日志中的敏感信息"""
# 需要完全遮蔽的字段名(不区分大小写)
_SENSITIVE_KEYS = frozenset({"authorization", "token", "password", "secret", "api_key", "apikey"})
# 消息中需要脱敏的正则模式
_MSG_PATTERNS: ClassVar[list[tuple[re.Pattern[str], str]]] = [
(re.compile(r"(Authorization:\s*Bearer\s+)\S+", re.IGNORECASE), r"\1***"),
(re.compile(r"(token\s*=\s*)sk-\S+", re.IGNORECASE), r"\1***"),
(re.compile(r"sk-[A-Za-z0-9]{20,}", re.IGNORECASE), "***"),
]
@staticmethod
def _mask_email(value: str) -> str:
"""部分遮蔽邮件地址:保留首2字符和末2字符(含域名末2字符)"""
at_pos = value.find("@")
if at_pos > 0:
full = value
# 保留整体首2字符和末2字符
if len(full) > 4:
return full[:2] + "***" + full[-2:]
return "***"
# 非邮件字符串,保留首2末2
if len(value) > 4:
return value[:2] + "***" + value[-2:]
return "***"
def _scrub_value(self, key: str, value: object) -> object:
"""根据 key 决定如何脱敏 value"""
if isinstance(value, dict):
return {k: self._scrub_value(k, v) for k, v in value.items()}
if isinstance(value, str):
lower_key = key.lower()
if lower_key in self._SENSITIVE_KEYS:
return "***"
if lower_key in {"account", "email", "username"}:
return self._mask_email(value)
# 检查值本身是否包含敏感 token
if re.search(r"sk-[A-Za-z0-9]{20,}", value, re.IGNORECASE):
return re.sub(r"sk-[A-Za-z0-9]{20,}", "***", value, flags=re.IGNORECASE)
return value
def _scrub_message(self, msg: str) -> str:
for pattern, replacement in self._MSG_PATTERNS:
msg = pattern.sub(replacement, msg)
return msg
def filter(self, record: logging.LogRecord) -> bool:
# 脱敏消息
record.msg = self._scrub_message(str(record.msg))
# 脱敏 record 上的额外属性
for attr in list(vars(record).keys()):
if attr.startswith("_") or attr in {
"name",
"msg",
"args",
"levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"taskName",
}:
continue
val = getattr(record, attr)
setattr(record, attr, self._scrub_value(attr, val))
return True
|