Skip to content

Commit ba94373

Browse files
committed
Add copy button for error details
1 parent 719669e commit ba94373

5 files changed

Lines changed: 331 additions & 6 deletions

File tree

assets/js/app.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,45 @@ const Hooks = {
5252
mounted() {
5353
Theme.init();
5454
}
55+
},
56+
CopyToClipboard: {
57+
mounted() {
58+
this.label = this.el.dataset.copyLabel || this.el.textContent;
59+
this.copiedLabel = this.el.dataset.copiedLabel || "Copied";
60+
this.timeout = null;
61+
this.onClick = () => this.copy();
62+
this.el.addEventListener("click", this.onClick);
63+
},
64+
destroyed() {
65+
this.el.removeEventListener("click", this.onClick);
66+
clearTimeout(this.timeout);
67+
},
68+
copy() {
69+
const target = document.getElementById(this.el.dataset.copyTarget);
70+
if (!target) return;
71+
72+
const text = target.value || target.textContent;
73+
if (!text) return;
74+
75+
const writeText = navigator.clipboard
76+
? navigator.clipboard.writeText(text).catch(() => this.writeTextFallback(target))
77+
: this.writeTextFallback(target);
78+
79+
writeText.then(() => {
80+
this.el.textContent = this.copiedLabel;
81+
clearTimeout(this.timeout);
82+
this.timeout = setTimeout(() => {
83+
this.el.textContent = this.label;
84+
}, 2000);
85+
});
86+
},
87+
writeTextFallback(target) {
88+
target.select();
89+
document.execCommand("copy");
90+
target.blur();
91+
92+
return Promise.resolve();
93+
}
5594
}
5695
};
5796

lib/error_tracker/web/live/show.ex

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ defmodule ErrorTracker.Web.Live.Show do
4141
socket =
4242
socket
4343
|> assign(occurrence: occurrence)
44+
|> assign(
45+
:copy_error_text,
46+
copy_error_text(socket.assigns.error, occurrence, socket.assigns.app)
47+
)
4448
|> load_related_occurrences()
4549

4650
{:noreply, socket}
@@ -156,4 +160,67 @@ defmodule ErrorTracker.Web.Live.Show do
156160
|> limit(^num_results)
157161
|> Repo.all()
158162
end
163+
164+
@doc false
165+
def copy_error_text(%Error{} = error, %Occurrence{} = occurrence, app) do
166+
[
167+
"Error ##{error.id}",
168+
"Occurrence ##{occurrence.id}",
169+
"Kind: #{error.kind}",
170+
"Message:\n#{occurrence.reason}",
171+
source_section(error),
172+
breadcrumbs_section(occurrence.breadcrumbs),
173+
stacktrace_section(occurrence.stacktrace, app),
174+
context_section(occurrence.context)
175+
]
176+
|> Enum.reject(&is_nil/1)
177+
|> Enum.join("\n\n")
178+
end
179+
180+
defp source_section(%Error{} = error) do
181+
if Error.has_source_info?(error) do
182+
String.trim("""
183+
Source:
184+
#{error.source_function}
185+
#{error.source_line}
186+
""")
187+
end
188+
end
189+
190+
defp breadcrumbs_section([]), do: nil
191+
defp breadcrumbs_section(nil), do: nil
192+
193+
defp breadcrumbs_section(breadcrumbs) do
194+
breadcrumbs =
195+
breadcrumbs
196+
|> Enum.reverse()
197+
|> Enum.with_index(1)
198+
|> Enum.map_join("\n", fn {breadcrumb, index} -> "#{index}. #{breadcrumb}" end)
199+
200+
"Breadcrumbs:\n#{breadcrumbs}"
201+
end
202+
203+
defp stacktrace_section(%{lines: []}, _app), do: nil
204+
defp stacktrace_section(nil, _app), do: nil
205+
206+
defp stacktrace_section(stacktrace, app) do
207+
lines =
208+
Enum.map_join(stacktrace.lines, "\n", fn line ->
209+
application = line.application || to_string(app)
210+
location = if line.line, do: "#{line.file}:#{line.line}", else: "(nofile)"
211+
212+
"(#{application}) #{line.module}.#{line.function}/#{line.arity}\n #{location}"
213+
end)
214+
215+
"Stacktrace:\n#{lines}"
216+
end
217+
218+
defp context_section(context) do
219+
json =
220+
context
221+
|> ErrorTracker.__default_json_encoder__().encode_to_iodata!()
222+
|> IO.iodata_to_binary()
223+
224+
"Context:\n#{json}"
225+
end
159226
end

