rails_error_dashboard 0.12.0 → 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 +14 -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 +107 -26
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d762f92c5645173b17543925db4d5740d1294af6a2da64e0a9cef6b6032211b
|
|
4
|
+
data.tar.gz: 056bca6e5ba6083666cf44302979bc8d3ff276d76563de87793051619b616994
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5383eafcd408b881b4721fc496df7077c387c6ecb7f10543dfe8570049dda45c2c408f59f2f0e10c2c385a448a8d1acc6719da0367a654b8e42455bd4c568797
|
|
7
|
+
data.tar.gz: 6c77de41c4c43f81eab6f7a3bd59626ca2d48d002fe312c535c71e8b8ffd33ec5eddcbec2b3601a8d3d52aee25ceb65037fdf4d56497730f3d85b7a5c8b463e7
|
|
@@ -10,7 +10,13 @@ module RailsErrorDashboard
|
|
|
10
10
|
# Declaring it here inverts the default: a controller is protected unless
|
|
11
11
|
# it explicitly opts out with skip_before_action, which is a visible,
|
|
12
12
|
# reviewable act rather than an omission nobody notices.
|
|
13
|
-
|
|
13
|
+
#
|
|
14
|
+
# Prepended so it runs before the CSRF check. A host app with
|
|
15
|
+
# default_protect_from_forgery registers verify_authenticity_token on
|
|
16
|
+
# ActionController::Base, ahead of anything declared here; an unauthenticated
|
|
17
|
+
# POST was then answered about its token (and by the error renderer) instead
|
|
18
|
+
# of with a 401.
|
|
19
|
+
prepend_before_action :authenticate_dashboard_user!
|
|
14
20
|
|
|
15
21
|
include Pagy::Method
|
|
16
22
|
|
|
@@ -46,7 +52,9 @@ module RailsErrorDashboard
|
|
|
46
52
|
# Log the error for debugging
|
|
47
53
|
Rails.logger.error("[RailsErrorDashboard] Dashboard controller error: #{exception.class} - #{exception.message}")
|
|
48
54
|
Rails.logger.error("Request: #{request.path} (#{request.method})")
|
|
49
|
-
|
|
55
|
+
# filtered_parameters, not params.inspect: the host app's filter_parameters
|
|
56
|
+
# must apply to the dashboard's log lines too.
|
|
57
|
+
Rails.logger.error("Params: #{request.filtered_parameters.inspect}")
|
|
50
58
|
Rails.logger.error(exception.backtrace&.first(10)&.join("\n")) if exception.backtrace
|
|
51
59
|
|
|
52
60
|
render_dashboard_error(
|
|
@@ -72,6 +80,45 @@ module RailsErrorDashboard
|
|
|
72
80
|
)
|
|
73
81
|
end
|
|
74
82
|
|
|
83
|
+
# Client mistakes are not dashboard failures. Without these, the catch-all
|
|
84
|
+
# above answered 500 "Something went wrong" for an expired CSRF token, which
|
|
85
|
+
# is both the wrong status and the wrong advice. One warn line each, and no
|
|
86
|
+
# parameters: a rejected form post is exactly where a secret would be.
|
|
87
|
+
rescue_from ActionController::ParameterMissing, ActionController::BadRequest do |exception|
|
|
88
|
+
Rails.logger.warn("[RailsErrorDashboard] Bad request: #{exception.class} on #{request.method} #{request.path}")
|
|
89
|
+
|
|
90
|
+
render_dashboard_error(
|
|
91
|
+
icon: "bi-x-octagon",
|
|
92
|
+
title: red_t("red.errors_page.bad_request.title"),
|
|
93
|
+
message: red_t("red.errors_page.bad_request.message"),
|
|
94
|
+
status: :bad_request
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
rescue_from ActionController::UnknownFormat do |exception|
|
|
99
|
+
Rails.logger.warn("[RailsErrorDashboard] Unknown format: #{exception.class} on #{request.method} #{request.path}")
|
|
100
|
+
|
|
101
|
+
render_dashboard_error(
|
|
102
|
+
icon: "bi-file-earmark-x",
|
|
103
|
+
title: red_t("red.errors_page.not_acceptable.title"),
|
|
104
|
+
message: red_t("red.errors_page.not_acceptable.message"),
|
|
105
|
+
status: :not_acceptable
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
rescue_from ActionController::InvalidAuthenticityToken do |exception|
|
|
110
|
+
Rails.logger.warn("[RailsErrorDashboard] CSRF token rejected: #{exception.class} on #{request.method} #{request.path}")
|
|
111
|
+
|
|
112
|
+
render_dashboard_error(
|
|
113
|
+
icon: "bi-shield-exclamation",
|
|
114
|
+
title: red_t("red.errors_page.csrf.title"),
|
|
115
|
+
message: red_t("red.errors_page.csrf.message"),
|
|
116
|
+
# Numeric: Rack renamed the symbol (:unprocessable_entity is deprecated,
|
|
117
|
+
# :unprocessable_content is unknown to the Rack that Rails 7.0 uses).
|
|
118
|
+
status: 422
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
75
122
|
# Handle Pagy pagination errors — redirect to page 1, preserving filters.
|
|
76
123
|
# Drop both :page and :per_page from the preserved query string. Either can
|
|
77
124
|
# trigger the rescue (page out of range, per_page negative or non-numeric);
|
|
@@ -81,9 +128,12 @@ module RailsErrorDashboard
|
|
|
81
128
|
Rails.logger.warn("[RailsErrorDashboard] Pagination error: #{exception.message}")
|
|
82
129
|
preserved = request.query_parameters.except("page", :page, "per_page", :per_page)
|
|
83
130
|
target = preserved.any? ? "#{request.path}?#{preserved.to_query}" : request.path
|
|
84
|
-
redirect_to target, status: :
|
|
131
|
+
redirect_to target, status: :see_other
|
|
85
132
|
end
|
|
86
133
|
|
|
134
|
+
DEFAULT_PER_PAGE = 25
|
|
135
|
+
MAX_PER_PAGE = 100
|
|
136
|
+
|
|
87
137
|
private
|
|
88
138
|
|
|
89
139
|
# Set the dashboard's locale for this request and restore whatever was
|
|
@@ -176,7 +226,26 @@ module RailsErrorDashboard
|
|
|
176
226
|
@resolved_pagy_locales ||= {}
|
|
177
227
|
end
|
|
178
228
|
|
|
229
|
+
# Page size for every paginated action. Only the upper bound is enforced
|
|
230
|
+
# here: a value that is not a positive integer is passed through unchanged
|
|
231
|
+
# so that Pagy rejects it and the rescue above redirects, as it always has.
|
|
232
|
+
def per_page_param
|
|
233
|
+
raw = params[:per_page]
|
|
234
|
+
return DEFAULT_PER_PAGE if raw.blank?
|
|
235
|
+
|
|
236
|
+
number = Integer(raw, exception: false) if raw.is_a?(String)
|
|
237
|
+
number && number > MAX_PER_PAGE ? MAX_PER_PAGE : raw
|
|
238
|
+
end
|
|
239
|
+
|
|
179
240
|
def render_dashboard_error(icon:, title:, message:, detail: nil, icon_style: nil, status: :internal_server_error)
|
|
241
|
+
# The styled page runs queries and names the host app's applications. A
|
|
242
|
+
# caller who has not authenticated gets the title and nothing else: no
|
|
243
|
+
# layout, no queries, and no exception detail.
|
|
244
|
+
unless @dashboard_authenticated
|
|
245
|
+
render plain: title, status: status
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
180
249
|
set_common_view_variables
|
|
181
250
|
error_html = <<~ERB
|
|
182
251
|
<div class="red-empty-state" style="margin-top: var(--space-6);">
|
|
@@ -208,6 +277,10 @@ module RailsErrorDashboard
|
|
|
208
277
|
else
|
|
209
278
|
authenticate_with_basic_auth
|
|
210
279
|
end
|
|
280
|
+
|
|
281
|
+
# performed? means a 401/403 (or a redirect from the host's lambda) has
|
|
282
|
+
# already been rendered. Read by render_dashboard_error.
|
|
283
|
+
@dashboard_authenticated = !performed?
|
|
211
284
|
end
|
|
212
285
|
|
|
213
286
|
def authenticate_with_lambda(auth_lambda)
|
|
@@ -229,14 +302,22 @@ module RailsErrorDashboard
|
|
|
229
302
|
|
|
230
303
|
def authenticate_with_basic_auth
|
|
231
304
|
authenticate_or_request_with_http_basic do |username, password|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
)
|
|
305
|
+
# A Basic header need not decode to "user:pass": with no colon the
|
|
306
|
+
# password is nil, with nothing decodable both are. secure_compare
|
|
307
|
+
# raises on nil, which used to surface as a 500 rendered by the
|
|
308
|
+
# dashboard's own error page. A malformed header is a failed login.
|
|
309
|
+
next false if username.nil? || password.nil?
|
|
310
|
+
|
|
311
|
+
# A credential configured as nil must deny everyone. Coercing it with
|
|
312
|
+
# to_s would make it equal to an empty login ("Basic Og==" is ":").
|
|
313
|
+
expected_username = RailsErrorDashboard.configuration.dashboard_username
|
|
314
|
+
expected_password = RailsErrorDashboard.configuration.dashboard_password
|
|
315
|
+
next false if expected_username.nil? || expected_password.nil?
|
|
316
|
+
|
|
317
|
+
# Non-short-circuit & on purpose: both comparisons always run, so the
|
|
318
|
+
# response time does not reveal which half was wrong.
|
|
319
|
+
ActiveSupport::SecurityUtils.secure_compare(username.to_s, expected_username.to_s) &
|
|
320
|
+
ActiveSupport::SecurityUtils.secure_compare(password.to_s, expected_password.to_s)
|
|
240
321
|
end
|
|
241
322
|
end
|
|
242
323
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
class ErrorsController < ApplicationController
|
|
5
|
-
|
|
5
|
+
# Authentication is declared once, on ApplicationController, and prepended.
|
|
6
|
+
# Re-declaring it here moved it to the END of the chain, behind the CSRF check.
|
|
6
7
|
before_action :set_application_context
|
|
7
8
|
before_action :check_default_credentials
|
|
8
9
|
before_action :load_storm_banner
|
|
@@ -45,6 +46,10 @@ module RailsErrorDashboard
|
|
|
45
46
|
# 4000, and the copy that drifts is the one the user reads.
|
|
46
47
|
AI_HELP_QUESTION_LIMIT = 4000
|
|
47
48
|
|
|
49
|
+
# Cached in place of nil when the issue tracker has nothing to say, so that
|
|
50
|
+
# "nothing" is remembered for the TTL like any other answer.
|
|
51
|
+
NO_PLATFORM_DATA = "none"
|
|
52
|
+
|
|
48
53
|
# The initializer every "not enabled" notice points at. A path, not prose.
|
|
49
54
|
INITIALIZER_PATH = "config/initializers/rails_error_dashboard.rb"
|
|
50
55
|
|
|
@@ -86,11 +91,18 @@ module RailsErrorDashboard
|
|
|
86
91
|
# on out-of-range pages (e.g. ?page=999999) so application_controller.rb's
|
|
87
92
|
# rescue_from can redirect to a valid page. Without this, pagy 43.x silently
|
|
88
93
|
# returns an empty result and users see "All clear!" instead of their data.
|
|
89
|
-
@pagy, @errors = pagy(:offset, errors_query, limit:
|
|
94
|
+
@pagy, @errors = pagy(:offset, errors_query, limit: per_page_param, raise_range_error: true)
|
|
90
95
|
|
|
91
96
|
# Get dashboard stats using Query (pass application filter)
|
|
92
97
|
@stats = Queries::DashboardStats.call(application_id: @current_application_id)
|
|
93
98
|
|
|
99
|
+
# Which Turbo streams this page listens to: its application's (or the
|
|
100
|
+
# global ones), and new-row prepends only when a new row belongs on top.
|
|
101
|
+
@live_streams = Services::ErrorBroadcaster.streams_for_view(
|
|
102
|
+
application_id: @current_application_id,
|
|
103
|
+
filtered: live_prepend_unsafe?
|
|
104
|
+
)
|
|
105
|
+
|
|
94
106
|
# Get filter options using Query (pass application filter)
|
|
95
107
|
filter_options = Queries::FilterOptions.call(application_id: @current_application_id)
|
|
96
108
|
@error_types = filter_options[:error_types]
|
|
@@ -130,8 +142,9 @@ module RailsErrorDashboard
|
|
|
130
142
|
# Phase 3: Workflow Integration Actions (via Commands)
|
|
131
143
|
|
|
132
144
|
def assign
|
|
133
|
-
|
|
134
|
-
|
|
145
|
+
result = Commands::AssignError.call(params[:id], assigned_to: params[:assigned_to])
|
|
146
|
+
flash[:alert] = red_t("red.flash.assign.blank") unless result[:success]
|
|
147
|
+
redirect_to error_path(result[:error], **app_context_params)
|
|
135
148
|
end
|
|
136
149
|
|
|
137
150
|
def unassign
|
|
@@ -140,13 +153,19 @@ module RailsErrorDashboard
|
|
|
140
153
|
end
|
|
141
154
|
|
|
142
155
|
def update_priority
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
result = Commands::UpdateErrorPriority.call(params[:id], priority_level: params[:priority_level])
|
|
157
|
+
flash[:alert] = red_t("red.flash.priority.invalid") unless result[:success]
|
|
158
|
+
redirect_to error_path(result[:error], **app_context_params)
|
|
145
159
|
end
|
|
146
160
|
|
|
147
161
|
def snooze
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
# The raw value, not to_i: "abc".to_i is 0 and a nested parameter has no
|
|
163
|
+
# to_i at all. The command decides what a number of hours is.
|
|
164
|
+
result = Commands::SnoozeError.call(params[:id], hours: params[:hours], reason: params[:reason])
|
|
165
|
+
unless result[:success]
|
|
166
|
+
flash[:alert] = red_t("red.flash.snooze.invalid_hours", max: Commands::SnoozeError::MAX_SNOOZE_HOURS)
|
|
167
|
+
end
|
|
168
|
+
redirect_to error_path(result[:error], **app_context_params)
|
|
150
169
|
end
|
|
151
170
|
|
|
152
171
|
def unsnooze
|
|
@@ -166,7 +185,19 @@ module RailsErrorDashboard
|
|
|
166
185
|
|
|
167
186
|
def update_status
|
|
168
187
|
result = Commands::UpdateErrorStatus.call(params[:id], status: params[:status], comment: params[:comment])
|
|
169
|
-
|
|
188
|
+
error = result[:error]
|
|
189
|
+
|
|
190
|
+
# The command refuses some transitions; saying nothing made a refused
|
|
191
|
+
# change look like a successful one.
|
|
192
|
+
if result[:success]
|
|
193
|
+
flash[:notice] = red_t("red.flash.status.updated", status: status_flash_label(error.status))
|
|
194
|
+
elsif result[:reason] == :invalid_transition
|
|
195
|
+
flash[:alert] = red_t("red.flash.status.invalid_transition",
|
|
196
|
+
from: status_flash_label(error.status), to: status_flash_label(params[:status]))
|
|
197
|
+
else
|
|
198
|
+
flash[:alert] = red_t("red.flash.status.unknown")
|
|
199
|
+
end
|
|
200
|
+
redirect_to error_path(error, **app_context_params)
|
|
170
201
|
end
|
|
171
202
|
|
|
172
203
|
def create_issue
|
|
@@ -361,8 +392,12 @@ module RailsErrorDashboard
|
|
|
361
392
|
result = Queries::ReleaseTimeline.call(days, application_id: @current_application_id)
|
|
362
393
|
all_releases = result[:releases]
|
|
363
394
|
@summary = result[:summary]
|
|
395
|
+
# Taken from every release, before pagination: the summary cards describe
|
|
396
|
+
# the whole period, not the page in view.
|
|
397
|
+
@current_release = all_releases.find { |r| r[:current] }
|
|
398
|
+
@problematic_release_count = all_releases.count { |r| r[:problematic] }
|
|
364
399
|
|
|
365
|
-
@pagy, @releases = pagy(:offset, all_releases, limit:
|
|
400
|
+
@pagy, @releases = pagy(:offset, all_releases, limit: per_page_param)
|
|
366
401
|
end
|
|
367
402
|
|
|
368
403
|
def storms
|
|
@@ -378,7 +413,7 @@ module RailsErrorDashboard
|
|
|
378
413
|
all_entries = result[:entries]
|
|
379
414
|
@summary = result[:summary]
|
|
380
415
|
|
|
381
|
-
@pagy, @entries = pagy(:offset, all_entries, limit:
|
|
416
|
+
@pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
|
|
382
417
|
end
|
|
383
418
|
|
|
384
419
|
def deprecations
|
|
@@ -398,7 +433,7 @@ module RailsErrorDashboard
|
|
|
398
433
|
@total_count = all_deprecations.sum { |d| d[:count] }
|
|
399
434
|
@affected_count = all_deprecations.flat_map { |d| d[:error_ids] }.uniq.size
|
|
400
435
|
|
|
401
|
-
@pagy, @deprecations = pagy(:offset, all_deprecations, limit:
|
|
436
|
+
@pagy, @deprecations = pagy(:offset, all_deprecations, limit: per_page_param)
|
|
402
437
|
end
|
|
403
438
|
|
|
404
439
|
def n_plus_one_summary
|
|
@@ -418,7 +453,7 @@ module RailsErrorDashboard
|
|
|
418
453
|
@total_count = all_patterns.sum { |p| p[:count] }
|
|
419
454
|
@affected_count = all_patterns.flat_map { |p| p[:error_ids] }.uniq.size
|
|
420
455
|
|
|
421
|
-
@pagy, @patterns = pagy(:offset, all_patterns, limit:
|
|
456
|
+
@pagy, @patterns = pagy(:offset, all_patterns, limit: per_page_param)
|
|
422
457
|
end
|
|
423
458
|
|
|
424
459
|
def cache_health_summary
|
|
@@ -439,7 +474,7 @@ module RailsErrorDashboard
|
|
|
439
474
|
@avg_hit_rate = non_nil_rates.any? ? (non_nil_rates.sum / non_nil_rates.size).round(1) : nil
|
|
440
475
|
@total_cache_ops = all_entries.sum { |e| e[:reads] + e[:writes] }
|
|
441
476
|
|
|
442
|
-
@pagy, @entries = pagy(:offset, all_entries, limit:
|
|
477
|
+
@pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
|
|
443
478
|
end
|
|
444
479
|
|
|
445
480
|
def job_health_summary
|
|
@@ -459,7 +494,7 @@ module RailsErrorDashboard
|
|
|
459
494
|
@total_failed = all_entries.sum { |e| e[:failed] || e[:errored] || 0 }
|
|
460
495
|
@adapters_detected = all_entries.map { |e| e[:adapter] }.uniq
|
|
461
496
|
|
|
462
|
-
@pagy, @entries = pagy(:offset, all_entries, limit:
|
|
497
|
+
@pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
|
|
463
498
|
end
|
|
464
499
|
|
|
465
500
|
def database_health_summary
|
|
@@ -490,7 +525,7 @@ module RailsErrorDashboard
|
|
|
490
525
|
@total_dead = all_entries.sum { |e| e[:dead] }
|
|
491
526
|
@total_waiting = all_entries.sum { |e| e[:waiting] }
|
|
492
527
|
|
|
493
|
-
@pagy, @entries = pagy(:offset, all_entries, limit:
|
|
528
|
+
@pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
|
|
494
529
|
end
|
|
495
530
|
|
|
496
531
|
def swallowed_exceptions
|
|
@@ -515,7 +550,7 @@ module RailsErrorDashboard
|
|
|
515
550
|
@total_rescue_count = all_entries.sum { |e| e[:rescue_count] }
|
|
516
551
|
@total_raise_count = all_entries.sum { |e| e[:raise_count] }
|
|
517
552
|
|
|
518
|
-
@pagy, @entries = pagy(:offset, all_entries, limit:
|
|
553
|
+
@pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
|
|
519
554
|
end
|
|
520
555
|
|
|
521
556
|
def rack_attack_summary
|
|
@@ -545,7 +580,7 @@ module RailsErrorDashboard
|
|
|
545
580
|
# never silently understated.
|
|
546
581
|
@overflow_count = result[:overflow_count].to_i
|
|
547
582
|
|
|
548
|
-
@pagy, @events = pagy(:offset, all_events, limit:
|
|
583
|
+
@pagy, @events = pagy(:offset, all_events, limit: per_page_param)
|
|
549
584
|
end
|
|
550
585
|
|
|
551
586
|
def actioncable_health_summary
|
|
@@ -566,7 +601,7 @@ module RailsErrorDashboard
|
|
|
566
601
|
@total_events = all_channels.sum { |c| c[:total_events] }
|
|
567
602
|
@total_rejections = all_channels.sum { |c| c[:rejection_count] }
|
|
568
603
|
|
|
569
|
-
@pagy, @channels = pagy(:offset, all_channels, limit:
|
|
604
|
+
@pagy, @channels = pagy(:offset, all_channels, limit: per_page_param)
|
|
570
605
|
end
|
|
571
606
|
|
|
572
607
|
def llm_health_summary
|
|
@@ -586,7 +621,7 @@ module RailsErrorDashboard
|
|
|
586
621
|
@totals = result[:totals]
|
|
587
622
|
all_models = result[:models]
|
|
588
623
|
|
|
589
|
-
@pagy, @models = pagy(:offset, all_models, limit:
|
|
624
|
+
@pagy, @models = pagy(:offset, all_models, limit: per_page_param)
|
|
590
625
|
end
|
|
591
626
|
|
|
592
627
|
def activestorage_health_summary
|
|
@@ -607,7 +642,7 @@ module RailsErrorDashboard
|
|
|
607
642
|
@total_operations = all_services.sum { |s| s[:total_operations] }
|
|
608
643
|
@errors_with_storage = all_services.sum { |s| s[:error_count] }
|
|
609
644
|
|
|
610
|
-
@pagy, @services = pagy(:offset, all_services, limit:
|
|
645
|
+
@pagy, @services = pagy(:offset, all_services, limit: per_page_param)
|
|
611
646
|
end
|
|
612
647
|
|
|
613
648
|
def diagnostic_dumps
|
|
@@ -620,8 +655,10 @@ module RailsErrorDashboard
|
|
|
620
655
|
scope = DiagnosticDump.recent
|
|
621
656
|
scope = scope.where(application_id: @current_application_id) if @current_application_id.present?
|
|
622
657
|
@total_dumps = scope.count
|
|
658
|
+
# The newest dump overall; the "latest" cards must not change with the page.
|
|
659
|
+
@latest_dump = scope.first
|
|
623
660
|
|
|
624
|
-
@pagy, @dumps = pagy(:offset, scope, limit:
|
|
661
|
+
@pagy, @dumps = pagy(:offset, scope, limit: per_page_param)
|
|
625
662
|
end
|
|
626
663
|
|
|
627
664
|
def create_diagnostic_dump
|
|
@@ -755,15 +792,30 @@ module RailsErrorDashboard
|
|
|
755
792
|
# a request like ?days=99999999 would scan the full table on every health
|
|
756
793
|
# query, defeating index pruning and burning CPU.
|
|
757
794
|
def days_param(default:)
|
|
758
|
-
|
|
795
|
+
# days[x]=1 and days[]=7 are not numbers; to_i on them raised.
|
|
796
|
+
raw = params[:days]
|
|
797
|
+
raw = default unless raw.is_a?(String) && raw.present?
|
|
759
798
|
raw.to_i.clamp(1, 365)
|
|
760
799
|
end
|
|
761
800
|
|
|
762
801
|
def set_application_context
|
|
802
|
+
# application_id[x]=1 is not an id. Left in params, the nested value reached
|
|
803
|
+
# url_for in the layout ("unable to convert unpermitted parameters to hash")
|
|
804
|
+
# and every page answered 500. An array of ids is still allowed.
|
|
805
|
+
params.delete(:application_id) if params[:application_id].is_a?(ActionController::Parameters)
|
|
806
|
+
|
|
763
807
|
@current_application_id = params[:application_id].presence
|
|
764
808
|
@applications = Application.ordered_by_name.pluck(:name, :id)
|
|
765
809
|
end
|
|
766
810
|
|
|
811
|
+
# True when a brand-new error cannot simply be put on top of this list: any
|
|
812
|
+
# filter other than the application is active (the row may not match it), a
|
|
813
|
+
# sort is chosen, or this is not the first page.
|
|
814
|
+
def live_prepend_unsafe?
|
|
815
|
+
other_filters = FILTERABLE_PARAMS - [ :application_id ]
|
|
816
|
+
other_filters.any? { |key| params[key].present? } || params[:page].to_s.to_i > 1
|
|
817
|
+
end
|
|
818
|
+
|
|
767
819
|
# Preserves the application_id param across redirects
|
|
768
820
|
def app_context_params
|
|
769
821
|
@current_application_id.present? ? { application_id: @current_application_id } : {}
|
|
@@ -777,6 +829,12 @@ module RailsErrorDashboard
|
|
|
777
829
|
[ "red", kind, error.external_issue_provider, repo.presence || "-", error.external_issue_number ].join("/")
|
|
778
830
|
end
|
|
779
831
|
|
|
832
|
+
# The status labels the index and show pages already use; an unlabelled
|
|
833
|
+
# value ("new" has no key) falls back to the raw text.
|
|
834
|
+
def status_flash_label(status)
|
|
835
|
+
red_t("red.errors.row.status.#{status}", default: status.to_s.tr("_", " "))
|
|
836
|
+
end
|
|
837
|
+
|
|
780
838
|
def fetch_platform_issue(error)
|
|
781
839
|
return nil unless error.external_issue_url.present? && error.external_issue_number.present?
|
|
782
840
|
return nil unless RailsErrorDashboard.configuration.enable_issue_tracking
|
|
@@ -784,13 +842,25 @@ module RailsErrorDashboard
|
|
|
784
842
|
# The repository belongs in the key: two repositories sharing an issue
|
|
785
843
|
# number would otherwise read each other's cached state.
|
|
786
844
|
cache_key = issue_cache_key("issue_state", error)
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
845
|
+
#
|
|
846
|
+
# The negative result is cached too, as NO_PLATFORM_DATA rather than nil.
|
|
847
|
+
# `return nil unless client` used to leave the METHOD, so the block never
|
|
848
|
+
# finished and nothing was written; a raising client skipped the write the
|
|
849
|
+
# same way. Either meant one API attempt (and its timeout) per page view.
|
|
850
|
+
cached = Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
|
|
851
|
+
fetch_issue_state(error) || NO_PLATFORM_DATA
|
|
793
852
|
end
|
|
853
|
+
cached == NO_PLATFORM_DATA ? nil : cached
|
|
854
|
+
rescue => e
|
|
855
|
+
nil
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def fetch_issue_state(error)
|
|
859
|
+
client = Services::IssueTrackerClient.for_error(error)
|
|
860
|
+
return nil unless client
|
|
861
|
+
|
|
862
|
+
result = client.fetch_issue(number: error.external_issue_number)
|
|
863
|
+
result[:success] ? result : nil
|
|
794
864
|
rescue => e
|
|
795
865
|
nil
|
|
796
866
|
end
|
|
@@ -801,13 +871,20 @@ module RailsErrorDashboard
|
|
|
801
871
|
|
|
802
872
|
# Cache for 60 seconds to avoid API hammering on page refreshes
|
|
803
873
|
cache_key = issue_cache_key("issue_comments", error)
|
|
874
|
+
# (an empty list is cached like any other answer -- see fetch_platform_issue)
|
|
804
875
|
Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
|
|
805
|
-
|
|
806
|
-
|
|
876
|
+
fetch_issue_comments(error)
|
|
877
|
+
end || []
|
|
878
|
+
rescue => e
|
|
879
|
+
[]
|
|
880
|
+
end
|
|
807
881
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
882
|
+
def fetch_issue_comments(error)
|
|
883
|
+
client = Services::IssueTrackerClient.for_error(error)
|
|
884
|
+
return [] unless client
|
|
885
|
+
|
|
886
|
+
result = client.fetch_comments(number: error.external_issue_number, per_page: 20)
|
|
887
|
+
result[:success] ? Array(result[:comments]) : []
|
|
811
888
|
rescue => e
|
|
812
889
|
[]
|
|
813
890
|
end
|
|
@@ -295,6 +295,9 @@ module RailsErrorDashboard
|
|
|
295
295
|
status: "new",
|
|
296
296
|
reopened_at: Time.current
|
|
297
297
|
)
|
|
298
|
+
# Someone reopened the issue on the forge: a user action, so the cached
|
|
299
|
+
# stat cards are invalidated now rather than at their TTL.
|
|
300
|
+
Services::AnalyticsCacheManager.clear
|
|
298
301
|
end
|
|
299
302
|
end
|
|
300
303
|
end
|
|
@@ -37,7 +37,39 @@ module RailsErrorDashboard
|
|
|
37
37
|
value.to_json.gsub("</", '<\/').html_safe
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# Returns
|
|
40
|
+
# Returns the value only when it is an absolute http(s) URL, otherwise nil.
|
|
41
|
+
# Every URL that comes from a stored column or a third-party API goes
|
|
42
|
+
# through this before it becomes an href, an img src or a window.open
|
|
43
|
+
# target. ERB escaping and link_to stop attribute breakout; neither rejects
|
|
44
|
+
# a scheme such as "javascript:". Same rule as the write path
|
|
45
|
+
# (Commands::LinkExistingIssue), so a row stored before that check existed
|
|
46
|
+
# is still inert when rendered.
|
|
47
|
+
def safe_external_url(value)
|
|
48
|
+
value if Services::UrlSafety.http_url?(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
DEFAULT_LABEL_COLOR = "#6c757d"
|
|
52
|
+
|
|
53
|
+
# A forge label colour, safe to put in a style attribute. Anything that is
|
|
54
|
+
# not 3 or 6 hex digits becomes neutral grey: ERB escaping keeps a value
|
|
55
|
+
# inside the attribute but does not stop it being CSS ("fff;position:fixed").
|
|
56
|
+
def safe_label_color(raw)
|
|
57
|
+
return DEFAULT_LABEL_COLOR unless raw.is_a?(String)
|
|
58
|
+
|
|
59
|
+
hex = raw.delete_prefix("#")
|
|
60
|
+
hex.match?(/\A(?:\h{6}|\h{3})\z/) ? "##{hex}" : DEFAULT_LABEL_COLOR
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Black or white, whichever reads on the given background. Takes the output
|
|
64
|
+
# of safe_label_color, never the raw label value.
|
|
65
|
+
def label_text_color(background)
|
|
66
|
+
hex = background.to_s.delete_prefix("#")
|
|
67
|
+
return "#fff" unless hex.match?(/\A(?:\h{6}|\h{3})\z/)
|
|
68
|
+
|
|
69
|
+
hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
|
|
70
|
+
hex.scan(/../).sum { |pair| pair.to_i(16) } > 382 ? "#000" : "#fff"
|
|
71
|
+
end
|
|
72
|
+
|
|
41
73
|
# Uses Catppuccin Mocha colors in dark theme via CSS variables
|
|
42
74
|
# @param severity [Symbol] The severity level (:critical, :high, :medium, :low, :info)
|
|
43
75
|
# @return [String] Bootstrap color class (danger, warning, info, secondary)
|
|
@@ -226,6 +258,19 @@ module RailsErrorDashboard
|
|
|
226
258
|
nil
|
|
227
259
|
end
|
|
228
260
|
|
|
261
|
+
# Parses a timestamp that came from somewhere RED does not control (an issue
|
|
262
|
+
# tracker's API). nil for anything that is not a date: a blank, free text, a
|
|
263
|
+
# number, a nested value. Never raises -- one odd comment must not take the
|
|
264
|
+
# error page down with it.
|
|
265
|
+
def safe_parse_time(value)
|
|
266
|
+
return value if value.is_a?(Time) || value.is_a?(DateTime)
|
|
267
|
+
return nil unless value.is_a?(String) && value.present?
|
|
268
|
+
|
|
269
|
+
(Time.zone || Time).parse(value)
|
|
270
|
+
rescue ArgumentError, TypeError, RangeError
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
229
274
|
# Renders a relative time ("3 hours ago") that updates automatically
|
|
230
275
|
# @param time [Time, DateTime, nil] The timestamp to display
|
|
231
276
|
# @param fallback [String] Text to show if time is nil
|
|
@@ -175,6 +175,11 @@ module RailsErrorDashboard
|
|
|
175
175
|
}
|
|
176
176
|
end
|
|
177
177
|
end
|
|
178
|
+
rescue => e
|
|
179
|
+
# Source context is a nicety. A cache store that is down must not take
|
|
180
|
+
# the error page with it -- the same rule read_coverage_for_file follows.
|
|
181
|
+
Rails.logger.error("[RailsErrorDashboard] read_source_code failed: #{e.class}: #{e.message}")
|
|
182
|
+
nil
|
|
178
183
|
end
|
|
179
184
|
|
|
180
185
|
# Read coverage data for a file when coverage tracking is active
|
|
@@ -204,6 +209,9 @@ module RailsErrorDashboard
|
|
|
204
209
|
reader = Services::GitBlameReader.new(frame[:file_path], frame[:line_number])
|
|
205
210
|
reader.read_blame
|
|
206
211
|
end
|
|
212
|
+
rescue => e
|
|
213
|
+
Rails.logger.error("[RailsErrorDashboard] read_git_blame failed: #{e.class}: #{e.message}")
|
|
214
|
+
nil
|
|
207
215
|
end
|
|
208
216
|
|
|
209
217
|
# Generate GitHub/GitLab/Bitbucket link for a frame
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Concerns
|
|
5
|
+
# Delivery of a plain, one-sentence message that is ABOUT the dashboard's
|
|
6
|
+
# own behaviour rather than about one error: "a storm is in progress",
|
|
7
|
+
# "further new-error notifications are suppressed". The per-error jobs
|
|
8
|
+
# build rich payloads from an ErrorLog; these messages have no row.
|
|
9
|
+
#
|
|
10
|
+
# Shared by StormNotificationJob and NotificationBurstSummaryJob.
|
|
11
|
+
module PlainChannelMessage
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# Slack takes {text:}, Discord {content:}, custom webhooks a
|
|
15
|
+
# machine-readable event. Email and PagerDuty are not used: neither has
|
|
16
|
+
# a sensible shape for a message that is not an incident.
|
|
17
|
+
def deliver_plain_message(message, webhook_payload)
|
|
18
|
+
config = RailsErrorDashboard.configuration
|
|
19
|
+
delivered = false
|
|
20
|
+
|
|
21
|
+
if config.enable_slack_notifications && config.slack_webhook_url.present?
|
|
22
|
+
post_json(config.slack_webhook_url, { text: message })
|
|
23
|
+
delivered = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if config.enable_discord_notifications && config.discord_webhook_url.present?
|
|
27
|
+
post_json(config.discord_webhook_url, { content: message })
|
|
28
|
+
delivered = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if config.enable_webhook_notifications && config.webhook_urls.present?
|
|
32
|
+
config.webhook_urls.each { |url| post_json(url, webhook_payload) }
|
|
33
|
+
delivered = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# An email-only or PagerDuty-only deployment has no channel for this.
|
|
37
|
+
# Say so where an operator can find it rather than dropping it silently.
|
|
38
|
+
unless delivered
|
|
39
|
+
Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord or webhook channel is enabled; message not sent: #{message}")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def t(key, locale, **options)
|
|
44
|
+
RailsErrorDashboard::I18nStore.translate(key, locale: locale, **options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def app_name(config)
|
|
48
|
+
config.application_name || ENV["APPLICATION_NAME"] ||
|
|
49
|
+
(defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def post_json(url, payload)
|
|
53
|
+
if defined?(HTTParty)
|
|
54
|
+
HTTParty.post(url, body: payload.to_json,
|
|
55
|
+
headers: { "Content-Type" => "application/json" }, timeout: 10)
|
|
56
|
+
else
|
|
57
|
+
uri = URI(url)
|
|
58
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
59
|
+
http.use_ssl = uri.scheme == "https"
|
|
60
|
+
http.open_timeout = 5
|
|
61
|
+
http.read_timeout = 10
|
|
62
|
+
request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
|
|
63
|
+
request.body = payload.to_json
|
|
64
|
+
http.request(request)
|
|
65
|
+
end
|
|
66
|
+
rescue => e
|
|
67
|
+
Rails.logger.error("[RailsErrorDashboard] #{self.class.name.demodulize} post failed: #{e.message}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|