一个内部库在 __init__.py 里调了 logging.basicConfig(level=INFO)。引入这个库之后,应用自己的日志配置全部失效——文件 handler 没了、格式变了、级别被覆盖。查这个问题花了两小时,因为"库改日志"这件事在代码里只有一行。
Python 的日志是全局单例:logging 根 logger 是进程级的,而 basicConfig 只生效一次(幂等),谁先调用谁赢。这个"单例"性质还带来一个后果:配置的生效顺序取决于 import 顺序,而 import 顺序在大型项目里几乎不可控。于是同一个库在不同服务里表现不同,而没有任何一处代码是错的。
所以"库配置日志"等于"抢占全局设置"。
1. 三种写法的边界
| 位置 | 该做什么 |
|---|---|
| 库(被 import) | 只 getLogger(__name__),不配置 |
| 应用入口 | 配置 handler/formatter/level |
| 测试 | 用 caplog 或临时 handler |
第一项是硬规则:库只负责"产生日志",不负责"决定日志去哪"。因为"去哪"取决于运行环境(控制台、文件、采集),而库不知道。
第三项在测试里常见反例:测试代码调用 basicConfig 后,后续测试的日志配置被污染第三项还有个"跨测试"的传染:一次 basicConfig 之后,后续所有测试的根 logger 都被固定,于是"某个测试之后日志就不再输出"这种诡异现象会持续到进程结束。
——而它只在"跑全量测试"时表现为"日志格式不对"。
2. basicConfig 为什么"只生效一次"
| 行为 | 说明 |
|---|---|
| 幂等 | 根 logger 已有 handler 时不做事 |
| 隐式 | 不报错、不提示 |
| 顺序敏感 | 谁先调谁生效 |
第三项还解释了"为什么本地好好的、线上就乱":本地是脚本入口(库先 import),线上是应用入口(应用先配置),两种顺序得到两种结果。
第三项导致"同一个库在两种启动方式下表现不同":入口先配了日志,库的调用被忽略;反之库赢。
第二项是最难查的:它不报错,只是"没生效"。所以"我的配置没起作用"这类问题,第一怀疑对象应当是"有人先调用了 basicConfig"。
// 反例:库在 import 时配置全局
// mylib/__init__.py
import logging
logging.basicConfig(level=logging.INFO) # 抢占全局,且幂等
// 正例:库只取自己的 logger
import logging
log = logging.getLogger(__name__) # 不配 handler、不设级别
def do(): log.info("doing") # 级别由应用决定
3. 应用侧的正确配置
| 要素 | 配置点 |
|---|---|
| handler | 入口(控制台/文件/采集) |
| formatter | 入口(含 traceId 等字段) |
| level | 入口(可被环境变量覆盖) |
| 传播(propagate) | 库通常保持默认 True |
第四项要理解:propagate=True 让日志向上冒泡到根 logger第四项与"handler 数量"要一起看:冒泡是逐层向上的,如果某一层的 logger 自己也有 handler,那么日志会在那一层被处理一次、再继续向上被处理一次,于是"重复输出"。
,于是库的日志会被应用的 handler 处理——这正是"库不需要自己配"的原因。
而反过来,如果库把 propagate 设为 False,它的日志就只在自己(未配置的)handler 里,于是"库的日志不见了"。
// 应用入口:一处配置,全局生效
import logging, os
logging.basicConfig(
level=os.getenv("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
handlers=[logging.StreamHandler()], # 生产上交给采集,不写文件
)
// 关键:库不改任何全局;应用只配一次
4. 三种常见的"日志消失"
| 现象 | 成因 |
|---|---|
| 库的日志不出现 | 库自己设了 propagate=False 或无 handler |
| 应用设置了级别但库仍不输出 | 库的 logger 级别被显式设过 |
| 日志重复输出 | 库和应用各加了一个 handler |
第一项(库的日志不出现)有一种"半失效":库调了 logging.getLogger(__name__).info(...),但那层的有效级别是 WARNING(继承自根或显式设置),于是 info 被过滤掉了——而代码里看起来是"日志打了"。
第三项在"库自己也配了 handler"时出现:一条日志被打印两次,而这类问题常被当成"框架 bug"。
第二项的机制:logger 的 level 是继承的第二项的排查手段是"打印有效级别":logging.getLogger("mylib").getEffectiveLevel() 会告诉你"实际生效的是多少",而不是"你设了多少"。这个方法能省掉很多猜测。
,但如果库显式设了 logger.setLevel(WARNING),那应用改成 INFO 也看不到库的 debug/info。
还有一个"父子 logger"的层级陷阱:getLogger("a.b") 的父是 getLogger("a"),而"父"未必是根 logger。于是"给 a 加了 handler"会让 a.b 的日志被处理两次(一次在 a,一次在根)。
// 层级与 handler 的交互
log_a = getLogger("a") addHandler(H1)
log_ab = getLogger("a.b") 无 handler
log_ab.info(...) → a.b 无 handler → 冒泡到 a → H1 处理 → 再冒泡到 root → root 的 handler 也处理
// 结果:两次输出;解法:给 a 设 propagate=False(但要确认没有别的地方依赖冒泡)
5. 结构化日志与上下文
| 要素 | 谁负责 |
|---|---|
| 字段(traceId、uid) | 应用(用 extra= 或 filter) |
| 格式(JSON) | 应用 |
| 采样 | 应用/采集侧 |
| 敏感字段脱敏 | 应用 |
第四项还有一个"合规"角度:日志会进采集系统,而采集系统通常没有脱敏能力。所以"在库层面少记"比"事后脱敏"更可靠——因为后者依赖每个下游都做对。
第四项是"库不该做"的典型:库不知道哪些字段敏感,所以脱敏必须由应用决定。而库能做的是"不要记录不该记的东西"(如完整请求体)。
// 应用侧的 filter:注入上下文,而不是让库去猜
class CtxFilter(logging.Filter):
def filter(self, record):
record.trace_id = get_trace_id() # 从 contextvars 取
return True
logger = logging.getLogger() # 根 logger
logger.addFilter(CtxFilter()) # 一处注入,全局生效
测试里的正确做法是"不改全局、只观察":用 caplog(pytest)或临时挂在 logger 上的 handler,用完移除。这样测试之间不会互相污染。
// 测试:临时 handler,不改全局
def test_do_logs_info(caplog):
with caplog.at_level(logging.INFO, logger="mylib"):
do()
assert "doing" in caplog.text
// 关键:at_level 只在 with 内生效,结束后恢复
6. 排错:五种"日志配置失效"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 配置完全不生效 | 有人先调了 basicConfig |
搜所有调用点 |
| 只部分生效 | 某库设了 propagate=False |
检查依赖库 |
| 日志重复 | 多个 handler | 清空后重配 |
| 级别改了没用 | logger 显式设了 level | 检查 setLevel |
| 格式里有空字段 | 上下文未注入 | 加 filter |
第一项的排查方式很直接:全局搜 basicConfig 与 setLevel。第四项(级别改了没用)的常见来源是"配置被覆盖":环境变量、配置文件、代码默认值三处都设了 level,最终生效的是最后执行的那个。所以"改一处"经常看不到效果。
而这类冲突在"依赖多"的项目里很常见。
最后一条"启动顺序"的实践:应用的日志配置应当是"入口第一件事",早于任何业务 import。这样即使某个库"不守规矩"调了 basicConfig,应用也已经配置过——后者的调用会被幂等地忽略。
// main.py 的前两行(在 import 业务模块之前)
import logging_config # 只做配置,不 import 业务
import app # 之后才加载业务与依赖
// 收益:配置顺序可控,不被第三方库抢占
7. 边界:什么情况库配置日志可以接受
| 场景 | 为什么 | 做法 |
|---|---|---|
| 命令行工具(自己就是应用) | 无上层 | 可配置 |
| 脚本(不被人 import) | 无宿主 | 可配置 |
| 库的"调试开关" | 显式要求 | 用 logger.setLevel 且文档写清 |
| 临时排查 | 一次性 | 随意 |
最后一条与"发布"有关的边界:库的日志行为是它契约的一部分。所以"我们下个版本要改日志配置"应当在变更说明里写出来——因为它会影响所有使用者的输出格式与级别。
判断口径一句话:这个模块会不会被"别人的应用"import。会,就不能碰全局日志配置;不会(自己就是入口),配什么都没问题。
8. 自测
- 为什么"库配置日志"等于"抢占全局设置"?
basicConfig的幂等性导致什么问题? propagate=True的作用是什么?库把它设为 False 会造成什么?- 应用侧四要素分别该配什么?为什么库的日志"能被应用的 handler 处理"?
- 三种"日志消失"里,哪一种表现为"重复输出"?它的成因是什么?
- 为什么"脱敏"必须由应用决定?库在这件事上能做什么、不该做什么?
- 判断"库能否配置日志"的那个问题是什么?