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,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# One background thread per web/worker process, shipping a single `health`
|
|
5
|
+
# record every config.health_interval seconds: Puma's thread pool, the
|
|
6
|
+
# Active Record connection pool, and Solid Queue's backlog. Started from the
|
|
7
|
+
# engine's "railwatch.health" initializer and re-armed in every forked child
|
|
8
|
+
# from Railwatch.restart_after_fork!.
|
|
9
|
+
#
|
|
10
|
+
# A sample must never be visible to the app: the whole thing runs inside
|
|
11
|
+
# Railwatch.ignore and rescues everything, so a missing constant, an
|
|
12
|
+
# unmigrated queue database, or a checkout timeout degrades to nil fields
|
|
13
|
+
# rather than raising on a thread nobody is watching.
|
|
14
|
+
module Health
|
|
15
|
+
ROLES = %w[web worker].freeze
|
|
16
|
+
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@wakeup = ConditionVariable.new
|
|
19
|
+
@thread = nil
|
|
20
|
+
@pid = nil
|
|
21
|
+
@stopping = false
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Idempotent; Railwatch.restart_after_fork! calls it again in every forked
|
|
26
|
+
# child.
|
|
27
|
+
def start!
|
|
28
|
+
return unless Railwatch.enabled?
|
|
29
|
+
return if defined?(Rails) && Rails.env.test?
|
|
30
|
+
return unless ROLES.include?(Subscribers::ProcessInfo.role)
|
|
31
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
32
|
+
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
return if @thread&.alive? && @pid == Process.pid
|
|
35
|
+
|
|
36
|
+
@pid = Process.pid
|
|
37
|
+
@stopping = false
|
|
38
|
+
@thread = Thread.new { run }
|
|
39
|
+
@thread.name = "railwatch-health"
|
|
40
|
+
@thread.abort_on_exception = false
|
|
41
|
+
@thread.report_on_exception = false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A forked child (Puma cluster worker, Solid Queue forked worker) inherits
|
|
46
|
+
# a dead thread and may inherit a mutex held by a vanished parent thread,
|
|
47
|
+
# so every synchronization primitive must be replaced before start!.
|
|
48
|
+
def restart_after_fork!
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
@wakeup = ConditionVariable.new
|
|
51
|
+
@thread = nil
|
|
52
|
+
@pid = nil
|
|
53
|
+
@stopping = false
|
|
54
|
+
remove_instance_variable(:@puma_server) if defined?(@puma_server)
|
|
55
|
+
start!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stop!
|
|
59
|
+
return unless @thread
|
|
60
|
+
|
|
61
|
+
@stopping = true
|
|
62
|
+
@mutex.synchronize { @wakeup.signal }
|
|
63
|
+
@thread.join(1)
|
|
64
|
+
@thread = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Sleeps on a ConditionVariable rather than Kernel#sleep so stop! (from
|
|
68
|
+
# at_exit) wakes the thread immediately instead of waiting out the
|
|
69
|
+
# remainder of the interval. @stopping is re-read while holding the mutex
|
|
70
|
+
# so a stop! that lands just before the wait can't have its signal missed
|
|
71
|
+
# and leave the process hanging for a full interval.
|
|
72
|
+
def run
|
|
73
|
+
until @stopping
|
|
74
|
+
@mutex.synchronize { @wakeup.wait(@mutex, Railwatch.config.health_interval) unless @stopping }
|
|
75
|
+
sample unless @stopping
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sample
|
|
80
|
+
Railwatch.ignore do
|
|
81
|
+
puma = puma_stats
|
|
82
|
+
pool = pool_stats
|
|
83
|
+
queue = solid_queue_stats
|
|
84
|
+
Railwatch.record(:health,
|
|
85
|
+
pid: Process.pid,
|
|
86
|
+
role: Subscribers::ProcessInfo.role,
|
|
87
|
+
memory: Execution.sampled_memory,
|
|
88
|
+
threads_max: puma[:threads_max],
|
|
89
|
+
threads_busy: puma[:threads_busy],
|
|
90
|
+
backlog: puma[:backlog],
|
|
91
|
+
pool_size: pool[:size],
|
|
92
|
+
pool_busy: pool[:busy],
|
|
93
|
+
pool_waiting: pool[:waiting],
|
|
94
|
+
queue_depth: queue[:queue_depth],
|
|
95
|
+
queue_latency: queue[:queue_latency],
|
|
96
|
+
detail: JSON.generate(detail(puma, queue)))
|
|
97
|
+
end
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
Railwatch.debug { "health sample failed: #{e.class}: #{e.message}" }
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
EMPTY = {}.freeze
|
|
104
|
+
|
|
105
|
+
def detail(puma, queue)
|
|
106
|
+
detail = {
|
|
107
|
+
queues: queue[:queues],
|
|
108
|
+
workers: queue[:workers],
|
|
109
|
+
requests_count: puma[:requests_count],
|
|
110
|
+
running: puma[:running],
|
|
111
|
+
max_threads_reached: puma[:max_threads_reached]
|
|
112
|
+
}
|
|
113
|
+
tasks = recurring_tasks
|
|
114
|
+
detail[:recurring_tasks] = tasks if tasks
|
|
115
|
+
detail
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The recurring tasks this process's Solid Queue knows about, key =>
|
|
119
|
+
# schedule, so the platform can tell a task that was removed from
|
|
120
|
+
# config/recurring.yml apart from one that stopped running. Read from
|
|
121
|
+
# the Jobs subscriber's cache (one query a minute per process, shared
|
|
122
|
+
# with scheduled-task detection). Left out rather than sent empty when
|
|
123
|
+
# there are none or the table could not be read: Jobs folds a failed
|
|
124
|
+
# read into an empty set, and "no manifest" must not read as "no tasks".
|
|
125
|
+
def recurring_tasks
|
|
126
|
+
schedules = Subscribers::Jobs.recurring_tasks[:schedules]
|
|
127
|
+
schedules.empty? ? nil : schedules
|
|
128
|
+
rescue StandardError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Puma::Server#stats is the only public API exposing busy_threads, so it is
|
|
133
|
+
# used in preference to the individual readers. It also resets Puma's
|
|
134
|
+
# own since-last-read backlog_max/reactor_max gauges, which Railwatch does
|
|
135
|
+
# not report.
|
|
136
|
+
def puma_stats
|
|
137
|
+
server = puma_server or return EMPTY
|
|
138
|
+
s = server.stats
|
|
139
|
+
# Puma's busy_threads counts queued requests too (spawned - waiting +
|
|
140
|
+
# todo), so it can exceed max_threads under load; the pool cannot,
|
|
141
|
+
# and that is what a utilisation percentage should describe.
|
|
142
|
+
{
|
|
143
|
+
threads_max: s[:max_threads],
|
|
144
|
+
threads_busy: s[:busy_threads] && s[:max_threads] ? [ s[:busy_threads], s[:max_threads] ].min : s[:busy_threads],
|
|
145
|
+
backlog: s[:backlog],
|
|
146
|
+
running: s[:running],
|
|
147
|
+
requests_count: s[:requests_count],
|
|
148
|
+
# No idle capacity left in the pool at sample time.
|
|
149
|
+
max_threads_reached: s[:pool_capacity]&.zero?
|
|
150
|
+
}
|
|
151
|
+
rescue StandardError
|
|
152
|
+
EMPTY
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Looked up once per process: ObjectSpace.each_object walks the whole heap,
|
|
156
|
+
# so it must not run on every sample. Puma constructs its Server while
|
|
157
|
+
# booting, long before the first sample fires.
|
|
158
|
+
def puma_server
|
|
159
|
+
return @puma_server if defined?(@puma_server)
|
|
160
|
+
@puma_server = defined?(::Puma::Server) ? ObjectSpace.each_object(::Puma::Server).first : nil
|
|
161
|
+
rescue StandardError
|
|
162
|
+
@puma_server = nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def pool_stats
|
|
166
|
+
ActiveRecord::Base.connection_pool.stat
|
|
167
|
+
rescue StandardError
|
|
168
|
+
EMPTY
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Read-only counts against the queue database. Solid Queue keeps one row
|
|
172
|
+
# per ready job, so `queue_latency` (the age of the oldest ready job) is
|
|
173
|
+
# the backlog's head-of-line wait, in microseconds.
|
|
174
|
+
def solid_queue_stats
|
|
175
|
+
return EMPTY unless defined?(::SolidQueue)
|
|
176
|
+
|
|
177
|
+
oldest = ::SolidQueue::ReadyExecution.minimum(:created_at)
|
|
178
|
+
{
|
|
179
|
+
queue_depth: ::SolidQueue::ReadyExecution.count,
|
|
180
|
+
queue_latency: oldest && ((Clock.now - oldest.to_time.utc.to_f) * 1_000_000).round,
|
|
181
|
+
queues: ::SolidQueue::ReadyExecution.group(:queue_name).count,
|
|
182
|
+
workers: ::SolidQueue::Process.where(kind: "Worker").count
|
|
183
|
+
}
|
|
184
|
+
rescue StandardError
|
|
185
|
+
EMPTY
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Carries trace_id, the enqueuing execution id, and that execution's user
|
|
5
|
+
# and tenant inside the Active Job payload, the same way Rails carries
|
|
6
|
+
# locale and timezone, so a job attempt links back to the request that
|
|
7
|
+
# enqueued it and is attributed to the same person and tenant.
|
|
8
|
+
module JobTracing
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
attr_accessor :railwatch_trace_id, :railwatch_parent_id, :railwatch_user, :railwatch_tenant
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serialize
|
|
16
|
+
exe = Railwatch.execution
|
|
17
|
+
data = super.merge(
|
|
18
|
+
"railwatch_trace_id" => railwatch_trace_id || exe&.trace_id,
|
|
19
|
+
"railwatch_parent_id" => railwatch_parent_id || exe&.id
|
|
20
|
+
)
|
|
21
|
+
# Identifier strings only -- never a user or tenant record -- and only
|
|
22
|
+
# when there is one, so a job enqueued with no identity produces the
|
|
23
|
+
# same payload it did before these keys existed.
|
|
24
|
+
#
|
|
25
|
+
# A request resolves its user lazily, at the end (Middleware::Request),
|
|
26
|
+
# so exe.user_id is usually still nil while the action is enqueuing;
|
|
27
|
+
# resolving here is what gives such a job its user. The result is
|
|
28
|
+
# memoised onto the execution with the same `||=` the middleware uses,
|
|
29
|
+
# so an action that enqueues fifty jobs resolves once, not fifty
|
|
30
|
+
# times -- resolving a present user costs ~8us. Inside a job the
|
|
31
|
+
# restored value is already on the execution and wins, so identity
|
|
32
|
+
# flows on unchanged through jobs that enqueue jobs. No execution means
|
|
33
|
+
# nothing to attribute (and Railwatch disabled), so nothing is resolved.
|
|
34
|
+
user = railwatch_user || (exe && (exe.user_id ||= Subscribers::Users.resolve_from_current))
|
|
35
|
+
tenant = railwatch_tenant || exe&.tenant || Context.current_tenant
|
|
36
|
+
data["railwatch_user"] = user if user
|
|
37
|
+
data["railwatch_tenant"] = tenant if tenant
|
|
38
|
+
data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def deserialize(job_data)
|
|
42
|
+
super
|
|
43
|
+
self.railwatch_trace_id = job_data["railwatch_trace_id"]
|
|
44
|
+
self.railwatch_parent_id = job_data["railwatch_parent_id"]
|
|
45
|
+
self.railwatch_user = job_data["railwatch_user"]
|
|
46
|
+
self.railwatch_tenant = job_data["railwatch_tenant"]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Middleware
|
|
5
|
+
# Outermost Rack middleware. Opens the request execution, times the
|
|
6
|
+
# lifecycle stages, catches anything that escapes the stack as an
|
|
7
|
+
# unhandled exception, and emits the request record at the end.
|
|
8
|
+
class Request
|
|
9
|
+
# Env keys repeat request after request (same client/proxy headers), so
|
|
10
|
+
# the Rack key -> "Header-Name" conversion is cached instead of
|
|
11
|
+
# split/map/capitalize/join-ing on every request.
|
|
12
|
+
HEADER_NAME_CACHE_LIMIT = 512
|
|
13
|
+
# W3C trace context: version-trace_id-parent_id-flags, all lower-case hex.
|
|
14
|
+
TRACEPARENT = /\A([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(.*)\z/
|
|
15
|
+
# Reverse proxies stamp the moment the request was accepted; the gap to
|
|
16
|
+
# our own start is how long it waited for a worker. Anything beyond this
|
|
17
|
+
# is clock skew between the proxy and this box, not a real wait.
|
|
18
|
+
MAX_QUEUE_TIME = 60_000_000 # microseconds
|
|
19
|
+
# Only a multipart request can carry an UploadedFile; same raw
|
|
20
|
+
# CONTENT_TYPE test Subscribers::Requests uses before its params walk.
|
|
21
|
+
MULTIPART = "multipart/form-data"
|
|
22
|
+
NO_FILES = [].freeze
|
|
23
|
+
|
|
24
|
+
# Returns [trace_id, parent_id, sampled] from an inbound traceparent,
|
|
25
|
+
# or nil when the header is absent or malformed.
|
|
26
|
+
def self.traceparent(value)
|
|
27
|
+
match = value && TRACEPARENT.match(value)
|
|
28
|
+
return nil unless match
|
|
29
|
+
return nil if match[1] == "ff"
|
|
30
|
+
# Version 00 has exactly 55 characters. Future versions may append
|
|
31
|
+
# opaque fields, but W3C requires the byte after trace-flags to be a
|
|
32
|
+
# dash; do not inspect or make assumptions about the fields beyond it.
|
|
33
|
+
return nil if match[1] == "00" && !match[5].empty?
|
|
34
|
+
return nil unless match[5].empty? || match[5].start_with?("-")
|
|
35
|
+
return nil if match[2] == "0" * 32 || match[3] == "0" * 16
|
|
36
|
+
|
|
37
|
+
[ match[2], match[3], match[4].to_i(16).odd? ]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(app)
|
|
41
|
+
@app = app
|
|
42
|
+
@header_name_cache = {}
|
|
43
|
+
@header_name_mutex = Mutex.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(env)
|
|
47
|
+
return @app.call(env) unless Railwatch.enabled?
|
|
48
|
+
return @app.call(env) if ignored_request?(env)
|
|
49
|
+
|
|
50
|
+
trace_id, parent_id, upstream_sampled = self.class.traceparent(env["HTTP_TRACEPARENT"])
|
|
51
|
+
exe = Railwatch.start_execution(source: :request, sample_kind: :requests,
|
|
52
|
+
trace_id: trace_id, parent_id: parent_id)
|
|
53
|
+
# The upstream service sampled this trace in, so keep our end of it
|
|
54
|
+
# too -- otherwise the trace has a hole where this request should be.
|
|
55
|
+
exe.keep! if upstream_sampled
|
|
56
|
+
exe.enter_stage(:middleware_before)
|
|
57
|
+
env["railwatch.execution"] = exe
|
|
58
|
+
status = headers = body = nil
|
|
59
|
+
begin
|
|
60
|
+
status, headers, body = @app.call(env)
|
|
61
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
62
|
+
Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "railwatch.middleware")
|
|
63
|
+
raise
|
|
64
|
+
ensure
|
|
65
|
+
exe.enter_stage(:middleware_after) unless exe.stage == :middleware_after
|
|
66
|
+
finish(env, exe, status, headers)
|
|
67
|
+
end
|
|
68
|
+
[ status, headers, body ]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def ignored_request?(env)
|
|
74
|
+
path = env["PATH_INFO"].to_s
|
|
75
|
+
Railwatch.config.ignored_request_paths.any? do |pattern|
|
|
76
|
+
pattern.is_a?(Regexp) ? pattern.match?(path) : pattern.to_s == path
|
|
77
|
+
end || self_ingest_request?(env, path)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Railwatch Cloud monitors itself. Its reporter therefore POSTs back into
|
|
81
|
+
# the same Rails process, and recording that POST would put another
|
|
82
|
+
# request record in the reporter forever: flush -> /ingest -> flush.
|
|
83
|
+
#
|
|
84
|
+
# Path alone is not enough: a customer application can own an unrelated
|
|
85
|
+
# /ingest route. This exemption needs the exact transport method, bearer
|
|
86
|
+
# token and public origin. Rack::Request normalizes Forwarded and
|
|
87
|
+
# X-Forwarded-* headers (including a non-default forwarded port), so the
|
|
88
|
+
# comparison still works behind a TLS-terminating reverse proxy without
|
|
89
|
+
# confusing the proxy's internal host with the public ingest origin.
|
|
90
|
+
def self_ingest_request?(env, path)
|
|
91
|
+
return false unless env["REQUEST_METHOD"] == "POST"
|
|
92
|
+
return false unless env["HTTP_AUTHORIZATION"] == "Bearer #{Railwatch.config.token}"
|
|
93
|
+
|
|
94
|
+
endpoint = URI.join(Railwatch.config.ingest_url, "/ingest")
|
|
95
|
+
return false unless path == endpoint.path
|
|
96
|
+
|
|
97
|
+
request = Rack::Request.new(env)
|
|
98
|
+
request.scheme.casecmp?(endpoint.scheme) &&
|
|
99
|
+
request.hostname.casecmp?(endpoint.host) &&
|
|
100
|
+
request.port == endpoint.port
|
|
101
|
+
rescue StandardError
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def finish(env, exe, status, headers)
|
|
106
|
+
exe.finish_stages
|
|
107
|
+
# Resolved here, once, for both the request record and the session
|
|
108
|
+
# key below (the start_processing subscriber ran before the app's
|
|
109
|
+
# before_actions, so it usually found no user yet).
|
|
110
|
+
exe.user_id ||= Subscribers::Users.resolve_id(env)
|
|
111
|
+
# The block is only called when the request record is going to ship;
|
|
112
|
+
# a head-sampled-out request nothing rescued skips the
|
|
113
|
+
# ActionDispatch::Request and the header walk entirely.
|
|
114
|
+
Railwatch.finish_execution(:request) { parent_fields(env, exe, status, headers) }
|
|
115
|
+
# A request with no user and no session cookie has no session, and
|
|
116
|
+
# Sessions.touch returns without writing anything.
|
|
117
|
+
Sessions.touch(exe, env, status) if Railwatch.config.track_sessions
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
Railwatch.debug { "request finish failed: #{e.class}: #{e.message}" }
|
|
120
|
+
Railwatch.finish_execution
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parent_fields(env, exe, status, headers)
|
|
124
|
+
req = ActionDispatch::Request.new(env)
|
|
125
|
+
route = env["railwatch.route"] || {}
|
|
126
|
+
pattern = route[:pattern] || (req.respond_to?(:route_uri_pattern) ? (req.route_uri_pattern rescue nil) : nil) || "unmatched"
|
|
127
|
+
controller = route[:controller]
|
|
128
|
+
action = route[:action]
|
|
129
|
+
method = req.request_method
|
|
130
|
+
exe.preview ||= "#{method} #{pattern}"
|
|
131
|
+
|
|
132
|
+
inertia = inertia_fields(env, headers)
|
|
133
|
+
payload = Railwatch.config.capture_request_payload && exe.counters[:exceptions].positive? ? Railwatch.redactor.params(req.filtered_parameters.except("controller", "action")) : nil
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
group: Record.group_hash(method, pattern),
|
|
137
|
+
method: method,
|
|
138
|
+
url: Record.url_without_sensitive_components(req.original_url, limit: 2048),
|
|
139
|
+
path: req.path,
|
|
140
|
+
route: pattern,
|
|
141
|
+
route_methods: [ route[:verb] ].compact,
|
|
142
|
+
route_domain: req.host,
|
|
143
|
+
controller: controller,
|
|
144
|
+
action: action,
|
|
145
|
+
format: request_format(req, env),
|
|
146
|
+
ip: req.remote_ip,
|
|
147
|
+
status_code: status.to_i,
|
|
148
|
+
request_size: req.content_length.to_i,
|
|
149
|
+
response_size: headers && (headers["Content-Length"] || headers["content-length"]).to_i,
|
|
150
|
+
view_runtime: env["railwatch.view_runtime"],
|
|
151
|
+
db_runtime: env["railwatch.db_runtime"],
|
|
152
|
+
redirect_to: env["railwatch.redirect_to"],
|
|
153
|
+
halted_callback: env["railwatch.halted_callback"],
|
|
154
|
+
unpermitted_parameters: env["railwatch.unpermitted_parameters"],
|
|
155
|
+
rate_limited: env["railwatch.rate_limited"],
|
|
156
|
+
inertia: inertia,
|
|
157
|
+
headers: request_headers(env),
|
|
158
|
+
payload: payload,
|
|
159
|
+
queue_time: queue_time(env, exe),
|
|
160
|
+
user_agent: req.user_agent.to_s[0, 256],
|
|
161
|
+
files: env["railwatch.files"] || uploaded_files_fallback(req, env)
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Rack parses the request body the first time anything asks it for
|
|
166
|
+
# params, and both of the reads below ask -- `uploaded_files` walks
|
|
167
|
+
# `request.params`, and ActionDispatch::Request#format goes through
|
|
168
|
+
# `parameters[:format]`.
|
|
169
|
+
#
|
|
170
|
+
# Once a controller has run, that parse has already happened and its
|
|
171
|
+
# result is memoized on env, so both reads are free (and the files were
|
|
172
|
+
# captured back in Subscribers::Requests, before Rack::TempfileReaper
|
|
173
|
+
# unlinked the tempfiles). When no controller ran -- a routing 404, a
|
|
174
|
+
# rack-attack block, a middleware that rejected the request -- doing it
|
|
175
|
+
# here would make Railwatch the only component that ever reads that body,
|
|
176
|
+
# at teardown, after the response has been decided. A streaming upload,
|
|
177
|
+
# or simply megabytes we would immediately throw away.
|
|
178
|
+
#
|
|
179
|
+
# So the fallback is narrow: multipart only, because nothing else can
|
|
180
|
+
# contain a file, and no format symbol is worth parsing a body for.
|
|
181
|
+
def uploaded_files_fallback(req, env)
|
|
182
|
+
return NO_FILES unless multipart?(env)
|
|
183
|
+
|
|
184
|
+
uploaded_files(req.params)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def request_format(req, env)
|
|
188
|
+
return "" unless env.key?("action_dispatch.request.formats") || multipart?(env)
|
|
189
|
+
|
|
190
|
+
(req.format&.symbol rescue nil).to_s
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def multipart?(env)
|
|
194
|
+
content_type = env["CONTENT_TYPE"]
|
|
195
|
+
!content_type.nil? && content_type.start_with?(MULTIPART)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Microseconds this request waited in the proxy/web-server queue before
|
|
199
|
+
# the execution started, from X-Request-Start (nginx, Heroku, HAProxy)
|
|
200
|
+
# or X-Queue-Start. nil when absent, unparseable, or implausible.
|
|
201
|
+
def queue_time(env, exe)
|
|
202
|
+
raw = env["HTTP_X_REQUEST_START"] || env["HTTP_X_QUEUE_START"]
|
|
203
|
+
started = raw && request_start_seconds(raw)
|
|
204
|
+
return nil unless started
|
|
205
|
+
|
|
206
|
+
micros = ((exe.started_at - started) * 1_000_000).round
|
|
207
|
+
return nil if micros > MAX_QUEUE_TIME
|
|
208
|
+
|
|
209
|
+
# A proxy clock running slightly ahead reads as a negative wait.
|
|
210
|
+
micros.negative? ? 0 : micros
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# "t=1700000000.123" (seconds), "t=1700000000123" (ms),
|
|
214
|
+
# "t=1700000000123456" (microseconds), or the same values bare. A proxy
|
|
215
|
+
# chain can collapse several into one comma-separated header; the first
|
|
216
|
+
# is the outermost. Unit is decided by magnitude, like Sentry's
|
|
217
|
+
# extract_queue_time.
|
|
218
|
+
def request_start_seconds(value)
|
|
219
|
+
raw = value.to_s.split(",").first.to_s.strip.delete_prefix("t=").strip
|
|
220
|
+
return nil unless /\A\d+(?:\.\d+)?\z/.match?(raw)
|
|
221
|
+
|
|
222
|
+
seconds = raw.to_f
|
|
223
|
+
if seconds > 10_000_000_000_000 then seconds / 1_000_000
|
|
224
|
+
elsif seconds > 10_000_000_000 then seconds / 1_000
|
|
225
|
+
else seconds
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def inertia_fields(env, headers)
|
|
230
|
+
# The X-Inertia response header is only set on the XHR-follow-up
|
|
231
|
+
# branch; a full-page (SSR or not) Inertia render never sets it, so
|
|
232
|
+
# the presence of the component env var (set on every Inertia
|
|
233
|
+
# render) also has to open this gate or SSR timing is silently lost.
|
|
234
|
+
return nil unless env["HTTP_X_INERTIA"] == "true" || (headers && (headers["X-Inertia"] || headers["x-inertia"])) || env["railwatch.inertia_component"]
|
|
235
|
+
|
|
236
|
+
{
|
|
237
|
+
component: env["railwatch.inertia_component"],
|
|
238
|
+
version: env["HTTP_X_INERTIA_VERSION"],
|
|
239
|
+
partial_component: env["HTTP_X_INERTIA_PARTIAL_COMPONENT"],
|
|
240
|
+
partial_only: env["HTTP_X_INERTIA_PARTIAL_DATA"],
|
|
241
|
+
partial_except: env["HTTP_X_INERTIA_PARTIAL_EXCEPT"],
|
|
242
|
+
props_bytes: env["railwatch.inertia_props_bytes"],
|
|
243
|
+
ssr_ms: env["railwatch.inertia_ssr_ms"]
|
|
244
|
+
}.compact
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Recursively pulls ActionDispatch::Http::UploadedFile metadata out of
|
|
248
|
+
# request.params -- never its contents. Handles both a single file
|
|
249
|
+
# field and array-of-files fields (e.g. `attachments[]`).
|
|
250
|
+
def uploaded_files(value, name = nil)
|
|
251
|
+
case value
|
|
252
|
+
when ActionDispatch::Http::UploadedFile
|
|
253
|
+
[ { name: name, size: (value.tempfile.size rescue nil), content_type: value.content_type, error: nil } ]
|
|
254
|
+
when Hash
|
|
255
|
+
value.flat_map { |k, v| uploaded_files(v, k.to_s) }
|
|
256
|
+
when Array
|
|
257
|
+
value.flat_map { |v| uploaded_files(v, name) }
|
|
258
|
+
else
|
|
259
|
+
[]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Builds the (already redacted) header hash in one pass over env so the
|
|
264
|
+
# redactor does not have to walk a second, intermediate hash.
|
|
265
|
+
def request_headers(env)
|
|
266
|
+
redactor = Railwatch.redactor
|
|
267
|
+
out = {}
|
|
268
|
+
env.each_pair do |k, v|
|
|
269
|
+
next unless k.start_with?("HTTP_") || k == "CONTENT_TYPE" || k == "CONTENT_LENGTH"
|
|
270
|
+
name = header_name(k)
|
|
271
|
+
out[name] = redactor.redact_header?(name) ? Redactor::FILTERED : v.to_s[0, 512]
|
|
272
|
+
end
|
|
273
|
+
out
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def header_name(key)
|
|
277
|
+
cached = @header_name_cache[key]
|
|
278
|
+
return cached if cached
|
|
279
|
+
|
|
280
|
+
name = key.delete_prefix("HTTP_").split("_").map(&:capitalize).join("-")
|
|
281
|
+
@header_name_mutex.synchronize do
|
|
282
|
+
@header_name_cache.clear if @header_name_cache.size >= HEADER_NAME_CACHE_LIMIT
|
|
283
|
+
@header_name_cache[key] = name
|
|
284
|
+
end
|
|
285
|
+
name
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "railwatch/spec_helper"
|
|
4
|
+
|
|
5
|
+
module Railwatch
|
|
6
|
+
# The Minitest half of railwatch/rspec: the same block assertions, phrased as
|
|
7
|
+
# assert_/refute_. Add to test/test_helper.rb:
|
|
8
|
+
#
|
|
9
|
+
# require "railwatch/minitest"
|
|
10
|
+
# class ActiveSupport::TestCase
|
|
11
|
+
# include Railwatch::Minitest
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Includes Railwatch::SpecHelper, so `railwatch_records(:query)` is available
|
|
15
|
+
# too. See docs/testing.md.
|
|
16
|
+
module Minitest
|
|
17
|
+
include SpecHelper
|
|
18
|
+
|
|
19
|
+
# assert_railwatch_queries(at_most: 5) { Order.find(id).total }
|
|
20
|
+
# Also takes exactly: or at_least:.
|
|
21
|
+
def assert_railwatch_queries(exactly: nil, at_most: nil, at_least: nil, &block)
|
|
22
|
+
bounds = { exactly: exactly, at_most: at_most, at_least: at_least }
|
|
23
|
+
queries = railwatch_capture(&block).select { |r| r[:t] == "query" }
|
|
24
|
+
assert SpecHelper.count_satisfied?(queries.size, **bounds),
|
|
25
|
+
"Expected the block to run #{SpecHelper.bound_description(**bounds)} database queries, " \
|
|
26
|
+
"but it ran #{queries.size}:#{SpecHelper.sql_lines(queries)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def refute_railwatch_n_plus_one(&block)
|
|
30
|
+
n_plus_ones = railwatch_capture(&block).select { |r| r[:t] == "n_plus_one" }
|
|
31
|
+
assert n_plus_ones.empty?,
|
|
32
|
+
"Expected no N+1 queries, but #{n_plus_ones.size} were " \
|
|
33
|
+
"detected:#{SpecHelper.n_plus_one_lines(n_plus_ones)}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def assert_railwatch_span(name, &block)
|
|
37
|
+
spans = railwatch_capture(&block).select { |r| r[:t] == "span" }
|
|
38
|
+
assert spans.any? { |s| s[:name] == name },
|
|
39
|
+
"Expected the block to record a #{name.inspect} span, but it recorded " \
|
|
40
|
+
"#{spans.size}: #{SpecHelper.record_names(spans, :name).inspect}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Patches
|
|
5
|
+
# Records the Inertia component and SSR time per request. inertia_rails
|
|
6
|
+
# has no instrumentation of its own, so this is a small prepend on the
|
|
7
|
+
# renderer; skipped entirely if the gem is not loaded.
|
|
8
|
+
module Inertia
|
|
9
|
+
module Renderer
|
|
10
|
+
def render
|
|
11
|
+
env = @request&.env
|
|
12
|
+
env["railwatch.inertia_component"] = @component.to_s if env
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Private on InertiaRails::Renderer, only called when SSR is enabled
|
|
17
|
+
# and the request isn't itself an Inertia XHR visit -- so this adds
|
|
18
|
+
# no cost to the common (non-SSR) render path.
|
|
19
|
+
def ssr_render
|
|
20
|
+
start = Clock.monotonic
|
|
21
|
+
super
|
|
22
|
+
ensure
|
|
23
|
+
env = @request&.env
|
|
24
|
+
env["railwatch.inertia_ssr_ms"] = ((Clock.monotonic - start) * 1000).round(2) if env
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.install!
|
|
29
|
+
return unless defined?(::InertiaRails::Renderer)
|
|
30
|
+
::InertiaRails::Renderer.prepend(Renderer) unless ::InertiaRails::Renderer.ancestors.include?(Renderer)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|