rails_error_dashboard 0.11.1 → 0.11.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4aa6cdee526bf9210f3762861a373dbefbe99b9ed94ecdb5f6464fb781bdddb4
4
- data.tar.gz: 3a7977ff26332c869b5bf6e4e5316d3470e978ff736184dcc4119f0982822c4c
3
+ metadata.gz: ca64d9f0169fc1dd0b900fbd5460377d5b086d45849e61ca1945045db6c10e4e
4
+ data.tar.gz: e9c09c9c4d5c9b716bf4da5aec37d28780aa2bcbd8d2d80cdacbdd9fb171a0b4
5
5
  SHA512:
6
- metadata.gz: ea46f47e2e96da83cf0c83d94596e63d63ab0dafeea5a3f696b2a1021b9eb57026485c9fec4d0827e8f8fcd4852fd587a69d28d3890ec14a5ec295d549826264
7
- data.tar.gz: 4ea0ff21bbcb9177fd43297a5145e17a6316ec52f2f8a5370b30e36879a32810cd289b22e7c1d66abb2b9f9a66593bc2e013ab3ccfc19af1c0ffc46c42863537
6
+ metadata.gz: 51d6dc61b08748427205b7d3a838ec14e968f01898b395fa9f678c7aca3dd4647b32bc37f0e91fa1d99782b89242707d20604441a81ce7c7ca8bc2cbaa47d1e4
7
+ data.tar.gz: da69121a17eff452ea3b0d31c909b765da9e607cb1a80c5ae304a6fbb4c45473a88d3c6d7c927762d64ed1dd76630ca2dc4b9b37813cdd5213fbec9b7063fe29
@@ -6,11 +6,17 @@ module RailsErrorDashboard
6
6
  # Two usage modes:
7
7
  # 1. With a counts hash — dispatched by RackAttackTracker's periodic flush.
8
8
  # Zero I/O in the request path; all DB writes happen here.
9
- # 2. Without arguments — scheduled periodic sweep that flushes the current
10
- # thread's buffer (useful as a cron safety net for low-traffic apps where
11
- # the flush interval may not be reached during a request).
9
+ # 2. Without arguments — sweeps EVERY live thread's buffer.
12
10
  #
13
- # Example cron (via solid_queue or whenever):
11
+ # Mode 2 is now a belt-and-braces backstop, not the primary drain. Buffers are
12
+ # drained at the end of each request and job by the executor hook registered in
13
+ # the engine (see RackAttackTracker#flush_if_due!), and again at process exit.
14
+ # Scheduling this job is therefore optional; it only ever finds counts on
15
+ # threads that are still alive but have not completed a unit of work since
16
+ # their deadline elapsed. It CANNOT recover counts from a thread that has
17
+ # already died — Thread.list does not include it.
18
+ #
19
+ # Optional cron (via solid_queue or whenever):
14
20
  # every 5.minutes { RailsErrorDashboard::RackAttackFlushJob.perform_later }
15
21
  class RackAttackFlushJob < ApplicationJob
16
22
  queue_as :default
@@ -205,7 +205,7 @@ module RailsErrorDashboard
205
205
  # their own table, independent of error capture (breadcrumbs optional).
206
206
  attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
207
207
  attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
208
- attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 60)
208
+ attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
209
209
 
210
210
  # ActionCable event tracking (requires enable_breadcrumbs = true)
211
211
  attr_accessor :enable_actioncable_tracking # Master switch (default: false)
@@ -429,7 +429,12 @@ module RailsErrorDashboard
429
429
  # Persists to its own table; does NOT require breadcrumbs.
430
430
  @enable_rack_attack_tracking = false
431
431
  @rack_attack_max_cache_size = 1000 # Max buffered keys per thread (LRU eviction)
432
- @rack_attack_flush_interval = 60 # Seconds between DB flushes
432
+ # Max age of buffered events before they are written out. Lowered from 60
433
+ # to 5 alongside the end-of-request drain (issue #170): the executor hook
434
+ # gates on this interval, so it is the upper bound on how stale the Rate
435
+ # Limits page can be, not a per-request cost. A flood still collapses to
436
+ # roughly one write per thread per interval.
437
+ @rack_attack_flush_interval = 5 # Seconds between DB flushes
433
438
 
434
439
  # ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
