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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. 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