rails_error_dashboard 0.12.1 → 0.13.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/app/controllers/rails_error_dashboard/application_controller.rb +92 -11
- data/app/controllers/rails_error_dashboard/errors_controller.rb +111 -34
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +3 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +46 -1
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +8 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +71 -0
- data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +68 -1
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
- data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
- data/app/models/rails_error_dashboard/error_comment.rb +0 -5
- data/app/models/rails_error_dashboard/error_log.rb +9 -3
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +11 -3
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -8
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
- data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
- data/config/locales/de.yml +28 -0
- data/config/locales/en.yml +35 -0
- data/config/locales/es.yml +28 -0
- data/config/locales/fr.yml +28 -0
- data/config/locales/it.yml +28 -0
- data/config/locales/ja.yml +28 -0
- data/config/locales/pl.yml +28 -0
- data/config/locales/pt-BR.yml +28 -0
- data/config/locales/ru.yml +28 -0
- data/config/locales/uk.yml +28 -0
- data/config/locales/zh-CN.yml +28 -0
- data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
- data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
- data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
- data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +39 -7
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +27 -4
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +82 -23
- data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
- data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
- data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
- data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
- data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
- data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
- data/lib/rails_error_dashboard/configuration.rb +19 -1
- data/lib/rails_error_dashboard/engine.rb +15 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -3
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +55 -50
- data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
- data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
- data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
- data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
- data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
- data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
- data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
- data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
- data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
- data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
- data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
- data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
- data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
- data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
- data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
- data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
- data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
- data/lib/rails_error_dashboard/value_objects/error_context.rb +3 -1
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +31 -0
- data/lib/tasks/error_dashboard.rake +54 -4
- metadata +10 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Sends the ONE message that replaces the rest of a burst of new-error
|
|
5
|
+
# notifications.
|
|
6
|
+
#
|
|
7
|
+
# A bad deploy can produce hundreds of DISTINCT new errors. Each is a first
|
|
8
|
+
# occurrence, so the per-error cooldown never applies; without a cap every
|
|
9
|
+
# one of them pages. NotificationThrottler.burst_decision lets the first
|
|
10
|
+
# config.notification_burst_limit through per window and asks for this job
|
|
11
|
+
# exactly once, when the limit is first exceeded.
|
|
12
|
+
#
|
|
13
|
+
# The message states the limit and the window rather than a count of what
|
|
14
|
+
# was suppressed: it is sent when suppression STARTS, and a count at that
|
|
15
|
+
# moment would always be one. Every error is still stored and counted; only
|
|
16
|
+
# the notifications are held back.
|
|
17
|
+
class NotificationBurstSummaryJob < ApplicationJob
|
|
18
|
+
include Concerns::PlainChannelMessage
|
|
19
|
+
|
|
20
|
+
queue_as :default
|
|
21
|
+
|
|
22
|
+
# @param limit [Integer] config.notification_burst_limit when the cap engaged
|
|
23
|
+
# @param window_seconds [Integer] config.notification_burst_window_seconds
|
|
24
|
+
# @param locale [String, nil] resolved at enqueue time
|
|
25
|
+
def perform(limit:, window_seconds:, locale: nil)
|
|
26
|
+
config = RailsErrorDashboard.configuration
|
|
27
|
+
|
|
28
|
+
deliver_plain_message(build_message(limit, window_seconds, config, job_locale(locale)), {
|
|
29
|
+
event: "new_error_notifications_suppressed",
|
|
30
|
+
limit: limit,
|
|
31
|
+
window_seconds: window_seconds,
|
|
32
|
+
application: app_name(config)
|
|
33
|
+
})
|
|
34
|
+
rescue => e
|
|
35
|
+
Rails.logger.error("[RailsErrorDashboard] NotificationBurstSummaryJob failed: #{e.class} - #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Whole sentences joined, never fragments: see StormNotificationJob.
|
|
41
|
+
# ":warning:" is Slack/Discord emoji shortcode, not text.
|
|
42
|
+
def build_message(limit, window_seconds, config, locale)
|
|
43
|
+
dashboard = (config.dashboard_base_url || "").chomp("/")
|
|
44
|
+
link = if dashboard.present?
|
|
45
|
+
" " + t("red.notifications.burst.dashboard", locale, url: "#{dashboard}/errors")
|
|
46
|
+
else
|
|
47
|
+
""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
":warning: " \
|
|
51
|
+
"#{t("red.notifications.burst.suppressed", locale, application: app_name(config), limit: limit, window: window_seconds)} " \
|
|
52
|
+
"#{t("red.notifications.burst.still_recorded", locale)}#{link}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -14,6 +14,25 @@ module RailsErrorDashboard
|
|
|
14
14
|
class RetentionCleanupJob < ApplicationJob
|
|
15
15
|
queue_as :default
|
|
16
16
|
|
|
17
|
+
# The errors retention applies to: "not seen for retention_days", not
|
|
18
|
+
# "first seen retention_days ago". occurred_at is stamped when a group is
|
|
19
|
+
# created and never moves, so expiring by it deleted errors that were still
|
|
20
|
+
# happening today -- with their comments and triage history -- the day
|
|
21
|
+
# they turned N days old.
|
|
22
|
+
#
|
|
23
|
+
# Equivalent to COALESCE(last_seen_at, occurred_at) < cutoff (the NULL arm
|
|
24
|
+
# covers rows from before last_seen_at existed), but written so that each
|
|
25
|
+
# arm can use its own index; wrapping the columns in COALESCE would force a
|
|
26
|
+
# full scan of the largest table on every run.
|
|
27
|
+
#
|
|
28
|
+
# Public because the rake task previews the same selection before it asks
|
|
29
|
+
# for confirmation.
|
|
30
|
+
def self.expired_scope(cutoff)
|
|
31
|
+
ErrorLog.where(
|
|
32
|
+
"last_seen_at < :cutoff OR (last_seen_at IS NULL AND occurred_at < :cutoff)", cutoff: cutoff
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
17
36
|
# @return [Integer] number of errors deleted
|
|
18
37
|
def perform
|
|
19
38
|
retention_days = RailsErrorDashboard.configuration.retention_days
|
|
@@ -26,8 +45,10 @@ module RailsErrorDashboard
|
|
|
26
45
|
# whenever no error logs happen to be expired.
|
|
27
46
|
cleanup_rack_attack_events(cutoff)
|
|
28
47
|
cleanup_storm_flush_batches(cutoff)
|
|
48
|
+
cleanup_diagnostic_dumps(cutoff)
|
|
49
|
+
cleanup_swallowed_exceptions(cutoff)
|
|
29
50
|
|
|
30
|
-
expired_scope =
|
|
51
|
+
expired_scope = self.class.expired_scope(cutoff)
|
|
31
52
|
return 0 if expired_scope.none?
|
|
32
53
|
|
|
33
54
|
deleted_count = 0
|
|
@@ -48,6 +69,9 @@ module RailsErrorDashboard
|
|
|
48
69
|
deleted_count += batch_size
|
|
49
70
|
end
|
|
50
71
|
|
|
72
|
+
# delete_all skips callbacks, and the stat cards are cached.
|
|
73
|
+
Services::AnalyticsCacheManager.clear if deleted_count > 0
|
|
74
|
+
|
|
51
75
|
if deleted_count > 0
|
|
52
76
|
RailsErrorDashboard::Logger.info(
|
|
53
77
|
"[RailsErrorDashboard] Retention cleanup: deleted #{deleted_count} errors older than #{retention_days} days"
|
|
@@ -86,6 +110,49 @@ module RailsErrorDashboard
|
|
|
86
110
|
)
|
|
87
111
|
end
|
|
88
112
|
|
|
113
|
+
# Diagnostic dumps and swallowed-exception aggregates are written
|
|
114
|
+
# independently of error logs and nothing else ever deletes them, so they
|
|
115
|
+
# grew without bound. Not gated on their feature flags: rows written while
|
|
116
|
+
# a feature was on must still expire after it is switched off. Own rescue
|
|
117
|
+
# each, like the cleanups around them.
|
|
118
|
+
def cleanup_diagnostic_dumps(cutoff)
|
|
119
|
+
return unless DiagnosticDump.table_exists?
|
|
120
|
+
|
|
121
|
+
deleted = 0
|
|
122
|
+
DiagnosticDump.where("captured_at < ?", cutoff).in_batches(of: 1000) do |batch|
|
|
123
|
+
deleted += batch.delete_all
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if deleted > 0
|
|
127
|
+
RailsErrorDashboard::Logger.info(
|
|
128
|
+
"[RailsErrorDashboard] Retention cleanup: deleted #{deleted} diagnostic dumps"
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
rescue => e
|
|
132
|
+
RailsErrorDashboard::Logger.debug(
|
|
133
|
+
"[RailsErrorDashboard] Diagnostic dump retention cleanup failed: #{e.class} - #{e.message}"
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def cleanup_swallowed_exceptions(cutoff)
|
|
138
|
+
return unless SwallowedException.table_exists?
|
|
139
|
+
|
|
140
|
+
deleted = 0
|
|
141
|
+
SwallowedException.where("period_hour < ?", cutoff).in_batches(of: 1000) do |batch|
|
|
142
|
+
deleted += batch.delete_all
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if deleted > 0
|
|
146
|
+
RailsErrorDashboard::Logger.info(
|
|
147
|
+
"[RailsErrorDashboard] Retention cleanup: deleted #{deleted} swallowed exception records"
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
rescue => e
|
|
151
|
+
RailsErrorDashboard::Logger.debug(
|
|
152
|
+
"[RailsErrorDashboard] Swallowed exception retention cleanup failed: #{e.class} - #{e.message}"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
89
156
|
# Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
|
|
90
157
|
# failure here (e.g. table not yet migrated) never blocks error cleanup.
|
|
91
158
|
def cleanup_rack_attack_events(cutoff)
|
|
@@ -7,6 +7,8 @@ module RailsErrorDashboard
|
|
|
7
7
|
# help nobody) — this one message replaces them. The gate guarantees at
|
|
8
8
|
# most one enqueue per episode; this job just delivers.
|
|
9
9
|
class StormNotificationJob < ApplicationJob
|
|
10
|
+
include Concerns::PlainChannelMessage
|
|
11
|
+
|
|
10
12
|
queue_as :default
|
|
11
13
|
|
|
12
14
|
# @param started_at [String] ISO8601 episode start
|
|
@@ -17,23 +19,12 @@ module RailsErrorDashboard
|
|
|
17
19
|
config = RailsErrorDashboard.configuration
|
|
18
20
|
message = build_message(started_at, state, config, job_locale(locale))
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
if config.enable_webhook_notifications && config.webhook_urls.any?
|
|
29
|
-
payload = {
|
|
30
|
-
event: "error_storm_detected",
|
|
31
|
-
started_at: started_at,
|
|
32
|
-
state: state,
|
|
33
|
-
application: app_name(config)
|
|
34
|
-
}
|
|
35
|
-
config.webhook_urls.each { |url| post_json(url, payload) }
|
|
36
|
-
end
|
|
22
|
+
deliver_plain_message(message, {
|
|
23
|
+
event: "error_storm_detected",
|
|
24
|
+
started_at: started_at,
|
|
25
|
+
state: state,
|
|
26
|
+
application: app_name(config)
|
|
27
|
+
})
|
|
37
28
|
rescue => e
|
|
38
29
|
Rails.logger.error("[RailsErrorDashboard] StormNotificationJob failed: #{e.class} - #{e.message}")
|
|
39
30
|
end
|
|
@@ -61,32 +52,5 @@ module RailsErrorDashboard
|
|
|
61
52
|
"#{t("red.notifications.storm.engaged", locale, mode: mode)} " \
|
|
62
53
|
"#{t("red.notifications.storm.suppressed", locale)}#{link}"
|
|
63
54
|
end
|
|
64
|
-
|
|
65
|
-
def t(key, locale, **options)
|
|
66
|
-
RailsErrorDashboard::I18nStore.translate(key, locale: locale, **options)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def app_name(config)
|
|
70
|
-
config.application_name || ENV["APPLICATION_NAME"] ||
|
|
71
|
-
(defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def post_json(url, payload)
|
|
75
|
-
if defined?(HTTParty)
|
|
76
|
-
HTTParty.post(url, body: payload.to_json,
|
|
77
|
-
headers: { "Content-Type" => "application/json" }, timeout: 10)
|
|
78
|
-
else
|
|
79
|
-
uri = URI(url)
|
|
80
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
81
|
-
http.use_ssl = uri.scheme == "https"
|
|
82
|
-
http.open_timeout = 5
|
|
83
|
-
http.read_timeout = 10
|
|
84
|
-
request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
|
|
85
|
-
request.body = payload.to_json
|
|
86
|
-
http.request(request)
|
|
87
|
-
end
|
|
88
|
-
rescue => e
|
|
89
|
-
Rails.logger.error("[RailsErrorDashboard] Storm notification post failed: #{e.message}")
|
|
90
|
-
end
|
|
91
55
|
end
|
|
92
56
|
end
|
|
@@ -48,17 +48,20 @@ module RailsErrorDashboard
|
|
|
48
48
|
return nil if mean.nil? || std_dev.nil?
|
|
49
49
|
return nil if current_count <= mean
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
# nil when std_dev is zero: a perfectly flat history has no spread to
|
|
52
|
+
# measure against, and dividing by it made every count :critical.
|
|
53
|
+
sigma = std_devs_above_mean(current_count)
|
|
54
|
+
return nil if sigma.nil?
|
|
52
55
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
:high
|
|
58
|
-
when (sensitivity + 2)..Float::INFINITY
|
|
56
|
+
# Half-open bands, highest first: a boundary value belongs to the band it
|
|
57
|
+
# opens (3.0 is :high, 4.0 is :critical). Inclusive ranges matched
|
|
58
|
+
# top-down gave each boundary to the band below it.
|
|
59
|
+
if sigma >= sensitivity + 2
|
|
59
60
|
:critical
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
elsif sigma >= sensitivity + 1
|
|
62
|
+
:high
|
|
63
|
+
elsif sigma >= sensitivity
|
|
64
|
+
:elevated
|
|
62
65
|
end
|
|
63
66
|
end
|
|
64
67
|
|
|
@@ -14,11 +14,6 @@ module RailsErrorDashboard
|
|
|
14
14
|
scope :recent_first, -> { order(created_at: :desc) }
|
|
15
15
|
scope :oldest_first, -> { order(created_at: :asc) }
|
|
16
16
|
|
|
17
|
-
# Get formatted timestamp for display
|
|
18
|
-
def formatted_time
|
|
19
|
-
created_at.strftime("%b %d, %Y at %I:%M %p")
|
|
20
|
-
end
|
|
21
|
-
|
|
22
17
|
# Check if comment was created recently (within last hour)
|
|
23
18
|
def recent?
|
|
24
19
|
created_at > 1.hour.ago
|
|
@@ -86,9 +86,9 @@ module RailsErrorDashboard
|
|
|
86
86
|
after_create_commit -> { Services::ErrorBroadcaster.broadcast_new(self) }
|
|
87
87
|
after_update_commit -> { Services::ErrorBroadcaster.broadcast_update(self) }
|
|
88
88
|
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
# No cache invalidation here, on purpose. A save is what a CAPTURE does, in
|
|
90
|
+
# the host app's request thread; the stats caches expire by TTL instead, and
|
|
91
|
+
# the commands behind user actions call AnalyticsCacheManager.clear themselves.
|
|
92
92
|
|
|
93
93
|
def set_defaults
|
|
94
94
|
self.platform ||= "API"
|
|
@@ -271,7 +271,13 @@ module RailsErrorDashboard
|
|
|
271
271
|
end
|
|
272
272
|
end
|
|
273
273
|
|
|
274
|
+
# The five workflow statuses. One list, so that a command can tell
|
|
275
|
+
# "unknown status" from "known, but not reachable from here".
|
|
276
|
+
STATUSES = %w[new in_progress investigating resolved wont_fix].freeze
|
|
277
|
+
|
|
274
278
|
def can_transition_to?(new_status)
|
|
279
|
+
return false unless STATUSES.include?(new_status)
|
|
280
|
+
|
|
275
281
|
# Define valid status transitions
|
|
276
282
|
valid_transitions = {
|
|
277
283
|
"new" => [ "in_progress", "investigating", "wont_fix" ],
|
|
@@ -35,6 +35,23 @@ module RailsErrorDashboard
|
|
|
35
35
|
# after the user's configuration is loaded
|
|
36
36
|
# See lib/rails_error_dashboard/engine.rb
|
|
37
37
|
|
|
38
|
+
# Read-side half of the invalid-bytes defence. Capture scrubs what it
|
|
39
|
+
# writes, but SQLite and MySQL will happily hold a row written before that
|
|
40
|
+
# (or by anything else), and one such string takes down every page that
|
|
41
|
+
# renders it: ERB raises "invalid byte sequence in UTF-8". Scrubbing on load
|
|
42
|
+
# makes the page render whether or not the operator has run
|
|
43
|
+
# error_dashboard:scrub_invalid_encoding yet. In memory only: nothing is
|
|
44
|
+
# written, and the record is not left dirty.
|
|
45
|
+
after_find :scrub_invalid_strings
|
|
46
|
+
|
|
47
|
+
# Names of the attributes the load-time scrub had to repair, so that
|
|
48
|
+
# Commands::ScrubInvalidEncoding can persist the repair: once the values
|
|
49
|
+
# are clean in memory, re-reading them can no longer tell a bad row apart.
|
|
50
|
+
# @return [Array<String>]
|
|
51
|
+
def invalid_encoding_attributes
|
|
52
|
+
@invalid_encoding_attributes || []
|
|
53
|
+
end
|
|
54
|
+
|
|
38
55
|
# Rails' default for a string column when the adapter has one (MySQL).
|
|
39
56
|
DEFAULT_STRING_LIMIT = 255
|
|
40
57
|
|
|
@@ -68,5 +85,22 @@ module RailsErrorDashboard
|
|
|
68
85
|
limits[name] = column.limit || DEFAULT_STRING_LIMIT if column.type == :string
|
|
69
86
|
end
|
|
70
87
|
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def scrub_invalid_strings
|
|
92
|
+
@attributes.each_value do |attribute|
|
|
93
|
+
value = attribute.value_before_type_cast
|
|
94
|
+
next unless value.is_a?(String)
|
|
95
|
+
next if value.encoding == Encoding::UTF_8 && value.valid_encoding? && !value.include?("\0")
|
|
96
|
+
|
|
97
|
+
name = attribute.name
|
|
98
|
+
self[name] = Services::EncodingSanitizer.scrub(value)
|
|
99
|
+
clear_attribute_changes([ name ])
|
|
100
|
+
(@invalid_encoding_attributes ||= []) << name
|
|
101
|
+
end
|
|
102
|
+
rescue => e
|
|
103
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] scrub on load failed: #{e.class}: #{e.message}")
|
|
104
|
+
end
|
|
71
105
|
end
|
|
72
106
|
end
|
|
@@ -23,7 +23,15 @@ module RailsErrorDashboard
|
|
|
23
23
|
scope :in_time_window, ->(start_time, end_time) { where(occurred_at: start_time..end_time) }
|
|
24
24
|
scope :for_user, ->(user_id) { where(user_id: user_id) }
|
|
25
25
|
scope :for_request, ->(request_id) { where(request_id: request_id) }
|
|
26
|
-
|
|
26
|
+
# Takes the raw session ID (or a stored digest). Rows are stored as a keyed
|
|
27
|
+
# digest while filter_sensitive_data is on, and raw otherwise or before the
|
|
28
|
+
# upgrade, so both forms are matched. Blank matches nothing.
|
|
29
|
+
scope :for_session, lambda { |session_id|
|
|
30
|
+
raw = session_id.to_s
|
|
31
|
+
next none if raw.empty?
|
|
32
|
+
|
|
33
|
+
where(session_id: [ raw, Services::SensitiveDataFilter.digest_session_id(raw) ].compact.uniq)
|
|
34
|
+
}
|
|
27
35
|
|
|
28
36
|
# Find occurrences within a time window around this occurrence
|
|
29
37
|
# @param window_minutes [Integer] Time window in minutes (default: 5)
|
|
@@ -1182,10 +1182,18 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
|
|
|
1182
1182
|
<i class="bi bi-layout-sidebar-inset"></i>
|
|
1183
1183
|
</button>
|
|
1184
1184
|
<!-- Search -->
|
|
1185
|
-
|
|
1185
|
+
<%# A plain GET form to the errors index, using the same "search"
|
|
1186
|
+
parameter as the index's own field. Hand-written rather than
|
|
1187
|
+
form_with: a GET form must not put an authenticity token or a
|
|
1188
|
+
utf8 field into the query string. Only String parameters are
|
|
1189
|
+
echoed back; a nested one is ignored. %>
|
|
1190
|
+
<form action="<%= errors_path %>" method="get" role="search" style="position: relative; margin: 0;">
|
|
1186
1191
|
<i class="bi bi-search" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); font-size: 13px; color: var(--text-tertiary);"></i>
|
|
1187
|
-
<input class="red-search-input" placeholder="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
|
|
1188
|
-
|
|
1192
|
+
<input class="red-search-input" type="text" name="search" value="<%= params[:search] if params[:search].is_a?(String) %>" placeholder="<%= red_t("red.navbar.search_placeholder") %>" aria-label="<%= red_t("red.navbar.search_placeholder") %>" id="globalSearch" />
|
|
1193
|
+
<% if params[:application_id].is_a?(String) && params[:application_id].present? %>
|
|
1194
|
+
<input type="hidden" name="application_id" value="<%= params[:application_id] %>" />
|
|
1195
|
+
<% end %>
|
|
1196
|
+
</form>
|
|
1189
1197
|
</div>
|
|
1190
1198
|
<div style="display: flex; align-items: center; gap: var(--space-3);">
|
|
1191
1199
|
<!-- Application Switcher (multi-app) -->
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
<% has_linked_issue = error.respond_to?(:external_issue_url) && error.external_issue_url.present? %>
|
|
2
|
+
<%# nil when the stored value is not plain http(s); the button is then omitted
|
|
3
|
+
(the issue card explains why). See safe_external_url. %>
|
|
4
|
+
<% discussion_issue_url = has_linked_issue ? safe_external_url(error.external_issue_url) : nil %>
|
|
2
5
|
<% has_audit_comments = error.respond_to?(:comments) && error.comments.any? %>
|
|
3
6
|
<% platform_comments = @platform_comments || [] %>
|
|
4
7
|
<% issue_tracking_enabled = RailsErrorDashboard.configuration.enable_issue_tracking %>
|
|
@@ -30,9 +33,11 @@
|
|
|
30
33
|
<span class="badge bg-secondary ms-2"><%= platform_comments.size %></span>
|
|
31
34
|
<% end %>
|
|
32
35
|
</h5>
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
+
<% if discussion_issue_url %>
|
|
37
|
+
<a href="<%= discussion_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
|
|
38
|
+
<i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.reply_on", provider: discussion_provider) %>
|
|
39
|
+
</a>
|
|
40
|
+
<% end %>
|
|
36
41
|
</div>
|
|
37
42
|
<div class="card-body p-0">
|
|
38
43
|
<% if platform_comments.any? %>
|
|
@@ -40,14 +45,14 @@
|
|
|
40
45
|
<% platform_comments.each_with_index do |comment, index| %>
|
|
41
46
|
<div class="px-3 py-3 <%= index < platform_comments.size - 1 ? 'border-bottom' : '' %>">
|
|
42
47
|
<div class="d-flex align-items-start gap-2 mb-2">
|
|
43
|
-
<% if comment[:avatar_url]
|
|
44
|
-
<img src="<%=
|
|
48
|
+
<% if (avatar = safe_external_url(comment[:avatar_url])) %>
|
|
49
|
+
<img src="<%= avatar %>" alt="<%= comment[:author] %>" width="24" height="24" class="rounded-circle" style="flex-shrink: 0;">
|
|
45
50
|
<% else %>
|
|
46
51
|
<i class="bi bi-person-circle" style="font-size: 1.3em; flex-shrink: 0;"></i>
|
|
47
52
|
<% end %>
|
|
48
53
|
<div>
|
|
49
54
|
<strong class="text-primary"><%= comment[:author] || red_t("red.common.unknown") %></strong>
|
|
50
|
-
<small class="text-muted ms-2"><%= comment[:created_at]
|
|
55
|
+
<small class="text-muted ms-2"><%= local_time(safe_parse_time(comment[:created_at]), format: :datetime, fallback: "") %></small>
|
|
51
56
|
</div>
|
|
52
57
|
</div>
|
|
53
58
|
<div class="ms-4 text-break" style="font-size: 0.9em;">
|
|
@@ -68,7 +73,7 @@
|
|
|
68
73
|
<div class="d-flex justify-content-between align-items-start">
|
|
69
74
|
<div>
|
|
70
75
|
<small class="text-primary fw-bold"><i class="bi bi-person-circle"></i> <%= comment.author_name %></small>
|
|
71
|
-
<small class="text-muted ms-2"><%= comment.
|
|
76
|
+
<small class="text-muted ms-2"><%= local_time(comment.created_at, format: :datetime) %></small>
|
|
72
77
|
</div>
|
|
73
78
|
</div>
|
|
74
79
|
<small class="text-muted text-break"><%= simple_format(h(comment.body), {}, wrapper_tag: "span") %></small>
|
|
@@ -84,9 +89,11 @@
|
|
|
84
89
|
<h5 class="mb-0">
|
|
85
90
|
<i class="bi bi-chat-dots"></i> <%= red_t("red.errors.discussion.title") %>
|
|
86
91
|
</h5>
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
92
|
+
<% if discussion_issue_url %>
|
|
93
|
+
<a href="<%= discussion_issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
|
|
94
|
+
<i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.discussion.start_on", provider: discussion_provider) %>
|
|
95
|
+
</a>
|
|
96
|
+
<% end %>
|
|
90
97
|
</div>
|
|
91
98
|
<div class="card-body text-center py-3">
|
|
92
99
|
<p class="text-muted small mb-0"><%= red_t("red.errors.discussion.empty", provider: discussion_provider_lc) %></p>
|
|
@@ -106,7 +113,7 @@
|
|
|
106
113
|
<div class="d-flex justify-content-between align-items-start">
|
|
107
114
|
<div>
|
|
108
115
|
<small class="text-primary fw-bold"><i class="bi bi-person-circle"></i> <%= comment.author_name %></small>
|
|
109
|
-
<small class="text-muted ms-2"><%= comment.
|
|
116
|
+
<small class="text-muted ms-2"><%= local_time(comment.created_at, format: :datetime) %></small>
|
|
110
117
|
</div>
|
|
111
118
|
</div>
|
|
112
119
|
<small class="text-muted text-break"><%= simple_format(h(comment.body), {}, wrapper_tag: "span") %></small>
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
<i class="bi bi-link-45deg me-2"></i>
|
|
6
6
|
<%= red_t("red.errors.issue_section.title") %>
|
|
7
7
|
</h5>
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
<%# nil unless the stored value is plain http(s) — see safe_external_url. %>
|
|
9
|
+
<% issue_url = safe_external_url(@error.external_issue_url) %>
|
|
10
|
+
<% if issue_url %>
|
|
11
|
+
<a href="<%= issue_url %>" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary">
|
|
10
12
|
<i class="bi bi-box-arrow-up-right me-1"></i> <%= red_t("red.errors.issue_section.view_issue") %>
|
|
11
13
|
</a>
|
|
12
14
|
<% end %>
|
|
@@ -23,6 +25,15 @@
|
|
|
23
25
|
end %>
|
|
24
26
|
<% issue = @platform_issue %>
|
|
25
27
|
|
|
28
|
+
<% unless issue_url %>
|
|
29
|
+
<%# Stored, but not plain http(s): shown as text, never as a link. %>
|
|
30
|
+
<div class="alert alert-warning py-2 px-3 mb-3" style="font-size: 0.85em;">
|
|
31
|
+
<i class="bi bi-exclamation-triangle me-1"></i>
|
|
32
|
+
<%= red_t("red.errors.issue_section.unsafe_link") %>
|
|
33
|
+
<code class="d-block text-break mt-1"><%= @error.external_issue_url.to_s.truncate(200) %></code>
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
|
|
26
37
|
<!-- Issue badge + status -->
|
|
27
38
|
<div class="d-flex align-items-center gap-2 mb-3">
|
|
28
39
|
<span class="badge bg-success">
|
|
@@ -50,8 +61,8 @@
|
|
|
50
61
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
51
62
|
<% issue[:assignees].each do |assignee| %>
|
|
52
63
|
<span class="d-inline-flex align-items-center gap-1 border rounded-pill px-2 py-1" style="font-size: 0.85em;">
|
|
53
|
-
<% if assignee[:avatar_url]
|
|
54
|
-
<img src="<%=
|
|
64
|
+
<% if (avatar = safe_external_url(assignee[:avatar_url])) %>
|
|
65
|
+
<img src="<%= avatar %>" width="18" height="18" class="rounded-circle">
|
|
55
66
|
<% end %>
|
|
56
67
|
<%= assignee[:login] %>
|
|
57
68
|
</span>
|
|
@@ -68,8 +79,9 @@
|
|
|
68
79
|
<% if issue[:labels].present? && issue[:labels].any? %>
|
|
69
80
|
<div class="d-flex align-items-center gap-1 flex-wrap">
|
|
70
81
|
<% issue[:labels].each do |label| %>
|
|
71
|
-
|
|
72
|
-
<%
|
|
82
|
+
<%# Third-party value going into style=: validated, never interpolated raw. %>
|
|
83
|
+
<% bg_color = safe_label_color(label[:color]) %>
|
|
84
|
+
<% text_color = label_text_color(bg_color) %>
|
|
73
85
|
<span class="badge rounded-pill" style="background-color: <%= bg_color %>; color: <%= text_color %>; font-size: 0.78em;">
|
|
74
86
|
<%= label[:name] %>
|
|
75
87
|
</span>
|
|
@@ -118,10 +130,12 @@
|
|
|
118
130
|
</div>
|
|
119
131
|
</div>
|
|
120
132
|
|
|
121
|
-
|
|
133
|
+
<%# The URL comes from the forge's API response; window.open would run a
|
|
134
|
+
javascript: target, so it gets the same http(s) check as the links. %>
|
|
135
|
+
<% if (new_issue_url = safe_external_url(flash[:new_issue_url])) %>
|
|
122
136
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
123
137
|
(function() {
|
|
124
|
-
window.open(
|
|
138
|
+
window.open(<%= js_safe_json(new_issue_url) %>, '_blank');
|
|
125
139
|
})();
|
|
126
140
|
</script>
|
|
127
141
|
<% end %>
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
|
|
73
73
|
<% if error.respond_to?(:environment) && error.environment.present? %>
|
|
74
74
|
<div class="mb-3">
|
|
75
|
-
<small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment") %></small>
|
|
75
|
+
<small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment.title") %></small>
|
|
76
76
|
<%= link_to errors_path(environment: error.environment, unresolved: '0'), class: "text-decoration-none", title: red_t("red.errors.sidebar.view_all_environment", environment: error.environment) do %>
|
|
77
77
|
<span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
|
|
78
78
|
<% end %>
|
|
@@ -106,12 +106,21 @@
|
|
|
106
106
|
The <a> is built here rather than living in the key. The key
|
|
107
107
|
is _html and therefore html_safe, so a raw URL interpolated
|
|
108
108
|
into an href inside it would not be escaped and a crafted
|
|
109
|
-
external_issue_url could inject an event handler.
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
external_issue_url could inject an event handler.
|
|
110
|
+
|
|
111
|
+
link_to escapes the URL, which stops attribute BREAKOUT. It
|
|
112
|
+
does not reject a scheme: link_to("x", "javascript:...")
|
|
113
|
+
is a working link. That protection comes from
|
|
114
|
+
safe_external_url, which returns nil for anything that is
|
|
115
|
+
not plain http(s). %>
|
|
116
|
+
<% stored_issue_url = error.respond_to?(:external_issue_url) ? error.external_issue_url : nil %>
|
|
117
|
+
<% sidebar_issue_url = safe_external_url(stored_issue_url) %>
|
|
118
|
+
<% if sidebar_issue_url %>
|
|
112
119
|
<%= red_t("red.errors.sidebar.issue_tracking_linked_html",
|
|
113
120
|
link: link_to(red_t("red.errors.sidebar.issue_tracking_link_text"),
|
|
114
|
-
|
|
121
|
+
sidebar_issue_url, target: "_blank", rel: "noopener")) %>
|
|
122
|
+
<% elsif stored_issue_url.present? %>
|
|
123
|
+
<%= red_t("red.errors.issue_section.unsafe_link") %>
|
|
115
124
|
<% else %>
|
|
116
125
|
<%= red_t("red.errors.sidebar.issue_tracking_unlinked") %>
|
|
117
126
|
<% end %>
|
|
@@ -504,7 +513,7 @@
|
|
|
504
513
|
<% if health[:job_queue] %>
|
|
505
514
|
<% jq = health[:job_queue] %>
|
|
506
515
|
<div class="mb-1">
|
|
507
|
-
<small class="text-muted"
|
|
516
|
+
<small class="text-muted"><%= red_t("red.errors.sidebar.health.job_queue", adapter: jq[:adapter]) %></small>
|
|
508
517
|
<code class="ms-1">
|
|
509
518
|
<% case jq[:adapter] %>
|
|
510
519
|
<% when "sidekiq" %>
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
71
71
|
document.addEventListener('DOMContentLoaded', function() {
|
|
72
72
|
const colors = window.getChartColors();
|
|
73
|
-
new Chartkick.LineChart("errors-over-time-chart", <%=
|
|
73
|
+
new Chartkick.LineChart("errors-over-time-chart", <%= js_safe_json(@errors_over_time) %>, {
|
|
74
74
|
color: "#DC2626",
|
|
75
75
|
curve: false,
|
|
76
76
|
points: true,
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
"#EF4444" // Red - iOS
|
|
123
123
|
];
|
|
124
124
|
|
|
125
|
-
new Chartkick.PieChart("errors-by-platform-chart", <%=
|
|
125
|
+
new Chartkick.PieChart("errors-by-platform-chart", <%= js_safe_json(@errors_by_platform) %>, {
|
|
126
126
|
colors: platformColors,
|
|
127
127
|
height: "300px",
|
|
128
128
|
legend: "bottom",
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
const colors = window.getChartColors();
|
|
174
174
|
const environmentColors = ["#EF4444", "#F59E0B", "#2563EB", "#10B981", "#8B5CF6", "#6B7280"];
|
|
175
175
|
|
|
176
|
-
new Chartkick.PieChart("errors-by-environment-chart", <%=
|
|
176
|
+
new Chartkick.PieChart("errors-by-environment-chart", <%= js_safe_json(environment_counts) %>, {
|
|
177
177
|
colors: environmentColors,
|
|
178
178
|
height: "300px",
|
|
179
179
|
legend: "bottom",
|
|
@@ -217,7 +217,7 @@
|
|
|
217
217
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
218
218
|
document.addEventListener('DOMContentLoaded', function() {
|
|
219
219
|
const colors = window.getChartColors();
|
|
220
|
-
new Chartkick.BarChart("errors-by-type-chart", <%=
|
|
220
|
+
new Chartkick.BarChart("errors-by-type-chart", <%= js_safe_json(@errors_by_type) %>, {
|
|
221
221
|
color: "#EF4444",
|
|
222
222
|
height: "400px",
|
|
223
223
|
library: {
|
|
@@ -260,7 +260,7 @@
|
|
|
260
260
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
261
261
|
document.addEventListener('DOMContentLoaded', function() {
|
|
262
262
|
const colors = window.getChartColors();
|
|
263
|
-
new Chartkick.ColumnChart("errors-by-hour-chart", <%=
|
|
263
|
+
new Chartkick.ColumnChart("errors-by-hour-chart", <%= js_safe_json(@errors_by_hour) %>, {
|
|
264
264
|
color: "#DC2626",
|
|
265
265
|
height: "300px",
|
|
266
266
|
library: {
|
|
@@ -469,7 +469,7 @@
|
|
|
469
469
|
<div id="errors-by-version-chart"></div>
|
|
470
470
|
<script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
|
|
471
471
|
document.addEventListener('DOMContentLoaded', function() {
|
|
472
|
-
const versionData = <%=
|
|
472
|
+
const versionData = <%= js_safe_json(@errors_by_version.transform_values { |v| v[:count] }) %>;
|
|
473
473
|
const colors = window.getChartColors();
|
|
474
474
|
|
|
475
475
|
new Chartkick.ColumnChart("errors-by-version-chart", versionData, {
|
|
@@ -608,7 +608,7 @@
|
|
|
608
608
|
document.addEventListener('DOMContentLoaded', function() {
|
|
609
609
|
const colors = window.getChartColors();
|
|
610
610
|
new Chartkick.BarChart("mttr-by-platform-chart",
|
|
611
|
-
<%=
|
|
611
|
+
<%= js_safe_json(@mttr_by_platform) %>, {
|
|
612
612
|
suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
|
|
613
613
|
height: "200px",
|
|
614
614
|
colors: ["#8B5CF6"],
|
|
@@ -661,7 +661,7 @@
|
|
|
661
661
|
document.addEventListener('DOMContentLoaded', function() {
|
|
662
662
|
const colors = window.getChartColors();
|
|
663
663
|
new Chartkick.LineChart("mttr-trend-chart",
|
|
664
|
-
<%=
|
|
664
|
+
<%= js_safe_json(@mttr_stats[:mttr_trend]) %>, {
|
|
665
665
|
suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
|
|
666
666
|
height: "250px",
|
|
667
667
|
colors: ["#10B981"],
|