Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
};

Expand Down
67 changes: 67 additions & 0 deletions lib/error_tracker/web/live/show.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
27 changes: 22 additions & 5 deletions lib/error_tracker/web/live/show.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,28 @@
<p class="text-sm uppercase font-semibold text-gray-400 light:text-gray-500">
Error #{@error.id} @ {format_datetime(@occurrence.inserted_at)}
</p>
<h1 class="my-1 text-2xl w-full font-semibold whitespace-nowrap text-ellipsis overflow-hidden">
({sanitize_module(@error.kind)}) {@error.reason
|> String.replace("\n", " ")
|> String.trim()}
</h1>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<h1 class="my-1 text-2xl w-full font-semibold whitespace-nowrap text-ellipsis overflow-hidden">
({sanitize_module(@error.kind)}) {@error.reason
|> String.replace("\n", " ")
|> String.trim()}
</h1>
<button
id="copy-error-button"
type="button"
class={[
"shrink-0 rounded-lg border border-gray-700 light:border-gray-300 px-3 py-2 text-sm font-semibold",
"text-sky-500 light:text-sky-600 hover:bg-gray-300/10 light:hover:bg-gray-100"
]}
phx-hook="CopyToClipboard"
data-copy-target="copy-error-text"
data-copy-label="Copy error"
data-copied-label="Copied"
>
Copy error
</button>
</div>
<textarea id="copy-error-text" class="sr-only" readonly><%= @copy_error_text %></textarea>
</div>

<div class="grid grid-cols-1 md:grid-cols-4 md:space-x-3 mt-6 gap-2">
Expand Down
148 changes: 147 additions & 1 deletion priv/static/app.js
Original file line number Diff line number Diff line change
@@ -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=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;
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;
56 changes: 56 additions & 0 deletions test/error_tracker/web/live/show_test.exs
Original file line number Diff line number Diff line change
@@ -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