🎉 Add better error reporting.

This commit is contained in:
Andrey Antukh 2021-01-24 16:04:23 +01:00 committed by Alonso Torres
parent 1b70283c3a
commit 04b0cf6330
10 changed files with 333 additions and 77 deletions

View file

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>penpot - error report {{id}}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono">
<style>
body {
margin: 0px;
padding: 0px;
}
pre {
margin: 0px;
}
* {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
}
.table {
display: flex;
flex-direction: column;
margin: 10px;
}
.table-row {
display: flex;
/* width: 100%; */
/* border: 1px solid red; */
}
.table-key {
font-weight: 600;
width: 70px;
padding: 4px;
}
.table-val {
font-weight: 200;
color: #333;
padding: 4px;
}
.multiline {
margin-top: 15px;
flex-direction: column;
}
.multiline .table-key {
margin-bottom: 10px;
border-bottom: 1px dashed #dddddd;
/* padding: 4px; */
width: unset;
}
</style>
</head>
<body>
<div class="table">
<div class="table-row">
<div class="table-key">ERID: </div>
<div class="table-val">{{id}}</div>
</div>
<div class="table-row">
<div class="table-key">VERS: </div>
<div class="table-val">{{version}}</div>
</div>
<div class="table-row">
<div class="table-key">HOST: </div>
<div class="table-val">{{host}}</div>
</div>
{% if type %}
<div class="table-row">
<div class="table-key">TYPE: </div>
<div class="table-val">{{type}}</div>
</div>
{% endif %}
{% if code %}
<div class="table-row">
<div class="table-key">CODE: </div>
<div class="table-val">{{code}}</div>
</div>
{% endif %}
<div class="table-row">
<div class="table-key">CLASS: </div>
<div class="table-val">{{class}}</div>
</div>
<div class="table-row">
<div class="table-key">HINT: </div>
<div class="table-val">{{hint}}</div>
</div>
<div class="table-row">
<div class="table-key">PATH: </div>
<div class="table-val">{{method|upper}} {{path}}</div>
</div>
{% if params %}
<div class="table-row multiline">
<div class="table-key">PARAMS: </div>
<div class="table-val">
<pre>{{params}}</pre>
</div>
</div>
{% endif %}
{% if explain %}
<div class="table-row multiline">
<div class="table-key">EXPLAIN: </div>
<div class="table-val">
<pre>{{explain}}</pre>
</div>
</div>
{% endif %}
{% if data %}
<div class="table-row multiline">
<div class="table-key">EDATA: </div>
<div class="table-val">
<pre>{{data}}</pre>
</div>
</div>
{% endif %}
<div class="table-row multiline">
<div class="table-key">TRACE:</div>
<div class="table-val">
<pre>{{message}}</pre>
</div>
</div>
</div>
</body>
</html>

View file

@ -26,7 +26,7 @@
:secret-key "default" :secret-key "default"
:asserts-enabled true :asserts-enabled true
:public-uri "http://localhost:3449/" :public-uri "http://localhost:3449"
:redis-uri "redis://localhost/0" :redis-uri "redis://localhost/0"
:storage-backend :fs :storage-backend :fs

View file

