rails_error_dashboard 0.5.7 → 0.5.8
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.
- checksums.yaml +4 -4
- data/README.md +27 -1
- data/app/controllers/rails_error_dashboard/errors_controller.rb +57 -4
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +192 -0
- data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +71 -0
- data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +43 -0
- data/app/jobs/rails_error_dashboard/create_issue_job.rb +68 -0
- data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +44 -0
- data/app/models/rails_error_dashboard/error_log.rb +2 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +19 -6
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +61 -102
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +67 -0
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -1
- data/config/routes.rb +7 -1
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +5 -0
- data/db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb +15 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -4
- data/lib/rails_error_dashboard/commands/create_issue.rb +59 -0
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +65 -0
- data/lib/rails_error_dashboard/configuration.rb +99 -0
- data/lib/rails_error_dashboard/engine.rb +39 -0
- data/lib/rails_error_dashboard/queries/active_storage_summary.rb +101 -0
- data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +99 -0
- data/lib/rails_error_dashboard/services/github_issue_client.rb +94 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
- data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +98 -0
- data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +162 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +12 -0
- data/lib/rails_error_dashboard/subscribers/active_storage_subscriber.rb +112 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +71 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +11 -1
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc2ad274b6cf17149a3d60eecb3c6cde4280379e8c74c870be26530641267f8b
|
|
4
|
+
data.tar.gz: b8bb6d9e448631bb294663b73d33379c7da33e0cba5ab97d79949b1860070cc3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2f1b87689dc7d3be38a036ead3d2e63236265ebe6a7135503e6cff3231a97f87b5203bc9f6b8da86e23735401f26616688adbe43bf2e225f6f3fce6cd808f0d3
|
|
7
|
+
data.tar.gz: da68e7b4b68b14f9a8dd349f7c5ccd0704844a4da2d77f97556e9cf3ebda1577d59ba54acb844a3d8de59704c16bf494065e6d23d83abfee07b6ca1a28b7afd8
|
data/README.md
CHANGED
|
@@ -131,15 +131,41 @@ Requires breadcrumbs to be enabled.
|
|
|
131
131
|
|
|
132
132
|

