slog access log

Recently I wanted to learn more about Go’s slog package. Also for some other project I needed to create an access logger. I could not find a solution so I decided to create my own. I was inspired by two solutions:

Working code example can be found on GitHub

Requirements

In my logger I wanted to have all standard data:

  • request duration,
  • status code,
  • bytes written,
  • method,
  • url,
  • user agent,
  • remote addr

plus some extra

  • correlation id - it is useful to group log entries from the same request,
  • custom header field - useful for custom extensions. Here in this example I am adding X-Forwarded-For which contains user IP in case of the server being behind the proxy.

Creating middleware

AccessLogMiddleware takes a closure which is responsible for logging the request.

alog := AccessLogMiddleware(func(r *http.Request, status, size int, duration time.Duration) {
	slog.InfoContext(
		r.Context(),
		"access log",
		slog.String("method", r.Method),
		slog.String("url", r.URL.RequestURI()),
		slog.String("user_agent", r.UserAgent()),
		slog.String("remote_addr", r.RemoteAddr),
		slog.String("remote_host", getHost(r.RemoteAddr)),
		slog.String("referer", r.Referer()),
		slog.String("proto", r.Proto),
		slog.Duration("took", duration),
		slog.Int("status_code", status),
		slog.Int("bytes", size),
	)
})

When creating the logger, I needed to add custom ContextHandler which is responsible for logging slog.Attr added to request’s context.

handler := &ContextHandler{slog.NewTextHandler(os.Stdout, nil)}
logger := slog.New(handler)
slog.SetDefault(logger)

Here is full example of main function.

func main() {
	handler := &ContextHandler{slog.NewTextHandler(os.Stdout, nil)}
	logger := slog.New(handler)
	slog.SetDefault(logger)

	mux := http.NewServeMux()
	mux.HandleFunc("/ping", pingHandler)

	addr := ":8888"
	slog.Info("starting listening", slog.String("addr", addr))

	alog := AccessLogMiddleware(func(r *http.Request, status, size int, duration time.Duration) {
		slog.InfoContext(
			r.Context(),
			"access log",
			slog.String("method", r.Method),
			slog.String("url", r.URL.RequestURI()),
			slog.String("user_agent", r.UserAgent()),
			slog.String("remote_addr", r.RemoteAddr),
			slog.String("remote_host", getHost(r.RemoteAddr)),
			slog.String("referer", r.Referer()),
			slog.String("proto", r.Proto),
			slog.Duration("took", duration),
			slog.Int("status_code", status),
			slog.Int("bytes", size),
		)
	})
	forwarded := CustomHeaderHandler("x-forwarded-for", "X-Forwarded-For")

	err := http.ListenAndServe(addr, forwarded(alog(mux)))
	if err != nil {
		slog.Error(err.Error())
	}
}


func pingHandler(w http.ResponseWriter, r *http.Request) {
	slog.InfoContext(r.Context(), "test")
	fmt.Fprintf(w, "pong")
}

AccessLogMiddleware

// AccessLogMiddleware creates http.Handler which logs http requests.
// It measures duration of request. It records response code and response size.
// It also adds correlation ID to the log entry.
func AccessLogMiddleware(f func(r *http.Request, status, size int, duration time.Duration)) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // start is needed to measure the request duration
			start := time.Now()

            // this writer is needed to record status code and bytes written
			lrw := newLoggingResponseWriter(w)

			correlationID := xid.New().String()

            // we are creating a copy of request's context
			ctx := AppendCtx(r.Context(), slog.String("correlation_id", correlationID))
            // replacing request with updated context
			r = r.WithContext(ctx)

			w.Header().Add("X-Correlation-ID", correlationID)

            // executing f closure in defer so the request will be logged even if the code panics
			defer func() {
				f(r, lrw.statusCode, lrw.bytes, time.Since(start))
			}()

			next.ServeHTTP(lrw, r)
		})
	}
}

ContextHandler


type ctxKey string

const slogFields ctxKey = "slog_fields"

// ContextHandler is used to log slog.Attrs added to context.Context
type ContextHandler struct {
	slog.Handler
}

func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
	if attrs, ok := ctx.Value(slogFields).([]slog.Attr); ok {
		for _, v := range attrs {
			r.AddAttrs(v)
		}
	}
	return h.Handler.Handle(ctx, r)
}

// AppendCtx returns a copy of context.Context with value named slogFields containing []slog.Attr needed for ContextHandler
func AppendCtx(parent context.Context, attr slog.Attr) context.Context {
	if parent == nil {
		parent = context.Background()
	}

	if v, ok := parent.Value(slogFields).([]slog.Attr); ok {
		v = append(v, attr)
		return context.WithValue(parent, slogFields, v)
	}
	v := []slog.Attr{}
	v = append(v, attr)
	return context.WithValue(parent, slogFields, v)
}

Testing

Sending the request

$ curl -v -H "X-Forwarded-For: 192.168.1.1"  http://127.0.0.1:8888/ping\?1
*   Trying 127.0.0.1:8888...
* Connected to 127.0.0.1 (127.0.0.1) port 8888
> GET /ping?1 HTTP/1.1
> Host: 127.0.0.1:8888
> User-Agent: curl/8.4.0
> Accept: */*
> X-Forwarded-For: 192.168.1.1
>
< HTTP/1.1 200 OK
< X-Correlation-Id: coa54o197i620lq8tchg
< Date: Mon, 08 Apr 2024 20:24:00 GMT
< Content-Length: 4
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host 127.0.0.1 left intact
pong%

Running the server

We can see here that the text log in pingHandler have the same correlation ID as access log.

Text log

time=2024-04-08T22:23:44.886+02:00 level=INFO msg="starting listening" addr=:8888
time=2024-04-08T22:24:00.325+02:00 level=INFO msg=test x-forwarded-for=192.168.1.1 correlation_id=coa54o197i620lq8tchg
time=2024-04-08T22:24:00.325+02:00 level=INFO msg="access log" method=GET url=/ping?1 user_agent=curl/8.4.0 remote_addr=127.0.0.1:61846 remote_host=127.0.0.1 referer="" proto=HTTP/1.1 took=149.708µs status_code=200 bytes=4 x-forwarded-for=192.168.1.1 correlation_id=coa54o197i620lq8tchg

JSON log

{"time":"2024-04-08T22:25:18.09855+02:00","level":"INFO","msg":"starting listening","addr":":8888"}
{"time":"2024-04-08T22:25:21.016254+02:00","level":"INFO","msg":"test","x-forwarded-for":"192.168.1.1","correlation_id":"coa55c997i6232p4q1tg"}
{"time":"2024-04-08T22:25:21.01635+02:00","level":"INFO","msg":"access log","method":"GET","url":"/ping?1","user_agent":"curl/8.4.0","remote_addr":"127.0.0.1:61853","remote_host":"127.0.0.1","referer":"","proto":"HTTP/1.1","took":95291,"status_code":200,"bytes":4,"x-forwarded-for":"192.168.1.1","correlation_id":"coa55c997i6232p4q1tg"}

Join the Newsletter

Subscribe to get my latest content by email.

    I won't send you spam. Unsubscribe at any time.

    For comments, please send me 📧 an email