rails_error_dashboard 0.15.0 → 0.16.1

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -4
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +29 -2
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +7 -14
  6. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  7. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +5 -0
  8. data/app/models/rails_error_dashboard/error_log.rb +11 -8
  9. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  10. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  11. data/app/views/layouts/rails_error_dashboard.html.erb +3 -1
  12. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  13. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +10 -3
  14. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  15. data/app/views/rails_error_dashboard/errors/correlation.html.erb +2 -2
  16. data/app/views/rails_error_dashboard/errors/overview.html.erb +14 -7
  17. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +26 -17
  18. data/app/views/rails_error_dashboard/errors/settings.html.erb +6 -5
  19. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  20. data/config/locales/de.yml +21 -1
  21. data/config/locales/en.yml +23 -1
  22. data/config/locales/es.yml +22 -1
  23. data/config/locales/fr.yml +22 -1
  24. data/config/locales/it.yml +22 -1
  25. data/config/locales/ja.yml +20 -1
  26. data/config/locales/pl.yml +23 -1
  27. data/config/locales/pt-BR.yml +22 -1
  28. data/config/locales/ru.yml +23 -1
  29. data/config/locales/uk.yml +23 -1
  30. data/config/locales/zh-CN.yml +20 -1
  31. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  32. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  33. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  34. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +14 -5
  35. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  36. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +7 -2
  37. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  38. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  39. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +2 -4
  40. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  41. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  42. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  43. data/lib/rails_error_dashboard/commands/log_error.rb +19 -7
  44. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  45. data/lib/rails_error_dashboard/configuration.rb +26 -3
  46. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  47. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  48. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  49. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  50. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  51. data/lib/rails_error_dashboard/queries/platform_comparison.rb +55 -18
  52. data/lib/rails_error_dashboard/services/backtrace_processor.rb +16 -7
  53. data/lib/rails_error_dashboard/services/crash_capture.rb +5 -0
  54. data/lib/rails_error_dashboard/services/github_link_generator.rb +1 -6
  55. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  56. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  57. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  58. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  59. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  60. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  61. data/lib/rails_error_dashboard/version.rb +1 -1
  62. data/lib/rails_error_dashboard.rb +16 -0
  63. data/lib/tasks/error_dashboard.rake +2 -3
  64. metadata +10 -2
@@ -218,6 +218,7 @@ module RailsErrorDashboard
218
218
  # Instance variable capture from tp.self (receiver object at raise time)
219
219
  attr_accessor :enable_instance_variables # Master switch (default: false)
220
220
  attr_accessor :instance_variable_max_count # Max ivars to capture (default: 20)
221
+ attr_accessor :occurrence_context_limit # Occurrences per error whose context is kept (default: 25; 0 = off)
221
222
  attr_accessor :instance_variable_filter_patterns # Additional sensitive ivar patterns (default: [])
222
223
 
223
224
  # Swallowed exception detection via TracePoint(:raise) + TracePoint(:rescue) (Ruby 3.3+ only)
@@ -481,6 +482,9 @@ module RailsErrorDashboard
481
482
  # Instance variable capture defaults - OFF by default (opt-in)
482
483
  @enable_instance_variables = false # Capture ivars from tp.self at raise time
483
484
  @instance_variable_max_count = 20 # Max ivars per exception
485
+ # Keep the moment-of-failure context of the newest 25 occurrences of each
486
+ # error, not only the latest. 0 keeps none.
487
+ @occurrence_context_limit = 25
484
488
  @instance_variable_filter_patterns = [] # Additional sensitive ivar name patterns
485
489
 
486
490
  # Swallowed exception detection defaults - OFF by default (Ruby 3.3+ opt-in)
@@ -674,6 +678,13 @@ module RailsErrorDashboard
674
678
  end
675
679
  end
676
680
 
681
+ # Any other git_branch_strategy links to main. A warning, not an error:
682
+ # a link format is not worth stopping a boot.
683
+ if git_branch_strategy && !%w[commit_sha current_branch main].include?(git_branch_strategy.to_s)
684
+ warnings << "git_branch_strategy must be one of :commit_sha, :current_branch or :main " \
685
+ "(got: #{git_branch_strategy.inspect}). Repository links will point at main."
686
+ end
687
+
677
688
  # Validate custom_fingerprint (must respond to .call if set)
