diff --git a/backend/deps.edn b/backend/deps.edn index 28ab1952e..202ffcee5 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -46,6 +46,8 @@ org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} integrant/integrant {:mvn/version "0.8.0"} + io.sentry/sentry {:mvn/version "5.1.2"} + software.amazon.awssdk/s3 {:mvn/version "2.17.40"}} :paths ["src" "resources"] diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index e43ca8aad..643517bbe 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -176,6 +176,11 @@ (s/def ::telemetry-with-taiga ::us/boolean) (s/def ::tenant ::us/string) +(s/def ::sentry-trace-sample-rate ::us/number) +(s/def ::sentry-attach-stack-trace ::us/boolean) +(s/def ::sentry-debug ::us/boolean) +(s/def ::sentry-dsn ::us/string) + (s/def ::config (s/keys :opt-un [::secret-key ::flags @@ -235,6 +240,10 @@ ::registration-enabled ::rlimits-image ::rlimits-password + ::sentry-dsn + ::sentry-debug + ::sentry-attach-stack-trace + ::sentry-trace-sample-rate ::smtp-default-from ::smtp-default-reply-to ::smtp-enabled diff --git a/backend/src/app/http/errors.clj b/backend/src/app/http/errors.clj index 753c218d8..54e9ece86 100644 --- a/backend/src/app/http/errors.clj +++ b/backend/src/app/http/errors.clj @@ -7,31 +7,49 @@ (ns app.http.errors "A errors handling for the http server." (:require + [app.common.data :as d] [app.common.exceptions :as ex] [app.common.uuid :as uuid] [app.util.logging :as l] - [cuerdas.core :as str] - [expound.alpha :as expound])) + [clojure.pprint] + [cuerdas.core :as str])) -(defn- explain-error - [error] - (with-out-str - (expound/printer (:data error)))) +(defn- parse-client-ip + [{:keys [headers] :as request}] + (or (some-> (get headers "x-forwarded-for") (str/split ",") first) + (get headers "x-real-ip") + (get request :remote-addr))) + +(defn- stringify-data + [data] + (binding [clojure.pprint/*print-right-margin* 200] + (let [result (with-out-str (clojure.pprint/pprint data))] + (str/prune result (* 1024 1024) "[...]")))) (defn get-error-context [request error] - (let [edata (ex-data error)] - (merge - {:id (uuid/next) - :path (:uri request) - :method (:request-method request) - :params (:params request) - :data edata} + (let [data (ex-data error)] + (d/without-nils + (merge + {:id (str (uuid/next)) + :path (str (:uri request)) + :method (name (:request-method request)) + :hint (or (:hint data) (ex-message error)) + :params (stringify-data (:params request)) + :data (stringify-data (dissoc data :explain)) + :ip-addr (parse-client-ip request) + :explain (str/prune (:explain data) (* 1024 1024) "[...]")} + + (when-let [id (:profile-id request)] + {:profile-id id}) + (let [headers (:headers request)] {:user-agent (get headers "user-agent") :frontend-version (get headers "x-frontend-version" "unknown")}) - (when (and (map? edata) (:data edata)) - {:explain (explain-error edata)})))) + + (when (map? data) + {:error-type (:type data) + :error-code (:code data)}))))) (defmulti handle-exception (fn [err & _rest] @@ -43,7 +61,6 @@ [err _] {:status 401 :body (ex-data err)}) - (defmethod handle-exception :restriction [err _] {:status 400 :body (ex-data err)}) @@ -57,13 +74,10 @@ {:status 400 :headers {"content-type" "text/html"} :body (str "
" - (explain-error edata) + (:explain edata) "\n")} {:status 400 - :body (cond-> edata - (map? (:data edata)) - (-> (assoc :explain (explain-error edata)) - (dissoc :data)))}))) + :body (dissoc edata :data)}))) (defmethod handle-exception :assertion [error request] @@ -77,9 +91,7 @@ {:status 500 :body {:type :server-error :code :assertion - :data (-> edata - (assoc :explain (explain-error edata)) - (dissoc :data))}})) + :data (dissoc edata :data)}})) (defmethod handle-exception :not-found [err _] diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj new file mode 100644 index 000000000..a4cc8af7a --- /dev/null +++ b/backend/src/app/loggers/database.clj @@ -0,0 +1,125 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.loggers.database + "A specific logger impl that persists errors on the database." + (:require + [app.common.exceptions :as ex] + [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.util.async :as aa] + [app.util.logging :as l] + [app.util.template :as tmpl] + [app.worker :as wrk] + [clojure.core.async :as a] + [clojure.java.io :as io] + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [integrant.core :as ig])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Error Listener +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(declare handle-event) + +(defonce enabled (atom true)) + +(defn- persist-on-database! + [{:keys [pool] :as cfg} {:keys [id] :as event}] + (db/with-atomic [conn pool] + (db/insert! conn :server-error-report + {:id id :content (db/tjson event)}))) + +(defn- parse-context + [event] + (reduce-kv + (fn [acc k v] + (cond + (= k :id) (assoc acc k (uuid/uuid v)) + (= k :profile-id) (assoc acc k (uuid/uuid v)) + (str/blank? v) acc + :else (assoc acc k v))) + {} + (:context event))) + +(defn parse-event + [event] + (-> (parse-context event) + (merge (dissoc event :context)) + (assoc :tenant (cf/get :tenant)) + (assoc :host (cf/get :host)) + (assoc :public-uri (cf/get :public-uri)) + (assoc :version (:full cf/version)))) + +(defn handle-event + [{:keys [executor] :as cfg} event] + (aa/with-thread executor + (try + (let [event (parse-event event)] + (persist-on-database! cfg event)) + (catch Exception e + (l/warn :hint "unexpected exception on database error logger" + :cause e))))) + +(defmethod ig/pre-init-spec ::reporter [_] + (s/keys :req-un [::wrk/executor ::db/pool ::receiver])) + +(defmethod ig/init-key ::reporter + [_ {:keys [receiver] :as cfg}] + (l/info :msg "initializing database error persistence") + (let [output (a/chan (a/sliding-buffer 128) + (filter #(= (:level %) "error")))] + (receiver :sub output) + (a/go-loop [] + (let [msg (a/ (io/resource "error-report.tmpl") + (tmpl/render content)))] + + + (fn [request] + (let [result (some-> (parse-id request) + (retrieve-report) + (render-template))] + (if result + {:status 200 + :headers {"content-type" "text/html; charset=utf-8"} + :body result} + {:status 404 + :body "not found"}))))) diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index f4a60e5c1..f253973ca 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -7,32 +7,51 @@ (ns app.loggers.mattermost "A mattermost integration for error reporting." (:require - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.common.uuid :as uuid] - [app.config :as cfg] + [app.config :as cf] [app.db :as db] + [app.loggers.database :as ldb] [app.util.async :as aa] [app.util.http :as http] [app.util.json :as json] [app.util.logging :as l] - [app.util.template :as tmpl] [app.worker :as wrk] [clojure.core.async :as a] - [clojure.java.io :as io] [clojure.spec.alpha :as s] - [cuerdas.core :as str] [integrant.core :as ig])) -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Error Listener -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(defonce enabled (atom true)) -(declare handle-event) +(defn- send-mattermost-notification! + [cfg {:keys [host id public-uri] :as event}] + (try + (let [uri (:uri cfg) + text (str "Exception on (host: " host ", url: " public-uri "/dbg/error-by-id/" id ")\n" + (when-let [pid (:profile-id event)] + (str "- profile-id: #uuid-" pid "\n"))) + rsp (http/send! {:uri uri + :method :post + :headers {"content-type" "application/json"} + :body (json/encode-str {:text text})})] + (when (not= (:status rsp) 200) + (l/error :hint "error on sending data to mattermost" + :response (pr-str rsp)))) -(defonce enabled-mattermost (atom true)) + (catch Exception e + (l/error :hint "unexpected exception on error reporter" + :cause e)))) -(s/def ::uri ::us/string) +(defn handle-event + [{:keys [executor] :as cfg} event] + (aa/with-thread executor + (try + (let [event (ldb/parse-event event)] + (when @enabled + (send-mattermost-notification! cfg event))) + (catch Exception e + (l/warn :hint "unexpected exception on error reporter" :cause e))))) + + +(s/def ::uri ::cf/error-report-webhook) (defmethod ig/pre-init-spec ::reporter [_] (s/keys :req-un [::wrk/executor ::db/pool ::receiver] @@ -58,95 +77,3 @@ [_ output] (when output (a/close! output))) - -(defn- send-mattermost-notification! - [cfg {:keys [host id] :as cdata}] - (try - (let [uri (:uri cfg) - text (str "Unhandled exception (host: " host ", url: " (cfg/get :public-uri) "/dbg/error-by-id/" id "\n" - "- profile-id: #" (:profile-id cdata) "\n") - rsp (http/send! {:uri uri - :method :post - :headers {"content-type" "application/json"} - :body (json/encode-str {:text text})})] - (when (not= (:status rsp) 200) - (l/error :hint "error on sending data to mattermost" - :response (pr-str rsp)))) - - (catch Exception e - (l/error :hint "unexpected exception on error reporter" - :cause e)))) - -(defn- persist-on-database! - [{:keys [pool] :as cfg} {:keys [id] :as cdata}] - (db/with-atomic [conn pool] - (db/insert! conn :server-error-report - {:id id :content (db/tjson cdata)}))) - -(defn- parse-context - [event] - (reduce-kv - (fn [acc k v] - (cond - (= k :id) (assoc acc k (uuid/uuid v)) - (= k :profile-id) (assoc acc k (uuid/uuid v)) - (str/blank? v) acc - :else (assoc acc k v))) - {:id (uuid/next)} - (:context event))) - -(defn- parse-event - [event] - (-> (parse-context event) - (merge (dissoc event :context)) - (assoc :tenant (cfg/get :tenant)) - (assoc :host (cfg/get :host)) - (assoc :public-uri (cfg/get :public-uri)) - (assoc :version (:full cfg/version)))) - -(defn handle-event - [{:keys [executor] :as cfg} event] - (aa/with-thread executor - (try - (let [cdata (parse-event event)] - (when @enabled-mattermost - (send-mattermost-notification! cfg cdata)) - (persist-on-database! cfg cdata)) - (catch Exception e - (l/error :hint "unexpected exception on error reporter" - :cause e))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Http Handler -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defmethod ig/pre-init-spec ::handler [_] - (s/keys :req-un [::db/pool])) - -(defmethod ig/init-key ::handler - [_ {:keys [pool] :as cfg}] - (letfn [(parse-id [request] - (let [id (get-in request [:path-params :id]) - id (us/uuid-conformer id)] - (when (uuid? id) - id))) - (retrieve-report [id] - (ex/ignoring - (when-let [{:keys [content] :as row} (db/get-by-id pool :server-error-report id)] - (assoc row :content (db/decode-transit-pgobject content))))) - - (render-template [{:keys [content] :as report}] - (some-> (io/resource "error-report.tmpl") - (tmpl/render content)))] - - - (fn [request] - (let [result (some-> (parse-id request) - (retrieve-report) - (render-template))] - (if result - {:status 200 - :headers {"content-type" "text/html; charset=utf-8"} - :body result} - {:status 404 - :body "not found"}))))) diff --git a/backend/src/app/loggers/sentry.clj b/backend/src/app/loggers/sentry.clj new file mode 100644 index 000000000..b1d4b1d1d --- /dev/null +++ b/backend/src/app/loggers/sentry.clj @@ -0,0 +1,172 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.loggers.sentry + "A mattermost integration for error reporting." + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.util.async :as aa] + [app.util.logging :as l] + [app.worker :as wrk] + [clojure.core.async :as a] + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [integrant.core :as ig]) + (:import + io.sentry.Scope + io.sentry.IHub + io.sentry.Hub + io.sentry.NoOpHub + io.sentry.protocol.User + io.sentry.SentryOptions + io.sentry.SentryLevel + io.sentry.ScopeCallback)) + +(defonce enabled (atom true)) + +(defn- parse-context + [event] + (reduce-kv + (fn [acc k v] + (cond + (= k :id) (assoc acc k (uuid/uuid v)) + (= k :profile-id) (assoc acc k (uuid/uuid v)) + (str/blank? v) acc + :else (assoc acc k v))) + {} + (:context event))) + +(defn- parse-event + [event] + (assoc event :context (parse-context event))) + +(defn- build-sentry-options + [cfg] + (let [version (:base cf/version)] + (doto (SentryOptions.) + (.setDebug (:debug cfg false)) + (.setTracesSampleRate (:traces-sample-rate cfg 1.0)) + (.setDsn (:dsn cfg)) + (.setServerName (cf/get :host)) + (.setEnvironment (cf/get :tenant)) + (.setAttachServerName true) + (.setAttachStacktrace (:attach-stack-trace cfg false)) + (.setRelease (str "backend@" (if (= version "0.0.0") "develop" version)))))) + +(defn handle-event + [^IHub shub event] + (letfn [(set-user! [^Scope scope {:keys [context] :as event}] + (let [user (User.)] + (.setIpAddress ^User user ^String (:ip-addr context)) + (when-let [pid (:profile-id context)] + (.setId ^User user ^String (str pid))) + (.setUser scope ^User user))) + + (set-level! [^Scope scope] + (.setLevel scope SentryLevel/ERROR)) + + (set-context! [^Scope scope {:keys [context] :as event}] + (let [uri (str (cf/get :public-uri) "/dbg/error-by-id/" (:id context))] + (.setContexts scope "detailed_error_uri" ^String uri)) + (when-let [vers (:frontend-version event)] + (.setContexts scope "frontend_version" ^String vers)) + (when-let [puri (:public-uri event)] + (.setContexts scope "public_uri" ^String (str puri))) + (when-let [uagent (:user-agent context)] + (.setContexts scope "user_agent" ^String uagent)) + (when-let [tenant (:tenant event)] + (.setTag scope "tenant" ^String tenant)) + (when-let [type (:error-type context)] + (.setTag scope "error_type" ^String (str type))) + (when-let [code (:error-code context)] + (.setTag scope "error_code" ^String (str code))) + ) + + (capture [^Scope scope {:keys [context error] :as event}] + (let [msg (str (:message error) "\n\n" + + "======================================================\n" + "=================== Params ===========================\n" + "======================================================\n" + + (:params context) "\n" + + (when (:explain context) + (str "======================================================\n" + "=================== Explain ==========================\n" + "======================================================\n" + (:explain context) "\n")) + + (when (:data context) + (str "======================================================\n" + "=================== Error Data =======================\n" + "======================================================\n" + (:data context) "\n")) + + (str "======================================================\n" + "=================== Stack Trace ======================\n" + "======================================================\n" + (:trace error)) + + "\n")] + (set-user! scope event) + (set-level! scope) + (set-context! scope event) + (.captureMessage ^IHub shub msg) + )) + ] + ;; (clojure.pprint/pprint event) + + (when @enabled + (.withScope ^IHub shub (reify ScopeCallback + (run [_ scope] + (->> event + (parse-event) + (capture scope)))))) + + )) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Error Listener +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(s/def ::receiver any?) +(s/def ::dsn ::cf/sentry-dsn) +(s/def ::trace-sample-rate ::cf/sentry-trace-sample-rate) +(s/def ::attach-stack-trace ::cf/sentry-attach-stack-trace) +(s/def ::debug ::cf/sentry-debug) + +(defmethod ig/pre-init-spec ::reporter [_] + (s/keys :req-un [::wrk/executor ::db/pool ::receiver] + :opt-un [::dsn ::trace-sample-rate ::attach-stack-trace])) + +(defmethod ig/init-key ::reporter + [_ {:keys [receiver dsn executor] :as cfg}] + (l/info :msg "initializing sentry reporter" :dsn dsn) + (let [opts (build-sentry-options cfg) + shub (if dsn + (Hub. ^SentryOptions opts) + (NoOpHub/getInstance)) + output (a/chan (a/sliding-buffer 128) + (filter #(= (:level %) "error")))] + (receiver :sub output) + (a/go-loop [] + (let [event (a/ params + (dissoc :cause) + (dissoc :message) + (assoc :hint message))] (ex-info message payload cause))) (defmacro raise diff --git a/common/src/app/common/spec.cljc b/common/src/app/common/spec.cljc index b4823ce9e..88f8eab9a 100644 --- a/common/src/app/common/spec.cljc +++ b/common/src/app/common/spec.cljc @@ -206,7 +206,7 @@ :name (pr-str spec) :line (:line &env) :file (:file (:meta nsdata))}) - message (str "Spec Assertion: '" (pr-str spec) "'")] + message (str "spec assert: '" (pr-str spec) "'")] `(spec-assert* ~spec ~x ~message ~context)))) (defmacro verify @@ -218,7 +218,7 @@ :name (pr-str spec) :line (:line &env) :file (:file (:meta nsdata))}) - message (str "Spec Assertion: '" (pr-str spec) "'")] + message (str "spec verify: '" (pr-str spec) "'")] `(spec-assert* ~spec ~x ~message ~context))) ;; --- Public Api