rails_error_dashboard 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -3
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +81 -4
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +192 -0
  5. data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +71 -0
  6. data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +43 -0
  7. data/app/jobs/rails_error_dashboard/create_issue_job.rb +68 -0
  8. data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +44 -0
  9. data/app/models/rails_error_dashboard/error_log.rb +2 -1
  10. data/app/views/layouts/rails_error_dashboard.html.erb +19 -6
  11. data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +1 -1
  12. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +92 -100
  13. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +121 -0
  14. data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +1 -0
  15. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +77 -73
  16. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
  17. data/app/views/rails_error_dashboard/errors/show.html.erb +13 -9
  18. data/config/routes.rb +6 -1
  19. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +5 -0
  20. data/db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb +15 -0
  21. data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -4
  22. data/lib/rails_error_dashboard/commands/create_issue.rb +59 -0
  23. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +65 -0
  24. data/lib/rails_error_dashboard/configuration.rb +99 -0
  25. data/lib/rails_error_dashboard/engine.rb +39 -0
  26. data/lib/rails_error_dashboard/queries/active_storage_summary.rb +101 -0
  27. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +122 -0
  28. data/lib/rails_error_dashboard/services/github_issue_client.rb +117 -0
  29. data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
  30. data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +121 -0
  31. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
  32. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +168 -0
  33. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +12 -0
  34. data/lib/rails_error_dashboard/subscribers/active_storage_subscriber.rb +112 -0
  35. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +71 -0
  36. data/lib/rails_error_dashboard/version.rb +1 -1
  37. data/lib/rails_error_dashboard.rb +11 -1
  38. metadata +21 -3
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Aggregate ActiveStorage events from breadcrumbs across all errors
6
+ # Scans error_logs breadcrumbs JSON, filters for "active_storage" category crumbs,
7
+ # and groups by service name with counts by operation type.
8
+ class ActiveStorageSummary
9
+ def self.call(days = 30, application_id: nil)
10
+ new(days, application_id: application_id).call
11
+ end
12
+
13
+ def initialize(days = 30, application_id: nil)
14
+ @days = days
15
+ @application_id = application_id
16
+ @start_date = days.days.ago
17
+ end
18
+
19
+ def call
20
+ {
21
+ services: aggregated_services
22
+ }
23
+ end
24
+
25
+ private
26
+
27
+ def base_query
28
+ scope = ErrorLog.where("occurred_at >= ?", @start_date)
29
+ .where.not(breadcrumbs: nil)
30
+ scope = scope.where(application_id: @application_id) if @application_id.present?
31
+ scope
32
+ end
33
+
34
+ def aggregated_services
35
+ results = {}
36
+
37
+ base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
38
+ crumbs = parse_breadcrumbs(error_log.breadcrumbs)
39
+ next if crumbs.empty?
40
+
41
+ as_crumbs = crumbs.select { |c| c["c"] == "active_storage" }
42
+ next if as_crumbs.empty?
43
+
44
+ as_crumbs.each do |crumb|
45
+ meta = crumb["meta"] || {}
46
+ service = meta["service"].to_s.presence || "Unknown"
47
+ operation = meta["operation"].to_s
48
+
49
+ results[service] ||= {
50
+ service: service,
51
+ upload_count: 0,
52
+ download_count: 0,
53
+ delete_count: 0,
54
+ exist_count: 0,
55
+ error_ids: [],
56
+ durations: [],
57
+ last_seen: nil
58
+ }
59
+
60
+ entry = results[service]
61
+
62
+ case operation
63
+ when "upload"
64
+ entry[:upload_count] += 1
65
+ when "download", "streaming_download"
66
+ entry[:download_count] += 1
67
+ when "delete", "delete_prefixed"
68
+ entry[:delete_count] += 1
69
+ when "exist"
70
+ entry[:exist_count] += 1
71
+ end
72
+
73
+ entry[:durations] << crumb["d"] if crumb["d"].is_a?(Numeric) && crumb["d"] > 0
74
+ entry[:error_ids] << error_log.id
75
+ entry[:last_seen] = [ entry[:last_seen], error_log.occurred_at ].compact.max
76
+ end
77
+ end
78
+
79
+ results.values.each do |r|
80
+ r[:error_ids] = r[:error_ids].uniq
81
+ r[:error_count] = r[:error_ids].size
82
+ r[:total_operations] = r[:upload_count] + r[:download_count] + r[:delete_count] + r[:exist_count]
83
+ r[:avg_duration_ms] = r[:durations].any? ? (r[:durations].sum / r[:durations].size).round(2) : nil
84
+ r[:slowest_ms] = r[:durations].max
85
+ r.delete(:durations)
86
+ end
87
+ results.values.sort_by { |r| [ -r[:total_operations], -r[:error_count] ] }
88
+ rescue => e
89
+ Rails.logger.error("[RailsErrorDashboard] ActiveStorageSummary query failed: #{e.class}: #{e.message}")
90
+ []
91
+ end
92
+
93
+ def parse_breadcrumbs(raw)
94
+ return [] if raw.blank?
95
+ JSON.parse(raw)
96
+ rescue JSON::ParserError
97
+ []
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Codeberg/Gitea/Forgejo REST API client for issue management.
6
+ #
7
+ # Codeberg runs Forgejo (hard fork of Gitea). The API is compatible with
8
+ # Gitea's /api/v1/ endpoints. Works with any Gitea or Forgejo instance.
9
+ #
10
+ # API Docs: https://docs.gitea.com/development/api-usage
11
+ # Codeberg: https://codeberg.org/api/swagger
12
+ # Auth: Personal access token
13
+ class CodebergIssueClient < IssueTrackerClient
14
+ def initialize(token:, repo:, api_url: nil)
15
+ super
16
+ @api_url = api_url || "https://codeberg.org/api/v1"
17
+ end
18
+
19
+ def create_issue(title:, body:, labels: [])
20
+ response = http_post(
21
+ "#{@api_url}/repos/#{@repo}/issues",
22
+ { title: title, body: truncate_body(body) },
23
+ auth_headers
24
+ )
25
+ # Note: Gitea/Forgejo labels require label IDs, not names.
26
+ # We skip labels in the create call — users can add them on the platform.
27
+
28
+ if response[:status] == 201
29
+ data = response[:body]
30
+ success_response(url: data["html_url"], number: data["number"])
31
+ else
32
+ error_response("Codeberg API error (#{response[:status]}): #{response[:body]&.dig("message") || response[:error]}")
33
+ end
34
+ end
35
+
36
+ def close_issue(number:)
37
+ response = http_patch(
38
+ "#{@api_url}/repos/#{@repo}/issues/#{number}",
39
+ { state: "closed" },
40
+ auth_headers
41
+ )
42
+
43
+ response[:status] == 201 ? success_response({}) : error_response("Codeberg API error (#{response[:status]})")
44
+ end
45
+
46
+ def reopen_issue(number:)
47
+ response = http_patch(
48
+ "#{@api_url}/repos/#{@repo}/issues/#{number}",
49
+ { state: "open" },
50
+ auth_headers
51
+ )
52
+
53
+ response[:status] == 201 ? success_response({}) : error_response("Codeberg API error (#{response[:status]})")
54
+ end
55
+
56
+ def add_comment(number:, body:)
57
+ response = http_post(
58
+ "#{@api_url}/repos/#{@repo}/issues/#{number}/comments",
59
+ { body: truncate_body(body) },
60
+ auth_headers
61
+ )
62
+
63
+ if response[:status] == 201
64
+ success_response(url: response[:body]["html_url"])
65
+ else
66
+ error_response("Codeberg API error (#{response[:status]})")
67
+ end
68
+ end
69
+
70
+ def fetch_comments(number:, per_page: 10)
71
+ response = http_get(
72
+ "#{@api_url}/repos/#{@repo}/issues/#{number}/comments?limit=#{per_page}",
73
+ auth_headers
74
+ )
75
+
76
+ if response[:status] == 200
77
+ comments = (response[:body] || []).map { |c|
78
+ {
79
+ author: c.dig("user", "login"),
80
+ avatar_url: c.dig("user", "avatar_url"),
81
+ body: c["body"],
82
+ created_at: c["created_at"],
83
+ url: c["html_url"]
84
+ }
85
+ }
86
+ success_response(comments: comments)
87
+ else
88
+ error_response("Codeberg API error (#{response[:status]})")
89
+ end
90
+ end
91
+
92
+ def fetch_issue(number:)
93
+ response = http_get(
94
+ "#{@api_url}/repos/#{@repo}/issues/#{number}",
95
+ auth_headers
96
+ )
97
+
98
+ if response[:status] == 200
99
+ data = response[:body]
100
+ success_response(
101
+ state: data["state"],
102
+ title: data["title"],
103
+ assignees: (data["assignees"] || []).map { |a|
104
+ { login: a["login"], avatar_url: a["avatar_url"] }
105
+ },
106
+ labels: (data["labels"] || []).map { |l|
107
+ { name: l["name"], color: l["color"] }
108
+ }
109
+ )
110
+ else
111
+ error_response("Codeberg API error (#{response[:status]})")
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def auth_headers
118
+ { "Authorization" => "token #{@token}" }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # GitHub REST API client for issue management.
6
+ #
7
+ # API Docs: https://docs.github.com/en/rest/issues/issues
8
+ # Auth: Personal access token with `repo` scope
9
+ # Rate limit: 5,000 requests/hour per authenticated user
10
+ class GitHubIssueClient < IssueTrackerClient
11
+ def initialize(token:, repo:, api_url: nil)
12
+ super
13
+ @api_url = api_url || "https://api.github.com"
14
+ end
15
+
16
+ def create_issue(title:, body:, labels: [])
17
+ response = http_post(
18
+ "#{@api_url}/repos/#{@repo}/issues",
19
+ { title: title, body: truncate_body(body), labels: labels },
20
+ auth_headers
21
+ )
22
+
23
+ if response[:status] == 201
24
+ data = response[:body]
25
+ success_response(url: data["html_url"], number: data["number"])
26
+ else
27
+ error_response("GitHub API error (#{response[:status]}): #{response[:body]&.dig("message") || response[:error]}")
28
+ end
29
+ end
30
+
31
+ def close_issue(number:)
32
+ response = http_patch(
33
+ "#{@api_url}/repos/#{@repo}/issues/#{number}",
34
+ { state: "closed" },
35
+ auth_headers
36
+ )
37
+
38
+ response[:status] == 200 ? success_response({}) : error_response("GitHub API error (#{response[:status]})")
39
+ end
40
+
41
+ def reopen_issue(number:)
42
+ response = http_patch(
43
+ "#{@api_url}/repos/#{@repo}/issues/#{number}",
44
+ { state: "open" },
45
+ auth_headers
46
+ )
47
+
48
+ response[:status] == 200 ? success_response({}) : error_response("GitHub API error (#{response[:status]})")
49
+ end
50
+
51
+ def add_comment(number:, body:)
52
+ response = http_post(
53
+ "#{@api_url}/repos/#{@repo}/issues/#{number}/comments",
54
+ { body: truncate_body(body) },
55
+ auth_headers
56
+ )
57
+
58
+ if response[:status] == 201
59
+ success_response(url: response[:body]["html_url"])
60
+ else
61
+ error_response("GitHub API error (#{response[:status]})")
62
+ end
63
+ end
64
+
65
+ def fetch_comments(number:, per_page: 10)
66
+ response = http_get(
67
+ "#{@api_url}/repos/#{@repo}/issues/#{number}/comments?per_page=#{per_page}&sort=created&direction=desc",
68
+ auth_headers
69
+ )
70
+
71
+ if response[:status] == 200
72
+ comments = (response[:body] || []).map { |c|
73
+ {
74
+ author: c.dig("user", "login"),
75
+ avatar_url: c.dig("user", "avatar_url"),
76
+ body: c["body"],
77
+ created_at: c["created_at"],
78
+ url: c["html_url"]
79
+ }
80
+ }
81
+ success_response(comments: comments)
82
+ else
83
+ error_response("GitHub API error (#{response[:status]})")
84
+ end
85
+ end
86
+
87
+ def fetch_issue(number:)
88
+ response = http_get(
89
+ "#{@api_url}/repos/#{@repo}/issues/#{number}",
90
+ auth_headers
91
+ )
92
+
93
+ if response[:status] == 200
94
+ data = response[:body]
95
+ success_response(
96
+ state: data["state"],
97
+ title: data["title"],
98
+ assignees: (data["assignees"] || []).map { |a|
99
+ { login: a["login"], avatar_url: a["avatar_url"] }
100
+ },
101
+ labels: (data["labels"] || []).map { |l|
102
+ { name: l["name"], color: l["color"] }
103
+ }
104
+ )
105
+ else
106
+ error_response("GitHub API error (#{response[:status]})")
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def auth_headers
113
+ { "Authorization" => "Bearer #{@token}", "Accept" => "application/vnd.github+json" }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -53,6 +53,8 @@ module RailsErrorDashboard
53
53
  generate_gitlab_link(normalized_repo, reference)