435
440
  @enable_actioncable_tracking = false
@@ -628,7 +633,7 @@ module RailsErrorDashboard
628
633
  # Rack::Attack initializer has loaded, so a missing constant here does
629
634
  # not prove it will still be missing at after_initialize (when the
630
635
  # subscriber actually registers). Auto-disabling would break that case.
631
- unless defined?(::Rack::Attack)
636
+ unless rack_attack_defined?
632
637
  warnings << "enable_rack_attack_tracking is enabled but the rack-attack gem " \
633
638
  "does not appear to be loaded. No events will be recorded until " \
634
639
  "Rack::Attack is installed and configured."
@@ -1033,6 +1038,14 @@ module RailsErrorDashboard
1033
1038
 
1034
1039
  # Detect where the engine is mounted in the host app's routes.
1035
1040
  # @return [String] mount path (default: "/red")
1041
+ # Extracted so specs can simulate the gem's absence. rack-attack is in the
1042
+ # dev bundle (so specs can drive the real middleware), which means
1043
+ # ::Rack::Attack is always defined during the suite and absence can no
1044
+ # longer be produced by simply not requiring it.
1045
+ def rack_attack_defined?
1046
+ defined?(::Rack::Attack) ? true : false
1047
+ end
1048
+
1036
1049
  def detect_engine_mount_path
1037
1050
  return "/red" unless defined?(Rails) && Rails.application
1038
1051
 
@@ -86,6 +86,21 @@ module RailsErrorDashboard
86
86
  defined?(Rack::Attack)
87
87
  RailsErrorDashboard::Subscribers::RackAttackSubscriber.subscribe!
88
88
 
89
+ # Drain buffered counts at the end of every request and job.
90
+ #
91
+ # Without this the buffer is only ever drained by a LATER event arriving
92
+ # on the SAME thread (see RackAttackTracker#flush_if_due!), so a rule that
93
+ # matches once stays invisible until the process exits, and counts on a
94
+ # Puma thread that retires are lost outright rather than delayed.
95
+ #
96
+ # to_complete fires after the response body is closed, so the client
97
+ # already has its bytes — this never delays a request (safety rule 2).
98
+ # It also fires when the app raised, and is re-entrant, so nested
99
+ # executor blocks do not double-flush.
100
+ Rails.application.executor.to_complete do
101
+ RailsErrorDashboard::Services::RackAttackTracker.flush_if_due!
102
+ end
103
+
89
104
  # Buffered counts live on the Puma threads that served the requests and
90
105
  # are only written out on the flush interval, which a low-traffic rule
91
106
  # may never reach. Without this, everything still buffered at SIGTERM
@@ -31,7 +31,26 @@ module RailsErrorDashboard
31
31
  "Claude-User" => /Claude-User/i,
32
32
  "Claude Code" => /Claude-?Code/i,
33
33
  "Perplexity-User" => /Perplexity-User/i,
34
- "Gemini-User" => /Gemini-User/i
34
+ "Gemini-User" => /Gemini-User/i,
35
+ # Observed in production traffic by the reporter of #170:
36
+ # "GitHubCopilotRuntime-WebFetch", 7 requests from 5 IPs. Classified as
37
+ # an assistant rather than a crawler because it fetches a specific URL a
38
+ # developer's Copilot session asked for, one page at a time — not a bulk
39
+ # corpus crawl.
40
+ #
41
+ # The pattern is deliberately anchored on "GitHubCopilot" rather than a
42
+ # bare /Copilot/i. Microsoft applies the Copilot brand very broadly, and
43
+ # its Copilot features are documented as riding Bingbot infrastructure or
44
+ # sending ordinary Edge/Chromium user agents with no bot signal — so a
45
+ # loose pattern would mislabel plain browser traffic as an AI agent,
46
+ # which is exactly the wrong-attribution failure this class avoids.
47
+ #
48
+ # No authoritative UA documentation exists for this agent: it is absent
49
+ # from GitHub's own docs, from ai-robots-txt/ai.robots.txt, and from the
50
+ # Dark Visitors catalogue as of 2026-08-29. The pattern therefore matches
51
+ # only what has actually been observed, plus the "-WebFetch" sibling
52
+ # suffix, instead of guessing at variants.
53
+ "GitHub Copilot" => /GitHubCopilot/i
35
54
  }.freeze
