flask celery logging 翻车记

虽然前面自己写过一个 Python Logging 的各种玩法(折腾 Python logging 的一些记录),结果没两个月,自己就在之前特意叮嘱过的地方翻了车

事情是这样的,我们的项目使用 Flask 作为业务框架,用 Celery 作为异步任务框架,按上篇里提到的,我们加了个 Filter 来加入 request_id 用于跟踪同一个 Web 请求或同一个 Task。所以,我们的 logging.conf 一开始是这样的,在 logfile 这个 Handler 里加入 request_id 并打印到日志文件,我们用阿里云的 logtail 把所有部署实例的日志文件都收集到一起,这一层 app Logger 往上抛是让终端都打印所有的日志,并且用 sentry 这个 Handler 在最上面收集各种异常报错

logging_conf = {
    "version": 1,
    "disable_existing_loggers": True,
    "filters": {
        "addRequestId": {
            "()": "app.utils.logging.request_filter.ContextFilter"
        }
    },
    "formatters": {
        "standard": {
            "format": "[%(asctime)s][%(levelname)s][%(pathname)s:%(lineno)s][%(funcName)s]: %(message)s"
        },
        "withRequestId": {
            "format": "[%(asctime)s][%(levelname)s][%(relpath)s:%(lineno)s][%(funcName)s][%(request_id)s]: %(message)s"
        }
    },
    "handlers": {
        "console": {
            "level": "DEBUG",
            "class": "logging.StreamHandler",
            "formatter": "standard"
        },
        "logfile": {
            "level": "DEBUG",
            "class": "logging.handlers.WatchedFileHandler",
            "filters": ["addRequestId"],
            "formatter": "withRequestId",
            "filename": "log/app.log"
        },
        "sentry": {
            "level": "ERROR",
            "class": "raven.handlers.logging.SentryHandler",
            "dsn": "DSN_URI",
            "string_max_length": 512000
        }
    },
    "loggers": {
        "app": {
            "level": "DEBUG",
            "handlers": ["logfile"],
            "propagate": True
        }
    },
    "root": {
        "level": "DEBUG",
        "handlers": ["console", "sentry"]
    }
}

后来,为了把 Celery 调度信息等也通过 logtail 收集到阿里云,我们就把 logtail 的源从日志文件改成了 stdout/stderr 输出,不同时收集日志文件是因为同样的日志在 logfileconsole 里会出现两次,没有必要。但是只改 stdout/stderr 又会导致收集到的信息没有我们辛苦加进去的 relpathrequest_id,一个很直接的思路就是,在 app 这层 Logger 上加一个 consoleWithIdStreamHandler,并且 formatter 用 withRequestId 不就好了,然后限制 app Logger 的 propagate 为 False 禁止上抛,问题应该完美解决?等会,这里有好几个问题

第一个问题是,如果 app Logger 不往上抛,那万一异常了,sentry Handler 也收集不到错误?头疼医头脚疼医脚,那就给 app Logger 也挂上 sentry Handler 不就解决问题

第二个问题是,handlers 的处理顺序是不是严格按我们配置的顺序来?如果不是的话,consoleWithId 进入的时候,可能 addRequestId 这个 Filter 还没执行,出现了输出时拿不到 relpathrequest_id 那不就挂了?这个简单,把 Filter 移到 Logger 这一层不就解决了。至此,配置如下(只摘录改动部分)

    # ...
    "handlers": {
        # ...
        "logfile": {
            "level": "DEBUG",
            "class": "logging.handlers.WatchedFileHandler",
            "formatter": "withRequestId",
            "filename": "log/app.log"
        },
        "consoleWithId": {
            "level": "DEBUG",
            "class": "logging.StreamHandler",
            "formatter": "withRequestId"
        },
        # ...
    },
    "loggers": {
        "app": {
            "level": "DEBUG",
            "filters": ["addRequestId"],
            "handlers": ["logfile", "consoleWithId", "sentry"],
            "propagate": False
        }
    },
    # ...

翻车就翻在「这个简单」上,按这个思路配置后,跑起来还是在 consoleWithId 的 Handler 上输出报错,而且报的就是 relpathrequest_id 字段不存在。怀疑自己的配置有问题,跑到代码里打日志的地方用 logging.getLogger(__name__) 看拿到的到底是哪个 Logger,以及上面挂了哪些 Handler,还有 Logger 和 Handler 的 Filters 都配的啥,发现除了 app 根上,如果是 app.foo 这样的路径,拿到的都是一个叫 celery.utils.log.ProcessAwareLogger 的 Logger,而且没有任何 Handler 和 Filter 挂在上面,所以,Celery 你这个坏人,到底对我的代码做了什么?

