Custom logging with timbre

Timbre?

At my job we recently started researching logging tools to make our RESTful API, written in Clojure, writing logs in JSON format. We were using Log4j already, but decided to use another tool for this task, making it less painful. So we felt into timbre. Is seemed so easy to use, but it is really undocumented.

According to timbre’s API, we needed to define our own appender for writing to a custom JSON file. And we found the output-fn option to configure this behaviour. But it is not documented at all, so we started looking for repositories, using timbre, examples and all the stuff. And finally, we ended up with our own solution.

Underneath you will find description of our way to use timbre from scratch.

Getting started

First of all, you will need a Leiningen project. We already have one, but you may want to create a blank one for the test purpose. But beware, you’d better generate it from an app template, so when you run it with lein run, you face not the problem I faced. The problem was an error I could not get rid of, saying Cannot find anything to run for: my-ns. So I just re-generated project from scratch and the problem gone. I did no research deeper, but if I will, I’ll write about it for sure.

Generating project from an app template is done really easily:

lein new app your-app-name

The process will be finished in seconds. Then you’ll need to add a few dependencies in your project.clj:

(defproject your-app-name
    ; ...
    :dependencies [[org.clojure/clojure "1.6.0"]
                 [com.taoensso/timbre "4.1.4"]
                 [org.clojure/data.json "0.2.6"]]
    ; ...
)

To install dependencies for your project, simply run lein deps.

Configuring timbre

To use timbre in your project all you need is to require it:

(ns timbre-test1.core
  (:require [taoensso.timbre :as timbre :refer (log info warn debug error)]
            [clojure.data.json :as json]))

(defn -main [& args]
  (info {:moo -3.14} {:foo :bar})
  (info {:some-complicated-hash {:inner-hash {:more-inner ["value1" "value2"]}}})
  (info "Hello, World!")
  (error "SOMETHING WENT WELL"))

This example shows one very important feature of timbre: it can log objects, other than strings and exceptions. This is really handy! Doing so, timbre is able to log a JSON object simply, almost out-of-the-box.

Custom configuration

Now the time has come for something non-trivial. We need to set up our own appender to force timbre log everything in JSON.

There is a set of default appenders in timbre already. For instance, there is a spit appender, writing logs to a file. And the default timbre’s appender, writing logs to stdout, is called sample. But there’s a trick: each appender in timbre should be configured by its id, not by its type. So you may have two different spit appenders, for example, writing to different files.

Timbre provides two methods of configuration:

  • merging, when you can override only some options of default config
  • setting, when you override all the default options at once

We used the first way, because some of timbre’s options were mystical to us - if we do not set them, we will get no output at all. And we have not researched which ones we need to set.

At first, we wrote a simple config, forcing logging to be printed onto a screen with some JSON formatting:

(ns timbre-test1.core
  (:require [taoensso.timbre :as timbre :refer (log info warn debug error)]
            [clojure.data.json :as json]))


(defn json-output-fn
  [{:keys [vargs_ hostname_ timestamp_ level] :as args}]
  (let [
        messages (map (fn [msg] { :timestamp @timestamp_
                                  :level     level
                                  :hostname  @hostname_
                                  :message   msg })
                   @vargs_)
        json-messages (map #(json/write-str %) messages)]
    (clojure.string/join "\n" json-messages)))


(defn -main [& args]
  (timbre/merge-config!
    {:appenders {
                 :println {
                    :output-fn json-output-fn
                 }}})
  (info {:moo -3.14} {:foo :bar})
)

Note the appender id: println. This code overrides output-fn for println appender (e. g. the default one). The parameter for output-fn is a function, receiving a map with different keys. The most important ones are vargs_, hostname_, timestamp_ and level. Those are enough to format the correct and full message with any format you might want.

The vargs_ variable contains all the arguments, passed to a logging call (info, warn, error - any function from timbre namespace, which performs logging). And the clojure.data.json/write-str function converts its params to JSON and stringifies it, returning valid JSON as a string.

A function should return a string, representing a message, which will be written to a screen or a file, depending on appender.

level argument is set by a macro, you are logging with. For example, timbre/info will set level to "info", timbre/error - to "error" and so on.

hostname_ and timestamp_ are helper arguments, you might not need them in all the use cases. But the timestamp_ one is really helpful. Always.

Then we overrided the default file appender, extending it with our json-output-fn:

(ns timbre-test1.core
  (:require [taoensso.timbre :as timbre :refer (log info warn debug error)]
            [taoensso.timbre.appenders.core :as appenders]
            [clojure.data.json :as json]))


(defn json-output-fn
  [{:keys [vargs_ hostname_ timestamp_ level] :as args}]
  (let [
        messages (map (fn [msg] {:timestamp @timestamp_
                                  :level     level
                                  :hostname  @hostname_
                                  :message   msg})
                   @vargs_)
        json-messages (map #(json/write-str %) messages)]
    (clojure.string/join "\n" json-messages)))


(defn -main [& args]
  (timbre/merge-config!
    {:appenders {
                 :spit (merge (appenders/spit-appender {:fname "timbre.log"}) {:output-fn json-output-fn})
                 }})
  (info {:moo -3.14} {:foo :bar})
)

Nothing new here, except of the appender used - now the application will log to both file and screen. To prevent logging to stdout, we disabled the println appender:

(defn -main [& args]
  (timbre/merge-config!
    {:appenders {
                 :println {:enabled? true}
                 :spit (merge (appenders/spit-appender {:fname "timbre.log"}) {:output-fn json-output-fn})
                 }})
  (info {:moo -3.14} {:foo :bar})
)

Here I should mention that we were not made to call our JSON appender spit - it was used for convention reasons only.

And that’s basically it - we made our logging to be written to a JSON file only.