rails_error_dashboard 0.11.4 → 0.11.6

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 (25) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -2
  3. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +31 -3
  4. data/app/jobs/rails_error_dashboard/baseline_calculation_job.rb +21 -0
  5. data/app/models/rails_error_dashboard/error_log.rb +4 -7
  6. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  7. data/config/locales/fr.yml +30 -30
  8. data/db/migrate/20260908000001_add_release_to_error_occurrences.rb +45 -0
  9. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +20 -9
  10. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +63 -28
  11. data/lib/rails_error_dashboard/commands/log_error.rb +11 -2
  12. data/lib/rails_error_dashboard/configuration.rb +7 -0
  13. data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
  14. data/lib/rails_error_dashboard/queries/baseline_stats.rb +39 -4
  15. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +5 -16
  16. data/lib/rails_error_dashboard/queries/release_timeline.rb +72 -1
  17. data/lib/rails_error_dashboard/services/baseline_calculator.rb +75 -62
  18. data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -0
  19. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +33 -3
  20. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +83 -22
  21. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +52 -12
  22. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +61 -2
  23. data/lib/rails_error_dashboard/value_objects/error_context.rb +19 -2
  24. data/lib/rails_error_dashboard/version.rb +1 -1
  25. metadata +4 -2
@@ -155,22 +155,35 @@ module RailsErrorDashboard
155
155
  # Cheap in-process bucketing key. Deliberately NOT the canonical
156
156
  # error_hash (that needs application_id = DB); the flush job
157
157
  # recomputes the canonical hash from the stored parts.
158
+ # Environment is a MATCH dimension (one row per environment), so it
159
+ # is part of the in-process key too: a staging event and a
160
+ # production event of the same error must not share one entry, or
161
+ # the flush job would reconcile both into whichever environment
162
+ # the worker runs in.
158
163
  def gate_key(parts)
