railwatch 0.1.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.
- checksums.yaml +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- metadata +157 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# One background thread per process. Drains the buffer every flush_interval
|
|
5
|
+
# seconds or when the buffer crosses flush_threshold, and posts to the
|
|
6
|
+
# platform. Re-armed after fork so Puma cluster workers and Solid Queue
|
|
7
|
+
# forked workers each get their own thread. Nothing here touches the app
|
|
8
|
+
# database.
|
|
9
|
+
class Reporter
|
|
10
|
+
INITIAL_RETRY_DELAY = 1.0
|
|
11
|
+
MAX_RETRY_DELAY = 60.0
|
|
12
|
+
# A retained batch is retried this many times, then dropped (and
|
|
13
|
+
# counted) so the buffer's newest records win again. Without the cap a
|
|
14
|
+
# batch that keeps failing would be pinned forever while everything
|
|
15
|
+
# newer was discarded around it. Eight attempts on the backoff ladder is
|
|
16
|
+
# roughly four minutes of outage.
|
|
17
|
+
MAX_RETRY_ATTEMPTS = 8
|
|
18
|
+
# An unhandled exception asks for an immediate flush so it reaches the
|
|
19
|
+
# platform without waiting out flush_interval. "Immediate" is this many
|
|
20
|
+
# seconds, not zero: during an exception storm every request would
|
|
21
|
+
# otherwise wake the thread for a handful of records, and a burst that
|
|
22
|
+
# produced 4,000 records went out as 400 POSTs of ten. A lone exception
|
|
23
|
+
# still ships within the window; a storm coalesces into full batches.
|
|
24
|
+
URGENT_FLUSH_DELAY = 0.25
|
|
25
|
+
# Three pressure ticks reach 8x and three clear ticks recover to 1x. That
|
|
26
|
+
# is enough to turn a saturated stream into breathing room without the
|
|
27
|
+
# long recovery and sparse telemetry a 16x cap would impose.
|
|
28
|
+
MAX_BACKPRESSURE_FACTOR = 8.0
|
|
29
|
+
DeliveryBatch = Data.define(:id, :records, :bytes, :dropped, :dropped_bytes, :prepared)
|
|
30
|
+
|
|
31
|
+
class DeliveryError < StandardError
|
|
32
|
+
attr_reader :status, :records, :bytes, :dropped, :dropped_bytes
|
|
33
|
+
|
|
34
|
+
def initialize(message, status: nil, records: 0, bytes: 0, dropped: 0, dropped_bytes: 0)
|
|
35
|
+
@status = status
|
|
36
|
+
@records = records
|
|
37
|
+
@bytes = bytes
|
|
38
|
+
@dropped = dropped
|
|
39
|
+
@dropped_bytes = dropped_bytes
|
|
40
|
+
super(message)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(config, transport: nil, random: Random)
|
|
45
|
+
@config = config
|
|
46
|
+
@buffer = build_buffer
|
|
47
|
+
@transport = transport || Transport::Http.new(config)
|
|
48
|
+
@random = random
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@flush_mutex = Mutex.new
|
|
51
|
+
@wakeup = ConditionVariable.new
|
|
52
|
+
@thread = nil
|
|
53
|
+
@pid = Process.pid
|
|
54
|
+
@stopping = false
|
|
55
|
+
@flush_requested = false
|
|
56
|
+
@urgent_at = nil
|
|
57
|
+
@retry_attempt = 0
|
|
58
|
+
@retry_at = nil
|
|
59
|
+
@retry_batch = nil
|
|
60
|
+
# Ruby ivars hold object references atomically. The reporter is the
|
|
61
|
+
# only writer, and sampler readers can safely tolerate one stale Float,
|
|
62
|
+
# so the hot execution path does not take a mutex for this value.
|
|
63
|
+
@backpressure_factor = 1.0
|
|
64
|
+
@in_flight_records = 0
|
|
65
|
+
@in_flight_dropped = 0
|
|
66
|
+
@in_flight_bytes = 0
|
|
67
|
+
@in_flight_dropped_bytes = 0
|
|
68
|
+
@shutdown_notified = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def buffer
|
|
72
|
+
ensure_process!
|
|
73
|
+
@buffer
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
attr_reader :backpressure_factor
|
|
77
|
+
|
|
78
|
+
def write(record, bytes = nil)
|
|
79
|
+
ensure_process!
|
|
80
|
+
size = @buffer.push(record, bytes)
|
|
81
|
+
arm_thread unless @thread&.alive?
|
|
82
|
+
request_flush if size >= @config.flush_threshold
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Wake the reporter immediately for unhandled exceptions without doing
|
|
86
|
+
# network I/O on the application thread.
|
|
87
|
+
def write_now(record)
|
|
88
|
+
ensure_process!
|
|
89
|
+
size = @buffer.push(record)
|
|
90
|
+
arm_thread unless @thread&.alive?
|
|
91
|
+
# A full buffer flushes now regardless; anything smaller flushes at
|
|
92
|
+
# the end of the urgent window, however many exceptions land in it.
|
|
93
|
+
return request_flush if size >= @config.flush_threshold
|
|
94
|
+
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
@urgent_at ||= Clock.monotonic + URGENT_FLUSH_DELAY
|
|
97
|
+
@wakeup.signal
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def flush
|
|
102
|
+
ensure_process!
|
|
103
|
+
@flush_mutex.synchronize do
|
|
104
|
+
update_backpressure
|
|
105
|
+
deliver_buffer
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ensure_thread
|
|
110
|
+
ensure_process!
|
|
111
|
+
arm_thread unless @thread&.alive?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Only valid in a forked child. It deliberately never acquires an
|
|
115
|
+
# inherited lock: another parent thread may have owned that mutex at the
|
|
116
|
+
# instant of fork, and its owner does not exist in the child.
|
|
117
|
+
def restart_after_fork!
|
|
118
|
+
return if @pid == Process.pid
|
|
119
|
+
|
|
120
|
+
@pid = Process.pid
|
|
121
|
+
@buffer = build_buffer
|
|
122
|
+
@transport = forked_transport
|
|
123
|
+
@mutex = Mutex.new
|
|
124
|
+
@flush_mutex = Mutex.new
|
|
125
|
+
@wakeup = ConditionVariable.new
|
|
126
|
+
@thread = nil
|
|
127
|
+
@stopping = false
|
|
128
|
+
@flush_requested = false
|
|
129
|
+
@urgent_at = nil
|
|
130
|
+
@retry_attempt = 0
|
|
131
|
+
@retry_at = nil
|
|
132
|
+
# Newer reporters retain an immutable delivery batch (including its
|
|
133
|
+
# idempotency key) between attempts. Keep this reset forward-compatible
|
|
134
|
+
# so that batch can never cross a process boundary after fork.
|
|
135
|
+
@retry_batch = nil
|
|
136
|
+
@backpressure_factor = 1.0
|
|
137
|
+
@in_flight_records = 0
|
|
138
|
+
@in_flight_dropped = 0
|
|
139
|
+
@in_flight_bytes = 0
|
|
140
|
+
@in_flight_dropped_bytes = 0
|
|
141
|
+
@shutdown_notified = false
|
|
142
|
+
remove_instance_variable(:@shutdown_deadline) if defined?(@shutdown_deadline)
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def shutdown
|
|
147
|
+
ensure_process!
|
|
148
|
+
ensure_thread if pending_records? && !@thread&.alive?
|
|
149
|
+
thread = @thread
|
|
150
|
+
return unless thread
|
|
151
|
+
|
|
152
|
+
timeout = [ @config.shutdown_timeout.to_f, 0.0 ].max
|
|
153
|
+
deadline = Clock.monotonic + timeout
|
|
154
|
+
@mutex.synchronize do
|
|
155
|
+
@stopping = true
|
|
156
|
+
@shutdown_deadline = deadline
|
|
157
|
+
@wakeup.broadcast
|
|
158
|
+
end
|
|
159
|
+
thread.join(timeout) unless thread == Thread.current
|
|
160
|
+
if thread.alive?
|
|
161
|
+
notify_unsent("shutdown timed out")
|
|
162
|
+
else
|
|
163
|
+
notify_unsent("shutdown completed")
|
|
164
|
+
end
|
|
165
|
+
rescue StandardError => e
|
|
166
|
+
Railwatch.debug { "shutdown flush failed: #{e.class}: #{e.message}" }
|
|
167
|
+
Railwatch.notify_unrecoverable(e)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def ensure_process!
|
|
173
|
+
restart_after_fork! if @pid != Process.pid
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def arm_thread
|
|
177
|
+
@mutex.synchronize do
|
|
178
|
+
return if @thread&.alive?
|
|
179
|
+
|
|
180
|
+
@stopping = false
|
|
181
|
+
@shutdown_notified = false
|
|
182
|
+
@thread = Thread.new { run }
|
|
183
|
+
@thread.name = "railwatch-reporter"
|
|
184
|
+
@thread.abort_on_exception = false
|
|
185
|
+
@thread.report_on_exception = false
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def forked_transport
|
|
190
|
+
transport = @transport.dup
|
|
191
|
+
transport.reset_after_fork! if transport.respond_to?(:reset_after_fork!)
|
|
192
|
+
transport
|
|
193
|
+
rescue TypeError
|
|
194
|
+
@transport.tap { |object| object.reset_after_fork! if object.respond_to?(:reset_after_fork!) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def request_flush
|
|
198
|
+
@mutex.synchronize do
|
|
199
|
+
@flush_requested = true
|
|
200
|
+
@wakeup.signal
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def run
|
|
205
|
+
loop do
|
|
206
|
+
action = wait_for_work
|
|
207
|
+
if action == :shutdown
|
|
208
|
+
flush_for_shutdown
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
begin
|
|
213
|
+
flush
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
Railwatch.debug { "flush error: #{e.class}: #{e.message}" }
|
|
216
|
+
Railwatch.notify_unrecoverable(e)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def wait_for_work
|
|
222
|
+
@mutex.synchronize do
|
|
223
|
+
interval_deadline = Clock.monotonic + @config.flush_interval
|
|
224
|
+
loop do
|
|
225
|
+
return :shutdown if @stopping
|
|
226
|
+
|
|
227
|
+
now = Clock.monotonic
|
|
228
|
+
if @retry_at
|
|
229
|
+
return :flush if now >= @retry_at
|
|
230
|
+
deadline = @retry_at
|
|
231
|
+
else
|
|
232
|
+
if @flush_requested
|
|
233
|
+
@flush_requested = false
|
|
234
|
+
@urgent_at = nil
|
|
235
|
+
return :flush
|
|
236
|
+
end
|
|
237
|
+
if @urgent_at && now >= @urgent_at
|
|
238
|
+
@urgent_at = nil
|
|
239
|
+
return :flush
|
|
240
|
+
end
|
|
241
|
+
return :flush if now >= interval_deadline
|
|
242
|
+
deadline = [ interval_deadline, @urgent_at ].compact.min
|
|
243
|
+
end
|
|
244
|
+
@wakeup.wait(@mutex, [ deadline - now, 0.0 ].max)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def deliver_buffer
|
|
250
|
+
# A 401 was reported once, when the transport first saw it; after
|
|
251
|
+
# that the token is wrong until the process restarts, and repeating
|
|
252
|
+
# the callback every flush would be a self-sustaining error source in
|
|
253
|
+
# an app that turns on_unrecoverable into an error report.
|
|
254
|
+
return discard_unauthorized if @transport.respond_to?(:unauthorized?) && @transport.unauthorized?
|
|
255
|
+
|
|
256
|
+
batch = drain_into_flight
|
|
257
|
+
if batch.records.empty?
|
|
258
|
+
delivery_succeeded
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
unless batch.prepared
|
|
263
|
+
deliverable = Railwatch.run_before_ingest(batch.records)
|
|
264
|
+
if deliverable.empty?
|
|
265
|
+
@buffer.account_dropped(batch.dropped, bytes: batch.dropped_bytes) if batch.dropped.positive?
|
|
266
|
+
delivery_succeeded
|
|
267
|
+
return
|
|
268
|
+
end
|
|
269
|
+
# A before_ingest hook can rewrite records, so their measured weights
|
|
270
|
+
# no longer describe them. Re-bound only then; without hooks the
|
|
271
|
+
# batch is already inside batch_bytes from drain_into_flight.
|
|
272
|
+
batch = if deliverable.equal?(batch.records)
|
|
273
|
+
DeliveryBatch.new(**batch.to_h, prepared: true)
|
|
274
|
+
else
|
|
275
|
+
rebound(batch, deliverable)
|
|
276
|
+
end
|
|
277
|
+
if batch.records.empty?
|
|
278
|
+
@buffer.account_dropped(batch.dropped, bytes: batch.dropped_bytes)
|
|
279
|
+
delivery_succeeded
|
|
280
|
+
return
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
result = deliver(batch)
|
|
285
|
+
Railwatch.debug do
|
|
286
|
+
"flushed #{batch.records.size} records/#{batch.bytes} bytes " \
|
|
287
|
+
"(dropped #{batch.dropped}/#{batch.dropped_bytes} bytes): #{result.to_h}"
|
|
288
|
+
end
|
|
289
|
+
if result.ok
|
|
290
|
+
# Per-record rejection is routine and documented (an unsupported
|
|
291
|
+
# record kind, a record the environment does not retain). Cloud's
|
|
292
|
+
# ingest batch is the authoritative accounting for it; re-reporting it
|
|
293
|
+
# through on_unrecoverable would page an operator for normal traffic.
|
|
294
|
+
Railwatch.debug { "ingest rejected #{result.rejected} of #{deliverable.size} records" } if result.rejected.to_i.positive?
|
|
295
|
+
delivery_succeeded
|
|
296
|
+
elsif retryable?(result)
|
|
297
|
+
retain(batch, result)
|
|
298
|
+
else
|
|
299
|
+
delivery_rejected(batch, result)
|
|
300
|
+
end
|
|
301
|
+
result
|
|
302
|
+
rescue StandardError => e
|
|
303
|
+
result = Transport::Http::Result.new(ok: false, error: "#{e.class}: #{e.message}")
|
|
304
|
+
batch&.records&.any? ? retain(batch, result) : delivery_succeeded
|
|
305
|
+
Railwatch.notify_unrecoverable(e)
|
|
306
|
+
result
|
|
307
|
+
ensure
|
|
308
|
+
in_flight(0, 0, 0, 0)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def retryable?(result)
|
|
312
|
+
return result.retryable? if result.respond_to?(:retryable?)
|
|
313
|
+
|
|
314
|
+
!result.ok && Transport::Http.retryable_status?(result.status)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def retain(batch, result)
|
|
318
|
+
@mutex.synchronize do
|
|
319
|
+
@in_flight_records = 0
|
|
320
|
+
@in_flight_dropped = 0
|
|
321
|
+
@in_flight_bytes = 0
|
|
322
|
+
@in_flight_dropped_bytes = 0
|
|
323
|
+
@retry_attempt += 1
|
|
324
|
+
if @retry_attempt > MAX_RETRY_ATTEMPTS
|
|
325
|
+
@buffer.account_dropped(batch.records.size + batch.dropped,
|
|
326
|
+
bytes: batch.bytes + batch.dropped_bytes)
|
|
327
|
+
@retry_attempt = 0
|
|
328
|
+
@retry_at = nil
|
|
329
|
+
Railwatch.debug { "gave up on a batch of #{batch.records.size} records after #{MAX_RETRY_ATTEMPTS} retries (#{result.error || result.status}); dropped and counted" }
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
@retry_batch = batch
|
|
333
|
+
delay = retry_delay(@retry_attempt)
|
|
334
|
+
@retry_at = Clock.monotonic + delay
|
|
335
|
+
@wakeup.signal
|
|
336
|
+
Railwatch.debug do
|
|
337
|
+
"retained #{batch.records.size} records after retryable delivery failure " \
|
|
338
|
+
"(#{result.error || result.status}); retry #{@retry_attempt} in #{delay.round(3)}s"
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def discard_unauthorized
|
|
344
|
+
batch = drain_into_flight
|
|
345
|
+
delivery_succeeded
|
|
346
|
+
Railwatch.debug { "transport unauthorized; discarded #{batch.records.size} records (dropped #{batch.dropped})" } unless batch.records.empty?
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def delivery_succeeded
|
|
351
|
+
@mutex.synchronize do
|
|
352
|
+
@in_flight_records = 0
|
|
353
|
+
@in_flight_dropped = 0
|
|
354
|
+
@in_flight_bytes = 0
|
|
355
|
+
@in_flight_dropped_bytes = 0
|
|
356
|
+
@retry_attempt = 0
|
|
357
|
+
@retry_at = nil
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def delivery_rejected(batch, result)
|
|
362
|
+
delivery_succeeded
|
|
363
|
+
detail = result.error.to_s.empty? ? "HTTP #{result.status}" : result.error
|
|
364
|
+
Railwatch.notify_unrecoverable(
|
|
365
|
+
DeliveryError.new("Railwatch ingest permanently rejected #{batch.records.size} records: #{detail}",
|
|
366
|
+
status: result.status, records: batch.records.size, bytes: batch.bytes,
|
|
367
|
+
dropped: batch.dropped, dropped_bytes: batch.dropped_bytes)
|
|
368
|
+
)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Equal jitter keeps a non-zero floor (no busy loop) while spreading
|
|
372
|
+
# reporters between 50% and 100% of each exponential window.
|
|
373
|
+
def retry_delay(attempt)
|
|
374
|
+
exponent = [ attempt - 1, 6 ].min
|
|
375
|
+
ceiling = [ INITIAL_RETRY_DELAY * (2**exponent), MAX_RETRY_DELAY ].min
|
|
376
|
+
ceiling * (0.5 + @random.rand * 0.5)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def drain_into_flight
|
|
380
|
+
batch = @mutex.synchronize do
|
|
381
|
+
retry_batch = @retry_batch
|
|
382
|
+
@retry_batch = nil
|
|
383
|
+
next drained_batch unless retry_batch
|
|
384
|
+
|
|
385
|
+
retry_batch
|
|
386
|
+
end
|
|
387
|
+
in_flight(batch.records.size, batch.dropped, batch.bytes, batch.dropped_bytes)
|
|
388
|
+
batch
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Called with the mutex held. One delivery is capped at batch_bytes while
|
|
392
|
+
# the queue holds up to buffer_bytes, so a full queue can be more than one
|
|
393
|
+
# POST. The split uses the weights measured when the records were pushed
|
|
394
|
+
# -- nothing is weighed twice -- and the tail goes back on the queue for
|
|
395
|
+
# the next flush instead of being dropped.
|
|
396
|
+
def drained_batch
|
|
397
|
+
records, dropped, dropped_bytes, sizes = @buffer.drain
|
|
398
|
+
cut = records.size
|
|
399
|
+
bytes = 0
|
|
400
|
+
sizes.each_with_index do |size, index|
|
|
401
|
+
# index.positive? so a single record heavier than batch_bytes still
|
|
402
|
+
# goes out on its own rather than deferring forever; the transport
|
|
403
|
+
# drops it there, once, and counts it.
|
|
404
|
+
if index.positive? && bytes + size > @config.batch_bytes
|
|
405
|
+
cut = index
|
|
406
|
+
break
|
|
407
|
+
end
|
|
408
|
+
bytes += size
|
|
409
|
+
end
|
|
410
|
+
if cut < records.size
|
|
411
|
+
@buffer.restore(records[cut..], sizes[cut..])
|
|
412
|
+
records = records[0, cut]
|
|
413
|
+
end
|
|
414
|
+
DeliveryBatch.new(id: SecureRandom.uuid, records: records, bytes: bytes,
|
|
415
|
+
dropped: dropped, dropped_bytes: dropped_bytes, prepared: false)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# A before_ingest hook returned different records; weigh them again and
|
|
419
|
+
# drop whatever no longer fits in one delivery.
|
|
420
|
+
def rebound(batch, deliverable)
|
|
421
|
+
kept = []
|
|
422
|
+
bytes = 0
|
|
423
|
+
dropped = 0
|
|
424
|
+
dropped_bytes = 0
|
|
425
|
+
deliverable.each do |record|
|
|
426
|
+
record_bytes = Record.buffered_bytes(record, limit: @config.batch_bytes)
|
|
427
|
+
if kept.any? && bytes + record_bytes > @config.batch_bytes || record_bytes > @config.batch_bytes
|
|
428
|
+
dropped += 1
|
|
429
|
+
dropped_bytes += record_bytes
|
|
430
|
+
else
|
|
431
|
+
kept << record
|
|
432
|
+
bytes += record_bytes
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
DeliveryBatch.new(id: batch.id, records: kept, bytes: bytes, dropped: batch.dropped + dropped,
|
|
436
|
+
dropped_bytes: batch.dropped_bytes + dropped_bytes, prepared: true)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Third-party/test transports written before batch idempotency only accept
|
|
440
|
+
# `dropped:`. Keep those working while the HTTP transport receives the
|
|
441
|
+
# stable identity required to replay a request safely.
|
|
442
|
+
def deliver(batch)
|
|
443
|
+
parameters = @transport.method(:deliver).parameters
|
|
444
|
+
accepts_batch_id = parameters.any? { |kind, name| kind == :keyrest || name == :batch_id }
|
|
445
|
+
accepts_dropped_bytes = parameters.any? { |kind, name| kind == :keyrest || name == :dropped_bytes }
|
|
446
|
+
accepts_backpressure = parameters.any? { |kind, name| kind == :keyrest || name == :backpressure_factor }
|
|
447
|
+
keywords = { dropped: batch.dropped }
|
|
448
|
+
keywords[:batch_id] = batch.id if accepts_batch_id
|
|
449
|
+
keywords[:dropped_bytes] = batch.dropped_bytes if accepts_dropped_bytes
|
|
450
|
+
keywords[:backpressure_factor] = @backpressure_factor if accepts_backpressure
|
|
451
|
+
@transport.deliver(batch.records, **keywords)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def update_backpressure
|
|
455
|
+
unless @config.backpressure
|
|
456
|
+
@backpressure_factor = 1.0
|
|
457
|
+
return
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
buffered, _, buffered_bytes, = @buffer.stats
|
|
461
|
+
high_water = @config.backpressure_high_water
|
|
462
|
+
pressured = buffered >= @config.buffer_size * high_water ||
|
|
463
|
+
buffered_bytes >= @config.buffer_bytes * high_water || @retry_attempt.positive?
|
|
464
|
+
@backpressure_factor = if pressured
|
|
465
|
+
[ @backpressure_factor * 2.0, MAX_BACKPRESSURE_FACTOR ].min
|
|
466
|
+
else
|
|
467
|
+
[ @backpressure_factor / 2.0, 1.0 ].max
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def in_flight(records, dropped, bytes, dropped_bytes)
|
|
472
|
+
@mutex.synchronize do
|
|
473
|
+
@in_flight_records = records
|
|
474
|
+
@in_flight_dropped = dropped
|
|
475
|
+
@in_flight_bytes = bytes
|
|
476
|
+
@in_flight_dropped_bytes = dropped_bytes
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def build_buffer
|
|
481
|
+
Buffer.new(@config.buffer_size, byte_capacity: @config.buffer_bytes)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def flush_for_shutdown
|
|
485
|
+
deadline = @mutex.synchronize { @shutdown_deadline }
|
|
486
|
+
loop do
|
|
487
|
+
flush if pending_records?
|
|
488
|
+
break unless pending_records?
|
|
489
|
+
|
|
490
|
+
now = Clock.monotonic
|
|
491
|
+
break if now >= deadline
|
|
492
|
+
|
|
493
|
+
retry_at = @mutex.synchronize { @retry_at }
|
|
494
|
+
wait_until([ retry_at || now, deadline ].min)
|
|
495
|
+
end
|
|
496
|
+
notify_unsent("shutdown deadline expired") if pending_records?
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def wait_until(deadline)
|
|
500
|
+
@mutex.synchronize do
|
|
501
|
+
while (remaining = deadline - Clock.monotonic).positive?
|
|
502
|
+
@wakeup.wait(@mutex, remaining)
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def notify_unsent(reason)
|
|
508
|
+
records, dropped, bytes, dropped_bytes = pending_delivery
|
|
509
|
+
return if records.zero?
|
|
510
|
+
|
|
511
|
+
should_notify = @mutex.synchronize do
|
|
512
|
+
next false if @shutdown_notified
|
|
513
|
+
@shutdown_notified = true
|
|
514
|
+
end
|
|
515
|
+
return unless should_notify
|
|
516
|
+
|
|
517
|
+
Railwatch.notify_unrecoverable(
|
|
518
|
+
DeliveryError.new("Railwatch #{reason} with #{records} unsent records retained in memory (#{bytes} bytes)",
|
|
519
|
+
records: records, bytes: bytes, dropped: dropped, dropped_bytes: dropped_bytes)
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def pending_delivery
|
|
524
|
+
@mutex.synchronize do
|
|
525
|
+
buffered, dropped, buffered_bytes, buffer_dropped_bytes = @buffer.stats
|
|
526
|
+
retry_records = @retry_batch&.records&.size || 0
|
|
527
|
+
retry_dropped = @retry_batch&.dropped || 0
|
|
528
|
+
[ buffered + retry_records + @in_flight_records,
|
|
529
|
+
dropped + retry_dropped + @in_flight_dropped,
|
|
530
|
+
buffered_bytes + (@retry_batch&.bytes || 0) + @in_flight_bytes,
|
|
531
|
+
buffer_dropped_bytes + (@retry_batch&.dropped_bytes || 0) + @in_flight_dropped_bytes ]
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def pending_records?
|
|
536
|
+
pending_delivery.first.positive?
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/matchers"
|
|
4
|
+
require "railwatch/spec_helper"
|
|
5
|
+
|
|
6
|
+
# Block matchers over the records a block of code produces, so a spec can
|
|
7
|
+
# assert on query counts, N+1s, spans, exceptions, and outgoing HTTP the same
|
|
8
|
+
# way it asserts on anything else. Add to spec/rails_helper.rb:
|
|
9
|
+
#
|
|
10
|
+
# require "railwatch/rspec"
|
|
11
|
+
#
|
|
12
|
+
# They work in a request spec (`expect { get "/widgets" }`) and in a plain
|
|
13
|
+
# model or service spec alike -- see docs/testing.md.
|
|
14
|
+
RSpec::Matchers.define :have_railwatch_queries do |bounds|
|
|
15
|
+
supports_block_expectations
|
|
16
|
+
|
|
17
|
+
match do |block|
|
|
18
|
+
@queries = railwatch_capture(&block).select { |r| r[:t] == "query" }
|
|
19
|
+
Railwatch::SpecHelper.count_satisfied?(@queries.size, **bounds)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
description { "run #{Railwatch::SpecHelper.bound_description(**bounds)} database queries" }
|
|
23
|
+
|
|
24
|
+
failure_message do
|
|
25
|
+
"expected the block to run #{Railwatch::SpecHelper.bound_description(**bounds)} database queries, " \
|
|
26
|
+
"but it ran #{@queries.size}:#{Railwatch::SpecHelper.sql_lines(@queries)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
failure_message_when_negated do
|
|
30
|
+
"expected the block not to run #{Railwatch::SpecHelper.bound_description(**bounds)} database queries, " \
|
|
31
|
+
"but it ran #{@queries.size}:#{Railwatch::SpecHelper.sql_lines(@queries)}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RSpec::Matchers.define :have_railwatch_n_plus_one do
|
|
36
|
+
supports_block_expectations
|
|
37
|
+
|
|
38
|
+
match do |block|
|
|
39
|
+
@n_plus_ones = railwatch_capture(&block).select { |r| r[:t] == "n_plus_one" }
|
|
40
|
+
@n_plus_ones.any?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
description { "trip Railwatch's N+1 detector" }
|
|
44
|
+
|
|
45
|
+
failure_message do
|
|
46
|
+
"expected the block to trip Railwatch's N+1 detector (config.n_plus_one_threshold = " \
|
|
47
|
+
"#{Railwatch.config.n_plus_one_threshold}), but no n_plus_one record was produced"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
failure_message_when_negated do
|
|
51
|
+
"expected no N+1 queries, but #{@n_plus_ones.size} were " \
|
|
52
|
+
"detected:#{Railwatch::SpecHelper.n_plus_one_lines(@n_plus_ones)}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
RSpec::Matchers.define :record_railwatch_span do |name|
|
|
57
|
+
supports_block_expectations
|
|
58
|
+
|
|
59
|
+
match do |block|
|
|
60
|
+
@spans = railwatch_capture(&block).select { |r| r[:t] == "span" }
|
|
61
|
+
name ? @spans.any? { |s| s[:name] == name } : @spans.any?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
description { name ? "record a #{name.inspect} span" : "record a span" }
|
|
65
|
+
|
|
66
|
+
failure_message do
|
|
67
|
+
"expected the block to record a #{name ? name.inspect : ''} span, but it recorded " \
|
|
68
|
+
"#{@spans.size}: #{Railwatch::SpecHelper.record_names(@spans, :name).inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
failure_message_when_negated do
|
|
72
|
+
"expected the block not to record a #{name ? name.inspect : ''} span, but it recorded " \
|
|
73
|
+
"#{@spans.size}: #{Railwatch::SpecHelper.record_names(@spans, :name).inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
RSpec::Matchers.define :record_railwatch_exception do |klass|
|
|
78
|
+
supports_block_expectations
|
|
79
|
+
|
|
80
|
+
match do |block|
|
|
81
|
+
@exceptions = railwatch_capture(&block).select { |r| r[:t] == "exception" }
|
|
82
|
+
klass ? @exceptions.any? { |e| e[:class] == klass.to_s } : @exceptions.any?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
description { "record #{klass ? "a #{klass}" : 'an'} exception" }
|
|
86
|
+
|
|
87
|
+
failure_message do
|
|
88
|
+
"expected the block to record #{klass ? "a #{klass}" : 'an'} exception, but it recorded " \
|
|
89
|
+
"#{@exceptions.size}:#{Railwatch::SpecHelper.exception_lines(@exceptions)}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
failure_message_when_negated do
|
|
93
|
+
"expected the block not to record #{klass ? "a #{klass}" : 'an'} exception, but it recorded " \
|
|
94
|
+
"#{@exceptions.size}:#{Railwatch::SpecHelper.exception_lines(@exceptions)}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The negative half of record_railwatch_exception, for "this block must not
|
|
99
|
+
# report anything at all" -- `expect { }.not_to record_railwatch_exceptions`.
|
|
100
|
+
RSpec::Matchers.define :record_railwatch_exceptions do
|
|
101
|
+
supports_block_expectations
|
|
102
|
+
|
|
103
|
+
match do |block|
|
|
104
|
+
@exceptions = railwatch_capture(&block).select { |r| r[:t] == "exception" }
|
|
105
|
+
@exceptions.any?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
description { "record at least one exception" }
|
|
109
|
+
|
|
110
|
+
failure_message { "expected the block to record an exception, but it recorded none" }
|
|
111
|
+
|
|
112
|
+
failure_message_when_negated do
|
|
113
|
+
"expected the block to record no exceptions, but it recorded " \
|
|
114
|
+
"#{@exceptions.size}:#{Railwatch::SpecHelper.exception_lines(@exceptions)}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
RSpec::Matchers.define :have_railwatch_outgoing_requests do |bounds|
|
|
119
|
+
supports_block_expectations
|
|
120
|
+
|
|
121
|
+
match do |block|
|
|
122
|
+
@outgoing = railwatch_capture(&block).select { |r| r[:t] == "outgoing_request" }
|
|
123
|
+
Railwatch::SpecHelper.count_satisfied?(@outgoing.size, **bounds)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
description { "make #{Railwatch::SpecHelper.bound_description(**bounds)} outgoing HTTP requests" }
|
|
127
|
+
|
|
128
|
+
failure_message do
|
|
129
|
+
"expected the block to make #{Railwatch::SpecHelper.bound_description(**bounds)} outgoing HTTP requests, " \
|
|
130
|
+
"but it made #{@outgoing.size}:#{Railwatch::SpecHelper.outgoing_lines(@outgoing)}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
failure_message_when_negated do
|
|
134
|
+
"expected the block not to make #{Railwatch::SpecHelper.bound_description(**bounds)} outgoing HTTP requests, " \
|
|
135
|
+
"but it made #{@outgoing.size}:#{Railwatch::SpecHelper.outgoing_lines(@outgoing)}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
RSpec.configure { |config| config.include Railwatch::SpecHelper } if defined?(RSpec.configure)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Sampling is decided once per execution. Sampled-in ships the whole tree.
|
|
5
|
+
# Unhandled exceptions in sampled-out executions still ship (governed by the
|
|
6
|
+
# exceptions rate), which is exactly Nightwatch's behaviour.
|
|
7
|
+
module Sampler
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def decide(kind)
|
|
11
|
+
rate = Railwatch.config.sample_rate(kind) / Railwatch.reporter.backpressure_factor
|
|
12
|
+
return true if rate >= 1.0
|
|
13
|
+
return false if rate <= 0.0
|
|
14
|
+
Random.rand < rate
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|