├── .github └── workflows │ └── default.yml ├── .gitignore ├── README.md ├── TODO.md ├── deps.edn ├── project.clj ├── raven.png ├── src └── raven │ ├── client.clj │ ├── exception.clj │ └── spec.clj └── test └── raven ├── client_test.clj └── integration_test.clj /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: default 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Install dependencies 9 | run: lein deps 10 | - name: Run tests 11 | run: lein test 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | pom.xml 5 | pom.xml.asc 6 | *.jar 7 | *.class 8 | /.lein-* 9 | /.nrepl-port 10 | .hgignore 11 | .hg/ 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | raven: clojure sentry client library 2 | ==================================== 3 | 4 | [![Travis status](https://api.travis-ci.org/exoscale/raven.svg)](https://travis-ci.org/exoscale/raven) 5 | 6 | A Clojure library to send events to a sentry host. 7 | 8 | ![An exoscale Raven](raven.png "An Exoscale Raven") 9 | 10 | ### Usage 11 | 12 | [![Clojars Project](https://img.shields.io/clojars/v/exoscale/raven.svg)](https://clojars.org/exoscale/raven) 13 | 14 | The main exported function is `capture!` and has three arities: 15 | 16 | - `(capture! dsn event)`: Send a capture over the network, see the description of DSN and ev below. 17 | - `(capture! dsn event tags)`: Send a capture passing an extra map of tags. 18 | - `(capture! context dsn event tags)`: Send a capture passing additional context, such as a specific HTTP client. 19 | 20 | The `capture!` function returns the Sentry Event ID. 21 | 22 | ```clojure 23 | (println "Sentry event created" (capture! (Exception.))) 24 | ``` 25 | 26 | A `release!` function allows for making a [Release](https://docs.sentry.io/workflow/releases/) via a provided webhook endpoint: 27 | 28 | ```clojure 29 | (release! "https://provided.sentry/endpoint" {:version "abcdef"}) 30 | ``` 31 | 32 | #### Arguments 33 | 34 | - **DSN**: A Sentry DSN as defined http://sentry.readthedocs.org/en/2.9.0/client/index.html#parsing-the-dsn 35 | - **Event**: Either an exception or a map. 36 | - **Tags**: A map of extra information to be sent (as Sentry "tags"). 37 | - **Context**: A map of additional information you can pass to Sentry. Note 38 | that omitting this parameter will make use of some thread-local storage for 39 | some of the functionality. 40 | 41 | #### Passing your own aleph connection pool 42 | 43 | In many cases, it makes sense to reuse an already existing aleph conneciton pool (created 44 | with http/connection-pool). Raven will reuse a connection pool if it is passed to 45 | the (capture!) function through the `context` parameter, as `:pool`. 46 | 47 | ```clojure 48 | (capture! {:pool (http/connection-pool {:connection-options {:raw-stream? true}})} "" "My message") 49 | ``` 50 | 51 | #### More aleph options 52 | 53 | You can also pass the following aleph configuration through the context: 54 | 55 | ```clojure 56 | (capture! {:pool nil 57 | :middleware nil 58 | :pool-timeout nil 59 | :response-executor nil 60 | :request-timeout nil 61 | :read-timeout nil 62 | :connection-timeout nil}) 63 | ``` 64 | 65 | ### Extra interfaces 66 | 67 | #### Tags 68 | 69 | On top of being able to set tags at capture time, it is possible to add extra 70 | tags using the `add-tag!` function, declaring the following arity: 71 | 72 | - `(add-tag! tag value)` Adds a tag entry with the specified tag name and 73 | value to a thread-local storage. 74 | - `(add-tag! context tag value)` Adds a specified tag in a user-specified 75 | context. Context is expected to be map-like. 76 | 77 | Tags specified this way will be overwritten by tag specified as part of the 78 | `(capture!)` call. 79 | 80 | #### Breadcrumbs 81 | 82 | Adding sentry "breadcrumbs" can be done using the `add-breadcrumb!` function, 83 | that has the following arities: 84 | 85 | - `(add-breadcrumb! breadcrumb)` Store a breadcrumb in thread-local storage. 86 | - `(add-breadcrumb! context breadcrumb)` Store a breadcrumb in a user-specified 87 | context. Context is expected to be map-like. 88 | 89 | Well-formatted breadcrumb maps can be created with the `make-breadcrumb!` 90 | helper, with the following arities: 91 | 92 | - `(make-breadcrumb! message category)` A breadcrumb will be created with the 93 | "info" level. 94 | - `(make-breadcrumb! message category level)` This allows specifying a level. 95 | Levels can be one of: 'debug' 'info' 'warning' 'warn' 'error' 'exception' 'critical' 'fatal' 96 | - `(make-breadcrumb! message category level timestamp)` This allows setting a 97 | custom timestamp instead of letting the helper get one for you. Timestamp 98 | must be a floating point representation of **seconds** elapsed since the 99 | epoch (not milliseconds). 100 | 101 | More information can be found on [Sentry's documentation website](https://docs.sentry.io/clientdev/interfaces/breadcrumbs/) 102 | 103 | #### User 104 | 105 | Sentry supports adding information about the user when capturing events. This 106 | library makes it possible using the `add-user!` function, with the following 107 | arities: 108 | 109 | - `(add-user! user)` Store a user in thread-local storage. 110 | - `(add-user! context user)` Store a user in a user-specified context. Context 111 | is expected to be map-like. 112 | 113 | Well-formatted user maps can be created with the `make-user` helper function, 114 | with the following arities: 115 | 116 | - `(make-user id)` A simple user map with the only required field (the user's 117 | id) is created. 118 | - `(make-user id email ip-address username)` A map with all "special" 119 | fields recognised by sentry is created. Additional fields can be added to the 120 | created user map if desired, and will simply show up in the interface as 121 | extra fields. 122 | 123 | More information can be found on [Sentry's documentation website](https://docs.sentry.io/clientdev/interfaces/user/) 124 | 125 | #### HTTP Requests 126 | 127 | As for Users, Sentry supports adding information about the HTTP request that 128 | resulted in the captured exception. To fill in that information this library 129 | provides `add-ring-request!` and `add-full-ring-request!` functions, 130 | with the following arities: 131 | 132 | - `(add-ring-request! ring-request)` Extract the required information from the 133 | supplied ring-compatible request map, and store the result in thread-local 134 | storage for future delivery with a call to `(capture!)` 135 | - `(add-ring-request! context ring-request)` Store the HTTP information 136 | extracted from the passed ring-compatible request in the user-specified 137 | map-like context (expected to be ultimately passed to `(capture!)`). 138 | 139 | The `add-full-ring-request!` function provides the same arities, and includes 140 | the request body in the capture, while `add-ring-request!` does not. 141 | 142 | Well formatted HTTP information maps can otherwise be created with the 143 | `make-http-info` helper function, with the following arities: 144 | 145 | - `(make-http-info url method)` A simple HTTP info map with only required 146 | fields (the request's URL and method) is created. 147 | - `(make-http-info url method headers query_string cookies data env)` Creates a 148 | map with all the "special" fields recognised by Sentry. Additional fields can 149 | be added to the created HTTP map if desired, and will simply show up in the 150 | interface as extra fields. 151 | 152 | Those maps can be passed to the Sentry context using the `add-http-info!` 153 | function, with the following arities: 154 | 155 | - `(add-http-info! info)` Store the HTTP information in thread-local storage. 156 | - `(add-http-info! context info)` Store the HTTP information in the 157 | user-specified map-like context (expected to be ultimately passed to `(capture!)`). 158 | 159 | Example: 160 | 161 | ```clojure 162 | (add-ring-request! ring-request) 163 | (capture! "First capture") 164 | (add-http-info! (make-http-info "http://example.com" :get)) 165 | (capture! "Second capture") 166 | ``` 167 | 168 | More information about the HTTP interface can be found on [Sentry's 169 | documentation website](https://docs.sentry.io/clientdev/interfaces/http/). 170 | 171 | #### Fingerprints 172 | 173 | In cases where you do not send an exception, Sentry will try to group your 174 | message by looking at differences in interfaces. In some cases, this is not 175 | enough, and you will want to specify a particular grouping fingerprint, [as 176 | explained in this part of the Sentry documentation](https://docs.sentry.io/learn/rollups/#custom-grouping). 177 | 178 | To set a custom fingerprint for a particular event, this library provides the 179 | `add-fingerprint!` function with the following arities: 180 | 181 | - `(add-fingerprint! fingerprint)` Store the fingerprint in thread-local 182 | storage. 183 | - `(add-fingerprint! context fingerprint)` Store the fingerprint in the 184 | user-specified map-like context (expected to be passed to `capture!`). 185 | 186 | The contents of the :fingerprint entry is expected to be a list of strings. 187 | 188 | #### Full example 189 | 190 | The following examples send Sentry a payload with all extra interfaces provided 191 | by this library, using the thread-local storage. 192 | 193 | ```clojure 194 | (def dsn "https://098f6bcd4621d373cade4e832627b4f6:ad0234829205b9033196ba818f7a872b@sentry.example.com/42") 195 | (add-breadcrumb! (make-breadcrumb! "The user did something" "com.example.Foo")) 196 | (add-breadcrumb! (make-breadcrumb! "The user did something wrong" "com.example.Foo" "error")) 197 | (add-user! (make-user "user-id" "test@example.com" "127.0.0.1" "username")) 198 | (add-ring-request! ring-request) 199 | (add-tag! :my_custom_tag "some value") 200 | (capture! dsn (Exception.) {:another_tag "another value"}) 201 | ``` 202 | 203 | Alternatively you can compose and send a valid payload using something like the following: 204 | 205 | ```clojure 206 | (capture! dsn (-> {} (add-exception! e) 207 | (add-user! (make-user "user-id") 208 | (add-ring-request! ring-request) 209 | (add-tag! :my_custom_tag "some value")) 210 | ``` 211 | 212 | ### Testing 213 | 214 | #### Unit tests 215 | 216 | As usual in the clojure world, a simple `lein test` should run unit tests. 217 | 218 | #### Integration tests 219 | 220 | To ensure the results are correctly handled by Sentry and that this library 221 | produces correct JSON payloads, a simple integration test can be run with 222 | 223 | ```bash 224 | DSN=http://... lein test :integration 225 | ``` 226 | 227 | This will publish a test event in the project associated with the DSN with as 228 | much test data as possible. 229 | 230 | #### Testing programs using this library 231 | 232 | In order to facilitate testing of programs using this library, a special 233 | ":memory:" DSN is supported. When passed to this library in place of a real 234 | DSN, the payload map that would be sent to sentry in an HTTP request is instead 235 | stored in the `http-requests-payload-stub` atom. 236 | 237 | In your tests, you can assert that a Sentry payload conforming to your 238 | expectations would have been sent to the sentry server with: 239 | 240 | ```clojure 241 | (do 242 | (code-that-invokes-capture-once) 243 | (is (= 1 (count @http-requests-payload-stub)))) 244 | ``` 245 | 246 | Users are responsible for cleaning the atom up between test runs, for example 247 | using the `clear-http-stub` convenience function. 248 | 249 | ### Changelog 250 | 251 | #### 0.4.13 252 | 253 | - Add `release!` functionality 254 | 255 | #### 0.4.6 256 | 257 | - Close the body netty stream 258 | 259 | #### 0.4.5 260 | 261 | - add-tags! function to add multiple tags to the context 262 | 263 | #### 0.4.4 264 | 265 | - Fixes regression introduced in 0.4.3 266 | 267 | #### 0.4.3 268 | 269 | - Added support for more aleph options. 270 | 271 | #### 0.4.2 272 | 273 | - Fixed bug when threading breadcrumbs. 274 | - Allowed users to inject an aleph connection pool via context. 275 | 276 | #### 0.4.1 277 | - Fixed tests 278 | - Do not include the body of the http request in (add-ring-request!). Instead, 279 | provide a (add-full-ring-request!) helper as well. 280 | 281 | #### 0.4.0 282 | 283 | - Switch to using the aleph http client. 284 | - Switch to using JSONista. 285 | - Read hostname and linux distribution information from the filesystem instead 286 | of shelling out. 287 | 288 | #### 0.3.3 289 | 290 | - Fix behavior on empty lsb_release string 291 | 292 | #### 0.3.1 293 | 294 | - Fixed shelling out bug to gather hostname/lsb_release (failed in containers) 295 | 296 | #### 0.3.0 297 | 298 | - Made the client async by default, "fire and forget". 299 | - Added an add-exception! helper to allow building payload by threading 300 | - Allow passing event_id through payload or context 301 | - Helper to create HTTP payloads from ring-compliant request maps 302 | 303 | #### 0.2.0 304 | 305 | - Added special ":memory:" DSN to allow easier testing of programs using this 306 | library. 307 | - Added support for custom tags 308 | - Added support for HTTP interface 309 | - Added support for User interface 310 | - Added support for Breadcrumbs interface 311 | - Changed public API to support thread-local storage. 312 | - Added specs for wire format (JSON) 313 | - Code cleanup 314 | 315 | #### 0.1.4 316 | 317 | - Add deps.edn support 318 | - Adapt to recent versions of net 319 | 320 | 321 | #### 0.1.2 322 | 323 | - Prevent reflection 324 | - Support `ex-data` 325 | 326 | ### Notes 327 | 328 | Largely inspired by https://github.com/sethtrain/raven-clj 329 | 330 | ### License 331 | 332 | Copyright © 2016 Pierre-Yves Ritschard 333 | 334 | Distributed under the MIT/ISC License 335 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | There are a few things we could add to this library, or general ideas that could 4 | prove useful. 5 | In no particular order of importance: 6 | 7 | - Refactor the source code in several clojure files 8 | - Use aleph instead of net (in order to allow injecting an aleph connection pool 9 | instead of a net/client) 10 | - Offer a top-level BYON (bring your own networking) function, returning a well 11 | formatted ring client request map. 12 | - Offer a top level function to add an exception+stacktrace to a context map 13 | like `(add-exception! context throwable)`. This allows us to compose functions 14 | better. 15 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src"] 2 | :deps 3 | {org.clojure/clojure {:mvn/version "1.10.0"} 4 | aleph {:mvn/version "0.4.6"} 5 | metosin/jsonista {:mvn/version "0.2.2"} 6 | org.flatland/useful {:mvn/version "0.11.6"}}} 7 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject exoscale/raven "0.4.16-SNAPSHOT" 2 | :description "clojure sentry client library" 3 | :url "https://github.com/exoscale/raven" 4 | :license {:name "MIT License"} 5 | :profiles {:dev {:global-vars {*warn-on-reflection* true}}} 6 | :dependencies [[org.clojure/clojure "1.10.0"] 7 | [aleph "0.4.6"] 8 | [metosin/jsonista "0.2.2"] 9 | [org.flatland/useful "0.11.6"]] 10 | :deploy-repositories [["snapshots" :clojars] ["releases" :clojars]] 11 | :test-selectors {:default (complement :integration-test) 12 | :integration :integration-test}) 13 | -------------------------------------------------------------------------------- /raven.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoscale/raven/14631c9e8655cbff77346ac736ca1f5891618919/raven.png -------------------------------------------------------------------------------- /src/raven/client.clj: -------------------------------------------------------------------------------- 1 | (ns raven.client 2 | "A netty-based Sentry client." 3 | (:import java.lang.Throwable 4 | java.lang.Exception) 5 | (:require [aleph.http :as http] 6 | [manifold.deferred :as d] 7 | [jsonista.core :as json] 8 | [clojure.string :as str] 9 | [clojure.spec.alpha :as s] 10 | [clojure.java.shell :as sh] 11 | [raven.spec :as spec] 12 | [raven.exception :as e] 13 | [flatland.useful.utils :as useful]) 14 | (:import java.io.Closeable 15 | (com.fasterxml.jackson.core JsonGenerator) 16 | (com.fasterxml.jackson.databind MapperFeature 17 | SerializationFeature))) 18 | 19 | (def user-agent 20 | "Our advertized UA" 21 | "exoscale-raven/0.4.4") 22 | 23 | ;; Make sure we enforce spec assertions. 24 | (s/check-asserts true) 25 | 26 | (def thread-storage 27 | "Storage for this particular thread. 28 | 29 | This is a little funny in that it needs to be dereferenced once in order to 30 | obtain an atom that is sure to be per-thread." 31 | (useful/thread-local (atom {}))) 32 | 33 | (def http-requests-payload-stub 34 | "Storage for stubbed http 'requests' - actually, we store just the request's 35 | payload in clojure form (before it's serialized to JSON)." 36 | (atom [])) 37 | 38 | (defn clear-http-stub 39 | "A convenience function to clear the http stub. 40 | 41 | This stub is only used when passing a DSN of ':memory:' to the lib." 42 | [] 43 | (swap! http-requests-payload-stub (fn [x] (vector)))) 44 | 45 | (defn clear-context 46 | "Reset this thread's context" 47 | [] 48 | (reset! @thread-storage {})) 49 | 50 | (defn sentry-uuid! 51 | "A random UUID, without dashes" 52 | [] 53 | (str/replace (str (java.util.UUID/randomUUID)) "-" "")) 54 | 55 | (def hostname-refresh-interval 56 | "How often to allow reading /etc/hostname, in seconds." 57 | 60) 58 | 59 | (defn get-hostname 60 | "Get the current hostname by shelling out to 'hostname'" 61 | [] 62 | (or 63 | (try 64 | (let [{:keys [exit out]} (sh/sh "hostname")] 65 | (if (= exit 0) 66 | (str/trim out))) 67 | (catch Exception _)) 68 | "")) 69 | 70 | (defn hostname 71 | "Fetches the hostname by shelling to 'hostname', whenever the given age 72 | is stale enough. If the given age is recent, as defined by 73 | hostname-refresh-interval, returns age and val instead." 74 | [[age val]] 75 | (if (and val (<= (* 1000 hostname-refresh-interval) 76 | (- (System/currentTimeMillis) age))) 77 | [age val] 78 | [(System/currentTimeMillis) (get-hostname)])) 79 | 80 | (let [cache (atom [nil nil])] 81 | (defn localhost 82 | "Returns the local host name." 83 | [] 84 | (if (re-find #"^Windows" (System/getProperty "os.name")) 85 | (or (System/getenv "COMPUTERNAME") "localhost") 86 | (or (System/getenv "HOSTNAME") 87 | (second (swap! cache hostname)))))) 88 | 89 | (def dsn-pattern 90 | "The shape of a sentry DSN" 91 | #"^(https?)://([^:]*):([^@]*)@(.*)/([0-9]+)$") 92 | 93 | (defn parse-dsn 94 | "Extract DSN parameters into a map" 95 | [dsn] 96 | (if-let [[_ proto key secret uri ^String pid] (re-find dsn-pattern dsn)] 97 | {:key key 98 | :secret secret 99 | :uri (format "%s://%s" proto uri) 100 | :pid (Long. pid)} 101 | (throw (ex-info "could not parse sentry DSN" {:dsn dsn})))) 102 | 103 | (defn default-payload 104 | "Provide default values for a payload." 105 | [ts localhost] 106 | {:level "error" 107 | :server_name localhost 108 | :timestamp ts 109 | :platform "java"}) 110 | 111 | (defn auth-header 112 | "Compute the Sentry auth header." 113 | [ts key sig] 114 | (let [params [[:version "2.0"] 115 | [:signature sig] 116 | [:timestamp ts] 117 | [:client user-agent] 118 | [:key key]] 119 | param (fn [[k v]] (format "sentry_%s=%s" (name k) v))] 120 | (str "Sentry " (str/join ", " (map param params))))) 121 | 122 | (defn merged-payload 123 | "Return a payload map depending on the type of the event." 124 | [event ts localhost] 125 | (merge (default-payload ts localhost) 126 | (cond 127 | (map? event) event 128 | (e/exception? event) (e/exception->ev event) 129 | :else {:message (str event)}))) 130 | 131 | (defn add-breadcrumbs-to-payload 132 | [context payload] 133 | (let [breadcrumbs-list (or (:breadcrumbs payload) (:breadcrumbs context))] 134 | (cond-> payload (seq breadcrumbs-list) (assoc :breadcrumbs {:values breadcrumbs-list})))) 135 | 136 | (defn add-user-to-payload 137 | [context payload] 138 | (cond-> payload (:user context) (assoc :user (:user context)))) 139 | 140 | (defn add-http-info-to-payload 141 | [context payload] 142 | (cond-> payload (:request context) (assoc :request (:request context)))) 143 | 144 | (defn slurp-pretty-name 145 | [path] 146 | (try 147 | (last (re-find #"PRETTY_NAME=\"(.*)\"" (slurp path))) 148 | (catch Exception _))) 149 | 150 | (defn get-linux-pretty-name 151 | "Get the Linux distribution pretty name from /etc/os-release resp. /usr/lib/os-release." 152 | [] 153 | (or 154 | (slurp-pretty-name "/etc/os-release") 155 | (slurp-pretty-name "/usr/lib/os-release") 156 | "Unknown Linux")) 157 | 158 | (let [cache (atom nil)] ;; cache version forever 159 | (defn get-os-name-linux 160 | "Get a human-readable name for the current linux distribution from /etc/os-release, 161 | caching the output" 162 | [] 163 | (or @cache 164 | (swap! cache (constantly (get-linux-pretty-name)))))) 165 | 166 | (defn get-os-context 167 | [] 168 | (let [os-name (System/getProperty "os.name") 169 | os-version (System/getProperty "os.version")] 170 | (if (re-find #"^Linux" os-name) 171 | {:name os-name :version (get-os-name-linux) :kernel_version os-version} 172 | {:name os-name :version os-version}))) 173 | 174 | (defn get-contexts 175 | [] 176 | {:java {:name (System/getProperty "java.vendor") 177 | :version (System/getProperty "java.version")} 178 | :os (get-os-context) 179 | :clojure {:name "clojure" :version (clojure-version)}}) 180 | 181 | (defn add-contexts-to-payload 182 | "Add relevant bits of sentry.interfaces.Contexts to our payload." 183 | [payload] 184 | (assoc payload :contexts (get-contexts))) 185 | 186 | (defn add-fingerprint-to-payload 187 | "If the context provides a fingerprint override entry, pass it to the payload." 188 | [context payload] 189 | (cond-> payload (:fingerprint context) (assoc :fingerprint (:fingerprint context)))) 190 | 191 | (defn add-tags-to-payload 192 | "If the context provides tags or we were given tags directly during capture!, 193 | ad them to the payload. Tags provided by capture! override tags provided by the 194 | context map." 195 | [context payload tags] 196 | (let [merged (merge (:tags context) tags)] 197 | (cond-> 198 | payload 199 | (not (empty? merged)) (assoc :tags merged)))) 200 | 201 | (defn add-uuid-to-payload 202 | [context payload] 203 | (assoc payload :event_id (:event_id context (:event_id payload (sentry-uuid!))))) 204 | 205 | (defn validate-payload 206 | "Returns a validated payload." 207 | [merged] 208 | (s/assert :raven.spec/payload merged)) 209 | 210 | (defn payload 211 | "Build a full valid payload." 212 | [context event ts localhost tags] 213 | (let [breadcrumbs-adder (partial add-breadcrumbs-to-payload context) 214 | user-adder (partial add-user-to-payload context) 215 | http-info-adder (partial add-http-info-to-payload context) 216 | fingerprint-adder (partial add-fingerprint-to-payload context) 217 | tags-adder (partial add-tags-to-payload context) 218 | uuid-adder (partial add-uuid-to-payload context)] 219 | (-> (merged-payload event ts localhost) 220 | (uuid-adder) 221 | (breadcrumbs-adder) 222 | (user-adder) 223 | (fingerprint-adder) 224 | (http-info-adder) 225 | (tags-adder tags) 226 | (add-contexts-to-payload) 227 | (validate-payload)))) 228 | 229 | (defn timestamp! 230 | "Retrieve a timestamp. 231 | 232 | The format used is the same as python's 'time.time()' function - the number 233 | of seconds since the epoch, as a double to acount for fractional seconds (since 234 | the granularity is miliseconds)." 235 | [] 236 | (double (/ (System/currentTimeMillis) 1000))) 237 | 238 | (defn sign 239 | "HMAC-SHA1 for Sentry's format." 240 | [payload ts key ^String secret] 241 | (let [key (javax.crypto.spec.SecretKeySpec. (.getBytes secret) "HmacSHA1") 242 | bs (-> (doto (javax.crypto.Mac/getInstance "HmacSHA1") 243 | (.init key) 244 | (.update (.getBytes (str ts))) 245 | (.update (.getBytes " "))) 246 | (.doFinal payload))] 247 | (reduce str (for [b bs] (format "%02x" b))))) 248 | 249 | (defn perform-in-memory-request 250 | "Perform an in-memory pseudo-request, actually just storing the payload on a storage 251 | atom, to let users inspect/retrieve the payload in their tests." 252 | [payload] 253 | (swap! http-requests-payload-stub conj payload)) 254 | 255 | (defrecord SafeMap []) 256 | (defmethod clojure.core/print-method SafeMap 257 | [m ^java.io.Writer writer] 258 | (.write writer (format "#exoscale/safe-map [%s]" (str/join " " (keys m))))) 259 | 260 | (def safe-map 261 | "Wraps map into SafeMap record, effectively hidding values from json 262 | output, will not allow 2-way roundtrip. ex: (safe-map {:a 1}) -> 263 | \"#exoscale/safe-map [:a]\". Can be used to hide secrets and/or shorten 264 | large/deep maps" 265 | map->SafeMap) 266 | 267 | (def json-mapper 268 | (doto (json/object-mapper {:encoders {SafeMap (fn [x ^JsonGenerator jg] (.writeString jg (pr-str x)))}}) 269 | (.configure SerializationFeature/FAIL_ON_EMPTY_BEANS false) 270 | (.configure MapperFeature/AUTO_DETECT_GETTERS false) 271 | (.configure MapperFeature/AUTO_DETECT_IS_GETTERS false) 272 | (.configure MapperFeature/AUTO_DETECT_SETTERS false) 273 | (.configure MapperFeature/AUTO_DETECT_FIELDS false) 274 | (.configure MapperFeature/DEFAULT_VIEW_INCLUSION false))) 275 | 276 | (def closable? (partial instance? Closeable)) 277 | 278 | (defn close-http-connection 279 | " 280 | When having a connection pool set, the body 281 | cannot be closed as it's of a different type. 282 | " 283 | [response] 284 | (let [{:keys [body]} response] 285 | (when (closable? body) 286 | (.close ^Closeable body)))) 287 | 288 | (defn re-throw-response 289 | " 290 | Throw the response in case of its status was not 2xx 291 | as we don't want Sentry errors to be unnoticed. 292 | " 293 | [response] 294 | (let [{:keys [status]} response] 295 | (when-not (-> status str first (= \2)) 296 | (throw (ex-info (format "Sentry HTTP error") response))))) 297 | 298 | (defn perform-http-request 299 | [context dsn ts payload] 300 | (let [json-payload (json/write-value-as-bytes payload json-mapper) 301 | {:keys [key secret uri]} (parse-dsn dsn) 302 | sig (sign json-payload ts key secret)] 303 | (d/chain 304 | (http/post (str uri "/api/store/") 305 | (merge (select-keys context [:pool :middleware :pool-timeout 306 | :response-executor :request-timeout 307 | :read-timeout :connection-timeout]) 308 | {:headers {:x-sentry-auth (auth-header ts key sig) 309 | :accept "application/json" 310 | :content-type "application/json;charset=utf-8"} 311 | :body json-payload 312 | :throw-exceptions? false})) 313 | (fn [response] 314 | (close-http-connection response) 315 | (re-throw-response response))))) 316 | 317 | (defn capture! 318 | "Send a capture over the network. If `ev` is an exception, 319 | build an appropriate payload for the exception." 320 | ([context dsn event tags] 321 | (let [ts (timestamp!) 322 | payload (payload context event ts (localhost) tags) 323 | uuid (:event_id payload)] 324 | (d/chain 325 | (if (= dsn ":memory:") 326 | (perform-in-memory-request payload) 327 | (perform-http-request context dsn ts payload)) 328 | (fn [_] 329 | (clear-context) 330 | uuid)))) 331 | ([dsn ev tags] 332 | (capture! @@thread-storage dsn ev tags)) 333 | ([dsn ev] 334 | (capture! @@thread-storage dsn ev {}))) 335 | 336 | (defn make-breadcrumb! 337 | "Create a breadcrumb map. 338 | 339 | level can be one of: 340 | ['debug' 'info' 'warning' 'warn' 'error' 'exception' 'critical' 'fatal']" 341 | ([message category] 342 | (make-breadcrumb! message category "info")) 343 | ([message category level] 344 | (make-breadcrumb! message category level (timestamp!))) 345 | ([message category level timestamp] 346 | {:type "default" ;; TODO: Extend to support more than just default breadcrumbs. 347 | :timestamp timestamp 348 | :level level 349 | :message message 350 | :category category}) 351 | ;; :data (expected in case of non-default) 352 | ) 353 | 354 | (defn add-breadcrumb! 355 | "Append a breadcrumb to the list of breadcrumbs. 356 | 357 | The context is expected to be a map, and if no context is specified, a 358 | thread-local storage map will be used instead. 359 | " 360 | ([breadcrumb] 361 | ;; We need to dereference to get the atom since "thread-storage" is thread-local. 362 | (swap! @thread-storage add-breadcrumb! breadcrumb)) 363 | ([context breadcrumb] 364 | ;; We add the breadcrumb to the context instead, in a ":breadcrumb" key. 365 | (update context :breadcrumbs conj (s/assert :raven.spec/breadcrumb breadcrumb)))) 366 | 367 | (defn make-user 368 | "Create a user map." 369 | ([id] 370 | {:id id}) 371 | ([id email ip-address username] 372 | {:id id 373 | :email email 374 | :ip_address ip-address 375 | :username username})) 376 | 377 | (defn add-user! 378 | "Add user information to the sentry context (or a thread-local storage)." 379 | ([user] 380 | (swap! @thread-storage add-user! user)) 381 | ([context user] 382 | (assoc context :user (s/assert :raven.spec/user user)))) 383 | 384 | (defn make-http-info 385 | ([url method] 386 | {:url url 387 | :method method}) 388 | ([url method headers query_string cookies data env] 389 | {:url url 390 | :method method 391 | :headers headers 392 | :query_string query_string 393 | :cookies cookies 394 | :data data 395 | :env env})) 396 | 397 | (defn get-full-ring-url 398 | "Given a ring compliant request object, return the full URL. 399 | This was lifted from ring's source code so as to not depend on it." 400 | [request] 401 | (str (-> request :scheme name) 402 | "://" 403 | (get-in request [:headers "host"]) 404 | (:uri request) 405 | (when-let [query (:query-string request)] 406 | (str "?" query)))) 407 | 408 | (defn get-ring-env 409 | [request] 410 | (cond-> {:REMOTE_ADDR (:remote-addr request) 411 | :websocket? (:websocket? request) 412 | :route-params (:route-params request)} 413 | (some? (:compojure/route request)) (assoc :compojure/route (:compojure/route request)) 414 | (some? (:route request)) (assoc :bidi/route (get-in request [:route :handler])))) 415 | 416 | (defn make-ring-request-info 417 | "Create well-formatted context map for the Sentry 'HTTP' interface by 418 | extracting the information from a standard ring-compliant 'request', as 419 | defined in https://github.com/ring-clojure/ring/wiki/Concepts#requests" 420 | [request] 421 | {:url (get-full-ring-url request) 422 | :method (:request-method request) 423 | :cookies (get-in request [:headers "cookie"]) 424 | :headers (:headers request) 425 | :query_string (:query-string request) 426 | :env (get-ring-env request) 427 | :data (:body request)}) 428 | 429 | (defn add-http-info! 430 | "Add HTTP information to the sentry context (or a thread-local storage)." 431 | ([http-info] 432 | (swap! @thread-storage add-http-info! http-info)) 433 | ([context http-info] 434 | (assoc context :request (s/assert :raven.spec/request http-info)))) 435 | 436 | (defn add-ring-request! 437 | "Add HTTP information to the Sentry payload from a ring-compliant request 438 | map, excluding its body." 439 | ([request] 440 | (add-http-info! (make-ring-request-info (dissoc request :body)))) 441 | ([context request] 442 | (add-http-info! context (make-ring-request-info (dissoc request :body))))) 443 | 444 | (defn add-full-ring-request! 445 | "Add HTTP information to the Sentry payload from a ring-compliant request 446 | map, including the request body" 447 | ([request] 448 | (add-http-info! (make-ring-request-info request))) 449 | ([context request] 450 | (add-http-info! context (make-ring-request-info request)))) 451 | 452 | (defn add-fingerprint! 453 | "Add a custom fingerprint to the context (or a thread-local storage)." 454 | ([fingerprint] 455 | (swap! @thread-storage add-fingerprint! fingerprint)) 456 | ([context fingerprint] 457 | (assoc context :fingerprint (s/assert :raven.spec/fingerprint fingerprint)))) 458 | 459 | (defn add-tag! 460 | "Add a custom tag to the context (or a thread-local storage)." 461 | ([tag value] 462 | (swap! @thread-storage add-tag! tag value)) 463 | ([context tag value] 464 | (assoc-in context [:tags tag] value))) 465 | 466 | (defn add-tags! 467 | "Add custom tags to the context (or a thread-local storage)." 468 | ([tags] 469 | (swap! @thread-storage add-tags! tags)) 470 | ([context tags] 471 | (reduce-kv add-tag! context tags))) 472 | 473 | (defn add-extra! 474 | "Add a map of extra data to the context (or a thread-local storage) 475 | preserving its previous keys." 476 | ([extra] 477 | (swap! @thread-storage add-extra! extra)) 478 | ([context extra] 479 | (update context :extra merge extra))) 480 | 481 | (defn add-exception! 482 | "Add an exception to the context (or a thread-local storage)." 483 | ([^Throwable e] 484 | (swap! @thread-storage add-exception! e)) 485 | ([context ^Throwable e] 486 | (let [env (e/exception->ev e)] 487 | (-> context 488 | (merge (dissoc env :extra)) 489 | (add-extra! (:extra env)))))) 490 | 491 | (defn release! 492 | "Release new application version with provided webhook release URL." 493 | [webhook-endpoint payload] 494 | (if (some? (:version payload)) 495 | (http/post webhook-endpoint 496 | {:headers {:content-type "application/json"} 497 | :body (json/write-value-as-bytes payload)}) 498 | (throw (ex-info "no version key provided" {:payload payload})))) 499 | -------------------------------------------------------------------------------- /src/raven/exception.clj: -------------------------------------------------------------------------------- 1 | (ns raven.exception 2 | " 3 | A separate namespace to handle exceptions. 4 | " 5 | (:require 6 | [clojure.string :as str]) 7 | (:import 8 | clojure.lang.Symbol 9 | java.util.Map)) 10 | 11 | 12 | (defn trace->frame 13 | " 14 | Turn a trace element into its Sentry counterpart. 15 | " 16 | [trace] 17 | 18 | (let [[^Symbol classname 19 | ^Symbol method 20 | ^String filename 21 | ^long lineno] trace] 22 | 23 | {:filename filename 24 | :lineno lineno 25 | :function (str classname "." method)})) 26 | 27 | 28 | (defn via->sign 29 | " 30 | Turn a `via` node into a signing node. Either take a `:type` field 31 | from the data or compose a line from a class name and a message. 32 | " 33 | [^Map via] 34 | (let [{ex-type :type 35 | :keys [message data]} via 36 | {error-type :type} data] 37 | (or error-type 38 | (str ex-type ":" message)))) 39 | 40 | 41 | (defn ex-map->sign 42 | " 43 | Turn an exception map into a string for further hashing. 44 | " 45 | [^Map ex-map] 46 | (let [{:keys [via]} ex-map] 47 | (str/join \newline (map via->sign via)))) 48 | 49 | 50 | (defn via->exception 51 | " 52 | Turn one of the `via` nodes into a sentry exception map. 53 | https://docs.sentry.io/development/sdk-dev/interfaces/exception/ 54 | We don't need to fill most of the fields since Sentry renders 55 | them not as expect anyway. 56 | " 57 | [^Map via] 58 | (let [{:keys [type message]} via] 59 | {:type (str type) 60 | :value message})) 61 | 62 | 63 | (defn exception->ev 64 | " 65 | Turn an exception instance into a Sentry top-level map. 66 | " 67 | [^Throwable e] 68 | 69 | (let [ex-map (Throwable->map e) 70 | {:keys [trace via]} ex-map 71 | [ex-top] via 72 | {:keys [type message]} ex-top] 73 | 74 | {:message message 75 | :culprit (str type) 76 | :checksum (-> ex-map ex-map->sign hash str) 77 | :stacktrace {:frames (map trace->frame trace)} 78 | :extra (select-keys ex-map [:via]) 79 | :exception {:values (mapv via->exception via)}})) 80 | 81 | 82 | (defn exception? 83 | "Is the value an exception?" 84 | [e] 85 | (instance? Throwable e)) 86 | 87 | 88 | (comment 89 | 90 | (def _e 91 | (ex-info "aaa" {:foo {:baz [1 2 3 :foo]}} 92 | (ex-info "bbb" {:bar {:aaa {:bbb :CCC}}} 93 | (ex-info "ccc" {:baz [true false]})))) 94 | 95 | (-> _e exception->ev (dissoc :stacktrace)) 96 | 97 | ) 98 | -------------------------------------------------------------------------------- /src/raven/spec.clj: -------------------------------------------------------------------------------- 1 | (ns raven.spec 2 | "Specifications for the wire JSON structure when talking to sentry." 3 | (:require [clojure.spec.alpha :as s])) 4 | 5 | (def valid-levels 6 | "A list of valid message levels." 7 | ["debug" "info" "warning" "warn" "error" "exception" "critical" "fatal"]) 8 | 9 | (def valid-types 10 | "A list of valid breadcrumbs types." 11 | ;; TODO: support the other types of breadcrumbs as well. 12 | ;;["default" "navigation" "http"] 13 | ["default"]) 14 | 15 | (defn is-valid-type? 16 | [typ] 17 | (some #(= % typ) valid-types)) 18 | 19 | (defn is-valid-level? 20 | [lvl] 21 | (some #(= % lvl) valid-levels)) 22 | 23 | (defn is-valid-platform? 24 | "Only one platform choice is valid for clojure." 25 | [platform] 26 | (= platform "java")) 27 | 28 | ;; timestamp is expected to be "the number of seconds since the epoch", with a 29 | ;; precision of a millisecond. 30 | (s/def ::timestamp float?) 31 | (s/def :raven.spec.breadcrumb/type is-valid-type?) 32 | (s/def :raven.spec.stacktrace/type string?) 33 | (s/def ::level is-valid-level?) 34 | (s/def ::message string?) 35 | (s/def ::sever_name string?) 36 | (s/def ::culprit string?) 37 | (s/def ::platform is-valid-platform?) 38 | (s/def ::headers map?) 39 | (s/def ::env map?) 40 | (s/def ::breadcrumb (s/keys :req-un [:raven.spec.breadcrumb/type ::timestamp ::level ::message ::category])) 41 | (s/def ::frame (s/keys :req-un [::filename ::lineno ::function])) 42 | (s/def ::values (s/coll-of ::breadcrumb)) 43 | (s/def ::frames (s/coll-of ::frame)) 44 | (s/def ::java (s/keys :req-un [::name ::version])) 45 | (s/def ::clojure (s/keys :req-un [::name ::version])) 46 | (s/def ::os (s/keys :req-un [::name ::version] :opt-un [::kernel_version])) 47 | (s/def ::fingerprint (s/coll-of string?)) 48 | 49 | ;; The sentry interfaces. We use the alias name instead of the full interface path 50 | ;; as suggested in https://docs.sentry.io/clientdev/interfaces/ 51 | ;; Those are also used to validate input from the library users. 52 | (s/def ::breadcrumbs (s/keys :req-un [::values])) 53 | (s/def ::user (s/keys :req-un [::id] :opt-un [::username ::email ::ip_address])) 54 | (s/def ::request (s/keys :req-un [::method ::url] :opt-un [::query_string ::cookies ::headers ::env ::data])) 55 | (s/def ::stacktrace (s/keys :req-un [::frames])) 56 | (s/def ::exception (s/keys :req-un [::value :raven.spec.stacktrace/type] :opt-un [::module ::thread_id ::stacktrace ::mechanism])) 57 | (s/def ::contexts (s/keys :req-un [::java ::clojure ::os])) 58 | 59 | ;; The main payload spec. 60 | (s/def ::payload (s/keys :req-un [::event_id ::level ::server_name ::timestamp ::platform ::contexts] :opt-un [::breadcrumbs ::user ::request ::fingerprint ::culprit])) 61 | -------------------------------------------------------------------------------- /test/raven/client_test.clj: -------------------------------------------------------------------------------- 1 | (ns raven.client-test 2 | (:require [clojure.string :as str] 3 | [clojure.test :refer :all] 4 | [raven.client :refer :all] 5 | [jsonista.core :as json] 6 | [manifold.deferred :as d] 7 | [clojure.edn :as edn]) 8 | (:import [manifold.deferred 9 | SuccessDeferred Deferred])) 10 | 11 | (def dsn-fixture 12 | "https://098f6bcd4621d373cade4e832627b4f6:ad0234829205b9033196ba818f7a872b@sentry.example.com/42") 13 | 14 | (def expected-parsed-dsn 15 | {:key "098f6bcd4621d373cade4e832627b4f6" 16 | :secret "ad0234829205b9033196ba818f7a872b" 17 | :uri "https://sentry.example.com" 18 | :pid 42}) 19 | 20 | (def expected-sig 21 | "75e297d21055bbd1b51229f266d71701e1b70e68") 22 | 23 | (def frozen-ts 24 | 1525265277.63) 25 | 26 | (def frozen-uuid 27 | "a059419cd1bd46a685b95080f260aed4") 28 | 29 | (def frozen-servername 30 | "Muninn") 31 | 32 | (def frozen-request 33 | "A frozen Ring request object" 34 | {:remote-addr "127.0.0.1" 35 | :params {} 36 | :route-params {} 37 | :headers {"accept" "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" 38 | "accept-encoding" "gzip, deflate" 39 | "accept-language" "en-GB,en;q=0.5" 40 | "connection" "keep-alive" 41 | "cookie" "csrftoken=somecsrfcookie; blah=something" 42 | "host" "localhost:8080" 43 | "upgrade-insecure-requests" "1" 44 | "user-agent" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0"} 45 | :server-port 8080 46 | :content-length 0 47 | :websocket? false 48 | :content-type nil 49 | :character-encoding "utf8" 50 | :uri "/example" 51 | :server-name "localhost" 52 | :query-string nil 53 | :body nil 54 | :scheme :http 55 | :request-method :get}) 56 | 57 | (def expected-test-url 58 | "http://localhost:8080/example") 59 | 60 | (def expected-message 61 | "a test message") 62 | 63 | (def expected-header 64 | (str "Sentry sentry_version=2.0, sentry_signature=" expected-sig ", sentry_timestamp=" frozen-ts ", sentry_client=" user-agent ", sentry_key=" (:key expected-parsed-dsn))) 65 | 66 | (def expected-user-id 67 | "Huginn") 68 | 69 | (def expected-payload 70 | {:level "error" 71 | :server_name frozen-servername 72 | :timestamp frozen-ts 73 | :platform "java" 74 | :event_id frozen-uuid 75 | :message expected-message}) 76 | 77 | (def payload-validation-keys 78 | [:level :server_name :culprit :timestamp :platform :event_id :project :message]) 79 | 80 | (def expected-breadcrumb 81 | {:type "default" 82 | :timestamp frozen-ts 83 | :level "info" 84 | :message "message" 85 | :category "category"}) 86 | 87 | (def expected-fingerprint 88 | ["Huginn" "og" "Muninn"]) 89 | 90 | (def simple-http-info 91 | {:url "http://example.com" 92 | :method "POST"}) 93 | 94 | (defn reset-storage 95 | "A fixture to reset the per-thread atom between tests." 96 | [f] 97 | (f) 98 | (clear-context) 99 | (clear-http-stub)) 100 | 101 | (use-fixtures :each reset-storage) 102 | 103 | (defn assert-equal-for-key 104 | [current-key payload reference] 105 | (= (current-key payload) (current-key reference))) 106 | 107 | (defn check-payload 108 | "Validate the payload against the expected payload." 109 | [expected-payload payload] 110 | (reduce #(and %1 %2) (map #(assert-equal-for-key % payload expected-payload) payload-validation-keys))) 111 | 112 | (defn make-test-payload 113 | [context] 114 | (payload (assoc context :event_id frozen-uuid) expected-message frozen-ts frozen-servername {})) 115 | 116 | (deftest raven-client-tests 117 | (testing "parsing DSN" 118 | (is (= (parse-dsn dsn-fixture) expected-parsed-dsn))) 119 | 120 | (testing "signing" 121 | (is (= (sign (.getBytes "payload") frozen-ts (:key expected-parsed-dsn) (:secret expected-parsed-dsn)) expected-sig))) 122 | 123 | (testing "the auth header is what we expect" 124 | (is (= (auth-header frozen-ts (:key expected-parsed-dsn) expected-sig) expected-header))) 125 | 126 | (testing "the payload is constructed from a map" 127 | (is (check-payload expected-payload (make-test-payload {})))) 128 | 129 | (testing "the payload is constructed from a string" 130 | (is (check-payload expected-payload (make-test-payload {})))) 131 | 132 | (testing "getting a full ring URL" 133 | (is (= expected-test-url (get-full-ring-url frozen-request)))) 134 | 135 | (testing "contexts are provided in the payload" 136 | (is (= (get-contexts) (:contexts (make-test-payload {})))))) 137 | 138 | (deftest gather-breadcrumbs 139 | (testing "we can gather breadcrumbs" 140 | (add-breadcrumb! (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)) 141 | (is (= [expected-breadcrumb] (:breadcrumbs @@thread-storage))))) 142 | 143 | (deftest add-breadcrumbs 144 | (testing "breadcrumbs are added to the payload" 145 | (add-breadcrumb! (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)) 146 | (is (= expected-breadcrumb (first (:values (:breadcrumbs (make-test-payload @@thread-storage)))))))) 147 | 148 | (deftest multi-breadcrumbs 149 | (testing "adding several breadcrumbs to the payload" 150 | (add-breadcrumb! (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)) 151 | (add-breadcrumb! (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)) 152 | (is (= 2 (count (:values (:breadcrumbs (make-test-payload @@thread-storage)))))))) 153 | 154 | (deftest multi-thread 155 | (testing "breadcrumbs are thread local" 156 | (add-breadcrumb! (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)) 157 | (add-breadcrumb! (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)) 158 | (is (nil? @(future (:breadcrumbs (make-test-payload @@thread-storage))))))) 159 | 160 | (deftest manual-context 161 | (testing "breadcrumbs are sent using a manual context." 162 | (let [context {:breadcrumbs [(make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts)]}] 163 | (is (= expected-breadcrumb (first (:values (:breadcrumbs (make-test-payload context))))))))) 164 | 165 | (deftest multi-breadcrumbs-in-manual-context 166 | (testing "multiple breadcrumbs are sent using a manual context." 167 | (let [context {:breadcrumbs [(make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb) (:level expected-breadcrumb) frozen-ts) (make-breadcrumb! (:message expected-breadcrumb) (:category expected-breadcrumb))]}] 168 | (is (= expected-breadcrumb (first (:values (:breadcrumbs (make-test-payload context)))))) 169 | (is (= 2 (count (:values (:breadcrumbs (make-test-payload context))))))))) 170 | 171 | (deftest add-user 172 | (testing "user is added to the payload" 173 | (add-user! (make-user expected-user-id)) 174 | (is (= expected-user-id (:id (:user (make-test-payload @@thread-storage))))))) 175 | 176 | (deftest manual-user 177 | (testing "user is sent using a manual context" 178 | (let [context {:user (make-user expected-user-id)}] 179 | (is (= expected-user-id (:id (:user (make-test-payload context)))))))) 180 | 181 | (deftest add-request 182 | (testing "http information is added to the payload" 183 | (add-http-info! (make-http-info (:url simple-http-info) (:method simple-http-info))) 184 | (is (= simple-http-info (:request (make-test-payload @@thread-storage)))))) 185 | 186 | (deftest manual-request 187 | (testing "http information is sent using a manual context" 188 | (let [context {:request (make-http-info (:url simple-http-info) (:method simple-http-info))}] 189 | (is (= simple-http-info (:request (make-test-payload context))))))) 190 | 191 | (deftest add-fingerprint 192 | (testing "fingerprints can be added to the payload" 193 | (add-fingerprint! expected-fingerprint) 194 | (is (= expected-fingerprint (:fingerprint (make-test-payload @@thread-storage)))))) 195 | 196 | (deftest manual-fingerprint 197 | (testing "fingerprints are sent using a manual context" 198 | (let [context (add-fingerprint! {} expected-fingerprint)] 199 | (is (= expected-fingerprint (:fingerprint (make-test-payload context))))))) 200 | 201 | (deftest capture-with-subbing 202 | (testing "we can capture payloads with in-memory stubbing." 203 | (capture! ":memory:" {:message "This is a stub message"}) 204 | (is (= "This is a stub message" (:message (first @http-requests-payload-stub)))))) 205 | 206 | (deftest capture-returns-uuid 207 | (testing "capturing an event returns the event's uuid" 208 | (let [out @(capture! ":memory:" {:message "whatever"})] 209 | (is (= 32 (count out))) 210 | (is (string? out))))) 211 | 212 | (deftest capture-without-tags 213 | (testing "we don't add a tags key if no tags are specified" 214 | (capture! ":memory:" {:message "This is a stub message"}) 215 | (is (not (contains? (first @http-requests-payload-stub) :tags))))) 216 | 217 | (deftest capture-without-users 218 | (testing "we don't add a user key if no user is specified" 219 | (capture! ":memory:" {:message "This is a stub message"}) 220 | (is (not (contains? (first @http-requests-payload-stub) :user))))) 221 | 222 | (deftest capture-with-inline-tags 223 | (testing "tags are added if they are passed during capture" 224 | (capture! ":memory:" {:message "This is a stub message"} {:feather_color "black"}) 225 | (is (= {:feather_color "black"} (:tags (first @http-requests-payload-stub)))))) 226 | 227 | (deftest capture-with-context-tags 228 | (testing "tags added by context are passed during capture" 229 | (add-tag! :feather_color "black") 230 | (capture! ":memory:" (Exception.)) 231 | (is (= {:feather_color "black"} (:tags (first @http-requests-payload-stub))))) 232 | (testing "tags added by context are passed during capture" 233 | (add-tags! {:env "prod" :feather_color "black"}) 234 | (capture! ":memory:" (Exception.)) 235 | (is (= {:feather_color "black" :env "prod"} (:tags (second @http-requests-payload-stub)))))) 236 | 237 | (deftest capture-tags-override-context 238 | (testing "tags added by context are overriden by inline tags" 239 | (add-tag! :feather_color "black") 240 | (capture! ":memory:" (Exception.) {:feather_color "svartur"}) 241 | (is (= {:feather_color "svartur"} (:tags (first @http-requests-payload-stub)))))) 242 | 243 | (deftest ring-request-composure 244 | (testing "passing a ring request to Sentry with compojure" 245 | (add-http-info! (make-ring-request-info (assoc frozen-request :compojure/route [:get "/example"]))) 246 | (capture! ":memory:" expected-message) 247 | (is (= (:url (:request (first @http-requests-payload-stub))) expected-test-url)) 248 | (is (= (get-in (first @http-requests-payload-stub) [:request :env :compojure/route]) [:get "/example"])))) 249 | 250 | (deftest ring-request-no-composure 251 | (testing "passing a ring request to Sentry with no compojure" 252 | (add-http-info! (make-ring-request-info frozen-request)) 253 | (capture! ":memory:" expected-message) 254 | (is (= (:url (:request (first @http-requests-payload-stub))) expected-test-url)) 255 | (is (= (get-in (first @http-requests-payload-stub) [:request :env :compojure/route]) nil)))) 256 | 257 | (deftest ring-request-query-string 258 | (testing "passing a ring request to Sentry with a query string" 259 | (add-http-info! (make-ring-request-info (assoc frozen-request :query-string "name=munnin"))) 260 | (capture! ":memory:" expected-message) 261 | (is (= (:url (:request (first @http-requests-payload-stub))) (str expected-test-url "?name=munnin"))) 262 | (is (= (get-in (first @http-requests-payload-stub) [:request :query_string]) "name=munnin")))) 263 | 264 | (deftest ring-request-no-params 265 | (testing "passing a ring request does not forward :params to sentry" 266 | (add-ring-request! (assoc frozen-request :params {:something "blah"})) 267 | (capture! ":memory:" expected-message) 268 | (is (nil? (:params (:env (:request (first @http-requests-payload-stub)))))))) 269 | 270 | (deftest no-http-client-in-context 271 | (testing "unused keys in context don't end up in payload" 272 | (let [context {:http_client "something"}] 273 | (is (= nil (:http_client (make-test-payload context))))))) 274 | 275 | (deftest composed-ring-request 276 | (testing "the composition add-ring-request! adds a ring request to the payload" 277 | (add-full-ring-request! frozen-request) 278 | (capture! ":memory:" expected-message) 279 | (is (= (:url (:request (first @http-requests-payload-stub))) expected-test-url)))) 280 | 281 | (deftest top-level-copmosition 282 | (testing "events can be produced by threading top-level functions" 283 | (capture! ":memory:" (-> {} 284 | (add-breadcrumb! expected-breadcrumb) 285 | (add-user! (make-user expected-user-id)) 286 | (add-full-ring-request! frozen-request) 287 | (add-exception! (Exception.)) 288 | (add-tag! :feather_color "black") 289 | (add-tag! :beak_color "black"))) 290 | (is (= (:url (:request (first @http-requests-payload-stub))) expected-test-url)) 291 | (is (= "black" (:feather_color (:tags (first @http-requests-payload-stub))))) 292 | (is (= "black" (:beak_color (:tags (first @http-requests-payload-stub))))) 293 | (is (= expected-user-id (:id (:user (first @http-requests-payload-stub))))) 294 | (is (= expected-breadcrumb (first (:values (:breadcrumbs (first @http-requests-payload-stub)))))))) 295 | 296 | (deftest passing-sentry-id 297 | (testing "passing an outside sentry ID will forward it to the server" 298 | (capture! ":memory:" (-> {:event_id "abcd"} 299 | (add-exception! (Exception.)))) 300 | (is (= "abcd" (:event_id (first @http-requests-payload-stub)))))) 301 | 302 | 303 | (deftest exception-sign 304 | (testing "Fixate the way we build a string to hash" 305 | 306 | (let [e (ex-info "error1" {:field1 1 307 | :type ::special-error} 308 | (new Exception "error2" 309 | (ex-info "error3" {:field3 3}))) 310 | 311 | sign (-> e Throwable->map raven.exception/ex-map->sign) 312 | 313 | lines [":raven.client-test/special-error" 314 | "java.lang.Exception:error2" 315 | "clojure.lang.ExceptionInfo:error3"]] 316 | 317 | (is (= sign (str/join \newline lines)))))) 318 | 319 | 320 | (deftest test-capture-result 321 | (let [result (capture! ":memory:" {:event_id "abcd"})] 322 | (is (instance? SuccessDeferred result)))) 323 | 324 | 325 | (deftest test-capture-http-exception 326 | (with-redefs [aleph.http/post 327 | (fn [& _] 328 | (d/future 329 | (throw (new Exception "boom"))))] 330 | (let [result (capture! "https://xxx:yyy@example.com/999" {:event_id "abcd"})] 331 | (is (instance? Deferred result)) 332 | (is (thrown-with-msg? Exception #"boom" @result))))) 333 | 334 | 335 | (deftest exception-structure 336 | 337 | (testing "Fixate the exceptions's structure" 338 | 339 | (let [e (ex-info "ex1" {:field1 1} 340 | (ex-info "ex2" {:field2 2})) 341 | 342 | context (add-exception! nil e)] 343 | 344 | (capture! ":memory:" context)) 345 | 346 | (let [fields [:message :culprit :checksum :extra :exception] 347 | submap (-> @http-requests-payload-stub 348 | first 349 | (select-keys fields) 350 | (update-in [:extra :via] 351 | (fn [via] 352 | (mapv #(dissoc % :at) via))))] 353 | 354 | (is (= submap 355 | '{:message "ex1" 356 | :culprit "clojure.lang.ExceptionInfo" 357 | :checksum "1364801774" 358 | :extra 359 | {:via 360 | [{:type clojure.lang.ExceptionInfo 361 | :message "ex1" 362 | :data {:field1 1}} 363 | {:type clojure.lang.ExceptionInfo 364 | :message "ex2" 365 | :data {:field2 2}}]} 366 | :exception 367 | {:values 368 | [{:type "clojure.lang.ExceptionInfo" :value "ex1"} 369 | {:type "clojure.lang.ExceptionInfo" :value "ex2"}]}}))))) 370 | 371 | (deftest exception-preserves-extra 372 | (testing "Adding an exception to the context saves old extra" 373 | 374 | (let [e (ex-info "ex1" {:field1 1} 375 | (ex-info "ex2" {:field2 2})) 376 | 377 | context (-> nil 378 | (add-extra! {:aaa 1}) 379 | (add-exception! e) 380 | (add-extra! {:bbb 2}))] 381 | 382 | (capture! ":memory:" context)) 383 | 384 | (let [extra (-> @http-requests-payload-stub 385 | first 386 | :extra)] 387 | 388 | (is (= (dissoc extra :via) 389 | {:aaa 1 :bbb 2})) 390 | 391 | (is (-> extra :via vector?))))) 392 | 393 | 394 | (defrecord Foo [b]) 395 | (deftest test-json-serializer 396 | (is (thrown? Exception (json/write-value-as-bytes (Object.))) 397 | "throws without our mapper") 398 | (is (json/write-value-as-bytes (Object.) 399 | json-mapper) 400 | "pass trough with our mapper") 401 | 402 | (is (= "{\"a\":{\"b\":{}}}" 403 | (String. (json/write-value-as-bytes {:a (Foo. (java.lang.Exception. "yolo"))} 404 | json-mapper))) 405 | "don't do (bean x) on unknown values") 406 | (is (= "\"#exoscale/safe-map [:a :b]\"" 407 | (json/write-value-as-string (safe-map {:a 1 :b 2}) 408 | json-mapper))) 409 | (is (= [:a :b] (edn/read-string {:readers {'exoscale/safe-map identity}} 410 | (with-out-str (pr (safe-map {:a 1 :b 2}))))) 411 | "make sure it's edn readable")) 412 | -------------------------------------------------------------------------------- /test/raven/integration_test.clj: -------------------------------------------------------------------------------- 1 | (ns raven.integration-test 2 | (:require [clojure.test :refer :all] 3 | [raven.client :refer :all] 4 | [aleph.http :refer [default-connection-pool]])) 5 | 6 | (def http-info-map 7 | (make-http-info "http://example.com" "POST" {:Content-Type "text/html"} "somekey=somevalue" "somecookie=somevalue" "some POST data. This might be BIG!" {:some-env "a value"})) 8 | 9 | (defn get-dsn 10 | [] 11 | (let [dsn (System/getenv "DSN")] 12 | (when (nil? dsn) 13 | (throw (Exception. "Please provide a 'DSN' environment variable with a valid DSN."))) 14 | dsn)) 15 | 16 | (deftest ^:integration-test raven-integration-test 17 | (testing "Sending out a test sentry entry." 18 | (add-breadcrumb! (make-breadcrumb! "The user did something" "category.1")) 19 | (add-breadcrumb! (make-breadcrumb! "The user did something else" "category.1")) 20 | (add-breadcrumb! (make-breadcrumb! "The user did something bad" "category.2" "error")) 21 | (add-user! (make-user "123456" "huginn@example.com" "127.0.0.1" "Huginn")) 22 | (add-tag! :integration-test-pool "default") 23 | (add-tag! :integration-test-context "thread-local") 24 | (add-http-info! http-info-map) 25 | (capture! (get-dsn) (Exception. "Test exception") {:arbitrary-tag "arbitrary-value"}) 26 | ;; We sleep for a second since otherwise the process dies before the request had time to fly out 27 | ;; to sentry (since it's asynchronous and therefore doesn't block the main thread). 28 | (Thread/sleep 1000))) 29 | 30 | (deftest ^:integration-test raven-integration-test-explicit-pool 31 | (testing "Sending out a test sentry entry using an explicit context and explcit pool" 32 | (capture! {:pool default-connection-pool} (get-dsn) (-> {} 33 | (add-user! (make-user "654321" "muninn@example.com" "127.1.1.1" "Muninn")) 34 | (add-http-info! http-info-map) 35 | (add-exception! (Exception. "Another test exception"))) {:integration-test-pool "explicit" 36 | :integration-test-context "explicit"}) 37 | 38 | ;; We sleep for a second since otherwise the process dies before the request had time to fly out 39 | ;; to sentry (since it's asynchronous and therefore doesn't block the main thread). 40 | (Thread/sleep 1000))) 41 | --------------------------------------------------------------------------------