diff --git a/.gitignore b/.gitignore index c6c83ae..7538032 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ foo.clj .env .clj-kondo/.cache tags.lock +.idea/ +*.iml +.cpcache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f9db2fa..7c3b142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6654775..c3d59b3 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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/) diff --git a/deps.edn b/deps.edn index feb66e3..f04157e 100644 --- a/deps.edn +++ b/deps.edn @@ -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"} diff --git a/src/sentry_clj/core.clj b/src/sentry_clj/core.clj index a311b44..96fd570 100644 --- a/src/sentry_clj/core.clj +++ b/src/sentry_clj/core.clj @@ -187,6 +187,7 @@ event-processors logs-enabled before-send-log-fn + before-send-metric-fn enabled]} (merge sentry-defaults config) sentry-options (SentryOptions.)] @@ -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 @@ -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 diff --git a/src/sentry_clj/metrics.clj b/src/sentry_clj/metrics.clj new file mode 100644 index 0000000..61973df --- /dev/null +++ b/src/sentry_clj/metrics.clj @@ -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) + 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)))) diff --git a/test/sentry_clj/core_test.clj b/test/sentry_clj/core_test.clj index 9b9437e..ccdc855 100644 --- a/test/sentry_clj/core_test.clj +++ b/test/sentry_clj/core_test.clj @@ -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})] @@ -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))))) diff --git a/test/sentry_clj/metrics_test.clj b/test/sentry_clj/metrics_test.clj new file mode 100644 index 0000000..48e5129 --- /dev/null +++ b/test/sentry_clj/metrics_test.clj @@ -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"}))))