2384 字
约 7 分钟
16
S10-A18 · 日志配置的边界:库不该改全局

一个内部库在 __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. 自测

  1. 为什么"库配置日志"等于"抢占全局设置"?basicConfig 的幂等性导致什么问题?
  2. propagate=True 的作用是什么?库把它设为 False 会造成什么?
  3. 应用侧四要素分别该配什么?为什么库的日志"能被应用的 handler 处理"?
  4. 三种"日志消失"里,哪一种表现为"重复输出"?它的成因是什么?
  5. 为什么"脱敏"必须由应用决定?库在这件事上能做什么、不该做什么?
  6. 判断"库能否配置日志"的那个问题是什么?
S10-A18 · 日志配置的边界:库不该改全局
https://www.clxhxhhr.top/posts/6532/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。