rails_error_dashboard 0.15.0 → 0.16.0

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -3
  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/overview.html.erb +10 -7
  16. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  17. data/app/views/rails_error_dashboard/errors/settings.html.erb +6 -5
  18. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  19. data/config/locales/de.yml +19 -0
  20. data/config/locales/en.yml +21 -0
  21. data/config/locales/es.yml +20 -0
  22. data/config/locales/fr.yml +20 -0
  23. data/config/locales/it.yml +20 -0
  24. data/config/locales/ja.yml +18 -0
  25. data/config/locales/pl.yml +21 -0
  26. data/config/locales/pt-BR.yml +20 -0
  27. data/config/locales/ru.yml +21 -0
  28. data/config/locales/uk.yml +21 -0
  29. data/config/locales/zh-CN.yml +18 -0
  30. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  31. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  32. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  33. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +14 -5
  34. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  35. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +7 -2
  36. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  37. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  38. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +2 -4
  39. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  40. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  41. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  42. data/lib/rails_error_dashboard/commands/log_error.rb +19 -7
  43. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  44. data/lib/rails_error_dashboard/configuration.rb +19 -3
  45. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  46. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  47. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  48. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  49. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  50. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  51. data/lib/rails_error_dashboard/services/backtrace_processor.rb +16 -7
  52. data/lib/rails_error_dashboard/services/crash_capture.rb +5 -0
  53. data/lib/rails_error_dashboard/services/github_link_generator.rb +1 -6
  54. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  55. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  56. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  57. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  58. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  59. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  60. data/lib/rails_error_dashboard/version.rb +1 -1
  61. data/lib/rails_error_dashboard.rb +16 -0
  62. data/lib/tasks/error_dashboard.rake +2 -3
  63. metadata +10 -2
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Where each capture came from: "server" for RED's own captures (middleware,
4
+ # the Rails.error subscriber, jobs, crash import), "client" for errors sent
5
+ # through ManualErrorReporter by a browser, mobile app or other client.
6
+ #
7
+ # error_occurrences.capture_source how this occurrence was captured
8
+ # error_logs.backtrace_source where the backtrace the error page shows
9
+ # came from (the group's backtrace can be
10
+ # replaced by a later capture's)
11
+ #
12
+ # A client's backtrace describes code that ran on the client, so the features
13
+ # that read this app's source files for a frame (View Source, git blame, the
14
+ # snippets in Copy for LLM and AI help) skip it.
15
+ #
16
+ # Nullable, no default: NULL is a row from before this migration and reads as
17
+ # server, which is how those rows have always been treated. No index: nothing
18
+ # queries by it. Not part of the fingerprint: a client report and a server
19
+ # capture of the same error stay one group.
20
+ #
21
+ # up/down rather than `change`: the column_exists? guards that make `up` safe
22
+ # to re-run would make a reversed `change` skip the removal.
23
+ class AddCaptureSourceToErrorOccurrencesAndErrorLogs < ActiveRecord::Migration[7.0]
24
+ COLUMNS = {
25
+ rails_error_dashboard_error_occurrences: :capture_source,
26
+ rails_error_dashboard_error_logs: :backtrace_source
27
+ }.freeze
28
+
29
+ def up
30
+ COLUMNS.each do |table, column|
31
+ next unless table_exists?(table)
32
+ next if column_exists?(table, column)
33
+
34
+ add_column table, column, :string, limit: 10
35
+ end
36
+ end
37
+
38
+ def down
39
+ COLUMNS.each do |table, column|
40
+ next unless table_exists?(table)
41
+ next unless column_exists?(table, column)
42
+
43
+ remove_column table, column
44
+ end
45
+ end
46
+ end
@@ -41,11 +41,14 @@ RailsErrorDashboard.configure do |config|
41
41
  # warden, session, request, params, cookies, redirect_to, etc.
42
42
  # Return truthy to allow access, falsy to deny (403 Forbidden).
43
43
  #
44
- # NOTE: Devise helpers (current_user, authenticate_user!) are NOT available
45
- # because the engine controller inherits from ActionController::Base, not your
46
- # app's ApplicationController. Use `warden` directly instead.
47
- #
48
- # Devise/Warden example (recommended):
44
+ # Devise helpers (current_user, user_signed_in?, authenticate_user!) work:
45
+ # Devise adds them to ActionController::Base, which the engine's controller
46
+ # inherits. Methods defined only in your app's ApplicationController do not:
47
+ # the lambda raises and access is denied (logged as "authenticate_with lambda
48
+ # raised").
49
+ #
50
+ # Devise examples:
51
+ # config.authenticate_with = -> { current_user&.admin? }
49
52
  # config.authenticate_with = -> { warden.authenticated? }
50
53
  #
51
54
  # Warden with redirect to login:
@@ -408,6 +411,12 @@ RailsErrorDashboard.configure do |config|
408
411
  config.enable_system_health = false
409
412
 
410
413
  <% end -%>
414
+ # Occurrence Context History (NEW in 0.16.0)
415
+ # Each occurrence keeps its own breadcrumbs, system health and variables, so
416
+ # any of an error's recent occurrences can be opened, not only the latest.
417
+ # Kept for the newest N occurrences per error; 0 stops storing them.
418
+ config.occurrence_context_limit = 25
419
+
411
420
  <% if @enable_swallowed_exceptions -%>
412
421
  # Swallowed Exception Detection - ENABLED
413
422
  # Requires Ruby 3.3+ — detects exceptions that are raised then silently rescued
@@ -23,6 +23,7 @@ module RailsErrorDashboard
23
23
 
24
24
  muted_count = 0
25
25
  failed_ids = []
26
+ failures = {}
26
27
  muted_errors = []
27
28
 
28
29
  errors.each do |error|
@@ -33,11 +34,18 @@ module RailsErrorDashboard
33
34
  muted_by: @muted_by,
34
35
  muted_reason: @reason
35
36
  )
37
+ # The comment a single mute writes, so the reason shows in the thread
38
+ if @reason.present?
39
+ error.comments.create!(author_name: @muted_by || "System", body: "Muted notifications: #{@reason}")
40
+ end
36
41
  muted_count += 1
37
42
  muted_errors << error
38
43
  rescue => e
39
44
  failed_ids << error.id
40
- RailsErrorDashboard::Logger.error("Failed to mute error #{error.id}: #{e.message}")
45
+ failures[error.id] = "#{e.class}: #{e.message}"
46
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
47
+ # this is the only record of why the row failed
48
+ Rails.logger.warn("[RailsErrorDashboard] Failed to mute error #{error.id}: #{e.class}: #{e.message}")
41
49
  end
42
50
  end
43
51
 
@@ -50,7 +58,8 @@ module RailsErrorDashboard
50
58
  count: muted_count,
51
59
  total: @error_ids.size,
52
60
  failed_ids: failed_ids,
53
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.mute", count: failed_ids.size) ]
61
+ failures: failures,
62
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.mute", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
54
63
  }
55
64
  rescue => e
56
65
  RailsErrorDashboard::Logger.error("Batch mute failed: #{e.message}")
@@ -24,6 +24,7 @@ module RailsErrorDashboard
24
24
 
25
25
  resolved_count = 0
26
26
  failed_ids = []
27
+ failures = {}
27
28
 
28
29
  resolved_errors = []
29
30
 
@@ -40,7 +41,10 @@ module RailsErrorDashboard
40
41
  resolved_errors << error
41
42
  rescue => e
42
43
  failed_ids << error.id
43
- RailsErrorDashboard::Logger.error("Failed to resolve error #{error.id}: #{e.message}")
44
+ failures[error.id] = "#{e.class}: #{e.message}"
45
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
46
+ # this is the only record of why the row failed
47
+ Rails.logger.warn("[RailsErrorDashboard] Failed to resolve error #{error.id}: #{e.class}: #{e.message}")
44
48
  end
45
49
  end
46
50
 
@@ -62,7 +66,8 @@ module RailsErrorDashboard
62
66
  count: resolved_count,
63
67
  total: @error_ids.size,
64
68
  failed_ids: failed_ids,
65
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.resolve", count: failed_ids.size) ]
69
+ failures: failures,
70
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.resolve", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
66
71
  }
67
72
  rescue => e
68
73
  RailsErrorDashboard::Logger.error("Batch resolve failed: #{e.message}")
@@ -21,6 +21,7 @@ module RailsErrorDashboard
21
21
 
22
22
  unmuted_count = 0
23
23
  failed_ids = []
24
+ failures = {}
24
25
  unmuted_errors = []
25
26
 
26
27
  errors.each do |error|
@@ -35,7 +36,10 @@ module RailsErrorDashboard
35
36
  unmuted_errors << error
36
37
  rescue => e
37
38
  failed_ids << error.id
38
- RailsErrorDashboard::Logger.error("Failed to unmute error #{error.id}: #{e.message}")
39
+ failures[error.id] = "#{e.class}: #{e.message}"
40
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
41
+ # this is the only record of why the row failed
42
+ Rails.logger.warn("[RailsErrorDashboard] Failed to unmute error #{error.id}: #{e.class}: #{e.message}")
39
43
  end
40
44
  end
41
45
 
@@ -48,7 +52,8 @@ module RailsErrorDashboard
48
52
  count: unmuted_count,
49
53
  total: @error_ids.size,
50
54
  failed_ids: failed_ids,
51
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.unmute", count: failed_ids.size) ]
55
+ failures: failures,
56
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.unmute", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
52
57
  }
53
58
  rescue => e