678
689
  if custom_fingerprint && !custom_fingerprint.respond_to?(:call)
679
690
  errors << "custom_fingerprint must respond to .call (lambda, proc, or object with .call method)"
@@ -712,6 +723,10 @@ module RailsErrorDashboard
712
723
  errors << "instance_variable_max_count must be at least 1 (got: #{instance_variable_max_count})"
713
724
  end
714
725
 
726
+ unless occurrence_context_limit.is_a?(Integer) && occurrence_context_limit >= 0
727
+ errors << "occurrence_context_limit must be a whole number of 0 or more (got: #{occurrence_context_limit.inspect})"
728
+ end
729
+
715
730
  # Validate swallowed exception detection settings
716
731
  # Auto-disable on Ruby < 3.3 (warn, don't crash)
717
732
  if detect_swallowed_exceptions && RUBY_VERSION < "3.3"
@@ -720,6 +735,13 @@ module RailsErrorDashboard
720
735
  "Feature has been auto-disabled. Upgrade Ruby to use this feature."
721
736
  @detect_swallowed_exceptions = false
722
737
  end
738
+
739
+ # Rails 7.0.0-7.0.8.7 render request exceptions without reporting them, so
740
+ # RED never sees them. Rails 7.0.10 backported 7.1's reporting.
741
+ if defined?(ActionPack) && ActionPack.gem_version < Gem::Version.new("7.0.10")
742
+ warnings << "Rails #{ActionPack.gem_version} does not report the request exceptions it renders as error pages, " \
743
+ "so unhandled request errors are not recorded. Upgrading to Rails 7.0.10 or later fixes this."
744
+ end
723
745
  # Validate sub-settings only if feature is still active after version check
724
746
  if detect_swallowed_exceptions
725
747
  if swallowed_exception_max_cache_size && swallowed_exception_max_cache_size < 1
@@ -1126,9 +1148,10 @@ module RailsErrorDashboard
1126
1148
  return nil if effective_issue_tracker_provider == :linear
1127
1149
  return nil if git_repository_url.blank?
1128
1150
 
1129
- # Extract owner/repo from URL: https://github.com/owner/repo(.git)
1130
- match = git_repository_url.match(%r{[:/]([^/]+/[^/]+?)(?:\.git)?$})
1131
- match&.[](1)
1151
+ # owner/repo from the normalized URL, so ".git", ".git/" and trailing
1152
+ # slashes all name the same repository the View Source links use
1153
+ url = Services::RepositoryUrl.normalize(git_repository_url)
1154
+ url.match(%r{[:/]([^/]+/[^/]+)\z})&.[](1)
1132
1155
  end
1133
1156
 
1134
1157
  # Resolve the issue tracker API token (supports string or lambda)
@@ -46,8 +46,10 @@ module RailsErrorDashboard
46
46
  # Skip duplicate reports from our own middleware when the subscriber
47
47
  # already captured this error with full request context above.
48
48
  # Without this, async logging enqueues two jobs for one exception —
49
- # and non-deterministic job ordering can overwrite good data.
50
- if source == "rack.middleware" &&
49
+ # and non-deterministic job ordering can overwrite good data. The
50
+ # middleware's report also carries context[:middleware], because Rails
51
+ # 7.0's reporter passes subscribers no source.
52
+ if (source == "rack.middleware" || context[:middleware]) &&
51
53
  Thread.current[:rails_error_dashboard_reported_errors]&.include?(error.object_id)
52
54
  return
53
55
  end
@@ -49,7 +49,12 @@ module RailsErrorDashboard
49
49
  # believe they had set a value that was never read.
50
50
  # @param source [String, nil] Source identifier (e.g., "frontend", "mobile_app")
51
51
  #
52
- # @return [ErrorLog, nil] The created error log record, or nil if filtered/ignored
52
+ # @return [ErrorLog, nil] The created or updated error log record. nil when
53
+ # no row is written now, or none is returned:
54
+ # - the error is ignored or sampled out (ignored_exceptions, sampling_rate)
55
+ # - storm protection only counts it (:count_only)
56
+ # - async_logging is on: a job writes the row later
57
+ # - logging failed internally (RED never raises into your code)
53
58
  #
54
59
  # @example Basic usage
