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
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)
|