About Go logging for reusable packages

Posted | martinangers.xyz

My last post on handling HTTP clients was generally well received as far as I know, so I’m going to push my luck and come back with a similar post, a recommendation for package writers this time on how to handle logging. There are many different logging packages, and it’s not necessarily obvious how to support logging of important events in a reusable package in such a way that those events are logged in the caller application’s preferred destination and format.

Update : as was mentioned in the thread on the golang bridge forum, a reusable package should avoid as much as possible to log anything, and return errors instead where it makes sense and let the caller worry about logging. That’s what I was trying to say with “it should either log something clearly important or not log at all” towards the end, but it’s worth making that clear right from the start.

Update 2 : the log15 package also uses key/value pairs in the variadic ...interface{} list, as logxi does, I did miss that. Thanks to Chris Hines for pointing that out in the golang bridge forum.

Current state of Go loggers

I checked many popular logging packages - those that had over 100 stars at that moment - based on the awesome-go list, in addition to the standard library’s log package. I omitted the log/syslog package as it can be wrapped in a standard *log.Logger.

That means the following packages have been reviewed:

There are many more logging packages, and I’m sorry for not including them in that list, but I had to draw a line somewhere. Now if you’re building a reusable package that needs to log some information, you’re faced with an interesting problem - what should be the type of the logger accepted by your package?

The stdlib itself uses, naturally, a *log.Logger value when it needs to support this (e.g. in http.Server, the ErrorLog field is such a value, same in httputil.ReverseProxy and cgi.Handler). But with so many fragmentation in the community, and even the package golang/glog provided in the language’s official repositories, chances are good that the caller of your package does not use the stdlib’s log package.

Let’s look at the various APIs offered by those packages to log an event, starting with the standard library for reference.

Let’s just focus on the signature for now using the Print family (the same comment applies to Panic and Fatal too). It’s unclear to me why there’s a Print and a Println variant, given that the log package adds a newline after the message if there was none. The difference is subtle and is the same as the one between Sprint:

Spaces are added between operands when neither is a string.

and Sprintln:

Spaces are always added between operands […]

I guess it may be useful sometimes? Anyway, take note of the signature of the various functions and let’s check what the community-provided logging packages have to offer.

Logrus acknowledges the issue of compatibility with the stdlib’s logger (and the fragmentation of the logging abstraction) with the StdLogger interface.

What stands out is that although logrus is interface-level compatible with the stdlib’s logger, no other package is (the “Print” family is lacking, generally replaced by leveled logging with “Info”, “Debug”, “Error” and such). What the wide majority of packages do support, however, is the signature of the logging function, especially the “Printf”-style: func(string, ...interface{}).

Accept a LogFunc

In this situation, the most flexible option seems to be to accept a LogFunc parameter in your package, with the “Printf”-style signature. For example (variable type added for clarity, if you initialize it with log.Printf you can get rid of the type):

package mypkg

// LogFunc is a function that logs the provided message with optional
// fmt.Sprintf-style arguments. By default, logs to the default log.Logger.
var LogFunc func(string, ...interface{}) = log.Printf

And setting it to nil can be used to disable logging for this package. This doesn’t enforce a coupling with any specific external package and is already widely supported by existing loggers. To paraphrase Mr Carmack, sometimes, the elegant abstraction is just a function.

I don’t think a reusable package should worry about the level of logging, it should either log something clearly important (e.g. the http.Server logs panics in a handler) or not log at all. Let the caller of the package worry about which level this should be logged to (e.g. pass in seelog.Debugf or glog.Infof).

Similarly, the package should not worry about the formatting and the “backend” of the logger. Again, it’s up to the caller to provide the method from a properly configured logger that will take care of rendering the logged message as desired, be it JSON in a file or plain text to some logging-as-a-service platform.

The downside is that some logging packages do not play well with that approach - logxi being the outsider in this list, treating the arguments as key-value pairs instead of fmt.Sprintf style. But then, it should be easy enough for callers to write an adapter for those non-standard loggers (in this case, maybe generate a format string with placeholders for each key-value pair).

Closing thoughts

I’ve kept this article centered on the low-level abstraction of how to interact with an injected logger dependency in the context of a reusable package, regardless of the relative merits of the various approaches, but on a higher level, you should be mindful of the complexity of your logging solution. The proliferation of logging levels is addressed in this blog post by Dave Cheney. The 12-factor app manifesto touches on the role of the app regarding logging.

Both tackle a different angle of logging, but both argue for a simpler, more straightforward approach to logging from the point of view of the application. From the 12-factor app manifest:

A twelve-factor app never concerns itself with routing or storage of its output stream. It should not attempt to write to or manage logfiles. Instead, each running process writes its event stream, unbuffered, to stdout.

From Dave Cheney’s post:

I believe that there are only two things you should log:

  • Things that developers care about when they are developing or debugging software.
  • Things that users care about when using your software.

Obviously these are debug and info levels, respectively.