├── VERSION ├── tests.edn ├── .gitignore ├── .github └── workflows │ └── clojure.yml ├── LICENSE ├── deps.edn ├── README.md ├── src └── clj_statsd.clj └── test └── clj_statsd └── statsd_test.clj /VERSION: -------------------------------------------------------------------------------- 1 | 0.4.3-SNAPSHOT -------------------------------------------------------------------------------- /tests.edn: -------------------------------------------------------------------------------- 1 | #kaocha/v1 {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .lein-deps-sum 2 | .lein-failures 3 | pom.xml 4 | *jar 5 | lib 6 | classes 7 | /target 8 | .cache 9 | pom.xml.asc 10 | .cpcache 11 | resources/git-version 12 | -------------------------------------------------------------------------------- /.github/workflows/clojure.yml: -------------------------------------------------------------------------------- 1 | name: Clojure CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | container: 11 | image: clojure:openjdk-17-tools-deps 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3.0.2 16 | 17 | - name: Test 18 | run: | 19 | clojure -X:check 20 | clojure -X:test 21 | 22 | - name: Lint & Format 23 | run: | 24 | clojure -T:project format-check 25 | clojure -T:project lint 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2012 Pierre-Yves Ritschard 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL 8 | WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED 9 | WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE 10 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL 11 | DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR 12 | PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 13 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 14 | PERFORMANCE OF THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:deps {org.clojure/clojure {:mvn/version "1.11.1"}} 2 | :paths ["src"] 3 | 4 | :exoscale.project/lib clj-statsd/clj-statsd 5 | :exoscale.project/version-file "VERSION" 6 | :exoscale.project/tasks 7 | {:release [{:run :exoscale.tools.project.standalone/version-remove-snapshot} 8 | {:run :exoscale.tools.project.standalone/deploy} 9 | {:run :exoscale.tools.project.standalone/git-commit-version} 10 | {:run :exoscale.tools.project.standalone/git-tag-version} 11 | {:run :exoscale.tools.project.standalone/version-bump-and-snapshot} 12 | {:run :exoscale.tools.project.standalone/git-commit-version} 13 | {:run :exoscale.tools.project.standalone/git-push}]} 14 | 15 | :slipset.deps-deploy/exec-args 16 | {:installer :remote :sign-releases? false :repository "clojars"} 17 | 18 | :aliases 19 | {:test 20 | {:extra-deps {lambdaisland/kaocha {:mvn/version "1.66.1034"}} 21 | :extra-paths ["test"] 22 | :exec-fn kaocha.runner/exec-fn} 23 | 24 | :check 25 | {:extra-deps {org.spootnik/deps-check {:mvn/version "0.5.2"}} 26 | :extra-paths ["test"] 27 | :exec-fn spootnik.deps-check/check 28 | :exec-args {:paths ["src" "test"]}} 29 | 30 | :project 31 | {:deps {io.github.exoscale/tools.project 32 | {:git/sha "d15a7cbc648c7206edb911a2b103ea5daf5b866b"}} 33 | :ns-default exoscale.tools.project}}} 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | clj-statsd is a client for the [statsd](https://github.com/etsy/statsd) 2 | protocol for the [clojure](http://clojure.org) programming language. 3 | 4 | [![Clojars Project](https://img.shields.io/clojars/v/clj-statsd.svg)](https://clojars.org/clj-statsd) 5 | 6 | An Example 7 | ---------- 8 | 9 | Here is a snippet showing the use of clj-statsd: 10 | 11 | ```clojure 12 | (ns testing 13 | (:require [clj-statsd :as s])) 14 | 15 | (s/setup "127.0.0.1" 8125) 16 | 17 | ;; Set a shared prefix for all stats keys 18 | (s/setup "127.0.0.1" 8125 :prefix :my-app) 19 | 20 | (s/increment :some_counter) ;; simple increment 21 | (s/decrement "some_other_counter") ;; simple decrement 22 | (s/increment :some_counter 2) ;; double increment 23 | (s/increment :some_counter 2 0.1) ;; sampled double increment 24 | 25 | (s/timing :timing_value 300) ;; record 300ms for "timing_value" 26 | 27 | (s/gauge :current_value 42) ;; record an arbitrary value 28 | (s/modify-gauge :current_value -2) ;; offset a gauge 29 | 30 | 31 | (s/with-timing :some_slow_code ;; time (some-slow-code) and then 32 | (some-slow-code)) ;; send the result using s/timing 33 | 34 | (s/with-sampled-timing :slow_code 1.0 ;; Like s/with-timing but with 35 | (slow-code) ;; a sample rate. 36 | 37 | (s/with-tagged-timing :slow 1.0 ["foo"] ;; Like s/with-timing but with 38 | (slow) ;; a sample rate and tags. 39 | ``` 40 | 41 | Buckets can be strings or keywords. For more information please refer to 42 | [statsd](https://github.com/etsy/statsd) 43 | 44 | Shutdown 45 | -------- 46 | 47 | Since clj-statsd uses agents, [(shutdown-agents)](https://clojuredocs.org/clojure.core/shutdown-agents) must be called when exiting the program. 48 | -------------------------------------------------------------------------------- /src/clj_statsd.clj: -------------------------------------------------------------------------------- 1 | (ns clj-statsd 2 | "Send metrics to statsd." 3 | (:require [clojure.string :as str]) 4 | (:import [java.util Random]) 5 | (:import [java.net DatagramPacket DatagramSocket InetAddress])) 6 | 7 | (def 8 | ^{:doc "Atom holding the socket configuration"} 9 | cfg 10 | (atom nil)) 11 | 12 | (def 13 | ^{:doc "Agent holding the datagram socket"} 14 | sockagt 15 | (agent nil)) 16 | 17 | (defn setup 18 | "Initialize configuration" 19 | [host port & opts] 20 | (send sockagt #(or % (DatagramSocket.))) 21 | (swap! cfg #(or % (merge {:random (Random.) 22 | :host (InetAddress/getByName host) 23 | :port (if (integer? port) port (Integer/parseInt port))} 24 | (apply hash-map opts))))) 25 | 26 | (defn- send-packet 27 | "Send a packet to the socket. Expected to be used through the `sockagt` agent" 28 | [^DatagramSocket socket ^DatagramPacket packet] 29 | (try 30 | (doto socket (.send packet)) 31 | (catch Exception _ 32 | socket))) 33 | 34 | (defn format-tags 35 | [tags] 36 | (when (seq tags) 37 | (str "|#" (str/join "," (map name tags))))) 38 | 39 | (defn format-stat 40 | ^String [prefix content tags] 41 | (str prefix content (format-tags tags))) 42 | 43 | (defn send-stat 44 | "Send a raw metric over the network." 45 | [prefix content tags] 46 | (let [fmt (format-stat prefix content tags)] 47 | (when-let [packet (try 48 | (DatagramPacket. 49 | ^"[B" (.getBytes fmt) 50 | ^Integer (count fmt) 51 | ^InetAddress (:host @cfg) 52 | ^Integer (:port @cfg)) 53 | (catch Exception _ 54 | nil))] 55 | (send sockagt send-packet packet)))) 56 | 57 | (defn publish 58 | "Send a metric over the network, based on the provided sampling rate. 59 | This should be a fully formatted statsd metric line." 60 | 61 | [^String content rate tags] 62 | (cond 63 | (nil? @cfg) 64 | nil 65 | 66 | (>= rate 1.0) 67 | (send-stat (:prefix @cfg) content tags) 68 | 69 | (<= (.nextDouble ^Random (:random @cfg)) rate) 70 | (send-stat (:prefix @cfg) (format "%s|@%f" content rate) tags) 71 | 72 | :else 73 | nil)) 74 | 75 | (defn increment 76 | "Increment a counter at specified rate, defaults to a one increment 77 | with a 1.0 rate" 78 | ([k] (increment k 1 1.0 [])) 79 | ([k v] (increment k v 1.0 [])) 80 | ([k v rate] (increment k v rate [])) 81 | ([k v rate tags] (publish (str (name k) ":" v "|c") rate tags))) 82 | 83 | (defn round-millis 84 | "Given a numeric value of milliseconds, convert it to an integer value of 85 | milliseconds by rounding to the nearest millisecond if necessary." 86 | [v] 87 | (cond (integer? v) v 88 | (number? v) (Math/round (double v)) 89 | :else 0)) 90 | 91 | (defn timing 92 | "Time an event at specified rate, defaults to 1.0 rate" 93 | ([k v] (timing k v 1.0)) 94 | ([k v rate] (timing k v rate [])) 95 | ([k v rate tags] (publish (str (name k) ":" (round-millis v) "|ms") rate tags))) 96 | 97 | (defn decrement 98 | "Decrement a counter at specified rate, defaults to a one decrement 99 | with a 1.0 rate" 100 | ([k] (increment k -1 1.0)) 101 | ([k v] (increment k (* -1 v) 1.0)) 102 | ([k v rate] (increment k (* -1 v) rate)) 103 | ([k v rate tags] (increment k (* -1 v) rate tags))) 104 | 105 | (defn- prepare-gauge-for-modify 106 | "Get the correct absolute value for this gauge" 107 | [v] 108 | (if (neg? v) 109 | (if (float? v) (Math/abs (double v)) (Math/abs (long v))) 110 | v)) 111 | 112 | (defn modify-gauge 113 | "Increment or decrement the value of a previously sent gauge" 114 | ([k v] (modify-gauge k v 1.0 [])) 115 | ([k v rate] (modify-gauge k v rate [])) 116 | ([k v rate tags] 117 | (publish (str (name k) ":" (if (neg? v) "-" "+") 118 | (prepare-gauge-for-modify v) "|g") rate tags))) 119 | 120 | (defn- sanitize-gauge 121 | "Ensure gauge value can be sent on the wire" 122 | [v] 123 | (when (neg? v) 124 | (throw (IllegalArgumentException. (str "bad value for gauge: " v)))) 125 | v) 126 | 127 | (defn gauge 128 | "Send an arbitrary value." 129 | ([k v] (gauge k v 1.0 [])) 130 | ([k v rate] (gauge k v rate [])) 131 | ([k v rate tags] (publish (str (name k) ":" v "|g") rate tags)) 132 | ([k v rate tags {:keys [change]}] 133 | (if (true? change) 134 | (modify-gauge k v rate tags) 135 | (publish (str (name k) ":" (sanitize-gauge v) "|g") rate tags)))) 136 | 137 | (defn unique 138 | "Send an event, unique occurences of which per flush interval 139 | will be counted by the statsd server. We have no rate call 140 | signature here because that wouldn't make much sense." 141 | ([k v] (publish (str (name k) ":" v "|s") 1.0 [])) 142 | ([k v tags] (publish (str (name k) ":" v "|s") 1.0 tags))) 143 | 144 | (defn with-timing-fn 145 | "Helper function for the timing macros. Time the execution of f, a function 146 | of no args, and then call timing with the other args." 147 | [f k rate tags] 148 | (let [start (System/nanoTime)] 149 | (try 150 | (f) 151 | (finally 152 | (timing k (/ (- (System/nanoTime) start) 1e6) rate tags))))) 153 | 154 | (defmacro with-tagged-timing 155 | "Time the execution of the provided code, with sampling and tags." 156 | [k rate tags & body] 157 | `(with-timing-fn (fn [] ~@body) ~k ~rate ~tags)) 158 | 159 | (defmacro with-sampled-timing 160 | "Time the execution of the provided code, with sampling." 161 | [k rate & body] 162 | `(with-timing-fn (fn [] ~@body) ~k ~rate [])) 163 | 164 | (defmacro with-timing 165 | "Time the execution of the provided code." 166 | [k & body] 167 | `(with-timing-fn (fn [] ~@body) ~k 1.0 [])) 168 | -------------------------------------------------------------------------------- /test/clj_statsd/statsd_test.clj: -------------------------------------------------------------------------------- 1 | (ns clj-statsd.statsd-test 2 | (:require 3 | [clojure.test :refer [are deftest is use-fixtures]] 4 | [clj-statsd :refer [cfg decrement format-stat gauge increment 5 | round-millis send-stat setup timing unique 6 | modify-gauge with-sampled-timing with-tagged-timing 7 | with-timing]])) 8 | 9 | (use-fixtures :each (fn [f] (setup "localhost" 8125) (f))) 10 | 11 | (defmacro should-send-expected-stat 12 | "Assert that the expected stat is passed to the send-stat method 13 | the expected number of times." 14 | [expected min-times max-times & body] 15 | `(let [counter# (atom 0)] 16 | (with-redefs 17 | [send-stat (fn [prefix# stat# tags#] 18 | (is (= ~expected (format-stat prefix# stat# tags#))) 19 | (swap! counter# inc))] 20 | ~@body) 21 | (is (and (>= @counter# ~min-times) (<= @counter# ~max-times)) (str "send-stat called " @counter# " times")))) 22 | 23 | (deftest should-send-increment 24 | (should-send-expected-stat "gorets:1|c" 3 3 25 | (increment "gorets") 26 | (increment :gorets) 27 | (increment "gorets", 1)) 28 | (should-send-expected-stat "gorets:7|c" 1 1 29 | (increment :gorets 7)) 30 | (should-send-expected-stat "gorets:1.1|c" 1 1 31 | (increment :gorets 1.1)) 32 | (should-send-expected-stat "gorets:1.1|c" 1 1 33 | (increment :gorets 1.1 2)) 34 | (should-send-expected-stat "gorets:1.1|c|#tag1,tag2" 1 1 35 | (increment :gorets 1.1 2 ["tag1" "tag2"])) 36 | (should-send-expected-stat "gorets:1.1|c|#tag1,tag2" 1 1 37 | (increment :gorets 1.1 2 [:tag1 :tag2]))) 38 | 39 | (deftest should-send-decrement 40 | (should-send-expected-stat "gorets:-1|c" 3 3 41 | (decrement "gorets") 42 | (decrement :gorets) 43 | (decrement "gorets", 1)) 44 | (should-send-expected-stat "gorets:-7|c" 1 1 45 | (decrement :gorets 7)) 46 | (should-send-expected-stat "gorets:-1.1|c" 1 1 47 | (decrement :gorets 1.1)) 48 | (should-send-expected-stat "gorets:-1.1|c" 1 1 49 | (decrement :gorets 1.1 2)) 50 | (should-send-expected-stat "gorets:-1.1|c|#tag1,tag2" 1 1 51 | (decrement :gorets 1.1 2 ["tag1" "tag2"]))) 52 | 53 | (deftest should-send-gauge 54 | (should-send-expected-stat "gaugor:333|g" 3 3 55 | (gauge "gaugor" 333) 56 | (gauge :gaugor 333) 57 | (gauge "gaugor" 333 1)) 58 | (should-send-expected-stat "guagor:1.1|g" 1 1 59 | (gauge :guagor 1.1)) 60 | (should-send-expected-stat "guagor:1.1|g" 1 1 61 | (gauge :guagor 1.1 2)) 62 | (should-send-expected-stat "guagor:1.1|g|#tag1,tag2" 1 1 63 | (gauge :guagor 1.1 2 ["tag1" "tag2"]))) 64 | 65 | (deftest should-send-modify-gauge 66 | (should-send-expected-stat "gaugor:+333|g" 3 3 67 | (modify-gauge "gaugor" 333) 68 | (modify-gauge :gaugor 333) 69 | (modify-gauge :gaugor 333 1)) 70 | 71 | (should-send-expected-stat "gaugor:-2|g" 3 3 72 | (modify-gauge "gaugor" -2) 73 | (modify-gauge :gaugor -2) 74 | (modify-gauge :gaugor -2 1)) 75 | 76 | (should-send-expected-stat "gaugor:+0|g" 3 3 77 | (modify-gauge "gaugor" 0) 78 | (modify-gauge :gaugor 0) 79 | (modify-gauge :gaugor 0 1))) 80 | 81 | (deftest should-send-unique 82 | (should-send-expected-stat "unique:765|s" 2 2 83 | (unique "unique" 765) 84 | (unique :unique 765))) 85 | 86 | (deftest should-round-millis 87 | (are [input expected] 88 | (= expected (round-millis input)) 89 | 90 | ; Good values 91 | 0 0 92 | 99 99 93 | 100 100 94 | 0.0 0 95 | 0.4 0 96 | 99.9 100 97 | 100.0 100 98 | 99 | ; Weird-but-legal values 100 | 1/3 0 101 | 2/3 1 102 | -0.5 0 103 | -0.6 -1 104 | -99.9 -100 105 | 106 | ; Bad values 107 | nil 0 108 | "bad value" 0 109 | :bad-value 0)) 110 | 111 | (deftest should-send-timing-with-default-rate 112 | (should-send-expected-stat "glork:320|ms" 2 2 113 | (timing "glork" 320) 114 | (timing :glork 320)) 115 | (should-send-expected-stat "glork:320|ms|#tag1,tag2" 2 2 116 | (timing "glork" 320 1 ["tag1" "tag2"]) 117 | (timing :glork 320 1 ["tag1" "tag2"]))) 118 | 119 | (deftest should-send-timing-with-provided-rate 120 | (should-send-expected-stat "glork:320|ms|@0.990000" 1 10 121 | (dotimes [_ 10] (timing "glork" 320 0.99)))) 122 | 123 | (deftest should-not-send-stat-without-cfg 124 | (with-redefs [cfg (atom nil)] 125 | (should-send-expected-stat "gorets:1|c" 0 0 (increment "gorets")))) 126 | 127 | (deftest should-time-code 128 | (let [calls (atom [])] 129 | (with-redefs [timing (fn [& args] 130 | (swap! calls conj args))] 131 | (with-timing "test.time" 132 | (Thread/sleep 200)) 133 | (let [[k v rate tags] (last @calls)] 134 | (is (= "test.time" k)) 135 | (is (>= v 200)) 136 | (is (= 1.0 rate)) 137 | (is (= [] tags))) 138 | (with-sampled-timing "test.time" 0.9 139 | (Thread/sleep 200)) 140 | (let [[k v rate tags] (last @calls)] 141 | (is (= "test.time" k)) 142 | (is (>= v 200)) 143 | (is (= 0.9 rate)) 144 | (is (= [] tags))) 145 | (with-tagged-timing "test.time" 0.9 ["tag1" "tag2"] 146 | (Thread/sleep 200)) 147 | (let [[k v rate tags] (last @calls)] 148 | (is (= "test.time" k)) 149 | (is (>= v 200)) 150 | (is (= 0.9 rate)) 151 | (is (= ["tag1" "tag2"] tags)))))) 152 | 153 | (deftest should-prefix 154 | (with-redefs [cfg (atom nil)] 155 | (setup "localhost" 8125 :prefix "test.stats.") 156 | (should-send-expected-stat "test.stats.gorets:1|c" 2 2 157 | (increment "gorets") 158 | (increment :gorets)))) 159 | --------------------------------------------------------------------------------