55
60
  # ManualErrorReporter.report(
@@ -106,6 +111,11 @@ module RailsErrorDashboard
106
111
  occurred_at: occurred_at || Time.current
107
112
  }.compact # Remove nil values
108
113
 
114
+ # Marks this as a client report, so nothing reads this app's source files
115
+ # for its backtrace. Set here, after the caller's values, and from no
116
+ # argument: source: is only a label.
117
+ context[:_capture_source] = "client"
118
+
109
119
  # severity is deliberately NOT forwarded: nothing downstream reads it,
110
120
  # and passing it on would keep the illusion that it does.
111
121
  if severity.present?
@@ -38,15 +38,19 @@ module RailsErrorDashboard
38
38
  # Report to Rails.error (will be logged by our ErrorReporter)
39
39
  # CRITICAL: Wrap in rescue to ensure gem failures don't break the app
40
40
  begin
41
- Rails.error.report(exception,
41
+ options = {
42
42
  handled: false,
43
43
  severity: :error,
44
44
  context: {
45
45
  request: ActionDispatch::Request.new(env),
46
46
  middleware: true
47
- },
48
- source: "rack.middleware"
49
- )
47
+ }
48
+ }
49
+ # Rails 7.0's ErrorReporter#report takes no source: keyword (7.1
50
+ # added it). Passing one raised ArgumentError there, which the rescue
51
+ # below swallowed, so on 7.0 this middleware reported nothing.
52
+ options[:source] = "rack.middleware" if report_takes_source?
53
+ Rails.error.report(exception, **options)
50
54
  rescue => e
51
55
  # If error reporting fails, log it but DON'T break the app
52
56
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Middleware error reporting failed: #{e.class} - #{e.message}")
@@ -61,6 +65,16 @@ module RailsErrorDashboard
61
65
  Thread.current[:rails_error_dashboard_reported_errors] = nil
62
66
  RailsErrorDashboard::Services::BreadcrumbCollector.clear_buffer
63
67
  end
68
+
69
+ private
70
+
71
+ # Asked of the reporter's class, not the object: a stubbed or wrapped
72
+ # report method hides its real keywords.
73
+ def report_takes_source?
74
+ Rails.error.class.instance_method(:report).parameters.any? do |type, name|
75
+ name == :source || type == :keyrest
76
+ end
77
+ end
64
78
  end
65
79
  end
66
80
  end
@@ -29,8 +29,6 @@ module RailsErrorDashboard
29
29
  errors_by_hour: errors_by_hour,
30
30
  top_users: top_affected_users,
31
31
  resolution_rate: resolution_rate,
32
- mobile_errors: mobile_errors_count,
33
- api_errors: api_errors_count,
34
32
  pattern_insights: pattern_insights
35
33
  }
36
34
  end
@@ -270,16 +268,6 @@ module RailsErrorDashboard
270
268
  ((resolved_count.to_f / total_groups) * 100).round(1)
271
269
  end
272
270
 
273
- def mobile_errors_count
274
- Queries::EventVolume.in_window(base_scope.where(platform: [ "iOS", "Android" ]), @start_date)
275
- end
276
-
277
- def api_errors_count
278
- Queries::EventVolume.in_window(
279
- base_scope.where("platform IS NULL OR platform = ?", "API"), @start_date
280
- )
281
- end
282
-
283
271
  # Pattern insights for top error types
284
272
  # Analyzes occurrence patterns and bursts for top 5 error types
285
273
  def pattern_insights
@@ -16,6 +16,13 @@ module RailsErrorDashboard
16
16
  # puts "#{result[:error].error_type} occurred #{result[:frequency]} times together"
17
17
  # end
18
18
  class CoOccurringErrors
19
+ # The newest occurrences of the target error that are compared. This
20
+ # renders on every error page while the feature is on, and it used to run
21
+ # one window query per lifetime occurrence: 50k occurrences, 50k queries.
22
+ TARGET_OCCURRENCES = 500
23
+ # At most this many occurrences of other errors are read for those windows
24
+ NEARBY_OCCURRENCES = 20_000
25
+
19
26
  # Find co-occurring errors
20
27
  #
21
28
  # @param error_log_id [Integer] ID of target error
@@ -34,53 +41,72 @@ module RailsErrorDashboard
34
41
  @limit = limit.to_i
