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 +4 -4
- data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +10 -4
- data/lib/rails_error_dashboard/configuration.rb +16 -3
- data/lib/rails_error_dashboard/engine.rb +15 -0
- data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +97 -10
- data/lib/rails_error_dashboard/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c470ccb09f844bb6187fff2f3519d00370e3a0d188319cba49beed93c1bc139d
|
|
4
|
+
data.tar.gz: a1adc23940e22ffe6f35d00a161c7ec6494a07cc3576ca1a0b541cc28605e108
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 —
|
|
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
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
309
|
+
# Cheap periodic flush check on the record path — no I/O.
|
|
219
310
|
def maybe_flush!
|
|
220
|
-
|
|
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 ||
|
|
347
|
+
RailsErrorDashboard.configuration.rack_attack_flush_interval || DEFAULT_FLUSH_INTERVAL
|
|
261
348
|
rescue => e
|
|
262
|
-
|
|
349
|
+
DEFAULT_FLUSH_INTERVAL
|
|
263
350
|
end
|
|
264
351
|
|
|
265
352
|
# Truncate to the column limit and strip the key separator. A rule name
|
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
|
+
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.
|
|
559
|
+
RED (Rails Error Dashboard) v0.11.2
|
|
560
560
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
561
561
|
|
|
562
562
|
First install:
|