|
|
133
133
|
|
|
134
|
-
**ActionCable Health** — Track WebSocket channel actions, transmissions, subscription confirmations, and rejections. Dashboard page at `/errors/actioncable_health_summary` with channel breakdown sorted by rejections. System health snapshot captures live connection count and adapter.
|
|
134
|
+
**ActionCable Health** — Track WebSocket channel actions, transmissions, subscription confirmations, and rejections. Dashboard page at `/errors/actioncable_health_summary` with channel breakdown sorted by rejections. System health snapshot captures live connection count and adapter.
|
|
135
135
|
|
|
136
136
|
```ruby
|
|
137
137
|
config.enable_actioncable_tracking = true # requires enable_breadcrumbs = true
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
**ActiveStorage Health** — Track file uploads, downloads, deletes, and existence checks across storage services (Disk, S3, GCS, Azure — any ActiveStorage backend). Dashboard page at `/errors/activestorage_health_summary` with per-service operation counts, average and slowest durations. Helps identify slow storage operations correlating with errors.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
config.enable_activestorage_tracking = true # requires enable_breadcrumbs = true
|
|
144
|
+
```
|
|
145
|
+
|
|
140
146
|
[Complete documentation →](docs/FEATURES.md#job-health-page)
|
|
141
147
|
</details>
|
|
142
148
|
|
|
149
|
+
<details>
|
|
150
|
+
<summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
|
|
151
|
+
|
|
152
|
+
Create, link, and manage issues directly from error detail pages. Supports GitHub, GitLab, and Codeberg/Gitea/Forgejo with provider auto-detection.
|
|
153
|
+
|
|
154
|
+
- **Manual:** "Create Issue" button + "Link Existing Issue" URL input
|
|
155
|
+
- **Auto-create:** On first occurrence and/or severity threshold — configurable
|
|
156
|
+
- **Lifecycle sync:** Resolve → close issue, recur → reopen + comment (throttled)
|
|
157
|
+
- **Two-way webhooks:** Issue closed/reopened on platform syncs to dashboard
|
|
158
|
+
- **RED branding:** Issues show "Created by RED (Rails Error Dashboard)"
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
config.enable_issue_tracking = true
|
|
162
|
+
config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
163
|
+
# Provider and repo auto-detected from git_repository_url
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
[Complete documentation →](docs/guides/CONFIGURATION.md)
|
|
167
|
+
</details>
|
|
168
|
+
|
|
143
169
|
<details>
|
|
144
170
|
<summary><strong>Source Code Integration + Git Blame</strong></summary>
|
|
145
171
|
|
|
@@ -75,7 +75,7 @@ module RailsErrorDashboard
|
|
|
75
75
|
|
|
76
76
|
def show
|
|
77
77
|
# Eagerly load associations to avoid N+1 queries
|
|
78
|
-
# - comments:
|
|
78
|
+
# - comments: Audit trail (workflow comments from snooze/mute/status changes)
|
|
79
79
|
# - parent_cascade_patterns/child_cascade_patterns: Used if cascade detection is enabled
|
|
80
80
|
@error = ErrorLog.includes(:comments, :parent_cascade_patterns, :child_cascade_patterns).find(params[:id])
|
|
81
81
|
@related_errors = @error.related_errors(limit: 5, application_id: @current_application_id)
|
|
@@ -139,9 +139,41 @@ module RailsErrorDashboard
|
|
|
139
139
|
redirect_to error_path(result[:error])
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
-
def
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
def create_issue
|
|
143
|
+
dashboard_url = error_url(params[:id])
|
|
144
|
+
result = Commands::CreateIssue.call(params[:id], dashboard_url: dashboard_url)
|
|
145
|
+
|
|
146
|
+
if result[:success]
|
|
147
|
+
flash[:notice] = "Issue created: #{result[:issue_url]}"
|
|
148
|
+
else
|
|
149
|
+
flash[:alert] = "Failed to create issue: #{result[:error]}"
|
|
150
|
+
end
|
|
151
|
+
redirect_to error_path(params[:id])
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def link_issue
|
|
155
|
+
result = Commands::LinkExistingIssue.call(params[:id], issue_url: params[:issue_url])
|
|
156
|
+
|
|
157
|
+
if result[:success]
|
|
158
|
+
flash[:notice] = "Issue linked successfully"
|
|
159
|
+
else
|
|
160
|
+
flash[:alert] = "Failed to link issue: #{result[:error]}"
|
|
161
|
+
end
|
|
162
|
+
redirect_to error_path(params[:id])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def unlink_issue
|
|
166
|
+
error = ErrorLog.find(params[:id])
|
|
167
|
+
error.update!(
|
|
168
|
+
external_issue_url: nil,
|
|
169
|
+
external_issue_number: nil,
|
|
170
|
+
external_issue_provider: nil
|
|
171
|
+
)
|
|
172
|
+
flash[:notice] = "Issue unlinked"
|
|
173
|
+
redirect_to error_path(error)
|
|
174
|
+
rescue => e
|
|
175
|
+
flash[:alert] = "Failed to unlink issue: #{e.message}"
|
|
176
|
+
redirect_to error_path(params[:id])
|
|
145
177
|
end
|
|
146
178
|
|
|
147
179
|
def analytics
|
|
@@ -432,6 +464,27 @@ module RailsErrorDashboard
|
|
|
432
464
|
@pagy, @channels = pagy(:offset, all_channels, limit: params[:per_page] || 25)
|
|
433
465
|
end
|
|
434
466
|
|
|
467
|
+
def activestorage_health_summary
|
|
468
|
+
unless RailsErrorDashboard.configuration.enable_activestorage_tracking &&
|
|
469
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
470
|
+
flash[:alert] = "ActiveStorage tracking is not enabled. Enable enable_activestorage_tracking and enable_breadcrumbs in config/initializers/rails_error_dashboard.rb"
|
|
471
|
+
redirect_to errors_path
|
|
472
|
+
return
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
days = (params[:days] || 30).to_i
|
|
476
|
+
@days = days
|
|
477
|
+
result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
|
|
478
|
+
all_services = result[:services]
|
|
479
|
+
|
|
480
|
+
# Summary stats (computed before pagination)
|
|
481
|
+
@unique_services = all_services.size
|
|
482
|
+
@total_operations = all_services.sum { |s| s[:total_operations] }
|
|
483
|
+
@errors_with_storage = all_services.sum { |s| s[:error_count] }
|
|
484
|
+
|
|
485
|
+
@pagy, @services = pagy(:offset, all_services, limit: params[:per_page] || 25)
|
|
486
|
+
end
|
|
487
|
+
|
|
435
488
|
def diagnostic_dumps
|
|
436
489
|
unless RailsErrorDashboard.configuration.enable_diagnostic_dump
|
|
437
490
|
flash[:alert] = "Diagnostic dumps are not enabled. Enable them in config/initializers/rails_error_dashboard.rb"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Receives webhooks from GitHub/GitLab/Codeberg for two-way issue sync.
|
|
5
|
+
#
|
|
6
|
+
# When an issue is closed/reopened on the platform, the corresponding
|
|
7
|
+
# error in the dashboard is resolved/reopened to match.
|
|
8
|
+
#
|
|
9
|
+
# Security: HMAC signature verification for each provider.
|
|
10
|
+
# - GitHub: X-Hub-Signature-256 (HMAC-SHA256)
|
|
11
|
+
# - GitLab: X-Gitlab-Token (shared secret)
|
|
12
|
+
# - Codeberg: X-Gitea-Signature (HMAC-SHA256)
|
|
13
|
+
class WebhooksController < ActionController::Base
|
|
14
|
+
skip_before_action :verify_authenticity_token
|
|
15
|
+
|
|
16
|
+
before_action :verify_webhook_enabled
|
|
17
|
+
before_action :verify_signature
|
|
18
|
+
|
|
19
|
+
def receive
|
|
20
|
+
provider = params[:provider]
|
|
21
|
+
payload = parse_payload
|
|
22
|
+
|
|
23
|
+
return head :ok unless payload
|
|
24
|
+
|
|
25
|
+
case provider
|
|
26
|
+
when "github"
|
|
27
|
+
handle_github(payload)
|
|
28
|
+
when "gitlab"
|
|
29
|
+
handle_gitlab(payload)
|
|
30
|
+
when "codeberg"
|
|
31
|
+
handle_codeberg(payload)
|
|
32
|
+
else
|
|
33
|
+
head :not_found
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
head :ok
|
|
38
|
+
rescue => e
|
|
39
|
+
Rails.logger.error("[RailsErrorDashboard] Webhook error: #{e.class}: #{e.message}")
|
|
40
|
+
head :ok # Always return 200 to prevent webhook retries on our errors
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def verify_webhook_enabled
|
|
46
|
+
unless RailsErrorDashboard.configuration.enable_issue_webhooks
|
|
47
|
+
head :not_found
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def verify_signature
|
|
52
|
+
provider = params[:provider]
|
|
53
|
+
secret = RailsErrorDashboard.configuration.issue_webhook_secret
|
|
54
|
+
|
|
55
|
+
unless secret.present?
|
|
56
|
+
Rails.logger.warn("[RailsErrorDashboard] Webhook received but no issue_webhook_secret configured")
|
|
57
|
+
head :unauthorized
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
body = request.body.read
|
|
62
|
+
request.body.rewind
|
|
63
|
+
|
|
64
|
+
verified = case provider
|
|
65
|
+
when "github"
|
|
66
|
+
verify_github_signature(body, secret)
|
|
67
|
+
when "gitlab"
|
|
68
|
+
verify_gitlab_token(secret)
|
|
69
|
+
when "codeberg"
|
|
70
|
+
verify_codeberg_signature(body, secret)
|
|
71
|
+
else
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
head :unauthorized unless verified
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def verify_github_signature(body, secret)
|
|
79
|
+
signature = request.headers["X-Hub-Signature-256"]
|
|
80
|
+
return false unless signature
|
|
81
|
+
|
|
82
|
+
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, body)
|
|
83
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def verify_gitlab_token(secret)
|
|
87
|
+
token = request.headers["X-Gitlab-Token"]
|
|
88
|
+
return false unless token
|
|
89
|
+
|
|
90
|
+
ActiveSupport::SecurityUtils.secure_compare(secret, token)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def verify_codeberg_signature(body, secret)
|
|
94
|
+
signature = request.headers["X-Gitea-Signature"]
|
|
95
|
+
return false unless signature
|
|
96
|
+
|
|
97
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
|
|
98
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def parse_payload
|
|
102
|
+
JSON.parse(request.body.read)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
nil
|
|
105
|
+
ensure
|
|
106
|
+
request.body.rewind
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# GitHub: issues webhook fires with action: opened/closed/reopened
|
|
110
|
+
def handle_github(payload)
|
|
111
|
+
return unless payload["action"].in?(%w[closed reopened])
|
|
112
|
+
|
|
113
|
+
issue_number = payload.dig("issue", "number")
|
|
114
|
+
return unless issue_number
|
|
115
|
+
|
|
116
|
+
error = find_error_by_issue(issue_number, "github")
|
|
117
|
+
return unless error
|
|
118
|
+
|
|
119
|
+
case payload["action"]
|
|
120
|
+
when "closed"
|
|
121
|
+
resolve_error(error, "Closed on GitHub by #{payload.dig("sender", "login")}")
|
|
122
|
+
when "reopened"
|
|
123
|
+
reopen_error(error)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# GitLab: issue webhook fires with object_attributes.action
|
|
128
|
+
def handle_gitlab(payload)
|
|
129
|
+
action = payload.dig("object_attributes", "action")
|
|
130
|
+
return unless action.in?(%w[close reopen])
|
|
131
|
+
|
|
132
|
+
issue_iid = payload.dig("object_attributes", "iid")
|
|
133
|
+
return unless issue_iid
|
|
134
|
+
|
|
135
|
+
error = find_error_by_issue(issue_iid, "gitlab")
|
|
136
|
+
return unless error
|
|
137
|
+
|
|
138
|
+
case action
|
|
139
|
+
when "close"
|
|
140
|
+
resolve_error(error, "Closed on GitLab by #{payload.dig("user", "username")}")
|
|
141
|
+
when "reopen"
|
|
142
|
+
reopen_error(error)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Codeberg/Gitea/Forgejo: issue webhook fires with action
|
|
147
|
+
def handle_codeberg(payload)
|
|
148
|
+
return unless payload["action"].in?(%w[closed reopened])
|
|
149
|
+
|
|
150
|
+
issue_number = payload.dig("issue", "number")
|
|
151
|
+
return unless issue_number
|
|
152
|
+
|
|
153
|
+
error = find_error_by_issue(issue_number, "codeberg")
|
|
154
|
+
return unless error
|
|
155
|
+
|
|
156
|
+
case payload["action"]
|
|
157
|
+
when "closed"
|
|
158
|
+
resolve_error(error, "Closed on Codeberg by #{payload.dig("sender", "login")}")
|
|
159
|
+
when "reopened"
|
|
160
|
+
reopen_error(error)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def find_error_by_issue(issue_number, provider)
|
|
165
|
+
ErrorLog.find_by(
|
|
166
|
+
external_issue_number: issue_number,
|
|
167
|
+
external_issue_provider: provider
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def resolve_error(error, message)
|
|
172
|
+
return if error.resolved?
|
|
173
|
+
|
|
174
|
+
Commands::ResolveError.call(
|
|
175
|
+
error.id,
|
|
176
|
+
resolved_by_name: "Webhook",
|
|
177
|
+
resolution_comment: message
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def reopen_error(error)
|
|
182
|
+
return unless error.resolved?
|
|
183
|
+
|
|
184
|
+
error.update!(
|
|
185
|
+
resolved: false,
|
|
186
|
+
resolved_at: nil,
|
|
187
|
+
status: "new",
|
|
188
|
+
reopened_at: Time.current
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Background job to add a recurrence comment on a linked issue.
|
|
5
|
+
#
|
|
6
|
+
# Triggered via :on_error_recurred plugin hook.
|
|
7
|
+
# Throttled: max 1 comment per hour per error to prevent spam on
|
|
8
|
+
# high-frequency errors.
|
|
9
|
+
class AddIssueRecurrenceCommentJob < ApplicationJob
|
|
10
|
+
queue_as :error_notifications
|
|
11
|
+
|
|
12
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 2
|
|
13
|
+
discard_on ActiveRecord::RecordNotFound
|
|
14
|
+
|
|
15
|
+
THROTTLE_INTERVAL = 3600 # 1 hour
|
|
16
|
+
|
|
17
|
+
# Track last comment time per error to throttle
|
|
18
|
+
@@last_comment_at = {}
|
|
19
|
+
|
|
20
|
+
def perform(error_log_id)
|
|
21
|
+
# Throttle: max 1 comment per hour per error
|
|
22
|
+
if throttled?(error_log_id)
|
|
23
|
+
Rails.logger.debug("[RailsErrorDashboard] Skipping recurrence comment for error #{error_log_id} — throttled")
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
error = ErrorLog.find(error_log_id)
|
|
28
|
+
return unless error.external_issue_url.present? && error.external_issue_number.present?
|
|
29
|
+
|
|
30
|
+
client = Services::IssueTrackerClient.from_config
|
|
31
|
+
return unless client
|
|
32
|
+
|
|
33
|
+
comment = "Error occurred again (#{error.occurrence_count} total occurrences).\n\n"
|
|
34
|
+
comment += "- **Last seen:** #{error.last_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}\n"
|
|
35
|
+
comment += "- **First seen:** #{error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}"
|
|
36
|
+
comment += "\n\n---\n*[RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)*"
|
|
37
|
+
|
|
38
|
+
result = client.add_comment(number: error.external_issue_number, body: comment)
|
|
39
|
+
|
|
40
|
+
if result[:success]
|
|
41
|
+
record_comment(error_log_id)
|
|
42
|
+
Rails.logger.info("[RailsErrorDashboard] Added recurrence comment on issue ##{error.external_issue_number}")
|
|
43
|
+
else
|
|
44
|
+
Rails.logger.error("[RailsErrorDashboard] Failed to add recurrence comment: #{result[:error]}")
|
|
45
|
+
end
|
|
46
|
+
rescue ActiveRecord::RecordNotFound
|
|
47
|
+
# Error was deleted
|
|
48
|
+
rescue => e
|
|
49
|
+
Rails.logger.error("[RailsErrorDashboard] AddIssueRecurrenceCommentJob failed: #{e.class}: #{e.message}")
|
|
50
|
+
raise # retry
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def throttled?(error_log_id)
|
|
56
|
+
last = @@last_comment_at[error_log_id]
|
|
57
|
+
last && (Time.current - last) < THROTTLE_INTERVAL
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_comment(error_log_id)
|
|
61
|
+
@@last_comment_at[error_log_id] = Time.current
|
|
62
|
+
# Cleanup old entries to prevent memory growth
|
|
63
|
+
cleanup_stale_entries if @@last_comment_at.size > 1000
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cleanup_stale_entries
|
|
67
|
+
cutoff = Time.current - THROTTLE_INTERVAL
|
|
68
|
+
@@last_comment_at.reject! { |_, t| t < cutoff }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Background job to close a linked issue when an error is resolved.
|
|
5
|
+
#
|
|
6
|
+
# Triggered via :on_error_resolved plugin hook.
|
|
7
|
+
# Adds a comment before closing: "Resolved in Rails Error Dashboard by {name}"
|
|
8
|
+
class CloseLinkedIssueJob < ApplicationJob
|
|
9
|
+
queue_as :error_notifications
|
|
10
|
+
|
|
11
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 2
|
|
12
|
+
discard_on ActiveRecord::RecordNotFound
|
|
13
|
+
|
|
14
|
+
def perform(error_log_id)
|
|
15
|
+
error = ErrorLog.find(error_log_id)
|
|
16
|
+
return unless error.external_issue_url.present? && error.external_issue_number.present?
|
|
17
|
+
|
|
18
|
+
client = Services::IssueTrackerClient.from_config
|
|
19
|
+
return unless client
|
|
20
|
+
|
|
21
|
+
# Add resolution comment
|
|
22
|
+
resolved_by = error.resolved_by_name.presence || "a team member"
|
|
23
|
+
comment = "**Resolved** by #{resolved_by}."
|
|
24
|
+
comment += "\n\nResolution: #{error.resolution_comment}" if error.resolution_comment.present?
|
|
25
|
+
comment += "\n\n---\n*[RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)*"
|
|
26
|
+
|
|
27
|
+
client.add_comment(number: error.external_issue_number, body: comment)
|
|
28
|
+
|
|
29
|
+
# Close the issue
|
|
30
|
+
result = client.close_issue(number: error.external_issue_number)
|
|
31
|
+
if result[:success]
|
|
32
|
+
Rails.logger.info("[RailsErrorDashboard] Closed issue ##{error.external_issue_number} for error #{error_log_id}")
|
|
33
|
+
else
|
|
34
|
+
Rails.logger.error("[RailsErrorDashboard] Failed to close issue ##{error.external_issue_number}: #{result[:error]}")
|
|
35
|
+
end
|
|
36
|
+
rescue ActiveRecord::RecordNotFound
|
|
37
|
+
# Error was deleted
|
|
38
|
+
rescue => e
|
|
39
|
+
Rails.logger.error("[RailsErrorDashboard] CloseLinkedIssueJob failed: #{e.class}: #{e.message}")
|
|
40
|
+
raise # retry
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Background job for creating issues on GitHub/GitLab/Codeberg.
|
|
5
|
+
#
|
|
6
|
+
# Used by auto-create (triggered from plugin hooks) and can be
|
|
7
|
+
# called directly for deferred issue creation.
|
|
8
|
+
#
|
|
9
|
+
# Retry strategy: 3 attempts with exponential backoff.
|
|
10
|
+
# Circuit breaker: skips if >5 failures in the last 10 minutes
|
|
11
|
+
# (tracked via class-level counter, reset on success).
|
|
12
|
+
class CreateIssueJob < ApplicationJob
|
|
13
|
+
queue_as :error_notifications
|
|
14
|
+
|
|
15
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
16
|
+
discard_on ActiveRecord::RecordNotFound
|
|
17
|
+
|
|
18
|
+
# Simple circuit breaker — class-level failure tracking
|
|
19
|
+
@@recent_failures = []
|
|
20
|
+
CIRCUIT_BREAKER_THRESHOLD = 5
|
|
21
|
+
CIRCUIT_BREAKER_WINDOW = 600 # 10 minutes
|
|
22
|
+
|
|
23
|
+
def perform(error_log_id, dashboard_url: nil)
|
|
24
|
+
if circuit_open?
|
|
25
|
+
Rails.logger.warn("[RailsErrorDashboard] CreateIssueJob circuit breaker open — skipping")
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
result = Commands::CreateIssue.call(error_log_id, dashboard_url: dashboard_url)
|
|
30
|
+
|
|
31
|
+
if result[:success]
|
|
32
|
+
record_success
|
|
33
|
+
Rails.logger.info("[RailsErrorDashboard] Issue created for error #{error_log_id}: #{result[:issue_url]}")
|
|
34
|
+
else
|
|
35
|
+
record_failure
|
|
36
|
+
Rails.logger.error("[RailsErrorDashboard] Failed to create issue for error #{error_log_id}: #{result[:error]}")
|
|
37
|
+
# Don't retry on "already has a linked issue" or "not configured"
|
|
38
|
+
return if result[:error]&.include?("already has") || result[:error]&.include?("not configured")
|
|
39
|
+
raise "Issue creation failed: #{result[:error]}" # triggers retry
|
|
40
|
+
end
|
|
41
|
+
rescue ActiveRecord::RecordNotFound
|
|
42
|
+
# Error was deleted — discard silently
|
|
43
|
+
rescue => e
|
|
44
|
+
record_failure
|
|
45
|
+
raise # re-raise for retry
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def circuit_open?
|
|
51
|
+
cleanup_old_failures
|
|
52
|
+
@@recent_failures.size >= CIRCUIT_BREAKER_THRESHOLD
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def record_failure
|
|
56
|
+
@@recent_failures << Time.current
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def record_success
|
|
60
|
+
@@recent_failures.clear
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def cleanup_old_failures
|
|
64
|
+
cutoff = Time.current - CIRCUIT_BREAKER_WINDOW
|
|
65
|
+
@@recent_failures.reject! { |t| t < cutoff }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Background job to reopen a linked issue when an error recurs after resolution.
|
|
5
|
+
#
|
|
6
|
+
# Triggered via :on_error_reopened plugin hook.
|
|
7
|
+
# Adds a comment: "Error recurred — reopened automatically. Occurrence #{count}"
|
|
8
|
+
class ReopenLinkedIssueJob < ApplicationJob
|
|
9
|
+
queue_as :error_notifications
|
|
10
|
+
|
|
11
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 2
|
|
12
|
+
discard_on ActiveRecord::RecordNotFound
|
|
13
|
+
|
|
14
|
+
def perform(error_log_id)
|
|
15
|
+
error = ErrorLog.find(error_log_id)
|
|
16
|
+
return unless error.external_issue_url.present? && error.external_issue_number.present?
|
|
17
|
+
|
|
18
|
+
client = Services::IssueTrackerClient.from_config
|
|
19
|
+
return unless client
|
|
20
|
+
|
|
21
|
+
# Reopen the issue
|
|
22
|
+
result = client.reopen_issue(number: error.external_issue_number)
|
|
23
|
+
|
|
24
|
+
# Add recurrence comment
|
|
25
|
+
comment = "**Reopened** — error recurred.\n\n"
|
|
26
|
+
comment += "- **Occurrences:** #{error.occurrence_count}\n"
|
|
27
|
+
comment += "- **Last seen:** #{error.last_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}"
|
|
28
|
+
comment += "\n\n---\n*[RED](https://github.com/AnjanJ/rails_error_dashboard) (Rails Error Dashboard)*"
|
|
29
|
+
|
|
30
|
+
client.add_comment(number: error.external_issue_number, body: comment)
|
|
31
|
+
|
|
32
|
+
if result[:success]
|
|
33
|
+
Rails.logger.info("[RailsErrorDashboard] Reopened issue ##{error.external_issue_number} for error #{error_log_id}")
|
|
34
|
+
else
|
|
35
|
+
Rails.logger.error("[RailsErrorDashboard] Failed to reopen issue ##{error.external_issue_number}: #{result[:error]}")
|
|
36
|
+
end
|
|
37
|
+
rescue ActiveRecord::RecordNotFound
|
|
38
|
+
# Error was deleted
|
|
39
|
+
rescue => e
|
|
40
|
+
Rails.logger.error("[RailsErrorDashboard] ReopenLinkedIssueJob failed: #{e.class}: #{e.message}")
|
|
41
|
+
raise # retry
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -37,7 +37,8 @@ module RailsErrorDashboard
|
|
|
37
37
|
# Association for tracking individual error occurrences
|
|
38
38
|
has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
|
|
39
39
|
|
|
40
|
-
#
|
|
40
|
+
# Comments used as internal audit trail for workflow actions (snooze, mute, status changes).
|
|
41
|
+
# Manual comment form removed in v0.6 — discussion now lives on issue tracker.
|
|
41
42
|
has_many :comments, class_name: "RailsErrorDashboard::ErrorComment", foreign_key: :error_log_id, dependent: :destroy
|
|
42
43
|
|
|
43
44
|
# Cascade pattern associations
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html>
|
|
3
3
|
<head>
|
|
4
|
-
<title><%= content_for?(:page_title) ? "#{content_for(:page_title)} | " : "" %><%= Rails.application.class.module_parent_name %> -
|
|
4
|
+
<title><%= content_for?(:page_title) ? "#{content_for(:page_title)} | " : "" %><%= Rails.application.class.module_parent_name %> - RED</title>
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
6
|
<meta name="turbo-visit-control" content="reload">
|
|
7
7
|
<% if respond_to?(:csrf_meta_tags) %>
|
|
@@ -1547,18 +1547,24 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
|
|
|
1547
1547
|
end
|
|
1548
1548
|
%>
|
|
1549
1549
|
<% if has_root %>
|
|
1550
|
-
<a class="navbar-brand" href="<%= root_url %>" title="Back to <%= Rails.application.class.module_parent_name %>">
|
|
1550
|
+
<a class="navbar-brand d-flex align-items-center" href="<%= root_url %>" title="Back to <%= Rails.application.class.module_parent_name %>">
|
|
1551
1551
|
<i class="bi bi-bug-fill"></i>
|
|
1552
1552
|
<span class="d-none d-sm-inline"><%= Rails.application.class.module_parent_name %></span>
|
|
1553
1553
|
<span class="d-none d-md-inline text-white-50 mx-2">|</span>
|
|
1554
|
-
<span class="d-none d-md-inline">
|
|
1554
|
+
<span class="d-none d-md-inline">
|
|
1555
|
+
<strong>RED</strong>
|
|
1556
|
+
<small class="text-white-50 ms-1" style="font-size: 0.65em; vertical-align: middle;">Rails Error Dashboard</small>
|
|
1557
|
+
</span>
|
|
1555
1558
|
</a>
|
|
1556
1559
|
<% else %>
|
|
1557
|
-
<span class="navbar-brand">
|
|
1560
|
+
<span class="navbar-brand d-flex align-items-center">
|
|
1558
1561
|
<i class="bi bi-bug-fill"></i>
|
|
1559
1562
|
<span class="d-none d-sm-inline"><%= Rails.application.class.module_parent_name %></span>
|
|
1560
1563
|
<span class="d-none d-md-inline text-white-50 mx-2">|</span>
|
|
1561
|
-
<span class="d-none d-md-inline">
|
|
1564
|
+
<span class="d-none d-md-inline">
|
|
1565
|
+
<strong>RED</strong>
|
|
1566
|
+
<small class="text-white-50 ms-1" style="font-size: 0.65em; vertical-align: middle;">Rails Error Dashboard</small>
|
|
1567
|
+
</span>
|
|
1562
1568
|
</span>
|
|
1563
1569
|
<% end %>
|
|
1564
1570
|
</div>
|
|
@@ -1681,6 +1687,13 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
|
|
|
1681
1687
|
<% end %>
|
|
1682
1688
|
</li>
|
|
1683
1689
|
<% end %>
|
|
1690
|
+
<% if RailsErrorDashboard.configuration.enable_activestorage_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
|
|
1691
|
+
<li class="nav-item">
|
|
1692
|
+
<%= link_to activestorage_health_summary_errors_path(nav_params), class: "nav-link #{request.path == activestorage_health_summary_errors_path ? 'active' : ''}" do %>
|
|
1693
|
+
<i class="bi bi-cloud-arrow-up"></i> ActiveStorage
|
|
1694
|
+
<% end %>
|
|
1695
|
+
</li>
|
|
1696
|
+
<% end %>
|
|
1684
1697
|
<% if RailsErrorDashboard.configuration.enable_system_health %>
|
|
1685
1698
|
<li class="nav-item">
|
|
1686
1699
|
<%= link_to job_health_summary_errors_path(nav_params), class: "nav-link #{request.path == job_health_summary_errors_path ? 'active' : ''}" do %>
|
|
@@ -1820,7 +1833,7 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
|
|
|
1820
1833
|
<div class="container">
|
|
1821
1834
|
<p class="text-muted mb-1">
|
|
1822
1835
|
<i class="bi bi-bug-fill text-primary"></i>
|
|
1823
|
-
Built with <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank" class="text-decoration-none">Rails Error Dashboard</a>
|
|
1836
|
+
Built with <a href="https://github.com/AnjanJ/rails_error_dashboard" target="_blank" class="text-decoration-none"><strong>RED</strong> — Rails Error Dashboard</a>
|
|
1824
1837
|
</p>
|
|
1825
1838
|
<p class="text-muted small mb-0">
|
|
1826
1839
|
Created by <a href="https://www.anjan.dev/" target="_blank" class="text-decoration-none">Anjan Jagirdar</a>
|