35
42
  end
36
43
 
44
+ # Four queries however many occurrences there are: the target exists, its
45
+ # newest occurrence times, other errors' occurrences in those windows, and
46
+ # the co-occurring errors themselves.
37
47
  def find_co_occurring
38
- target_error = ErrorLog.find_by(id: @error_log_id)
39
- return [] unless target_error
40
-
41
- # Get all occurrences of the target error
42
- target_occurrences = ErrorOccurrence.where(error_log_id: @error_log_id)
43
- return [] if target_occurrences.empty?
44
-
45
- # For each occurrence, find other errors in the time window
46
- co_occurrence_data = Hash.new { |h, k| h[k] = { count: 0, delays: [] } }
48
+ return [] unless ErrorLog.exists?(id: @error_log_id)
47
49
 
48
- target_occurrences.find_each do |occurrence|
49
- window = @window_minutes.minutes
50
- start_time = occurrence.occurred_at - window
51
- end_time = occurrence.occurred_at + window
50
+ targets = ErrorOccurrence.where(error_log_id: @error_log_id)
51
+ .order(occurred_at: :desc).limit(TARGET_OCCURRENCES).pluck(:occurred_at).sort
52
+ return [] if targets.empty?
52
53
 
53
- # Find other error occurrences in this time window
54
- nearby_occurrences = ErrorOccurrence
55
- .in_time_window(start_time, end_time)
56
- .where.not(error_log_id: @error_log_id)
57
- .includes(:error_log)
54
+ window = @window_minutes.minutes
55
+ stats = Hash.new { |h, k| h[k] = { count: 0, delay_sum: 0.0 } }
58
56
 
59
- nearby_occurrences.each do |nearby|
60
- error_log_id = nearby.error_log_id
61
- co_occurrence_data[error_log_id][:count] += 1
57
+ nearby_occurrences(merged_windows(targets, window)).each do |error_log_id, occurred_at|
58
+ # Each target occurrence within the window of this one is a pair, as
59
+ # before. Delay is negative when this error came first.
60
+ index = targets.bsearch_index { |target| target >= occurred_at - window }
61
+ next unless index
62
62
 
63
- # Calculate delay (negative = before, positive = after target error)
64
- delay = (nearby.occurred_at - occurrence.occurred_at).to_f
65
- co_occurrence_data[error_log_id][:delays] << delay
63
+ while index < targets.size && targets[index] <= occurred_at + window
64
+ stats[error_log_id][:count] += 1
65
+ stats[error_log_id][:delay_sum] += (occurred_at - targets[index]).to_f
66
+ index += 1
66
67
  end
67
68
  end
68
69
 
69
- # Filter by minimum frequency and build results
70
- results = co_occurrence_data.select { |_id, data| data[:count] >= @min_frequency }.map do |error_log_id, data|
71
- error = ErrorLog.find(error_log_id)
72
- avg_delay = data[:delays].sum / data[:delays].size
70
+ frequent = stats.select { |_id, data| data[:count] >= @min_frequency }
71
+ errors = ErrorLog.where(id: frequent.keys).index_by(&:id)
73
72
 
74
- {
75
- error: error,
76
- frequency: data[:count],
77
- avg_delay_seconds: avg_delay.round(2)
78
- }
73
+ results = frequent.filter_map do |error_log_id, data|
74
+ error = errors[error_log_id] or next
75
+ { error: error, frequency: data[:count], avg_delay_seconds: (data[:delay_sum] / data[:count]).round(2) }
79
76
  end
80
77
 
81
78
  # Sort by frequency (most common first) and limit results
82
79
  results.sort_by { |r| -r[:frequency] }.first(@limit)
83
80
  end
81
+
82
+ private
83
+
84
+ # The windows around each target time, overlapping ones merged, so a burst
85
+ # of target occurrences becomes one range
86
+ def merged_windows(targets, window)
87
+ targets.each_with_object([]) do |target, ranges|
88
+ from = target - window
89
+ to = target + window
90
+ if ranges.any? && from <= ranges.last[1]
91
+ ranges.last[1] = to
92
+ else
93
+ ranges << [ from, to ]
94
+ end
95
+ end
96
+ end
97
+
98
+ # [error_log_id, occurred_at] for other errors' occurrences in any window
99
+ def nearby_occurrences(ranges)
100
+ column = "#{ErrorOccurrence.table_name}.occurred_at"
101
+ in_a_window = ranges.map { "(#{column} BETWEEN ? AND ?)" }.join(" OR ")
102
+
103
+ ErrorOccurrence
104
+ .where.not(error_log_id: @error_log_id)
105
+ .where(in_a_window, *ranges.flatten)
106
+ .order(occurred_at: :desc)
107
+ .limit(NEARBY_OCCURRENCES)
108
+ .pluck(:error_log_id, :occurred_at)
109
+ end
84
110
  end
