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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ foo.clj
.env
.clj-kondo/.cache
tags.lock
.idea/
*.iml
.cpcache/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ 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.32.235]

- Add Sentry Metrics support (`sentry-clj.metrics` namespace)
- Add `:before-send-metric-fn` configuration option
- Update Sentry Java SDK to 8.32.0

## [8.29.238]

- Update Sentry Java SDK to 8.29.0
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ If you want an interpolated message, you need to provide the full map, i.e.,
| `:serialization-max-depth` | Set to a lower number, i.e., 2, if you experience circular reference errors when sending events | 5
| `: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. |
| `:event-processors` | A seqable collection (vector for example) containing instances of event processors (implementing io.sentry.EventProcessor) |

Some examples:
Expand Down Expand Up @@ -201,6 +202,40 @@ Each key is optional.
- `:env` - A map containing key/value pairs of `Strings`, i.e., `{"a" "b" "c" "d"}`
- `:other` - A map containing key/value pairs of `Strings`, i.e., `{"a" "b" "c" "d"}`

## Metrics

The `sentry-clj.metrics` namespace provides functions for sending custom metrics
to Sentry. Metrics are enabled by default in the Sentry Java SDK.

```clojure
(require '[sentry-clj.metrics :as metrics])

;; Counters - track incrementing values
(metrics/increment "page_view")
(metrics/increment "button_click" 2.0)
(metrics/increment "api_call" 1.0 :none {:endpoint "/users"})

;; Gauges - track values that go up and down
(metrics/gauge "queue_depth" 42.0)
(metrics/gauge "memory_usage" 1024.0 :byte)
(metrics/gauge "cpu_usage" 0.85 :ratio {:region "us-east-1"})

;; Distributions - track value distributions
(metrics/distribution "response_time" 187.5 :millisecond)
(metrics/distribution "page_load" 1.0 :millisecond {:browser "Firefox"})
```

### Supported Unit Keywords

| category | keywords |
|-------------|------------------------------------------------------------------------------------------------------------------|
| Duration | `:nanosecond` `:microsecond` `:millisecond` `:second` `:minute` `:hour` `:day` `:week` |
| Information | `:bit` `:byte` `:kilobyte` `:kibibyte` `:megabyte` `:mebibyte` `:gigabyte` `:gibibyte` `:terabyte` `:tebibyte` `:petabyte` `:pebibyte` `:exabyte` `:exbibyte` |
| Fraction | `:ratio` `:percent` |
| None | `:none` or `nil` |

Raw strings are also accepted as units.

## Logs
[API Documentation](https://docs.sentry.io/platforms/java/logs/)

Expand Down
2 changes: 1 addition & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
;;
;;
;;
io.sentry/sentry {:mvn/version "8.29.0"}
io.sentry/sentry {:mvn/version "8.32.0"}
ring/ring-core {:mvn/version "1.15.3"}}

:aliases {:build {:extra-deps {io.github.clojure/tools.build {:mvn/version "0.10.11"}
Expand Down
7 changes: 7 additions & 0 deletions src/sentry_clj/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
event-processors
logs-enabled
before-send-log-fn
before-send-metric-fn
enabled]} (merge sentry-defaults config)
sentry-options (SentryOptions.)]

Expand Down Expand Up @@ -252,6 +253,11 @@
(reify io.sentry.SentryOptions$Logs$BeforeSendLogCallback
(execute [_ event]
(before-send-log-fn event))))))
(when before-send-metric-fn
(-> sentry-options .getMetrics (.setBeforeSend
(reify io.sentry.SentryOptions$Metrics$BeforeSendMetricCallback
(execute [_ metric hint]
(before-send-metric-fn metric hint))))))
(when-let [instrumenter (case instrumenter
:sentry Instrumenter/SENTRY
:otel Instrumenter/OTEL
Expand Down Expand Up @@ -305,6 +311,7 @@
| `:traces-sample-fn` | A function (taking a custom sample context and a transaction context) enables you to control trace transactions |
| `: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 |
| `: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
137 changes: 137 additions & 0 deletions src/sentry_clj/metrics.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
(ns sentry-clj.metrics
"Metrics integration with Sentry.

Provides functions for sending counters, gauges, and distributions:
- `increment` - Track incrementing values (e.g., button clicks, function calls)
- `gauge` - Track values that go up and down (e.g., memory usage, queue depth)
- `distribution` - Track value distributions (e.g., response times)

## Basic Usage
```clojure
(increment \"page_view\")
(gauge \"queue_depth\" 42.0)
(distribution \"response_time\" 187.5 :millisecond)
```

## With Attributes
```clojure
(distribution \"page_load\" 1.0 :millisecond {:browser \"Firefox\"})
(increment \"api_call\" 1.0 nil {:endpoint \"/users\"})
```"
(:import [io.sentry Sentry SentryAttributes SentryAttribute]
[io.sentry.metrics IMetricsApi SentryMetricsParameters MetricsUnit$Duration MetricsUnit$Information MetricsUnit$Fraction]))

(set! *warn-on-reflection* true)

(defn- get-metrics-api
"Returns the IMetricsApi instance from Sentry."
^IMetricsApi []
(Sentry/metrics))

(def ^:private unit-map
{:none nil
;; Duration
:nanosecond MetricsUnit$Duration/NANOSECOND
:microsecond MetricsUnit$Duration/MICROSECOND
:millisecond MetricsUnit$Duration/MILLISECOND
:second MetricsUnit$Duration/SECOND
:minute MetricsUnit$Duration/MINUTE
:hour MetricsUnit$Duration/HOUR
:day MetricsUnit$Duration/DAY
:week MetricsUnit$Duration/WEEK
;; Information
:bit MetricsUnit$Information/BIT
:byte MetricsUnit$Information/BYTE
:kilobyte MetricsUnit$Information/KILOBYTE
:kibibyte MetricsUnit$Information/KIBIBYTE
:megabyte MetricsUnit$Information/MEGABYTE
:mebibyte MetricsUnit$Information/MEBIBYTE
:gigabyte MetricsUnit$Information/GIGABYTE
:gibibyte MetricsUnit$Information/GIBIBYTE
:terabyte MetricsUnit$Information/TERABYTE
:tebibyte MetricsUnit$Information/TEBIBYTE
:petabyte MetricsUnit$Information/PETABYTE
:pebibyte MetricsUnit$Information/PEBIBYTE
:exabyte MetricsUnit$Information/EXABYTE
:exbibyte MetricsUnit$Information/EXBIBYTE
;; Fraction
:ratio MetricsUnit$Fraction/RATIO
:percent MetricsUnit$Fraction/PERCENT})

(defn- keyword->unit
"Converts a keyword to a MetricsUnit string constant.
Accepts keywords from unit-map, raw strings, or nil.
Both :none and nil mean no unit."
^String [unit]
(cond
(nil? unit) nil
(string? unit) unit
(keyword? unit) (if (contains? unit-map unit)
(get unit-map unit)
(throw (IllegalArgumentException. (str "Unknown metric unit: " unit))))
:else (throw (IllegalArgumentException. (str "Invalid metric unit type: " (type unit))))))

(defn- attrs->params
"Converts a Clojure map of attributes to SentryMetricsParameters.
Reuses the SentryAttribute type detection pattern from sentry-clj.logging."
^SentryMetricsParameters [attrs]
(let [attributes (reduce-kv
(fn [acc k v]
(let [attr-name (name k)
Comment on lines +74 to +80
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The attrs->params function calls reduce-kv on attrs without a nil check, which can cause a NullPointerException if metric functions are called with nil attributes.
Severity: HIGH

Suggested Fix

Before calling reduce-kv on the attrs parameter within the attrs->params function, add a check to ensure attrs is not nil. If it is nil, return an empty collection to avoid the NullPointerException. A (when (map? attrs) ...) guard would be an effective way to handle this.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/sentry_clj/metrics.clj#L74-L80

Potential issue: The 4-arity versions of the `increment`, `gauge`, and `distribution`
functions pass their `attrs` argument directly to the `attrs->params` helper. This
helper function uses `reduce-kv` on `attrs` without first checking if it is `nil`.
Unlike `reduce`, `reduce-kv` throws a `NullPointerException` when called on a `nil`
collection. Therefore, if a user calls one of these metric functions with `nil` for the
`attrs` parameter, for example `(increment "metric" 1.0 :second nil)`, the application
will crash with a `NullPointerException`. This can happen in legitimate scenarios, such
as when building function calls dynamically.

Copy link
Contributor Author

@abogoyavlensky abogoyavlensky Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like, reduce-kv on nil returns the initial value ([]) - it does not throw a NullPointerException. So, the fix might be not needed.

attr (cond
(string? v) (SentryAttribute/stringAttribute attr-name v)
(boolean? v) (SentryAttribute/booleanAttribute attr-name v)
(integer? v) (if (<= Integer/MIN_VALUE v Integer/MAX_VALUE)
(SentryAttribute/integerAttribute attr-name (int v))
(SentryAttribute/doubleAttribute attr-name (double v)))
(or (double? v) (float? v)) (SentryAttribute/doubleAttribute attr-name (double v))
:else (SentryAttribute/named attr-name v))]
(conj acc attr)))
[]
attrs)]
(SentryMetricsParameters/create
(SentryAttributes/of (into-array SentryAttribute attributes)))))

(defn increment
"Increment a counter metric.
Wraps Sentry.metrics().count().
Named `increment` to avoid clash with clojure.core/count.

- `(increment name)` — increment by 1.0
- `(increment name value)` — increment by value
- `(increment name value unit)` — with unit keyword or string
- `(increment name value unit attrs)` — with attributes map"
([metric-name]
(.count (get-metrics-api) ^String metric-name))
([metric-name value]
(.count (get-metrics-api) ^String metric-name ^Double (double value)))
([metric-name value unit]
(.count (get-metrics-api) ^String metric-name ^Double (double value) ^String (keyword->unit unit)))
([metric-name value unit attrs]
(.count (get-metrics-api) ^String metric-name ^Double (double value) ^String (keyword->unit unit) ^SentryMetricsParameters (attrs->params attrs))))

(defn gauge
"Record a gauge metric value.

- `(gauge name value)` — record value
- `(gauge name value unit)` — with unit keyword or string
- `(gauge name value unit attrs)` — with attributes map"
([metric-name value]
(.gauge (get-metrics-api) ^String metric-name ^Double (double value)))
([metric-name value unit]
(.gauge (get-metrics-api) ^String metric-name ^Double (double value) ^String (keyword->unit unit)))
([metric-name value unit attrs]
(.gauge (get-metrics-api) ^String metric-name ^Double (double value) ^String (keyword->unit unit) ^SentryMetricsParameters (attrs->params attrs))))

(defn distribution
"Record a distribution metric value.

- `(distribution name value)` — record value
- `(distribution name value unit)` — with unit keyword or string
- `(distribution name value unit attrs)` — with attributes map"
([metric-name value]
(.distribution (get-metrics-api) ^String metric-name ^Double (double value)))
([metric-name value unit]
(.distribution (get-metrics-api) ^String metric-name ^Double (double value) ^String (keyword->unit unit)))
([metric-name value unit attrs]
(.distribution (get-metrics-api) ^String metric-name ^Double (double value) ^String (keyword->unit unit) ^SentryMetricsParameters (attrs->params attrs))))
2 changes: 2 additions & 0 deletions test/sentry_clj/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@
:trace-options-requests false
:logs-enabled true
:before-send-log-fn (fn [event] (.setBody event "new message body") event)
:before-send-metric-fn (fn [metric _hint] metric)
:instrumenter :otel
:event-processors [(SomeEventProcessor.)]
:enabled false})]
Expand All @@ -339,6 +340,7 @@
(expect false (.isTraceOptionsRequests sentry-options))
(expect true (.isEnabled (.getLogs sentry-options)))
(expect false (nil? (.getBeforeSend (.getLogs sentry-options))))
(expect false (nil? (.getBeforeSend (.getMetrics sentry-options))))
(expect Instrumenter/OTEL (.getInstrumenter sentry-options))
(expect (instance? SomeEventProcessor (last (.getEventProcessors sentry-options))))
(expect false (.isEnabled sentry-options)))))
Expand Down
120 changes: 120 additions & 0 deletions test/sentry_clj/metrics_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
(ns sentry-clj.metrics-test
(:require
[expectations.clojure.test :refer [defexpect expect expecting]]
[sentry-clj.metrics :as sut])
(:import
[io.sentry Sentry SentryOptions]
[io.sentry.metrics SentryMetricsParameters MetricsUnit$Duration MetricsUnit$Information MetricsUnit$Fraction]))

(defn- get-test-options ^SentryOptions
[]
(let [sentry-options (SentryOptions.)]
(.setDsn sentry-options "https://key@sentry.io/proj")
(.setEnvironment sentry-options "test")
(.setRelease sentry-options "release@1.0.0")
sentry-options))

(defn- setup-test-sentry!
"Initializes Sentry for testing."
[]
(let [sentry-options (get-test-options)]
(Sentry/init ^SentryOptions sentry-options)
sentry-options))

;; Unit keyword mapping tests

(defexpect keyword->unit-test
(expecting "converts duration keywords correctly"
(expect MetricsUnit$Duration/NANOSECOND (#'sut/keyword->unit :nanosecond))
(expect MetricsUnit$Duration/MICROSECOND (#'sut/keyword->unit :microsecond))
(expect MetricsUnit$Duration/MILLISECOND (#'sut/keyword->unit :millisecond))
(expect MetricsUnit$Duration/SECOND (#'sut/keyword->unit :second))
(expect MetricsUnit$Duration/MINUTE (#'sut/keyword->unit :minute))
(expect MetricsUnit$Duration/HOUR (#'sut/keyword->unit :hour))
(expect MetricsUnit$Duration/DAY (#'sut/keyword->unit :day))
(expect MetricsUnit$Duration/WEEK (#'sut/keyword->unit :week)))

(expecting "converts information keywords correctly"
(expect MetricsUnit$Information/BIT (#'sut/keyword->unit :bit))
(expect MetricsUnit$Information/BYTE (#'sut/keyword->unit :byte))
(expect MetricsUnit$Information/KILOBYTE (#'sut/keyword->unit :kilobyte))
(expect MetricsUnit$Information/KIBIBYTE (#'sut/keyword->unit :kibibyte))
(expect MetricsUnit$Information/MEGABYTE (#'sut/keyword->unit :megabyte))
(expect MetricsUnit$Information/MEBIBYTE (#'sut/keyword->unit :mebibyte))
(expect MetricsUnit$Information/GIGABYTE (#'sut/keyword->unit :gigabyte))
(expect MetricsUnit$Information/GIBIBYTE (#'sut/keyword->unit :gibibyte))
(expect MetricsUnit$Information/TERABYTE (#'sut/keyword->unit :terabyte))
(expect MetricsUnit$Information/TEBIBYTE (#'sut/keyword->unit :tebibyte))
(expect MetricsUnit$Information/PETABYTE (#'sut/keyword->unit :petabyte))
(expect MetricsUnit$Information/PEBIBYTE (#'sut/keyword->unit :pebibyte))
(expect MetricsUnit$Information/EXABYTE (#'sut/keyword->unit :exabyte))
(expect MetricsUnit$Information/EXBIBYTE (#'sut/keyword->unit :exbibyte)))

(expecting "converts fraction keywords correctly"
(expect MetricsUnit$Fraction/RATIO (#'sut/keyword->unit :ratio))
(expect MetricsUnit$Fraction/PERCENT (#'sut/keyword->unit :percent)))

(expecting ":none and nil map to no unit"
(expect nil (#'sut/keyword->unit :none))
(expect nil (#'sut/keyword->unit nil)))

(expecting "raw strings pass through"
(expect "custom_unit" (#'sut/keyword->unit "custom_unit")))

(expecting "invalid keyword throws"
(expect IllegalArgumentException (#'sut/keyword->unit :bogus))))

;; attrs->params tests

(defexpect attrs->params-test
(expecting "converts a map to SentryMetricsParameters"
(let [params (#'sut/attrs->params {:endpoint "/users" :method "GET"})]
(expect true (instance? SentryMetricsParameters params))))

(expecting "handles various attribute types"
(let [params (#'sut/attrs->params {:name "test"
:active true
:count 42
:score 98.5})]
(expect true (instance? SentryMetricsParameters params))))

(expecting "handles large integer values without overflow"
(let [params (#'sut/attrs->params {:timestamp 1700000000000})]
(expect true (instance? SentryMetricsParameters params)))))

;; Smoke tests - verify the public API functions execute without errors.
;; These call the real Sentry SDK (with a test DSN) which silently drops the
;; metrics. We verify no exceptions are thrown and the correct nil return.

(defexpect increment-smoke-test
(setup-test-sentry!)
(expecting "increment with name only"
(expect nil? (sut/increment "page_view")))
(expecting "increment with name and value"
(expect nil? (sut/increment "button_click" 2.0)))
(expecting "increment with name, value, and unit"
(expect nil? (sut/increment "requests" 1.0 :millisecond)))
(expecting "increment with name, value, nil unit"
(expect nil? (sut/increment "requests" 1.0 :none)))
(expecting "increment with name, value, string unit"
(expect nil? (sut/increment "requests" 1.0 "custom_unit")))
(expecting "increment with name, value, unit, and attrs"
(expect nil? (sut/increment "api_call" 1.0 :none {:endpoint "/users"}))))

(defexpect gauge-smoke-test
(setup-test-sentry!)
(expecting "gauge with name and value"
(expect nil? (sut/gauge "queue_depth" 42.0)))
(expecting "gauge with name, value, and unit"
(expect nil? (sut/gauge "memory_usage" 1024.0 :byte)))
(expecting "gauge with name, value, unit, and attrs"
(expect nil? (sut/gauge "cpu_usage" 0.85 :ratio {:region "us-east-1"}))))

(defexpect distribution-smoke-test
(setup-test-sentry!)
(expecting "distribution with name and value"
(expect nil? (sut/distribution "response_time" 187.5)))
(expecting "distribution with name, value, and unit"
(expect nil? (sut/distribution "response_time" 187.5 :millisecond)))
(expecting "distribution with name, value, unit, and attrs"
(expect nil? (sut/distribution "page_load" 1.0 :millisecond {:browser "Firefox" :region "us-east-1"}))))