rails_error_dashboard 0.1.0 → 0.1.3

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +305 -703
  3. data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +107 -0
  4. data/app/assets/stylesheets/rails_error_dashboard/_components.scss +625 -0
  5. data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +257 -0
  6. data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +203 -0
  7. data/app/assets/stylesheets/rails_error_dashboard/application.css +926 -15
  8. data/app/assets/stylesheets/rails_error_dashboard/application.css.map +7 -0
  9. data/app/assets/stylesheets/rails_error_dashboard/application.scss +61 -0
  10. data/app/controllers/rails_error_dashboard/application_controller.rb +18 -0
  11. data/app/controllers/rails_error_dashboard/errors_controller.rb +140 -4
  12. data/app/helpers/rails_error_dashboard/application_helper.rb +55 -0
  13. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +91 -0
  14. data/app/helpers/rails_error_dashboard/overview_helper.rb +78 -0
  15. data/app/helpers/rails_error_dashboard/user_agent_helper.rb +118 -0
  16. data/app/jobs/rails_error_dashboard/application_job.rb +19 -0
  17. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +48 -0
  18. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +263 -0
  19. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -8
  20. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +2 -1
  21. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +5 -5
  22. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +10 -6
  23. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +5 -6
  24. data/app/mailers/rails_error_dashboard/application_mailer.rb +1 -1
  25. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +1 -1
  26. data/app/models/rails_error_dashboard/cascade_pattern.rb +74 -0
  27. data/app/models/rails_error_dashboard/error_baseline.rb +100 -0
  28. data/app/models/rails_error_dashboard/error_comment.rb +27 -0
  29. data/app/models/rails_error_dashboard/error_log.rb +471 -3
  30. data/app/models/rails_error_dashboard/error_occurrence.rb +49 -0
  31. data/app/views/layouts/rails_error_dashboard.html.erb +816 -178
  32. data/app/views/layouts/rails_error_dashboard_old_backup.html.erb +383 -0
  33. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +3 -10
  34. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -2
  35. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +78 -0
  36. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +209 -0
  37. data/app/views/rails_error_dashboard/errors/_stats.html.erb +34 -0
  38. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +167 -0
  39. data/app/views/rails_error_dashboard/errors/analytics.html.erb +152 -56
  40. data/app/views/rails_error_dashboard/errors/correlation.html.erb +373 -0
  41. data/app/views/rails_error_dashboard/errors/index.html.erb +294 -138
  42. data/app/views/rails_error_dashboard/errors/overview.html.erb +253 -0
  43. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +399 -0
  44. data/app/views/rails_error_dashboard/errors/show.html.erb +781 -65
  45. data/config/routes.rb +9 -0
  46. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +66 -0
  47. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +26 -0
  48. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +12 -0
  49. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +9 -0
  50. data/db/migrate/20251225100236_create_error_occurrences.rb +31 -0
  51. data/db/migrate/20251225101920_create_cascade_patterns.rb +33 -0
  52. data/db/migrate/20251225102500_create_error_baselines.rb +38 -0
  53. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +27 -0
  54. data/db/migrate/20251226020100_create_error_comments.rb +18 -0
  55. data/lib/generators/rails_error_dashboard/install/install_generator.rb +276 -1
  56. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +272 -37
  57. data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +36 -0
  58. data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +55 -0
  59. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -1
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -2
  61. data/lib/rails_error_dashboard/commands/log_error.rb +272 -7
  62. data/lib/rails_error_dashboard/commands/resolve_error.rb +16 -0
  63. data/lib/rails_error_dashboard/configuration.rb +90 -5
  64. data/lib/rails_error_dashboard/error_reporter.rb +15 -7
  65. data/lib/rails_error_dashboard/logger.rb +105 -0
  66. data/lib/rails_error_dashboard/middleware/error_catcher.rb +17 -10
  67. data/lib/rails_error_dashboard/plugin.rb +6 -3
  68. data/lib/rails_error_dashboard/plugin_registry.rb +2 -2
  69. data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +0 -1
  70. data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +3 -4
  71. data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +1 -3
  72. data/lib/rails_error_dashboard/queries/analytics_stats.rb +44 -6
  73. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  74. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +86 -0
  75. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +242 -2
  76. data/lib/rails_error_dashboard/queries/error_cascades.rb +74 -0
  77. data/lib/rails_error_dashboard/queries/error_correlation.rb +375 -0
  78. data/lib/rails_error_dashboard/queries/errors_list.rb +106 -10
  79. data/lib/rails_error_dashboard/queries/filter_options.rb +0 -1
  80. data/lib/rails_error_dashboard/queries/platform_comparison.rb +254 -0
  81. data/lib/rails_error_dashboard/queries/similar_errors.rb +93 -0
  82. data/lib/rails_error_dashboard/services/backtrace_parser.rb +113 -0
  83. data/lib/rails_error_dashboard/services/baseline_alert_throttler.rb +88 -0
  84. data/lib/rails_error_dashboard/services/baseline_calculator.rb +269 -0
  85. data/lib/rails_error_dashboard/services/cascade_detector.rb +95 -0
  86. data/lib/rails_error_dashboard/services/pattern_detector.rb +268 -0
  87. data/lib/rails_error_dashboard/services/similarity_calculator.rb +144 -0
  88. data/lib/rails_error_dashboard/value_objects/error_context.rb +27 -1
  89. data/lib/rails_error_dashboard/version.rb +1 -1
  90. data/lib/rails_error_dashboard.rb +57 -7
  91. metadata +69 -10
  92. data/app/models/rails_error_dashboard/application_record.rb +0 -5
  93. data/lib/rails_error_dashboard/queries/developer_insights.rb +0 -277
  94. data/lib/rails_error_dashboard/queries/errors_list_v2.rb +0 -149
  95. data/lib/tasks/rails_error_dashboard_tasks.rake +0 -4
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query object for comparing error metrics across platforms
6
+ #
7
+ # Provides analytics comparing iOS vs Android vs API vs Web platforms:
8
+ # - Error rates and trends
9
+ # - Severity distribution
10
+ # - Resolution times
11
+ # - Top errors per platform
12
+ # - Platform stability scores
13
+ # - Cross-platform errors
14
+ #
15
+ # @example
16
+ # comparison = PlatformComparison.new(days: 7)
17
+ # comparison.error_rate_by_platform
18
+ # # => { "ios" => 150, "android" => 200, "api" => 50, "web" => 100 }
19
+ class PlatformComparison
20
+ attr_reader :days
21
+
22
+ # @param days [Integer] Number of days to analyze (default: 7)
23
+ def initialize(days: 7)
24
+ @days = days
25
+ @start_date = days.days.ago
26
+ end
27
+
28
+ # Get error count by platform for the time period
29
+ # @return [Hash] Platform name => error count
30
+ def error_rate_by_platform
31
+ ErrorLog
32
+ .where("occurred_at >= ?", @start_date)
33
+ .group(:platform)
34
+ .count
35
+ end
36
+
37
+ # Get severity distribution by platform
38
+ # @return [Hash] Platform => { severity => count }
39
+ def severity_distribution_by_platform
40
+ platforms = ErrorLog.distinct.pluck(:platform).compact
41
+
42
+ platforms.each_with_object({}) do |platform, result|
43
+ errors = ErrorLog
44
+ .where(platform: platform)
45
+ .where("occurred_at >= ?", @start_date)
46
+
47
+ # Calculate severity in Ruby since it's a method, not a column
48
+ severity_counts = Hash.new(0)
49
+ errors.each do |error|
50
+ severity_counts[error.severity] += 1
51
+ end
52
+
53
+ result[platform] = severity_counts
54
+ end
55
+ end
56
+
57
+ # Get average resolution time by platform
58
+ # @return [Hash] Platform => average hours to resolve
59
+ def resolution_time_by_platform
60
+ platforms = ErrorLog.distinct.pluck(:platform).compact
61
+
62
+ platforms.each_with_object({}) do |platform, result|
63
+ resolved_errors = ErrorLog
64
+ .where(platform: platform)
65
+ .where.not(resolved_at: nil)
66
+ .where("occurred_at >= ?", @start_date)
67
+
68
+ if resolved_errors.any?
69
+ total_hours = resolved_errors.sum do |error|
70
+ ((error.resolved_at - error.occurred_at) / 3600.0).round(2)
71
+ end
72
+ result[platform] = (total_hours / resolved_errors.count).round(2)
73
+ else
74
+ result[platform] = nil
75
+ end
76
+ end
77
+ end
78
+
79
+ # Get top 10 errors for each platform
80
+ # @return [Hash] Platform => Array of error hashes
81
+ def top_errors_by_platform
82
+ platforms = ErrorLog.distinct.pluck(:platform).compact
83
+
84
+ platforms.each_with_object({}) do |platform, result|
85
+ result[platform] = ErrorLog
86
+ .where(platform: platform)
87
+ .where("occurred_at >= ?", @start_date)
88
+ .select(:id, :error_type, :message, :occurrence_count, :occurred_at)
89
+ .order(occurrence_count: :desc)
90
+ .limit(10)
91
+ .map do |error|
92
+ {
93
+ id: error.id,
94
+ error_type: error.error_type,
95
+ message: error.message&.truncate(100),
96
+ severity: error.severity, # Calls the method
97
+ occurrence_count: error.occurrence_count,
98
+ occurred_at: error.occurred_at
99
+ }
100
+ end
101
+ end
102
+ end
103
+
104
+ # Calculate platform stability score (0-100)
105
+ # Higher score = more stable (fewer errors, faster resolution)
106
+ # @return [Hash] Platform => stability score
107
+ def platform_stability_scores
108
+ platforms = ErrorLog.distinct.pluck(:platform).compact
109
+ error_rates = error_rate_by_platform
110
+ resolution_times = resolution_time_by_platform
111
+
112
+ # Find max values for normalization
113
+ max_errors = error_rates.values.max || 1
114
+ max_resolution_time = resolution_times.values.compact.max || 1
115
+
116
+ platforms.each_with_object({}) do |platform, result|
117
+ error_count = error_rates[platform] || 0
118
+ avg_resolution = resolution_times[platform] || 0
119
+
120
+ # Normalize to 0-1 scale (inverted - lower is better)
121
+ error_score = 1.0 - (error_count.to_f / max_errors)
122
+ resolution_score = avg_resolution.positive? ? 1.0 - (avg_resolution / max_resolution_time) : 1.0
123
+
124
+ # Weight: 70% error count, 30% resolution time
125
+ # Convert to 0-100 scale
126
+ result[platform] = ((error_score * 0.7 + resolution_score * 0.3) * 100).round(1)
127
+ end
128
+ end
129
+
130
+ # Find errors that occur across multiple platforms
131
+ # @return [Array<Hash>] Errors with their platforms
132
+ def cross_platform_errors
133
+ # Get error types that appear on 2+ platforms
134
+ error_types_with_platforms = ErrorLog
135
+ .where("occurred_at >= ?", @start_date)
136
+ .group(:error_type, :platform)
137
+ .select(:error_type, :platform)
138
+ .having("COUNT(*) > 0")
139
+ .pluck(:error_type, :platform)
140
+
141
+ # Group by error_type to find those on multiple platforms
142
+ errors_by_type = error_types_with_platforms.group_by { |error_type, _| error_type }
143
+
144
+ errors_by_type
145
+ .select { |_, platforms| platforms.map(&:last).uniq.count > 1 }
146
+ .map do |error_type, platform_pairs|
147
+ platforms = platform_pairs.map(&:last).uniq
148
+ total_count = ErrorLog
149
+ .where(error_type: error_type, platform: platforms)
150
+ .where("occurred_at >= ?", @start_date)
151
+ .sum(:occurrence_count)
152
+
153
+ {
154
+ error_type: error_type,
155
+ platforms: platforms.sort,
156
+ total_occurrences: total_count,
157
+ platform_breakdown: platforms.each_with_object({}) do |platform, breakdown|
158
+ breakdown[platform] = ErrorLog
159
+ .where(error_type: error_type, platform: platform)
160
+ .where("occurred_at >= ?", @start_date)
161
+ .sum(:occurrence_count)
162
+ end
163
+ }
164
+ end
165
+ .sort_by { |error| -error[:total_occurrences] }
166
+ end
167
+
168
+ # Get daily error trend by platform
169
+ # @return [Hash] Platform => { date => count }
170
+ def daily_trend_by_platform
171
+ platforms = ErrorLog.distinct.pluck(:platform).compact
172
+
173
+ platforms.each_with_object({}) do |platform, result|
174
+ result[platform] = ErrorLog
175
+ .where(platform: platform)
176
+ .where("occurred_at >= ?", @start_date)
177
+ .group_by_day(:occurred_at, range: @start_date..Time.current)
178
+ .count
179
+ end
180
+ end
181
+
182
+ # Get platform health summary
183
+ # @return [Hash] Platform => health metrics
184
+ def platform_health_summary
185
+ platforms = ErrorLog.distinct.pluck(:platform).compact
186
+ error_rates = error_rate_by_platform
187
+ stability_scores = platform_stability_scores
188
+
189
+ platforms.each_with_object({}) do |platform, result|
190
+ total_errors = error_rates[platform] || 0
191
+
192
+ # Count critical errors by checking severity method
193
+ critical_errors = ErrorLog
194
+ .where(platform: platform)
195
+ .where("occurred_at >= ?", @start_date)
196
+ .select { |error| error.severity == :critical }
197
+ .count
198
+
199
+ unresolved_errors = ErrorLog
200
+ .where(platform: platform, resolved_at: nil)
201
+ .where("occurred_at >= ?", @start_date)
202
+ .count
203
+
204
+ resolved_errors = ErrorLog
205
+ .where(platform: platform)
206
+ .where.not(resolved_at: nil)
207
+ .where("occurred_at >= ?", @start_date)
208
+ .count
209
+
210
+ resolution_rate = total_errors.positive? ? ((resolved_errors.to_f / total_errors) * 100).round(1) : 0.0
211
+
212
+ # Calculate error velocity (increasing or decreasing)
213
+ first_half = ErrorLog
214
+ .where(platform: platform)
215
+ .where("occurred_at >= ? AND occurred_at < ?", @start_date, @start_date + (@days / 2.0).days)
216
+ .count
217
+
218
+ second_half = ErrorLog
219
+ .where(platform: platform)
220
+ .where("occurred_at >= ?", @start_date + (@days / 2.0).days)
221
+ .count
222
+
223
+ velocity = first_half.positive? ? (((second_half - first_half).to_f / first_half) * 100).round(1) : 0.0
224
+
225
+ result[platform] = {
226
+ total_errors: total_errors,
227
+ critical_errors: critical_errors,
228
+ unresolved_errors: unresolved_errors,
229
+ resolution_rate: resolution_rate,
230
+ stability_score: stability_scores[platform] || 0,
231
+ error_velocity: velocity, # Positive = increasing, negative = decreasing
232
+ health_status: determine_health_status(stability_scores[platform] || 0, velocity)
233
+ }
234
+ end
235
+ end
236
+
237
+ private
238
+
239
+ # Determine health status based on stability score and velocity
240
+ # @param stability_score [Float] 0-100 stability score
241
+ # @param velocity [Float] Error velocity percentage
242
+ # @return [Symbol] :healthy, :warning, or :critical
243
+ def determine_health_status(stability_score, velocity)
244
+ if stability_score >= 80 && velocity <= 10
245
+ :healthy
246
+ elsif stability_score >= 60 && velocity <= 50
247
+ :warning
248
+ else
249
+ :critical
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Find errors similar to a target error using fuzzy matching
6
+ #
7
+ # Uses SimilarityCalculator to compute similarity scores based on:
8
+ # - Backtrace pattern similarity (70% weight)
9
+ # - Message similarity (30% weight)
10
+ #
11
+ # Returns errors with similarity >= threshold, sorted by score descending
12
+ class SimilarErrors
13
+ # Find similar errors
14
+ #
15
+ # @param error_id [Integer] ID of target error
16
+ # @param threshold [Float] Minimum similarity score (0.0-1.0), default 0.6
17
+ # @param limit [Integer] Maximum number of results, default 10
18
+ # @return [Array<Hash>] Array of {error: ErrorLog, similarity: Float}
19
+ def self.call(error_id, threshold: 0.6, limit: 10)
20
+ new(error_id, threshold: threshold, limit: limit).find_similar
21
+ end
22
+
23
+ def initialize(error_id, threshold: 0.6, limit: 10)
24
+ @error_id = error_id
25
+ @threshold = threshold.to_f
26
+ @limit = limit.to_i
27
+ end
28
+
29
+ def find_similar
30
+ target_error = ErrorLog.find_by(id: @error_id)
31
+ return [] unless target_error
32
+
33
+ # Find candidate errors to compare
34
+ candidates = find_candidates(target_error)
35
+
36
+ # Calculate similarity scores
37
+ similar_errors = candidates.map do |candidate|
38
+ score = Services::SimilarityCalculator.call(target_error, candidate)
39
+ next if score < @threshold
40
+
41
+ {
42
+ error: candidate,
43
+ similarity: score.round(3)
44
+ }
45
+ end.compact
46
+
47
+ # Sort by similarity score (highest first) and limit results
48
+ similar_errors.sort_by { |item| -item[:similarity] }.first(@limit)
49
+ end
50
+
51
+ private
52
+
53
+ def find_candidates(target_error)
54
+ # Build candidate query with multiple strategies for performance
55
+
56
+ # Strategy 1: Same backtrace signature (fastest, most precise)
57
+ candidates = []
58
+ if target_error.backtrace_signature.present?
59
+ candidates += ErrorLog
60
+ .where(backtrace_signature: target_error.backtrace_signature)
61
+ .where.not(id: target_error.id)
62
+ .limit(50)
63
+ end
64
+
65
+ # Strategy 2: Same error type (fast, good recall)
66
+ if candidates.size < 20
67
+ candidates += ErrorLog
68
+ .where(error_type: target_error.error_type)
69
+ .where.not(id: target_error.id)
70
+ .where.not(id: candidates.map(&:id))
71
+ .limit(30)
72
+ end
73
+
74
+ # Strategy 3: Same platform + same first word in error type
75
+ # (catches similar errors like NoMethodError vs NameError)
76
+ if candidates.size < 20 && target_error.platform.present?
77
+ error_prefix = target_error.error_type&.split("::")&.last&.split(/(?=[A-Z])/, 2)&.first
78
+ if error_prefix.present?
79
+ candidates += ErrorLog
80
+ .where(platform: target_error.platform)
81
+ .where("error_type LIKE ?", "%#{error_prefix}%")
82
+ .where.not(id: target_error.id)
83
+ .where.not(id: candidates.map(&:id))
84
+ .limit(20)
85
+ end
86
+ end
87
+
88
+ # Return unique candidates
89
+ candidates.uniq
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Service: Parse and categorize backtrace frames
6
+ # Filters out framework noise to show only relevant application code
7
+ class BacktraceParser
8
+ # Match both formats:
9
+ # /path/file.rb:123:in `method'
10
+ # /path/file.rb:123:in 'ClassName#method'
11
+ FRAME_PATTERN = %r{^(.+):(\d+)(?::in [`'](.+)['`])?$}
12
+
13
+ def self.parse(backtrace_string)
14
+ new(backtrace_string).parse
15
+ end
16
+
17
+ def initialize(backtrace_string)
18
+ @backtrace_string = backtrace_string
19
+ end
20
+
21
+ def parse
22
+ return [] if @backtrace_string.blank?
23
+
24
+ lines = @backtrace_string.split("\n")
25
+ lines.map.with_index do |line, index|
26
+ parse_frame(line.strip, index)
27
+ end.compact
28
+ end
29
+
30
+ private
31
+
32
+ def parse_frame(line, index)
33
+ match = line.match(FRAME_PATTERN)
34
+ return nil unless match
35
+
36
+ file_path = match[1]
37
+ line_number = match[2].to_i
38
+ method_name = match[3] || "(unknown)"
39
+
40
+ {
41
+ index: index,
42
+ file_path: file_path,
43
+ line_number: line_number,
44
+ method_name: method_name,
45
+ category: categorize_frame(file_path),
46
+ full_line: line,
47
+ short_path: shorten_path(file_path)
48
+ }
49
+ end
50
+
51
+ def categorize_frame(file_path)
52
+ # Application code (highest priority)
53
+ return :app if app_code?(file_path)
54
+
55
+ # Gem code (dependencies)
56
+ return :gem if gem_code?(file_path)
57
+
58
+ # Rails framework
59
+ return :framework if rails_code?(file_path)
60
+
61
+ # Ruby core/stdlib
62
+ :ruby_core
63
+ end
64
+
65
+ def app_code?(file_path)
66
+ # Match /app/, /lib/ directories in the application
67
+ file_path.include?("/app/") ||
68
+ (file_path.include?("/lib/") && !file_path.include?("/gems/") && !file_path.include?("/ruby/"))
69
+ end
70
+
71
+ def gem_code?(file_path)
72
+ file_path.include?("/gems/") ||
73
+ file_path.include?("/bundler/gems/") ||
74
+ file_path.include?("/vendor/bundle/")
75
+ end
76
+
77
+ def rails_code?(file_path)
78
+ file_path.include?("/railties-") ||
79
+ file_path.include?("/actionpack-") ||
80
+ file_path.include?("/actionview-") ||
81
+ file_path.include?("/activerecord-") ||
82
+ file_path.include?("/activesupport-") ||
83
+ file_path.include?("/actioncable-") ||
84
+ file_path.include?("/activejob-") ||
85
+ file_path.include?("/actionmailer-") ||
86
+ file_path.include?("/activestorage-") ||
87
+ file_path.include?("/actionmailbox-") ||
88
+ file_path.include?("/actiontext-") ||
89
+ file_path.include?("/rails-")
90
+ end
91
+
92
+ def shorten_path(file_path)
93
+ # Remove gem version numbers and long paths
94
+ # /Users/.../.gem/ruby/3.4.0/gems/activerecord-8.0.4/lib/... → activerecord/.../file.rb
95
+ if file_path.include?("/gems/")
96
+ parts = file_path.split("/gems/").last
97
+ gem_and_path = parts.split("/", 2)
98
+ gem_name = gem_and_path.first.split("-").first # Remove version
99
+ path_in_gem = gem_and_path.last
100
+ "#{gem_name}/#{path_in_gem}"
101
+ # /path/to/app/controllers/... → app/controllers/...
102
+ elsif file_path.include?("/app/")
103
+ file_path.split("/app/").last.prepend("app/")
104
+ elsif file_path.include?("/lib/") && !file_path.include?("/ruby/")
105
+ file_path.split("/lib/").last.prepend("lib/")
106
+ else
107
+ # Just show filename for Ruby core
108
+ File.basename(file_path)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Throttles baseline alerts to prevent alert fatigue
6
+ #
7
+ # Tracks when alerts were last sent for each error_type/platform combination
8
+ # and prevents sending duplicate alerts within the cooldown window.
9
+ #
10
+ # Uses an in-memory cache (class variable) for simplicity. For distributed
11
+ # systems, consider using Redis or a database-backed solution.
12
+ class BaselineAlertThrottler
13
+ @last_alert_times = {}
14
+ @mutex = Mutex.new
15
+
16
+ class << self
17
+ # Check if an alert should be sent (not in cooldown period)
18
+ # @param error_type [String] The error type
19
+ # @param platform [String] The platform
20
+ # @param cooldown_minutes [Integer] Cooldown period in minutes
21
+ # @return [Boolean] True if alert should be sent
22
+ def should_alert?(error_type, platform, cooldown_minutes: 120)
23
+ key = alert_key(error_type, platform)
24
+
25
+ @mutex.synchronize do
26
+ last_time = @last_alert_times[key]
27
+
28
+ # No previous alert, allow this one
29
+ return true if last_time.nil?
30
+
31
+ # Check if cooldown period has passed
32
+ Time.current > (last_time + cooldown_minutes.minutes)
33
+ end
34
+ end
35
+
36
+ # Record that an alert was sent
37
+ # @param error_type [String] The error type
38
+ # @param platform [String] The platform
39
+ def record_alert(error_type, platform)
40
+ key = alert_key(error_type, platform)
41
+
42
+ @mutex.synchronize do
43
+ @last_alert_times[key] = Time.current
44
+ end
45
+ end
46
+
47
+ # Get time since last alert
48
+ # @param error_type [String] The error type
49
+ # @param platform [String] The platform
50
+ # @return [Integer, nil] Minutes since last alert, or nil if never alerted
51
+ def minutes_since_last_alert(error_type, platform)
52
+ key = alert_key(error_type, platform)
53
+
54
+ @mutex.synchronize do
55
+ last_time = @last_alert_times[key]
56
+ return nil if last_time.nil?
57
+
58
+ ((Time.current - last_time) / 60).to_i
59
+ end
60
+ end
61
+
62
+ # Clear all alert records (useful for testing)
63
+ def clear!
64
+ @mutex.synchronize do
65
+ @last_alert_times.clear
66
+ end
67
+ end
68
+
69
+ # Clean up old entries (older than max_age_hours)
70
+ # Call periodically to prevent memory growth
71
+ # @param max_age_hours [Integer] Remove entries older than this (default: 24)
72
+ def cleanup!(max_age_hours: 24)
73
+ cutoff_time = max_age_hours.hours.ago
74
+
75
+ @mutex.synchronize do
76
+ @last_alert_times.delete_if { |_, time| time < cutoff_time }
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def alert_key(error_type, platform)
83
+ "#{error_type}:#{platform}"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end