36
55
 
37
56
  AI_CRAWLERS = {
@@ -24,7 +24,23 @@ module RailsErrorDashboard
24
24
  # - Async flush via background job
25
25
  class RackAttackTracker
26
26
  COUNTS_THREAD_KEY = :red_rack_attack_counts
27
- FLUSH_THREAD_KEY = :red_rack_attack_last_flush
27
+
28
+ # Monotonic timestamp of the moment the buffer became non-empty — the
29
+ # DEADLINE clock, not a "last flush" clock.
30
+ #
31
+ # WHY THE DISTINCTION MATTERS: this used to hold the last flush time and be
32
+ # seeded lazily inside maybe_flush! with `||= now`, which meant the very
33
+ # first event of a buffer set the clock to now and then compared `now - now
34
+ # >= interval` — false, always. A rule that matched once and never again
35
+ # therefore never flushed at all, and a manual `curl` test showed an empty
36
+ # table indefinitely (issue #170, third report). Seeding when the buffer
37
+ # STARTS filling makes the guarantee "buffered data is never older than
38
+ # flush_interval", which is the property the dashboard actually needs.
39
+ DEADLINE_THREAD_KEY = :red_rack_attack_deadline_at
40
+
41
+ # Kept as an alias so a host or spec holding the old key name still clears
42
+ # the right slot. Both are cleared together in reset!.
43
+ FLUSH_THREAD_KEY = :red_rack_attack_last_flush
28
44
 
29
45
  # Field length caps — must match the column limits in the migration so that
30
46
  # truncation happens before the value ever reaches the unique upsert index.
@@ -38,6 +54,10 @@ module RailsErrorDashboard
38
54
  # eviction. Without this the evicted count vanishes silently and the
39
55
  # dashboard under-reports with no indication anything was lost — the same
40
56
  # problem StormProtection::CountBuffer solves with an overflow counter.
57
+ # Fallback when configuration is unreadable. Must match
58
+ # Configuration#rack_attack_flush_interval's default.
59
+ DEFAULT_FLUSH_INTERVAL = 5
60
+
41
61
  OVERFLOW_RULE = "__overflow__"
42
62
  OVERFLOW_MATCH_TYPE = "overflow"
43
63
 
@@ -69,6 +89,13 @@ module RailsErrorDashboard
69
89
  )
70
90
 
71
91
  counts = (Thread.current[COUNTS_THREAD_KEY] ||= {})
92
+
93
+ # Start the deadline the moment the buffer goes from empty to non-empty.
94
+ # Doing it here (rather than lazily at flush-check time) is what makes
95
+ # "never older than flush_interval" true for a buffer that receives
96
+ # exactly one event and then goes quiet.
97
+ Thread.current[DEADLINE_THREAD_KEY] ||= monotonic_now if counts.empty?
98
+
72
99
  counts[key] = (counts[key] || 0) + 1
73
100
 
74
101
  # LRU eviction — bounds memory under rotating-discriminator attacks.
@@ -98,7 +125,11 @@ module RailsErrorDashboard
98
125
 
99
126
  snapshot = counts.dup
100
127
  counts.clear
101
- Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f
128
+ # Buffer is empty again, so there is nothing to be late: clear the
129
+ # deadline. The next record! reseeds it. Leaving a stale timestamp
130
+ # here would make the very next event look instantly overdue.
131
+ Thread.current[DEADLINE_THREAD_KEY] = nil
132
+ Thread.current[FLUSH_THREAD_KEY] = nil
102
133
 
103
134
  dispatch_flush(snapshot, sync: sync)
104
135
  nil
@@ -132,6 +163,7 @@ module RailsErrorDashboard
132
163
 
133
164
  snapshot = counts.dup
134
165
  counts.clear
166
+ thread[DEADLINE_THREAD_KEY] = nil
135
167
  thread[FLUSH_THREAD_KEY] = nil
136
168
 
137
169
  dispatch_flush(snapshot, sync: true)
@@ -149,10 +181,51 @@ module RailsErrorDashboard
149
181
  nil
150
182
  end
151
183
 
