rails_error_dashboard 0.11.1 → 0.11.2

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: c470ccb09f844bb6187fff2f3519d00370e3a0d188319cba49beed93c1bc139d
4
+ data.tar.gz: a1adc23940e22ffe6f35d00a161c7ec6494a07cc3576ca1a0b541cc28605e108
5
5
  SHA512:
6
- metadata.gz: ea46f47e2e96da83cf0c83d94596e63d63ab0dafeea5a3f696b2a1021b9eb57026485c9fec4d0827e8f8fcd4852fd587a69d28d3890ec14a5ec295d549826264
7
- data.tar.gz: 4ea0ff21bbcb9177fd43297a5145e17a6316ec52f2f8a5370b30e36879a32810cd289b22e7c1d66abb2b9f9a66593bc2e013ab3ccfc19af1c0ffc46c42863537
6
+ metadata.gz: dabde3ae967ff2d0615b0fa91477f3706490ef2fe8975fa26a364727905c646e393605a6d6c0bca2c1e614eb311b550d192a3f7d632bd19626407f586cd016ba
7
+ data.tar.gz: c0f5e711dac845b70506151dc56f2b4c079eb8dfe2dc85b491dfef4ae011d488d55c758fc0a0aee18ac64faf949253a1be3cb6c421e9315a62cbccef823d6f9d
@@ -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
@@ -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.2"
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.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -556,7 +556,7 @@ metadata:
556
556
  funding_uri: https://github.com/sponsors/AnjanJ
557
557
  post_install_message: |
558
558
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
559
- RED (Rails Error Dashboard) v0.11.1
559
+ RED (Rails Error Dashboard) v0.11.2
560
560
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
561
561
 
562
562
  First install: