Logging Django errors to Slack channels.

article

Foreword

This solution isn't meant as a replacement for Sentry, Bugsnag, Rollbar or anything similar. It's only meant as a replacement for Django standard email error reporting for small projects in initial stages where integrating full-blown error reporting service would be an overkill.

These packages and versions were used in this guide;

  • Python 3.6
  • Django 1.11
  • Requests 2.18.1

It might work on older versions but I haven't tried it.

If you just want to jump to the final code; click here.

Problem

If you develop small sized applications using Django framework then you most probably use Django built-in error logging via email.

It's pretty useful and better than nothing. But lets agree – email is primarily for communication between humans and (of course) for incoming spam from email lists you don't even remember you've signed up for.

It's not for error logs that make even more mess in your inbox – especially if you get a ton of them after you've introduced highly public facing bug in your last deployment. Such mess.

Every developer (or team of developers) has its own methods for logging their server errors. Some use tools like Sentry or Rollbar and some even go with custom solutions. But those are not always free and they require some setup and maintenance which in most cases is not worth it for smaller projects.

Solution

Recently I've started working on some additional Django projects deployed to production (one of them is GGather.com). And using server error logging to email was especially cumbersome when it came from multiple different projects.

I needed something better but also similar in ease of installation, accessibility and price (free).

I've then got the Eureka Moment. Why not use Slack? People are using it to order Uber, look at GIFs or play ping-pong. So why not use it for error reporting?

It's an application that I almost always have opened, maybe even more than my email. And it's basically on every device which makes it easier to notice notifications when something bad is happening.

And with help of Slack channels I could send error logs to them for each of my projects. It's actually really great for managing multiple small projects!

Bonus points for special custom message (attachment) API that will help pretty nicely as you will see reading further.

Execution

1. Directory and file

First thing we will need to do is to add somewhere in the project a file that will contain our logger code. It's up to you where you'll add it and how you'll name it. I've personally named it slack_logger.py and it's in my app config directory (next to settings.py).

2. Imports

We will set up our file by importing all the necessary packages and utilities in it.

import requests
import json
import time
import math
from copy import copy
from django.conf import settings
from django.utils.log import AdminEmailHandler
from django.views.debug import ExceptionReporter

...

3. SlackExceptionHandler

Next thing we will need to do is to start on our exception handler class. We won't reinvent the wheel so this handler will be a subclass of django.utils.log.AdminEmailHandler. This is a standard Django email logger that is used when the error logs are sent to admin emails.

In that class we will overwrite the emit method that originally extracts exception data and actually sends the email. In our Slack version we will only need the extraction part; without email sending.

We can't use the python super here because we need to disable the last self.send_mail(...) line. If we will leave it, then our code will also send email with the error and we don't want that.

All of our logic from now on will live in that method. So the next code snippets will also be in the context of emit method.

...

class SlackExceptionHandler(AdminEmailHandler):

  # replacing default django emit (https://github.com/django/django/blob/master/django/utils/log.py)
  def emit(self, record, *args, **kwargs):

    # original AdminEmailHandler "emit" method code (but without actually sending email)
    try:
      request = record.request
      subject = '%s (%s IP): %s' % (
        record.levelname,
        ('internal' if request.META.get('REMOTE_ADDR') in settings.INTERNAL_IPS
         else 'EXTERNAL'),
        record.getMessage()
      )
    except Exception:
      subject = '%s: %s' % (
        record.levelname,
        record.getMessage()
      )
      request = None
    subject = self.format_subject(subject)

    # Since we add a nicely formatted traceback on our own, create a copy
    # of the log record without the exception data.
    no_exc_record = copy(record)
    no_exc_record.exc_info = None
    no_exc_record.exc_text = None

    if record.exc_info:
      exc_info = record.exc_info
    else:
      exc_info = (None, record.getMessage(), None)

    reporter = ExceptionReporter(request, is_email=True, *exc_info)
    message = "%s\n\n%s" % (self.format(no_exc_record), reporter.get_traceback_text())
    html_message = reporter.get_traceback_html() if self.include_html else None

    #self.send_mail(subject, message, fail_silently=True, html_message=html_message)



    ...

4. Details attachment

While still in emit method context we can start constructing our message. More precisely – message "attachment" with details like request status, request user, etc.

