1- """日志模块."""
1+ """日志模块.
2+
3+ 提供 uvicorn 兼容的 dictConfig 构建、JSON 结构化格式化器、
4+ 以及 gzip 压缩轮转支持。
5+ """
26
37from __future__ import annotations
48
9+ import gzip
10+ import logging
11+ import logging .handlers
12+ import os
13+ from pathlib import Path
14+
15+ from .formatters import JsonFormatter
16+
17+ # ── 常量 ────────────────────────────────────────────────────────
18+
19+ _DEFAULT_MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file
20+ _DEFAULT_BACKUP_COUNT = 5 # Keep 5 rotated backups
21+ _FILE_LOG_LEVEL = "DEBUG" # File logs capture everything
22+
23+
24+ def _gzip_namer (default_name : str ) -> str :
25+ """RotatingFileHandler namer: 为轮转文件添加 .gz 后缀."""
26+ return default_name + ".gz"
27+
28+
29+ def _gzip_rotator (source : str , dest : str ) -> None :
30+ """RotatingFileHandler rotator: 将源文件 gzip 压缩后写入目标.
31+
32+ 流程:
33+ 1. 读取 source 文件全部内容
34+ 2. gzip 压缩写入 dest 文件
35+ 3. 删除 source 原文件
36+ """
37+ with open (source , "rb" ) as f_in :
38+ data = f_in .read ()
39+ with open (dest , "wb" ) as f_out :
40+ f_out .write (gzip .compress (data , compresslevel = 6 ))
41+ os .remove (source )
42+
43+
44+ def _create_rotating_file_handler (
45+ * ,
46+ filename : str ,
47+ maxBytes : int = _DEFAULT_MAX_BYTES ,
48+ backupCount : int = _DEFAULT_BACKUP_COUNT ,
49+ encoding : str = "utf-8" ,
50+ ) -> logging .handlers .RotatingFileHandler :
51+ """创建带 gzip 压缩轮转的 RotatingFileHandler(dictConfig 兼容工厂函数).
52+
53+ ``logging.config.dictConfig`` 仅支持通过构造函数 kwargs 配置 handler,
54+ 而 ``namer`` / ``rotator`` 是实例属性而非构造参数,因此需要通过工厂函数注入。
55+ """
56+ handler = logging .handlers .RotatingFileHandler (
57+ filename = filename ,
58+ maxBytes = maxBytes ,
59+ backupCount = backupCount ,
60+ encoding = encoding ,
61+ )
62+ handler .namer = _gzip_namer
63+ handler .rotator = _gzip_rotator
64+ return handler
65+
566
6- def build_log_config (level : str = "INFO" ) -> dict :
7- """构建 uvicorn log_config,使用 yyyy-MM-dd HH:mm:ss 时间格式."""
8- return {
67+ def build_log_config (
68+ level : str = "INFO" ,
69+ file_path : str | None = None ,
70+ max_bytes : int = _DEFAULT_MAX_BYTES ,
71+ backup_count : int = _DEFAULT_BACKUP_COUNT ,
72+ ) -> dict :
73+ """构建 uvicorn log_config,支持双写(控制台 + 文件).
74+
75+ Args:
76+ level: 控制台日志级别(默认 INFO)。
77+ file_path: 文件日志路径。为 ``None`` 时仅输出到控制台(向后兼容)。
78+ max_bytes: 单个日志文件最大字节数(默认 5 MB)。
79+ backup_count: 保留的轮转备份文件数(默认 5)。
80+
81+ Returns:
82+ 符合 ``logging.config.dictConfig`` 规范的字典。
83+
84+ 双写行为:
85+ - 控制台:人类可读格式,级别由 ``level`` 参数控制(handler 级别过滤)
86+ - 文件:JSON 结构化格式,固定 DEBUG 级别(捕获所有日志)
87+ - 当 ``file_path`` 为 ``None`` 或空字符串时,退化为纯控制台模式(向后兼容)
88+ """
89+ config : dict = {
990 "version" : 1 ,
1091 "disable_existing_loggers" : False ,
1192 "formatters" : {
@@ -17,7 +98,7 @@ def build_log_config(level: str = "INFO") -> dict:
1798 },
1899 "access" : {
19100 "()" : "uvicorn.logging.AccessFormatter" ,
20- " fmt" : '%(asctime)s %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s' ,
101+ ' fmt' : '%(asctime)s %(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s' ,
21102 "datefmt" : "%Y-%m-%d %H:%M:%S" ,
22103 },
23104 },
@@ -26,11 +107,13 @@ def build_log_config(level: str = "INFO") -> dict:
26107 "formatter" : "default" ,
27108 "class" : "logging.StreamHandler" ,
28109 "stream" : "ext://sys.stderr" ,
110+ "level" : level ,
29111 },
30112 "access" : {
31113 "formatter" : "access" ,
32114 "class" : "logging.StreamHandler" ,
33115 "stream" : "ext://sys.stdout" ,
116+ "level" : "INFO" ,
34117 },
35118 },
36119 "loggers" : {
@@ -48,3 +131,53 @@ def build_log_config(level: str = "INFO") -> dict:
48131 },
49132 },
50133 }
134+
135+ # ── 条件注入:文件日志基础设施 ────────────────────────────
136+ if file_path :
137+ # 确保日志目录存在
138+ log_file = Path (file_path )
139+ log_file .parent .mkdir (parents = True , exist_ok = True )
140+
141+ # 注入 JSON formatter
142+ config ["formatters" ]["json" ] = {
143+ "()" : "coding.proxy.logging.formatters.JsonFormatter" ,
144+ }
145+
146+ # 注入 RotatingFileHandler(gzip 压缩轮转)
147+ # 使用工厂函数(而非 class + namer/rotator kwargs),
148+ # 因为 dictConfig 不支持将 namer/rotator 作为构造参数传递
149+ config ["handlers" ]["file" ] = {
150+ "formatter" : "json" ,
151+ "()" : "coding.proxy.logging._create_rotating_file_handler" ,
152+ "filename" : str (log_file .resolve ()),
153+ "maxBytes" : max_bytes ,
154+ "backupCount" : backup_count ,
155+ "encoding" : "utf-8" ,
156+ }
157+
158+ # 为每个 logger 添加 file handler
159+ # 注意:uvicorn.error 无 handlers 键(通过 propagate 继承 uvicorn 的 handler)
160+ for logger_name in ("uvicorn" , "uvicorn.error" , "uvicorn.access" , "coding.proxy" ):
161+ logger_cfg = config ["loggers" ][logger_name ]
162+ handlers = logger_cfg .get ("handlers" , [])
163+ if isinstance (handlers , list ):
164+ handlers .append ("file" )
165+ logger_cfg ["handlers" ] = handlers
166+ else :
167+ logger_cfg ["handlers" ] = [handlers , "file" ]
168+
169+ # Logger 级别设为 DEBUG(让所有消息通过到 file handler)
170+ # Console handler 已设 level 过滤,确保控制台仅输出 INFO+
171+ config ["loggers" ]["coding.proxy" ]["level" ] = _FILE_LOG_LEVEL
172+ config ["loggers" ]["uvicorn" ]["level" ] = _FILE_LOG_LEVEL
173+ config ["loggers" ]["uvicorn.error" ]["level" ] = _FILE_LOG_LEVEL
174+
175+ return config
176+
177+
178+ __all__ = [
179+ "build_log_config" ,
180+ "JsonFormatter" ,
181+ "_gzip_namer" ,
182+ "_gzip_rotator" ,
183+ ]
0 commit comments