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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -4
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +79 -6
  5. data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +4 -2
  6. data/app/jobs/rails_error_dashboard/application_job.rb +56 -14
  7. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +22 -4
  8. data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +4 -2
  9. data/app/jobs/rails_error_dashboard/create_issue_job.rb +1 -1
  10. data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +4 -2
  11. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +25 -0
  12. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +22 -4
  13. data/app/models/rails_error_dashboard/error_log.rb +47 -0
  14. data/app/models/rails_error_dashboard/storm_flush_batch.rb +50 -0
  15. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +27 -1
  16. data/app/views/rails_error_dashboard/errors/_stats.html.erb +6 -0
  17. data/app/views/rails_error_dashboard/errors/overview.html.erb +13 -1
  18. data/config/locales/de.yml +9 -0
  19. data/config/locales/en.yml +35 -0
  20. data/config/locales/es.yml +9 -0
  21. data/config/locales/fr.yml +10 -1
  22. data/config/locales/it.yml +9 -0
  23. data/config/locales/ja.yml +9 -0
  24. data/config/locales/pl.yml +9 -0
  25. data/config/locales/pt-BR.yml +9 -0
  26. data/config/locales/ru.yml +9 -0
  27. data/config/locales/uk.yml +9 -0
  28. data/config/locales/zh-CN.yml +9 -0
  29. data/db/migrate/20260915000001_add_group_identity_unique_index_to_error_logs.rb +207 -0
  30. data/db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb +89 -0
  31. data/db/migrate/20260915000003_add_context_provenance_to_error_logs.rb +42 -0
  32. data/db/migrate/20260915000004_create_storm_flush_batches.rb +50 -0
  33. data/lib/rails_error_dashboard/commands/create_issue.rb +10 -2
  34. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +135 -4
  35. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +139 -34
  36. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +20 -2
  37. data/lib/rails_error_dashboard/commands/log_error.rb +206 -19
  38. data/lib/rails_error_dashboard/configuration.rb +4 -3
  39. data/lib/rails_error_dashboard/engine.rb +28 -0
  40. data/lib/rails_error_dashboard/integrations/tracer.rb +26 -7
  41. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +147 -61
  42. data/lib/rails_error_dashboard/queries/user_impact_summary.rb +57 -7
  43. data/lib/rails_error_dashboard/services/error_hash_generator.rb +61 -19
  44. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +38 -0
  45. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +22 -3
  46. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +101 -14
  47. data/lib/rails_error_dashboard/version.rb +1 -1
  48. 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). Fall back to
147
- # sync logging so the error is still captured. Without this rescue,
148
- # the exception propagates back to ErrorReporter, which re-reports it
149
- # via Rails.error.report → infinite recursion (issue #114).
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
- def initialize(exception, context = {})
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 the span itself raises
200
- # somehow, the outer rescue still catches it and returns nil. Defense
201
- # in depth. When the block raises, the Tracer façade records the
202
- # exception on the span and re-raises so the rescue can swallow it.
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
- error_hash = Services::ErrorHashGenerator.call(
258
- @exception,
259
- controller_name: error_context.controller_name,
260
- action_name: error_context.action_name,
261
- application_id: application.id,
262
- context: @context
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
- error_log = ErrorLog.find_or_increment_by_hash(error_hash, attributes.merge(error_hash: error_hash))
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", "fr", "es", "pt-BR" and "ja". Everything but English is
237
- # machine-translated and has NOT been reviewed by a native speaker — see
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
- return yield(NOOP_SPAN) unless emit?(kind)
69
-
70
- tr = tracer
71
- return yield(NOOP_SPAN) unless tr
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
- # Tracer internals failed (e.g. OTel SDK threw on add_span). Fall back
86
- # to running the block with a no-op so the host app never sees a crash
87
- # caused by the tracer.
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