From 8c5cfc01ff103e632cf65f3ce1a998cfe3a8d86f Mon Sep 17 00:00:00 2001 From: Andrey Bogoyavlenskiy Date: Wed, 18 Feb 2026 22:12:03 +0000 Subject: [PATCH 1/2] Add JVM profiling support --- CHANGELOG.md | 5 ++++ README.md | 43 ++++++++++++++++++++++++++++++ src/sentry_clj/core.clj | 18 ++++++++++++- src/sentry_clj/profiling.clj | 34 +++++++++++++++++++++++ test/sentry_clj/core_test.clj | 20 +++++++++++++- test/sentry_clj/profiling_test.clj | 37 +++++++++++++++++++++++++ 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 src/sentry_clj/profiling.clj create mode 100644 test/sentry_clj/profiling_test.clj diff --git a/CHANGELOG.md b/CHANGELOG.md index 34223ff..32cc7bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ endeavour to be non-breaking (by moving to new names rather than by breaking existing names). COMMITS is an ever-increasing counter of commits since the beginning of this repository. +## [8.33.244] + +- Add JVM Profiling support (`sentry-clj.profiling` namespace) with `start-profiler!`, `stop-profiler!`, and `with-profiling` macro +- Add `:profile-session-sample-rate` and `:profile-lifecycle` configuration options + ## [8.33.243] - Add Sentry Metrics support (`sentry-clj.metrics` namespace) (Thanks @abogoyavlensky) diff --git a/README.md b/README.md index c3d59b3..6bf6ca2 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ If you want an interpolated message, you need to provide the full map, i.e., | `:trace-options-requests` | Set to enable or disable tracing of options requests. |true | `:instrumenter` | Sets instrumenter for tracing. (values - :sentry - for default Sentry instrumentation, :otel - OpenTelemetry instrumentation) | :sentry | `:before-send-metric-fn` | A function (taking a metric event and a hint). Return nil to drop the metric. | +| `:profile-session-sample-rate` | Set a sample rate (0.0–1.0) for profiling sessions. Requires `io.sentry/sentry-async-profiler` on the classpath. | +| `:profile-lifecycle` | `:trace` to profile automatically while a span is active, or `:manual` to call `start-profiler!` / `stop-profiler!` directly. | | `:event-processors` | A seqable collection (vector for example) containing instances of event processors (implementing io.sentry.EventProcessor) | Some examples: @@ -331,6 +333,47 @@ If you are using Logback, you can add the Sentry appender to your logback config ``` +## JVM Profiling + +[API Documentation](https://docs.sentry.io/platforms/java/profiling/) + +The `sentry-clj.profiling` namespace provides functions for JVM CPU profiling. +Profiling requires adding `io.sentry/sentry-async-profiler` to your `deps.edn` +(it is not included transitively by this library). Supported on macOS and Linux only. + +Two lifecycle modes are supported: + +**Trace lifecycle** — profiles are collected automatically while an active span exists: + +```clojure +(require '[sentry-clj.core :as sentry]) + +(sentry/init! "https://public@sentry.io/1" + {:traces-sample-rate 1.0 + :profile-session-sample-rate 1.0 + :profile-lifecycle :trace}) +``` + +**Manual lifecycle** — you control start and stop explicitly: + +```clojure +(require '[sentry-clj.core :as sentry]) +(require '[sentry-clj.profiling :as profiling]) + +(sentry/init! "https://public@sentry.io/1" + {:profile-session-sample-rate 1.0 + :profile-lifecycle :manual}) + +;; Wrap a block of code — stop is guaranteed even if an exception is thrown +(profiling/with-profiling + (run-expensive-computation)) + +;; Or control start/stop manually +(profiling/start-profiler!) +(run-expensive-computation) +(profiling/stop-profiler!) +``` + ## License Copyright © 2022 Coda Hale, Sentry diff --git a/src/sentry_clj/core.clj b/src/sentry_clj/core.clj index 96fd570..4c1068d 100644 --- a/src/sentry_clj/core.clj +++ b/src/sentry_clj/core.clj @@ -4,7 +4,7 @@ [clojure.string :refer [blank?]] [clojure.walk :as walk]) (:import - [io.sentry Breadcrumb DateUtils EventProcessor Sentry SentryEvent SentryLevel SentryOptions Instrumenter] + [io.sentry Breadcrumb DateUtils EventProcessor Instrumenter ProfileLifecycle Sentry SentryEvent SentryLevel SentryOptions] [io.sentry.protocol Message Request SentryId User] [java.util Date HashMap Map UUID])) @@ -21,6 +21,14 @@ :fatal SentryLevel/FATAL SentryLevel/INFO)) +(defn ^:private keyword->profile-lifecycle + "Converts a keyword into a `ProfileLifecycle` enum value." + [lifecycle] + (case lifecycle + :trace ProfileLifecycle/TRACE + :manual ProfileLifecycle/MANUAL + nil)) + (defn java-util-hashmappify-vals "Converts an ordinary Clojure map into a java.util.HashMap object. This is done recursively for all nested maps as well. @@ -188,6 +196,8 @@ logs-enabled before-send-log-fn before-send-metric-fn + profile-session-sample-rate + profile-lifecycle enabled]} (merge sentry-defaults config) sentry-options (SentryOptions.)] @@ -258,6 +268,10 @@ (reify io.sentry.SentryOptions$Metrics$BeforeSendMetricCallback (execute [_ metric hint] (before-send-metric-fn metric hint)))))) + (when profile-session-sample-rate + (.setProfileSessionSampleRate sentry-options (double profile-session-sample-rate))) + (when profile-lifecycle + (.setProfileLifecycle sentry-options ^ProfileLifecycle (keyword->profile-lifecycle profile-lifecycle))) (when-let [instrumenter (case instrumenter :sentry Instrumenter/SENTRY :otel Instrumenter/OTEL @@ -312,6 +326,8 @@ | `:logs-enabled` | Enable Sentry structured logging integration | false | `:before-send-log-fn` | A function (taking a log event) to filter logs, or update them before they are sent to Sentry | | `:before-send-metric-fn` | A function (taking a metric event and a hint) to filter or modify metrics before they are sent to Sentry | + | `:profile-session-sample-rate` | Set a sample rate (0.0–1.0) for profiling sessions. Requires `io.sentry/sentry-async-profiler` on the classpath | + | `:profile-lifecycle` | `:trace` to profile automatically with active spans, or `:manual` to control via `sentry-clj.profiling/start-profiler!` and `stop-profiler!` | | `:serialization-max-depth` | Set to a lower number, i.e., 2, if you experience circular reference errors when sending events | 5 | `:trace-options-request` | Set to enable or disable tracing of options requests | true diff --git a/src/sentry_clj/profiling.clj b/src/sentry_clj/profiling.clj new file mode 100644 index 0000000..e5fc152 --- /dev/null +++ b/src/sentry_clj/profiling.clj @@ -0,0 +1,34 @@ +(ns sentry-clj.profiling + "Wrapper for Sentry JVM profiling (manual lifecycle). + + For profiling to work, `io.sentry/sentry-async-profiler` must be on the classpath + and Sentry must be initialized with `{:profile-session-sample-rate 1.0 :profile-lifecycle :manual}`." + (:import + [io.sentry Sentry])) + +(set! *warn-on-reflection* true) + +(defn start-profiler! + "Starts the JVM profiler session. + + Only meaningful when `{:profile-lifecycle :manual}` is set in `sentry-clj.core/init!` + and `io.sentry/sentry-async-profiler` is on the classpath." + [] + (Sentry/startProfiler)) + +(defn stop-profiler! + "Stops the current JVM profiler session and sends the profile to Sentry." + [] + (Sentry/stopProfiler)) + +(defmacro with-profiling + "Wraps `body` in a profiling session, calling `start-profiler!` before and + `stop-profiler!` after. Guarantees `stop-profiler!` is called even if an + exception is thrown." + [& body] + `(do + (start-profiler!) + (try + ~@body + (finally + (stop-profiler!))))) diff --git a/test/sentry_clj/core_test.clj b/test/sentry_clj/core_test.clj index ccdc855..a931f05 100644 --- a/test/sentry_clj/core_test.clj +++ b/test/sentry_clj/core_test.clj @@ -4,7 +4,7 @@ [expectations.clojure.test :refer [defexpect expect expecting]] [sentry-clj.core :as sut]) (:import - [io.sentry Breadcrumb EventProcessor Instrumenter JsonSerializer SentryLevel SentryOptions] + [io.sentry Breadcrumb EventProcessor Instrumenter JsonSerializer ProfileLifecycle SentryLevel SentryOptions] [io.sentry.protocol Request User] [java.io StringWriter] [java.util Date HashMap UUID])) @@ -358,3 +358,21 @@ "sentry is disabled" (let [sentry-options ^SentryOptions (sentry-options "http://www.example.com" {:enabled false})] (expect false (.isEnabled sentry-options))))) + +(defexpect keyword->profile-lifecycle-test + (expecting "converts profiling lifecycle keywords correctly" + (expect ProfileLifecycle/TRACE (#'sut/keyword->profile-lifecycle :trace)) + (expect ProfileLifecycle/MANUAL (#'sut/keyword->profile-lifecycle :manual))) + (expecting "returns nil for nil input" + (expect nil (#'sut/keyword->profile-lifecycle nil)))) + +(defexpect sentry-profiling-options-tests + (expecting "profile-session-sample-rate is set on options" + (let [opts ^SentryOptions (sentry-options "http://www.example.com" {:profile-session-sample-rate 0.5})] + (expect 0.5 (.getProfileSessionSampleRate opts)))) + (expecting "profile-lifecycle :trace is set on options" + (let [opts ^SentryOptions (sentry-options "http://www.example.com" {:profile-lifecycle :trace})] + (expect ProfileLifecycle/TRACE (.getProfileLifecycle opts)))) + (expecting "profile-lifecycle :manual is set on options" + (let [opts ^SentryOptions (sentry-options "http://www.example.com" {:profile-lifecycle :manual})] + (expect ProfileLifecycle/MANUAL (.getProfileLifecycle opts))))) diff --git a/test/sentry_clj/profiling_test.clj b/test/sentry_clj/profiling_test.clj new file mode 100644 index 0000000..7a9d7fb --- /dev/null +++ b/test/sentry_clj/profiling_test.clj @@ -0,0 +1,37 @@ +(ns sentry-clj.profiling-test + (:require + [expectations.clojure.test :refer [defexpect expect expecting]] + [sentry-clj.profiling :as sut]) + (:import + [io.sentry Sentry SentryOptions])) + +(defn- setup-test-sentry! + [] + (let [opts (SentryOptions.)] + (.setDsn opts "https://key@sentry.io/proj") + (.setEnvironment opts "test") + (Sentry/init ^SentryOptions opts))) + +(defexpect start-profiler-smoke-test + (setup-test-sentry!) + (expecting "start-profiler! returns nil without error" + (expect nil? (sut/start-profiler!)))) + +(defexpect stop-profiler-smoke-test + (setup-test-sentry!) + (expecting "stop-profiler! returns nil without error" + (expect nil? (sut/stop-profiler!)))) + +(defexpect with-profiling-test + (expecting "returns the body's value" + (with-redefs [sut/start-profiler! (fn [] nil) + sut/stop-profiler! (fn [] nil)] + (expect 42 (sut/with-profiling 42)))) + + (expecting "calls stop-profiler! even when body throws" + (let [stopped? (atom false)] + (with-redefs [sut/start-profiler! (fn [] nil) + sut/stop-profiler! (fn [] (reset! stopped? true))] + (expect Exception + (sut/with-profiling (throw (Exception. "boom")))) + (expect true @stopped?))))) From 5376dbeea76067e53cbd8c01eb42e69b307dd404 Mon Sep 17 00:00:00 2001 From: Andrey Bogoyavlenskiy Date: Wed, 18 Feb 2026 22:47:44 +0000 Subject: [PATCH 2/2] Fix invalid :profile-lifecycle keywords throwing instead of silently passing nil to SDK --- src/sentry_clj/core.clj | 7 ++++--- test/sentry_clj/core_test.clj | 10 ++++++++-- test/sentry_clj/profiling_test.clj | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/sentry_clj/core.clj b/src/sentry_clj/core.clj index 4c1068d..8c26ae2 100644 --- a/src/sentry_clj/core.clj +++ b/src/sentry_clj/core.clj @@ -27,7 +27,8 @@ (case lifecycle :trace ProfileLifecycle/TRACE :manual ProfileLifecycle/MANUAL - nil)) + nil nil + (throw (IllegalArgumentException. (str "Invalid profile-lifecycle: " lifecycle))))) (defn java-util-hashmappify-vals "Converts an ordinary Clojure map into a java.util.HashMap object. @@ -270,8 +271,8 @@ (before-send-metric-fn metric hint)))))) (when profile-session-sample-rate (.setProfileSessionSampleRate sentry-options (double profile-session-sample-rate))) - (when profile-lifecycle - (.setProfileLifecycle sentry-options ^ProfileLifecycle (keyword->profile-lifecycle profile-lifecycle))) + (when-let [lifecycle (keyword->profile-lifecycle profile-lifecycle)] + (.setProfileLifecycle sentry-options ^ProfileLifecycle lifecycle)) (when-let [instrumenter (case instrumenter :sentry Instrumenter/SENTRY :otel Instrumenter/OTEL diff --git a/test/sentry_clj/core_test.clj b/test/sentry_clj/core_test.clj index a931f05..759daae 100644 --- a/test/sentry_clj/core_test.clj +++ b/test/sentry_clj/core_test.clj @@ -364,7 +364,9 @@ (expect ProfileLifecycle/TRACE (#'sut/keyword->profile-lifecycle :trace)) (expect ProfileLifecycle/MANUAL (#'sut/keyword->profile-lifecycle :manual))) (expecting "returns nil for nil input" - (expect nil (#'sut/keyword->profile-lifecycle nil)))) + (expect nil (#'sut/keyword->profile-lifecycle nil))) + (expecting "throws for invalid lifecycle keyword" + (expect IllegalArgumentException (#'sut/keyword->profile-lifecycle :bogus)))) (defexpect sentry-profiling-options-tests (expecting "profile-session-sample-rate is set on options" @@ -375,4 +377,8 @@ (expect ProfileLifecycle/TRACE (.getProfileLifecycle opts)))) (expecting "profile-lifecycle :manual is set on options" (let [opts ^SentryOptions (sentry-options "http://www.example.com" {:profile-lifecycle :manual})] - (expect ProfileLifecycle/MANUAL (.getProfileLifecycle opts))))) + (expect ProfileLifecycle/MANUAL (.getProfileLifecycle opts)))) + (expecting "nil profile-lifecycle does not override SDK default" + (let [sdk-default (.getProfileLifecycle (SentryOptions.)) + opts ^SentryOptions (sentry-options "http://www.example.com")] + (expect sdk-default (.getProfileLifecycle opts))))) diff --git a/test/sentry_clj/profiling_test.clj b/test/sentry_clj/profiling_test.clj index 7a9d7fb..659b752 100644 --- a/test/sentry_clj/profiling_test.clj +++ b/test/sentry_clj/profiling_test.clj @@ -33,5 +33,5 @@ (with-redefs [sut/start-profiler! (fn [] nil) sut/stop-profiler! (fn [] (reset! stopped? true))] (expect Exception - (sut/with-profiling (throw (Exception. "boom")))) + (sut/with-profiling (throw (Exception. "boom")))) (expect true @stopped?)))))