Adding stacktraces to log messages

10 December 2013 (updated 25 September 2017)

I always had this issue with libraries that logs useful stuff but make figuring out how you called said library very hard. For example, Django logs SQL queries but it's hard to figure out what code made the ORM query. This is a huge pain for Ajax requests - django-debug-toolbar can't help you much with those ... well, not yet.

Turns out Python's logging module allows custom formatters. How about we write one?

The filter is just a class with a filter(record) method. It appears that filter can be called multiple times so we need to make sure we don't patch the record more than 1 time:

class WithStacktrace(object):
    def filter(self, record):
        if not hasattr(record, 'stack_patched'):
            record.msg += " -- Stack: \n" + ''.join(format_stack(f=sys._getframe(1)))
            record.stack_patched = True
        return True

You probably notice now the stacktrace can get rather long, and it includes useless frames from the logging module. We can fix that by blacklisting frames from logging or whatever we chose to ignore (like the django.db package):

import sys
from traceback import format_stack

class WithStacktrace(object):
    def __init__(self, skip=(), limit=5):
        self.skip = [__name__, 'logging']
        self.skip.extend(skip)
        self.limit = limit

    def filter(self, record):
        if not hasattr(record, 'stack_patched'):
            frame = sys._getframe(1)
            if self.skip:
                while frame.f_back and [
                    skip for skip in self.skip
                    if frame.f_globals.get('__name__', '').startswith(skip)
                ]:
                    frame = frame.f_back

            record.msg += " -- Stack: \n" + ''.join(format_stack(f=frame, limit=self.limit)).rstrip()
            record.stack_patched = True
        return True

This will "skip" frames till it finds a frame that's not from a blacklisted module.

If we're using Django we can just add this in the settings (assuming we import or define WithStacktrace in the settings):

LOGGING['loggers']['django.db.backends'] = {
    'level': 'DEBUG',
    'filters': ['add_stack'],
}
LOGGING.setdefault('filters', {})['add_stack'] = {
    '()': WithStacktrace,
    'skip': ("django.db", "south.", "__main__"),
    'limit': 1, # just one frame, increase this if not enough
}

It would look like this:

[2013-12-07 17:58:17,297] DEBUG - django.db.backends - (0.001) SELECT "auth_user"."id", "auth_user"."password", "auth_user"."last_login", "auth_user"."is_superuser", "auth_user"."username", "auth_user"."first_name", "auth_user"."last_name", "auth_user"."email", "auth_user"."is_staff", "auth_user"."is_active", "auth_user"."date_joined" FROM "auth_user" WHERE "auth_user"."username" = 'ionelmc' ; args=('ionelmc',) -- Stack:
  File "foobar.py", line 2, in <module>
    list(User.objects.filter(username='ionelmc'))
[2013-12-07 17:58:18,819] DEBUG - django.db.backends - (1.522) select id, username, pg_sleep(0.01) from auth_user; args=() -- Stack:
  File "foobar.py", line 3, in <module>
    User.objects.raw('select id, username, pg_sleep(0.01) from auth_user')[0]

I believe this is very dull, and since we're most probably running the app in the terminal (you would only want all this detail in development) we could use some colors. Let's spice it up with a better filter:

import sys
try:
    from sqlparse import format as sqlformat
except ImportError:
    sqlformat = lambda s, reindent=None: s
from traceback import format_stack

class WithStacktrace(object):
    def __init__(self, skip=(), limit=5):
        self.skip = [__name__, 'logging']
        self.skip.extend(skip)
        self.limit = limit

    def filter(self, record):
        if not hasattr(record, 'stack_patched'):
            frame = sys._getframe(1)
            if self.skip:
                while [skip for skip in self.skip
                       if frame.f_globals.get('__name__', '').startswith(skip)]:
                    frame = frame.f_back
            if (
                hasattr(record, 'duration') and
                hasattr(record, 'sql') and
                hasattr(record, 'params')
            ):
                record.msg = (
                    "\x1b[31mduration: %s%.4f secs\x1b[0m, "
                    "\x1b[33marguments: %s%s\x1b[0m\n  \x1b[1;33m%s\x1b[0m\n "
                    "-- stack: \n\x1b[32m%s\x1b[0m" % (
                        "\x1b[31m" if record.duration < 0.1 else "\x1b[1;31m",
                        record.duration,
                        "\x1b[1;33m" if record.params else '',
                        record.params,
                        '\n  '.join(sqlformat(
                            record.sql,
                            reindent=True
                        ).strip().splitlines()),
                        ''.join(format_stack(f=frame, limit=self.limit)).rstrip()
                    )
                )
            else:
                record.msg += "\n -- stack: \n\x1b[32m%s\x1b[0m" % (
                    ''.join(format_stack(f=frame, limit=self.limit)).rstrip()
                )
            record.stack_patched = True
        return True

