1
0
forked from erp-dev/erp

feat: log formatted

This commit is contained in:
2026-06-26 17:03:54 +08:00
parent e8bf4b5e49
commit cb6589c884
26 changed files with 1410 additions and 50 deletions

View File

@@ -0,0 +1,149 @@
import json
import logging
import os
import socket
import traceback
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlsplit
_STANDARD_RECORD_ATTRS = {
"args",
"asctime",
"created",
"exc_info",
"exc_text",
"filename",
"funcName",
"levelname",
"levelno",
"lineno",
"message",
"module",
"msecs",
"msg",
"name",
"pathname",
"process",
"processName",
"relativeCreated",
"stack_info",
"taskName",
"thread",
"threadName",
}
def _json_default(value: Any) -> str:
return repr(value)
def _utc_timestamp(created: float) -> str:
return datetime.fromtimestamp(created, tz=timezone.utc).isoformat(timespec="milliseconds")
def _split_client_addr(client_addr: Any) -> tuple[str | None, int | None]:
if not isinstance(client_addr, str):
return None, None
host, sep, port = client_addr.rpartition(":")
if not sep:
return client_addr, None
try:
return host, int(port)
except ValueError:
return client_addr, None
class JsonFormatter(logging.Formatter):
"""
Emit one JSON object per log line for container log collectors.
Uvicorn access logs carry structured fields in record.args; keep using
uvicorn's own access logger and only unpack those fields at format time.
"""
def __init__(self, *args, service_name: str | None = None, environment: str | None = None, **kwargs):
super().__init__(*args, **kwargs)
self.service_name = service_name or os.getenv("SERVICE_NAME", "flower")
self.environment = environment or os.getenv("ENVIRONMENT") or os.getenv("DJANGO_ENV") or ""
self.hostname = socket.gethostname()
def format(self, record: logging.LogRecord) -> str:
event = {
"timestamp": _utc_timestamp(record.created),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"service": self.service_name,
"environment": self.environment,
"hostname": self.hostname,
"module": record.module,
"function": record.funcName,
"line": record.lineno,
"process": record.process,
"process_name": record.processName,
"thread": record.threadName,
}
if record.name == "uvicorn.access":
event.update(self._format_uvicorn_access(record))
if record.exc_info:
exc_type, exc_value, _tb = record.exc_info
event["exception"] = {
"type": exc_type.__name__ if exc_type else None,
"message": str(exc_value) if exc_value else None,
"stacktrace": "".join(traceback.format_exception(*record.exc_info)),
}
elif record.exc_text:
event["exception"] = {"stacktrace": record.exc_text}
if record.stack_info:
event["stack_info"] = record.stack_info
extra = self._collect_extra(record)
if extra:
event["extra"] = extra
return json.dumps(event, ensure_ascii=False, default=_json_default, separators=(",", ":"))
def _format_uvicorn_access(self, record: logging.LogRecord) -> dict[str, Any]:
if not isinstance(record.args, tuple) or len(record.args) != 5:
return {}
client_addr, method, full_path, http_version, status_code = record.args
split_result = urlsplit(str(full_path))
client_host, client_port = _split_client_addr(client_addr)
return {
"client_addr": client_addr,
"client_host": client_host,
"client_port": client_port,
"method": method,
"full_path": full_path,
"path": split_result.path or str(full_path),
"query_string": split_result.query,
"http_version": http_version,
"status_code": int(status_code),
}
def _collect_extra(self, record: logging.LogRecord) -> dict[str, Any]:
extra = {}
for key, value in record.__dict__.items():
if key.startswith("_") or key in _STANDARD_RECORD_ATTRS:
continue
extra[key] = value
return extra
class MaxLevelFilter(logging.Filter):
"""Allow records below the configured level."""
def __init__(self, max_level: str | int):
super().__init__()
self.max_level = logging._checkLevel(max_level)
def filter(self, record: logging.LogRecord) -> bool:
return record.levelno < self.max_level

View File

