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