API | Wiki | Slack channel | Latest release: v1.1.0 (2025-08-22)
Telemere is the next-gen version of Timbre. It offers one API to cover:
- Traditional logging (string messages)
- Structured logging (rich Clojure data types and structures)
- Tracing (nested flow tracking, with optional data)
- Basic performance monitoring (nested form runtimes)
It's pure Clj/s, small, easy to use, super fast, and seriously flexible:
(tel/log! {:level :info, :id ::login, :data {:user-id 1234}, :msg "User logged in!"})
Works great with:
- Trove for logging by library authors
- Tufte for rich performance monitoring
- Truss for assertions and error handling
- Traditional logging outputs strings (messages).
- Structured logging in contrast outputs data. It retains rich data types and (nested) structures throughout the logging pipeline from logging callsite → filters → middleware → handlers.
A data-oriented pipeline can make a huge difference - supporting easier filtering, transformation, and analysis. It’s also usually faster, since you only pay for serialization if/when you need it. In a lot of cases you can avoid serialization altogether if your final target (DB, etc.) supports the relevant types.
The structured (data-oriented) approach is inherently more flexible, faster, and well suited to the tools and idioms offered by Clojure and ClojureScript.
See examples.cljc for REPL-ready snippets, or expand below:
Create signals
(require '[taoensso.telemere :as tel])
;; No config needed for typical use cases!!
;; Signals print to console by default for both Clj and Cljs
;; Traditional style logging (data formatted into message string):
(tel/log! {:level :info, :msg (str "User " 1234 " logged in!")})
;; Modern/structured style logging (explicit id and data)
(tel/log! {:level :info, :id :auth/login, :data {:user-id 1234}})
;; Mixed style (explicit id and data, with message string)
(tel/log! {:level :info, :id :auth/login, :data {:user-id 1234}, :msg "User logged in!"})
;; Trace (can interop with OpenTelemetry)
;; Tracks form runtime, return value, and (nested) parent tree
(tel/trace! {:id ::my-id :data {...}}
(do-some-work))
;; Check resulting signal content for debug/tests
(tel/with-signal (tel/log! {...})) ; => {:keys [ns level id data msg_ ...]}
;; Getting fancy (all costs are conditional!)
(tel/log!
{:level :debug
:sample 0.75 ; 75% sampling (noop 25% of the time)
:when (my-conditional)
:limit {"1 per sec" [1 1000]
"5 per min" [5 60000]} ; Rate limit
:limit-by my-user-ip-address ; Rate limit scope
:do (inc-my-metric!)
:let
[diagnostics (my-expensive-diagnostics)
formatted (my-expensive-format diagnostics)]
:data
{:diagnostics diagnostics
:formatted formatted
:local-state *my-dynamic-context*}}
;; Message string or vector to join as string
["Something interesting happened!" formatted])
Filter signals
;; Set minimum level
(tel/set-min-level! :warn) ; For all signals
(tel/set-min-level! :log :debug) ; For `log!` signals specifically
;; Set id and namespace filters
(tel/set-id-filter! {:allow #{::my-particular-id "my-app/*"}})
(tel/set-ns-filter! {:disallow "taoensso.*" :allow "taoensso.sente.*"})
;; SLF4J signals will have their `:ns` key set to the logger's name
;; (typically a source class)
(tel/set-ns-filter! {:disallow "com.noisy.java.package.*"})
;; Set minimum level for `log!` signals for particular ns pattern
(tel/set-min-level! :log "taoensso.sente.*" :warn)
;; Use transforms (xfns) to filter and/or arbitrarily modify signals
;; by signal data/content/etc.
(tel/set-xfn!
(fn [signal]
(if (-> signal :data :skip-me?)
nil ; Filter signal (don't handle)
(assoc signal :transformed? true))))
(tel/with-signal (tel/log! {... :data {:skip-me? true}})) ; => nil
(tel/with-signal (tel/log! {... :data {:skip-me? false}})) ; => {...}
;; See `tel/help:filters` docstring for more filtering options
Add handlers
;; Add your own signal handler
(tel/add-handler! :my-handler
(fn
([signal] (println signal))
([] (println "Handler has shut down"))))
;; Use `add-handler!` to set handler-level filtering and back-pressure
(tel/add-handler! :my-handler
(fn
([signal] (println signal))
([] (println "Handler has shut down")))
{:async {:mode :dropping, :buffer-size 1024, :n-threads 1}
:priority 100
:sample 0.5
:min-level :info
:ns-filter {:disallow "taoensso.*"}
:limit {"1 per sec" [1 1000]}
;; See `tel/help:handler-dispatch-options` for more
})
;; See current handlers
(tel/get-handlers) ; => {<handler-id> {:keys [handler-fn handler-stats_ dispatch-opts]}}
;; Add console handler to print signals as human-readable text
(tel/add-handler! :my-handler
(tel/handler:console
{:output-fn (tel/format-signal-fn {})}))
;; Add console handler to print signals as edn
(tel/add-handler! :my-handler
(tel/handler:console
{:output-fn (tel/pr-signal-fn {:pr-fn :edn})}))
;; Add console handler to print signals as JSON
;; Ref. <https://github.com/metosin/jsonista> (or any alt JSON lib)
#?(:clj (require '[jsonista.core :as jsonista]))
(tel/add-handler! :my-handler
(tel/handler:console
{:output-fn
#?(:cljs :json ; Use js/JSON.stringify
:clj jsonista/write-value-as-string)}))
- Elegant unified API that's easy to use and deeply flexible.
- Pure Clojure vals and fns for easy config, composition, and REPL debugging.
- Sensible defaults to get started fast.
- Beginner-oriented documentation, docstrings, and error messages.
- Interop ready with tools.logging, Java logging via SLF4J v2, OpenTelemetry, and Tufte.
- Timbre shim for easy/gradual migration from Timbre.
- Extensive set of handlers included out-the-box.
- Rich filtering by namespace, id pattern, level, level by namespace pattern, etc.
- Fully configurable a/sync dispatch support with per-handler performance monitoring.
- Turn-key sampling, rate limiting, and back-pressure monitoring.
- Highly optimized and blazing fast!
- Telemere compared to Timbre (Telemere's predecessor)
- Telemere compared to μ/log (structured micro-logging library)
80% of Telemere's functionality is available through one macro: signal!
and a rich set of opts.
Use that directly, or any of the wrapper macros that you find most convenient. They're semantically equivalent but have ergonomics slightly tweaked for different common use cases:
Name | Args | Returns |
---|---|---|
log! |
[opts] or [?level msg] |
nil |
event! |
[opts] or [id ?level] |
nil |
trace! |
[opts] or [?id run] |
Form result |
spy! |
[opts] or [?level run] |
Form result |
error! |
[opts] or [?id error] |
Given error |
catch->error! |
[opts] or [?id error] |
Form value or given fallback |
signal! |
[opts] |
Depends on opts |
Detailed help is available without leaving your IDE:
Var | Help with |
---|---|
help:signal-creators |
Creating signals |
help:signal-options |
Options when creating signals |
help:signal-content |
Signal content (map given to transforms/handlers) |
help:filters |
Signal filtering and transformation |
help:handlers |
Signal handler management |
help:handler-dispatch-options |
Signal handler dispatch options |
help:environmental-config |
Config via JVM properties, environment variables, or classpath resources |
Telemere is highly optimized and offers great performance at any scale, handling up to 4.2 million filtered signals/sec on a 2020 Macbook Pro M1.
Signal call benchmarks (per thread):
Compile-time filtering? | Runtime filtering? | Profile? | Trace? | nsecs / call |
---|---|---|---|---|
✓ (elide) | - | - | - | 0 |
- | ✓ | - | - | 350 |
- | ✓ | ✓ | - | 450 |
- | ✓ | ✓ | ✓ | 1000 |
- Nanoseconds per signal call ~ milliseconds per 1e6 calls
- Times exclude handler runtime (which depends on handler/s, is usually async)
- Benched on a 2020 Macbook Pro M1, running Clojure v1.12 and OpenJDK v22
Telemere is optimized for real-world performance. This means prioritizing flexibility and realistic usage over synthetic micro-benchmarks.
Large applications can produce absolute heaps of data, not all equally valuable. Quickly processing infinite streams of unmanageable junk is an anti-pattern. As scale and complexity increase, it becomes more important to strategically plan what data to collect, when, in what quantities, and how to manage it.
Telemere is designed to help with all that. It offers rich data and unmatched filtering support - including per-signal and per-handler sampling and rate limiting, and zero cost compile-time filtering.
Use these to ensure that you're not capturing useless/low-value/high-noise information in production! With appropriate planning, Telemere is designed to scale to systems of any size and complexity.
See here for detailed tips on real-world usage.
See ✅ links below for features and usage,
See ❤️ links below to vote on future handlers:
Target (↓) | Clj | Cljs |
---|---|---|
Apache Kafka | ❤️ | - |
AWS Kinesis | ❤️ | - |
Console | ✅ | ✅ |
Console (raw) | - | ✅ |
Datadog | ❤️ | ❤️ |
✅ | - | |
Graylog | ❤️ | - |
Jaeger | ❤️ | - |
Logstash | ❤️ | - |
OpenTelemetry | ✅ | ❤️ |
Redis | ❤️ | - |
SQL | ❤️ | - |
Slack | ✅ | - |
TCP socket | ✅ | - |
UDP socket | ✅ | - |
Zipkin | ❤️ | - |
You can also easily write your own handlers.
My plan for Telemere is to offer a stable core of limited scope, then to focus on making it as easy for the community to write additional stuff like handlers, transforms, and utils.
See here for community resources.
- Wiki (getting started, usage, etc.)
- API reference via cljdoc
- Extensive internal help (no need to leave your IDE)
- Support via Slack channel or GitHub issues
- General observability tips (advice on building and maintaining observable Clojure/Script systems, and getting the most out of Telemere)
You can help support continued work on this project and others, thank you!! 🙏
Copyright © 2023-2025 Peter Taoussanis.
Licensed under EPL 1.0 (same as Clojure).