Compare commits

...

16 Commits

Author SHA1 Message Date
adam-james
ff11bd7f79 Merge branch 'master' into attach-render-errors-subscriptions 2024-06-03 08:04:14 -07:00
Adam James
39c81419e8 Add a card query error test 2024-05-28 17:28:44 -07:00
adam-james
0d95540349 Merge branch 'master' into attach-render-errors-subscriptions 2024-05-28 16:48:27 -07:00
Adam James
f3a26b5135 was using the wrong key here, not sure why, maybe it changed recently? 2024-05-28 15:32:30 -07:00
adam-james
a5c33e1756 Merge branch 'master' into attach-render-errors-subscriptions 2024-05-28 13:33:42 -07:00
Adam James
b89f86c568 add back some changes from master 2024-05-28 10:52:44 -07:00
Adam James
e108633f36 Fix lint errors 2024-05-27 16:57:54 -07:00
Adam James
a54ef9bc99 fix some code looking at messages when it should now be attachments 2024-05-27 15:48:52 -07:00
adam-james
9705d8976f Merge branch 'master' into attach-render-errors-subscriptions 2024-05-27 15:13:54 -07:00
Adam James
a222ac98c0 WIP on making the test actually capture the error behaviour
Not sure if this is actually doing what I need it to yet
2024-03-25 08:26:08 -07:00
Adam James
520b1f6320 Merge branch 'attach-render-errors-subscriptions' of https://github.com/metabase/metabase into attach-render-errors-subscriptions 2024-03-22 16:15:36 -07:00
Adam James
4a9d63ebc7 Add test that asserts error attachment exists when error occurs. 2024-03-22 16:13:22 -07:00
adam-james
e16bfa4a76 Merge branch 'master' into attach-render-errors-subscriptions 2024-03-22 13:50:16 -07:00
adam-james
d0148baa24 Merge branch 'master' into attach-render-errors-subscriptions 2024-03-21 08:57:44 -07:00
Adam James
3e5d3985d8 Fix viz-settings in error attachment 2024-03-19 15:31:36 -07:00
Adam James
6a61796277 Attach Static-viz render errors to subscription emails
Fixes: #39814

Our static-viz renders an error message into subscription emails if the render failed, but it provides no further
details as to what caused the failure.

This PR adds a .txt attachment in the case of a card render or card query failure. The contents of the text file are a
short message about the problem with the card's name, the visualization_settings that the card was rendered with, and
the stacktrace.

This should be helpful if a user is reporting a render error as a bug since this can be attached or copy/pasted into
the bug report.

The attachment was used so that the actualy body of the email remains clean/legible.
2024-03-19 15:19:43 -07:00
8 changed files with 256 additions and 101 deletions

View File

@@ -193,6 +193,7 @@
metabase.public-settings #{metabase.public-settings
metabase.public-settings.premium-features}
metabase.pulse #{metabase.pulse
metabase.pulse.attachment
metabase.pulse.markdown
metabase.pulse.parameters
metabase.pulse.preview

View File

