Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## [Unreleased]

- Add a "Copy for LLM" button that copies an occurrence as an LLM-ready prompt, served from `/errors/:error_id/occurrences/:id.md` ([@bvalentino](https://github.com/fractaledmind/solid_errors/pull/97))
- Fix `undefined method 'bootstrap_svg'` crash on the error page when an error has no occurrences

## [0.7.0] - 2025-06-11
Expand Down
18 changes: 18 additions & 0 deletions app/controllers/solid_errors/occurrences_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
module SolidErrors
class OccurrencesController < ApplicationController
# GET /errors/1/occurrences/1
#
# Always renders the markdown prompt, whether or not the URL carried the
# `.md` extension. Served as text/plain so browsers display it inline
# instead of downloading it.
def show
@occurrence = Occurrence.find_by!(id: params[:id], error_id: params[:error_id])
@error = @occurrence.error

# Rendered as `text` rather than `md` so ActionView's ERB handler skips
# HTML escaping (its escape_ignore_list covers text/plain), which would
# otherwise mangle backtraces and exception messages.
render "show", formats: [:text], content_type: "text/plain", layout: false
end
end
end
23 changes: 23 additions & 0 deletions app/views/layouts/solid_errors/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,29 @@
document.querySelectorAll('[data-controller="fade"]').forEach(element => {
fadeOut(element);
});

// Copy links fetch their own href and put the body on the clipboard. If the
// clipboard is unavailable (non-secure origin) we never preventDefault, so
// the browser follows the link to the plain text instead.
document.addEventListener('click', event => {
const link = event.target.closest('[data-copy]')
if (!link || !navigator.clipboard) return

event.preventDefault()
event.stopPropagation()

const label = link.querySelector('span')
const original = label.textContent

fetch(link.href)
.then(response => response.text())
.then(text => navigator.clipboard.writeText(text))
.then(() => {
label.textContent = 'Copied!'
setTimeout(() => { label.textContent = original }, 2000)
})
.catch(() => { window.location = link.href })
});
</script>
</body>
</html>
4 changes: 4 additions & 0 deletions app/views/solid_errors/errors/_actions.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
</svg>
<span>Back to errors</span>
<% end %>
<% if (latest_occurrence = error.occurrences.order(created_at: :desc).first) %>
<%= render "solid_errors/errors/copy_button",
url: error_occurrence_path(error, latest_occurrence) %>
<% end %>
<% if error.resolved? %>
<%= render 'solid_errors/errors/delete_button', error: error %>
<% else %>
Expand Down
10 changes: 10 additions & 0 deletions app/views/solid_errors/errors/_copy_button.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<%# locals: (url:, classes: "inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-8") -%>
<%# Deliberately an <a>: when the clipboard API is unavailable (any non-secure
origin) or JS fails, the browser just follows the link to the plain text. -%>
<%= link_to url, class: classes, data: {copy: true} do %>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0z"/>
</svg>
<span>Copy for LLM</span>
<% end %>
11 changes: 8 additions & 3 deletions app/views/solid_errors/occurrences/_occurrence.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@

<%= tag.section id: dom_id(occurrence), class: "" do %>
<%= tag.details open: open do %>
<summary class="hover:bg-gray-50 p-2 rounded cursor-pointer">
<%= time_tag seen_at, seen_at %>
(<em><%= time_ago_in_words(seen_at) %> ago</em>)
<summary class="hover:bg-gray-50 p-2 rounded cursor-pointer flex items-center gap-3">
<span>
<%= time_tag seen_at, seen_at %>
(<em><%= time_ago_in_words(seen_at) %> ago</em>)
</span>
<%= render "solid_errors/errors/copy_button",
url: error_occurrence_path(occurrence.error_id, occurrence),
classes: "inline-flex items-center gap-1 text-sm font-medium cursor-pointer border rounded-lg py-1 px-2 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-4" %>
</summary>
<div class="">
<dl class="ml-6">
Expand Down
40 changes: 40 additions & 0 deletions app/views/solid_errors/occurrences/show.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<%- backtrace = @occurrence.parsed_backtrace -%>
Fix this error from the <%= Rails.application.class.module_parent_name %> application, reported by Solid Errors:

<fault id="<%= @error.id %>" environment="<%= Rails.env %>">
<class><%= @error.exception_class %></class>
<message><%= @error.message %></message>
<severity><%= @error.severity %></severity>
<source><%= @error.source %></source>
<occurred-at><%= @occurrence.created_at.utc.iso8601 %></occurred-at>
<occurrence-count><%= @error.occurrences.count %></occurrence-count>
<project-root><%= SolidErrors::BacktraceLine::RAILS_ROOT %></project-root>
<backtrace>
<%- backtrace.lines.each do |line| -%>
<%= line.filtered_method ? line.to_s : line.unparsed_line %>
<%- end -%>
</backtrace>
<%- if backtrace.application_lines.any? -%>
<source-context>
<%- backtrace.application_lines.each do |line| -%>
<file path="<%= line.filtered_file %>" line="<%= line.filtered_number %>">
<%- line.source.each do |number, code| -%>
<%= number %>: <%= code.chomp %>
<%- end -%>
</file>
<%- end -%>
</source-context>
<%- end -%>
<%- if @occurrence.context.present? -%>
<context>
<%= JSON.pretty_generate(@occurrence.context) %>
</context>
<%- end -%>
</fault>

Investigate and fix the root cause of this error:

1. Use the backtrace and source context to locate where the error was raised in this codebase.
2. Check the request context for the conditions that triggered it.
3. Implement a fix that addresses the root cause, following the conventions of the surrounding code.
4. Add or update tests covering the failure case where applicable.
4 changes: 3 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
SolidErrors::Engine.routes.draw do
get "/", to: "errors#index", as: :root

resources :errors, only: [:index, :show, :update, :destroy], path: ""
resources :errors, only: [:index, :show, :update, :destroy], path: "" do
resources :occurrences, only: [:show]
end
end
4 changes: 4 additions & 0 deletions lib/solid_errors/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class Engine < ::Rails::Engine
end
end

initializer "solid_errors.mime_types" do
Mime::Type.register "text/markdown", :md unless Mime[:md]
end

initializer "solid_errors.active_record.error_subscriber" do
Rails.error.subscribe(SolidErrors::Subscriber.new)
end
Expand Down
2 changes: 2 additions & 0 deletions test/dummy/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@

# Defines the root path route ("/")
# root "posts#index"

mount SolidErrors::Engine => "/solid_errors"
end
45 changes: 45 additions & 0 deletions test/integration/solid_errors/markdown_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
require "test_helper"

class SolidErrors::MarkdownTest < ActionDispatch::IntegrationTest
setup do
begin
raise StandardError, "Something <went> wrong"
rescue => exception
Rails.error.report(exception, context: {controller: "UsersController", action: "show"})
end

@error = SolidErrors::Error.last
@occurrence = @error.occurrences.last
end

test "renders the occurrence as a plain text prompt" do
get "/solid_errors/#{@error.id}/occurrences/#{@occurrence.id}.md"

assert_response :success
assert_equal "text/plain", response.media_type

assert_includes response.body, "Investigate and fix the root cause of this error:"
assert_includes response.body, "<class>StandardError</class>"
assert_includes response.body, "<backtrace>"
assert_includes response.body, "\"controller\": \"UsersController\""
# Not HTML escaped: the exception message survives verbatim.
assert_includes response.body, "<message>Something <went> wrong</message>"
end

test "404s when the occurrence does not belong to the error" do
other = SolidErrors::Error.create!(exception_class: "Other", message: "other",
severity: "error", fingerprint: "other")

get "/solid_errors/#{other.id}/occurrences/#{@occurrence.id}.md"

assert_response :not_found
end

test "the error page links to the copy prompt" do
get "/solid_errors/#{@error.id}"

assert_response :success
assert_includes response.body, "Copy for LLM"
assert_includes response.body, "/solid_errors/#{@error.id}/occurrences/#{@occurrence.id}"
end
end