Error logging with Sentry

In this recipe we will use Sentry to collect the runtime exceptions generated by our application. We will use the raven-haskell package, which is a client for a Sentry event server. Mind that this package is not present on Stackage, so if we are using Stack we’ll need to add it to our extra-deps section in the stack.yaml file.

To exemplify this we will need the following imports

{-# LANGUAGE DataKinds     #-}
{-# LANGUAGE TypeOperators #-}

import           Control.Exception                      (Exception,
                                                         SomeException, throw)
import           Data.ByteString.Char8                  (unpack)
import           Network.Wai                            (Request, rawPathInfo,
                                                         requestHeaderHost)
import           Network.Wai.Handler.Warp               (defaultOnException,
                                                         defaultSettings,
                                                         runSettings,
                                                         setOnException,
                                                         setPort)
import           Servant
import           System.Log.Raven                       (initRaven, register,
                                                         silentFallback)
import           System.Log.Raven.Transport.HttpConduit (sendRecord)
import           System.Log.Raven.Types                 (SentryLevel (Error),
                                                         SentryRecord (..))

Just for the sake of the example we will use the following API which will throw an exception

type API = "break" :> Get '[JSON] ()

data MyException = MyException deriving (Show)

instance Exception MyException

server = breakHandler
  where breakHandler :: Handler ()
        breakHandler = do
          throw MyException
          return ()

First thing we need to do if we want to intercept and log this exception, we need to look in the section of our code where we run the warp application, and instead of using the simple run function from warp, we use the runSettings functions which allows to customise the handling of requests

main :: IO ()
main =
  let
    settings =
      setPort 8080 $
      setOnException sentryOnException $
      defaultSettings
  in
    runSettings settings $ serve (Proxy :: Proxy API) server

The definition of the sentryOnException function could look as follows

sentryOnException :: Maybe Request -> SomeException -> IO ()
sentryOnException mRequest exception = do
  sentryService <- initRaven
    "https://username:[email protected]/id"
    id
    sendRecord
    silentFallback
  register
    sentryService
    "myLogger"
    Error
    (formatMessage mRequest exception)
    (recordUpdate mRequest exception)
  defaultOnException mRequest exception

It does three things. First it initializes the service which will communicate with Sentry. The parameters it receives are:

  • the Sentry DSN, which is obtained when creating a new project on Sentry
  • a default way to update sentry fields, where we use the identity function
  • an event trasport, which generally would be sendRecord, an HTTPS capable trasport which uses http-conduit
  • a fallback handler, which we choose to be silentFallback since later we are logging to the console anyway.

In the second step it actually sends our message to Sentry with the register function. Its arguments are:

  • the configured Sentry service which we just created
  • the name of the logger
  • the error level (see SentryLevel for the possible options)
  • the message we want to send
  • an update function to handle the specific SentryRecord

Eventually it just delegates the error handling to the default warp mechanism.

The function formatMessage simply uses the request and the exception to return a string with the error message.

formatMessage :: Maybe Request -> SomeException -> String
formatMessage Nothing exception        = "Exception before request could be parsed: " ++ show exception
formatMessage (Just request) exception = "Exception " ++ show exception ++ " while handling request " ++ show request

The only piece left now is the recordUpdate function which allows to decorate with other attributes the default SentryRecord.

recordUpdate :: Maybe Request -> SomeException -> SentryRecord -> SentryRecord
recordUpdate Nothing exception record        = record
recordUpdate (Just request) exception record = record
  { srCulprit = Just $ unpack $ rawPathInfo request
  , srServerName = fmap unpack $ requestHeaderHost request
  }

In this examples we set the raw path as the culprit and we use the Host header to populate the server name field.

You can try to run this code using the cookbook-sentry executable. You should obtain a MyException error in the console and, if you provided a valid Sentry DSN, you should also find your error in the Sentry interface.