forked from erp-dev/erp
feat: log formatted
This commit is contained in:
149
flower/logging_formatters.py
Normal file
149
flower/logging_formatters.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 会携带 traceback,Formatter 会自动把 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,
|
||||
},
|
||||
|
||||
81
flower/uvicorn_log_config.json
Normal file
81
flower/uvicorn_log_config.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user