Skip to content

Commit afcf1a3

Browse files
authored
Merge pull request #148 from ThreeFish-AI/vk/b1c7-
feat(logging): 实现日志双写(控制台+文件)
2 parents a02b819 + 3232c93 commit afcf1a3

8 files changed

Lines changed: 530 additions & 7 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ junit.xml
2222
config.yaml
2323
.claude/.prompts.md
2424
.python-version
25+
26+
# Log files (dual-write logging)
27+
coding-proxy.log*

src/coding/proxy/cli/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,18 @@ def start(
104104
# 打印启动品牌横幅
105105
print_banner(console, host=cfg.server.host, port=cfg.server.port)
106106

107+
# 解析文件日志路径:未显式配置时使用默认值
108+
_file_path: str | None = cfg.logging.file or "coding-proxy.log"
107109
uvicorn.run(
108110
fastapi_app,
109111
host=cfg.server.host,
110112
port=cfg.server.port,
111-
log_config=build_log_config(cfg.logging.level),
113+
log_config=build_log_config(
114+
level=cfg.logging.level,
115+
file_path=_file_path,
116+
max_bytes=cfg.logging.max_bytes,
117+
backup_count=cfg.logging.backup_count,
118+
),
112119
)
113120

114121

src/coding/proxy/config/config.default.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ server:
88

99
logging:
1010
level: "INFO"
11+
# file: "coding-proxy.log" # 文件日志路径;设为 null 或空字符串禁用
12+
# max_bytes: 5242880 # 单文件上限(5 MB),触发轮转
13+
# backup_count: 5 # 保留 gzip 压缩备份文件数
1114

1215
# === 降级链路优先级(可选) ===
1316
#

src/coding/proxy/config/server.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,19 @@ class DatabaseConfig(BaseModel):
1717

1818

1919
class LoggingConfig(BaseModel):
20+
"""日志配置.
21+
22+
Attributes:
23+
level: 控制台日志级别(INFO / WARNING / DEBUG 等)。
24+
file: 文件日志路径。为 ``None`` 时使用默认值 ``coding-proxy.log``;
25+
设为空字符串可禁用文件日志。
26+
max_bytes: 单个日志文件最大字节数(触发轮转)。默认 5 MB。
27+
backup_count: 保留的已压缩备份文件数。默认 5。
28+
"""
2029
level: str = "INFO"
2130
file: str | None = None
31+
max_bytes: int = 5 * 1024 * 1024
32+
backup_count: int = 5
2233

2334

2435
__all__ = ["ServerConfig", "DatabaseConfig", "LoggingConfig"]

src/coding/proxy/logging/__init__.py

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,92 @@
1-
"""日志模块."""
1+
"""日志模块.
2+
3+
提供 uvicorn 兼容的 dictConfig 构建、JSON 结构化格式化器、
4+
以及 gzip 压缩轮转支持。
5+
"""
26

37
from __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+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""结构化日志格式化器(JSON 输出).
2+
3+
为文件日志提供机器可读的 JSON 格式输出,
4+
与控制台的人类可读格式形成正交双写。
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import logging
11+
from datetime import datetime, timezone
12+
13+
14+
class JsonFormatter(logging.Formatter):
15+
"""将 LogRecord 格式化为单行 JSON 字符串.
16+
17+
输出字段:
18+
- ``timestamp``: ISO 8601 UTC 时间戳
19+
- ``level``: 日志级别名称(DEBUG/INFO/WARNING/ERROR)
20+
- ``logger``: logger 名称(如 ``coding.proxy.routing.executor``)
21+
- ``message``: 格式化后的日志消息
22+
- ``exception``: 异常堆栈(仅当存在时)
23+
24+
设计要点:
25+
- 使用 ``ensure_ascii=False`` 支持中文日志内容
26+
- 异常信息(exc_info)序列化为 ``exception`` 字段
27+
- 时间戳统一使用 UTC ISO 格式,便于跨时区聚合分析
28+
- ``sort_keys=True`` 保证输出确定性,便于日志聚合工具处理
29+
"""
30+
31+
def format(self, record: logging.LogRecord) -> str:
32+
"""将 LogRecord 序列化为 JSON 行."""
33+
message = record.getMessage()
34+
35+
exception: str | None = None
36+
if record.exc_info and record.exc_info[0] is not None:
37+
exception = self.formatException(record.exc_info)
38+
39+
log_entry: dict[str, object] = {
40+
"timestamp": datetime.fromtimestamp(
41+
record.created, tz=timezone.utc
42+
).isoformat(),
43+
"level": record.levelname,
44+
"logger": record.name,
45+
"message": message,
46+
}
47+
48+
if exception:
49+
log_entry["exception"] = exception
50+
51+
return json.dumps(log_entry, ensure_ascii=False, sort_keys=True)

src/coding/proxy/routing/usage_recorder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def log_model_call(
6666
)
6767
if cost_value is not None:
6868
cost_str = cost_value.format()
69-
logger.info(
69+
logger.debug(
7070
"ModelCall: vendor=%s model_requested=%s model_served=%s "
7171
"duration=%dms tokens=[in:%d out:%d cache_create:%d cache_read:%d] cost=%s",
7272
vendor,

0 commit comments

Comments
 (0)