From ba9437367c59b104b41dfaef5fddc023da1fe02e Mon Sep 17 00:00:00 2001 From: Spencer Lyon Date: Tue, 2 Jun 2026 16:50:05 -0400 Subject: [PATCH] Add copy button for error details --- assets/js/app.js | 39 ++++++ lib/error_tracker/web/live/show.ex | 67 ++++++++++ lib/error_tracker/web/live/show.html.heex | 27 +++- priv/static/app.js | 148 +++++++++++++++++++++- test/error_tracker/web/live/show_test.exs | 56 ++++++++ 5 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 test/error_tracker/web/live/show_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index 8897c8e..aad54d9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -52,6 +52,45 @@ const Hooks = { mounted() { Theme.init(); } + }, + CopyToClipboard: { + mounted() { + this.label = this.el.dataset.copyLabel || this.el.textContent; + this.copiedLabel = this.el.dataset.copiedLabel || "Copied"; + this.timeout = null; + this.onClick = () => this.copy(); + this.el.addEventListener("click", this.onClick); + }, + destroyed() { + this.el.removeEventListener("click", this.onClick); + clearTimeout(this.timeout); + }, + copy() { + const target = document.getElementById(this.el.dataset.copyTarget); + if (!target) return; + + const text = target.value || target.textContent; + if (!text) return; + + const writeText = navigator.clipboard + ? navigator.clipboard.writeText(text).catch(() => this.writeTextFallback(target)) + : this.writeTextFallback(target); + + writeText.then(() => { + this.el.textContent = this.copiedLabel; + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.el.textContent = this.label; + }, 2000); + }); + }, + writeTextFallback(target) { + target.select(); + document.execCommand("copy"); + target.blur(); + + return Promise.resolve(); + } } }; diff --git a/lib/error_tracker/web/live/show.ex b/lib/error_tracker/web/live/show.ex index a832a89..2c9cf5a 100644 --- a/lib/error_tracker/web/live/show.ex +++ b/lib/error_tracker/web/live/show.ex @@ -41,6 +41,10 @@ defmodule ErrorTracker.Web.Live.Show do socket = socket |> assign(occurrence: occurrence) + |> assign( + :copy_error_text, + copy_error_text(socket.assigns.error, occurrence, socket.assigns.app) + ) |> load_related_occurrences() {:noreply, socket} @@ -156,4 +160,67 @@ defmodule ErrorTracker.Web.Live.Show do |> limit(^num_results) |> Repo.all() end + + @doc false + def copy_error_text(%Error{} = error, %Occurrence{} = occurrence, app) do + [ + "Error ##{error.id}", + "Occurrence ##{occurrence.id}", + "Kind: #{error.kind}", + "Message:\n#{occurrence.reason}", + source_section(error), + breadcrumbs_section(occurrence.breadcrumbs), + stacktrace_section(occurrence.stacktrace, app), + context_section(occurrence.context) + ] + |> Enum.reject(&is_nil/1) + |> Enum.join("\n\n") + end + + defp source_section(%Error{} = error) do + if Error.has_source_info?(error) do + String.trim(""" + Source: + #{error.source_function} + #{error.source_line} + """) + end + end + + defp breadcrumbs_section([]), do: nil + defp breadcrumbs_section(nil), do: nil + + defp breadcrumbs_section(breadcrumbs) do + breadcrumbs = + breadcrumbs + |> Enum.reverse() + |> Enum.with_index(1) + |> Enum.map_join("\n", fn {breadcrumb, index} -> "#{index}. #{breadcrumb}" end) + + "Breadcrumbs:\n#{breadcrumbs}" + end + + defp stacktrace_section(%{lines: []}, _app), do: nil + defp stacktrace_section(nil, _app), do: nil + + defp stacktrace_section(stacktrace, app) do + lines = + Enum.map_join(stacktrace.lines, "\n", fn line -> + application = line.application || to_string(app) + location = if line.line, do: "#{line.file}:#{line.line}", else: "(nofile)" + + "(#{application}) #{line.module}.#{line.function}/#{line.arity}\n #{location}" + end) + + "Stacktrace:\n#{lines}" + end + + defp context_section(context) do + json = + context + |> ErrorTracker.__default_json_encoder__().encode_to_iodata!() + |> IO.iodata_to_binary() + + "Context:\n#{json}" + end end diff --git a/lib/error_tracker/web/live/show.html.heex b/lib/error_tracker/web/live/show.html.heex index b24b22c..82c928c 100644 --- a/lib/error_tracker/web/live/show.html.heex +++ b/lib/error_tracker/web/live/show.html.heex @@ -8,11 +8,28 @@

