questionable services

Technical writings about computing infrastructure, HTTP & security.

(by Matt Silverlock)


A Guide To Writing Logging Middleware in Go

•••

This is an opinionated guide on how to write extensible logging middleware for Go web services.

I’ve had a number of requests to add a built-in logger to gorilla/mux and to extend what is logged by gorilla/handlers, and they’re hard to triage. Many of the asks are for different things, since “what” to log, how much to log, and which library to use are not agreed-upon by all. Further, and especially in mux’s case, logging is not the focus of the library, and writing your own logging “middleware” can be simpler than you expect.

The patterns in this guide can be extended to any HTTP middleware use-cases, including authentication & authorization, metrics, tracing, and web security. Logging just happens to be one of the most common use-cases and makes for a great example.

Why is Middleware Useful?

If you’ve been writing Go for a while, you can skip to the code at the end of this post.

Middleware allows us to separate concerns and write composable applications—and in a world of micro-services, allow clearer lines of ownership for specific components.

Specifically:

With this in mind, let’s see how defining “re-usable” middleware in Go actually works.

A Common Middleware Interface

One thing that’s important when writing any middleware is that it be loosely coupled from your choice of framework or router-specific APIs. Handlers should be usable by any HTTP-speaking Go service: if team A chooses net/http, team B chooses gorilla/mux, and team C wants to use Twirp, then our middleware shouldn’t force a choice or be constrained within a particular framework.

Go’s net/http library defines the http.Handler interface, and satisfying this makes it easy to write portable HTTP handling code.

The only method required to satisfy http.Handler is ServeHTTP(http.ResponseWriter, *http.Request) - and the concrete http.HandlerFunc type means that you can convert any type with a matching signature into a type that satisfies http.Handler.

Example:

func ExampleMiddleware(next http.Handler) http.Handler {
  // We wrap our anonymous function, and cast it to a http.HandlerFunc
  // Because our function signature matches ServeHTTP(w, r), this allows
  // our function (type) to implicitly satisify the http.Handler interface.
  return http.HandlerFunc(
    func(w http.ResponseWriter, r *http.Request) {
      // Logic before - reading request values, putting things into the
      // request context, performing authentication

      // Important that we call the 'next' handler in the chain. If we don't,
      // then request handling will stop here.
      next.ServeHTTP(w, r)
      // Logic after - useful for logging, metrics, etc.
      //
      // It's important that we don't use the ResponseWriter after we've called the
      // next handler: we may cause conflicts when trying to write the response
    }
  )
}

This is effectively the recipe for any middleware we want to build. Each middleware component (which is just a http.Handler implementation!) wraps another, performs any work it needs to, and then calls the handler it wrapped via next.ServeHTTP(w, r).

If we need to pass values between handlers, such as the ID of the authenticated user, or a request or trace ID, we can the use the context.Context attached to the *http.Request via the *Request.Context() method introduced back in Go 1.7.

A stack of middleware would look like the below:

router := http.NewServeMux()
router.HandleFunc("/", indexHandler)

// Requests traverse LoggingMiddleware -> OtherMiddleware -> YetAnotherMiddleware -> final handler
configuredRouter := LoggingMiddleware(OtherMiddleware(YetAnotherMiddleware(router))))
log.Fatal(http.ListenAndServe(":8000", configuredRouter))

This looks composable (check!), but what about if we want to inject dependencies or otherwise customize the behaviour of each handler in the stack?

Injecting Dependencies

In the above ExampleMiddleware, we created a simple function that accepted a http.Handler and returned a http.Handler. But what if we wanted to provide our own logger implementation, inject other config, and/or not rely on global singletons?

Let’s take a look at how we can achieve that while still having our middleware accept (and return) http.Handler.

func NewExampleMiddleware(someThing string) func(http.Handler) http.Handler {
  return func(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
      // Logic here

      // Call the next handler
      next.ServeHTTP(w, r)
    }

    return http.HandlerFunc(fn)
  }
}

By returning a func(http.Handler) http.Handler we can make the dependencies of our middleware clearer, and allow consumers of our middleware to configure it to their needs.

In our logging example, we make want to pass an application-level logger with some existing configuration—say, the service name, and a timestamp format—to our LoggingMiddleware, without having to copy-paste it or otherwise rely on package globals, which make our code harder to reason about & test.

The Code: LoggingMiddleware

Let’s take everything we’ve learned above, with a middleware function that logs:

Source on GitHub