184
+ # Drain this thread's buffer at the end of a unit of work (a request or a
185
+ # job), if it has been waiting longer than flush_interval.
186
+ #
187
+ # WHY THIS EXISTS (issue #170, third report): before this, the ONLY
188
+ # in-process drain was maybe_flush! inside record, so the buffer could
189
+ # only ever be flushed by a LATER event landing on the SAME thread. Two
190
+ # consequences, both reported as "no events are recorded at all":
191
+ #
192
+ # 1. A rule that matched once showed nothing until the process exited.
193
+ # `curl` once, look at the dashboard, see an empty table — forever.
194
+ # 2. Worse, Puma reuses and retires threads. Counts buffered on a thread
195
+ # that dies are unreachable to flush_all_threads! (it walks
196
+ # Thread.list), so they were lost outright, not merely delayed.
197
+ # Measured: 5 of 5 events lost when the serving threads exited.
198
+ #
199
+ # ActiveSupport::Executor#to_complete is the right boundary because Rails
200
+ # already guarantees it runs once per request and once per job. Crucially,
201
+ # ActionDispatch::Executor returns a Rack::BodyProxy and defers the hook
202
+ # until the SERVER CLOSES THE RESPONSE BODY — so this runs after the client
203
+ # has its bytes and cannot delay the response (safety rule 2).
204
+ #
205
+ # The flush is gated on flush_due?, so a flood does not turn into one
206
+ # UPDATE per request — the exact regression #143's buffer exists to
207
+ # prevent. Measured 0.068 ms/req gated vs 0.508 ms/req ungated.
208
+ def flush_if_due!
209
+ return unless enabled?
210
+ return unless flush_due?
211
+
212
+ # sync: the response is already sent, so there is nothing left to block,
213
+ # and enqueueing a job per interval would be more overhead than the
214
+ # single upsert it replaces.
215
+ flush!(sync: true)
216
+ nil
217
+ rescue => e
218
+ RailsErrorDashboard::Logger.debug(
219
+ "[RailsErrorDashboard] RackAttackTracker.flush_if_due! failed: #{e.class} - #{e.message}"
220
+ )
221
+ nil
222
+ end
223
+
152
224
  # Clear thread-local state without persisting. Used by specs and by
153
225
  # thread teardown paths.
154
226
  def reset!
155
227
  Thread.current[COUNTS_THREAD_KEY] = nil
228
+ Thread.current[DEADLINE_THREAD_KEY] = nil
156
229
  Thread.current[FLUSH_THREAD_KEY] = nil
157
230
  nil
158
231
  rescue => e
@@ -179,6 +252,24 @@ module RailsErrorDashboard
179
252
  parts.fill("", parts.length, 6 - parts.length)
180
253
  end
181
254
 
255
+ # Cheap deadline check — a float subtraction, no I/O.
256
+ #
257
+ # Returns true when the buffer has been waiting at least flush_interval.
258
+ # Uses a monotonic clock: Time.now can jump backwards (NTP correction,
259
+ # leap second) and would then defer the flush indefinitely.
260
+ def flush_due?
261
+ deadline = Thread.current[DEADLINE_THREAD_KEY]
262
+ return false if deadline.nil?
263
+
264
+ (monotonic_now - deadline) >= flush_interval
265
+ rescue => e
266
+ false
267
+ end
268
+
269
+ def monotonic_now
270
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
271
+ end
272
+
182
273
  private
183
274
 
184
275
  def enabled?
@@ -215,13 +306,9 @@ module RailsErrorDashboard
215
306
  )
216
307
  end
217
308
 
218
- # Cheap periodic flush check a float subtraction, no I/O.
309
+ # Cheap periodic flush check on the record path no I/O.
219
310
  def maybe_flush!
220
- now = Time.now.to_f
221
- last_flush = Thread.current[FLUSH_THREAD_KEY] ||= now
222
- return unless (now - last_flush) >= flush_interval
223
-
224
- flush!
311
+ flush! if flush_due?
225
312
  end
226
313
 
227
314
  # Dispatch asynchronously so the request path never waits on the DB.
@@ -257,9 +344,9 @@ module RailsErrorDashboard
257
344
  end
258
345
 
259
346
  def flush_interval
260
- RailsErrorDashboard.configuration.rack_attack_flush_interval || 60
347
+ RailsErrorDashboard.configuration.rack_attack_flush_interval || DEFAULT_FLUSH_INTERVAL
261
348
  rescue => e