Output would look like this:

[2013-12-07 18:12:48,382] DEBUG - django.db.backends - duration: 0.0009 secs, arguments: ('ionelmc',)
  SELECT "auth_user"."id",
         "auth_user"."password",
         "auth_user"."last_login",
         "auth_user"."is_superuser",
         "auth_user"."username",
         "auth_user"."first_name",
         "auth_user"."last_name",
         "auth_user"."email",
         "auth_user"."is_staff",
         "auth_user"."is_active",
         "auth_user"."date_joined"
  FROM "auth_user"
  WHERE "auth_user"."username" = 'ionelmc'
 -- stack:
  File "foobar.py", line 2, in <module>
    list(User.objects.filter(username='ionelmc'))
[2013-12-07 18:12:49,911] DEBUG - django.db.backends - duration: 1.5223 secs, arguments: ()
  select id,
         username,
         pg_sleep(0.01)
  from auth_user
 -- stack:
  File "foobar.py", line 3, in <module>
    User.objects.raw('select id, username, pg_sleep(0.01) from auth_user')[0]

We can take it even further by coloring the SQL and tracebacks using pygments. It would look like this:

import sys
import sqlparse
from traceback import format_stack
from pygments import highlight
from pygments.lexers import SqlLexer, PythonTracebackLexer
from pygments.formatters import Terminal256Formatter, TerminalFormatter

class WithStacktrace(object):
    _term_formatter = TerminalFormatter(bg='dark')
    _sql_lexer = SqlLexer()
    _tb_lexer = PythonTracebackLexer()

    def __init__(self, skip=(), limit=5):
        self.skip = [__name__, 'logging']
        self.skip.extend(skip)
        self.limit = limit

    def filter(self, record):
        if not hasattr(record, 'stack_patched'):
            frame = sys._getframe(1)
            if self.skip:
                while frame and [
                    skip for skip in self.skip
                    if frame.f_globals.get('__name__', '').startswith(skip)
                ]:
                    frame = frame.f_back
            if (
                hasattr(record, 'duration') and
                hasattr(record, 'sql') and
                hasattr(record, 'params')
            ):
                if record.duration < 0.1:
                    record.msg = "\x1b[31mduration: %.4f secs\x1b[0m" % record.duration
                else:
                    record.msg = "\x1b[31mduration: \x1b[1;31m%.4f secs\x1b[0m" % \
                        record.duration
                if record.params:
                    record.msg += ", \x1b[33marguments: \x1b[1;33m%s\x1b[0m" % (
                        record.params,
                    )
                else:
                    record.msg += ", \x1b[33marguments: %s\x1b[0m" % (record.params, )
                sql = record.sql
                try:
                    sql = '\n  '.join(
                        sqlparse.format(sql, reindent=True).strip().splitlines()
                    )
                    sql = highlight(sql, self._sql_lexer, self._term_formatter)
                except Exception as ex:
                    pass
                record.msg += "\n  \x1b[1;33m%s\x1b[0m" % sql
                if frame:
                    tb = ''.join(format_stack(f=frame, limit=self.limit))
                    try:
                        record.msg += " -- stack: \n%s" % highlight(
                            tb,
                            self._tb_lexer,
                            self._term_formatter
                        ).rstrip()
                    except Exception as ex:
                        record.msg += " -- stack: \n\x1b[32m%s\x1b[0m" % tb.rstrip()

            record.stack_patched = True
        return True

The output would look like this:

[2013-12-07 18:10:56,766] DEBUG - django.db.backends - duration: 0.0009 secs, arguments: ('ionelmc',)
  SELECT "auth_user"."id",
         "auth_user"."password",
         "auth_user"."last_login",
         "auth_user"."is_superuser",
         "auth_user"."username",
         "auth_user"."first_name",
         "auth_user"."last_name",
         "auth_user"."email",
         "auth_user"."is_staff",
         "auth_user"."is_active",
         "auth_user"."date_joined"
  FROM "auth_user"
  WHERE "auth_user"."username" = 'ionelmc'
 -- stack:
  File "foobar.py", line 2, in <module>
    list(User.objects.filter(username='ionelmc'))
[2013-12-07 18:10:58,304] DEBUG - django.db.backends - duration: 1.5201 secs, arguments: ()
  select id,
         username,
         pg_sleep(0.01)
  from auth_user
 -- stack:
  File "foobar.py", line 3, in <module>
    User.objects.raw('select id, username, pg_sleep(0.01) from auth_user')[0]

I think it doesn't look much better ...

Notes:

  • The output snippets are created with ansi2html.
  • An astute reader will point out that there's django.utils.termcolors or some other handy library ... but I don't think it's really needed.

This entry was tagged as debugging django logging python sql