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
data/lib/railwatch.rb ADDED
@@ -0,0 +1,550 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest"
5
+ require "securerandom"
6
+ require "set"
7
+ require "uri"
8
+ require "zlib"
9
+ require "active_support"
10
+ require "active_support/core_ext/object/blank"
11
+ require "active_support/isolated_execution_state"
12
+ require "active_support/parameter_filter"
13
+
14
+ require "railwatch/version"
15
+ require "railwatch/clock"
16
+ require "railwatch/release_detector"
17
+ require "railwatch/configuration"
18
+ require "railwatch/secret_safety"
19
+ require "railwatch/execution"
20
+ require "railwatch/current"
21
+ require "railwatch/record"
22
+ require "railwatch/buffer"
23
+ require "railwatch/transport/http"
24
+ require "railwatch/reporter"
25
+ require "railwatch/sampler"
26
+ require "railwatch/redactor"
27
+ require "railwatch/sql_normalizer"
28
+ require "railwatch/backtrace"
29
+ require "railwatch/context"
30
+ require "railwatch/profiler"
31
+ require "railwatch/attachments"
32
+ # Railwatch::Faraday subclasses ::Faraday::Middleware at load time, so it can't
33
+ # be required here: `gemspec` puts this gem in the Gemfile's :default group,
34
+ # and Bundler.require(*Rails.groups) requires gems in Gemfile declaration
35
+ # order, so railwatch itself is often required *before* an app's own `gem
36
+ # "faraday"` line further down the Gemfile. lib/railwatch/engine.rb requires it
37
+ # instead, from a Rails initializer, which always runs after Bundler.require
38
+ # has finished loading every gem.
39
+
40
+ # Public API. Mirrors the Laravel Nightwatch facade: user, sample, dontSample,
41
+ # ignore, pause, resume, report, redact*, reject*, plus context and deploy.
42
+ module Railwatch
43
+ # Monotonic clock reading taken the moment this file loads, i.e. as early in
44
+ # process boot as Railwatch can observe. `process` records measure boot_seconds
45
+ # from here, not from an unset global.
46
+ BOOTED_AT = Clock.monotonic
47
+
48
+ class << self
49
+ def config
50
+ @config ||= Configuration.new
51
+ end
52
+
53
+ def configure
54
+ yield config
55
+ config
56
+ end
57
+
58
+ def reporter
59
+ @reporter ||= Reporter.new(config)
60
+ end
61
+
62
+ def redactor
63
+ @redactor ||= Redactor.new(config)
64
+ end
65
+
66
+ def enabled?
67
+ config.enabled?
68
+ end
69
+
70
+ # Reset for tests and after reconfiguration.
71
+ def reset!
72
+ @reporter&.shutdown
73
+ @config = nil
74
+ @reporter = nil
75
+ @redactor = nil
76
+ end
77
+
78
+ # Runs in every forked child (ActiveSupport::ForkTracker, registered by
79
+ # the engine). The reporter is reset before the child's own process
80
+ # record is written, so nothing inherited from the parent can be flushed
81
+ # alongside it; the health sampler and session flusher restart last, so
82
+ # they emit into the child's reporter, not the parent's.
83
+ def restart_after_fork!
84
+ Profiler.restart_after_fork!
85
+ @reporter&.restart_after_fork!
86
+ Subscribers::Users.restart_after_fork!
87
+ Subscribers::ProcessInfo.restart_after_fork!
88
+ Health.restart_after_fork!
89
+ Sessions.restart_after_fork!
90
+ end
91
+
92
+ # --- execution lifecycle -------------------------------------------------
93
+
94
+ def execution
95
+ Current.execution
96
+ end
97
+
98
+ def start_execution(source:, sample_kind: source, trace_id: nil, parent_id: nil, preview: nil)
99
+ exe = Execution.new(source: source, sampled: Sampler.decide(sample_kind),
100
+ trace_id: trace_id, parent_id: parent_id, preview: preview)
101
+ exe.tenant = Context.current_tenant
102
+ # A job (or command) can run inline, nested inside a request's own
103
+ # execution -- e.g. ActiveJob::TestHelper's inline test adapter, or a
104
+ # controller action that calls perform_now. Remembering the execution
105
+ # this one is nested inside lets finish_execution restore it instead of
106
+ # clearing the thread-local outright and losing the outer parent.
107
+ exe.parent_execution = Current.execution
108
+ Current.execution = exe
109
+ # Profiling is off by default, and then this costs one Float
110
+ # comparison per execution: the rest sits behind the short circuit,
111
+ # and tail_buffering? is a bare ivar read the Execution already made.
112
+ start_profile(exe) if config.profile_sample > 0.0 || (exe.tail_buffering? && config.profile_slow_ms)
113
+ exe
114
+ end
115
+
116
+ PARENT_TYPES = %i[request job_attempt scheduled_task command channel_action].freeze
117
+
118
+ # Ends the execution. A sampled-in execution ships all of its buffered
119
+ # child records plus the parent; a sampled-out one ships only the parent,
120
+ # and only if it raised (so every unhandled exception has a parent) --
121
+ # unless the tail decision (tail_keep?) rescues the whole tree, which is
122
+ # also how a failure-context ring is promoted.
123
+ #
124
+ # The parent's fields may be given as a block instead of keywords. It is
125
+ # called only when a parent is actually going to ship, so a caller whose
126
+ # fields are expensive to assemble (the request middleware: an
127
+ # ActionDispatch::Request, a header walk) skips that work for a
128
+ # head-sampled-out execution nothing rescued.
129
+ def finish_execution(parent_type = nil, group: nil, **fields)
130
+ exe = Current.execution
131
+ return Current.clear unless exe
132
+
133
+ exe.capture_memory
134
+ tail = !exe.sampled? && tail_keep?(exe)
135
+ shipping = exe.sampled? || tail
136
+ # The profile is a child record of this execution, so it has to be
137
+ # buffered before the parent is built and the tree is shipped. Stopped
138
+ # either way: a profiler left running would outlive the execution.
139
+ profiled = exe.profiler_handle && ship_profile(exe, shipping)
140
+ parent = nil
141
+ if parent_type && (shipping || exe.exception_sampled)
142
+ fields.merge!(yield) if block_given?
143
+ group ||= fields.delete(:group)
144
+ fields[:tail_sampled] = true if tail
145
+ fields[:profiled] = true if profiled
146
+ parent = build_parent(parent_type, exe, group: group, **fields)
147
+ end
148
+ if shipping
149
+ # Before the records are written: it settles each pending `user`
150
+ # entity's final reference, which a tenant bound after the entity was
151
+ # resolved will have changed.
152
+ Subscribers::Users.commit_execution!(exe) if exe.pending_users
153
+ exe.each_record { |record, bytes| reporter.write(record, bytes) }
154
+ if exe.dropped_records.positive?
155
+ reporter.buffer.account_dropped(exe.dropped_records, bytes: exe.dropped_bytes)
156
+ end
157
+ reporter.write(parent) if parent
158
+ elsif parent
159
+ # Head-sampled out, but an unhandled exception rolled the exceptions
160
+ # sample in: the exception needs its parent, and only its parent.
161
+ reporter.write(parent)
162
+ end
163
+ parent
164
+ ensure
165
+ # Restores the execution this one was nested inside (nil at the
166
+ # outermost level, which behaves the same as the old Current.clear).
167
+ Current.execution = exe&.parent_execution
168
+ end
169
+
170
+ # --- record writing --------------------------------------------------------
171
+
172
+ # Records that make sense without a parent execution (console, boot).
173
+ # Every other type is dropped when nothing is executing, matching Nightwatch.
174
+ STANDALONE_TYPES = %i[process user visit exception health attachment session].freeze
175
+
176
+ # Low-level write for a record hash the caller already built (hot-path
177
+ # subscribers assemble one hash literal instead of packing kwargs, then
178
+ # hand it here). Same enabled/ignored/recording/redact checks as record.
179
+ def push(type, rec)
180
+ return unless enabled?
181
+
182
+ exe = Current.execution
183
+ return unless recordable?(type, exe)
184
+
185
+ finish(type, rec, exe)
186
+ end
187
+
188
+ # Write a child record for the current execution. Silently no-ops when
189
+ # disabled, sampled out, paused, or the type is ignored.
190
+ def record(type, group: nil, timestamp: nil, **fields)
191
+ return unless enabled?
192
+
193
+ exe = Current.execution
194
+ return unless recordable?(type, exe)
195
+
196
+ finish(type, Record.build(type, exe, group: group, timestamp: timestamp, **fields), exe)
197
+ end
198
+
199
+ def build_parent(type, exe, group: nil, **fields)
200
+ rec = Record.build(type, exe, group: group, timestamp: exe.started_at,
201
+ duration: exe.duration, stages: exe.stage_durations.compact.transform_keys(&:to_s),
202
+ counters: exe.counters, peak_memory: exe.peak_memory, allocations: exe.allocations,
203
+ gc_time: exe.gc_time, exception_preview: exe.exception_preview,
204
+ context: Context.serialized, **fields)
205
+ run_redactors(type, rec)
206
+ end
207
+
208
+ def record_now(type, group: nil, **fields)
209
+ return unless enabled?
210
+
211
+ rec = Record.build(type, Current.execution, group: group, **fields)
212
+ rec = run_redactors(type, rec) or return
213
+ reporter.write_now(rec)
214
+ rec
215
+ end
216
+
217
+ # --- sampling / ignoring ---------------------------------------------------
218
+
219
+ def sample(rate = 1.0)
220
+ exe = Current.execution or return
221
+ exe.sampled = rate.to_f >= 1.0 || (rate.to_f > 0.0 && Random.rand < rate.to_f)
222
+ end
223
+
224
+ def dont_sample
225
+ Current.execution&.sampled = false
226
+ end
227
+
228
+ # Tail sampling: keep this execution (and everything buffered for it)
229
+ # whatever the head decision was. Records made before this call were only
230
+ # buffered if tail sampling was already on for the execution.
231
+ def keep!
232
+ Current.execution&.keep!
233
+ end
234
+
235
+ def sampling?
236
+ Current.execution&.sampled? || false
237
+ end
238
+
239
+ def ignore
240
+ pause
241
+ yield
242
+ ensure
243
+ resume
244
+ end
245
+
246
+ def pause
247
+ Current.execution&.paused_depth += 1
248
+ end
249
+
250
+ def resume
251
+ exe = Current.execution
252
+ exe.paused_depth -= 1 if exe && exe.paused_depth.positive?
253
+ end
254
+
255
+ def paused?
256
+ Current.execution&.paused? || false
257
+ end
258
+
259
+ # --- errors ----------------------------------------------------------------
260
+
261
+ def report(error, handled: true, severity: nil, context: {}, attachments: nil, fingerprint: nil)
262
+ rec = Subscribers::Exceptions.capture(error, handled: handled, severity: severity || (handled ? :warning : :error),
263
+ context: context, source: "railwatch.manual", fingerprint: fingerprint)
264
+ attachments&.each { |name, data| Attachments.attach(name, data, exception: error) }
265
+ rec
266
+ end
267
+
268
+ def attach(...) = Attachments.attach(...)
269
+
270
+ # --- context / user --------------------------------------------------------
271
+
272
+ def context(**attrs)
273
+ Context.set(**attrs)
274
+ end
275
+
276
+ def user(&block)
277
+ config.user(&block)
278
+ end
279
+
280
+ def fingerprint(&block)
281
+ config.fingerprint(&block)
282
+ end
283
+
284
+ # --- redaction / rejection -------------------------------------------------
285
+
286
+ %i[requests queries exceptions cache_events commands mail outgoing_requests logs].each do |type|
287
+ define_method(:"redact_#{type}") { |&blk| config.redactors[type] << blk }
288
+ end
289
+
290
+ %i[queries cache_events mail notifications broadcasts outgoing_requests enqueued_jobs logs].each do |type|
291
+ define_method(:"reject_#{type}") { |&blk| config.rejectors[type] << blk }
292
+ end
293
+
294
+ def reject_cache_keys(prefixes)
295
+ config.ignored_cache_key_prefixes.concat(Array(prefixes))
296
+ end
297
+
298
+ # --- self-monitoring --------------------------------------------------------
299
+
300
+ def on_unrecoverable(&block)
301
+ config.on_unrecoverable = block
302
+ end
303
+
304
+ # Called (rescued) whenever the gem itself rescues an internal exception:
305
+ # a subscriber block raising, or delivery failing after its retry.
306
+ # Falls back to the debug log when no callback is registered.
307
+ def notify_unrecoverable(error)
308
+ if config.on_unrecoverable
309
+ config.on_unrecoverable.call(error)
310
+ else
311
+ debug { "unrecoverable internal error: #{error.class}: #{error.message}" }
312
+ end
313
+ rescue StandardError => e
314
+ debug { "on_unrecoverable callback raised #{e.class}: #{e.message}" }
315
+ end
316
+
317
+ # --- outgoing request helper -------------------------------------------------
318
+
319
+ # For HTTP clients without a dedicated patch (e.g. Faraday adapters other
320
+ # than Net::HTTP). Wraps the block, returns its value untouched, and
321
+ # records an outgoing_request child when the result exposes a status.
322
+ def instrument_outgoing(method, url)
323
+ start = Clock.monotonic
324
+ started_at = Clock.now
325
+ result = yield
326
+ if result.respond_to?(:status)
327
+ host = (URI(url.to_s).host rescue nil)
328
+ record(:outgoing_request, group: Record.group_hash(host, method.to_s.upcase),
329
+ timestamp: started_at, host: host, method: method.to_s.upcase,
330
+ url: url.to_s[0, 2048], duration: Clock.micros_since(start), status_code: result.status.to_i)
331
+ end
332
+ result
333
+ end
334
+
335
+ # --- custom spans -------------------------------------------------------
336
+
337
+ SPAN_MAX_ATTRIBUTES = 25
338
+ SPAN_MAX_VALUE = 200
339
+
340
+ # Times an arbitrary block as a `span` child record and returns the
341
+ # block's value. A no-op wrapper (still yields) when Railwatch is disabled
342
+ # or nothing is recording.
343
+ #
344
+ # Railwatch.span("pdf.render", pages: 12) { renderer.call }
345
+ def span(name, **attributes)
346
+ return yield unless enabled?
347
+
348
+ exe = Current.execution
349
+ return yield unless exe&.recording?
350
+
351
+ start = Clock.monotonic
352
+ started_at = Clock.now
353
+ status = "failed"
354
+ begin
355
+ result = yield
356
+ status = "ok"
357
+ result
358
+ ensure
359
+ exe.count(:spans)
360
+ record(:span, group: Record.group_hash(name), timestamp: started_at,
361
+ name: name.to_s[0, 255], duration: Clock.micros_since(start),
362
+ attributes: span_attributes(attributes), status: status)
363
+ end
364
+ end
365
+
366
+ # --- distributed tracing --------------------------------------------------
367
+
368
+ # W3C traceparent for an outgoing request to `host`: nil when propagation
369
+ # is off, no execution is running, or the host isn't on the allow list.
370
+ def traceparent(host)
371
+ return nil unless config.propagate_traces
372
+
373
+ exe = Current.execution
374
+ return nil unless exe && propagate_to?(host)
375
+
376
+ "00-#{exe.trace_id.delete('-')}-#{exe.id.delete('-')[0, 16]}-#{exe.sampled? ? '01' : '00'}"
377
+ end
378
+
379
+ def before_ingest(&block)
380
+ config.before_ingest << block
381
+ end
382
+
383
+ def run_before_ingest(batch)
384
+ config.before_ingest.each do |hook|
385
+ result = hook.call(batch)
386
+ return [] if result == false
387
+ batch = result if result.is_a?(Array)
388
+ end
389
+ batch
390
+ end
391
+
392
+ # --- misc ------------------------------------------------------------------
393
+
394
+ def flush
395
+ reporter.flush
396
+ end
397
+
398
+ # Internal diagnostics, only when RAILWATCH_DEBUG=1. Goes to stderr rather
399
+ # than Rails.logger so it can never be captured as an app log record.
400
+ def debug
401
+ return unless config.debug
402
+ warn("[railwatch] #{yield}")
403
+ end
404
+
405
+ private
406
+
407
+ # Tail decision for a head-sampled-out execution. Only executions that
408
+ # were buffering for the tail can be rescued -- with tail sampling off
409
+ # nothing was buffered, and the exception_sampled path above still ships
410
+ # the lone parent exactly as it did before.
411
+ def tail_keep?(exe)
412
+ return false unless exe.tail_buffering?
413
+ return true if exe.keep
414
+ # A failure-context ring is promoted by one thing only: an unhandled
415
+ # exception this execution actually reported. An execution that
416
+ # completed normally -- or whose exception was ignored, handled,
417
+ # withheld from an interactive runner, raised inside Railwatch.ignore, or
418
+ # lost to the exceptions sample rate -- discards the ring here and
419
+ # ships exactly what it would have shipped without one.
420
+ return exe.exception_reported || false if exe.failure_context?
421
+ return true if exe.exception_sampled
422
+
423
+ slow = config.tail_sample_slow_ms
424
+ !slow.nil? && exe.duration >= slow * 1_000
425
+ end
426
+
427
+ # Starts the process-global sampling profiler for this execution. Only
428
+ # reached when profiling is configured at all (see start_execution), so
429
+ # loading a backend and rolling the dice stay off the default path.
430
+ def start_profile(exe)
431
+ # An app's test suite inherits its RAILWATCH_* environment, and starting
432
+ # a real profile on every example would make that suite crawl, so in
433
+ # the test env profiling is opt-in through an explicitly non-zero
434
+ # profile_sample -- profile_slow_ms alone is not enough.
435
+ return if config.profile_sample <= 0.0 && defined?(Rails) && Rails.env.test?
436
+ return unless Profiler.available?
437
+
438
+ rolled = exe.sampled? && config.profile_sample > 0.0 && Random.rand < config.profile_sample
439
+ # profile_slow_ms can't know an execution is slow until it is over, so
440
+ # it profiles every tail-buffering execution from its first line and
441
+ # throws away the ones that turn out to be fast.
442
+ return unless rolled || (exe.tail_buffering? && config.profile_slow_ms)
443
+
444
+ exe.profile_sampled = rolled
445
+ exe.profiler_handle = Profiler.start
446
+ rescue StandardError => e
447
+ debug { "starting profile failed: #{e.class}: #{e.message}" }
448
+ nil
449
+ end
450
+
451
+ # Stops this execution's profile -- always, since the backend is
452
+ # process-global and must not be left running -- and buffers it as a
453
+ # `profile` child when the tree ships and the profile is one we asked
454
+ # for: any profile when profile_slow_ms is off, otherwise only a slow
455
+ # execution or one the head profile_sample roll picked. Returns whether
456
+ # a record was buffered, which is what puts `profiled` on the parent.
457
+ def ship_profile(exe, ships)
458
+ profile = Profiler.stop
459
+ return false unless profile && ships
460
+
461
+ slow = config.profile_slow_ms
462
+ return false unless slow.nil? || exe.profile_sampled || profile.duration >= slow * 1_000
463
+
464
+ collapsed = profile.collapsed
465
+ !record(:profile, timestamp: exe.started_at, profiler: profile.profiler.to_s,
466
+ mode: profile.mode.to_s, interval: profile.interval, duration: profile.duration,
467
+ samples: profile.samples, stacks_bytes: collapsed.bytesize,
468
+ stacks: Base64.strict_encode64(Zlib.gzip(collapsed))).nil?
469
+ rescue StandardError => e
470
+ debug { "shipping profile failed: #{e.class}: #{e.message}" }
471
+ false
472
+ end
473
+
474
+ # Same treatment exception locals get: stringified, truncated, capped,
475
+ # and run through the app's parameter filter.
476
+ def span_attributes(attributes)
477
+ return nil if attributes.empty?
478
+
479
+ raw = attributes.first(SPAN_MAX_ATTRIBUTES).to_h do |k, v|
480
+ s = v.is_a?(String) ? v : v.inspect
481
+ [ k.to_s, s.length > SPAN_MAX_VALUE ? s[0, SPAN_MAX_VALUE] : s ]
482
+ end
483
+ redactor.params(raw)
484
+ end
485
+
486
+ def propagate_to?(host)
487
+ allowed = config.trace_propagation_hosts
488
+ return true if allowed.nil?
489
+
490
+ host = host.to_s
491
+ allowed.any? { |h| h.start_with?(".") ? host.end_with?(h) : host == h }
492
+ end
493
+
494
+ PLURALS = {
495
+ request: :requests, exception: :exceptions, command: :commands,
496
+ query: :queries, n_plus_one: :queries, transaction: :transactions, cache_event: :cache_events,
497
+ mail: :mail, broadcast: :broadcasts, notification: :notifications, outgoing_request: :outgoing_requests,
498
+ storage_op: :storage_ops, view_render: :view_renders, log: :logs, deprecation: :deprecations,
499
+ session: :sessions
500
+ }.freeze
501
+
502
+ def type_plural(type)
503
+ PLURALS.fetch(type, type)
504
+ end
505
+
506
+ # Shared by push and record: is this type allowed to be written right now?
507
+ def recordable?(type, exe)
508
+ return false if exe.nil? && !STANDALONE_TYPES.include?(type)
509
+ return false if exe && !exe.recording?
510
+ !config.ignored?(type_plural(type))
511
+ end
512
+
513
+ # Shared tail of push and record: redact/reject, then buffer or ship.
514
+ def finish(type, rec, exe)
515
+ unless config.redactors.empty? && config.rejectors.empty?
516
+ rec = run_redactors(type, rec) or return
517
+ return if rejected?(type, rec)
518
+ end
519
+
520
+ exe ? exe.buffer(rec) : reporter.write(rec)
521
+ rec
522
+ end
523
+
524
+ # config.redactors/rejectors default their Hash on write (Railwatch.redact_*)
525
+ # so a registered type gets its own array; #fetch on read means a type
526
+ # with no hooks never triggers that default proc and allocates one.
527
+ EMPTY_HOOKS = [].freeze
528
+
529
+ def run_redactors(type, rec)
530
+ hooks = config.redactors.fetch(type_plural(type), EMPTY_HOOKS)
531
+ return rec if hooks.empty?
532
+ hooks.each { |h| h.call(rec) }
533
+ rec
534
+ rescue StandardError => e
535
+ debug { "redactor for #{type} raised #{e.class}: #{e.message}; dropping record" }
536
+ nil
537
+ end
538
+
539
+ def rejected?(type, rec)
540
+ hooks = config.rejectors.fetch(type_plural(type), EMPTY_HOOKS)
541
+ return false if hooks.empty?
542
+ hooks.any? { |h| h.call(rec) }
543
+ rescue StandardError
544
+ false
545
+ end
546
+ end
547
+ end
548
+
549
+ require "railwatch/subscribers"
550
+ require "railwatch/engine" if defined?(Rails::Engine)