railwatch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +122 -0
- data/CHANGELOG.md +462 -0
- data/MIT-LICENSE +20 -0
- data/README.md +226 -0
- data/app/controllers/railwatch/beacon_controller.rb +254 -0
- data/config/routes.rb +5 -0
- data/docs/ai-and-mcp.md +227 -0
- data/docs/configuration.md +931 -0
- data/docs/faq.md +230 -0
- data/docs/getting-started.md +279 -0
- data/docs/records.md +834 -0
- data/docs/replacing-nightwatch.md +216 -0
- data/docs/replacing-sentry.md +573 -0
- data/docs/security.md +94 -0
- data/docs/self-hosting.md +60 -0
- data/docs/source-maps.md +60 -0
- data/docs/testing.md +175 -0
- data/docs/troubleshooting.md +319 -0
- data/lib/generators/railwatch/install/install_generator.rb +280 -0
- data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
- data/lib/generators/railwatch/install/templates/post-deploy +98 -0
- data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
- data/lib/railwatch/attachments.rb +83 -0
- data/lib/railwatch/backtrace.rb +158 -0
- data/lib/railwatch/buffer.rb +122 -0
- data/lib/railwatch/clock.rb +25 -0
- data/lib/railwatch/configuration.rb +334 -0
- data/lib/railwatch/console.rb +48 -0
- data/lib/railwatch/context.rb +125 -0
- data/lib/railwatch/controller_helpers.rb +21 -0
- data/lib/railwatch/current.rb +32 -0
- data/lib/railwatch/engine.rb +144 -0
- data/lib/railwatch/execution.rb +367 -0
- data/lib/railwatch/faraday.rb +73 -0
- data/lib/railwatch/health.rb +188 -0
- data/lib/railwatch/job_tracing.rb +49 -0
- data/lib/railwatch/middleware/request.rb +289 -0
- data/lib/railwatch/minitest.rb +43 -0
- data/lib/railwatch/patches/inertia.rb +34 -0
- data/lib/railwatch/patches/net_http.rb +102 -0
- data/lib/railwatch/patches/rake_task.rb +88 -0
- data/lib/railwatch/patches/runner_command.rb +120 -0
- data/lib/railwatch/patches.rb +43 -0
- data/lib/railwatch/profiler.rb +270 -0
- data/lib/railwatch/record.rb +119 -0
- data/lib/railwatch/redactor.rb +67 -0
- data/lib/railwatch/release_detector.rb +97 -0
- data/lib/railwatch/reporter.rb +539 -0
- data/lib/railwatch/rspec.rb +139 -0
- data/lib/railwatch/sampler.rb +17 -0
- data/lib/railwatch/secret_safety.rb +62 -0
- data/lib/railwatch/sessions.rb +162 -0
- data/lib/railwatch/source_maps.rb +59 -0
- data/lib/railwatch/spec_helper.rb +147 -0
- data/lib/railwatch/sql_normalizer.rb +398 -0
- data/lib/railwatch/subscribers/base.rb +54 -0
- data/lib/railwatch/subscribers/broadcasts.rb +107 -0
- data/lib/railwatch/subscribers/cache.rb +107 -0
- data/lib/railwatch/subscribers/deprecations.rb +26 -0
- data/lib/railwatch/subscribers/exceptions.rb +304 -0
- data/lib/railwatch/subscribers/jobs.rb +282 -0
- data/lib/railwatch/subscribers/logs.rb +137 -0
- data/lib/railwatch/subscribers/mail.rb +42 -0
- data/lib/railwatch/subscribers/notifications.rb +36 -0
- data/lib/railwatch/subscribers/process_info.rb +98 -0
- data/lib/railwatch/subscribers/queries.rb +183 -0
- data/lib/railwatch/subscribers/requests.rb +94 -0
- data/lib/railwatch/subscribers/storage.rb +35 -0
- data/lib/railwatch/subscribers/users.rb +159 -0
- data/lib/railwatch/subscribers/views.rb +54 -0
- data/lib/railwatch/subscribers.rb +34 -0
- data/lib/railwatch/transport/http.rb +208 -0
- data/lib/railwatch/version.rb +5 -0
- data/lib/railwatch.rb +550 -0
- data/lib/tasks/railwatch_tasks.rake +289 -0
- data/llms.txt +38 -0
- metadata +157 -0
|
@@ -0,0 +1,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
|