54
54
  when :bitbucket
55
55
  generate_bitbucket_link(normalized_repo, reference)
56
+ when :codeberg
57
+ generate_codeberg_link(normalized_repo, reference)
56
58
  else
57
59
  @error = "Unsupported repository type"
58
60
  nil
@@ -77,13 +79,14 @@ module RailsErrorDashboard
77
79
 
78
80
  # Detect repository type from URL
79
81
  #
80
- # @return [Symbol] :github, :gitlab, :bitbucket, or :unknown
82
+ # @return [Symbol] :github, :gitlab, :bitbucket, :codeberg, or :unknown
81
83
  def detect_repository_type
82
84
  normalized = normalize_repository_url.downcase
83
85
 
84
86
  return :github if normalized.include?("github.com")
85
87
  return :gitlab if normalized.include?("gitlab.com") || normalized.include?("gitlab.")
86
88
  return :bitbucket if normalized.include?("bitbucket.org") || normalized.include?("bitbucket.")
89
+ return :codeberg if normalized.include?("codeberg.org") || normalized.include?("gitea.") || normalized.include?("forgejo.")
87
90
 
88
91
  :unknown
89
92
  end
@@ -154,6 +157,21 @@ module RailsErrorDashboard
154
157
  normalized_path = normalize_file_path