54
59
  RailsErrorDashboard::Logger.error("Batch unmute failed: #{e.message}")
@@ -230,6 +230,12 @@ module RailsErrorDashboard
230
230
  return {} unless incoming.include?("\n") || incoming.length > stored.length
231
231
 
232
232
  upgrade = { backtrace: @attributes[:backtrace] }
233
+ # The source goes with the backtrace it describes: a client's backtrace
234
+ # replacing a server one must make the row client, and the reverse. A
235
+ # caller that names none is RED's own server-side code.
236
+ if ErrorLog.column_names.include?("backtrace_source")
237
+ upgrade[:backtrace_source] = @attributes[:backtrace_source].presence || "server"
238
+ end
233
239
  # The row is no longer a reconstructed exemplar: it now carries a real
234
240
  # stack from a real capture, so it must stop describing itself as
235
241
  # "minimal".
@@ -91,10 +91,8 @@ module RailsErrorDashboard
91
91
  end
92
92
 
93
93
  def current_application_id
94
- app_name = RailsErrorDashboard.configuration.application_name
95
- return nil unless app_name.present?
96
-
97
- Application.find_by(name: app_name)&.id
94
+ # The name capture files errors under, so these rows join that app
95
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
98
96
  rescue => e
99
97
  nil
100
98
  end
@@ -83,10 +83,8 @@ module RailsErrorDashboard
83
83
  end
84
84
 
85
85
  def current_application_id
86
- app_name = RailsErrorDashboard.configuration.application_name
87
- return nil unless app_name.present?
88
-
89
- Application.find_by(name: app_name)&.id
86
+ # The name capture files errors under, so these rows join that app
87
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
90
88
  rescue => e
91
89
  nil
92
90
  end
@@ -391,6 +391,12 @@ module RailsErrorDashboard
391
391
  if ErrorLog.column_names.include?("context_captured_at")
392
392
  create_attrs[:context_captured_at] = create_attrs[:occurred_at]
393
393
  end
394
+ # The backtrace above is the exemplar's first frame, so it is a
395
+ # client's when the counted event was a client report. An entry from
396
+ # a release that did not record the source reads as server.
397
+ if ErrorLog.column_names.include?("backtrace_source")
398
+ create_attrs[:backtrace_source] = entry["capture_source"] == "client" ? "client" : "server"
399
+ end
394
400
  begin
395
401
  # requires_new opens a SAVEPOINT: on PostgreSQL a failed INSERT aborts
396
402
  # its transaction, and every later statement -- including the recovery
@@ -498,12 +504,7 @@ module RailsErrorDashboard
498
504
  end
499
505
 
500
506
  def resolve_application
501
- # Same chain LogError uses — app name is process-global
502
- app_name = RailsErrorDashboard.configuration.application_name ||
503
- ENV["APPLICATION_NAME"] ||
504
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
505
- "Rails Application"
506
- Application.find_or_create_by_name(app_name)
507
+ Application.find_or_create_by_name(RailsErrorDashboard.resolved_application_name)
507
508
  end
508
509
 
509
510
  # Record the interval whose per-event timing was lost.
@@ -90,10 +90,8 @@ module RailsErrorDashboard
90
90
  end
91
91
 
92
92
  def current_application_id
93
- app_name = RailsErrorDashboard.configuration.application_name
94
- return nil unless app_name.present?
95
-
96
- Application.find_by(name: app_name)&.id
93
+ # The name capture files errors under, so these rows join that app
94
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
97
95
  rescue => e
98
96
  nil
99
97
  end
@@ -513,6 +513,13 @@ module RailsErrorDashboard
513
513
  attributes[:environment] = resolve_environment
514
514
  end
515
515
 
516
+ # Where the backtrace this row is created with came from. Not part of
517
+ # the fingerprint: a client report and a server capture of one error
518
+ # stay one group.
519
+ if ErrorLog.column_names.include?("backtrace_source")
520
+ attributes[:backtrace_source] = capture_source
521
+ end
522
+
516
523
  # Neutralise invalid bytes BEFORE filtering: the filter runs regexes,
517
524
  # which raise on an invalid string, and PostgreSQL rejects the INSERT.
518
525
  attributes = Services::EncodingSanitizer.scrub_deep(attributes)
@@ -654,8 +661,12 @@ module RailsErrorDashboard
654
661
  occurrence_columns = ErrorOccurrence.column_names
655
662
  occurrence_attrs[:app_version] = attributes[:app_version] if occurrence_columns.include?("app_version")
656
663
  occurrence_attrs[:git_sha] = attributes[:git_sha] if occurrence_columns.include?("git_sha")
664
+ occurrence_attrs[:capture_source] = capture_source if occurrence_columns.include?("capture_source")
657
665
  occurrence_attrs = Services::EncodingSanitizer.scrub_deep(occurrence_attrs)
