Jezen Thomas

Jezen Thomas

CTO & Co-Founder at Supercede.

Tracing User Requests in Yesod

Recently I decided I want to invest in my Yesod application’s logging system. Yesod provides sane defaults for logging using the Apache style and this is enough for most applications, however in my case I had a few extra demands. Namely, the logs should:

  • Contain an authenticated user’s ID so we have full visibility into every user’s behaviour
  • Generate a unique ID for each request, so requests can be discussed internally
  • Show the request’s total duration, which will help us find performance bottlenecks
  • Be formatted in JSON for ease of parsing with other tools

Most of these needs could be addressed with functionality the relevant libraries included in a typical scaffolded Yesod site already provide. One of my needs necessitated a little more work. More on that in a moment.

First, let’s take a look at how logging is typically set up in a scaffolded Yesod site. For our purposes, all we need to care about is what we define as our outputFormat. The value we use here should be different depending on whether you’re running the application in development or production — unless of course you want to see JSON logs in development.

-- ./src/Application.hs

makeLogWare :: App -> IO Middleware
makeLogWare foundation =
  mkRequestLogger def
    { outputFormat =
      if appDetailedRequestLogging $ appSettings foundation
        then Detailed True
        else Apache $
               if appIpFromHeader $ appSettings foundation
               then FromFallback
               else FromSocket
    , destination = Logger $ loggerSet $ appLogger foundation
    }

Yesod applications are set to use detailed logging in development by default. This format is easier to visually parse than either Apache or JSON logs, but would be far too noisy in production. The True in Detailed True means we want the logs to use colours. A more descriptive type here might have been useful, but that’s by the by. Here’s how those detailed logs look (here without colour):

GET /auth/page/simple/login
  Accept: text/html,application/xhtml+xml,application/xml;q…
  Status: 200 OK 0.007939s
GET /favicon.ico
  Accept: image/webp,image/apng,image/*,*/*;q=0.8
  Status: 200 OK 0.001512s

Ok, so that’s just fine, but we’re more interested now in the production logs. We don’t want the Apache format, so that’s what we’ll change. The available data constructors can be found in the wai-extra package. You’ll see Apache IPAddrSource and Detailed Bool, both of which we’re familiar with from the scaffolding. There are a few others however with “custom output format” in their names which are a little more useful.

The penultimate data constructor has an associated JSON formatter called formatAsJSON. As it turns out, this combination of data constructor and formatting function (and also a custom middleware, but that’s internal) includes the request duration. So that’s two of my requirements taken care of! I believe logging the request duration is based on this prior art by Maximilian Tagher — the implementation looks identical.

So if we have the means to provide a custom formatter, why don’t we just add the ID of an authenticated user there? Well, here’s the thing. Request logging happens at the WAI level, which is the outer layer of the system. To read the authenticated user’s ID we need to check against the authentication system, but authentication happens at the Yesod level, which is an inner layer of the system. So first, we need some way to pass arbitrary data from the inner Yesod layer to the outer WAI layer. I think request and response headers are a suitable mechanism for transferring this kind of data, and we can use the addHeader function to easily add any response header we want. We also want this response header to be applied to every request, so we should use a middleware to add this functionality to all handlers.

Adding Yesod middleware is trivial:

-- ./src/Foundation.hs

addUserIdToResponseHeadersMiddleware :: Handler a -> Handler a
addUserIdToResponseHeadersMiddleware handler = do
  let toKey = UUID.toText . unUserUUID . userUuid . entityVal
  mUserId <- toKey <<$>> maybeAuth
  traverse_ (addHeader "User-ID") mUserId
  handler

In this middleware — which is just a polymorphic handler that composes with any other handler — we use maybeAuth to get a value of Maybe (Entity User). We have a function toKey which takes a user’s ID from an Entity User, and since our user entity is wrapped in a Maybe, and then again wrapped in a monadic action that retrieves that value from the database, we need to fmap twice over maybeAuth to get our Maybe UserUUID in one pass. The custom operator <<$>> gives us this double fmap, and is defined as such:

infixl 4 <<$>>
(<<$>>) :: (Functor f, Functor g) => (a -> b) -> f (g a) -> f (g b)
(<<$>>) = fmap . fmap

If the user is not authenticated, the value of mUserId will be Nothing, in which case we do nothing. If the user is authenticated, we add their ID it to a response header with a key of User-ID. The traverse_ function in this context gives us this behaviour of “if the value is a Just then do something with it, otherwise do nothing”.

We also need to apply our middleware, which is just a case of tacking it onto whatever other middleware chain we already have.

-- ./src/Foundation.hs

instance Yesod App where
  -- ...Other stuff is probably here

  yesodMiddleware = defaultYesodMiddleware
    . addUserIdToResponseHeadersMiddleware

  -- ...More stuff

That’s great, but there’s a problem: How do we get these response headers in our request log formatter? I went through the source, and unfortunately none of the formatting middlewares actually provided the response headers to the log formatter. I sent a pull request which adds another OutputFormat data constructor, along with its associated custom middleware and log formatter to the wai-extra package, and it was merged earlier today. So as of version 3.0.27, you can use this data constructor:

type OutputFormatterWithDetailsAndHeaders
   = ZonedDate -- ^ When the log message was generated
  -> Request -- ^ The WAI request
  -> Status -- ^ HTTP status code
  -> Maybe Integer -- ^ Response size
  -> NominalDiffTime -- ^ Duration of the request
  -> [S8.ByteString] -- ^ The request body
  -> B.Builder -- ^ Raw response
  -> [Header] -- ^ The response headers
  -> LogStr

…together with the new formatAsJSONWithHeaders function. So our makeLogWare function can now be changed to this:

-- ./src/Application.hs

makeLogWare :: App -> IO Middleware
makeLogWare foundation =
  mkRequestLogger def
    { outputFormat =
      if appDetailedRequestLogging $ appSettings foundation
        then Detailed True
        else OutputFormatterWithDetailsAndHeaders formatAsJSONWithHeaders
    , destination = Logger $ loggerSet $ appLogger foundation
    }

This will now render all request logs in production as JSON, along with the request duration and any response headers. It will also redact any request header with a key of Cookie, or any response header with a key of Set-Cookie.

Almost there. Now we just want to generated a unique ID for each request as it’s coming in, and stuff that ID in a request header. We can do this easily enough at the outer WAI layer.

-- ./src/Application.hs

addRequestId :: Middleware
addRequestId app req sendRes = do
  reqId <- UUID.toASCIIBytes <$> UUID.nextRandom
  let hs = ("Request-ID", reqId) : WAI.requestHeaders req
  app (req { WAI.requestHeaders = hs }) sendRes

The first line uses the uuid package to generate a UUIDv4, and then converts it to a human-readable bytestring. We then construct a new value for the request headers — here marked as hs — which is just the new header prepended to the list of existing request headers. We then continue with the rest of the request lifecycle, sending along a modified request with the new headers.

As before, don’t forget to add this new middleware to the appropriate chain. This would be our WAI middleware chain, not the Yesod middleware chain we used earlier.

-- ./src/Application.hs

makeApplication :: App -> IO Application
makeApplication foundation = do
  logWare  <- makeLogWare foundation
  appPlain <- toWaiAppPlain foundation
  return $ addRequestId $ logWare $ defaultMiddlewaresNoLogging appPlain

Go forth, and trace users.