85
111
  end
86
112
  end
@@ -65,6 +65,13 @@ module RailsErrorDashboard
65
65
  @volume ||= Queries::EventVolume.new(base_scope, @start_date)
66
66
  end
67
67
 
68
+ # Events per platform for the platforms with errors in the window, the
69
+ # only ones scored or given a health card. Errors with no platform value
70
+ # belong to none.
71
+ def active_error_rates
72
+ @active_error_rates ||= error_rate_by_platform.except(nil)
73
+ end
74
+
68
75
  # platform => { error_type => events in the window }.
69
76
  #
70
77
  # Severity, critical counts and cross-platform totals all fold from this
@@ -158,25 +165,50 @@ module RailsErrorDashboard
158
165
  # Higher score = more stable (fewer errors, faster resolution)
159
166
  # @return [Hash] Platform => stability score
160
167
  def platform_stability_scores
161
- platforms = base_scope.distinct.pluck(:platform).compact
162
- error_rates = error_rate_by_platform
168
+ # Only platforms with errors in the window are scored. Every platform
169
+ # ever seen used to be, and one with none in the window scored 100.
170
+ error_rates = active_error_rates
171
+ # A score compares platforms. With fewer than two that have errors in
172
+ # the window there is nothing to compare against, so none is rated (a
173
+ # lone platform used to come out Critical every time).
174
+ return {} if error_rates.size < 2
163
175
  resolution_times = resolution_time_by_platform
164
176
 
165
- # Find max values for normalization
166
- max_errors = error_rates.values.max || 1
167
- max_resolution_time = resolution_times.values.compact.max || 1
177
+ total_errors = error_rates.values.sum
178
+ even_share = 1.0 / error_rates.size
179
+ fastest_resolution = resolution_times.values_at(*error_rates.keys).compact.min
168
180
 
169
- platforms.each_with_object({}) do |platform, result|
170
- error_count = error_rates[platform] || 0
171
- avg_resolution = resolution_times[platform] || 0
181
+ error_rates.each_with_object({}) do |(platform, error_count), result|
182
+ avg_resolution = resolution_times[platform]
172
183
 
173
- # Normalize to 0-1 scale (inverted - lower is better)
174
- error_score = 1.0 - (error_count.to_f / max_errors)
175
- resolution_score = avg_resolution.positive? ? 1.0 - (avg_resolution / max_resolution_time) : 1.0
184
+ # This platform's share of all errors against an even split: full
185
+ # credit at or below it, falling linearly to 0 when one platform has
186
+ # every error. Against the raw share instead, no evenly split app
187
+ # could be Healthy. The even-split test is done in integers so an
188
+ # exact split is never lost to rounding.
189
+ error_score = if error_count * error_rates.size <= total_errors
190
+ 1.0
191
+ else
192
+ 1.0 - (((error_count.to_f / total_errors) - even_share) / (1.0 - even_share))
193
+ end
176
194
 
177
- # Weight: 70% error count, 30% resolution time
178
- # Convert to 0-100 scale
179
- result[platform] = ((error_score * 0.7 + resolution_score * 0.3) * 100).round(1)
195
+ # The fastest platform's average resolution time over this one's,
196
+ # each floored at one hour: the fastest and any as fast get full
197
+ # credit, one twice as slow gets half. Against the slowest instead,
198
+ # the slowest always scored 0 here. The floor keeps an average of 0.0
199
+ # (resolved at once) from leaving every other platform with none.
200
+ result[platform] = if avg_resolution
201
+ resolution_score = [ fastest_resolution, 1.0 ].max / [ avg_resolution, 1.0 ].max
202
+
203
+ # Weight: 70% error count, 30% resolution time
204
+ # Convert to 0-100 scale
205
+ ((error_score * 0.7 + resolution_score * 0.3) * 100).round(1)
206
+ else
207
+ # Nothing resolved in the window: no resolution time to compare, so
208
+ # the score is the volume credit alone, on the same scale. It used
209
+ # to get the full resolution credit. The cards say which it is.
210
+ (error_score * 100).round(1)
211
+ end
180
212
  end