跑去翻 celery.utils.log.ProcessAwareLogger 这个东西的源码都没看出个所以然,似乎只是为了保证 Flask 的 signal handler 机制正常,排查思路也断掉,再跑去看看我们那个 app.utils.logging.request_filter 的处理,有没有哪里不对的,在这个自定义的 filter 里裸用 print 打印,发现这个 filter 压根没被调用到?嗯?没被调用到?

回去看自己的上一篇,果然里面自己就提到过这里有坑(主流程解释的第 5 步)

如果开启了日志往上传递,则判断当前 Logger 是否有父 Logger,如果有的话,直接将当前 LogRecord 传给父 Logger 从 4 开始处理(跳过 1/2/3,注意此处级别控制 1 会不生效,绑定在父 Logger 上的 Filter 也不执行)

WTF!果然坑都是自己不掉一遍,别人说千万遍也不会记得的,哪怕说的这个人是自己。那好咯,把 addRequestId 这个 Filter 还是从 app Logger 上移到 Handler 层面上好了,每个需要的 Handler 都给挂上,多点性能开销就多点吧

不过这样配的感觉还是怪怪的,比如有些错误会被 sentry 收集两次,因为在 app 里一直往上抛会被 app Logger 里的 sentry 收集,如果这个错误还继续往上抛到了框架层面,框架的错误还会被 rootLogger 的 sentry 又收集一次。而且,既然 app 的里面和外面都有终端和 sentry,为啥不在最外面一次处理好,中间拦着不往上抛没有任何意义。调整了下,直接把 standard 这个 Formatter 和 console 这个 Handler 给去掉,在 rootLogger 上挂 consoleWithIdsentry 就好,最后完整的配置如下

logging_conf = {
    "version": 1,
    "disable_existing_loggers": True,
    "filters": {
        "addRequestId": {
            "()": "app.utils.logging.request_filter.ContextFilter"
        }
    },
    "formatters": {
        "request": {
            "format": "[%(asctime)s][%(levelname)s][%(relpath)s:%(lineno)s][%(funcName)s][%(request_id)s]: %(message)s"
        }
    },
    "handlers": {
        "logfile": {
            "level": "DEBUG",
            "class": "logging.handlers.WatchedFileHandler",
            "filters": ["addRequestId"],
            "formatter": "request",
            "filename": "log/app.log"
        },
        "consoleWithId": {
            "level": "DEBUG",
            "class": "logging.StreamHandler",
            "filters": ["addRequestId"],
            "formatter": "request"
        },
        "sentry": {
            "level": "ERROR",
            "class": "raven.handlers.logging.SentryHandler",
            "dsn": "DSN_URI",
            "string_max_length": 512000
        }
    },
    "loggers": {
        "app": {
            "level": "DEBUG",
            "handlers": ["logfile"],
        }
    },
    "root": {
        "level": "DEBUG",
        "handlers": ["consoleWithId", "sentry"]
    }
}

因为 Logger 的 propagate 默认就是 True,所以相对于第一版在 app 这个 Logger 上去掉了这条配置也没关系

最后,因为 addRequestId 这个 Filter 还是会被调两次,想优化下性能,就在 Filter 做完后加一个标记,下次再进来如果看到有这个标记就直接跳过,以及,对于非项目内的日志就不要用项目内的相对路径而用绝对路径替代。代码如下

# coding: utf8

import logging
import os.path

from celery import current_task
from flask import g, has_app_context, has_request_context


_proj_root_path = os.path.abspath(os.path.join(__file__, './../../../../'))
_proj_root_length = len(_proj_root_path)


class ContextFilter(logging.Filter):
    def filter(self, record):
        # ignore duplicate filter
        if hasattr(record, 'filter_by_yewen'):
            return True

        # request_id for flask web or celery task
        request_id = 'Standalone'

        if has_app_context():
            if has_request_context():
                request_id = g.get('request_id', 'UnknownRequest')
            elif current_task:
                request_id = current_task.request.id or 'UnknownTask'

        record.request_id = request_id

        # handle log_decorator pass
        record.funcName = getattr(record, 'orig_funcName', record.funcName)
        record.pathname = getattr(record, 'orig_pathname', record.pathname)
        record.lineno = getattr(record, 'orig_lineno', record.lineno)

        # relative path
        if record.pathname.startswith(_proj_root_path):
            record.relpath = record.pathname[_proj_root_length:]
        else:
            record.relpath = record.pathname

        record.filter_by_yewen = True

        return True