Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -331,6 +333,47 @@ If you are using Logback, you can add the Sentry appender to your logback config
</configuration>
```

## 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
Expand Down
19 changes: 18 additions & 1 deletion src/sentry_clj/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand All @@ -21,6 +21,15 @@
: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 nil
(throw (IllegalArgumentException. (str "Invalid profile-lifecycle: " lifecycle)))))

(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.
Expand Down Expand Up @@ -188,6 +197,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.)]

Expand Down Expand Up @@ -258,6 +269,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-let [lifecycle (keyword->profile-lifecycle profile-lifecycle)]
(.setProfileLifecycle sentry-options ^ProfileLifecycle lifecycle))
(when-let [instrumenter (case instrumenter
:sentry Instrumenter/SENTRY
:otel Instrumenter/OTEL
Expand Down Expand Up @@ -312,6 +327,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

Expand Down
34 changes: 34 additions & 0 deletions src/sentry_clj/profiling.clj
Original file line number Diff line number Diff line change
@@ -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!)))))
26 changes: 25 additions & 1 deletion test/sentry_clj/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down Expand Up @@ -358,3 +358,27 @@
"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)))
(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"
(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))))
(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)))))
37 changes: 37 additions & 0 deletions test/sentry_clj/profiling_test.clj
Original file line number Diff line number Diff line change
@@ -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?)))))