@@ -18,10 +18,10 @@ class ApiAuditLogMiddleware:
"""
API审计日志中间件
用于记录特定URL前缀的POST请求保存"创建"操作的历史现场。
用于记录特定URL前缀的POST/PUT/PATCH请求,保存"创建/更新"操作的历史现场。
特性:
- 只记录POST请求
- 只记录POST、PUT、PATCH请求
- 通过URL前缀白名单过滤
- 支持multipart/form-data请求文件字段只记录元信息
- 通过Celery异步写入不阻塞API响应
@@ -42,8 +42,8 @@ class ApiAuditLogMiddleware:
if not self.enabled:
return self.get_response(request)
# 只处理POST请求
if request.method != 'POST':
# 只处理POST、PUT、PATCH请求
if request.method not in ('POST', 'PUT', 'PATCH'):
return self.get_response(request)
# 检查URL是否在白名单中
@@ -51,7 +51,7 @@ class ApiAuditLogMiddleware:
logger.debug('ApiAuditLog: URL不匹配白名单, path=%s, prefixes=%s', request.path, self.url_prefixes)
return self.get_response(request)
logger.info('ApiAuditLog: 捕获到POST请求, path=%s', request.path)
logger.info('ApiAuditLog: 捕获到%s请求, path=%s', request.method, request.path)
# 在请求进入视图前,缓存请求体数据
# 注意request.body只能读取一次需要在这里缓存
@@ -86,30 +86,54 @@ class ApiAuditLogMiddleware:
return True
return False
def _get_post_and_files(self, request):
"""
获取表单字段和文件兼容PUT/PATCH请求
Django的request.POST/request.FILES只在method为POST时才会被解析
PUT/PATCH请求即使是multipart/form-data或urlencoded也不会自动填充
因此这里针对非POST方法手动解析。
"""
if request.method == 'POST':
return request.POST, request.FILES
content_type = request.content_type or ''
if 'multipart/form-data' in content_type:
try:
return request.parse_file_upload(request.META, io.BytesIO(request.body))
except MultiPartParserError:
return QueryDict(), MultiValueDict()
elif 'application/x-www-form-urlencoded' in content_type:
return QueryDict(request.body, encoding=request.encoding or settings.DEFAULT_CHARSET), MultiValueDict()
return QueryDict(), MultiValueDict()
def _extract_request_data(self, request) -> dict:
"""
提取请求数据
对于multipart/form-data请求分别处理表单字段和文件字段。
文件字段只记录元信息(文件名、大小、类型),不记录二进制内容。
"""
content_type = request.content_type or ''
if 'multipart/form-data' in content_type:
# multipart请求分别处理表单字段和文件
data = {}
post, files = self._get_post_and_files(request)
# 处理普通表单字段
for key, values in request.POST.lists():
for key, values in post.lists():
if len(values) == 1:
data[key] = values[0]
else:
data[key] = values
# 处理文件字段:只记录元信息
for key, files in request.FILES.lists():
for key, file_list in files.lists():
file_infos = []
for f in files:
for f in file_list:
file_infos.append({
'_type': 'file',
'name': f.name,
@@ -120,26 +144,27 @@ class ApiAuditLogMiddleware:
data[key] = file_infos[0]
else:
data[key] = file_infos
return data
elif 'application/json' in content_type:
# JSON请求解析body
try:
return json.loads(request.body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
return {'_raw': request.body.decode('utf-8', errors='replace')}
elif 'application/x-www-form-urlencoded' in content_type:
# 表单请求
data = {}
for key, values in request.POST.lists():
post, _ = self._get_post_and_files(request)
for key, values in post.lists():
if len(values) == 1:
data[key] = values[0]
else:
data[key] = values
return data
else:
# 其他类型:尝试作为文本记录
try:

View File

@@ -2,6 +2,10 @@
七牛云 SDK 兼容性补丁
修复 qiniu SDK 中 FormUploader 对空数据的判断问题
"""
import logging
logger = logging.getLogger(__name__)
def patch_qiniu_form_uploader():
@@ -112,14 +116,10 @@ def patch_qiniu_form_uploader():
# 替换方法
FormUploader.upload = upload_wrapper
print("✓ 七牛云 FormUploader 补丁已应用")
return True
except Exception as e:
print(f"应用七牛云补丁时出错: {e}")
import traceback
traceback.print_exc()
logger.exception("应用七牛云补丁时出错: %s", e)
return False