@@ -5,14 +5,13 @@
[buddy.core.codecs :as codecs]
[cheshire.core :as json]
[clojure.core.cache :as cache]
[clojure.java.io :as io]
[clojure.string :as str]
[hiccup.core :refer [html]]
[java-time.api :as t]
[medley.core :as m]
[metabase.config :as config]
[metabase.db.query :as mdb.query]
[metabase.driver :as driver]
[metabase.driver.util :as driver.u]
[metabase.email :as email]
[metabase.lib.util :as lib.util]
[metabase.models.collection :as collection]
@@ -21,15 +20,13 @@
[metabase.models.user :refer [User]]
[metabase.public-settings :as public-settings]
[metabase.public-settings.premium-features :as premium-features]
[metabase.pulse.attachment :as attachment]
[metabase.pulse.markdown :as markdown]
[metabase.pulse.parameters :as pulse-params]
[metabase.pulse.render :as render]
[metabase.pulse.render.image-bundle :as image-bundle]
[metabase.pulse.render.js-svg :as js-svg]
[metabase.pulse.render.style :as style]
[metabase.query-processor.store :as qp.store]
[metabase.query-processor.streaming :as qp.streaming]
[metabase.query-processor.streaming.interface :as qp.si]
[metabase.query-processor.timezone :as qp.timezone]
[metabase.util :as u]
[metabase.util.date-2 :as u.date]
@@ -42,7 +39,6 @@
[stencil.loader :as stencil-loader]
[toucan2.core :as t2])
(:import
(java.io File IOException OutputStream)
(java.time LocalTime)
(java.time.format DateTimeFormatter)))
@@ -314,10 +310,15 @@
(email/send-message! message)))
(defn- make-message-attachment [[content-id url]]
{:type :inline
:content-id content-id
:content-type "image/png"
:content url})
(if (str/includes? content-id "_error")
{:type :inline
:content-id content-id
:content-type "text"
:content url}
{:type :inline
:content-id content-id
:content-type "image/png"
:content url}))
(defn- pulse-link-context
[{:keys [cards dashboard_id]}]
@@ -357,81 +358,8 @@
"&pulse-id=" (:id pulse)))}
(pulse-link-context pulse))))
(defn- create-temp-file
"Separate from `create-temp-file-or-throw` primarily so that we can simulate exceptions in tests"
[suffix]
(doto (File/createTempFile "metabase_attachment" suffix)
.deleteOnExit))
(defn- create-temp-file-or-throw
"Tries to create a temp file, will give the users a better error message if we are unable to create the temp file"
[suffix]
(try
(create-temp-file suffix)
(catch IOException e
(let [ex-msg (tru "Unable to create temp file in `{0}` for email attachments "
(System/getProperty "java.io.tmpdir"))]
(throw (IOException. ex-msg e))))))
(defn- create-result-attachment-map [export-type card-name ^File attachment-file]
(let [{:keys [content-type]} (qp.si/stream-options export-type)]
{:type :attachment
:content-type content-type
:file-name (format "%s_%s.%s"
(or card-name "query_result")
(u.date/format (t/zoned-date-time))
(name export-type))
:content (-> attachment-file .toURI .toURL)
:description (format "More results for '%s'" card-name)}))
(defn- stream-api-results-to-export-format
"For legacy compatibility. Takes QP results in the normal `:api` response format and streams them to a different
format.
TODO -- this function is provided mainly because rewriting all the Pulse/Alert code to stream results directly
was a lot of work. I intend to rework that code so we can stream directly to the correct export format(s) at some
point in the future; for now, this function is a stopgap.
Results are streamed synchronously. Caller is responsible for closing `os` when this call is complete."
[export-format format-rows? ^OutputStream os {{:keys [rows]} :data, database-id :database_id, :as results}]
;; make sure Database/driver info is available for the streaming results writers -- they might need this in order to
;; get timezone information when writing results
(driver/with-driver (driver.u/database->driver database-id)
(qp.store/with-metadata-provider database-id
(let [w (qp.si/streaming-results-writer export-format os)
cols (-> results :data :cols)
viz-settings (-> results :data :viz-settings)
[ordered-cols output-order] (qp.streaming/order-cols cols viz-settings)
viz-settings' (assoc viz-settings :output-order output-order)]
(qp.si/begin! w
(-> results
(assoc-in [:data :format-rows?] format-rows?)
(assoc-in [:data :ordered-cols] ordered-cols))
viz-settings')
(dorun
(map-indexed
(fn [i row]
(qp.si/write-row! w row i ordered-cols viz-settings'))
rows))
(qp.si/finish! w results)))))
(defn- result-attachment
[{{card-name :name format-rows :format_rows :as card} :card
{{:keys [rows]} :data :as result} :result}]
(when (seq rows)
[(when-let [temp-file (and (:include_csv card)
(create-temp-file-or-throw "csv"))]
(with-open [os (io/output-stream temp-file)]
(stream-api-results-to-export-format :csv format-rows os result))
(create-result-attachment-map "csv" card-name temp-file))
(when-let [temp-file (and (:include_xls card)
(create-temp-file-or-throw "xlsx"))]
(with-open [os (io/output-stream temp-file)]
(stream-api-results-to-export-format :xlsx format-rows os result))
(create-result-attachment-map "xlsx" card-name temp-file))]))
(defn- part-attachments [parts]
(filter some? (mapcat result-attachment parts)))
(filter some? (mapcat attachment/result-attachment parts)))
(defn- render-part
[timezone part]

View File

@@ -0,0 +1,94 @@
(ns metabase.pulse.attachment
(:require
[clojure.java.io :as io]
[java-time.api :as t]
[metabase.driver :as driver]
[metabase.driver.util :as driver.u]
[metabase.query-processor.store :as qp.store]
[metabase.query-processor.streaming :as qp.streaming]
[metabase.query-processor.streaming.interface :as qp.si]
[metabase.util.date-2 :as u.date]
[metabase.util.i18n :as i18n :refer [tru]])
(:import
(java.io File IOException OutputStream)))
(set! *warn-on-reflection* true)
(defn- create-temp-file
"Separate from `create-temp-file-or-throw` primarily so that we can simulate exceptions in tests"
[filename suffix]
(doto (File/createTempFile filename (str "." suffix))
.deleteOnExit))
(defn create-temp-file-or-throw
"Tries to create a temp file, will give the users a better error message if we are unable to create the temp file"
[filename suffix]
(try
(create-temp-file filename suffix)
(catch IOException e
(let [ex-msg (tru "Unable to create temp file in `{0}` for email attachments "
(System/getProperty "java.io.tmpdir"))]
(throw (IOException. ex-msg e))))))
(defn text-file-writer
"Writes given text to a file via the given output-stream."
[output-stream ^String text]
(let [writer (io/writer output-stream)]
(.write writer text)
(.flush writer)))
(defn- stream-api-results-to-export-format
"For legacy compatibility. Takes QP results in the normal `:api` response format and streams them to a different
format.
TODO -- this function is provided mainly because rewriting all the Pulse/Alert code to stream results directly
was a lot of work. I intend to rework that code so we can stream directly to the correct export format(s) at some
point in the future; for now, this function is a stopgap.
Results are streamed synchronously. Caller is responsible for closing `os` when this call is complete."
[export-format ^OutputStream os {{:keys [rows]} :data, database-id :database_id, :as results}]
;; make sure Database/driver info is available for the streaming results writers -- they might need this in order to
;; get timezone information when writing results
(driver/with-driver (driver.u/database->driver database-id)
(qp.store/with-metadata-provider database-id
(let [w (qp.si/streaming-results-writer export-format os)
cols (-> results :data :cols)
viz-settings (-> results :data :viz-settings)
[ordered-cols output-order] (qp.streaming/order-cols cols viz-settings)
viz-settings' (assoc viz-settings :output-order output-order)]
(qp.si/begin! w
(assoc-in results [:data :ordered-cols] ordered-cols)
viz-settings')
(dorun
(map-indexed
(fn [i row]
(qp.si/write-row! w row i ordered-cols viz-settings'))
rows))
(qp.si/finish! w results)))))
(defn- create-result-attachment-map [export-type card-name ^File attachment-file]
(let [{:keys [content-type]} (qp.si/stream-options export-type)]
{:type :attachment
:content-type content-type
:file-name (format "%s_%s.%s"
(or card-name "query_result")
(u.date/format (t/zoned-date-time))
(name export-type))
:content (-> attachment-file .toURI .toURL)
:description (format "More results for '%s'" card-name)}))
(defn result-attachment
"Creates the result attachment maps for csv and xlsx files."
[{{card-name :name :as card} :card
{{:keys [rows]} :data :as result} :result}]
(when (seq rows)
[(when-let [temp-file (and (:include_csv card)
(create-temp-file-or-throw "metabase_attachment" "csv"))]
(with-open [os (io/output-stream temp-file)]
(stream-api-results-to-export-format :csv os result))
(create-result-attachment-map "csv" card-name temp-file))
(when-let [temp-file (and (:include_xls card)
(create-temp-file-or-throw "metabase_attachment" "xlsx"))]
(with-open [os (io/output-stream temp-file)]
(stream-api-results-to-export-format :xlsx os result))
(create-result-attachment-map "xlsx" card-name temp-file))]))

View File

@@ -8,8 +8,8 @@
[hickory.core :as hik]
[hickory.render :as hik.r]
[hickory.zip :as hik.z]
[metabase.email.messages :as messages]
[metabase.pulse :as pulse]
[metabase.pulse.attachment :as attachment]
[metabase.pulse.markdown :as markdown]
[metabase.pulse.render :as render]
[metabase.pulse.render.image-bundle :as img]
@@ -37,13 +37,11 @@
(for [cell row]
[:td {:style table-style} cell])])]))
(def ^:private result-attachment #'messages/result-attachment)
(defn- render-csv-for-dashcard
[part]
(-> part
(assoc-in [:card :include_csv] true)
result-attachment
attachment/result-attachment
first
:content
slurp

View File

@@ -1,18 +1,27 @@
(ns metabase.pulse.render
(:require
[clojure.java.io :as io]
[clojure.stacktrace :as st]
[clojure.string :as str]
[hiccup.core :refer [h]]
[metabase.formatter :as formatter]
[metabase.models.dashboard-card :as dashboard-card]
[metabase.pulse.attachment :as attachment]
[metabase.pulse.markdown :as markdown]
[metabase.pulse.render.body :as body]
[metabase.pulse.render.image-bundle :as image-bundle]
[metabase.pulse.render.png :as png]
[metabase.pulse.render.style :as style]
[metabase.shared.models.visualization-settings :as mb.viz]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.urls :as urls]))
[metabase.util.urls :as urls])
(:import
(java.io File)))
(set! *warn-on-reflection* true)
(def ^:dynamic *include-buttons*
"Should the rendered pulse include buttons? (default: `false`)"
@@ -128,6 +137,30 @@
[card]
((some-fn :include_csv :include_xls) card))
(defn- error-file-attachment
[card e]
(let [filename (u/slugify (str (:name card) "_error"))]
(when-let [^File temp-file (attachment/create-temp-file-or-throw filename "txt")]
(with-open [os (io/output-stream temp-file)]
(attachment/text-file-writer
os
(format (str/join
"\n"
[(if (:card-error (ex-data e))
"There was a card query error with your card: %s"
"There was a rendering error with your card: %s")
""
"The visualization settings:"
"%s"
""
"The stacktrace:"
"%s"])
(:name card)
(:visualization_settings card)
(with-out-str (st/print-stack-trace e)))))
{(u/slugify (str (:name card) "_error"))
(-> temp-file .toURI .toURL)})))
(mu/defn ^:private render-pulse-card-body :- formatter/RenderedPulseCard
[render-type
timezone-id :- [:maybe :string]
@@ -147,11 +180,12 @@
(if (:card-error (ex-data e))
(do
(log/error e "Pulse card query error")
(body/render :card-error nil nil nil nil nil))
(assoc (body/render :card-error nil nil nil nil nil)
:attachments (error-file-attachment card e)))
(do
(log/error e "Pulse card render error")
(body/render :render-error nil nil nil nil nil))))))
(assoc (body/render :render-error nil nil nil nil nil)
:attachments (error-file-attachment card e)))))))
(mu/defn render-pulse-card :- formatter/RenderedPulseCard
"Render a single `card` for a `Pulse` to Hiccup HTML. `result` is the QP results. Returns a map with keys

View File

@@ -2,7 +2,6 @@
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase.email.messages :as messages]
[metabase.models
:refer [Card
Collection
@@ -20,6 +19,7 @@
[metabase.models.pulse :as pulse]
[metabase.public-settings :as public-settings]
[metabase.pulse]
[metabase.pulse.attachment :as attachment]
[metabase.pulse.render.body :as body]
[metabase.pulse.test-util :as pulse.test-util]
[metabase.test :as mt]
@@ -895,7 +895,7 @@
(when (seq rows)
[(let [^java.io.ByteArrayOutputStream baos (java.io.ByteArrayOutputStream.)]
(with-open [os baos]
(#'messages/stream-api-results-to-export-format :csv true os result)
(#'attachment/stream-api-results-to-export-format :csv os result)
(let [output-string (.toString baos "UTF-8")]
{:type :attachment
:content-type :csv
@@ -926,7 +926,7 @@
PulseChannel {pc-id :id} {:pulse_id pulse-id}
PulseChannelRecipient _ {:user_id (pulse.test-util/rasta-id)
:pulse_channel_id pc-id}]
(with-redefs [messages/result-attachment result-attachment]
(with-redefs [attachment/result-attachment result-attachment]
(metabase.pulse/send-pulse! pulse)
(is (= 1
(-> @mt/inbox
@@ -942,6 +942,6 @@
(deftest attachment-filenames-stay-readable-test
(testing "Filenames remain human-readable (#41669)"
(let [tmp (#'messages/create-temp-file ".tmp")
{:keys [file-name]} (#'messages/create-result-attachment-map :csv "テストSQL質問" tmp)]
(let [tmp (#'attachment/create-temp-file "tmp" ".tmp")
{:keys [file-name]} (#'attachment/create-result-attachment-map :csv "テストSQL質問" tmp)]
(is (= "テストSQL質問" (first (str/split file-name #"_")))))))

View File

@@ -5,6 +5,7 @@
[metabase.email :as email]
[metabase.email-test :as et]
[metabase.email.messages :as messages]
[metabase.pulse.attachment :as attachment]
[metabase.test :as mt]
[metabase.test.util :as tu]
[metabase.util.retry :as retry]
@@ -61,8 +62,8 @@
(str/includes? "deactivated"))))))
(defmacro ^:private with-create-temp-failure [& body]
`(with-redefs [messages/create-temp-file (fn [~'_]
(throw (IOException. "Failed to write file")))]
`(with-redefs [attachment/create-temp-file (fn [~'_ ~'_]
(throw (IOException. "Failed to write file")))]
~@body))
;; Test that IOException bubbles up
@@ -71,7 +72,7 @@
IOException
(re-pattern (format "Unable to create temp file in `%s`" (System/getProperty "java.io.tmpdir")))
(with-create-temp-failure
(#'messages/create-temp-file-or-throw "txt")))))
(#'attachment/create-temp-file-or-throw "metabase_attachment" "txt")))))
(deftest alert-schedule-text-test
(testing "Alert schedules can be described as English strings, with the timezone included"

View File

@@ -12,8 +12,15 @@
[metabase.email :as email]
[metabase.models :refer [Card Collection Dashboard DashboardCard Pulse PulseCard PulseChannel PulseChannelRecipient]]
[metabase.pulse]
[metabase.pulse.render :as render]
[metabase.pulse.render.body :as body]
[metabase.pulse.util :as pulse.util]
[metabase.query-processor :as qp]
[metabase.query-processor.middleware.permissions :as qp.perms]
[metabase.test :as mt]
[metabase.util :as u]))
[metabase.util :as u]
[metabase.util.log :as log]
[toucan2.core :as t2]))
(defmacro with-metadata-data-cards
"Provide a fixture that includes:
@@ -913,3 +920,95 @@
(mt/with-test-user nil
(metabase.pulse/send-pulse! pulse)))
(is (string? (get-in @mt/inbox ["rasta@metabase.com" 0 :body 0 :content]))))))))
(defn- run-pulse-and-return-txt-attachment-contents
"Simulate sending the pulse email, get the attachments."
[pulse]
(mt/with-fake-inbox
(with-redefs [email/bcc-enabled? (constantly false)]
(mt/with-test-user nil
(metabase.pulse/send-pulse! pulse)))
(->>
(get-in @mt/inbox ["rasta@metabase.com" 0 :body])
(keep
(fn [{:keys [content-type content]}]
(when (= "text" content-type)
(slurp content)))))))
(defmethod body/render ::will-fail
[& _args]
(throw (Exception. "Sorry buddy.")))
(deftest render-errors-are-attached-to-subscriptions
(testing "When a render error occurs, details are attached as a .txt file"
(mt/dataset test-data
(let [q {:database (mt/id)
:type :query
:query
{:source-table (mt/id :orders)
:aggregation [[:count]]
:breakout [[:field (mt/id :orders :created_at) {:base-type :type/DateTime, :temporal-unit :month}]]}}]
(mt/with-temp [Card {card-id :id} {:display ::will-fail
:dataset_query q}
Pulse {pulse-id :id :as pulse} {:name "Test Pulse"}
PulseCard _ {:pulse_id pulse-id
:card_id card-id}
PulseChannel {pulse-channel-id :id} {:channel_type :email
:pulse_id pulse-id
:enabled true}
PulseChannelRecipient _ {:pulse_channel_id pulse-channel-id
:user_id (mt/user->id :rasta)}]
(with-redefs [render/detect-pulse-chart-type (fn [{:keys [display]} & _] display)]
(let [[render-error] (run-pulse-and-return-txt-attachment-contents pulse)]
(is (str/includes? render-error "Sorry buddy.")))))))))
(defn- execute-card
"Mess with the query so it errors."
[{pulse-creator-id :creator_id} card-or-id & {:as options}]
(let [card-id (u/the-id card-or-id)]
(try
(when-let [{query :dataset_query
metadata :result_metadata
card-type :type
:as card} (t2/select-one :model/Card :id card-id, :archived false)]
(let [query (-> query
(assoc :async? false)
(assoc-in [:native :query] "GRAB_EM_ALL"))
process-query (fn []
(binding [qp.perms/*card-id* card-id]
(qp/process-query
(qp/userland-query-with-default-constraints
(assoc query :middleware {:skip-results-metadata? true
:process-viz-settings? true
:js-int-to-string? false})
(merge (cond-> {:executed-by pulse-creator-id
:context :pulse
:card-id card-id}
(= card-type :model)
(assoc :metadata/model-metadata metadata))
options)))))
result (process-query)]
{:card card
:result result}))
(catch Throwable e
(log/warnf e "Error running query for Card %s" card-id)))))
(deftest query-errors-are-attached-to-subscriptions
(testing "When a query error occurs, details are attached as a .txt file"
(mt/dataset test-data
(let [q {:database (mt/id)
:type :native
:native {:query "SELECT 1 as A"}}]
(mt/with-temp [Card {card-id :id} {:display ::will-fail
:dataset_query q}
Pulse {pulse-id :id :as pulse} {:name "Test Pulse"}
PulseCard _ {:pulse_id pulse-id
:card_id card-id}
PulseChannel {pulse-channel-id :id} {:channel_type :email
:pulse_id pulse-id
:enabled true}
PulseChannelRecipient _ {:pulse_channel_id pulse-channel-id
:user_id (mt/user->id :rasta)}]
(with-redefs [pulse.util/execute-card execute-card]
(let [[query-error] (run-pulse-and-return-txt-attachment-contents pulse)]
(is (str/includes? query-error "There was a card query error with your card")))))))))