├── .gitignore
├── project.clj
├── src
└── clj_airbrake
│ ├── ring.clj
│ └── core.clj
├── test
└── clj_airbrake
│ └── test
│ ├── core.clj
│ └── ring.clj
├── test-resources
└── airbrake_2_0.xsd
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | pom.xml
2 | *jar
3 | lib
4 | classes
5 | .sw?
6 | README.html
7 | .lein*
8 | target
9 |
--------------------------------------------------------------------------------
/project.clj:
--------------------------------------------------------------------------------
1 | (defproject clj-airbrake "3.2.1"
2 | :description "Airbrake Client"
3 | :min-lein-version "2.0.0"
4 | :url "https://github.com/bmabey/clj-airbrake"
5 | :dependencies [[http-kit "2.1.19"]
6 | [clj-stacktrace "0.2.8"]
7 | [ring/ring-core "1.2.0"]
8 | [org.clojure/clojure "1.7.0"]
9 | [cheshire "5.5.0"]]
10 | :profiles {:dev
11 | {:resource-paths ["test-resources"]
12 | :dependencies [[enlive "1.1.4"]]}})
13 |
--------------------------------------------------------------------------------
/src/clj_airbrake/ring.clj:
--------------------------------------------------------------------------------
1 | (ns clj-airbrake.ring
2 | (:use clj-airbrake.core)
3 | (:require [ring.util.codec :as codec :refer [url-decode]]))
4 |
5 | (defn request-to-message
6 | "Maps the ring request map to the format of the airbrake params"
7 | [{:keys [query-string] :as req}]
8 | {:context {:url (str (name (:scheme req))
9 | "://"
10 | (:server-name req)
11 | (:uri req)
12 | (when query-string (str "?" (codec/url-decode query-string))))
13 | :headers (get req :headers {})}
14 | :params (or (:params req)
15 | {:query-string query-string})
16 | :session (get req :session {})})
17 |
18 | (defn wrap-airbrake
19 | "Catches exceptions and sends Airbrake notification."
20 | ([handler airbrake-config]
21 | (wrap-airbrake handler airbrake-config request-to-message))
22 | ([handler airbrake-config request-mapper]
23 | (fn [req]
24 | (with-airbrake airbrake-config (request-mapper req)
25 | (handler req)))))
26 |
--------------------------------------------------------------------------------
/test/clj_airbrake/test/core.clj:
--------------------------------------------------------------------------------
1 | (ns clj-airbrake.test.core
2 | (:use [clj-airbrake.core] :reload)
3 | (:use clojure.test))
4 |
5 | (deftest test-make-notice
6 | (with-redefs [cheshire.core/generate-string identity]
7 | (let [notifier {:name "clj-airbrake"
8 | :version version
9 | :url "http://github.com/leadtune/clj-airbrake"}
10 | notice (make-notice (Exception.) {:environemnt-name "env" :root-directory "/app/dir"})]
11 | (= notifier (:notifer (make-notice (Exception.) {})))
12 | (= "env" (:environment (:context notice)))
13 | (= "/app/dir" (:rootDirectory (:context notice))))))
14 |
15 | (deftest test-make-error
16 | (let [e (Exception. "Something went wrong")]
17 | (= "Exception" (:type (make-error e)))
18 | (= "Something went wrong" (:message (make-error e)))
19 | (= [] (:backtrace (make-error e)))))
20 |
21 | (deftest test-ignored-environments
22 | (let [notice-args (atom nil)]
23 | (with-redefs [clj-airbrake.core/send-notice-async (fn [& args] (future (reset! notice-args args)))]
24 | (notify {:api-key "api-key" :environment-name "development" :project "p"} (Exception.))
25 | (is (= nil @notice-args))
26 |
27 | (notify {:api-key "api-key" :environment-name "production" :project "p"} (Exception.))
28 | (is (not (= nil @notice-args))))))
29 |
--------------------------------------------------------------------------------
/test-resources/airbrake_2_0.xsd:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/test/clj_airbrake/test/ring.clj:
--------------------------------------------------------------------------------
1 | (ns clj-airbrake.test.ring
2 | (:use [clj-airbrake.ring] :reload)
3 | (:use clojure.test))
4 |
5 | (def request {:remote-addr "127.0.0.1"
6 | :scheme :http
7 | :request-method :get
8 | :query-string nil
9 | :content-type "application/json"
10 | :uri "/"
11 | :server-name "localhost"
12 | :headers {:accept-language "en-US"
13 | :accept-encoding "gzip, deflate"
14 | :content-length "1028"
15 | :content-type "application/json"
16 | :host "localhost:3000"
17 | :user-agent "firefox"}
18 | :content-length 1028
19 | :server-port 8080
20 | :character-encoding nil})
21 |
22 | (defn- call-app
23 | [app req]
24 | (let [environment-name "production"]
25 | ((wrap-airbrake app {:api-key "api-key" :environment-name environment-name :project "project"}) req)))
26 |
27 | (deftest test-wrap-airbrake
28 | (testing "returns response when no exception thrown"
29 | (let [app (fn [req] "response")]
30 | (is (= "response"
31 | (call-app app request)))))
32 |
33 | (testing "re-throws exception"
34 | (let [app (fn [req] (/ 1 0))]
35 | (with-redefs [clj-airbrake.core/notify (fn [& args] nil)]
36 | (is (thrown? ArithmeticException
37 | (call-app app request))))))
38 |
39 | (testing "notifies airbrake with request params"
40 | (let [app (fn [req] (/ 1 0))
41 | notify-params (atom nil)
42 | airbrake-message (request-to-message request)]
43 | (with-redefs [clj-airbrake.core/notify (fn [& args] (reset! notify-params args))]
44 | (try (call-app app request)
45 | (catch ArithmeticException e))
46 | (is (= airbrake-message
47 | (last @notify-params)))))))
48 |
49 |
50 | (deftest test-request-to-message
51 | (let [message (request-to-message request)]
52 | (is (= "http://localhost/"
53 | (:url (:context message))))
54 | (testing "params"
55 | (is (= {:query-string nil}
56 | (:params message)))
57 | (is (= {:query-string "blah=yes"}
58 | (:params (request-to-message (assoc request :query-string "blah=yes")))))
59 | (is (= {:foo "bar"}
60 | (:params (request-to-message (assoc request
61 | :params {:foo "bar"})))))
62 | (is (= "http://localhost/?blah=yes"
63 | (:url (:context (request-to-message (assoc request :query-string "blah=yes"))))))
64 | (is (= "http://localhost/?x[y]=1"
65 | (:url (:context (request-to-message (assoc request :query-string "x%5By%5D=1")))))))
66 | (is (= {}
67 | (:session message)))
68 | (is (= {:accept-language "en-US"
69 | :accept-encoding "gzip, deflate"
70 | :content-length "1028"
71 | :content-type "application/json"
72 | :host "localhost:3000"
73 | :user-agent "firefox"}
74 | (:headers (:context message))))))
75 |
--------------------------------------------------------------------------------
/src/clj_airbrake/core.clj:
--------------------------------------------------------------------------------
1 | (ns clj-airbrake.core
2 | (:use (clj-stacktrace [core :only [parse-exception]] [repl :only [method-str]]))
3 | (:require [org.httpkit.client :as httpclient]
4 | [clojure.java.io :as jio]
5 | [clojure.string :as s]
6 | [cheshire.core :refer :all])
7 | (:import [java.util.concurrent ThreadPoolExecutor LinkedBlockingQueue TimeUnit]
8 | [org.httpkit PrefixThreadFactory]))
9 |
10 | (defn get-version []
11 | (or (System/getProperty "clj-airbrake.version")
12 | (let [props (doto (java.util.Properties.)
13 | (.load (jio/reader (jio/resource "META-INF/maven/clj-airbrake/clj-airbrake/pom.properties"))))]
14 | (.getProperty props "version"))))
15 |
16 | (defn get-operating-system []
17 | (str (System/getProperty "os.name") " " (System/getProperty "os.version") " " (System/getProperty "os.arch")))
18 |
19 | (def version (get-version))
20 |
21 | (defn make-error [message-prefix throwable]
22 | (let [{:keys [trace-elems]} (parse-exception throwable)]
23 | {:type (.getName (type throwable))
24 | :message (str message-prefix (.getMessage throwable))
25 | :backtrace
26 | (for [{:keys [file line], :as elem} trace-elems]
27 | {:line line :file file :function (method-str elem)})}))
28 |
29 | (defn sensitive? [preds [k _]]
30 | ((apply some-fn preds) k))
31 |
32 | (defn matches? [r k]
33 | (re-find r (name k)))
34 |
35 | (defn scrub [regexes m]
36 | (let [preds (map #(partial matches? %) regexes)]
37 | (->> m
38 | (remove (partial sensitive? preds))
39 | (into {}))))
40 |
41 | (defn get-environment-variables [sensitive-environment-variables]
42 | (scrub sensitive-environment-variables (System/getenv)))
43 |
44 | (defn remove-sensitive-params [params sensitive-params]
45 | (if params
46 | (scrub sensitive-params params)
47 | {}))
48 |
49 | (defn make-notice [throwable {:keys [message-prefix context session params environment-name root-directory]} sensitive-environment-variables sensitive-params]
50 | (generate-string
51 | {:notifier {:name "clj-airbrake"
52 | :version version
53 | :url "http://github.com/bmabey/clj-airbrake"}
54 | :errors [(make-error message-prefix throwable)]
55 | :context (merge {:os (get-operating-system)
56 | :language (str "Clojure-" (clojure-version))
57 | :environment environment-name
58 | :rootDirectory root-directory}
59 | context)
60 | :environment (get-environment-variables sensitive-environment-variables)
61 | :session (or session {})
62 | :params (remove-sensitive-params params sensitive-params)}))
63 |
64 | (defn- get-url [host project api-key]
65 | (str "https://" host "/api/v3/projects/" project "/notices?key=" api-key))
66 |
67 | (defn handle-response [response]
68 | (-> response :body (parse-string true)))
69 |
70 | (def thread-pool
71 | (let [max (.availableProcessors (Runtime/getRuntime))
72 | queue (LinkedBlockingQueue.)
73 | factory (PrefixThreadFactory. "airbrake-worker-")]
74 | (ThreadPoolExecutor. max max 60 TimeUnit/SECONDS queue factory)))
75 |
76 | (defn send-notice-async [notice callback host project api-key]
77 | (httpclient/post (get-url host project api-key)
78 | {:body notice
79 | :headers {"Content-Type" "application/json"}
80 | :worker-pool thread-pool}
81 | #(-> % handle-response callback)))
82 |
83 | (defn is-ignored-environment? [environment ignored-environments]
84 | (if (coll? ignored-environments)
85 | (get ignored-environments environment)))
86 |
87 | (defn validate-config [{:keys [environment-name api-key project] :as config}]
88 | ;; Pull in Schema or another validation library?
89 | (if (or (s/blank? api-key)
90 | (s/blank? project))
91 | (throw (IllegalArgumentException. "Airbrake configuration must contain non-empty 'api-key' and 'project'"))))
92 |
93 | (def defaults
94 | {:host "airbrake.io"
95 | :ignored-environments #{"test" "development"}
96 | :sensitive-environment-variables [#"(?i)PASS" #"(?i)SECRET" #"(?i)TOKEN" #"(?i)AWS_ACCESS_KEY_ID" #"(?i)AWS_SECRET_ACCESS_KEY"]
97 | :sensitive-params [#"(?i)pass"]})
98 |
99 | (defn notify-async
100 | ([airbrake-config callback throwable]
101 | (notify-async airbrake-config callback throwable {}))
102 | ([airbrake-config callback throwable extra-data]
103 | (let [{:keys [environment-name api-key project host ignored-environments root-directory sensitive-environment-variables sensitive-params]} (merge defaults airbrake-config)
104 | notice-data (merge extra-data {:environment-name environment-name :root-directory root-directory})]
105 | (validate-config airbrake-config)
106 | (if (is-ignored-environment? environment-name ignored-environments)
107 | (future nil)
108 | (-> (make-notice throwable notice-data sensitive-environment-variables sensitive-params)
109 | (send-notice-async callback host project api-key))))))
110 |
111 | (defn notify
112 | ([airbrake-config]
113 | (partial notify airbrake-config))
114 | ([airbrake-config throwable]
115 | (notify airbrake-config throwable {}))
116 | ([airbrake-config throwable extra-data]
117 | @(notify-async airbrake-config identity throwable extra-data)))
118 |
119 | (defmacro def-notify [name airbrake-config]
120 | `(def ~name (notify ~airbrake-config)))
121 |
122 | (defmacro with-airbrake [airbrake-config extra-data & body]
123 | `(try
124 | ~@body
125 | (catch Throwable t#
126 | ;; should we log here?
127 | (notify ~airbrake-config
128 | t#
129 | ~extra-data)
130 | (throw t#))))
131 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # clj-airbrake
2 |
3 | [](https://clojars.org/clj-airbrake)
4 |
5 | Clojure client for the [Airbrake API](http://www.airbrakeapp.com/pages/home)
6 |
7 | ## Usage
8 |
9 | ```clojure
10 | (require '[clj-airbrake.core :as airbrake])
11 |
12 | (def airbrake-config ..)
13 |
14 | (def request {:context {:url "http://example.com"
15 | :component "foo"
16 | :action "bar"
17 | :headers {"SERVER_NAME" "nginx", "HTTP_USER_AGENT" "Mozilla"}}
18 | :params {"city" "LA", "state" "CA"}
19 | :session {"user-id" "23"}})
20 |
21 | (def exception (try (throw (Exception. "Foo")) (catch Exception e e)) ; throw to get a stacktrace
22 |
23 | ;; blocking notify
24 | (airbrake/notify airbrake-configuration exception request)
25 | => {:error-id 42 :id 100 :url "http://sub.airbrakeapp.com/errors/42/notices/100"}
26 |
27 | ;; async notify
28 | (airbrake/notify-async airbrake-configuration (fn [resp] ...) exception request)
29 |
30 | ;; wrapper shorthand
31 | (airbrake/with-airbrake airbrake-configuration
32 | request
33 | ;; your code goes here
34 | )
35 | ```
36 |
37 | Note: `notify` and `notify-async` use http-kit to send the notice. To avoid possible deadlocks with other requests when sending the notice (see issue #30), we create our own threadpool for http-kit to use rather than using the default threadpool.
38 |
39 | ## Airbrake configuration
40 |
41 | Below is an example of the `airbrake-configuration`:
42 |
43 | ```clojure
44 | {
45 | :api-key "API_KEY" ;required
46 | :project "PROJECT_ID" ;required
47 | :host ;optional
48 | :environment-name "env" ;optional
49 | :root-dirctory "/app/dir" ;optional
50 |
51 | :ignored-environments #{"test" "development"} ;optional but defaults to 'development' and 'test'
52 | :sensitive-environment-variables [#"pass"] ;optional
53 | :sensitive-params [#"pass"] ;optional
54 | }
55 | ```
56 | Both `api-key` and `project` can both be found in the settings for your project in the Airbrake website.
57 |
58 | ### Senstive data
59 |
60 | There are times that your environment and params may contain sensitive data (e.g. passwords) that you don't want sent to airbrake. We try to provide sensible defaults for this but can be configured throught the keys `sensitive-environment-variables` and `sensitive-params`. Both values are a vector of regexes that will be tested against the keys in your environment variable and params.
61 |
62 | ### Currying
63 |
64 | Unsurprisingly, passing the configuration to `notify` for every single call could be painful. So a convinience macro is provided.
65 |
66 | ```clojure
67 | (def airbrake-config {:api-key "api-key" :project "project"})
68 |
69 | (def-notify my-notify airbrake-config)
70 |
71 | (my-notify (Exception. "Something went wrong."))
72 | ```
73 |
74 | Notify is also overloaded so if you just pass the airbrake configuration it will return a function that can be used to send a notification.
75 |
76 |
77 | ## Request
78 |
79 | Optionally you can pass a 3rd parameter to `notify` and 4th parameter to `notify-async`
80 |
81 | This parameter must be a map and will look for three keys in this map: `session`, `params`, and `context`
82 |
83 | `session` and `params` are expected to be maps with any keys.
84 |
85 | ### Context
86 | Context can contain the following keys:
87 | ```clojure
88 | {
89 | :environment "" ; will default to `environment-name` from configuration
90 | :rootDirectory "" ; will default to `root-directory` from configuration
91 | :os "" ; will look up operating system
92 | :language "" ; will default to "Clojure-1.7.0" (or whichever version of Clojure you're running)
93 | :component ""
94 | :action ""
95 | :version ""
96 | :url ""
97 | :userAgent ""
98 | :userId ""
99 | :userUsername ""
100 | :userName ""
101 | :userEmail ""
102 | }
103 | ```
104 |
105 | More information about what can be passed to Airbrake can be found in the Airbrake documentation - https://airbrake.io/docs/#create-notice-v3
106 |
107 | ## Ring Middleware
108 |
109 |
110 | Basic support for Ring is provided in the `clj-airbrake.ring` namespace: request parameters and session information are passed to Airbrake. A simple ring example:
111 |
112 | ```clojure
113 | (use 'ring.adapter.jetty)
114 | (use 'ring.middleware.params)
115 | (use 'ring.middleware.stacktrace)
116 | (use 'clj-airbrake.ring)
117 |
118 | (defn app [req]
119 | {:status 200
120 | :headers {"Content-Type" "text/html"}
121 | :body (throw (Exception. "Testing"))})
122 |
123 | (run-jetty (-> app
124 | (wrap-params)
125 | (wrap-airbrake airbrake-configuration)
126 | (wrap-stacktrace))
127 | {:port 8080})
128 | ```
129 |
130 | ## Installation
131 |
132 | `clj-airbrake` is available as a Maven artifact [Clojars](http://clojars.org/clj-airbrake).
133 |
134 |
135 | Leiningen:
136 |
137 | ```clojure
138 | :dependencies
139 | [[clj-airbrake "3.0.3"] ...]
140 | ```
141 | Maven:
142 |
143 |
144 | clj-airbrake
145 | clj-airbrake
146 | 3.0.3
147 |
148 |
149 |
150 | ## Development
151 |
152 | Running the tests:
153 |
154 | $ lein deps
155 | $ lein test
156 |
157 |
158 | ## TODO
159 |
160 | * Param filtering. (e.g. automatically filter out any 'password' params)
161 |
162 | ## License
163 |
164 | Copyright (C) 2010 LeadTune and Ben Mabey
165 |
166 | Released under the MIT License:
167 |
168 | [ring]: https://github.com/ring-clojure/ring/wiki
169 |
--------------------------------------------------------------------------------