@ -12,67 +12,94 @@
(:require (:require
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cfg] [app.config :as cfg]
[app.db :as db] [app.db :as db]
[app.tasks :as tasks] [app.tasks :as tasks]
[app.worker :as wrk] [app.worker :as wrk]
[app.util.json :as json] [app.util.json :as json]
[app.util.http :as http] [app.util.http :as http]
[app.util.template :as tmpl]
[clojure.pprint :refer [pprint]]
[clojure.core.async :as a] [clojure.core.async :as a]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[clojure.java.io :as io]
[cuerdas.core :as str] [cuerdas.core :as str]
[integrant.core :as ig] [integrant.core :as ig]
[promesa.exec :as px])) [promesa.exec :as px])
(:import
org.apache.logging.log4j.core.LogEvent
org.apache.logging.log4j.util.ReadOnlyStringMap))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Error Reporting ;; Error Listener
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare send-notification!) (declare handle-event)
(defonce queue-fn identity)
(defonce queue (a/chan (a/sliding-buffer 64)))
(defonce queue-fn (fn [event] (a/>!! queue event)))
(s/def ::uri ::us/string) (s/def ::uri ::us/string)
(defmethod ig/pre-init-spec ::instance [_]
(s/keys :req-un [::wrk/executor] (defmethod ig/pre-init-spec ::reporter [_]
(s/keys :req-un [::wrk/executor ::db/pool]
:opt-un [::uri])) :opt-un [::uri]))
(defmethod ig/init-key ::instance (defmethod ig/init-key ::reporter
[_ {:keys [executor uri] :as cfg}] [_ {:keys [executor uri] :as cfg}]
(let [out (a/chan (a/sliding-buffer 64))] (log/info "Intializing error reporter.")
(log/info "Intializing error reporter.") (let [close-ch (a/chan 1)]
(if uri (a/go-loop []
(do (let [[val port] (a/alts! [close-ch queue])]
(alter-var-root #'queue-fn (constantly (fn [x] (a/>!! out (str x))))) (cond
(a/go-loop [] (= port close-ch)
(let [val (a/<! out)] (log/info "Stoping error reporting loop.")
(if (nil? val)
(log/info "Closing error reporting loop.")
(do
(px/run! executor #(send-notification! cfg val))
(recur))))))
(log/info "No webhook uri is provided (error reporting becomes noop)."))
out))
(defmethod ig/halt-key! ::instance (nil? val)
[_ out] (log/info "Stoping error reporting loop.")
(alter-var-root #'queue-fn (constantly identity))
(a/close! out))
(defn send-notification! :else
[cfg report] (do
(px/run! executor #(handle-event cfg val))
(recur)))))
close-ch))
(defmethod ig/halt-key! ::reporter
[_ close-ch]
(a/close! close-ch))
(defn- get-context-data
[event]
(let [^LogEvent levent (deref event)
^ReadOnlyStringMap rosm (.getContextData levent)]
(into {:message (str event)}
(comp
(map (fn [[key val]]
(cond
(= "id" key) [:id (uuid/uuid val)]
(= "profile-id" key) [:profile-id (uuid/uuid val)]
(str/blank? val) nil
(string? key) [(keyword key) val]
:else [key val])))
(filter some?))
(.toMap rosm))))
(defn- send-mattermost-notification!
[cfg {:keys [message host version id] :as cdata}]
(try (try
(let [uri (:uri cfg) (let [uri (:uri cfg)
prefix (str/<< "Unhandled exception (@channel):\n" prefix (str/<< "Unhandled exception (@channel):\n"
"- host: `~(:host cfg/config)`\n" "- detail: ~(:public-uri cfg/config)/dbg/error-by-id/~{id}\n"
"- version: `~(:full cfg/version)`") "- host: `~{host}`\n"
text (str prefix "\n```\n" report "\n```") "- version: `~{version}`\n")
text (str prefix "```\n" message "\n```")
rsp (http/send! {:uri uri rsp (http/send! {:uri uri
:method :post :method :post
:headers {"content-type" "application/json"} :headers {"content-type" "application/json"}
:body (json/encode-str {:text text})})] :body (json/encode-str {:text text})})]
(when (not= (:status rsp) 200) (when (not= (:status rsp) 200)
(log/warnf "Error reporting webhook replying with unexpected status: %s\n%s" (log/warnf "Error reporting webhook replying with unexpected status: %s\n%s"
(:status rsp) (:status rsp)
@ -80,3 +107,54 @@
(catch Exception e (catch Exception e
(log/warnf e "Unexpected exception on error reporter.")))) (log/warnf e "Unexpected exception on error reporter."))))
(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 handle-event
[cfg event]
(try
(let [cdata (get-context-data event)]
(when (:uri cfg)
(send-mattermost-notification! cfg cdata))
(persist-on-database! cfg cdata))
(catch Exception e
(log/warnf e "Unexpected exception on error reporter."))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; 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"})))))

View file

@ -9,17 +9,18 @@
(ns app.http (ns app.http
(:require (:require
[app.common.spec :as us]
[app.common.data :as d] [app.common.data :as d]
[app.common.spec :as us]
[app.common.uuid :as uuid]
[app.config :as cfg] [app.config :as cfg]
[app.http.assets :as assets]
[app.http.auth :as auth] [app.http.auth :as auth]
[app.http.errors :as errors] [app.http.errors :as errors]
[app.http.middleware :as middleware] [app.http.middleware :as middleware]
[app.http.assets :as assets]
[app.metrics :as mtx] [app.metrics :as mtx]
[clojure.spec.alpha :as s]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[integrant.core :as ig] [integrant.core :as ig]
[clojure.spec.alpha :as s]
[reitit.ring :as rr] [reitit.ring :as rr]
[ring.adapter.jetty9 :as jetty]) [ring.adapter.jetty9 :as jetty])
(:import (:import
@ -101,12 +102,11 @@
(try (try
(handler request) (handler request)
(catch Exception e (catch Exception e
(log/errorf e (let [cdata (errors/get-error-context request e)]
(str "Unhandled exception: " (ex-message e) "\n" (errors/update-thread-context! cdata)
"=| uri: " (pr-str (:uri request)) "\n" (log/errorf e "Unhandled exception: %s (id: %s)" (ex-message e) (str (:id cdata)))
"=| method: " (pr-str (:request-method request)) "\n")) {:status 500
{:status 500 :body "internal server error"}))))))
:body "internal server error"})))))
(defn- create-router (defn- create-router
[{:keys [session rpc google-auth gitlab-auth github-auth metrics ldap-auth storage svgparse] :as cfg}] [{:keys [session rpc google-auth gitlab-auth github-auth metrics ldap-auth storage svgparse] :as cfg}]
@ -119,12 +119,15 @@
["/by-file-media-id/:id" {:get #(assets/file-objects-handler storage %)}] ["/by-file-media-id/:id" {:get #(assets/file-objects-handler storage %)}]
["/by-file-media-id/:id/thumbnail" {:get #(assets/file-thumbnails-handler storage %)}]] ["/by-file-media-id/:id/thumbnail" {:get #(assets/file-thumbnails-handler storage %)}]]
["/dbg"
["/error-by-id/:id" {:get (:error-reporter-handler cfg)}]]
["/api" {:middleware [[middleware/format-response-body] ["/api" {:middleware [[middleware/format-response-body]
[middleware/parse-request-body] [middleware/parse-request-body]
[middleware/errors errors/handle]
[middleware/params] [middleware/params]
[middleware/multipart-params] [middleware/multipart-params]
[middleware/keyword-params] [middleware/keyword-params]
[middleware/errors errors/handle]
[middleware/cookies]]} [middleware/cookies]]}
["/svg" {:post svgparse}] ["/svg" {:post svgparse}]

View file

@ -10,27 +10,50 @@
(ns app.http.errors (ns app.http.errors
"A errors handling for the http server." "A errors handling for the http server."
(:require (:require
[clojure.pprint :refer [pprint]]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.uuid :as uuid]
[app.config :as cfg]
[clojure.pprint :refer [pprint]]
[clojure.tools.logging :as log] [clojure.tools.logging :as log]
[cuerdas.core :as str] [cuerdas.core :as str]
[expound.alpha :as expound])) [expound.alpha :as expound])
(:import
org.apache.logging.log4j.ThreadContext))
(defn get-context-string (defn update-thread-context!
[request edata] [data]
(str "=| uri: " (pr-str (:uri request)) "\n" (run! (fn [[key val]]
"=| method: " (pr-str (:request-method request)) "\n" (ThreadContext/put
"=| params: \n" (name key)
(with-out-str (cond
(pprint (:params request))) (coll? val) (with-out-str (pprint val))
"\n" (instance? clojure.lang.Named val) (name val)
:else (str val))))
data))
(when (map? edata) (defn- explain-error
(str "=| ex-data: \n" [error]
(with-out-str (with-out-str
(pprint edata)))) (expound/printer (:data error))))
"\n")) (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)
:version (:full cfg/version)
:host (:host cfg/config)
:class (.getCanonicalName ^java.lang.Class (class error))
:hint (ex-message error)}
(when (map? edata)
edata)
(when (and (map? edata) (:data edata))
{:explain (explain-error edata)}))))
(defmulti handle-exception (defmulti handle-exception
(fn [err & _rest] (fn [err & _rest]
@ -42,11 +65,6 @@
[err _] [err _]
{:status 401 :body (ex-data err)}) {:status 401 :body (ex-data err)})
(defn- explain-error
[error]
(with-out-str
(expound/printer (:data error))))
(defmethod handle-exception :validation (defmethod handle-exception :validation
[err req] [err req]
(let [header (get-in req [:headers "accept"]) (let [header (get-in req [:headers "accept"])
@ -66,11 +84,10 @@
(defmethod handle-exception :assertion (defmethod handle-exception :assertion
[error request] [error request]
(let [edata (ex-data error)] (let [edata (ex-data error)
(log/error error cdata (get-error-context request error)]
(str "Internal error: assertion\n" (update-thread-context! cdata)
(get-context-string request edata) (log/errorf error "Internal error: assertion (id: %s)" (str (:id cdata)))
(explain-error edata)))
{:status 500 {:status 500
:body {:type :server-error :body {:type :server-error
:data (-> edata :data (-> edata
@ -83,15 +100,15 @@
(defmethod handle-exception :default (defmethod handle-exception :default
[error request] [error request]
(let [edata (ex-data error)] (let [cdata (get-error-context request error)]
(log/error error (update-thread-context! cdata)
(str "Internal Error: " (log/errorf error "Internal error: %s (id: %s)"
(ex-message error) (ex-message error)
(get-context-string request edata))) (str (:id cdata)))
{:status 500 {:status 500
:body {:type :server-error :body {:type :server-error
:hint (ex-message error) :hint (ex-message error)
:data edata}})) :data (ex-data error)}}))
(defn handle (defn handle
[error req] [error req]

View file

@ -14,7 +14,6 @@
[app.metrics :as mtx] [app.metrics :as mtx]
[app.util.transit :as t] [app.util.transit :as t]
[app.util.json :as json] [app.util.json :as json]
;; [clojure.data.json :as json]
[clojure.java.io :as io] [clojure.java.io :as io]
[ring.middleware.cookies :refer [wrap-cookies]] [ring.middleware.cookies :refer [wrap-cookies]]
[ring.middleware.keyword-params :refer [wrap-keyword-params]] [ring.middleware.keyword-params :refer [wrap-keyword-params]]

View file

@ -84,6 +84,9 @@
:github-auth (ig/ref :app.http.auth/github) :github-auth (ig/ref :app.http.auth/github)
:ldap-auth (ig/ref :app.http.auth/ldap) :ldap-auth (ig/ref :app.http.auth/ldap)
:svgparse (ig/ref :app.svgparse/handler) :svgparse (ig/ref :app.svgparse/handler)
:error-reporter-handler (ig/ref :app.error-reporter/handler)
:storage (ig/ref :app.storage/storage)} :storage (ig/ref :app.storage/storage)}
:app.svgparse/svgc :app.svgparse/svgc
@ -240,10 +243,14 @@
:app.srepl/server :app.srepl/server
{:port 6062} {:port 6062}
:app.error-reporter/instance :app.error-reporter/reporter
{:uri (:error-report-webhook cfg/config) {:uri (:error-report-webhook cfg/config)
:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor)} :executor (ig/ref :app.worker/executor)}
:app.error-reporter/handler
{:pool (ig/ref :app.db/pool)}
:app.storage/storage :app.storage/storage
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:executor (ig/ref :app.worker/executor) :executor (ig/ref :app.worker/executor)

View file

@ -131,6 +131,9 @@
{:name "0039-fix-some-on-delete-triggers" {:name "0039-fix-some-on-delete-triggers"
:fn (mg/resource "app/migrations/sql/0039-fix-some-on-delete-triggers.sql")} :fn (mg/resource "app/migrations/sql/0039-fix-some-on-delete-triggers.sql")}
{:name "0040-add-error-report-tables"
:fn (mg/resource "app/migrations/sql/0040-add-error-report-tables.sql")}
]) ])

View file

@ -0,0 +1,10 @@
CREATE TABLE server_error_report (
id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
content jsonb,
PRIMARY KEY (id, created_at)
);
ALTER TABLE server_error_report
ALTER COLUMN content SET STORAGE external;

View file

@ -99,6 +99,10 @@ http {
proxy_pass http://127.0.0.1:6060/api; proxy_pass http://127.0.0.1:6060/api;
} }
location /dbg {
proxy_pass http://127.0.0.1:6060/dbg;
}
location /export { location /export {
proxy_pass http://127.0.0.1:6061; proxy_pass http://127.0.0.1:6061;
} }