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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Railwatch
6
+
7
+ config.railwatch = ActiveSupport::OrderedOptions.new
8
+
9
+ # The request middleware goes first so wall time includes every other
10
+ # middleware, exactly like Nightwatch's GlobalMiddleware.
11
+ initializer "railwatch.middleware", before: :load_config_initializers do |app|
12
+ app.middleware.insert_before 0, Railwatch::Middleware::Request
13
+ end
14
+
15
+ # Before anything subscribes or starts a thread, and after the app's own
16
+ # initializer has had its say about config: a console captures nothing.
17
+ initializer "railwatch.console", after: :load_config_initializers, before: "railwatch.subscribe" do
18
+ Railwatch::Console.silence!
19
+ end
20
+
21
+ initializer "railwatch.transport_security", after: :load_config_initializers do
22
+ next if Railwatch.config.ingest_url_allowed?
23
+
24
+ Rails.logger.warn(
25
+ "Railwatch will not send telemetry to #{Railwatch.config.ingest_url}: plain HTTP is allowed only for loopback " \
26
+ "hosts unless RAILWATCH_ALLOW_HTTP=true"
27
+ )
28
+ end
29
+
30
+ # Belt and braces for a console that reaches a prompt some other way than
31
+ # `bin/rails console` (`require "rails/console/app"` then IRB.start, say),
32
+ # where Rails::Console was not yet defined when the initializer above ran.
33
+ # Later, so this one has threads to stop.
34
+ console do
35
+ Railwatch::Console.silence!
36
+ end
37
+
38
+ initializer "railwatch.subscribe", after: :load_config_initializers do |app|
39
+ next unless Railwatch.enabled?
40
+
41
+ Railwatch::Subscribers.install!(app)
42
+ Railwatch::Patches.install!
43
+ end
44
+
45
+ # The process record is written once the app has finished initializing.
46
+ # The after_initialize hook is registered from inside this initializer
47
+ # rather than from the engine's class body (which runs at
48
+ # Bundler.require time, before config/application.rb): hooks run in
49
+ # registration order, so this way it lands after every after_initialize
50
+ # block the app registers from application.rb, its environment files,
51
+ # and config/initializers. boot_seconds covers all of them, and an app
52
+ # that reconfigures Railwatch in its own after_initialize is respected.
53
+ # Each forked child writes its own record from
54
+ # Railwatch.restart_after_fork!.
55
+ initializer "railwatch.process", after: :load_config_initializers do
56
+ config.after_initialize do
57
+ Railwatch::Subscribers::ProcessInfo.record! if Railwatch.enabled?
58
+ end
59
+ end
60
+
61
+ # Not gated on Railwatch.enabled?: a rake process runs load_tasks (from
62
+ # the Rakefile) before initialize!, so a token set in
63
+ # config/initializers is not visible yet at that point. Both patches
64
+ # check Railwatch.enabled? on every call and are inert when it is off.
65
+ rake_tasks do
66
+ Railwatch::Patches.install_rake_task!
67
+ end
68
+
69
+ runner do
70
+ Railwatch::Patches.install_runner_command!
71
+ end
72
+
73
+ # Runs unconditionally (not gated on Railwatch.enabled?) so `Railwatch::Faraday`
74
+ # is a valid constant for apps to reference in their Faraday stack setup
75
+ # regardless of whether Railwatch itself is enabled -- Railwatch.record already
76
+ # no-ops when disabled, so the middleware is inert either way.
77
+ initializer "railwatch.faraday" do
78
+ require "railwatch/faraday" if defined?(::Faraday)
79
+ end
80
+
81
+ initializer "railwatch.active_job" do
82
+ ActiveSupport.on_load(:active_job) { include Railwatch::JobTracing }
83
+ end
84
+
85
+ initializer "railwatch.action_controller" do
86
+ ActiveSupport.on_load(:action_controller) { include Railwatch::ControllerHelpers }
87
+ end
88
+
89
+ initializer "railwatch.shutdown" do
90
+ at_exit { Railwatch.reporter.shutdown if Railwatch.enabled? }
91
+ end
92
+
93
+ # Threads do not survive fork. Rails' own ForkTracker (a Process._fork
94
+ # hook, so it sees fork, Process.fork, and Kernel#fork exactly once per
95
+ # child) runs Railwatch.restart_after_fork! in every Puma cluster worker
96
+ # and Solid Queue forked worker: the reporter first, so nothing
97
+ # inherited from the parent can be flushed, then the health sampler and
98
+ # session flusher. Registered whether or not Railwatch is enabled yet --
99
+ # the check is made at fork time, so an app that enables Railwatch late
100
+ # still gets a clean child -- and never allowed to raise: ForkTracker
101
+ # runs its callbacks in order with no rescue, and one that raised would
102
+ # skip every callback after it, including Active Record's pool reset.
103
+ initializer "railwatch.fork" do
104
+ require "active_support/fork_tracker"
105
+ ActiveSupport::ForkTracker.after_fork do
106
+ Railwatch.restart_after_fork! if Railwatch.enabled?
107
+ rescue StandardError => e
108
+ Railwatch.debug { "fork reset failed: #{e.class}: #{e.message}" }
109
+ end
110
+ end
111
+
112
+ # Declared after "railwatch.shutdown" (initializers run in declaration
113
+ # order) so its at_exit is registered later and therefore runs first
114
+ # (at_exit is LIFO): the health thread is stopped before the reporter's
115
+ # final flush, not after it.
116
+ initializer "railwatch.health" do
117
+ next unless Railwatch.enabled?
118
+
119
+ Railwatch::Health.start!
120
+ at_exit { Railwatch::Health.stop! }
121
+ end
122
+
123
+ # Same shape as "railwatch.health": one flusher thread per web process,
124
+ # stopped before the reporter's final flush.
125
+ initializer "railwatch.sessions" do
126
+ next unless Railwatch.enabled? && Railwatch.config.track_sessions
127
+
128
+ Railwatch::Sessions.start!
129
+ at_exit { Railwatch::Sessions.stop! }
130
+ end
131
+
132
+ # lib/tasks/railwatch_tasks.rake is picked up by Rails::Engine's default
133
+ # lib/tasks convention; the rake_tasks block above only installs the
134
+ # Rake::Task patch.
135
+ end
136
+ end
137
+
138
+ require "railwatch/console"
139
+ require "railwatch/health"
140
+ require "railwatch/sessions"
141
+ require "railwatch/middleware/request"
142
+ require "railwatch/job_tracing"
143
+ require "railwatch/controller_helpers"
144
+ require "railwatch/patches"
@@ -0,0 +1,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Railwatch
6
+ # The parent of every child record: one HTTP request, one job attempt, one
7
+ # scheduled task run, one command, or one Action Cable channel action. Holds
8
+ # the sampling decision, the trace id, the current lifecycle stage, and the
9
+ # counters that end up on the parent record. Never touches the database.
10
+ class Execution
11
+ SOURCES = %i[request job scheduled_task command channel_action].freeze
12
+ MAX_RECORDS = 10_000
13
+ COUNTERS = %i[queries cached_queries exceptions logs cache_events jobs_enqueued mail
14
+ broadcasts notifications outgoing_requests storage_ops view_renders
15
+ transactions hydrated_models lazy_loads deprecations spans].freeze
16
+ # GC.stat with no key builds the whole stat hash; whether this Ruby
17
+ # reports GC time never changes, so ask once.
18
+ GC_TIME_SUPPORTED = GC.stat.key?(:time)
19
+
20
+ attr_reader :source, :id, :trace_id, :parent_id, :started_at, :started_mono, :counters,
21
+ :stages, :stage_durations, :query_groups, :records, :dropped_records,
22
+ :dropped_bytes, :buffered_bytes, :keep
23
+ attr_accessor :sampled, :exception_preview, :paused_depth,
24
+ :peak_memory, :allocations_start, :gc_time_start,
25
+ :queue_latency, :drift, :exception_sampled, :parent_execution
26
+ # Set by Subscribers::Exceptions.capture at the moment it actually writes
27
+ # an unhandled exception for this execution -- not when it rolls the
28
+ # exceptions sample -- so it is the one signal that promotes a
29
+ # failure-context ring. Left uninitialized (nil) like the pairs below:
30
+ # an execution that never fails must not pay a write for one that does.
31
+ attr_accessor :exception_reported
32
+ # Set only when this execution started the process-global sampling
33
+ # profiler (Railwatch.start_profile): the backend handle, plus whether the
34
+ # head profile_sample roll -- rather than profile_slow_ms -- is what
35
+ # chose it. Deliberately not initialized in #initialize: with profiling
36
+ # off, which is the default, an execution must not pay two ivar writes
37
+ # for a feature it isn't using.
38
+ attr_accessor :profiler_handle, :profile_sampled
39
+ # Release health, and only written when config.track_sessions is on:
40
+ # the key of the session this request belongs to (Railwatch::Sessions),
41
+ # and whether an unhandled exception escaped it. Left uninitialized for
42
+ # the same reason as the profiler pair above.
43
+ attr_accessor :session_key, :session_crashed
44
+ # Set only by Railwatch::Patches::RunnerCommand, for a `rails runner` an
45
+ # engineer typed or piped: the execution is still recorded, but its
46
+ # exceptions are a shell session's, not the application's, so nothing
47
+ # reports them (Subscribers::Exceptions.capture). Left uninitialized like
48
+ # the pairs above -- a request must not pay an ivar write for this.
49
+ attr_accessor :interactive
50
+ # Set only by Subscribers::Users, and only for a user this process has
51
+ # not emitted an entity for this hour: the cache key(s) and the `user`
52
+ # record object(s) buffered for them, held until finish_execution says
53
+ # whether this tree actually shipped. Left uninitialized like the pairs
54
+ # above -- the common case (a user already seen this hour, or none at
55
+ # all) must not pay an ivar write.
56
+ attr_accessor :pending_users
57
+ attr_reader :preview, :user_id, :user_raw_id, :tenant
58
+
59
+ # One rule for turning a resolved user id into the reference that goes on
60
+ # a record. Already-qualified references (a job payload's, or one built
61
+ # while the tenant was known) pass through unchanged.
62
+ def self.qualified_user(raw_id, tenant)
63
+ return raw_id if raw_id.nil? || tenant.nil? || raw_id.start_with?("#{tenant}:")
64
+
65
+ "#{tenant}:#{raw_id}"
66
+ end
67
+
68
+ def preview=(value)
69
+ @preview = value
70
+ @envelope = nil
71
+ end
72
+
73
+ # The raw id is kept because the tenant may not be bound yet: an app that
74
+ # resolves its user in a before_action and its tenant in the next one
75
+ # would otherwise emit "1" for every tenant's user 1.
76
+ def user_id=(value)
77
+ @user_raw_id = value&.to_s
78
+ @user_id = Execution.qualified_user(@user_raw_id, @tenant)
79
+ @envelope = nil
80
+ end
81
+
82
+ def tenant=(value)
83
+ previous_user = @user_id
84
+ @tenant = value
85
+ @user_id = Execution.qualified_user(@user_raw_id, @tenant)
86
+ requalify_buffered_records(previous_user) if value && @user_id != previous_user
87
+ @envelope = nil
88
+ end
89
+
90
+ def initialize(source:, sampled:, trace_id: nil, parent_id: nil, preview: nil)
91
+ raise ArgumentError, "unknown source #{source}" unless SOURCES.include?(source)
92
+
93
+ @source = source
94
+ @id = SecureRandom.uuid
95
+ @trace_id = trace_id || SecureRandom.uuid
96
+ @parent_id = parent_id
97
+ @sampled = sampled
98
+ @preview = preview
99
+ @started_at = Clock.now
100
+ @started_mono = Clock.monotonic
101
+ @counters = COUNTERS.to_h { |c| [ c, 0 ] }
102
+ @stages = []
103
+ @stage_durations = {}
104
+ @current_stage = nil
105
+ @current_stage_started = @started_mono
106
+ @query_groups = Hash.new(0)
107
+ @paused_depth = 0
108
+ @exception_preview = nil
109
+ @user_id = nil
110
+ @user_raw_id = nil
111
+ @tenant = nil
112
+ @records = []
113
+ @record_bytes = []
114
+ @buffered_bytes = 0
115
+ @dropped_records = 0
116
+ @dropped_bytes = 0
117
+ @keep = false
118
+ # Tail sampling keeps buffering child records for a head-sampled-out
119
+ # execution so the ship/discard decision can be made at the end. Read
120
+ # once here rather than per record: recording? is on the hot path.
121
+ @tail_buffering = !Railwatch.config.tail_sample_slow_ms.nil?
122
+ # Failure context is the same mechanism on a shorter leash: with tail
123
+ # sampling off, a head-sampled-out execution still buffers its last
124
+ # config.failure_context child records in a ring, and only an unhandled
125
+ # exception promotes them (Railwatch.tail_keep?). Skipped when tail
126
+ # sampling is already buffering everything -- the larger buffer wins --
127
+ # and when the head kept this execution, which buffers everything
128
+ # anyway. Off by default, so a sampled-out execution stays as cheap as
129
+ # it has always been.
130
+ @failure_context = !sampled && !@tail_buffering && Railwatch.config.failure_context.positive?
131
+ @tail_buffering ||= @failure_context
132
+ @record_limit = @failure_context ? Railwatch.config.failure_context : MAX_RECORDS
133
+ @byte_limit = Railwatch.config.execution_buffer_bytes
134
+ @transaction_statement_counts = Hash.new(0)
135
+ @allocations_start = GC.stat(:total_allocated_objects)
136
+ @gc_time_start = GC.stat(:time) if GC_TIME_SUPPORTED
137
+ end
138
+
139
+ def sampled?
140
+ @sampled
141
+ end
142
+
143
+ def paused?
144
+ @paused_depth.positive?
145
+ end
146
+
147
+ def recording?
148
+ (sampled? || @tail_buffering) && !paused?
149
+ end
150
+
151
+ # Ship this execution's whole tree regardless of the head sampling
152
+ # decision (Railwatch.keep!), buffering child records from here on.
153
+ def keep!
154
+ @keep = true
155
+ @tail_buffering = true
156
+ # Whatever the ring already dropped is gone, but a kept execution ships
157
+ # its whole tree, so from here on it buffers like any other.
158
+ @failure_context = false
159
+ @record_limit = MAX_RECORDS
160
+ end
161
+
162
+ # Whether child records are buffered even when the head decision sampled
163
+ # this execution out, so finish_execution can still decide to ship them.
164
+ def tail_buffering?
165
+ @tail_buffering
166
+ end
167
+
168
+ # Whether that buffer is a failure-context ring (bounded, promoted only
169
+ # by an unhandled exception) rather than a full tail-sampling buffer.
170
+ def failure_context?
171
+ @failure_context
172
+ end
173
+
174
+ # A tenant that binds after records were already buffered leaves them
175
+ # attributed to an unqualified user and to no tenant at all. Rewriting
176
+ # them here is a single pass over a buffer that is usually a handful of
177
+ # records, and it happens at most once per execution -- the guard in
178
+ # #tenant= only fires when the reference actually changed.
179
+ def requalify_buffered_records(previous_user)
180
+ @records.each do |record|
181
+ record[:user] = @user_id if record[:user] == previous_user
182
+ record[:tenant] = @tenant if record[:tenant].nil?
183
+ end
184
+ end
185
+
186
+ def stage
187
+ @current_stage
188
+ end
189
+
190
+ # Close the current stage and open the next. Stage durations are integers
191
+ # in microseconds keyed by stage name, like Nightwatch's request record.
192
+ def enter_stage(name)
193
+ now = Clock.monotonic
194
+ if @current_stage
195
+ @stage_durations[@current_stage] = (@stage_durations[@current_stage] || 0) + ((now - @current_stage_started) * 1_000_000).round
196
+ end
197
+ @current_stage = name
198
+ @current_stage_started = now
199
+ @stages << name
200
+ @envelope = nil
201
+ end
202
+
203
+ def finish_stages
204
+ enter_stage(nil)
205
+ @current_stage = nil
206
+ end
207
+
208
+ # Child records wait here until the execution ends, so a sampling
209
+ # decision made late (route-level railwatch_sample, dont_sample) still
210
+ # applies to everything recorded before it.
211
+ def buffer(record)
212
+ bytes = Record.buffered_bytes(record, limit: @byte_limit)
213
+ # A record heavier than the whole per-execution budget can only be
214
+ # dropped: making room for it would mean discarding the entire tree and
215
+ # still not fitting.
216
+ if bytes > @byte_limit
217
+ @dropped_records += 1
218
+ @dropped_bytes += bytes
219
+ return
220
+ end
221
+
222
+ # A failure-context ring keeps the LAST record_limit records: the ones
223
+ # just before the exception are the ones worth having. Every other
224
+ # buffer keeps the earliest and rejects the overflow. Either way the
225
+ # loss is counted onto the parent's batch (Railwatch.finish_execution).
226
+ if @failure_context
227
+ drop_oldest while @records.any? && (@records.size >= @record_limit || @buffered_bytes + bytes > @byte_limit)
228
+ elsif @records.size >= @record_limit || @buffered_bytes + bytes > @byte_limit
229
+ @dropped_records += 1
230
+ @dropped_bytes += bytes
231
+ return
232
+ end
233
+
234
+ @records << record
235
+ @record_bytes << bytes
236
+ @buffered_bytes += bytes
237
+ end
238
+
239
+ # The tree, with each record's already-measured weight, so the reporter
240
+ # queue does not weigh them a second time.
241
+ def each_record
242
+ @records.each_with_index { |record, index| yield record, @record_bytes[index] }
243
+ end
244
+
245
+ def drop_oldest
246
+ @records.shift
247
+ bytes = @record_bytes.shift
248
+ @buffered_bytes -= bytes
249
+ @dropped_records += 1
250
+ @dropped_bytes += bytes
251
+ end
252
+ private :drop_oldest
253
+
254
+ # Resident set size in bytes, Linux only. Reading /proc costs ~14µs, so
255
+ # it is sampled at most once per MEMORY_SAMPLE_INTERVAL per process and
256
+ # every execution in between reports the last sample; RSS moves slowly
257
+ # compared with request rates, so the value stays representative.
258
+ MEMORY_SAMPLE_INTERVAL = 1.0
259
+ @memory_sample = nil
260
+ @memory_sampled_at = 0.0
261
+
262
+ def self.sampled_memory
263
+ now = Clock.monotonic
264
+ if now - @memory_sampled_at > MEMORY_SAMPLE_INTERVAL
265
+ @memory_sampled_at = now
266
+ @memory_sample = File.read("/proc/self/statm").split(" ", 3)[1].to_i * 4096
267
+ end
268
+ @memory_sample
269
+ rescue StandardError
270
+ @memory_sample
271
+ end
272
+
273
+ def capture_memory
274
+ @peak_memory = self.class.sampled_memory
275
+ end
276
+
277
+ def count(counter, by = 1)
278
+ @counters[counter] += by
279
+ end
280
+
281
+ # Rails.error and an outer middleware can observe the same error. Count
282
+ # that occurrence once even when sampling or pause prevents its report.
283
+ # Reporting has separate flags so a suppressed observation can still be
284
+ # captured after those gates change.
285
+ def first_exception_observation?(error, handled)
286
+ mark_exception(error, handled ? 1 : 2)
287
+ end
288
+
289
+ def first_exception_report?(error, handled)
290
+ mark_exception(error, handled ? 4 : 8)
291
+ end
292
+
293
+ def track_query_group(group)
294
+ @query_groups[group] += 1
295
+ end
296
+
297
+ # Statement counting for the currently-open transaction(s), keyed by the
298
+ # AR transaction object's identity so nested/concurrent transactions on
299
+ # the same execution don't collide. Read once (at transaction end) and
300
+ # discarded, so this never grows across an execution's lifetime.
301
+ def count_transaction_statement(transaction_object_id)
302
+ @transaction_statement_counts[transaction_object_id] += 1
303
+ end
304
+
305
+ def transaction_statement_count(transaction_object_id)
306
+ @transaction_statement_counts.delete(transaction_object_id) || 0
307
+ end
308
+
309
+ def duration
310
+ Clock.micros_since(@started_mono)
311
+ end
312
+
313
+ def allocations
314
+ GC.stat(:total_allocated_objects) - @allocations_start
315
+ end
316
+
317
+ def gc_time
318
+ return nil unless @gc_time_start
319
+ GC.stat(:time) - @gc_time_start
320
+ end
321
+
322
+ # The envelope every child record shares with its parent. Rebuilt only
323
+ # when a stage, user, tenant, or preview changes; records merge a copy.
324
+ #
325
+ # The app's tenant is usually bound INSIDE the execution -- a middleware
326
+ # nested under Railwatch's (activerecord-tenanted's TenantSelector), an
327
+ # around_action, a job's with_tenant block -- so it was nil when the
328
+ # execution opened. While it is still nil, every envelope read asks the
329
+ # app again (two constant checks and a thread-local read) so the first
330
+ # record after the bind, and everything after it including the parent,
331
+ # carries the tenant.
332
+ def envelope
333
+ if @tenant.nil? && (bound = Context.current_tenant)
334
+ self.tenant = bound
335
+ end
336
+ @envelope ||= {
337
+ trace_id: @trace_id,
338
+ execution_source: @source.name,
339
+ execution_id: @id,
340
+ parent_id: @parent_id,
341
+ execution_preview: @preview,
342
+ execution_stage: @current_stage&.name,
343
+ user: @user_id,
344
+ tenant: @tenant
345
+ }.freeze
346
+ end
347
+
348
+ private
349
+
350
+ # Exception deduplication belongs to an execution, not to the Exception
351
+ # object. Weak identity keys avoid retaining every reported error for the
352
+ # full lifetime of a long-running execution, and the map is allocated on
353
+ # the first exception so an execution that never sees one pays nothing.
354
+ def mark_exception(error, flag)
355
+ states = (@exception_states ||= ObjectSpace::WeakMap.new)
356
+ state = states[error].to_i
357
+ return false if state.anybits?(flag)
358
+
359
+ states[error] = state | flag
360
+ true
361
+ rescue StandardError
362
+ # Telemetry must never interfere with the application. If an unusual
363
+ # exception cannot be used as a weak key, fail open and report it.
364
+ true
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railwatch
4
+ # Outgoing-request instrumentation for apps built on Faraday instead of
5
+ # (or in addition to) Net::HTTP. Opt in per connection:
6
+ #
7
+ # Faraday.new(url) { |f| f.use Railwatch::Faraday }
8
+ #
9
+ # Overrides #call directly instead of Faraday::Middleware's on_request/
10
+ # on_complete/on_error hooks: middleware instances are built once and
11
+ # reused across every request on that connection, so per-request timing
12
+ # state has to live on the local call stack, not on an instance variable.
13
+ class Faraday < ::Faraday::Middleware
14
+ def call(env)
15
+ start = Clock.monotonic
16
+ started_at = Clock.now
17
+ # Faraday's default adapter is Net::HTTP, which Patches::NetHttp already
18
+ # instruments globally -- without this, a Faraday call would produce two
19
+ # outgoing_request records. Reuse its reentry flag for the duration of
20
+ # @app.call so it defers to this middleware's own (more accurate) record.
21
+ previous = Thread.current[Patches::NetHttp::REENTRY]
22
+ Thread.current[Patches::NetHttp::REENTRY] = true
23
+ propagate_trace(env)
24
+ begin
25
+ @app.call(env).on_complete do |response_env|
26
+ record(response_env, start, started_at)
27
+ end
28
+ rescue StandardError => e
29
+ record(env, start, started_at, error: e)
30
+ raise
31
+ ensure
32
+ Thread.current[Patches::NetHttp::REENTRY] = previous
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ # The Net::HTTP patch is suppressed for the duration of this call, so the
39
+ # traceparent has to be set here. Never overwrites the app's own header.
40
+ def propagate_trace(env)
41
+ return if env.request_headers.key?("traceparent")
42
+
43
+ traceparent = Railwatch.traceparent(env.url.host)
44
+ env.request_headers["traceparent"] = traceparent if traceparent
45
+ rescue StandardError => e
46
+ Railwatch.debug { "traceparent propagation failed: #{e.message}" }
47
+ end
48
+
49
+ def record(env, start, started_at, error: nil)
50
+ url = env.url
51
+ Railwatch.record(:outgoing_request, group: Record.group_hash(url.host, env.method.to_s.upcase),
52
+ timestamp: started_at, host: url.host, method: env.method.to_s.upcase,
53
+ url: Record.url_without_sensitive_components(url, limit: 2048),
54
+ duration: Clock.micros_since(start), status_code: env.status.to_i,
55
+ error: error && "#{error.class}: #{error.message}"[0, 255],
56
+ response_body: response_body(env))
57
+ end
58
+
59
+ # Faraday threads one Env through the whole stack, and the adapter
60
+ # overwrites its body with the response, so env.body is the response only
61
+ # once a status came back -- on a connection failure it is still the
62
+ # outgoing request payload, which must never be filed as a response body.
63
+ # (A 4xx/5xx raised by the raise_error middleware below this one lands in
64
+ # #call's rescue with the response already saved onto the env, so it is
65
+ # captured there too.)
66
+ def response_body(env)
67
+ return nil unless Railwatch.config.capture_response_body_on_error
68
+ return nil unless env.status.to_i >= 400
69
+
70
+ Patches::NetHttp.captured_response_body(env.body)
71
+ end
72
+ end
73
+ end