181
213
  end
182
214
 
@@ -220,12 +252,13 @@ module RailsErrorDashboard
220
252
  # Get platform health summary
221
253
  # @return [Hash] Platform => health metrics
222
254
  def platform_health_summary
223
- error_rates = error_rate_by_platform
255
+ error_rates = active_error_rates
224
256
  stability_scores = platform_stability_scores
225
257
  severities = severity_distribution_by_platform
226
258
  midpoint = @start_date + (@days / 2.0).days
227
259
 
228
- platforms.each_with_object({}) do |platform, result|
260
+ # A card for each platform with errors in the window, as the scores
261
+ error_rates.each_key.with_object({}) do |platform, result|
229
262
  platform_scope = base_scope.where(platform: platform)
230
263
  groups_in_window = platform_scope.where("occurred_at >= ?", @start_date)
231
264
 
@@ -252,9 +285,13 @@ module RailsErrorDashboard
252
285
  critical_errors: critical_errors,
253
286
  unresolved_errors: unresolved_errors,
254
287
  resolution_rate: resolution_rate,
255
- stability_score: stability_scores[platform] || 0,
288
+ # false when nothing from the window is resolved, so the stability
289
+ # score is volume alone
290
+ resolution_measured: resolved_errors.positive?,
291
+ # nil and :not_rated when there are fewer than two platforms
292
+ stability_score: stability_scores[platform],
256
293
  error_velocity: velocity, # Positive = increasing, negative = decreasing
257
- health_status: determine_health_status(stability_scores[platform] || 0, velocity)
294
+ health_status: stability_scores.key?(platform) ? determine_health_status(stability_scores[platform], velocity) : :not_rated
258
295
  }
259
296
  end
260
297
  end
@@ -99,13 +99,7 @@ module RailsErrorDashboard
99
99
  return nil if backtrace.blank?
100
100
 
101
101
  lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
102
- frames = lines.first(20).map do |line|
103
- if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
104
- "#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
105
- elsif line =~ %r{([^/]+\.rb)}
106
- Regexp.last_match(1)
107
- end
108
- end.compact.uniq
102
+ frames = lines.first(20).map { |line| frame_key(line) }.compact.uniq
109
103
 
110
104
  return nil if frames.empty?
111
105
 
@@ -113,6 +107,21 @@ module RailsErrorDashboard
113
107
  Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
114
108
  end
115
109
 