159
- parts[:gate_key] ||= parts[:custom_hash] || Digest::SHA256.hexdigest(
160
- "#{parts[:error_class]}|#{ErrorHashGenerator.normalize_message(parts[:message])}|" \
161
- "#{parts[:first_app_frame]}|#{parts[:controller_name]}|#{parts[:action_name]}"
164
+ parts[:gate_key] ||= Digest::SHA256.hexdigest(
165
+ "#{parts[:custom_hash] || identity_key(parts)}|#{parts[:environment]}"
162
166
  )[0..15]
163
167
  end
164
168
 
169
+ def identity_key(parts)
170
+ "#{parts[:error_class]}|#{ErrorHashGenerator.normalize_message(parts[:message])}|" \
171
+ "#{parts[:first_app_frame]}|#{parts[:controller_name]}|#{parts[:action_name]}"
172
+ end
173
+
165
174
  def gate_parts(exception, context)
166
175
  {
167
176
  error_class: exception.class.name,
168
- message: exception.message.to_s[0, 500],
177
+ message: exception.message.to_s[0, ErrorHashGenerator::HASH_MESSAGE_LIMIT],
169
178
  first_app_frame: ErrorHashGenerator.extract_app_frame_from_locations(exception) ||
170
179
  ErrorHashGenerator.extract_app_frame(exception.backtrace),
171
180
  controller_name: context[:controller_name]&.to_s,
172
181
  action_name: context[:action_name]&.to_s,
173
- custom_hash: custom_hash_for(exception, context)
182
+ custom_hash: custom_hash_for(exception, context),
183
+ # Only an EXPLICIT environment from the caller. nil means "the
184
+ # worker's own environment", resolved at flush time exactly as
185
+ # LogError resolves it for a full capture.
186
+ environment: context[:environment].to_s.strip.presence&.[](0, 64)
174
187
  }
175
188
  end
176
189
 
@@ -203,19 +216,46 @@ module RailsErrorDashboard
203
216
  @last_flush = now
204
217
  snapshot = count_buffer.snapshot!
205
218
  episode = breaker.episode_snapshot
206
- breaker.clear_closed_episode!
207
219
 
208
- StormFlushJob.perform_later(
209
- entries: snapshot[:entries],
210
- overflow: snapshot[:overflow],
211
- episode: serialize_episode(episode)
212
- )
220
+ begin
221
+ job = StormFlushJob.perform_later(
222
+ entries: snapshot[:entries],
223
+ overflow: snapshot[:overflow],
224
+ episode: serialize_episode(episode)
225
+ )
226
+ # Active Job swallows ActiveJob::EnqueueError and returns false
227
+ # (and a job that was not enqueued says so) — a failed handoff
228
+ # that never raises.
229
+ unless enqueued?(job)
230
+ reason = (job.respond_to?(:enqueue_error) && job.enqueue_error&.message) || "enqueue returned #{job.inspect}"
231
+ raise "StormFlushJob was not enqueued: #{reason}"
232
+ end
233
+ rescue => e
234
+ # The queue is often the very thing that is down during a storm
235
+ # (a SolidQueue enqueue is a DB write). The batch is not gone:
236
+ # put it back so the next interval retries, and leave the
237
+ # closed episode in place so it is persisted by that retry.
238
+ count_buffer.restore(snapshot[:entries], snapshot[:overflow])
239
+ RailsErrorDashboard::Logger.error(
240
+ "[RailsErrorDashboard] Storm flush enqueue failed (batch retained for retry): #{e.class} - #{e.message}"
241
+ )
242
+ return
243
+ end
244
+
245
+ breaker.clear_closed_episode!
213
246
  rescue => e
214
247
  RailsErrorDashboard::Logger.error(
215
- "[RailsErrorDashboard] Storm flush enqueue failed: #{e.class} - #{e.message}"
248
+ "[RailsErrorDashboard] Storm flush failed: #{e.class} - #{e.message}"
216
249
  )
217
250
  end
218
251
 
252
+ def enqueued?(job)
253
+ return false unless job
254
+ return job.successfully_enqueued? if job.respond_to?(:successfully_enqueued?)
255
+
256
+ true
257
+ end
258
+
219
259
  def serialize_episode(episode)
220
260
  return nil unless episode
221
261
 
@@ -8,11 +8,18 @@ module RailsErrorDashboard
8
8
  # Puma stats, job queue, RubyVM/YJIT, ActionCable, file descriptors, system load,
9
9
  # system memory pressure, GC context, and TCP connection states.
10
10
  #
11
- # NOT memoized — fresh data every call (unlike EnvironmentSnapshot).
11
+ # NOT memoized — fresh data every call (unlike EnvironmentSnapshot), with
12
+ # one exception: job-queue depth counts are queries against the queue
13
+ # store, so they are cached per process for
14
+ # config.system_health_queue_stats_cache_seconds (failures included), with
15
+ # one refresh in flight at a time (and can be switched off with
16
+ # config.system_health_queue_stats = false).
12
17
  # Every metric call individually wrapped in rescue => nil.
13
18
  #
14
19
  # Safety contract (from HOST_APP_SAFETY.md):
15
- # - Total snapshot < 1ms budget (~0.3ms typical on Linux)
20
+ # - In-process metrics < 1ms budget (~0.3ms typical on Linux). The
21
+ # queue-depth counts are the documented exception: their latency is
22
+ # the queue store's, which is why they are cached and optional.
16
23
  # - NEVER ObjectSpace.each_object or ObjectSpace.count_objects (heap scan)
17
24
  # - NEVER Thread.list.map(&:backtrace) (GVL hold)
18
25
  # - Thread.list.count only (O(1), safe)
@@ -133,7 +140,59 @@ module RailsErrorDashboard
133
140
 
134
141
  # Auto-detect and capture job queue stats
135
142
  # @return [Hash, nil] Job queue stats with :adapter key, or nil
143
+ # Process-wide cache for the queue-depth counts: { at: monotonic, stats: Hash|nil }.
144
+ # A nil stats entry is a FAILED collection, cached for the same interval
145
+ # so a broken queue store is not re-queried on every error.
146
+ def self.queue_stats_cache
147
+ @queue_stats_cache ||= Concurrent::AtomicReference.new(nil)
148
+ end
149
+
150
+ # Single-flight guard for the refresh. Never blocks: a thread that finds
151
+ # a refresh already running serves the stale entry (or nil) instead of
152
+ # starting a second one, so a burst of errors on a cold cache runs the
153
+ # queue counts once, not once per thread.
154
+ def self.queue_stats_refresh_lock
155
+ @queue_stats_refresh_lock ||= Mutex.new
156
+ end
157
+
158
+ def self.reset_queue_stats_cache!
159
+ queue_stats_cache.set(nil)
160
+ end
161
+
136
162
  def job_queue_stats
163
+ config = RailsErrorDashboard.configuration
164
+ return nil unless config.system_health_queue_stats
165
+
166
+ ttl = config.system_health_queue_stats_cache_seconds.to_f
167
+ return collect_job_queue_stats if ttl <= 0
168
+
169
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ cached = self.class.queue_stats_cache.get
171
+ return present_cached(cached, now) if cached && (now - cached[:at]) < ttl
172
+
173
+ lock = self.class.queue_stats_refresh_lock
174
+ return present_cached(cached, now, stale: true) unless lock.try_lock
175
+
176
+ begin
177
+ stats = collect_job_queue_stats
178
+ self.class.queue_stats_cache.set({ at: now, stats: stats })
179
+ stats
180
+ ensure
181
+ lock.unlock
182
+ end
183
+ rescue => e
184
+ nil
185
+ end
186
+
187
+ def present_cached(cached, now, stale: false)
188
+ return nil unless cached && cached[:stats]
189
+
190
+ presented = cached[:stats].merge(cached_age_seconds: (now - cached[:at]).round(1))
191
+ presented[:stale] = true if stale
192
+ presented
193
+ end
194
+
195
+ def collect_job_queue_stats
137
196
  if defined?(::Sidekiq::Stats)
138
197
  sidekiq_stats
139
198
  elsif defined?(::SolidQueue)
@@ -7,7 +7,7 @@ module RailsErrorDashboard
7
7
  class ErrorContext
8
8
  attr_reader :user_id, :request_url, :request_params, :user_agent, :ip_address, :platform,
9
9
  :controller_name, :action_name, :request_id, :session_id,
10
- :http_method, :hostname, :content_type, :request_duration_ms
10
+ :http_method, :hostname, :content_type, :request_duration_ms, :environment
11
11
 
12
12
  def initialize(context, source = nil)
13
13
  @context = context
@@ -27,8 +27,15 @@ module RailsErrorDashboard
27
27
  @hostname = extract_hostname
28
28
  @content_type = extract_content_type
29
29
  @request_duration_ms = extract_request_duration_ms
30
+ @environment = extract_environment
30
31
  end
31
32
 
33
+ # Everything a second ErrorContext needs to rebuild THIS context from a
34
+ # plain hash. ErrorReporter hands `to_h` to LogError, which constructs a
35
+ # new ErrorContext from it, so any reader that is not represented here
36
+ # is silently nil on the far side of that hop. request_id/session_id
37
+ # were exactly that: extracted from the request, dropped here, and every
38
+ # occurrence on the main capture path stored nil for both.
32
39
  def to_h
33
40
  {
34
41
  user_id: user_id,
@@ -39,10 +46,13 @@ module RailsErrorDashboard
39
46
  platform: platform,
40
47
  controller_name: controller_name,
41
48
  action_name: action_name,
49
+ request_id: request_id,
50
+ session_id: session_id,
42
51
  http_method: http_method,
43
52
  hostname: hostname,
44
53
  content_type: content_type,
45
- request_duration_ms: request_duration_ms
54
+ request_duration_ms: request_duration_ms,
55
+ environment: environment
46
56
  }
47
57
  end
48
58
 
@@ -246,6 +256,13 @@ module RailsErrorDashboard
246
256
  nil
247
257
  end
248
258
 
259
+ # An explicit environment from the caller (a sender attributing an event
260
+ # to its own environment). nil means "resolve from configuration".
261
+ def extract_environment
262
+ value = @context[:environment].to_s.strip
263
+ value.empty? ? nil : value
264
+ end
265
+
249
266
  # Auto-detect user_id from ActiveSupport::CurrentAttributes
250
267
  # Checks for common patterns: Current.user, Current.account (with .id)
251
268
  # Returns nil if CurrentAttributes is not used or user is not set
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.11.4"
2
+ VERSION = "0.11.6"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_error_dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.4
4
+ version: 0.11.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -279,6 +279,7 @@ files:
279
279
  - app/jobs/rails_error_dashboard/application_job.rb
280
280
  - app/jobs/rails_error_dashboard/async_error_logging_job.rb
281
281
  - app/jobs/rails_error_dashboard/baseline_alert_job.rb
282
+ - app/jobs/rails_error_dashboard/baseline_calculation_job.rb
282
283
  - app/jobs/rails_error_dashboard/close_linked_issue_job.rb
283
284
  - app/jobs/rails_error_dashboard/concerns/localized_job.rb
284
285
  - app/jobs/rails_error_dashboard/create_issue_job.rb
@@ -407,6 +408,7 @@ files:
407
408
  - db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
408
409
  - db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb
409
410
  - db/migrate/20260826000001_add_environment_to_error_logs.rb
411
+ - db/migrate/20260908000001_add_release_to_error_occurrences.rb
410
412
  - lib/generators/rails_error_dashboard/install/install_generator.rb
411
413
  - lib/generators/rails_error_dashboard/install/templates/README
412
414
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -575,7 +577,7 @@ metadata:
575
577
  funding_uri: https://github.com/sponsors/AnjanJ
576
578
  post_install_message: |
577
579
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
578
- RED (Rails Error Dashboard) v0.11.4
580
+ RED (Rails Error Dashboard) v0.11.6
579
581
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
580
582
 
581
583
  First install: