Skip to main content Arjen Wiersma

Observability in Clojure

Observability in cloud-native applications is crucial for managing complex systems and ensuring reliability (Chakraborty & Kundan, 2021; Kosińska et al., 2023). It enables continuous generation of actionable insights based on system signals, helping teams deliver excellent customer experiences despite underlying complexities (Hausenblas, 2023; Chakraborty & Kundan, 2021). In essence, adding proper observability to your system allows you to find and diagnose issues without having to dig through tons of unstructured log files.

The running project

In my previous post on reitit we built a simple endpoint using Clojure and reitit. The complete code for the small project was:

clojure code snippet start

(ns core
  (:require
   [reitit.ring :as ring]
   [ring.adapter.jetty :as jetty]))

(defn handler [request]
  {:status 200
   :body (str "Hello world!")})

(def router (ring/router
             ["/hello" {:get #'handler}]))

(def app (ring/ring-handler router
                            (ring/create-default-handler)))

clojure code snippet end

Nice and easy eh? That simplicity is what I truly love about Clojure . That, and the fact that there is an awesome interoperability with the Java ecosystem of libraries.

Adding observability

In Clojure it is possible to add observability through the wonderful clj-otel library by Steffan Westcott. It implements the OpenTelemetry standard which makes it integrate nicely in products such as HoneyComb.io and Jaeger.

The library has a great tutorial that you can follow here. Applying the knowledge from this tutorial to our reitit application is also trivial. To show the power of observability a JDBC connection will be added to the application. It is not necessary to mess with any tables or such, it will just leverage a connection to a Postgres database and a value will be queried from it.

First, lets see the updated deps.edn file.

clojure code snippet start

{:deps {ring/ring-jetty-adapter {:mvn/version "1.13.0"}
        metosin/reitit {:mvn/version "0.7.2"}

        ;; Observability
        com.github.steffan-westcott/clj-otel-api {:mvn/version "0.2.7"}
        
        ;; Database access
        com.github.seancorfield/next.jdbc {:mvn/version "1.3.981"}
        org.postgresql/postgresql {:mvn/version "42.7.4"}
        com.zaxxer/HikariCP {:mvn/version "6.2.1"}}

 :aliases {:otel {:jvm-opts ["-javaagent:opentelemetry-javaagent.jar"
                             "-Dotel.resource.attributes=service.name=blog-service"
                             "-Dotel.metrics.exporter=none"
                             ]}}}

clojure code snippet end

You will notice some new dependencies, as well as an alias that you can use to start the repl with. If you, like me, use Emacs you can codify this into a .dir-locals.el file for your project.

emacs-lisp code snippet start

((nil . ((cider-clojure-cli-aliases . ":otel"))))

emacs-lisp code snippet end

Now, whenever cider creates a new repl it will use the otel alias as well.

The agent that is listed as javaagent can be downloaded from the OpenTelemetry Java Instrumentation page. This will immediately bring in a slew of default instrumentations to the project. Give it a try with the starter project, you will notice that all the jetty requests will show up in your jaeger instance (you did look at the tutorial, right?).

Finally, here is the update project for you to play with.

clojure code snippet start

(ns core
  (:require
   [next.jdbc :as jdbc]
   [reitit.ring :as ring]
   [ring.adapter.jetty :as jetty]
   [ring.util.response :as response]
   [steffan-westcott.clj-otel.api.trace.http :as trace-http]
   [steffan-westcott.clj-otel.api.trace.span :as span]))

(def counter (atom 0))

;; add your database configuration here
(def db {:jdbcUrl "jdbc:postgresql://localhost:5432/db-name?user=db-user&password=db-pass"})

(def ds (jdbc/get-datasource db))

(defn wrap-db
  [handler db]
  (fn [req]
    (handler (assoc req :db db))))

(defn wrap-exception [handler]
  (fn [request]
    (try
      (handler request)
      (catch Throwable e
        (span/add-exception! e {:escaping? false})
        (let [resp (response/response (ex-message e))]
          (response/status resp 500))))))

(defn db->value [db]
  (let [current @counter]
    (span/with-span! "Incrementing counter"
      (span/add-span-data! {:attributes {:service.counter/count current}})
      (swap! counter inc))
    (:value (first (jdbc/execute! db [(str "select " current " as value")])))))

(defn handler [request]
  (let [db (:db request)
        dbval (db->value db)]
    (span/add-span-data! {:attributes {:service.counter/count dbval}})
    {:status 200
     :body (str "Hello world: " dbval)}))

(def router (ring/router
             ["/hello" {:get (-> #'handler
                                 (wrap-db ds)
                                 wrap-exception
                                 trace-http/wrap-server-span)}]))
                                 
(def app (ring/ring-handler router
                            (ring/create-default-handler)))

(def server (jetty/run-jetty #'app {:port 3000, :join? false}))
;; (.stop server)

clojure code snippet end

There are several interesting bits to be aware of. First the handler is wrapped in several middleware functions, one to pass the database connection, the other to wrap the exceptions (such as in the tutorial) and finally the middleware to wrap a server request. The db->value creates its own span to keep track of its activity.

After making several requests you will see that Jaeger contains the same amount of traces. A normal trace will show 3 bars, each of which you can expand and explore.

A trace in Jaeger

If you take the database offline (that is why we used Postgres), you will notice that the exception is neatly logged.

Exceptions in Jaeger

Observability allows you to get a great insight into how you application is running in production. With the clj-otel library it is a breeze to enhance your own application.