110
+ # "file.rb:method" for one backtrace line, the unit similar-error matching
111
+ # compares. It accepts Ruby 3.3's in `save' and Ruby 3.4's in 'User#save',
112
+ # and drops 3.4's class qualifier ("block in User#save" -> "block in
113
+ # save"), so a frame matches whichever Ruby recorded it.
114
+ #
115
+ # @param line [String]
116
+ # @return [String, nil]
117
+ def self.frame_key(line)
118
+ if line =~ %r{([^/]+\.rb):.*?in [`'](.+)'$}
119
+ "#{Regexp.last_match(1)}:#{Regexp.last_match(2).gsub(/\b[A-Z][\w:]*[#.]/, "")}"
120
+ elsif line =~ %r{([^/]+\.rb)}
121
+ Regexp.last_match(1)
122
+ end
123
+ end
124
+
116
125
  # Calculate signature directly from Location objects
117
126
  # @param locations [Array<Thread::Backtrace::Location>] Backtrace locations
118
127
  # @return [String, nil] 16-character hex signature
@@ -267,6 +267,11 @@ module RailsErrorDashboard
267
267
  attributes[:exception_cause] = cause_json
268
268
  end
269
269
 
270
+ # The at_exit hook wrote this backtrace from the crashed process
271
+ if ErrorLog.column_names.include?("backtrace_source")
272
+ attributes[:backtrace_source] = "server"
273
+ end
274
+
270
275
  if ErrorLog.column_names.include?("error_hash")
271
276
  attributes[:error_hash] = Services::ErrorHashGenerator.from_attributes(
272
277
  error_type: attributes[:error_type],
@@ -77,14 +77,9 @@ module RailsErrorDashboard
77
77
 
78
78
  private
79
79
 
80
- # Normalize repository URL (remove .git suffix, trailing slashes, etc.)
81
- #
82
80
  # @return [String]
83
81
  def normalize_repository_url
84
- url = repository_url.strip
85
- url = url.chomp(".git")
86
- url = url.chomp("/")
87
- url
82
+ RepositoryUrl.normalize(repository_url)
88
83
  end
89
84
 
90
85
  # Host of the repository URL, downcased, or nil when the value is not an
@@ -64,33 +64,32 @@ module RailsErrorDashboard
64
64
  raw = @error.backtrace
65
65
  return nil if raw.blank?
66
66
 
67
- lines = raw.split("\n")
68
- app_lines = lines.reject { |l| l.include?("/gems/") || l.include?("/ruby/") || l.include?("/vendor/") }
69
- app_lines = lines.first(MAX_BACKTRACE_LINES) if app_lines.empty?
67
+ app_lines = app_frames.map { |frame| frame[:full_line] }
68
+ app_lines = raw.split("\n") if app_lines.empty?
70
69
  app_lines = app_lines.first(MAX_BACKTRACE_LINES)
71
70
 
72
71
  "## Backtrace\n\n```\n#{app_lines.join("\n")}\n```"
73
72
  end
74
73
 
74
+ # The application's frames, as the error page classifies them. RED stores
75
+ # frames shortened ("gems/…", "ruby/…"), so a "/gems/" substring test
76
+ # kept every gem frame.
77
+ def app_frames
78
+ @app_frames ||= Services::BacktraceParser.parse(@error.backtrace.to_s).select { |frame| frame[:category] == :app }
79
+ end
80
+
75
81
  def source_code_section
76
82
  return nil unless defined?(RailsErrorDashboard) &&
77
83
  RailsErrorDashboard.configuration.enable_source_code_integration
78
84
 
79
- raw = @error.backtrace
80
- return nil if raw.blank?
81
-
82
- lines = raw.split("\n")
83
- app_lines = lines.reject { |l| l.include?("/gems/") || l.include?("/ruby/") || l.include?("/vendor/") }
84
- return nil if app_lines.empty?
85
+ return nil if @error.backtrace.blank? || app_frames.empty?
86
+ # A client's frames describe code that ran on the client
87
+ return nil if @error.client_backtrace?
85
88
 
86
89
  snippets = []
87
- app_lines.first(3).each do |frame|
88
- # Parse "file:line:in 'method'" format
89
- match = frame.match(/^(.+?):(\d+)/)
90
- next unless match
91
-
92
- file_path = match[1]
93
- line_number = match[2].to_i
90
+ app_frames.first(3).each do |frame|
91
+ file_path = frame[:file_path]
92
+ line_number = frame[:line_number]
94
93
 
95
94
  reader = Services::SourceCodeReader.new(file_path, line_number)
96
95
  source_lines = reader.read_lines(context: 3)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # The one normal form of a configured repository URL, shared by the View
6
+ # Source links and the issue tracker: no surrounding whitespace, no trailing
7
+ # slashes, no ".git". The slashes go first, or ".git/" keeps its ".git".
8
+ module RepositoryUrl
9
+ # @param url [String, nil]
10
+ # @return [String, nil]
11
+ def self.normalize(url)
12
+ return nil if url.nil?
13
+
14
+ url.to_s.strip.sub(%r{/+\z}, "").delete_suffix(".git").sub(%r{/+\z}, "")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -80,19 +80,13 @@ module RailsErrorDashboard
80
80
  end
81
81
 
82
82
  # Extract meaningful frames from backtrace
83
- # Format: "file.rb:123:in `method_name`" => "file.rb:method_name"
83
+ # "file.rb:123:in `name'" (Ruby 3.3) or "file.rb:123:in 'Class#name'"
84
+ # (Ruby 3.4) => "file.rb:name", via BacktraceProcessor.frame_key
84
85
  def extract_frames(backtrace)
85
86
  return [] if backtrace.blank?
86
87
 
87
88
  lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
88
- lines.first(20).map do |line| # Only consider first 20 frames
89
- # Extract file path and method name, ignore line numbers
90
- if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
91
- "#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
92
- elsif line =~ %r{([^/]+\.rb)}
93
- Regexp.last_match(1)
94
- end
95
- end.compact.uniq
89
+ lines.first(20).map { |line| BacktraceProcessor.frame_key(line) }.compact.uniq # Only consider first 20 frames
96
90
  end
97
91
 
98
92
  # Normalize message for comparison
@@ -38,7 +38,8 @@ module RailsErrorDashboard
38
38
  Entry = Struct.new(
39
39
  :error_class, :message, :first_app_frame,
40
40
  :controller_name, :action_name, :custom_hash, :environment,
41
- :opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets
41
+ :opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets,
42
+ :capture_source
42
43
  )
43
44
 
44
45
  # The bucket an instant belongs to, as an epoch second.
@@ -120,6 +121,7 @@ module RailsErrorDashboard
120
121
  # job CANNOT recompute this -- it has to travel, or a storm count
121
122
  # lands on a different row than the full capture path would.
122
123
  "opaque_identity" => entry.opaque_identity,
124
+ "capture_source" => entry.capture_source,
123
125
  "count" => entry.count.value,
124
126
  "first_seen_at" => entry.first_seen_at.iso8601,
125
127
  "last_seen_at" => entry.last_seen_at.iso8601,
@@ -167,7 +169,7 @@ module RailsErrorDashboard
167
169
  parts[:controller_name], parts[:action_name], parts[:custom_hash], parts[:environment],
168
170
  parts[:opaque_identity],
169
171
  Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current,
170
- Concurrent::Map.new
172
+ Concurrent::Map.new, parts[:capture_source]
171
173
  )
172
174
  end
173
175
  end
@@ -216,7 +218,8 @@ module RailsErrorDashboard
216
218
  error_class: entry["error_class"], message: entry["message"],
217
219
  first_app_frame: entry["first_app_frame"], controller_name: entry["controller_name"],
218
220
  action_name: entry["action_name"], custom_hash: entry["custom_hash"],
219
- environment: entry["environment"], opaque_identity: entry["opaque_identity"]
221
+ environment: entry["environment"], opaque_identity: entry["opaque_identity"],
222
+ capture_source: entry["capture_source"]
220
223
  }