155
158
  "#{repo_url}/src/#{ref}/#{normalized_path}#lines-#{line_number}"
156
159
  end
160
+
161
+ # Generate Codeberg/Gitea/Forgejo link
162
+ #
163
+ # Format: https://codeberg.org/user/repo/src/commit/{ref}/path/to/file.rb#L42
164
+ # Same as GitHub's /blob/ but Codeberg uses /src/commit/ or /src/branch/
165
+ #
166
+ # @param repo_url [String] Normalized repository URL
167
+ # @param ref [String] Commit SHA or branch name
168
+ # @return [String]
169
+ def generate_codeberg_link(repo_url, ref)
170
+ normalized_path = normalize_file_path
171
+ # Codeberg uses /src/commit/{sha} for commits and /src/branch/{name} for branches
172
+ ref_type = ref.match?(/\A[0-9a-f]{7,40}\z/i) ? "commit" : "branch"
173
+ "#{repo_url}/src/#{ref_type}/#{ref}/#{normalized_path}#L#{line_number}"
174
+ end
157
175
  end
158
176
  end
159
177
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # GitLab REST API client for issue management.
6
+ #
7
+ # API Docs: https://docs.gitlab.com/api/issues/
8
+ # Auth: Personal access token or project access token
9
+ # Project ID: URL-encoded path ("user%2Frepo") or numeric ID
10
+ class GitLabIssueClient < IssueTrackerClient
11
+ def initialize(token:, repo:, api_url: nil)
12
+ super
13
+ @api_url = api_url || "https://gitlab.com/api/v4"
14
+ @encoded_repo = URI.encode_www_form_component(@repo)
15
+ end
16
+
17
+ def create_issue(title:, body:, labels: [])
18
+ response = http_post(
19
+ "#{@api_url}/projects/#{@encoded_repo}/issues",
20
+ { title: title, description: truncate_body(body), labels: labels.join(",") },
21
+ auth_headers
22
+ )
23
+
24
+ if response[:status] == 201
25
+ data = response[:body]
26
+ success_response(url: data["web_url"], number: data["iid"])
27
+ else
28
+ error_response("GitLab API error (#{response[:status]}): #{response[:body]&.dig("message") || response[:error]}")
29
+ end
30
+ end
31
+
32
+ def close_issue(number:)
33
+ response = http_put(
34
+ "#{@api_url}/projects/#{@encoded_repo}/issues/#{number}",
35
+ { state_event: "close" },
36
+ auth_headers
37
+ )
38
+
39
+ response[:status] == 200 ? success_response({}) : error_response("GitLab API error (#{response[:status]})")
40
+ end
41
+
42
+ def reopen_issue(number:)
43
+ response = http_put(
44
+ "#{@api_url}/projects/#{@encoded_repo}/issues/#{number}",
45
+ { state_event: "reopen" },
46
+ auth_headers
47
+ )
48
+
49
+ response[:status] == 200 ? success_response({}) : error_response("GitLab API error (#{response[:status]})")
50
+ end
51
+
52
+ def add_comment(number:, body:)
53
+ response = http_post(
54
+ "#{@api_url}/projects/#{@encoded_repo}/issues/#{number}/notes",
55
+ { body: truncate_body(body) },
56
+ auth_headers
57
+ )
58
+
59
+ if response[:status] == 201
60
+ # GitLab notes don't have a direct URL — construct from issue URL + note anchor
61
+ note_id = response[:body]["id"]
62
+ issue_url = "#{@api_url.sub("/api/v4", "")}/#{@repo}/-/issues/#{number}#note_#{note_id}"
63
+ success_response(url: issue_url)
64
+ else
65
+ error_response("GitLab API error (#{response[:status]})")
66
+ end
67
+ end
68
+
69
+ def fetch_comments(number:, per_page: 10)
70
+ response = http_get(
71
+ "#{@api_url}/projects/#{@encoded_repo}/issues/#{number}/notes?per_page=#{per_page}&sort=desc",
72
+ auth_headers
73
+ )
74
+
75
+ if response[:status] == 200
76
+ comments = (response[:body] || []).reject { |n| n["system"] }.map { |n|
77
+ {
78
+ author: n.dig("author", "username"),
79
+ avatar_url: n.dig("author", "avatar_url"),
80
+ body: n["body"],
81
+ created_at: n["created_at"],
82
+ url: nil # GitLab notes don't have individual URLs in API response
83
+ }
84
+ }
85
+ success_response(comments: comments)
86
+ else
87
+ error_response("GitLab API error (#{response[:status]})")
88
+ end
89
+ end
90
+
91
+ def fetch_issue(number:)
92
+ response = http_get(
93
+ "#{@api_url}/projects/#{@encoded_repo}/issues/#{number}",
94
+ auth_headers
95
+ )
96
+
97
+ if response[:status] == 200
98
+ data = response[:body]
99
+ success_response(
100
+ state: data["state"],
101
+ title: data["title"],
102
+ assignees: (data["assignees"] || []).map { |a|
103
+ { login: a["username"], avatar_url: a["avatar_url"] }
104
+ },
105
+ labels: (data["labels"] || []).map { |l|
106
+ { name: l, color: nil }
107
+ }
108
+ )
109
+ else
110
+ error_response("GitLab API error (#{response[:status]})")
111
+ end
112
+ end
113
+
114
+ private
115
+
116
+ def auth_headers
117
+ { "PRIVATE-TOKEN" => @token }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Format error details as markdown for issue tracker body.
6
+ #
7
+ # Tailored for issue context — shorter than MarkdownErrorFormatter,
8
+ # includes a link back to the dashboard, and omits system health
9
+ # (not useful in a GitHub issue body).
10
+ #
11
+ # @example
12
+ # IssueBodyFormatter.call(error)
13
+ # # => "## NoMethodError\n\nundefined method 'foo'...\n\n[View in Dashboard](url)"
14
+ class IssueBodyFormatter
15
+ MAX_BACKTRACE_LINES = 20
16
+
17
+ def self.call(error, dashboard_url: nil)
18
+ new(error, dashboard_url).generate
19
+ rescue => e
20
+ "Error details could not be formatted: #{e.message}"
21
+ end
22
+
23
+ def initialize(error, dashboard_url)
24
+ @error = error
25
+ @dashboard_url = dashboard_url
26
+ end
27
+
28
+ def generate
29
+ sections = []
30
+
31
+ sections << heading_section
32
+ sections << backtrace_section
33
+ sections << cause_chain_section
34
+ sections << request_context_section
35
+ sections << environment_section
36
+ sections << metadata_section
37
+ sections << dashboard_link_section
38
+
39
+ sections.compact.join("\n\n")
40
+ rescue => e
41
+ "Error details could not be formatted."
42
+ end
43
+
44
+ private
45
+
46
+ def heading_section
47
+ "## #{@error.error_type}\n\n#{@error.message}"
48
+ end
49
+
50
+ def backtrace_section
51
+ raw = @error.backtrace
52
+ return nil if raw.blank?
53
+
54
+ lines = raw.split("\n")
55
+ app_lines = lines.reject { |l| l.include?("/gems/") || l.include?("/ruby/") || l.include?("/vendor/") }
56
+ app_lines = lines.first(MAX_BACKTRACE_LINES) if app_lines.empty?
57
+ app_lines = app_lines.first(MAX_BACKTRACE_LINES)
58
+
59
+ "### Backtrace\n\n```\n#{app_lines.join("\n")}\n```"
60
+ end
61
+
62
+ def cause_chain_section
63
+ raw = @error.exception_cause
64
+ return nil if raw.blank?
65
+
66
+ causes = parse_json(raw)
67
+ return nil unless causes.is_a?(Array) && causes.any?
68
+
69
+ items = causes.each_with_index.map { |cause, i|
70
+ "#{i + 1}. **#{cause["class_name"]}** — #{cause["message"]}"
71
+ }
72
+
73
+ "### Exception Cause Chain\n\n#{items.join("\n")}"
74
+ end
75
+
76
+ def request_context_section
77
+ return nil if @error.request_url.blank?
78
+
79
+ items = []
80
+ items << "- **Controller:** #{@error.controller_name}##{@error.action_name}" if @error.controller_name.present?
81
+ items << "- **Method:** #{@error.http_method}" if @error.http_method.present?
82
+ items << "- **URL:** #{@error.request_url}"
83
+ items << "- **Hostname:** #{@error.hostname}" if @error.hostname.present?
84
+
85
+ "### Request Context\n\n#{items.join("\n")}"
86
+ end
87
+
88
+ def environment_section
89
+ raw = @error.environment_info
90
+ return nil if raw.blank?
91
+
92
+ env = parse_json(raw)
93
+ return nil unless env.is_a?(Hash) && env.any?
94
+
95
+ items = []
96
+ items << "- **Ruby:** #{env["ruby_version"]}" if env["ruby_version"]
97
+ items << "- **Rails:** #{env["rails_version"]}" if env["rails_version"]
98
+ items << "- **Env:** #{env["rails_env"]}" if env["rails_env"]
99
+
100
+ return nil if items.empty?
101
+
102
+ "### Environment\n\n#{items.join("\n")}"
103
+ end
104
+
105
+ def metadata_section
106
+ items = []
107
+ items << "- **Platform:** #{@error.platform}" if @error.platform.present?
108
+ items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
109
+ items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
110
+
111
+ return nil if items.empty?
112
+
113
+ "### Metadata\n\n#{items.join("\n")}"
114
+ end
115
+
116
+ def dashboard_link_section
117
+ parts = []
118
+ parts << "[View in Dashboard](#{@dashboard_url})" if @dashboard_url.present?
119
+ parts << "Created by [RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)"
120
+
121
+ "---\n\n#{parts.join(" | ")}"
122
+ end
123
+
124
+ def parse_json(raw)
125
+ return nil if raw.blank?
126
+ JSON.parse(raw)
127
+ rescue JSON::ParserError
128
+ nil
129
+ end
130
+ end
131
+ end
132
+ end