在structlog的宽日志中处理 'level' 关键字

编程语言 2026-07-12

我想用Python的 structlog 实现全面的日志记录。我也想为它提供一些单元测试,并且我很困惑如何把 structlog 与底层标准库 logging. 搭配使用。总体来说,我想实现的是将所有日志格式化成一个简单的JSON,且没有嵌套的键,例如:

{
    "msg": "some event",
    "level": "warning",
    "timestamp": "2026-03-09 19:01:20",
    "user_id": 123,
    "frequency": "some key"
    // some other key-value pairs
}

初始 structlog 配置:

def configure_events() -> None:
    processors = [
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
        structlog.stdlib.render_to_log_kwargs,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.JSONRenderer()
    ]
    structlog.configure(
        processors=processors,
        wrapper_class=structlog.make_filtering_bound_logger("INFO"),
        logger_factory=structlog.stdlib.LoggerFactory(),
        cache_logger_on_first_use=True,
    )

测试配置

@pytest.fixture
def capture_logs():
    emitted = []

    def capture(*args):
        event_dict = args[-1]
        emitted.append(event_dict)
        return event_dict

    with patch("structlog.processors.JSONRenderer.__call__", capture):
        yield emitted

当我像这样运行测试时:

def test_event_emits_structured_wide_event(capture_logs):
    configure_events()
    logger = structlog.get_logger()

    logger.info("some event", user_id=123)

    assert len(capture_logs) == 1
    log = capture_logs[0]

    assert log["msg"] == "some event"
    assert log["extra"]["level"] == "info"
    assert log["extra"]["user_id"] == 123

测试运行得很好,但 leveluser_id 位于 extra 键下,这可以理解,因为日志会按传入的参数来处理。可是我不想要这样,我希望所有键都只是一个简单的JSON。我尝试了很多不同的方法,最终我想到了添加一个自定义处理器,将 extra 键的值扁平化,因此实现之后,我的 structlog 配置看起来是这样的:

def configure_events() -> None:

    def _flatten_extra(logger, method_name, event_dict):
        if "extra" in event_dict:
            extra = event_dict.pop("extra")
            if not isinstance(extra, dict):
                raise ValueError("Value of 'extra' is not of dict type.")
            event_dict.update(extra)
        return event_dict

    processors = [
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False),
        structlog.stdlib.render_to_log_kwargs,
        structlog.stdlib.PositionalArgumentsFormatter(),
        _flatten_extra,
        structlog.processors.JSONRenderer()
    ]
    structlog.configure(
        processors=processors,
        wrapper_class=structlog.make_filtering_bound_logger("INFO"),
        logger_factory=structlog.stdlib.LoggerFactory(),
        cache_logger_on_first_use=True,
    )

然后在我的断言中移除了对 extra 的引用后,测试就顺利通过。

但这在WARNING上不起作用。 我有如下测试:

def test_event_can_use_warning_level(capture_logs):
    configure_events()
    logger = structlog.get_logger()
    logger.warning("check_failed", user_id=222, reason="timeout")
    assert capture_logs[0]["level"] == "warning"

测试因为 TypeError: Logger._log() got an unexpected keyword argument 'user_id' 而失败,这点对我来说很奇怪,为什么不能像INFO那样传参,但也无妨,下面是我测试的内容:

def test_event_can_use_warning_level(capture_logs):
    configure_events()
    logger = structlog.get_logger()
    extra = {"user_id": 222, "reason": "timeout"}
    logger.warning("check_failed", extra=extra)
    assert capture_logs[0]["level"] == "warning"

而这次测试的结果让我陷入了主要的困惑,它因为 TypeError: Logger._log() got multiple values for argument 'level' 而失败。根据我的理解,标准库 logging 会在日志消息中添加一个由方法名派生的 level 关键字,但为什么它在INFO和 WARNING之间的行为会不同?

我现在完全搞不清楚,也想不出办法。我到底错过了什么,还是配置错了?

解决方案

你在 JSONRenderer.__call__ 的mock函数中返回了一个 dict,这将导致未定义行为。你可以按下面的示例来修复。

@pytest.fixture
def capture_logs():
    emitted = []

    old_call = structlog.processors.JSONRenderer.__call__
    def capture(*args):
        event_dict = args[-1]
        emitted.append(event_dict)
        return old_call(*args)

    with patch("structlog.processors.JSONRenderer.__call__", capture):
        yield emitted
站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章