Error #{@error.id} @ {format_datetime(@occurrence.inserted_at)}

-

- ({sanitize_module(@error.kind)}) {@error.reason - |> String.replace("\n", " ") - |> String.trim()} -

+
+

+ ({sanitize_module(@error.kind)}) {@error.reason + |> String.replace("\n", " ") + |> String.trim()} +

+ +
+
diff --git a/priv/static/app.js b/priv/static/app.js index 9cafda9..0663b41 100644 --- a/priv/static/app.js +++ b/priv/static/app.js @@ -1 +1,147 @@ -var x=Object.create;var{defineProperty:g,getPrototypeOf:E,getOwnPropertyNames:F}=Object;var I=Object.prototype.hasOwnProperty;var w=(e,i,u)=>{u=e!=null?x(E(e)):{};const t=i||!e||!e.__esModule?g(u,"default",{value:e,enumerable:!0}):u;for(let o of F(e))if(!I.call(t,o))g(t,o,{get:()=>e[o],enumerable:!0});return t};var A=(e,i)=>()=>(i||e((i={exports:{}}).exports,i),i.exports);var m=A((y,f)=>{(function(e,i){function u(){t.width=e.innerWidth,t.height=5*r.barThickness;var n=t.getContext("2d");n.shadowBlur=r.shadowBlur,n.shadowColor=r.shadowColor;var s,a=n.createLinearGradient(0,0,t.width,0);for(s in r.barColors)a.addColorStop(s,r.barColors[s]);n.lineWidth=r.barThickness,n.beginPath(),n.moveTo(0,r.barThickness/2),n.lineTo(Math.ceil(o*t.width),r.barThickness/2),n.strokeStyle=a,n.stroke()}var t,o,c,d=null,p=null,h=null,r={autoRun:!0,barThickness:3,barColors:{0:"rgba(26, 188, 156, .9)",".25":"rgba(52, 152, 219, .9)",".50":"rgba(241, 196, 15, .9)",".75":"rgba(230, 126, 34, .9)","1.0":"rgba(211, 84, 0, .9)"},shadowBlur:10,shadowColor:"rgba(0, 0, 0, .6)",className:null},l={config:function(n){for(var s in n)r.hasOwnProperty(s)&&(r[s]=n[s])},show:function(n){var s,a;c||(n?h=h||setTimeout(()=>l.show(),n):(c=!0,p!==null&&e.cancelAnimationFrame(p),t||((a=(t=i.createElement("canvas")).style).position="fixed",a.top=a.left=a.right=a.margin=a.padding=0,a.zIndex=100001,a.display="none",r.className&&t.classList.add(r.className),s="resize",n=u,(a=e).addEventListener?a.addEventListener(s,n,!1):a.attachEvent?a.attachEvent("on"+s,n):a["on"+s]=n),t.parentElement||i.body.appendChild(t),t.style.opacity=1,t.style.display="block",l.progress(0),r.autoRun&&function T(){d=e.requestAnimationFrame(T),l.progress("+"+0.05*Math.pow(1-Math.sqrt(o),2))}()))},progress:function(n){return n===void 0||(typeof n=="string"&&(n=(0<=n.indexOf("+")||0<=n.indexOf("-")?o:0)+parseFloat(n)),o=1b.default.show(300));window.addEventListener("phx:page-loading-stop",(e)=>b.default.hide());document.addEventListener("click",function(e){var i=e.target.closest("[data-theme-toggle]");if(i)v.toggle()});C.connect();window.liveSocket=C; +var __create = Object.create; +var __getProtoOf = Object.getPrototypeOf; +var __defProp = Object.defineProperty; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __toESM = (mod, isNodeMode, target) => { + target = mod != null ? __create(__getProtoOf(mod)) : {}; + const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; + for (let key of __getOwnPropNames(mod)) + if (!__hasOwnProp.call(to, key)) + __defProp(to, key, { + get: () => mod[key], + enumerable: true + }); + return to; +}; +var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); + +// ../node_modules/topbar/topbar.min.js +var require_topbar_min = __commonJS((exports, module) => { + (function(window2, document2) { + function repaint() { + canvas.width = window2.innerWidth, canvas.height = 5 * options.barThickness; + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur, ctx.shadowColor = options.shadowColor; + var stop, lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness, ctx.beginPath(), ctx.moveTo(0, options.barThickness / 2), ctx.lineTo(Math.ceil(currentProgress * canvas.width), options.barThickness / 2), ctx.strokeStyle = lineGradient, ctx.stroke(); + } + var canvas, currentProgress, showing, progressTimerId = null, fadeTimerId = null, delayTimerId = null, options = { autoRun: true, barThickness: 3, barColors: { 0: "rgba(26, 188, 156, .9)", ".25": "rgba(52, 152, 219, .9)", ".50": "rgba(241, 196, 15, .9)", ".75": "rgba(230, 126, 34, .9)", "1.0": "rgba(211, 84, 0, .9)" }, shadowBlur: 10, shadowColor: "rgba(0, 0, 0, .6)", className: null }, topbar = { config: function(opts) { + for (var key in opts) + options.hasOwnProperty(key) && (options[key] = opts[key]); + }, show: function(handler) { + var type, elem; + showing || (handler ? delayTimerId = delayTimerId || setTimeout(() => topbar.show(), handler) : (showing = true, fadeTimerId !== null && window2.cancelAnimationFrame(fadeTimerId), canvas || ((elem = (canvas = document2.createElement("canvas")).style).position = "fixed", elem.top = elem.left = elem.right = elem.margin = elem.padding = 0, elem.zIndex = 100001, elem.display = "none", options.className && canvas.classList.add(options.className), type = "resize", handler = repaint, (elem = window2).addEventListener ? elem.addEventListener(type, handler, false) : elem.attachEvent ? elem.attachEvent("on" + type, handler) : elem["on" + type] = handler), canvas.parentElement || document2.body.appendChild(canvas), canvas.style.opacity = 1, canvas.style.display = "block", topbar.progress(0), options.autoRun && function loop() { + progressTimerId = window2.requestAnimationFrame(loop), topbar.progress("+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)); + }())); + }, progress: function(to) { + return to === undefined || (typeof to == "string" && (to = (0 <= to.indexOf("+") || 0 <= to.indexOf("-") ? currentProgress : 0) + parseFloat(to)), currentProgress = 1 < to ? 1 : to, repaint()), currentProgress; + }, hide: function() { + clearTimeout(delayTimerId), delayTimerId = null, showing && (showing = false, progressTimerId != null && (window2.cancelAnimationFrame(progressTimerId), progressTimerId = null), function loop() { + return 1 <= topbar.progress("+.1") && (canvas.style.opacity -= 0.05, canvas.style.opacity <= 0.05) ? (canvas.style.display = "none", void (fadeTimerId = null)) : void (fadeTimerId = window2.requestAnimationFrame(loop)); + }()); + } }; + typeof module == "object" && typeof module.exports == "object" ? module.exports = topbar : typeof define == "function" && define.amd ? define(function() { + return topbar; + }) : this.topbar = topbar; + }).call(exports, window, document); +}); + +// app.js +var import_topbar = __toESM(require_topbar_min(), 1); +var csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); +var livePath = document.querySelector("meta[name='live-path']").getAttribute("content"); +var liveTransport = document.querySelector("meta[name='live-transport']").getAttribute("content"); +var Theme = { + STORAGE_KEY: "error-tracker-theme", + init() { + const saved = localStorage.getItem(this.STORAGE_KEY); + if (saved === "light") { + document.body.classList.add("light-theme"); + } + }, + toggle() { + const isLight = document.body.classList.toggle("light-theme"); + localStorage.setItem(this.STORAGE_KEY, isLight ? "light" : "dark"); + }, + isLight() { + return document.body.classList.contains("light-theme"); + } +}; +var Hooks = { + JsonPrettyPrint: { + mounted() { + this.formatJson(); + }, + updated() { + this.formatJson(); + }, + formatJson() { + try { + const rawJson = this.el.textContent.trim(); + const formattedJson = JSON.stringify(JSON.parse(rawJson), null, 2); + this.el.textContent = formattedJson; + } catch (error) { + console.error("Error formatting JSON:", error); + } + } + }, + ThemeInit: { + mounted() { + Theme.init(); + } + }, + CopyToClipboard: { + mounted() { + this.label = this.el.dataset.copyLabel || this.el.textContent; + this.copiedLabel = this.el.dataset.copiedLabel || "Copied"; + this.timeout = null; + this.onClick = () => this.copy(); + this.el.addEventListener("click", this.onClick); + }, + destroyed() { + this.el.removeEventListener("click", this.onClick); + clearTimeout(this.timeout); + }, + copy() { + const target = document.getElementById(this.el.dataset.copyTarget); + if (!target) + return; + const text = target.value || target.textContent; + if (!text) + return; + const writeText = navigator.clipboard ? navigator.clipboard.writeText(text).catch(() => this.writeTextFallback(target)) : this.writeTextFallback(target); + writeText.then(() => { + this.el.textContent = this.copiedLabel; + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.el.textContent = this.label; + }, 2000); + }); + }, + writeTextFallback(target) { + target.select(); + document.execCommand("copy"); + target.blur(); + return Promise.resolve(); + } + } +}; +var liveSocket = new LiveView.LiveSocket(livePath, Phoenix.Socket, { + transport: liveTransport === "longpoll" ? Phoenix.LongPoll : WebSocket, + params: { _csrf_token: csrfToken }, + hooks: Hooks +}); +import_topbar.default.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => import_topbar.default.show(300)); +window.addEventListener("phx:page-loading-stop", (_info) => import_topbar.default.hide()); +document.addEventListener("click", function(e) { + var toggle = e.target.closest("[data-theme-toggle]"); + if (toggle) { + Theme.toggle(); + } +}); +liveSocket.connect(); +window.liveSocket = liveSocket; diff --git a/test/error_tracker/web/live/show_test.exs b/test/error_tracker/web/live/show_test.exs new file mode 100644 index 0000000..e946225 --- /dev/null +++ b/test/error_tracker/web/live/show_test.exs @@ -0,0 +1,56 @@ +defmodule ErrorTracker.Web.Live.ShowTest do + use ExUnit.Case, async: true + + alias ErrorTracker.Error + alias ErrorTracker.Occurrence + alias ErrorTracker.Stacktrace + alias ErrorTracker.Web.Live.Show + + test "copy_error_text/3 includes LLM-friendly error details" do + error = %Error{ + id: 123, + kind: "Elixir.RuntimeError", + source_function: "Demo.run/1", + source_line: "lib/demo.ex:10" + } + + occurrence = %Occurrence{ + id: 456, + reason: "Something broke", + breadcrumbs: ["opened dashboard", "clicked button"], + context: %{"request_id" => "req-1"}, + stacktrace: %Stacktrace{ + lines: [ + %Stacktrace.Line{ + application: "demo", + module: "Demo", + function: "run", + arity: 1, + file: "lib/demo.ex", + line: 10 + }, + %Stacktrace.Line{ + application: nil, + module: "Kernel", + function: "apply", + arity: 2, + file: "nofile", + line: nil + } + ] + } + } + + text = Show.copy_error_text(error, occurrence, :fallback_app) + + assert text =~ "Error #123" + assert text =~ "Occurrence #456" + assert text =~ "Kind: Elixir.RuntimeError" + assert text =~ "Message:\nSomething broke" + assert text =~ "Source:\nDemo.run/1\nlib/demo.ex:10" + assert text =~ "Breadcrumbs:\n1. clicked button\n2. opened dashboard" + assert text =~ "(demo) Demo.run/1\n lib/demo.ex:10" + assert text =~ "(fallback_app) Kernel.apply/2\n (nofile)" + assert text =~ ~s("request_id":"req-1") + end +end