658
- ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
666
+ occurrence = ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
667
+ # This occurrence's own copy of the context the row just got. Never
668
+ # raises, so a failure here is never logged as the occurrence's.
669
+ RecordOccurrenceContext.call(occurrence, attributes) if occurrence.persisted?
659
670
  rescue => e
660
671
  RailsErrorDashboard::Logger.error("Failed to create error occurrence: #{e.message}")
661
672
  end
@@ -799,12 +810,7 @@ module RailsErrorDashboard
799
810
  end
800
811
 
801
812
  def find_or_create_application
802
- app_name = RailsErrorDashboard.configuration.application_name ||
803
- ENV["APPLICATION_NAME"] ||
804
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
805
- "Rails Application"
806
-
807
- Application.find_or_create_by_name(app_name)
813
+ Application.find_or_create_by_name(RailsErrorDashboard.resolved_application_name)
808
814
  rescue => e
809
815
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Failed to find/create application: #{e.message}")
810
816
  # Fallback: try to find any application or create default
@@ -1006,6 +1012,12 @@ module RailsErrorDashboard
1006
1012
 
1007
1013
  # Symbol or string key: the async job round-trips the context through the
1008
1014
  # queue serializer, which turns symbol keys into strings.
1015
+ # "client" for a ManualErrorReporter report, "server" for every other
1016
+ # capture. Only the exact marker ManualErrorReporter sets counts.
1017
+ def capture_source
1018
+ context_value(:_capture_source) == "client" ? "client" : "server"
1019
+ end
1020
+
1009
1021
  def context_value(key)
1010
1022
  return nil unless @context.is_a?(Hash)
1011
1023
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Keeps one occurrence's moment-of-failure context, then trims the error's
6
+ # stored contexts to the newest config.occurrence_context_limit.
7
+ #
8
+ # The values are the JSON strings LogError has just written to the error
9
+ # row (breadcrumbs, system health, local and instance variables), so an
10
+ # occurrence holds exactly what the row would, filtered the same way. A
11
+ # capture with none of them (a storm-protection :lite capture, or every
12
+ # feature off) stores nothing.
13
+ #
14
+ # Runs inside the capture: it never raises.
15
+ class RecordOccurrenceContext
16
+ PIECES = %i[breadcrumbs system_health local_variables instance_variables].freeze
17
+
18
+ # @param occurrence [ErrorOccurrence] the occurrence just created
19
+ # @param attributes [Hash] the attributes LogError wrote to the error row
20
+ def self.call(occurrence, attributes)
21
+ new(occurrence, attributes).call
22
+ end
23
+
24
+ def initialize(occurrence, attributes)
25
+ @occurrence = occurrence
26
+ @attributes = attributes
27
+ end
28
+
29
+ def call
30
+ limit = RailsErrorDashboard.configuration.occurrence_context_limit.to_i
31
+ return if limit <= 0
32
+
33
+ values = PIECES.index_with { |piece| @attributes[piece].presence }.compact
34
+ return if values.empty?
35
+
36
+ # An app that updated the gem but has not migrated yet
37
+ return unless ErrorOccurrenceContext.table_exists?
38
+
39
+ ErrorOccurrenceContext.create!(
40
+ values.merge(error_occurrence_id: @occurrence.id, error_log_id: @occurrence.error_log_id, created_at: Time.current)
41
+ )
42
+ trim(limit)
43
+ rescue => e
44
+ RailsErrorDashboard::Logger.error(
45
+ "[RailsErrorDashboard] Failed to record occurrence context: #{e.class}: #{e.message}"
46
+ )
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ # Everything past the newest `limit`, normally the one the insert pushed out
53
+ def trim(limit)
54
+ stale = ErrorOccurrenceContext.where(error_log_id: @occurrence.error_log_id)
55
+ .order(id: :desc).offset(limit).pluck(:id)
56
+ ErrorOccurrenceContext.where(id: stale).delete_all if stale.any?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -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"
@@ -1126,9 +1141,10 @@ module RailsErrorDashboard
1126
1141
  return nil if effective_issue_tracker_provider == :linear
1127
1142
  return nil if git_repository_url.blank?
1128
1143
 
1129
- # Extract owner/repo from URL: https://github.com/owner/repo(.git)
1130
- match = git_repository_url.match(%r{[:/]([^/]+/[^/]+?)(?:\.git)?$})
1131
- match&.[](1)
1144
+ # owner/repo from the normalized URL, so ".git", ".git/" and trailing
1145
+ # slashes all name the same repository the View Source links use
1146
+ url = Services::RepositoryUrl.normalize(git_repository_url)
1147
+ url.match(%r{[:/]([^/]+/[^/]+)\z})&.[](1)
1132
1148
  end
1133
1149
 
1134
1150
  # 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