diff --git a/app/views/layouts/solid_errors/application.html.erb b/app/views/layouts/solid_errors/application.html.erb index 1c353bc..3c26af4 100644 --- a/app/views/layouts/solid_errors/application.html.erb +++ b/app/views/layouts/solid_errors/application.html.erb @@ -51,6 +51,39 @@ document.querySelectorAll('[data-controller="fade"]').forEach(element => { fadeOut(element); }); + + function copyClaudeAnalysis(event) { + const button = event.currentTarget; + const text = button.getAttribute('data-claude-text'); + + // Create a temporary textarea to copy the text + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + + // Select and copy the text + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + + // Update button text to show success + const buttonText = document.getElementById('copy-button-text'); + const originalText = buttonText.textContent; + buttonText.textContent = 'Copied!'; + + // Update button colors for visual feedback + button.classList.remove('text-purple-600', 'border-purple-600', 'hover:ring-purple-200'); + button.classList.add('text-green-600', 'border-green-600', 'hover:ring-green-200'); + + // Reset button text after 2 seconds + setTimeout(() => { + buttonText.textContent = originalText; + button.classList.remove('text-green-600', 'border-green-600', 'hover:ring-green-200'); + button.classList.add('text-purple-600', 'border-purple-600', 'hover:ring-purple-200'); + }, 2000); + } diff --git a/app/views/solid_errors/errors/_actions.html.erb b/app/views/solid_errors/errors/_actions.html.erb index da60125..1ff78e3 100644 --- a/app/views/solid_errors/errors/_actions.html.erb +++ b/app/views/solid_errors/errors/_actions.html.erb @@ -5,6 +5,22 @@ Back to errors <% end %> + + <% llm_text = capture do %> + <%= render 'solid_errors/errors/llm_text', error: error, occurrences: occurrences %> + <% end %> + <% # Strip HTML comments that Rails adds in development mode %> + <% clean_llm_text = llm_text.gsub(//m, '').strip %> + + <% if error.resolved? %> <%= render 'solid_errors/errors/delete_button', error: error %> <% else %> diff --git a/app/views/solid_errors/errors/_error.html.erb b/app/views/solid_errors/errors/_error.html.erb index 45b0aeb..d2faec7 100644 --- a/app/views/solid_errors/errors/_error.html.erb +++ b/app/views/solid_errors/errors/_error.html.erb @@ -45,10 +45,13 @@
<% first_seen_at = error.occurrences.minimum(:created_at) %> - - - <%= time_tag first_seen_at, "#{time_ago_in_words(first_seen_at)} ago" %> - + <% if first_seen_at %> + + <%= time_tag first_seen_at, "#{time_ago_in_words(first_seen_at)} ago" %> + + <% else %> + N/A + <% end %>
@@ -57,10 +60,13 @@
<% last_seen_at = error.occurrences.maximum(:created_at) %> - - - <%= time_tag last_seen_at, "#{time_ago_in_words(last_seen_at)} ago" %> - + <% if last_seen_at %> + + <%= time_tag last_seen_at, "#{time_ago_in_words(last_seen_at)} ago" %> + + <% else %> + N/A + <% end %>
@@ -101,5 +107,5 @@
- <%= render "solid_errors/errors/actions", error: error if show_actions %> + <%= render "solid_errors/errors/actions", error: error, occurrences: @occurrences if show_actions %> <% end %> diff --git a/app/views/solid_errors/errors/_llm_text.html.erb b/app/views/solid_errors/errors/_llm_text.html.erb new file mode 100644 index 0000000..bbfc7ce --- /dev/null +++ b/app/views/solid_errors/errors/_llm_text.html.erb @@ -0,0 +1,77 @@ +Error Analysis Request: + +EXCEPTION: <%= error.exception_class %> +MESSAGE: <%= error.message %> +SEVERITY: <%= error.severity %> +SOURCE: <%= error.source %> +STATUS: <%= error.resolved? ? 'Resolved' : 'Unresolved' %> + +OCCURRENCES: <%= error.occurrences.count %> total +First seen: <%= error.occurrences.minimum(:created_at)&.iso8601 || 'N/A' %> +Last seen: <%= error.occurrences.maximum(:created_at)&.iso8601 || 'N/A' %> + +<% if occurrences&.any? %> +MOST RECENT OCCURRENCE: +<% most_recent = occurrences.first %> +Timestamp: <%= most_recent.created_at.iso8601 %> +<% if most_recent.context.present? %> +Context: +<% most_recent.context.each do |key, value| %> + <%= key %>: <%= value %> +<% end %> +<% end %> + +BACKTRACE: +<% + lines = most_recent.parsed_backtrace.lines + filtered_lines = [] + + lines.each_with_index do |line, i| + if line.application? + # Add the previous line if it exists and is from GEM_ROOT + if i > 0 && !lines[i-1].application? + filtered_lines << lines[i-1] unless filtered_lines.last == lines[i-1] + end + + # Add the current application line + filtered_lines << line + + # Add the next line if it exists and is from GEM_ROOT + if i < lines.length - 1 && !lines[i+1].application? + filtered_lines << lines[i+1] + end + elsif i == 0 + # Always include the first line if it's not an application line + filtered_lines << line + end + end + + # If no application lines, show first few lines + filtered_lines = lines.take(5) if filtered_lines.empty? + + prev_was_gem = false + collapsed_count = 0 + + filtered_lines.each_with_index do |line, i| + # Check if we should show a collapsed indicator + if i > 0 && !line.application? && !filtered_lines[i-1].application? + # Count how many GEM_ROOT lines were skipped + original_index = lines.index(line) + prev_original_index = lines.index(filtered_lines[i-1]) + if original_index && prev_original_index + skipped = original_index - prev_original_index - 1 + if skipped > 0 +%> ... (<%= skipped %> GEM_ROOT frames omitted) ... +<% + end + end + end +%><%= lines.index(line) + 1 %>. <%= line.filtered_file || line.unparsed_line %><% if line.filtered_number %>:<%= line.filtered_number %><% end %><% if line.filtered_method %> in `<%= line.filtered_method %>`<% end %> +<% end %> +<% end %> + +Please analyze this error and suggest: +1. Root cause of the error +2. Potential fixes +3. Any patterns or anti-patterns you notice +4. Recommendations for preventing similar errors \ No newline at end of file diff --git a/test/controllers/solid_errors/errors_controller_test.rb b/test/controllers/solid_errors/errors_controller_test.rb new file mode 100644 index 0000000..680f2a3 --- /dev/null +++ b/test/controllers/solid_errors/errors_controller_test.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require "test_helper" + +module SolidErrors + class ErrorsControllerTest < ActionDispatch::IntegrationTest + setup do + # Create a test error with occurrences + @error = SolidErrors::Error.create!( + exception_class: "StandardError", + message: "Test error message for Claude analysis", + severity: "error", + source: "application", + fingerprint: Digest::SHA256.hexdigest("test-error") + ) + + # Create a more realistic backtrace with mix of GEM_ROOT and PROJECT_ROOT + rails_root = Rails.root.to_s + @occurrence = SolidErrors::Occurrence.create!( + error: @error, + backtrace: [ + "#{Gem.path.first}/gems/actionpack-7.0.0/lib/action_dispatch.rb:10:in `call'", + "#{Gem.path.first}/gems/rack-2.0.0/lib/rack.rb:20:in `call'", + "#{Gem.path.first}/gems/rails-7.0.0/lib/rails.rb:30:in `call'", + "#{rails_root}/app/controllers/test_controller.rb:10:in `index'", + "#{rails_root}/app/models/test_model.rb:25:in `process'", + "#{Gem.path.first}/gems/activerecord-7.0.0/lib/active_record.rb:100:in `execute'", + "#{Gem.path.first}/gems/activerecord-7.0.0/lib/active_record.rb:110:in `query'", + "#{Gem.path.first}/gems/activerecord-7.0.0/lib/active_record.rb:120:in `select_all'", + "#{rails_root}/app/services/data_processor.rb:45:in `fetch_data'", + "#{Gem.path.first}/gems/sidekiq-6.0.0/lib/sidekiq.rb:200:in `perform'" + ].join("\n"), + context: { + request_url: "http://example.com/test", + user_id: 123, + params: { id: 1 }.to_json + } + ) + end + + teardown do + SolidErrors::Occurrence.destroy_all + SolidErrors::Error.destroy_all + end + + test "should show error with Claude analysis section" do + get "/solid_errors/#{@error.id}" + assert_response :success + + # Check that the LLM copy button is present with icon + assert_select "button[onclick='copyClaudeAnalysis(event)']" do + assert_select "svg" # Check for copy icon + assert_select "span", text: "Copy for LLM" + end + end + + test "Claude analysis section contains error details" do + get "/solid_errors/#{@error.id}" + assert_response :success + + # Check that the button with error details in data attribute exists + assert_select "button[data-claude-text]" do |elements| + button_content = elements.first['data-claude-text'] + + # Check for key error information + assert_match(/EXCEPTION: StandardError/, button_content) + assert_match(/MESSAGE: Test error message for Claude analysis/, button_content) + assert_match(/SEVERITY: error/, button_content) + assert_match(/SOURCE: application/, button_content) + assert_match(/STATUS: Unresolved/, button_content) + + # Check for occurrence information + assert_match(/OCCURRENCES: 1 total/, button_content) + assert_match(/MOST RECENT OCCURRENCE:/, button_content) + + # Check for context + assert_match(/Context:/, button_content) + assert_match(/request_url:/, button_content) + assert_match(/user_id:/, button_content) + + # Check for backtrace + assert_match(/BACKTRACE:/, button_content) + assert_match(/test_controller\.rb/, button_content) + assert_match(/test_model\.rb/, button_content) + assert_match(/data_processor\.rb/, button_content) + + # Check for analysis prompt + assert_match(/Please analyze this error and suggest:/, button_content) + assert_match(/1\. Root cause of the error/, button_content) + assert_match(/2\. Potential fixes/, button_content) + assert_match(/3\. Any patterns or anti-patterns you notice/, button_content) + assert_match(/4\. Recommendations for preventing similar errors/, button_content) + end + end + + test "Claude analysis section handles errors without occurrences" do + # Create an error without occurrences + error_without_occurrences = SolidErrors::Error.create!( + exception_class: "NoMethodError", + message: "undefined method `foo' for nil:NilClass", + severity: "error", + source: "application", + fingerprint: Digest::SHA256.hexdigest("test-error-no-occurrences") + ) + + get "/solid_errors/#{error_without_occurrences.id}" + assert_response :success + + # Should still have the Claude analysis button + assert_select "button[onclick='copyClaudeAnalysis(event)']" + + # Check that it handles missing occurrence data gracefully + assert_select "button[data-claude-text]" do |elements| + button_content = elements.first['data-claude-text'] + + assert_match(/EXCEPTION: NoMethodError/, button_content) + assert_match(/OCCURRENCES: 0 total/, button_content) + # Should not have MOST RECENT OCCURRENCE section when there are no occurrences + assert_no_match(/MOST RECENT OCCURRENCE:/, button_content) + end + end + + test "Claude analysis button has onclick handler" do + get "/solid_errors/#{@error.id}" + assert_response :success + + # Check that the button has the onclick handler for copying + assert_select "button[onclick='copyClaudeAnalysis(event)']" + + # Check that the JavaScript function is defined + assert_match(/function copyClaudeAnalysis/, response.body) + end + + test "Claude analysis section filters backtrace intelligently" do + get "/solid_errors/#{@error.id}" + assert_response :success + + # Check that the button contains filtered backtrace + assert_select "button[data-claude-text]" do |elements| + button_content = elements.first['data-claude-text'] + + # Should include PROJECT_ROOT frames + assert_match(/\[PROJECT_ROOT\]\/app\/controllers\/test_controller\.rb/, button_content) + assert_match(/\[PROJECT_ROOT\]\/app\/models\/test_model\.rb/, button_content) + assert_match(/\[PROJECT_ROOT\]\/app\/services\/data_processor\.rb/, button_content) + + # Should include some GEM_ROOT frames (context around PROJECT_ROOT) + assert_match(/\[GEM_ROOT\]/, button_content) + + # Should show omission indicator when multiple GEM_ROOT frames are collapsed + assert_match(/\.\.\. \(\d+ GEM_ROOT frames omitted\) \.\.\./, button_content) || + assert_match(/BACKTRACE:/, button_content) # Allow for cases where no omission occurs + + # Should NOT include all the intermediate GEM_ROOT frames + backtrace_section = button_content.split("BACKTRACE:").last.split("Please analyze").first + gem_root_count = backtrace_section.scan(/\[GEM_ROOT\]/).count + + # We expect fewer GEM_ROOT entries than the original (which had 7) + assert gem_root_count < 7, "Expected filtered backtrace to have fewer GEM_ROOT entries, but found #{gem_root_count}" + end + end + end +end \ No newline at end of file