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.
- checksums.yaml +4 -4
- data/README.md +7 -3
- data/app/controllers/rails_error_dashboard/errors_controller.rb +29 -2
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +7 -14
- data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +5 -0
- data/app/models/rails_error_dashboard/error_log.rb +11 -8
- data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
- data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +10 -3
- data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
- data/app/views/rails_error_dashboard/errors/settings.html.erb +6 -5
- data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
- data/config/locales/de.yml +19 -0
- data/config/locales/en.yml +21 -0
- data/config/locales/es.yml +20 -0
- data/config/locales/fr.yml +20 -0
- data/config/locales/it.yml +20 -0
- data/config/locales/ja.yml +18 -0
- data/config/locales/pl.yml +21 -0
- data/config/locales/pt-BR.yml +20 -0
- data/config/locales/ru.yml +21 -0
- data/config/locales/uk.yml +21 -0
- data/config/locales/zh-CN.yml +18 -0
- data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
- data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
- data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +14 -5
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
- data/lib/rails_error_dashboard/commands/log_error.rb +19 -7
- data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
- data/lib/rails_error_dashboard/configuration.rb +19 -3
- data/lib/rails_error_dashboard/error_reporter.rb +4 -2
- data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +16 -7
- data/lib/rails_error_dashboard/services/crash_capture.rb +5 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +1 -6
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
- data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
- data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +16 -0
- data/lib/tasks/error_dashboard.rake +2 -3
- 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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# app's ApplicationController
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1130
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|