Logging

This guide explains how logging works in ShipClojure, how to configure log levels for different environments, and how to add logging to your own code.


Overview

ShipClojure uses Telemere as its logging library. Telemere is the next-generation replacement for Timbre, providing structured telemetry with a unified API.

Key Features:

  • Structured JSON logging for production
  • Contextual logging that accumulates request information
  • Unified backend for both Clojure logs and Java/SLF4J logs
  • Per-namespace log level configuration
  • Automatic sensitive data redaction

Telemere as the Unified Logging Backend

ShipClojure uses Telemere for all logging, including logs from Java libraries. This is achieved through telemere-slf4j, which routes all SLF4J logging calls through Telemere.

Dependencies

;; In deps.edn
com.taoensso/telemere {:mvn/version "1.2.0"}
com.taoensso/telemere-slf4j {:mvn/version "1.2.0"}
org.slf4j/slf4j-api {:mvn/version "2.0.17"}

This setup means:

  • Your Clojure code uses taoensso.telemere directly
  • Java libraries (Datomic, HTTP-Kit, PostgreSQL driver, etc.) use SLF4J, which routes to Telemere
  • All logs go through the same handlers and filters

Verifying Interop

You can verify the SLF4J integration is working in your REPL:

(require '[taoensso.telemere :as t])
(t/check-interop)
;; => {:slf4j {:sending->telemere? true, :telemere-receiving? true}}

Basic Logging

Creating Logs

Use t/log! for basic logging:

(require '[taoensso.telemere :as t])

;; Simple message
(t/log! :info "User logged in")

;; With data
(t/log! {:level :info
         :id :user/login
         :data {:user-id 123}}
        "User logged in")

;; With error
(t/log! {:level :error
         :error ex}
        "Failed to process payment")

Log Levels

Telemere supports these levels (from most to least verbose):

  • :trace
  • :debug
  • :info
  • :warn
  • :error
  • :fatal

Contextual Logging

One of Telemere's most powerful features is contextual logging with t/with-ctx+. ShipClojure uses this to gradually add context as a request flows through the middleware chain.

How Context Accumulates

1. API call starts
   → Add {:request-method :uri :server-name :req-id} to log context

   2. Request params are parsed
      → Add params to log context (with sensitive data redacted)

      3. JWT is decoded to get user claims
         → Add relevant user info to log context

         4. Business logic logs have full request context

This means any log statement deep in your business logic automatically includes the request method, URI, request ID, user info, and more.

Implementation

The context is added through Ring middleware in saas.web.middleware.logger:

(defn wrap-log-request-start
  "Ring middleware to log basic information about a request.
  Adds the key :saas.logging/start-ms to the request map"
  [handler]
  (fn [request]
    (t/with-xfn reduce-request-log-noise-xf
      (t/with-ctx+ (into {:req-id (uuid)}
                         (select-keys request [:request-method :uri :server-name]))
        (handler (log-request-start request))))))

Later middleware adds more context:

(defn wrap-log-request-params
  [handler]
  (fn [request]
    (let [params (redacted-request-params request)]
      (t/with-ctx+ params
        (handler request)))))

Benefits

With this setup, every log entry contains the full context:

{
  "timestamp": "2025-10-16T06:30:22.287897Z",
  "level": "info",
  "id": "db/query",
  "msg": "Fetched user profile",
  "ctx": {
    "req-id": "5efd277d-1ee7-46f8-94d7-93131c0d628a",
    "request-method": "post",
    "uri": "/cqrs/query",
    "server-name": "localhost",
    "params": {
      "query/kind": ":query/get-profile"
    }
  }
}

You can trace all logs for a single request by filtering on req-id.


Configuring Log Levels

Per-Environment Configuration

Log levels are configured in the environment-specific env.clj files:

  • env/dev/clj/saas/env.clj - Development settings
  • env/prod/clj/saas/env.clj - Production settings
  • env/test/clj/saas/env.clj - Test settings

Understanding Signal Kinds

Telemere uses "signal kinds" to distinguish log sources:

  • :log - Logs from Telemere's log! macro (your Clojure code)
  • :slf4j - Logs from Java libraries via SLF4J

When configuring levels, you need to specify the correct kind:

;; For your Clojure code (uses log!)
(t/set-min-level! :log "saas.*" :debug)

;; For Java libraries (use SLF4J)
(t/set-min-level! :slf4j "datomic.*" :warn)

Silencing Noisy Dependencies

When you add a new Java dependency that's too chatty, silence it by adding to the appropriate configure-*-log-levels! function:

;; In env/dev/clj/saas/env.clj
(defn configure-dev-log-levels!
  []
  ;; ... existing config ...

  ;; Silence your new noisy dependency
  (t/set-min-level! :slf4j "com.noisy.library.*" :warn))

Development Configuration

Development is configured to be verbose for application code but quiet for third-party libraries:

(defn configure-dev-log-levels!
  []
  ;; Silence infrastructure noise
  (t/set-min-level! :slf4j "org.postgresql.*" :warn)
  (t/set-min-level! :slf4j "datomic.*" :error)
  (t/set-min-level! :slf4j "org.httpkit.*" :warn)
  (t/set-min-level! :slf4j "io.methvin.watcher.*" :warn)

  ;; Keep application logs verbose
  (t/set-min-level! :log "saas.*" :debug))

Production Configuration

Production is less strict on infrastructure but keeps application logs at INFO:

(defn configure-prod-log-levels!
  []
  ;; Infrastructure at reasonable levels
  (t/set-min-level! :slf4j "datomic.peer.*" :info)
  (t/set-min-level! :slf4j "datomic.connection.*" :warn)

  ;; Application at INFO
  (t/set-min-level! :log "saas.*" :info))

Test Configuration

Tests are configured to be as quiet as possible:

(defn configure-test-log-levels!
  []
  ;; Silence everything except errors
  (t/set-min-level! :slf4j "datomic.*" :error)
  (t/set-min-level! :slf4j "org.postgresql.*" :error)

  ;; App logs at INFO
  (t/set-min-level! :log "saas.*" :info))

Sensitive Data Redaction

The logging middleware automatically redacts sensitive fields from request parameters:

(def default-redact-key?
  #{:authorization :password :token :secret :secret-key
    :secret-token :refresh-token :confirm-password
    :access-token :id-token :csrf-token})

Logged as:

{
  "params": {
    "user/email": "user@example.com",
    "user/password": "[REDACTED]"
  }
}

Adding Custom Redaction

Edit src/clj/saas/logging.clj:

(def default-redact-key?
  #{:authorization :password :token :secret
    :api-key :credit-card :ssn}) ; Add more keys