You can learn more about the messages and attachments on the Slack API and you can test the message formatting in the Slack message builder sandbox.

    ...

    # construct slack attachment detail fields
    attachments =  [
      {
        'title': subject,
        'color': 'danger',
        'fields': [
          {
            "title": "Level",
            "value": record.levelname,
            "short": True,
          },
          {
            "title": "Method",
            "value": request.method if request else 'No Request',
            "short": True,
          },
          {
            "title": "Path",
            "value": request.path if request else 'No Request',
            "short": True,
          },
          {
            "title": "User",
            "value": ( (request.user.username + ' (' + str(request.user.pk) + ')'
                     if request.user.is_authenticated else 'Anonymous' )
                     if request else 'No Request' ),
            "short": True,
          },
          {
            "title": "Status Code",
            "value": record.status_code,
            "short": True,
          },
          {
            "title": "UA",
            "value": ( request.META['HTTP_USER_AGENT']
                     if request and request.META else 'No Request' ),
            "short": False,
          },
          {
            "title": 'GET Params',
            "value": json.dumps(request.GET) if request else 'No Request',
            "short": False,
          },
          {
            "title": "POST Data",
            "value": json.dumps(request.POST) if request else 'No Request',
            "short": False,
          },
        ],
      },

    ]

    ...

5. Error body

Now it's the time to construct the main error message body that is provided by Django with all the debug information like traceback, environment variables, etc.

While it may sound easy – it's tricky. I found out the hard way that Slack attachment detail has a max size of 8000 bytes (characters). The standard Django error log is most of the time 3-5x times bigger.

This is why we have to split that error log and include it in multiple attachments.

    ...

    # add main error message body

    # slack message attachment text has max of 8000 bytes
    # lets split it up into 7900 bytes long chunks to be on the safe side

    split = 7900
    parts = range( math.ceil( len( message.encode('utf8') ) / split ) )

    for part in parts:

      start = 0     if part == 0 else split * part
      end   = split if part == 0 else split * part + split

      # combine final text and prepend it with line breaks
      # so the details in slack message will fully collapse
      detail_text = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n' + message[start:end]

      attachments.append({
        'color': 'danger',
        'title': 'Details (Part {})'.format(part + 1),
        'text': detail_text,
        'ts': time.time(),
      })

    ...

6. Final payload

We can now construct the final payload that we will send to Slack webhook.

    ...

    # construct main text
    main_text = 'Error at ' + time.strftime("%A, %d %b %Y %H:%M:%S +0000", time.gmtime())

    # construct data
    data = {
      'payload': json.dumps({'main_text': main_text,'attachments': attachments}),
    }

    ...

7. Create Slack webhook url

Before we can send the error to Slack channel we need to create a Slack webhook url that we will use when sending the error.

  1. Login into https://slack.com in the team where you want to receive error messages.
  2. Go to https://my.slack.com/apps/new/A0F7XDUAZ-incoming-webhooks.
  3. Choose a channel.
  4. Click big green button "Add Incoming WebHooks integration" .
  5. Copy "Webhook URL".
  6. (Optionally) Customize your webhook name, label & icon.

8. Send it

Now that we have our webhook url we can include it in our code and send the request with the payload using requests library.

    ...

    # setup channel webhook
    webhook_url = 'https://hooks.slack.com/services/xxx/xxx/xxx'

    # send it
    r = requests.post(webhook_url, data=data)

9. Configure the logger in your project settings

The last step we will need to do is to tell Django that we actually want to use our custom logger instead of email logger.

To do that we need to edit our settings.py (or whatever file you store your settings in) and include our own SlackExceptionHandler.

from django.utils.log import DEFAULT_LOGGING

...

LOGGING = DEFAULT_LOGGING

LOGGING['handlers']['slack_admins'] = {
  'level': 'ERROR',
  'filters': ['require_debug_false'],
  'class': 'config.helpers.slack_logger.SlackExceptionHandler',
}

LOGGING['loggers']['django'] = {
  'handlers': ['console', 'slack_admins'],
  'level': 'INFO',
}

10. Done

Now you can test your new Slack error logger. Try using 1/0 in your code to throw ZeroDivisionError error and see it logged in your Slack channel.

Results

Message

Complete Code

Final complete code can be found in this gist - gist.github.com/DominikSerafin/0e14ea2ea60022201c9a0c04454a2926.

Resources