rails_error_dashboard 0.11.9 → 0.12.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 +4 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -4
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +79 -6
- data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/application_job.rb +56 -14
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +22 -4
- data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/create_issue_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +22 -4
- data/app/models/rails_error_dashboard/error_log.rb +47 -0
- data/app/models/rails_error_dashboard/storm_flush_batch.rb +50 -0
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +27 -1
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +6 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +13 -1
- data/config/locales/de.yml +9 -0
- data/config/locales/en.yml +35 -0
- data/config/locales/es.yml +9 -0
- data/config/locales/fr.yml +10 -1
- data/config/locales/it.yml +9 -0
- data/config/locales/ja.yml +9 -0
- data/config/locales/pl.yml +9 -0
- data/config/locales/pt-BR.yml +9 -0
- data/config/locales/ru.yml +9 -0
- data/config/locales/uk.yml +9 -0
- data/config/locales/zh-CN.yml +9 -0
- data/db/migrate/20260915000001_add_group_identity_unique_index_to_error_logs.rb +207 -0
- data/db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb +89 -0
- data/db/migrate/20260915000003_add_context_provenance_to_error_logs.rb +42 -0
- data/db/migrate/20260915000004_create_storm_flush_batches.rb +50 -0
- data/lib/rails_error_dashboard/commands/create_issue.rb +10 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +135 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +139 -34
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +20 -2
- data/lib/rails_error_dashboard/commands/log_error.rb +206 -19
- data/lib/rails_error_dashboard/configuration.rb +4 -3
- data/lib/rails_error_dashboard/engine.rb +28 -0
- data/lib/rails_error_dashboard/integrations/tracer.rb +26 -7
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +147 -61
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +57 -7
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +61 -19
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +38 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +22 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +101 -14
- data/lib/rails_error_dashboard/version.rb +1 -1
- metadata +9 -3
|
@@ -5,6 +5,20 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Command: Log an error to the database
|
|
6
6
|
# This is a write operation that creates an ErrorLog record
|
|
7
7
|
class LogError
|
|
8
|
+
# Raised internally when perform_later did not reach the queue, so the
|
|
9
|
+
# one rescue below covers both failure shapes. Never escapes this class.
|
|
10
|
+
class EnqueueFailed < StandardError; end
|
|
11
|
+
|
|
12
|
+
# The error store is down right now and may be up in a moment. Worth
|
|
13
|
+
# another attempt from a background worker; indistinguishable from any
|
|
14
|
+
# other failure on a user's request, where nothing is ever re-raised.
|
|
15
|
+
RETRYABLE_STORE_ERRORS = [
|
|
16
|
+
ActiveRecord::ConnectionNotEstablished,
|
|
17
|
+
ActiveRecord::StatementInvalid,
|
|
18
|
+
ActiveRecord::LockWaitTimeout,
|
|
19
|
+
ActiveRecord::Deadlocked
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
8
22
|
def self.call(exception, context = {})
|
|
9
23
|
# Filter FIRST (ignore list + static sampling) so ignored exceptions
|
|
10
24
|
# never count toward storm state. _pre_filtered prevents the sync path
|
|
@@ -79,6 +93,19 @@ module RailsErrorDashboard
|
|
|
79
93
|
# Queue error logging as a background job
|
|
80
94
|
def self.call_async(exception, context = {})
|
|
81
95
|
# Serialize exception data for the job
|
|
96
|
+
# Grouping identity is computed from the RAW message, BEFORE redaction
|
|
97
|
+
# below. ErrorHashGenerator hashes a 500-char prefix of the unredacted
|
|
98
|
+
# message, so redacting first would silently re-group every error whose
|
|
99
|
+
# message contains a filtered key: "password=hunter2" and
|
|
100
|
+
# "password=[FILTERED]" are different fingerprints.
|
|
101
|
+
#
|
|
102
|
+
# application_id is deliberately absent. Resolving it means
|
|
103
|
+
# Application.find_or_create_by_name -- a DB write -- and this runs on
|
|
104
|
+
# the request thread, where the gem promises no I/O. The worker
|
|
105
|
+
# completes the hash with the application resolved, exactly as
|
|
106
|
+
# FlushStormCounts#canonical_hash does for storm counts.
|
|
107
|
+
identity_parts = capture_identity_parts(exception, context)
|
|
108
|
+
|
|
82
109
|
exception_data = {
|
|
83
110
|
class_name: exception.class.name,
|
|
84
111
|
message: exception.message,
|
|
@@ -86,6 +113,20 @@ module RailsErrorDashboard
|
|
|
86
113
|
cause_chain: serialize_cause_chain(exception)
|
|
87
114
|
}
|
|
88
115
|
|
|
116
|
+
# Redact BEFORE the payload crosses the queue boundary. Until now the
|
|
117
|
+
# filter ran only just before the INSERT, so a durable adapter
|
|
118
|
+
# (Sidekiq/Redis, Solid Queue) persisted the raw secret in its own
|
|
119
|
+
# store, its backups, and any job-argument logging -- even though the
|
|
120
|
+
# error row itself was correctly redacted.
|
|
121
|
+
#
|
|
122
|
+
# Breadcrumbs, locals and instance variables are already filtered by
|
|
123
|
+
# their own collectors (BreadcrumbCollector.filter_sensitive,
|
|
124
|
+
# VariableSerializer.filter_serialized) before they are put in the
|
|
125
|
+
# context above, so this covers the rest: message, cause chain, request
|
|
126
|
+
# params and request URL -- the four keys filter_attributes touches.
|
|
127
|
+
exception_data, context = redact_async_payload(exception_data, context)
|
|
128
|
+
context = context.merge(_identity: identity_parts) if identity_parts
|
|
129
|
+
|
|
89
130
|
# Storm shedding: :lite captures skip ALL pre-enqueue context harvest —
|
|
90
131
|
# this is request-thread CPU, the most valuable thing to shed.
|
|
91
132
|
lite = storm_lite?(context)
|
|
@@ -140,13 +181,23 @@ module RailsErrorDashboard
|
|
|
140
181
|
kind: :capture,
|
|
141
182
|
attributes: build_capture_span_attributes(exception, was_async: true)
|
|
142
183
|
) do |_span|
|
|
143
|
-
AsyncErrorLoggingJob.perform_later(exception_data, context)
|
|
184
|
+
job = AsyncErrorLoggingJob.perform_later(exception_data, context)
|
|
185
|
+
|
|
186
|
+
# A raise is not the only way a handoff fails. From Rails 7.2
|
|
187
|
+
# perform_later swallows ActiveJob::EnqueueError and returns false
|
|
188
|
+
# (an aborting enqueue callback does the same), so without this
|
|
189
|
+
# check the capture is dropped silently: no queued job, no row.
|
|
190
|
+
# The storm gate has always checked this; ordinary capture did not.
|
|
191
|
+
unless ApplicationJob.enqueued?(job)
|
|
192
|
+
raise EnqueueFailed, ApplicationJob.enqueue_failure_reason(job)
|
|
193
|
+
end
|
|
144
194
|
end
|
|
145
195
|
rescue => e
|
|
146
|
-
# Queue adapter failed (e.g., Redis down for Sidekiq)
|
|
147
|
-
# sync logging so the error is
|
|
148
|
-
#
|
|
149
|
-
# via Rails.error.report →
|
|
196
|
+
# Queue adapter failed (e.g., Redis down for Sidekiq), or the job
|
|
197
|
+
# never reached the queue. Fall back to sync logging so the error is
|
|
198
|
+
# still captured. Without this rescue, the exception propagates back
|
|
199
|
+
# to ErrorReporter, which re-reports it via Rails.error.report →
|
|
200
|
+
# infinite recursion (issue #114).
|
|
150
201
|
RailsErrorDashboard::Logger.error(
|
|
151
202
|
"[RailsErrorDashboard] Async enqueue failed (#{e.class}: #{e.message}), falling back to sync logging"
|
|
152
203
|
)
|
|
@@ -154,6 +205,76 @@ module RailsErrorDashboard
|
|
|
154
205
|
end
|
|
155
206
|
end
|
|
156
207
|
|
|
208
|
+
# The opaque half of the canonical fingerprint, computed from the RAW
|
|
209
|
+
# exception before the payload is redacted for the queue.
|
|
210
|
+
#
|
|
211
|
+
# This is a digest, not the identity parts themselves: an earlier version
|
|
212
|
+
# shipped `normalized_message` and put the very secret the redaction had
|
|
213
|
+
# just removed straight back on the queue. normalize_message replaces
|
|
214
|
+
# hex, digits and quoted strings -- it has no notion of secrets.
|
|
215
|
+
#
|
|
216
|
+
# A custom fingerprint lambda already yields a complete, message-free
|
|
217
|
+
# value, so it is passed through unchanged.
|
|
218
|
+
def self.capture_identity_parts(exception, context)
|
|
219
|
+
custom = Services::ErrorHashGenerator.send(:try_custom_fingerprint, exception, context)
|
|
220
|
+
return custom if custom
|
|
221
|
+
|
|
222
|
+
Services::ErrorHashGenerator.opaque_identity(
|
|
223
|
+
error_class: exception.class.name,
|
|
224
|
+
normalized_message: Services::ErrorHashGenerator.normalize_message(exception.message),
|
|
225
|
+
frames: Services::ErrorHashGenerator.extract_app_frame_from_locations(exception) ||
|
|
226
|
+
Services::ErrorHashGenerator.extract_app_frame(exception.backtrace),
|
|
227
|
+
controller_name: context[:controller_name]&.to_s,
|
|
228
|
+
action_name: context[:action_name]&.to_s
|
|
229
|
+
)
|
|
230
|
+
rescue => e
|
|
231
|
+
# No identity parts simply means the worker recomputes the hash from
|
|
232
|
+
# the (redacted) payload, which is the pre-existing behaviour.
|
|
233
|
+
RailsErrorDashboard::Logger.debug(
|
|
234
|
+
"[RailsErrorDashboard] capture_identity_parts failed: #{e.class} - #{e.message}"
|
|
235
|
+
)
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Apply the storage filter to everything secret-bearing that crosses the
|
|
240
|
+
# queue, reusing SensitiveDataFilter so the queue and the database are
|
|
241
|
+
# redacted by ONE policy rather than two that can drift.
|
|
242
|
+
def self.redact_async_payload(exception_data, context)
|
|
243
|
+
return [ exception_data, context ] unless RailsErrorDashboard.configuration.filter_sensitive_data
|
|
244
|
+
|
|
245
|
+
filtered = Services::SensitiveDataFilter.filter_attributes(
|
|
246
|
+
message: exception_data[:message],
|
|
247
|
+
request_params: context[:request_params],
|
|
248
|
+
request_url: context[:request_url],
|
|
249
|
+
exception_cause: exception_data[:cause_chain]&.to_json
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
exception_data = exception_data.merge(message: filtered[:message])
|
|
253
|
+
if exception_data[:cause_chain] && filtered[:exception_cause]
|
|
254
|
+
begin
|
|
255
|
+
exception_data = exception_data.merge(
|
|
256
|
+
cause_chain: JSON.parse(filtered[:exception_cause], symbolize_names: true)
|
|
257
|
+
)
|
|
258
|
+
rescue JSON::ParserError
|
|
259
|
+
# Keep the filtered-but-unparsed chain out of the payload entirely
|
|
260
|
+
# rather than shipping the raw one.
|
|
261
|
+
exception_data = exception_data.merge(cause_chain: nil)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
context = context.merge(request_params: filtered[:request_params]) if context.key?(:request_params)
|
|
266
|
+
context = context.merge(request_url: filtered[:request_url]) if context.key?(:request_url)
|
|
267
|
+
|
|
268
|
+
[ exception_data, context ]
|
|
269
|
+
rescue => e
|
|
270
|
+
# Never fail a capture over redaction. Fall back to the previous
|
|
271
|
+
# behaviour: the row itself is still filtered before the INSERT.
|
|
272
|
+
RailsErrorDashboard::Logger.error(
|
|
273
|
+
"[RailsErrorDashboard] Async payload redaction failed: #{e.class} - #{e.message}"
|
|
274
|
+
)
|
|
275
|
+
[ exception_data, context ]
|
|
276
|
+
end
|
|
277
|
+
|
|
157
278
|
# Serialize cause chain for async job serialization
|
|
158
279
|
# Returns an array of hashes (not JSON string) for ActiveJob compatibility
|
|
159
280
|
def self.serialize_cause_chain(exception)
|
|
@@ -185,9 +306,19 @@ module RailsErrorDashboard
|
|
|
185
306
|
end
|
|
186
307
|
private_class_method :serialize_cause_chain
|
|
187
308
|
|
|
188
|
-
|
|
309
|
+
# @param exception [Exception] the exception to capture
|
|
310
|
+
# @param context [Hash] request/job context
|
|
311
|
+
# @param worker [Boolean] true when a background job is the caller.
|
|
312
|
+
# The capture path's blanket rescue exists so a failing capture can
|
|
313
|
+
# never break a user's request (safety rule 1). A worker has the
|
|
314
|
+
# opposite obligation: if the error store was unreachable, the job did
|
|
315
|
+
# NOT deliver the capture, and saying otherwise discards the payload.
|
|
316
|
+
# In worker mode an unreachable-store failure is re-raised so Active
|
|
317
|
+
# Job can retry it; every other failure is still swallowed.
|
|
318
|
+
def initialize(exception, context = {}, worker: false)
|
|
189
319
|
@exception = exception
|
|
190
320
|
@context = context
|
|
321
|
+
@worker = worker
|
|
191
322
|
end
|
|
192
323
|
|
|
193
324
|
def call
|
|
@@ -196,10 +327,11 @@ module RailsErrorDashboard
|
|
|
196
327
|
# tracing pipeline. Child spans (breadcrumbs, health, notifications)
|
|
197
328
|
# nest under this one automatically via OTel context propagation.
|
|
198
329
|
#
|
|
199
|
-
# The span lives INSIDE the rescue clause — if
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
330
|
+
# The span lives INSIDE the rescue clause — if span setup itself fails,
|
|
331
|
+
# the Tracer runs this block once with a no-op span, and anything that
|
|
332
|
+
# still escapes is caught by the outer rescue below. Defense in depth.
|
|
333
|
+
# When this block raises, the façade records the exception on the span
|
|
334
|
+
# and re-raises it exactly once; it never re-runs the block.
|
|
203
335
|
Integrations::Tracer.in_span(
|
|
204
336
|
"capture_error",
|
|
205
337
|
kind: :capture,
|
|
@@ -254,13 +386,19 @@ module RailsErrorDashboard
|
|
|
254
386
|
end
|
|
255
387
|
|
|
256
388
|
# Generate error hash for deduplication (including controller/action context and application)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
389
|
+
#
|
|
390
|
+
# On the async path the identity was captured from the RAW exception
|
|
391
|
+
# before the payload was redacted for the queue; completing it here
|
|
392
|
+
# with application_id keeps grouping identical to the sync path, where
|
|
393
|
+
# the hash is taken before filter_attributes runs.
|
|
394
|
+
error_hash = canonical_hash_from_identity(application) ||
|
|
395
|
+
Services::ErrorHashGenerator.call(
|
|
396
|
+
@exception,
|
|
397
|
+
controller_name: error_context.controller_name,
|
|
398
|
+
action_name: error_context.action_name,
|
|
399
|
+
application_id: application.id,
|
|
400
|
+
context: @context
|
|
401
|
+
)
|
|
264
402
|
|
|
265
403
|
# Calculate backtrace signature for fuzzy matching (if column exists)
|
|
266
404
|
if ErrorLog.column_names.include?("backtrace_signature")
|
|
@@ -366,8 +504,17 @@ module RailsErrorDashboard
|
|
|
366
504
|
end
|
|
367
505
|
|
|
368
506
|
# Find existing error or create new one
|
|
369
|
-
# This ensures accurate occurrence tracking
|
|
370
|
-
|
|
507
|
+
# This ensures accurate occurrence tracking.
|
|
508
|
+
#
|
|
509
|
+
# _context_fidelity travels with the attributes so the grouping command
|
|
510
|
+
# can tell a full capture from a shed one. A :lite capture carries no
|
|
511
|
+
# context payloads by design, and must not be recorded as though it
|
|
512
|
+
# refreshed the snapshot -- nor allowed to overwrite a good backtrace.
|
|
513
|
+
# It is stripped before the INSERT (it is a signal, not a column).
|
|
514
|
+
error_log = ErrorLog.find_or_increment_by_hash(
|
|
515
|
+
error_hash,
|
|
516
|
+
attributes.merge(error_hash: error_hash, _context_fidelity: storm_lite ? "lite" : "full")
|
|
517
|
+
)
|
|
371
518
|
|
|
372
519
|
# OTel: now that the error_log exists, attach its id + dedup flag + severity
|
|
373
520
|
# to the parent capture span so operators can correlate to dashboard URLs.
|
|
@@ -427,6 +574,13 @@ module RailsErrorDashboard
|
|
|
427
574
|
RailsErrorDashboard::Logger.error("Original exception: #{@exception.class} - #{@exception.message}") if @exception
|
|
428
575
|
RailsErrorDashboard::Logger.error("Context: #{@context.inspect.truncate(500)}") if @context
|
|
429
576
|
RailsErrorDashboard::Logger.error(e.backtrace&.first(5)&.join("\n")) if e.backtrace
|
|
577
|
+
|
|
578
|
+
# A worker must not report a delivery it did not make. Only the
|
|
579
|
+
# store-unavailable failures are re-raised (they are worth another
|
|
580
|
+
# attempt); a payload problem would fail identically on every retry,
|
|
581
|
+
# so it stays swallowed here as it always has.
|
|
582
|
+
raise if @worker && RETRYABLE_STORE_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
583
|
+
|
|
430
584
|
nil # Explicitly return nil, never raise
|
|
431
585
|
end
|
|
432
586
|
|
|
@@ -444,6 +598,14 @@ module RailsErrorDashboard
|
|
|
444
598
|
|
|
445
599
|
Services::ErrorNotificationDispatcher.call(error_log)
|
|
446
600
|
Services::NotificationThrottler.record_notification(error_log)
|
|
601
|
+
rescue => e
|
|
602
|
+
# The error row is already written by the time we get here. A channel
|
|
603
|
+
# that cannot be reached (Redis down for the Slack job's enqueue, a
|
|
604
|
+
# broken webhook config) must not take the capture down with it: the
|
|
605
|
+
# caller asked us to record an error, and we did. Log, don't raise.
|
|
606
|
+
RailsErrorDashboard::Logger.error(
|
|
607
|
+
"[RailsErrorDashboard] Failed to dispatch notification for error #{error_log&.id}: #{e.class} - #{e.message}"
|
|
608
|
+
)
|
|
447
609
|
end
|
|
448
610
|
|
|
449
611
|
# The environment this error is attributed to: an explicit context value
|
|
@@ -456,6 +618,23 @@ module RailsErrorDashboard
|
|
|
456
618
|
end
|
|
457
619
|
|
|
458
620
|
# Find or create application for multi-app support
|
|
621
|
+
# Complete the fingerprint the request thread started, if it sent one.
|
|
622
|
+
# The request thread hashed the identity parts into an opaque value (no
|
|
623
|
+
# message text crosses the queue); this adds the application, which only
|
|
624
|
+
# a worker can resolve. Same two stages as the sync path, so a capture
|
|
625
|
+
# groups onto the same row whether it travelled through the queue or not.
|
|
626
|
+
def canonical_hash_from_identity(application)
|
|
627
|
+
opaque = @context[:_identity]
|
|
628
|
+
return nil unless opaque.is_a?(String) && opaque.present?
|
|
629
|
+
|
|
630
|
+
Services::ErrorHashGenerator.complete(opaque, application.id)
|
|
631
|
+
rescue => e
|
|
632
|
+
RailsErrorDashboard::Logger.debug(
|
|
633
|
+
"[RailsErrorDashboard] canonical_hash_from_identity failed: #{e.class} - #{e.message}"
|
|
634
|
+
)
|
|
635
|
+
nil
|
|
636
|
+
end
|
|
637
|
+
|
|
459
638
|
def find_or_create_application
|
|
460
639
|
app_name = RailsErrorDashboard.configuration.application_name ||
|
|
461
640
|
ENV["APPLICATION_NAME"] ||
|
|
@@ -508,6 +687,14 @@ module RailsErrorDashboard
|
|
|
508
687
|
if error_log.critical?
|
|
509
688
|
ActiveSupport::Notifications.instrument("critical_error.rails_error_dashboard", payload)
|
|
510
689
|
end
|
|
690
|
+
rescue => e
|
|
691
|
+
# AS::Notifications re-raises subscriber exceptions to the instrumenting
|
|
692
|
+
# caller (fanout.rb#iterate_guarding_exceptions), so a host subscriber
|
|
693
|
+
# on error_logged.rails_error_dashboard would otherwise abort a capture
|
|
694
|
+
# whose row is already persisted.
|
|
695
|
+
RailsErrorDashboard::Logger.error(
|
|
696
|
+
"[RailsErrorDashboard] Failed to emit instrumentation events for error #{error_log&.id}: #{e.class} - #{e.message}"
|
|
697
|
+
)
|
|
511
698
|
end
|
|
512
699
|
|
|
513
700
|
# Check if error exceeds baseline and send alert if needed
|
|
@@ -233,8 +233,9 @@ module RailsErrorDashboard
|
|
|
233
233
|
# Locale the dashboard renders in, independent of the host app's locale.
|
|
234
234
|
#
|
|
235
235
|
# Drives both Pagy's pagination labels and RED's own translation lookups.
|
|
236
|
-
# Ships "en", "de", "
|
|
237
|
-
#
|
|
236
|
+
# Ships "en", "de", "es", "fr", "pt-BR", "ja", "ru", "uk", "pl", "it" and
|
|
237
|
+
# "zh-CN". "fr" has been reviewed by a native speaker; everything but
|
|
238
|
+
# English and French is machine-translated and has NOT been — see
|
|
238
239
|
# docs/guides/TRANSLATIONS.md. A missing or wrong translation falls back to
|
|
239
240
|
# English rather than breaking the page.
|
|
240
241
|
#
|
|
@@ -467,7 +468,7 @@ module RailsErrorDashboard
|
|
|
467
468
|
|
|
468
469
|
# Dashboard UI
|
|
469
470
|
@accent_color = :crimson # :crimson, :ruby, :ember, :violet
|
|
470
|
-
@dashboard_locale = "en" # en, de, es, fr, pt-BR, ja, ru, uk, pl, it, zh-CN (non-English machine-translated)
|
|
471
|
+
@dashboard_locale = "en" # en, de, es, fr, pt-BR, ja, ru, uk, pl, it, zh-CN (fr native-reviewed; other non-English machine-translated)
|
|
471
472
|
|
|
472
473
|
# LLM-powered AI help defaults - OFF until provider and API key are configured
|
|
473
474
|
@llm_provider = ENV["RED_LLM_PROVIDER"]&.to_sym
|
|
@@ -109,6 +109,34 @@ module RailsErrorDashboard
|
|
|
109
109
|
at_exit { RailsErrorDashboard::Services::RackAttackTracker.flush_all_threads! }
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Drain the storm count buffer at the end of every request and job, and
|
|
113
|
+
# again at process exit.
|
|
114
|
+
#
|
|
115
|
+
# Counted-only events accumulate in this process's memory and were only
|
|
116
|
+
# ever written out by a LATER admit! reaching the flush interval (see
|
|
117
|
+
# Gate#maybe_flush!). That makes the drain conditional on the flood
|
|
118
|
+
# continuing: when the errors stop -- which is exactly when an operator
|
|
119
|
+
# starts looking -- the tail of the burst stays in memory indefinitely,
|
|
120
|
+
# and a deploy drops it.
|
|
121
|
+
#
|
|
122
|
+
# to_complete fires after the response body is closed, so the client
|
|
123
|
+
# already has its bytes and this never delays a request (safety rule 2).
|
|
124
|
+
# It also fires when the app raised, and is re-entrant, so nested
|
|
125
|
+
# executor blocks do not double-flush. The call is interval-gated, so a
|
|
126
|
+
# flood costs a clock read per request rather than an enqueue.
|
|
127
|
+
#
|
|
128
|
+
# at_exit drains unconditionally and writes synchronously: at shutdown a
|
|
129
|
+
# job handed to the queue may never be picked up. at_exit, not
|
|
130
|
+
# Signal.trap -- trapping would clobber Puma's USR1/USR2 handlers
|
|
131
|
+
# (safety rule 9).
|
|
132
|
+
if RailsErrorDashboard.configuration.enable_storm_protection
|
|
133
|
+
Rails.application.executor.to_complete do
|
|
134
|
+
RailsErrorDashboard::Services::StormProtection::Gate.flush_if_due!
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
at_exit { RailsErrorDashboard::Services::StormProtection::Gate.drain! }
|
|
138
|
+
end
|
|
139
|
+
|
|
112
140
|
# Subscribe to ActionCable AS::Notifications events (requires breadcrumbs + ActionCable)
|
|
113
141
|
if RailsErrorDashboard.configuration.enable_actioncable_tracking &&
|
|
114
142
|
RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
@@ -65,15 +65,33 @@ module RailsErrorDashboard
|
|
|
65
65
|
# @yieldparam span [NoopSpan, ::OpenTelemetry::Trace::Span] real or no-op
|
|
66
66
|
# @return [Object] whatever the block returns
|
|
67
67
|
def in_span(name, kind: :capture, attributes: {})
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
# The work block must run AT MOST ONCE. Everything that can fail
|
|
69
|
+
# before it is entered (emit? checks, tracer construction, attribute
|
|
70
|
+
# merging, the SDK's own span setup) falls back to a no-op span and
|
|
71
|
+
# runs the block once, here. Once the block has been entered, its
|
|
72
|
+
# own exceptions belong to the caller and propagate untouched —
|
|
73
|
+
# they must never re-enter the block.
|
|
74
|
+
#
|
|
75
|
+
# This method previously wrapped the yield in a rescue that yielded
|
|
76
|
+
# again on failure. A downstream failure AFTER the work completed
|
|
77
|
+
# (a notification dispatcher raising, say) therefore re-ran the whole
|
|
78
|
+
# capture: one exception became two occurrences and two count
|
|
79
|
+
# increments, with OTel disabled, for every user. The `entered` flag
|
|
80
|
+
# is what makes "at most once" structural rather than incidental.
|
|
81
|
+
entered = false
|
|
82
|
+
|
|
83
|
+
tr = (tracer if emit?(kind))
|
|
84
|
+
|
|
85
|
+
unless tr
|
|
86
|
+
entered = true
|
|
87
|
+
return yield(NOOP_SPAN)
|
|
88
|
+
end
|
|
72
89
|
|
|
73
90
|
full_name = "#{INSTRUMENTATION_NAME}.#{name}"
|
|
74
91
|
merged = base_attributes.merge(safe_stringify(attributes))
|
|
75
92
|
|
|
76
93
|
tr.in_span(full_name, attributes: merged) do |span|
|
|
94
|
+
entered = true
|
|
77
95
|
begin
|
|
78
96
|
yield span
|
|
79
97
|
rescue StandardError => e
|
|
@@ -82,9 +100,10 @@ module RailsErrorDashboard
|
|
|
82
100
|
end
|
|
83
101
|
end
|
|
84
102
|
rescue StandardError => e
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
103
|
+
# Only reachable while the block has NOT run: tracer internals failed
|
|
104
|
+
# during setup. Re-raise anything that escaped the block itself.
|
|
105
|
+
raise if entered
|
|
106
|
+
|
|
88
107
|
Logger.debug("[RailsErrorDashboard] Tracer.in_span(#{name.inspect}) failed: #{e.class}: #{e.message}")
|
|
89
108
|
yield NOOP_SPAN
|
|
90
109
|
end
|