View File

@@ -360,7 +360,7 @@ PRE_SALES_ORDER_CREATED_FOLLOWUP_URL_TEMPLATE = env(
# Logging configuration
# 目标:
# - 生产环境出现 500 时,能在 stdout 日志里看到完整 traceback
# - 所有日志都带时间戳(含毫秒),便于快速定位与检索
# - 所有日志以单行 JSON 输出,便于 Loki / Filebeat 检索与聚合
LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
DJANGO_LOG_LEVEL = env('DJANGO_LOG_LEVEL', default=LOG_LEVEL).upper()
UVICORN_LOG_LEVEL = env('UVICORN_LOG_LEVEL', default=LOG_LEVEL).upper()
@@ -370,49 +370,60 @@ LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'default': {
# 注意Python logging 的 datefmt 基于 time.strftime不支持 %f
# 这里用 %(msecs)03d 输出毫秒。
'format': '%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s',
'datefmt': '%Y-%m-%d %H:%M:%S',
'json': {
'()': 'flower.logging_formatters.JsonFormatter',
},
},
'filters': {
'below_warning': {
'()': 'flower.logging_formatters.MaxLevelFilter',
'max_level': 'WARNING',
},
},
'handlers': {
'console': {
'stdout': {
'class': 'logging.StreamHandler',
'formatter': 'default',
'formatter': 'json',
'stream': 'ext://sys.stdout',
'filters': ['below_warning'],
},
'stderr': {
'class': 'logging.StreamHandler',
'formatter': 'json',
'stream': 'ext://sys.stderr',
'level': 'WARNING',
},
},
'root': {
'handlers': ['console'],
'handlers': ['stdout', 'stderr'],
'level': LOG_LEVEL,
},
'loggers': {
# Django 在发生未捕获异常500会通过 django.request 输出错误日志,
# record.exc_info 会携带 tracebackFormatter 会自动把 traceback 打到日志中。
'django.request': {
'handlers': ['console'],
'handlers': ['stdout', 'stderr'],
'level': 'ERROR',
'propagate': False,
},
'django': {
'handlers': ['console'],
'handlers': ['stdout', 'stderr'],
'level': DJANGO_LOG_LEVEL,
'propagate': False,
},
# Uvicorn 日志access/error统一走我们的 console formatter确保有时间戳
'uvicorn': {
'handlers': ['console'],
'handlers': ['stdout', 'stderr'],
'level': UVICORN_LOG_LEVEL,
'propagate': False,
},
'uvicorn.error': {
'handlers': ['console'],
'handlers': ['stdout', 'stderr'],
'level': UVICORN_LOG_LEVEL,
'propagate': False,
},
'uvicorn.access': {
'handlers': ['console'],
'handlers': ['stdout', 'stderr'],
'level': UVICORN_ACCESS_LOG_LEVEL,
'propagate': False,
},

View File

@@ -0,0 +1,81 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"default": {
"()": "flower.logging_formatters.JsonFormatter"
},
"access": {
"()": "flower.logging_formatters.JsonFormatter"
}
},
"filters": {
"below_warning": {
"()": "flower.logging_formatters.MaxLevelFilter",
"max_level": "WARNING"
}
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stdout",
"filters": [
"below_warning"
]
},
"stderr": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stderr",
"level": "WARNING"
},
"access_stdout": {
"class": "logging.StreamHandler",
"formatter": "access",
"stream": "ext://sys.stdout",
"filters": [
"below_warning"
]
},
"access_stderr": {
"class": "logging.StreamHandler",
"formatter": "access",
"stream": "ext://sys.stderr",
"level": "WARNING"
}
},
"root": {
"handlers": [
"stdout",
"stderr"
],
"level": "INFO"
},
"loggers": {
"uvicorn": {
"handlers": [
"stdout",
"stderr"
],
"level": "INFO",
"propagate": false
},
"uvicorn.error": {
"handlers": [
"stdout",
"stderr"
],
"level": "INFO",
"propagate": false
},
"uvicorn.access": {
"handlers": [
"access_stdout",
"access_stderr"
],
"level": "INFO",
"propagate": false
}
}
}