// request_logger.go
import (
  "net/http"
  "runtime/debug"
  "time"

  log "github.com/go-kit/kit/log"
)

// responseWriter is a minimal wrapper for http.ResponseWriter that allows the
// written HTTP status code to be captured for logging.
type responseWriter struct {
  http.ResponseWriter
  status      int
  wroteHeader bool
}

func wrapResponseWriter(w http.ResponseWriter) *responseWriter {
  return &responseWriter{ResponseWriter: w}
}

func (rw *responseWriter) Status() int {
  return rw.status
}

func (rw *responseWriter) WriteHeader(code int) {
  if rw.wroteHeader {
    return
  }

  rw.status = code
  rw.ResponseWriter.WriteHeader(code)
  rw.wroteHeader = true

  return
}

// LoggingMiddleware logs the incoming HTTP request & its duration.
func LoggingMiddleware(logger log.Logger) func(http.Handler) http.Handler {
  return func(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
      defer func() {
        if err := recover(); err != nil {
          w.WriteHeader(http.StatusInternalServerError)
          logger.Log(
            "err", err,
            "trace", debug.Stack(),
          )
        }
      }()

      start := time.Now()
      wrapped := wrapResponseWriter(w)
      next.ServeHTTP(wrapped, r)
      logger.Log(
        "status", wrapped.status,
        "method", r.Method,
        "path", r.URL.EscapedPath(),
        "duration", time.Since(start),
      )
    }

    return http.HandlerFunc(fn)
  }
}

Review:

Notably, I use kit/log here, although you could use any logger you like, including the standard library - noting that you’d be missing the benefits of structured logging if you went down that path.

A Full Example

Below is a full (runnable!) example, using a version of LoggingMiddleware we defined earlier from the elithrar/admission-control package:

// server.go
package main

import (
  "fmt"
  stdlog "log"
  "net/http"
  "os"

  "github.com/elithrar/admission-control"
  log "github.com/go-kit/kit/log"
)

func myHandler(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "hello!")
}

func main() {
  router := http.NewServeMux()
  router.HandleFunc("/", myHandler)

  var logger log.Logger
  // Logfmt is a structured, key=val logging format that is easy to read and parse
  logger = log.NewLogfmtLogger(log.NewSyncWriter(os.Stderr))
  // Direct any attempts to use Go's log package to our structured logger
  stdlog.SetOutput(log.NewStdlibAdapter(logger))
  // Log the timestamp (in UTC) and the callsite (file + line number) of the logging
  // call for debugging in the future.
  logger = log.With(logger, "ts", log.DefaultTimestampUTC, "loc", log.DefaultCaller)

  // Create an instance of our LoggingMiddleware with our configured logger
  loggingMiddleware := admissioncontrol.LoggingMiddleware(logger)
  loggedRouter := loggingMiddleware(router)

  // Start our HTTP server
  if err := http.ListenAndServe(":8000", loggedRouter); err != nil {
    logger.Log("status", "fatal", "err", err)
    os.Exit(1)
  }
}

If we run this server, and then make a request against it, we’ll see our log line output to stderr:

    $ go run server.go
    # Make a request with: curl localhost:8000/
    ts=2020-03-21T18:30:58.8816186Z loc=server.go:62 status=0 method=GET path=/ duration=7.6µs

If we wanted to log more information - such as *Request.Host, a value from *Request.Context() (e.g. a trace ID), or specific response headers, we could easily do that by extending the call to logger.Log as needed in our own version of the middleware.

Summary

We were able to build a flexible, re-usable middleware component by:

Taking this, you can hopefully see how you might provide the basis for authentication middleware, or metrics middleware that counts status codes and response sizes.

And because we used http.Handler as our foundation, the middleware we author can be easily consumed by others!

Pretty good, huh?

Postscript: Logs vs Metrics vs Traces

It’s worth taking a moment to define what we mean by “logging”. Logging is about capturing (hopefully) structured event data, and logs are good for detailed investigation, but are large in volume and can be slow(er) to query. Metrics are directional (think: # of requests, login failures, etc) and good for monitoring trends, but don’t give you the full picture. Traces track the lifecycle of a request or query across systems.

Although this article talks about better logging for Go web services, a production application should consider all dimensions. I recommend reading Peter Bourgon’s post on Metrics, tracing & logging for a deeper dive on this topic.


© 2024 Matt Silverlock | Mastodon | Code snippets are MIT licensed | Built with Jekyll