lib/error_tracker/web/live/show.html.heex

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,28 @@
88
<p class="text-sm uppercase font-semibold text-gray-400 light:text-gray-500">
99
Error #{@error.id} @ {format_datetime(@occurrence.inserted_at)}
1010
</p>
11-
<h1 class="my-1 text-2xl w-full font-semibold whitespace-nowrap text-ellipsis overflow-hidden">
12-
({sanitize_module(@error.kind)}) {@error.reason
13-
|> String.replace("\n", " ")
14-
|> String.trim()}
15-
</h1>
11+
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
12+
<h1 class="my-1 text-2xl w-full font-semibold whitespace-nowrap text-ellipsis overflow-hidden">
13+
({sanitize_module(@error.kind)}) {@error.reason
14+
|> String.replace("\n", " ")
15+
|> String.trim()}
16+
</h1>
17+
<button
18+
id="copy-error-button"
19+
type="button"
20+
class={[
21+
"shrink-0 rounded-lg border border-gray-700 light:border-gray-300 px-3 py-2 text-sm font-semibold",
22+
"text-sky-500 light:text-sky-600 hover:bg-gray-300/10 light:hover:bg-gray-100"
23+
]}
24+
phx-hook="CopyToClipboard"
25+
data-copy-target="copy-error-text"
26+
data-copy-label="Copy error"
27+
data-copied-label="Copied"
28+
>
29+
Copy error
30+
</button>
31+
</div>
32+
<textarea id="copy-error-text" class="sr-only" readonly><%= @copy_error_text %></textarea>
1633
</div>
1734

1835
<div class="grid grid-cols-1 md:grid-cols-4 md:space-x-3 mt-6 gap-2">

