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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0cfea703e2d74b6771503d098e3bc5f048ec781923ee08a1692e34bbf28c9734
4
- data.tar.gz: 4b77fb5c2dd00920d2eaca85b92d35dcbb19282408dee936584deffd2e7126a6
3
+ metadata.gz: 18faae83cf7bba23409732000c1cb230ba7ef067f23a454084445f754df4be0d
4
+ data.tar.gz: ad82e112e173f8247cf31b214b040ba82e4035040bbdf14d8c53a5782db36bb1
5
5
  SHA512:
6
- metadata.gz: 1bcc5f0f868d3e864deccdfbe23a0a3c3e1da03f3cfb49208724f61deceaf7047bba7c1d3f19358a75e1a254336059894a55bec79656f67e55217eec18c34d19
7
- data.tar.gz: 810aa6c9a5db5300d4f6f5964ce92aa06dada34dfc7b513e782a914de8a3f0baf7e35b8b03056b9a1060b018b8dca0d6b51534f528211e779c008a77060fa10d
6
+ metadata.gz: 0d0032aeb56ccc44a8c8966da338f75c37e71002534fd0bbf3e019cfe0582df37638e50863e8d4a77212731c1190de214d521521df2e986a23d36a575748d5d9
7
+ data.tar.gz: 4862e358faa4a87f1091e33cf08c0e654a7e959b8af551a1223e3c9d098d383d503342aa8c2fb4d66f20c95fc4864276df631be6b93bcdd22ef50269516ef997
data/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![Downloads](https://img.shields.io/gem/dt/rails_error_dashboard)](https://rubygems.org/gems/rails_error_dashboard)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Tests](https://github.com/AnjanJ/rails_error_dashboard/workflows/Tests/badge.svg)](https://github.com/AnjanJ/rails_error_dashboard/actions)
7
+ [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?logo=githubsponsors)](https://github.com/sponsors/AnjanJ)
7
8
  [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow?logo=buymeacoffee)](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
  ![Cache Health](docs/images/cache-health.png)
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. No error tracker surfaces this alongside HTTP errors.
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 buying me a coffee. It keeps the project going and lets me know people are finding it useful.
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://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
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>&nbsp;&nbsp;<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: Used in the comments section (@error.comments.count, @error.comments.recent_first)
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 add_comment
143
- @error = Commands::AddErrorComment.call(params[:id], author_name: params[:author_name], body: params[:body])
144
- redirect_to error_path(@error)
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
- # Association for comment threads (Phase 3: Workflow Integration)
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