221
224
  end
222
225
 
@@ -279,7 +279,12 @@ module RailsErrorDashboard
279
279
  # Only an EXPLICIT environment from the caller. nil means "the
280
280
  # worker's own environment", resolved at flush time exactly as
281
281
  # LogError resolves it for a full capture.
282
- environment: context[:environment].to_s.strip.presence&.[](0, 64)
282
+ environment: context[:environment].to_s.strip.presence&.[](0, 64),
283
+ # Whose backtrace first_app_frame came from, for the row the flush
284
+ # may build from it. Not part of the identity or the gate key: a
285
+ # client report and a server capture of one error share an entry,
286
+ # which keeps the first event's frame, and so its source.
287
+ capture_source: context[:_capture_source] == "client" ? "client" : "server"
283
288
  )
284
289
  end
285
290
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module ValueObjects
5
+ # The error as it looked at one occurrence: a read-only copy of the error
6
+ # row whose breadcrumbs, system health and variables are that occurrence's
7
+ # stored context. Every other field keeps the row's latest value.
8
+ #
9
+ # A real ErrorLog rather than a delegator, so every way a card reads a
10
+ # piece sees the occurrence's value: the instance-variables card goes
11
+ # through read_attribute and error.class.column_names, because
12
+ # Object#instance_variables shadows the attribute reader.
13
+ module OccurrenceSnapshot
14
+ # @param error [ErrorLog] the error the context belongs to
15
+ # @param context [ErrorOccurrenceContext] one of its occurrences' contexts
16
+ # @return [ErrorLog] read-only; a piece the occurrence did not capture is nil
17
+ def self.build(error, context)
18
+ pieces = Commands::RecordOccurrenceContext::PIECES.to_h { |piece| [ piece.to_s, context.read_attribute(piece) ] }
19
+ error.class.instantiate(error.attributes_before_type_cast.merge(pieces)).tap(&:readonly!)
20
+ end
21
+ end
22
+ end
23
+ end