priv/static/app.js

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,147 @@
1-
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=1<n?1:n,u()),o},hide:function(){clearTimeout(h),h=null,c&&(c=!1,d!=null&&(e.cancelAnimationFrame(d),d=null),function n(){return 1<=l.progress("+.1")&&(t.style.opacity-=0.05,t.style.opacity<=0.05)?(t.style.display="none",void(p=null)):void(p=e.requestAnimationFrame(n))}())}};typeof f=="object"&&typeof y=="object"?f.exports=l:typeof define=="function"&&define.amd?define(function(){return l}):this.topbar=l}).call(y,window,document)});var b=w(m(),1),B=document.querySelector("meta[name='csrf-token']").getAttribute("content"),M=document.querySelector("meta[name='live-path']").getAttribute("content"),N=document.querySelector("meta[name='live-transport']").getAttribute("content"),v={STORAGE_KEY:"error-tracker-theme",init(){if(localStorage.getItem(this.STORAGE_KEY)==="light")document.body.classList.add("light-theme")},toggle(){const e=document.body.classList.toggle("light-theme");localStorage.setItem(this.STORAGE_KEY,e?"light":"dark")},isLight(){return document.body.classList.contains("light-theme")}},q={JsonPrettyPrint:{mounted(){this.formatJson()},updated(){this.formatJson()},formatJson(){try{const e=this.el.textContent.trim(),i=JSON.stringify(JSON.parse(e),null,2);this.el.textContent=i}catch(e){console.error("Error formatting JSON:",e)}}},ThemeInit:{mounted(){v.init()}}},C=new LiveView.LiveSocket(M,Phoenix.Socket,{transport:N==="longpoll"?Phoenix.LongPoll:WebSocket,params:{_csrf_token:B},hooks:q});b.default.config({barColors:{0:"#29d"},shadowColor:"rgba(0, 0, 0, .3)"});window.addEventListener("phx:page-loading-start",(e)=>b.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;
1+
var __create = Object.create;
2+
var __getProtoOf = Object.getPrototypeOf;
3+
var __defProp = Object.defineProperty;
4+
var __getOwnPropNames = Object.getOwnPropertyNames;
5+
var __hasOwnProp = Object.prototype.hasOwnProperty;
6+
var __toESM = (mod, isNodeMode, target) => {
7+
target = mod != null ? __create(__getProtoOf(mod)) : {};
8+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
9+
for (let key of __getOwnPropNames(mod))
10+
if (!__hasOwnProp.call(to, key))
11+
__defProp(to, key, {
12+
get: () => mod[key],
13+
enumerable: true
14+
});
15+
return to;
16+
};
17+
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
18+
19+
// ../node_modules/topbar/topbar.min.js
20+
var require_topbar_min = __commonJS((exports, module) => {
21+
(function(window2, document2) {
22+
function repaint() {
23+
canvas.width = window2.innerWidth, canvas.height = 5 * options.barThickness;
24+
var ctx = canvas.getContext("2d");
25+
ctx.shadowBlur = options.shadowBlur, ctx.shadowColor = options.shadowColor;
26+
var stop, lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
27+
for (stop in options.barColors)
28+
lineGradient.addColorStop(stop, options.barColors[stop]);
29+
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();
30+
}
31+
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) {
32+
for (var key in opts)
33+
options.hasOwnProperty(key) && (options[key] = opts[key]);
34+
}, show: function(handler) {
35+
var type, elem;
36+
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() {
37+
progressTimerId = window2.requestAnimationFrame(loop), topbar.progress("+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2));
38+
}()));
39+
}, progress: function(to) {
40+
return to === undefined || (typeof to == "string" && (to = (0 <= to.indexOf("+") || 0 <= to.indexOf("-") ? currentProgress : 0) + parseFloat(to)), currentProgress = 1 < to ? 1 : to, repaint()), currentProgress;
41+
}, hide: function() {
42+
clearTimeout(delayTimerId), delayTimerId = null, showing && (showing = false, progressTimerId != null && (window2.cancelAnimationFrame(progressTimerId), progressTimerId = null), function loop() {
43+
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));
44+
}());
45+
} };
46+
typeof module == "object" && typeof module.exports == "object" ? module.exports = topbar : typeof define == "function" && define.amd ? define(function() {
47+
return topbar;
48+
}) : this.topbar = topbar;
49+
}).call(exports, window, document);
50+
});
51+
52+
// app.js
53+
var import_topbar = __toESM(require_topbar_min(), 1);
54+
var csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
55+
var livePath = document.querySelector("meta[name='live-path']").getAttribute("content");
56+
var liveTransport = document.querySelector("meta[name='live-transport']").getAttribute("content");
57+
var Theme = {
58+
STORAGE_KEY: "error-tracker-theme",
59+
init() {
60+
const saved = localStorage.getItem(this.STORAGE_KEY);
61+
if (saved === "light") {
62+
document.body.classList.add("light-theme");
63+
}
64+
},
65+
toggle() {
66+
const isLight = document.body.classList.toggle("light-theme");
67+
localStorage.setItem(this.STORAGE_KEY, isLight ? "light" : "dark");
68+
},
69+
isLight() {
70+
return document.body.classList.contains("light-theme");
71+
}
72+
};
73+
var Hooks = {
74+
JsonPrettyPrint: {
75+
mounted() {
76+
this.formatJson();
77+
},
78+
updated() {
79+
this.formatJson();
80+
},
81+
formatJson() {
82+
try {
83+
const rawJson = this.el.textContent.trim();
84+
const formattedJson = JSON.stringify(JSON.parse(rawJson), null, 2);
85+
this.el.textContent = formattedJson;
86+
} catch (error) {
87+
console.error("Error formatting JSON:", error);
88+
}
89+
}
90+
},
91+
ThemeInit: {
92+
mounted() {
93+
Theme.init();
94+
}
95+
},
96+
CopyToClipboard: {
97+
mounted() {
98+
this.label = this.el.dataset.copyLabel || this.el.textContent;
99+
this.copiedLabel = this.el.dataset.copiedLabel || "Copied";
100+
this.timeout = null;
101+
this.onClick = () => this.copy();
102+
this.el.addEventListener("click", this.onClick);
103+
},
104+
destroyed() {
105+
this.el.removeEventListener("click", this.onClick);
106+
clearTimeout(this.timeout);
107+
},
108+
copy() {
109+
const target = document.getElementById(this.el.dataset.copyTarget);
110+
if (!target)
111+
return;
112+
const text = target.value || target.textContent;
113+
if (!text)
114+
return;
115+
const writeText = navigator.clipboard ? navigator.clipboard.writeText(text).catch(() => this.writeTextFallback(target)) : this.writeTextFallback(target);
116+
writeText.then(() => {
117+
this.el.textContent = this.copiedLabel;
118+
clearTimeout(this.timeout);
119+
this.timeout = setTimeout(() => {
120+
this.el.textContent = this.label;
121+
}, 2000);
122+
});
123+
},
124+
writeTextFallback(target) {
125+
target.select();
126+
document.execCommand("copy");
127+
target.blur();
128+
return Promise.resolve();
129+
}
130+
}
131+
};
132+
var liveSocket = new LiveView.LiveSocket(livePath, Phoenix.Socket, {
133+
transport: liveTransport === "longpoll" ? Phoenix.LongPoll : WebSocket,
134+
params: { _csrf_token: csrfToken },
135+
hooks: Hooks
136+
});
137+
import_topbar.default.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
138+
window.addEventListener("phx:page-loading-start", (_info) => import_topbar.default.show(300));
139+
window.addEventListener("phx:page-loading-stop", (_info) => import_topbar.default.hide());
140+
document.addEventListener("click", function(e) {
141+
var toggle = e.target.closest("[data-theme-toggle]");
142+
if (toggle) {
143+
Theme.toggle();
144+
}
145+
});
146+
liveSocket.connect();
147+
window.liveSocket = liveSocket;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule ErrorTracker.Web.Live.ShowTest do
2+
use ExUnit.Case, async: true
3+
4+
alias ErrorTracker.Error
5+
alias ErrorTracker.Occurrence
6+
alias ErrorTracker.Stacktrace
7+
alias ErrorTracker.Web.Live.Show
8+
9+
test "copy_error_text/3 includes LLM-friendly error details" do
10+
error = %Error{
11+
id: 123,
12+
kind: "Elixir.RuntimeError",
13+
source_function: "Demo.run/1",
14+
source_line: "lib/demo.ex:10"
15+
}
16+
17+
occurrence = %Occurrence{
18+
id: 456,
19+
reason: "Something broke",
20+
breadcrumbs: ["opened dashboard", "clicked button"],
21+
context: %{"request_id" => "req-1"},
22+
stacktrace: %Stacktrace{
23+
lines: [
24+
%Stacktrace.Line{
25+
application: "demo",
26+
module: "Demo",
27+
function: "run",
28+
arity: 1,
29+
file: "lib/demo.ex",
30+
line: 10
31+
},
32+
%Stacktrace.Line{
33+
application: nil,
34+
module: "Kernel",
35+
function: "apply",
36+
arity: 2,
37+
file: "nofile",
38+
line: nil
39+
}
40+
]
41+
}
42+
}
43+
44+
text = Show.copy_error_text(error, occurrence, :fallback_app)
45+
46+
assert text =~ "Error #123"
47+
assert text =~ "Occurrence #456"
48+
assert text =~ "Kind: Elixir.RuntimeError"
49+
assert text =~ "Message:\nSomething broke"
50+
assert text =~ "Source:\nDemo.run/1\nlib/demo.ex:10"
51+
assert text =~ "Breadcrumbs:\n1. clicked button\n2. opened dashboard"
52+
assert text =~ "(demo) Demo.run/1\n lib/demo.ex:10"
53+
assert text =~ "(fallback_app) Kernel.apply/2\n (nofile)"
54+
assert text =~ ~s("request_id":"req-1")
55+
end
56+
end

0 commit comments

Comments
 (0)