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.
- checksums.yaml +4 -4
- data/README.md +30 -3
- data/app/controllers/rails_error_dashboard/errors_controller.rb +81 -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/_breadcrumbs_group.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +92 -100
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +121 -0
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +77 -73
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +148 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +13 -9
- data/config/routes.rb +6 -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 +122 -0
- data/lib/rails_error_dashboard/services/github_issue_client.rb +117 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +19 -1
- data/lib/rails_error_dashboard/services/gitlab_issue_client.rb +121 -0
- data/lib/rails_error_dashboard/services/issue_body_formatter.rb +132 -0
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +168 -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 +21 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18faae83cf7bba23409732000c1cb230ba7ef067f23a454084445f754df4be0d
|
|
4
|
+
data.tar.gz: ad82e112e173f8247cf31b214b040ba82e4035040bbdf14d8c53a5782db36bb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d0032aeb56ccc44a8c8966da338f75c37e71002534fd0bbf3e019cfe0582df37638e50863e8d4a77212731c1190de214d521521df2e986a23d36a575748d5d9
|
|
7
|
+
data.tar.gz: 4862e358faa4a87f1091e33cf08c0e654a7e959b8af551a1223e3c9d098d383d503342aa8c2fb4d66f20c95fc4864276df631be6b93bcdd22ef50269516ef997
|
data/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://rubygems.org/gems/rails_error_dashboard)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://github.com/AnjanJ/rails_error_dashboard/actions)
|
|
7
|
+
[](https://github.com/sponsors/AnjanJ)
|
|
7
8
|
[](https://buymeacoffee.com/anjanj)
|
|
8
9
|
|
|
9
10
|
**Self-hosted Rails error monitoring — free, forever.**
|
|
@@ -131,15 +132,41 @@ Requires breadcrumbs to be enabled.
|
|
|
131
132
|
|
|
132
133
|

|
|
133
134
|
|
|
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
|
+
**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
136
|
|
|
136
137
|
```ruby
|
|
137
138
|
config.enable_actioncable_tracking = true # requires enable_breadcrumbs = true
|
|
138
139
|
```
|
|
139
140
|
|
|
141
|
+
**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.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
config.enable_activestorage_tracking = true # requires enable_breadcrumbs = true
|
|
145
|
+
```
|
|
146
|
+
|
|
140
147
|
[Complete documentation →](docs/FEATURES.md#job-health-page)
|
|
141
148
|
</details>
|
|
142
149
|
|
|
150
|
+
<details>
|
|
151
|
+
<summary><strong>Issue Tracking — GitHub, GitLab, Codeberg</strong></summary>
|
|
152
|
+
|
|
153
|
+
Create, link, and manage issues directly from error detail pages. Supports GitHub, GitLab, and Codeberg/Gitea/Forgejo with provider auto-detection.
|
|
154
|
+
|
|
155
|
+
- **Manual:** "Create Issue" button + "Link Existing Issue" URL input
|
|
156
|
+
- **Auto-create:** On first occurrence and/or severity threshold — configurable
|
|
157
|
+
- **Lifecycle sync:** Resolve → close issue, recur → reopen + comment (throttled)
|
|
158
|
+
- **Two-way webhooks:** Issue closed/reopened on platform syncs to dashboard
|
|
159
|
+
- **RED branding:** Issues show "Created by RED (Rails Error Dashboard)"
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
config.enable_issue_tracking = true
|
|
163
|
+
config.issue_tracker_token = ENV["RED_BOT_TOKEN"]
|
|
164
|
+
# Provider and repo auto-detected from git_repository_url
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
[Complete documentation →](docs/guides/CONFIGURATION.md)
|
|
168
|
+
</details>
|
|
169
|
+
|
|
143
170
|
<details>
|
|
144
171
|
<summary><strong>Source Code Integration + Git Blame</strong></summary>
|
|
145
172
|
|
|
@@ -453,9 +480,9 @@ Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](
|
|
|
453
480
|
|
|
454
481
|
## Support
|
|
455
482
|
|
|
456
|
-
If this gem saves you some headaches (or some money on error tracking SaaS), consider
|
|
483
|
+
If this gem saves you some headaches (or some money on error tracking SaaS), consider sponsoring the project. It keeps RED going and lets me know people are finding it useful.
|
|
457
484
|
|
|
458
|
-
<a href="https://www.buymeacoffee.com/anjanj" target="_blank"><img src="https://
|
|
485
|
+
<a href="https://github.com/sponsors/AnjanJ" target="_blank"><img src="https://img.shields.io/badge/Sponsor_on_GitHub-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Sponsor on GitHub"></a> <a href="https://www.buymeacoffee.com/anjanj" target="_blank"><img src="https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?style=for-the-badge&logo=buymeacoffee&logoColor=black" alt="Buy Me A Coffee"></a>
|
|
459
486
|
|
|
460
487
|
---
|
|
461
488
|
|
|
@@ -75,12 +75,16 @@ 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)
|
|
82
82
|
@error_markdown = Services::MarkdownErrorFormatter.call(@error, related_errors: @related_errors)
|
|
83
83
|
|
|
84
|
+
# Fetch platform issue state and comments if linked
|
|
85
|
+
@platform_issue = fetch_platform_issue(@error)
|
|
86
|
+
@platform_comments = fetch_platform_comments(@error)
|
|
87
|
+
|
|
84
88
|
# Dispatch plugin event for error viewed
|
|
85
89
|
RailsErrorDashboard::PluginRegistry.dispatch(:on_error_viewed, @error)
|
|
86
90
|
end
|
|
@@ -139,9 +143,28 @@ module RailsErrorDashboard
|
|
|
139
143
|
redirect_to error_path(result[:error])
|
|
140
144
|
end
|
|
141
145
|
|
|
142
|
-
def
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
def create_issue
|
|
147
|
+
dashboard_url = error_url(params[:id])
|
|
148
|
+
result = Commands::CreateIssue.call(params[:id], dashboard_url: dashboard_url)
|
|
149
|
+
|
|
150
|
+
if result[:success]
|
|
151
|
+
flash[:notice] = "Issue created successfully"
|
|
152
|
+
flash[:new_issue_url] = result[:issue_url]
|
|
153
|
+
else
|
|
154
|
+
flash[:alert] = "Failed to create issue: #{result[:error]}"
|
|
155
|
+
end
|
|
156
|
+
redirect_to error_path(params[:id], anchor: "issue-tracking")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def link_issue
|
|
160
|
+
result = Commands::LinkExistingIssue.call(params[:id], issue_url: params[:issue_url])
|
|
161
|
+
|
|
162
|
+
if result[:success]
|
|
163
|
+
flash[:notice] = "Issue linked successfully"
|
|
164
|
+
else
|
|
165
|
+
flash[:alert] = "Failed to link issue: #{result[:error]}"
|
|
166
|
+
end
|
|
167
|
+
redirect_to error_path(params[:id], anchor: "issue-tracking")
|
|
145
168
|
end
|
|
146
169
|
|
|
147
170
|
def analytics
|
|
@@ -432,6 +455,27 @@ module RailsErrorDashboard
|
|
|
432
455
|
@pagy, @channels = pagy(:offset, all_channels, limit: params[:per_page] || 25)
|
|
433
456
|
end
|
|
434
457
|
|
|
458
|
+
def activestorage_health_summary
|
|
459
|
+
unless RailsErrorDashboard.configuration.enable_activestorage_tracking &&
|
|
460
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
461
|
+
flash[:alert] = "ActiveStorage tracking is not enabled. Enable enable_activestorage_tracking and enable_breadcrumbs in config/initializers/rails_error_dashboard.rb"
|
|
462
|
+
redirect_to errors_path
|
|
463
|
+
return
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
days = (params[:days] || 30).to_i
|
|
467
|
+
@days = days
|
|
468
|
+
result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
|
|
469
|
+
all_services = result[:services]
|
|
470
|
+
|
|
471
|
+
# Summary stats (computed before pagination)
|
|
472
|
+
@unique_services = all_services.size
|
|
473
|
+
@total_operations = all_services.sum { |s| s[:total_operations] }
|
|
474
|
+
@errors_with_storage = all_services.sum { |s| s[:error_count] }
|
|
475
|
+
|
|
476
|
+
@pagy, @services = pagy(:offset, all_services, limit: params[:per_page] || 25)
|
|
477
|
+
end
|
|
478
|
+
|
|
435
479
|
def diagnostic_dumps
|
|
436
480
|
unless RailsErrorDashboard.configuration.enable_diagnostic_dump
|
|
437
481
|
flash[:alert] = "Diagnostic dumps are not enabled. Enable them in config/initializers/rails_error_dashboard.rb"
|
|
@@ -510,6 +554,39 @@ module RailsErrorDashboard
|
|
|
510
554
|
@applications = Application.ordered_by_name.pluck(:name, :id)
|
|
511
555
|
end
|
|
512
556
|
|
|
557
|
+
def fetch_platform_issue(error)
|
|
558
|
+
return nil unless error.external_issue_url.present? && error.external_issue_number.present?
|
|
559
|
+
return nil unless RailsErrorDashboard.configuration.enable_issue_tracking
|
|
560
|
+
|
|
561
|
+
cache_key = "red/issue_state/#{error.external_issue_provider}/#{error.external_issue_number}"
|
|
562
|
+
Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
|
|
563
|
+
client = Services::IssueTrackerClient.from_config
|
|
564
|
+
return nil unless client
|
|
565
|
+
|
|
566
|
+
result = client.fetch_issue(number: error.external_issue_number)
|
|
567
|
+
result[:success] ? result : nil
|
|
568
|
+
end
|
|
569
|
+
rescue => e
|
|
570
|
+
nil
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def fetch_platform_comments(error)
|
|
574
|
+
return [] unless error.external_issue_url.present? && error.external_issue_number.present?
|
|
575
|
+
return [] unless RailsErrorDashboard.configuration.enable_issue_tracking
|
|
576
|
+
|
|
577
|
+
# Cache for 60 seconds to avoid API hammering on page refreshes
|
|
578
|
+
cache_key = "red/issue_comments/#{error.external_issue_provider}/#{error.external_issue_number}"
|
|
579
|
+
Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
|
|
580
|
+
client = Services::IssueTrackerClient.from_config
|
|
581
|
+
return [] unless client
|
|
582
|
+
|
|
583
|
+
result = client.fetch_comments(number: error.external_issue_number, per_page: 20)
|
|
584
|
+
result[:success] ? result[:comments] : []
|
|
585
|
+
end
|
|
586
|
+
rescue => e
|
|
587
|
+
[]
|
|
588
|
+
end
|
|
589
|
+
|
|
513
590
|
def check_default_credentials
|
|
514
591
|
@default_credentials_warning = RailsErrorDashboard.configuration.default_credentials?
|
|
515
592
|
end
|
|
@@ -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
|