JSON File Logging

In production (and optionally in development), logs are written to JSON files for easy querying.

Configuration

(logging/add-json-file-handler!
  {:path "logs/saas-app.json"
   :interval :daily           ; :daily, :weekly, or :monthly
   :max-file-size (* 1024 1024 100) ; 100MB
   :max-num-parts 10          ; Parts per interval
   :max-num-intervals 30})    ; Days to keep

Log Format

Each line is a single JSON object:

{
  "timestamp": "2025-10-16T06:30:22.287897Z",
  "level": "info",
  "id": "api-call/response",
  "msg": "POST /cqrs/command => 200",
  "ns": "saas.logging",
  "line": 71,
  "data": {
    "status": 200,
    "saas.logging/ms": 45
  },
  "ctx": {
    "req-id": "5efd277d-1ee7-46f8-94d7-93131c0d628a",
    "request-method": "post",
    "uri": "/cqrs/command"
  }
}

Querying Logs in Production

ShipClojure includes Kamal aliases for common log queries:

# Live tail with formatting
kamal json-logs-tail

# All errors
kamal json-logs-errors

# Latest 100 logs, sorted
kamal json-logs-latest

# Today's logs
kamal json-logs-today

Common jq Queries

# Filter by request ID
cat logs/saas-app.json | jq -s --arg id "YOUR-REQ-ID" \
  '.[] | select(.ctx."req-id" == $id)'

# Find slow requests (>1000ms)
cat logs/saas-app.json | jq -s \
  '.[] | select(.data."saas.logging/ms" > 1000)'

# Count errors by type
cat logs/saas-app.json | jq -s \
  '[.[] | select(.level == "error")] | group_by(.id) | map({id: .[0].id, count: length})'

For more query examples, see the Observability Guide.


Filtering Noisy Logs

Transform Functions

Use t/with-xfn to filter logs based on content:

(defn reduce-request-log-noise-xf
  "Return nil for signals we don't want to log"
  [signal]
  (if-let [uri (-> signal :data :uri)]
    (cond
      (.startsWith ^String uri "/js") nil  ; Skip JS requests
      :else signal)
    signal))

;; In middleware
(t/with-xfn reduce-request-log-noise-xf
  (handler request))

Namespace Filters

Use t/set-ns-filter! to blanket-allow or disallow namespaces:

;; Disallow all logs from a namespace
(t/set-ns-filter! {:disallow "com.noisy.library.*"})

;; Allow specific namespace within a disallowed pattern
(t/set-ns-filter! {:disallow "com.library.*"
                   :allow "com.library.important.*"})

Adding Logging to Your Code

In Handlers

(defn handle-purchase [req]
  (let [{:keys [user-id product-id]} (:params req)]
    (t/log! {:level :info
             :id :purchase/start
             :data {:user-id user-id :product-id product-id}}
            "Processing purchase")

    ;; ... business logic ...

    (t/log! {:level :info
             :id :purchase/complete}
            "Purchase completed")))

In Business Logic

The context from middleware is automatically included:

(defn calculate-shipping [order]
  ;; This log will include req-id, uri, user info, etc.
  (t/log! :debug ["Calculating shipping for order" (:id order)])
  ;; ... logic ...
  )

Error Logging

(try
  (process-payment! payment)
  (catch Exception e
    (t/log! {:level :error
             :id :payment/failed
             :error e
             :data {:payment-id (:id payment)}}
            "Payment processing failed")))

Debugging Logging Issues

Check Active Handlers

(t/get-handlers)
;; => {:console #<...>, :json-file #<...>}

Check Current Filters

(t/get-filters)
;; Shows all active filters

Test Signal Creation

(t/with-signal
  (t/log! :info "Test message"))
;; => {:level :info, :msg "Test message", ...}

Temporarily Disable Filters

(t/without-filters
  (t/log! :debug "This will always appear"))

Further Reading