262
- 60
349
+ DEFAULT_FLUSH_INTERVAL
263
350
  end
264
351
 
265
352
  # Truncate to the column limit and strip the key separator. A rule name
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.11.1"
2
+ VERSION = "0.11.3"
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.1
4
+ version: 0.11.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -219,24 +219,43 @@ dependencies:
219
219
  - - "~>"
220
220
  - !ruby/object:Gem::Version
221
221
  version: '0.15'
222
- description: 'Rails Error Dashboard (RED) is an open-source, self-hosted Rails engine
223
- for investigating production exceptions without sending error data to a monitoring
222
+ description: |
223
+ == Rails-native failure investigation
224
+
225
+ Rails Error Dashboard (RED) is an open-source, self-hosted Rails engine for
226
+ investigating production exceptions without sending error data to a monitoring
224
227
  vendor. It groups errors and records request context and cause chains and, when
225
- enabled, breadcrumbs plus local and instance variables captured before Ruby unwinds
226
- the stack. RED attaches Rails and Ruby runtime health to the error record on every
227
- captured occurrence, including Active Record pool, Puma, background jobs, GC, memory,
228
- threads, file descriptors and system pressure. Built-in storm protection progressively
229
- sheds expensive context and I/O during error floods while retaining useful exemplars
230
- and exact occurrence counts. Run RED with your application''s database or an isolated
231
- error database. It supports PostgreSQL, MySQL/Trilogy and SQLite, and includes workflow,
232
- notifications (Slack, Email, Discord, PagerDuty, webhooks), two-way issue sync with
233
- GitHub, GitLab, Codeberg and Linear, Copy as RSpec/curl/LLM, swallowed-exception
234
- detection, LLM observability without prompt capture, OpenTelemetry span export and
235
- Rails-specific operational views. The dashboard is translated into 11 languages
236
- (machine-translated outside English, awaiting native review). A self-hosted Sentry
237
- alternative that keeps error data in your own database. The gem is MIT and free
238
- forever. Supports Rails 7.0-8.1 and Ruby 3.2-4.0. Beta: APIs may change before 1.0.
239
- Live demo: https://rails-error-dashboard.anjan.dev'
228
+ enabled, breadcrumbs plus local and instance variables captured before Ruby
229
+ unwinds the stack.
230
+
231
+ == What it records
232
+
233
+ * Rails and Ruby runtime health on the error record, refreshed on every captured
234
+ occurrence: Active Record pool, Puma, background jobs, GC, memory, threads,
235
+ file descriptors and system pressure
236
+ * Built-in storm protection that progressively sheds expensive context and I/O
237
+ during error floods while retaining useful exemplars and exact occurrence
238
+ counts
239
+ * Copy as RSpec, curl or LLM prompt; swallowed-exception detection; LLM
240
+ observability without prompt capture; OpenTelemetry span export
241
+ * Workflow, notifications (Slack, Email, Discord, PagerDuty, webhooks) and
242
+ two-way issue sync with GitHub, GitLab, Codeberg and Linear
243
+ * Rails-specific operational views: jobs, database, cache, Action Cable, Active
244
+ Storage, Rack::Attack and deprecations
245
+
246
+ == Running it
247
+
248
+ Run RED with your application's database or an isolated error database, on
249
+ PostgreSQL, MySQL/Trilogy or SQLite. The dashboard is translated into 11
250
+ languages (machine-translated outside English, awaiting native review). A
251
+ self-hosted Sentry alternative that keeps error data in your own database. The
252
+ gem is MIT and free forever.
253
+
254
+ Supports Rails 7.0-8.1 and Ruby 3.2-4.0. Beta: APIs may change before 1.0.
255
+
256
+ Live demo: https://rails-error-dashboard.anjan.dev
257
+
258
+ Documentation: https://AnjanJ.github.io/rails_error_dashboard
240
259
  email:
241
260
  - anjan.jagirdar@gmail.com
242
261
  executables: []
@@ -556,7 +575,7 @@ metadata:
556
575
  funding_uri: https://github.com/sponsors/AnjanJ
557
576
  post_install_message: |
558
577
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
559
- RED (Rails Error Dashboard) v0.11.1
578
+ RED (Rails Error Dashboard) v0.11.3
560
579
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
561
580
 
562
581
  First install: