clarkdave.net

Tracking errors with Logstash and Sentry

Logstash is an excellent way to eat up logs and send them elsewhere. In a typical setup you’ll send them to Elasticsearch and the excellent Kibana for viewing and analysis, which works well but is missing a vital part: being alerted when your application throws errors.

There’s a whole bunch of ways you can deal with errors without Logstash, one of which is Sentry. This is a software service which takes your errors, samples and groups them and, crucially, alerts you by email (or many other options). Sentry can plug in to most applications with its different Raven clients, which will allow you to track errors as part of your application and then send them to Sentry directly.

But, well, if you’re already using Logstash to log everything (including errors), wouldn’t it be great to just have Logstash send errors on to Sentry for you? I think so! And, luckily, it’s quick and easy to do!

Introduction

Logstash comes bundled with a lot of outputs, but alas a Sentry-compatible Raven output is not one. Sentry doesn’t have a public REST API, but all Raven clients send a message by HTTP anyway and so we can make a small Logstash output plugin to do this for us.

Note: Logstash does have a generic HTTP output, but because the Sentry HTTP endpoints expect a very specific body, I decided it would be easier to use a custom plugin than try to wrangle the HTTP output to do what we need.

The Sentry plugin

Start by creating a new plugin in your Logstash plugins directly, like below. I don’t know why the plugins directory also needs to have a logstash folder, but it does.

/opt/logstash/server/plugins/logstash/outputs/sentry.rb

Now add this code to start the plugin - we’ll implement the receive method in a moment. Or if you’d prefer, just grab the completed file from GitHub and go from there.

require 'logstash/outputs/base'
require 'logstash/namespace'

class LogStash::Outputs::Sentry < LogStash::Outputs::Base

  config_name 'sentry'
  milestone 1

  config :key, :validate => :string, :required => true
  config :secret, :validate => :string, :required => true
  config :project_id, :validate => :string, :required => true

  public
  def register
    require 'net/https'
    require 'uri'

    @url = "https://app.getsentry.com/api/#{project_id}/store/"
    @uri = URI.parse(@url)
    @client = Net::HTTP.new(@uri.host, @uri.port)
    @client.use_ssl = true
    @client.verify_mode = OpenSSL::SSL::VERIFY_NONE

    @logger.debug("Client", :client => @client.inspect)
  end
end

This initialises a plugin named sentry with three configuration values: key, secret and project_id. These can all be found in the DSN provided to you by Sentry for your project. You can find this DSN in Sentry, under project settings and All Platforms. It’ll look something like this:

https://0507f05a5d7f41aaaadba4fe669449fb:[email protected]/371923

The values are:

https://{key}:{secret}@app.getsentry.com/{project_id}

You might already be thinking “but Raven clients should use a DSN, not be hardcoded to use Sentry’s domain” and you’d be right. It would be fairly trivial to change this plugin to act as a more generic Raven client. When I have more time I’d like to do just that, and submit to Logstash as a bundled output; this would then work not just for Sentry’s hosted service but also your own Sentry installations (Sentry is open-source!)

Anyway, now let’s implement receive method which will actually send the message to Sentry:

class LogStash::Outputs::Sentry < LogStash::Outputs::Base

  # ...

  public
  def receive(event)
    return unless output?(event)

    require 'securerandom'

    packet = {
      :event_id => SecureRandom.uuid.gsub('-', ''),
      :timestamp => event['@timestamp'],
      :message => event['message']
    }

    packet[:level] = event['[fields][level]']

    packet[:platform] = 'logstash'
    packet[:server_name] = event['host']
    packet[:extra] = event['fields'].to_hash

    @logger.debug("Sentry packet", :sentry_packet => packet)

    auth_header = "Sentry sentry_version=5," +
      "sentry_client=raven_logstash/1.0," +
      "sentry_timestamp=#{event['@timestamp'].to_i}," +
      "sentry_key=#{@key}," +
      "sentry_secret=#{@secret}"

    request = Net::HTTP::Post.new(@uri.path)

    begin
      request.body = packet.to_json
      request.add_field('X-Sentry-Auth', auth_header)

      response = @client.request(request)

      @logger.info("Sentry response", :request => request.inspect, :response => response.inspect)

      raise unless response.code == '200'
    rescue Exception => e
      @logger.warn("Unhandled exception", :request => request.inspect, :response => response.inspect, :exception => e.inspect)
    end
  end
end

You might need to tweak this code to suit your own logs. I filter all my logs so that they have a fields key which contains any contextual fields like user_id, response time, etc. It’s useful to have this when examining errors, so I send off everything in fields to Sentry under its extra field.

This code also assumes that you have a value for each log in [fields][level], which is the log level. This is required for Sentry to know what kind of error it is. This can be a string like “warning”, “error” or an numeric value.

If you do use numeric log levels an important caveat applies: your log level numbering scheme may not match that which Sentry uses.

Sentry will accept numeric log levels, but it treats them like so:

  • 30: warning
  • 40: error
  • 50: fatal

If you have a different scheme, like I did, you’ll need to adjust your numeric log level before sending it on to Sentry. In my case, all my log levels are +10, so 40 is actually a warning. This is an easy fix:

packet[:level] -= 10 if packet[:level] > 10

Finally, you may want to check the code for things like event['host'] in case these you have these named differently.

Adding the plugin to your Logstash config

With the plugin in place the only thing left to do is add it to your config as an output. Although you could just stick it in, this would result in all logs being sent to Sentry, a use case it’s not really designed for (it excels when you only send it errors).

I recommend adding the Sentry output in combination with a condition so it’s only used if the log is a warning or above (you could change this to errors or above if you don’t need warnings in Sentry). This will look something like this:

# if you use numeric log levels

if [fields][level] >= 40 {
  sentry {
    'key' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    'secret' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    'project_id' => '137235'
  }
}

# if you use string log levels

if [fields][level] == 'warning' or [fields][level] == 'error' or [fields][level] == 'fatal' {
  sentry {
    'key' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    'secret' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
    'project_id' => '137235'
  }
}

And that should be about it! Make sure you are passing the --pluginpath option to the Logstash server daemon so it can find your plugin, and don’t forget you can also pass -vv for extra logging to help debug things if it doesn’t work.

blog comments powered by Disqus