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,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module Railwatch
|
|
6
|
+
module Patches
|
|
7
|
+
# Outgoing HTTP. Net::HTTP is under Faraday's default adapter, HTTParty,
|
|
8
|
+
# RestClient, and ruby-llm, so one prepend covers most of the ecosystem.
|
|
9
|
+
# Requests to the Railwatch ingest itself are skipped.
|
|
10
|
+
module NetHttp
|
|
11
|
+
REENTRY = :railwatch_net_http
|
|
12
|
+
|
|
13
|
+
def request(req, body = nil, &block)
|
|
14
|
+
return super if Thread.current[REENTRY] || !Railwatch.enabled? || railwatch_self_request?
|
|
15
|
+
exe = Railwatch.execution
|
|
16
|
+
# Before the recording? gate: a sampled-out execution still propagates
|
|
17
|
+
# its trace context, just with the "not sampled" flag.
|
|
18
|
+
Railwatch::Patches::NetHttp.propagate_trace(req, address)
|
|
19
|
+
return super if exe && !exe.recording?
|
|
20
|
+
|
|
21
|
+
Thread.current[REENTRY] = true
|
|
22
|
+
start = Clock.monotonic
|
|
23
|
+
started_at = Clock.now
|
|
24
|
+
response = nil
|
|
25
|
+
error = nil
|
|
26
|
+
begin
|
|
27
|
+
response = super
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
error = e
|
|
30
|
+
raise
|
|
31
|
+
ensure
|
|
32
|
+
Thread.current[REENTRY] = nil
|
|
33
|
+
exe&.count(:outgoing_requests)
|
|
34
|
+
Railwatch::Patches::NetHttp.record(self, req, response, error, start, started_at)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def railwatch_self_request?
|
|
39
|
+
ingest = URI(Railwatch.config.ingest_url)
|
|
40
|
+
address == ingest.host && port == ingest.port
|
|
41
|
+
rescue StandardError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Never overwrites a traceparent the app set itself.
|
|
46
|
+
def self.propagate_trace(req, host)
|
|
47
|
+
return if req.key?("traceparent")
|
|
48
|
+
|
|
49
|
+
traceparent = Railwatch.traceparent(host)
|
|
50
|
+
req["traceparent"] = traceparent if traceparent
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Railwatch.debug { "traceparent propagation failed: #{e.message}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.record(http, req, response, error, start, started_at)
|
|
56
|
+
host = http.address
|
|
57
|
+
default_port = http.use_ssl? ? 443 : 80
|
|
58
|
+
url = "#{http.use_ssl? ? 'https' : 'http'}://#{host}#{http.port == default_port ? '' : ":#{http.port}"}#{req.path}"
|
|
59
|
+
Railwatch.record(:outgoing_request,
|
|
60
|
+
group: Record.group_hash(host, req.method),
|
|
61
|
+
timestamp: started_at,
|
|
62
|
+
host: host,
|
|
63
|
+
method: req.method,
|
|
64
|
+
url: Record.url_without_sensitive_components(url, limit: 2048),
|
|
65
|
+
duration: Clock.micros_since(start),
|
|
66
|
+
status_code: response&.code.to_i,
|
|
67
|
+
request_size: (req.body || "").bytesize,
|
|
68
|
+
response_size: response ? (response["Content-Length"]&.to_i || response.body&.bytesize rescue nil) : nil,
|
|
69
|
+
error: error && "#{error.class}: #{error.message}"[0, 255],
|
|
70
|
+
response_body: response_body(response, error),
|
|
71
|
+
source: Backtrace.caller_location(skip: 4))
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Railwatch.debug { "outgoing request record failed: #{e.message}" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
RESPONSE_BODY_MAX = 4096
|
|
77
|
+
|
|
78
|
+
def self.response_body(response, error)
|
|
79
|
+
return nil unless Railwatch.config.capture_response_body_on_error
|
|
80
|
+
return nil unless error || response&.code.to_i >= 400
|
|
81
|
+
|
|
82
|
+
# Net::HTTPResponse#body reads from the socket the first time it is
|
|
83
|
+
# called, which would consume a response the caller is streaming out
|
|
84
|
+
# of #read_body. @body holds a String only once Net::HTTP has already
|
|
85
|
+
# buffered the whole body (which #request does for every response it
|
|
86
|
+
# isn't streaming), so reading it here can never touch the socket.
|
|
87
|
+
captured_response_body(response&.instance_variable_get(:@body))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Shared with Railwatch::Faraday. A JSON object body goes through the
|
|
91
|
+
# same parameter filter as request params and is re-serialized; any
|
|
92
|
+
# other body has no keys to match, so it is stored as it arrived.
|
|
93
|
+
def self.captured_response_body(body)
|
|
94
|
+
return nil unless body.is_a?(String) && !body.empty?
|
|
95
|
+
|
|
96
|
+
parsed = (JSON.parse(body) rescue nil)
|
|
97
|
+
body = JSON.generate(Railwatch.redactor.params(parsed)) if parsed.is_a?(Hash)
|
|
98
|
+
body[0, RESPONSE_BODY_MAX]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Patches
|
|
5
|
+
# Rake tasks are Rails' commands. Each top-level task invocation is a
|
|
6
|
+
# `command` execution; nested tasks (prerequisites) run inside it.
|
|
7
|
+
module RakeTask
|
|
8
|
+
SKIP = %w[environment].freeze
|
|
9
|
+
|
|
10
|
+
def execute(args = nil)
|
|
11
|
+
run_as_command(args_suffix(args)) { super }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Rake dispatches a task's prerequisites via #invoke (Task#invoke_with_call_chain
|
|
15
|
+
# calls invoke_prerequisites, which fully invokes -- and executes -- each
|
|
16
|
+
# prerequisite, BEFORE calling the dependent task's own #execute). Patching
|
|
17
|
+
# #invoke means the top-level command execution is already open by the time
|
|
18
|
+
# prerequisites run, so their own #invoke/#execute calls see Railwatch.execution
|
|
19
|
+
# is not nil and just run plain, nesting inside the one command record instead
|
|
20
|
+
# of each starting (and finishing) their own.
|
|
21
|
+
def invoke(*args)
|
|
22
|
+
run_as_command(args.any? ? "[#{args.join(',')}]" : "") { super }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# A vendor-excluded task (db:migrate, say) can internally call
|
|
28
|
+
# `Rake::Task["db:_dump"].invoke` from its own action body -- an
|
|
29
|
+
# implementation detail, not a prerequisite -- to run a task that isn't
|
|
30
|
+
# itself vendor-excluded. With no execution open (the outer task never
|
|
31
|
+
# started one), that inner call looks exactly like a fresh top-level
|
|
32
|
+
# invocation and would ship its own unwanted command record. This flag
|
|
33
|
+
# marks "we're inside a task we deliberately chose not to track," so
|
|
34
|
+
# anything invoked underneath it is left untracked too.
|
|
35
|
+
def run_as_command(command_suffix)
|
|
36
|
+
return yield unless Railwatch.enabled? && !SKIP.include?(name) && Railwatch.execution.nil?
|
|
37
|
+
return yield if Thread.current[:railwatch_vendor_excluded_rake]
|
|
38
|
+
|
|
39
|
+
if vendor_excluded?
|
|
40
|
+
Thread.current[:railwatch_vendor_excluded_rake] = true
|
|
41
|
+
begin
|
|
42
|
+
return yield
|
|
43
|
+
ensure
|
|
44
|
+
Thread.current[:railwatch_vendor_excluded_rake] = false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
exe = Railwatch.start_execution(source: :command, sample_kind: :commands, preview: "rake #{name}")
|
|
49
|
+
exe.enter_stage(:action)
|
|
50
|
+
exit_code = 0
|
|
51
|
+
begin
|
|
52
|
+
yield
|
|
53
|
+
rescue SystemExit => e
|
|
54
|
+
exit_code = e.status
|
|
55
|
+
raise
|
|
56
|
+
rescue SignalException => e
|
|
57
|
+
# SIGTERM/SIGINT is how Kamal, systemd and Ctrl-C stop a long-running
|
|
58
|
+
# task (a litestream replicator, a queue worker); it is a shutdown,
|
|
59
|
+
# not a failure, so the command closes with the signal's exit code
|
|
60
|
+
# and no exception is reported.
|
|
61
|
+
exit_code = 128 + (e.signo || 0)
|
|
62
|
+
raise
|
|
63
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
64
|
+
exit_code = 1
|
|
65
|
+
Railwatch::Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "application.rake")
|
|
66
|
+
raise
|
|
67
|
+
ensure
|
|
68
|
+
exe.finish_stages
|
|
69
|
+
Railwatch.finish_execution(:command,
|
|
70
|
+
group: Record.group_hash(name),
|
|
71
|
+
class: "Rake::Task",
|
|
72
|
+
name: name,
|
|
73
|
+
command: "rake #{name}#{command_suffix}",
|
|
74
|
+
exit_code: exit_code.to_i.clamp(0, 255))
|
|
75
|
+
Railwatch.flush
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def args_suffix(args)
|
|
80
|
+
args.respond_to?(:to_a) && args.to_a.any? ? "[#{args.to_a.join(',')}]" : ""
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def vendor_excluded?
|
|
84
|
+
!Railwatch.config.capture_default_vendor_commands && Configuration::DEFAULT_VENDOR_COMMANDS.include?(name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Patches
|
|
5
|
+
# `bin/rails runner` is a command execution, same as a rake task, but
|
|
6
|
+
# Rails::Command::RunnerCommand#perform isn't a Rake::Task so it needs its
|
|
7
|
+
# own prepend.
|
|
8
|
+
#
|
|
9
|
+
# Two very different things arrive here, and only one of them is an issue.
|
|
10
|
+
# A DEPLOYED script -- `rails runner script/nightly.rb` from cron, a
|
|
11
|
+
# release step, a container entrypoint -- must report: a nightly job that
|
|
12
|
+
# starts dying is exactly what monitoring is for. An INTERACTIVE run is an
|
|
13
|
+
# engineer at a shell typing at production, and their typo (a misspelled
|
|
14
|
+
# attribute, a tenant slug that does not exist, an `unless ... next` that
|
|
15
|
+
# does not parse) is the ops equivalent of a shell error, not a bug in the
|
|
16
|
+
# app. Under Sentry those typos opened four of fifteen unresolved issues
|
|
17
|
+
# in this app and woke an automated responder each time.
|
|
18
|
+
#
|
|
19
|
+
# The line is WHERE THE CODE CAME FROM, which railties makes plain -- its
|
|
20
|
+
# #perform reaches the operator's code through three call sites:
|
|
21
|
+
#
|
|
22
|
+
# rails runner - -> eval($stdin.read, TOPLEVEL_BINDING, "stdin")
|
|
23
|
+
# rails runner 'Some.code' -> eval(code_or_file, TOPLEVEL_BINDING, __FILE__, __LINE__)
|
|
24
|
+
# rails runner script.rb -> Kernel.load(expanded_file_path)
|
|
25
|
+
#
|
|
26
|
+
# so the argument alone answers it: `-` is piped, anything that is not a
|
|
27
|
+
# `.rb` path was typed inline, and a `.rb` path is a file -- deployed
|
|
28
|
+
# unless it sits in a scratch directory (config.interactive_runner_paths),
|
|
29
|
+
# because nothing an app deploys lives in /tmp.
|
|
30
|
+
#
|
|
31
|
+
# An interactive run still opens its execution and still ships the
|
|
32
|
+
# `command` record (with exit_code, and `interactive: true`) -- you can
|
|
33
|
+
# see that someone ran it, and what it cost. Only the exception is
|
|
34
|
+
# withheld, and that is done by flagging the execution rather than by
|
|
35
|
+
# skipping the capture below: the Rails executor hands the error to
|
|
36
|
+
# Rails.error inside `super`, so Subscribers::Exceptions has already seen
|
|
37
|
+
# it by the time this rescue runs.
|
|
38
|
+
module RunnerCommand
|
|
39
|
+
def perform(code_or_file = nil, *command_argv)
|
|
40
|
+
return super unless Railwatch.enabled? && Railwatch.execution.nil?
|
|
41
|
+
|
|
42
|
+
RunnerCommand.instrument(code_or_file) { super }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The prepend happens from the engine's runner hook, which #perform
|
|
46
|
+
# reaches (through boot_application! and load_runner) after it has
|
|
47
|
+
# already started -- so in a real `bin/rails runner` process the
|
|
48
|
+
# invocation on the stack is the unpatched one and the override above
|
|
49
|
+
# never runs (it does when railties' runner command was loaded before
|
|
50
|
+
# boot, as in this gem's own specs). Method lookup is dynamic, though:
|
|
51
|
+
# by the time that #perform reaches conditional_executor the prepend
|
|
52
|
+
# is in place, so this is where a runner started from the shell gets
|
|
53
|
+
# its execution. Thor keeps the positional arguments on #args, and the
|
|
54
|
+
# first one is code_or_file.
|
|
55
|
+
def conditional_executor(enabled, **kwargs, &block)
|
|
56
|
+
return super unless Railwatch.enabled? && Railwatch.execution.nil?
|
|
57
|
+
|
|
58
|
+
RunnerCommand.instrument(args.first) { super }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Opens the command execution around the runner's work, withholding the
|
|
62
|
+
# exception (but not the command record) for an interactive run.
|
|
63
|
+
def self.instrument(code_or_file)
|
|
64
|
+
preview = code_or_file.to_s[0, 200]
|
|
65
|
+
interactive = interactive?(code_or_file)
|
|
66
|
+
exe = Railwatch.start_execution(source: :command, sample_kind: :commands, preview: "rails runner #{preview}")
|
|
67
|
+
exe.interactive = true if interactive
|
|
68
|
+
exe.enter_stage(:action)
|
|
69
|
+
exit_code = 0
|
|
70
|
+
begin
|
|
71
|
+
yield
|
|
72
|
+
rescue SystemExit => e
|
|
73
|
+
exit_code = e.status
|
|
74
|
+
raise
|
|
75
|
+
rescue SignalException => e
|
|
76
|
+
# A signal ends the runner by design (see the rake patch); not reported.
|
|
77
|
+
exit_code = 128 + (e.signo || 0)
|
|
78
|
+
raise
|
|
79
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
80
|
+
exit_code = 1
|
|
81
|
+
Railwatch::Subscribers::Exceptions.capture(e, handled: false, severity: :error, source: "application.runner")
|
|
82
|
+
raise
|
|
83
|
+
ensure
|
|
84
|
+
exe.finish_stages
|
|
85
|
+
fields = {
|
|
86
|
+
group: Record.group_hash("runner"),
|
|
87
|
+
class: "Rails::Command::RunnerCommand",
|
|
88
|
+
name: "runner",
|
|
89
|
+
command: "rails runner #{preview}",
|
|
90
|
+
exit_code: exit_code.to_i.clamp(0, 255)
|
|
91
|
+
}
|
|
92
|
+
fields[:interactive] = true if interactive
|
|
93
|
+
Railwatch.finish_execution(:command, **fields)
|
|
94
|
+
Railwatch.flush
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Typed or piped by a human, rather than loaded from a deployed file.
|
|
99
|
+
def self.interactive?(code_or_file)
|
|
100
|
+
argument = code_or_file.to_s
|
|
101
|
+
# "-" (stdin), "" (railties prints help and exits), and inline code.
|
|
102
|
+
return true unless argument.end_with?(".rb")
|
|
103
|
+
|
|
104
|
+
scratch?(argument)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Expanded so a relative path is judged by where it actually resolves.
|
|
108
|
+
# An argument File.expand_path refuses (a "~nobody/x.rb") is matched
|
|
109
|
+
# as-is rather than assumed interactive: when in doubt, report.
|
|
110
|
+
def self.scratch?(argument)
|
|
111
|
+
path = begin
|
|
112
|
+
File.expand_path(argument)
|
|
113
|
+
rescue StandardError
|
|
114
|
+
argument
|
|
115
|
+
end
|
|
116
|
+
Railwatch.config.interactive_runner_paths.any? { |directory| path.start_with?(directory) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "railwatch/patches/net_http"
|
|
4
|
+
require "railwatch/patches/rake_task"
|
|
5
|
+
require "railwatch/patches/runner_command"
|
|
6
|
+
require "railwatch/patches/inertia"
|
|
7
|
+
|
|
8
|
+
module Railwatch
|
|
9
|
+
module Patches
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The patches every process needs. Rake and the runner command are
|
|
13
|
+
# installed from the engine's rake_tasks and runner hooks instead, which
|
|
14
|
+
# fire only in a process that is actually about to run one: requiring
|
|
15
|
+
# rake and railties' runner command in every web and worker boot cost
|
|
16
|
+
# about 170 ms for code those processes never call.
|
|
17
|
+
def install!
|
|
18
|
+
::Net::HTTP.prepend(NetHttp) unless ::Net::HTTP.ancestors.include?(NetHttp)
|
|
19
|
+
Inertia.install!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# From Rails::Engine#load_tasks, which has already required rake.
|
|
23
|
+
def install_rake_task!
|
|
24
|
+
::Rake::Task.prepend(RakeTask) unless ::Rake::Task.ancestors.include?(RakeTask)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# From Rails::Application#load_runner, which RunnerCommand#perform calls
|
|
28
|
+
# after boot_application! -- so the class is loaded by the time this
|
|
29
|
+
# runs, and the prepend is in place before conditional_executor.
|
|
30
|
+
def install_runner_command!
|
|
31
|
+
require "rails/command"
|
|
32
|
+
require "rails/commands/runner/runner_command"
|
|
33
|
+
unless ::Rails::Command::RunnerCommand.ancestors.include?(RunnerCommand)
|
|
34
|
+
::Rails::Command::RunnerCommand.prepend(RunnerCommand)
|
|
35
|
+
end
|
|
36
|
+
rescue LoadError, StandardError => e
|
|
37
|
+
# A railties rename would land here; say so rather than leaving every
|
|
38
|
+
# deployed `rails runner` script silently untraced.
|
|
39
|
+
Railwatch.debug { "runner command patch not installed: #{e.class}: #{e.message}" }
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
# Sampling profiler for one execution, on top of whichever backend the app
|
|
5
|
+
# has in its Gemfile: Vernier (preferred) or StackProf. Both are optional
|
|
6
|
+
# dependencies, and every path here degrades to nil rather than raising --
|
|
7
|
+
# a profile is a nice-to-have, never a reason to break a request.
|
|
8
|
+
#
|
|
9
|
+
# Both backends are process-global: there is one profiler per process, not
|
|
10
|
+
# one per thread. An execution that starts while another one is being
|
|
11
|
+
# profiled is simply not profiled, and is counted in `skipped`.
|
|
12
|
+
module Profiler
|
|
13
|
+
# What `stop` hands back. `interval` and `duration` are microseconds;
|
|
14
|
+
# `collapsed` is the folded-stack text described on `collapse`.
|
|
15
|
+
Profile = Struct.new(:profiler, :mode, :interval, :duration, :samples, :collapsed)
|
|
16
|
+
|
|
17
|
+
# The profile currently running in this process: which backend started
|
|
18
|
+
# it, when, and on which thread. Vernier samples every thread in the
|
|
19
|
+
# process, and only the thread that asked for the profile is running
|
|
20
|
+
# this execution.
|
|
21
|
+
Handle = Struct.new(:backend, :mode, :interval, :started, :thread_id)
|
|
22
|
+
|
|
23
|
+
# Preference order when config.profiler doesn't pin one.
|
|
24
|
+
BACKENDS = %i[vernier stackprof].freeze
|
|
25
|
+
|
|
26
|
+
# Uncompressed cap on the collapsed text. Rails stacks run 30-200 frames
|
|
27
|
+
# deep, so a busy request folds to a few hundred KiB; gzip takes that
|
|
28
|
+
# down ~30x, well inside a batch. Over the cap the least frequent stacks
|
|
29
|
+
# are dropped: the long tail of one-sample stacks, not the profile's
|
|
30
|
+
# shape.
|
|
31
|
+
MAX_COLLAPSED_BYTES = 4 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
# Frames with no Ruby file of their own (C functions). Vernier reports
|
|
34
|
+
# those as "<cfunc>", StackProf as "<cfunc>" with a nil line.
|
|
35
|
+
CFUNC = "<cfunc>"
|
|
36
|
+
|
|
37
|
+
# Ruby's own stdlib directory, e.g. .../lib/ruby/3.4.0/.
|
|
38
|
+
RUBY_LIB_PREFIX = "#{RbConfig::CONFIG['rubylibdir']}/"
|
|
39
|
+
|
|
40
|
+
@lock = Mutex.new
|
|
41
|
+
@running = nil
|
|
42
|
+
@loadable = {}
|
|
43
|
+
@skipped = 0
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Executions that wanted a profile while another one was already being
|
|
47
|
+
# profiled in this process. Read by tests and diagnostics.
|
|
48
|
+
attr_reader :skipped
|
|
49
|
+
|
|
50
|
+
# Whether this process can profile at all. Memoised: the require is
|
|
51
|
+
# the expensive part (see loadable?), choosing between two symbols is
|
|
52
|
+
# not, so config.profiler stays live and overridable.
|
|
53
|
+
def available?
|
|
54
|
+
!backend.nil?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# :vernier, :stackprof, or nil when neither gem is installed.
|
|
58
|
+
# config.profiler pins one by name (an unknown name simply doesn't
|
|
59
|
+
# load, so profiling stays off); otherwise the first backend that
|
|
60
|
+
# loads wins.
|
|
61
|
+
def backend
|
|
62
|
+
wanted = Railwatch.config.profiler
|
|
63
|
+
return loadable?(wanted.to_sym) ? wanted.to_sym : nil if wanted
|
|
64
|
+
|
|
65
|
+
BACKENDS.find { |name| loadable?(name) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Starts the process-global profiler and returns a handle, or nil when
|
|
69
|
+
# no backend is installed, one is already running, or the backend
|
|
70
|
+
# refused to start.
|
|
71
|
+
def start(mode: :wall)
|
|
72
|
+
return nil unless available?
|
|
73
|
+
|
|
74
|
+
@lock.synchronize do
|
|
75
|
+
if @running
|
|
76
|
+
@skipped += 1
|
|
77
|
+
next nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
handle = Handle.new(backend, mode, Railwatch.config.profile_interval_us,
|
|
81
|
+
Clock.monotonic, Thread.current.object_id)
|
|
82
|
+
# StackProf returns false rather than raising when something else
|
|
83
|
+
# in the process is already profiling.
|
|
84
|
+
if start_backend(handle) == false
|
|
85
|
+
@skipped += 1
|
|
86
|
+
next nil
|
|
87
|
+
end
|
|
88
|
+
@running = handle
|
|
89
|
+
end
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
Railwatch.debug { "profiler start failed: #{e.class}: #{e.message}" }
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Stops the running profile and folds it into a Profile, or nil when
|
|
96
|
+
# nothing was running or anything at all went wrong on the way.
|
|
97
|
+
def stop
|
|
98
|
+
handle = @lock.synchronize { @running.tap { @running = nil } }
|
|
99
|
+
return nil unless handle
|
|
100
|
+
|
|
101
|
+
duration = Clock.micros_since(handle.started)
|
|
102
|
+
result = stop_backend(handle)
|
|
103
|
+
return nil unless result
|
|
104
|
+
|
|
105
|
+
counts = sample_counts(result, handle.thread_id)
|
|
106
|
+
Profile.new(handle.backend, handle.mode, handle.interval, duration,
|
|
107
|
+
counts.each_value.sum, format_counts(counts))
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
Railwatch.debug { "profiler stop failed: #{e.class}: #{e.message}" }
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Folded-stack text for a backend result: one `outermost;...;leaf
|
|
114
|
+
# count` line per unique stack, most sampled first with ties broken by
|
|
115
|
+
# the stack text, so the same profile always serialises to the same
|
|
116
|
+
# bytes. Capped at MAX_COLLAPSED_BYTES.
|
|
117
|
+
def collapse(result, thread_id: nil)
|
|
118
|
+
format_counts(sample_counts(result, thread_id))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Test hook: forgets which backends loaded, plus any state a killed
|
|
122
|
+
# profile left behind.
|
|
123
|
+
def reset!
|
|
124
|
+
@lock.synchronize do
|
|
125
|
+
@running = nil
|
|
126
|
+
@loadable = {}
|
|
127
|
+
@skipped = 0
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Process._fork hook. A child inherits @running still holding the
|
|
132
|
+
# Handle for a profile the parent was taking, and nothing in the child
|
|
133
|
+
# ever stops it: `start` then sees a profile already running and every
|
|
134
|
+
# execution in that worker is counted as skipped and never profiled
|
|
135
|
+
# again. Clear the process-global state without taking @lock -- the
|
|
136
|
+
# child is single-threaded here, and Ruby has already abandoned any
|
|
137
|
+
# mutex a parent thread held across the fork.
|
|
138
|
+
def restart_after_fork!
|
|
139
|
+
@running = nil
|
|
140
|
+
@loadable = {}
|
|
141
|
+
@skipped = 0
|
|
142
|
+
self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Whether a backend gem can be required, resolved once per process:
|
|
148
|
+
# requiring is what has to be memoised, not the choice between two
|
|
149
|
+
# symbols.
|
|
150
|
+
def loadable?(name)
|
|
151
|
+
return @loadable[name] if @loadable.key?(name)
|
|
152
|
+
|
|
153
|
+
@loadable[name] = begin
|
|
154
|
+
require name.to_s
|
|
155
|
+
true
|
|
156
|
+
rescue LoadError
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def start_backend(handle)
|
|
162
|
+
case handle.backend
|
|
163
|
+
when :vernier then ::Vernier.start_profile(mode: handle.mode, interval: handle.interval)
|
|
164
|
+
when :stackprof then ::StackProf.start(mode: handle.mode, interval: handle.interval, raw: true)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def stop_backend(handle)
|
|
169
|
+
case handle.backend
|
|
170
|
+
when :vernier
|
|
171
|
+
::Vernier.stop_profile
|
|
172
|
+
when :stackprof
|
|
173
|
+
::StackProf.stop
|
|
174
|
+
::StackProf.results
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# {collapsed stack => sample count}. StackProf hands back a Hash,
|
|
179
|
+
# Vernier a Vernier::Result.
|
|
180
|
+
def sample_counts(result, thread_id)
|
|
181
|
+
result.is_a?(Hash) ? stackprof_counts(result) : vernier_counts(result, thread_id)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Vernier samples every thread in the process -- an idle thread
|
|
185
|
+
# sleeping through the whole window otherwise outweighs the work being
|
|
186
|
+
# profiled -- so only the thread that started the profile is folded in.
|
|
187
|
+
def vernier_counts(result, thread_id)
|
|
188
|
+
counts = Hash.new(0)
|
|
189
|
+
thread = result.threads[thread_id] || result.main_thread
|
|
190
|
+
return counts unless thread
|
|
191
|
+
|
|
192
|
+
table = result.stack_table
|
|
193
|
+
labels = {}
|
|
194
|
+
lines = {}
|
|
195
|
+
weights = thread[:weights]
|
|
196
|
+
thread[:samples].each_with_index do |stack_idx, i|
|
|
197
|
+
counts[lines[stack_idx] ||= vernier_line(table, stack_idx, labels)] += weights[i]
|
|
198
|
+
end
|
|
199
|
+
counts
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Vernier's stack table is a tree of leaf -> parent links, so the walk
|
|
203
|
+
# comes out leaf-first and is reversed into folded order.
|
|
204
|
+
def vernier_line(table, stack_idx, labels)
|
|
205
|
+
frames = []
|
|
206
|
+
while stack_idx
|
|
207
|
+
func_idx = table.frame_func_idx(table.stack_frame_idx(stack_idx))
|
|
208
|
+
frames << (labels[func_idx] ||= label(table.func_name(func_idx), table.func_filename(func_idx),
|
|
209
|
+
table.func_first_lineno(func_idx)))
|
|
210
|
+
stack_idx = table.stack_parent_idx(stack_idx)
|
|
211
|
+
end
|
|
212
|
+
frames.reverse.join(";")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# StackProf's :raw is a flat array of `depth, frame_id * depth, weight`
|
|
216
|
+
# groups with the outermost frame first -- decoded exactly as
|
|
217
|
+
# StackProf::Report#print_stackcollapse decodes it.
|
|
218
|
+
def stackprof_counts(result)
|
|
219
|
+
counts = Hash.new(0)
|
|
220
|
+
frames = result[:frames]
|
|
221
|
+
raw = result[:raw]
|
|
222
|
+
return counts unless frames && raw
|
|
223
|
+
|
|
224
|
+
labels = {}
|
|
225
|
+
i = 0
|
|
226
|
+
while (depth = raw[i])
|
|
227
|
+
line = raw[i + 1, depth].map { |id| labels[id] ||= stackprof_label(frames[id]) }.join(";")
|
|
228
|
+
counts[line] += raw[i + depth + 1].to_i
|
|
229
|
+
i += depth + 2
|
|
230
|
+
end
|
|
231
|
+
counts
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def stackprof_label(frame)
|
|
235
|
+
label(frame[:name], frame[:file], frame[:line])
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# "Class#method (path:line)".
|
|
239
|
+
def label(name, file, line)
|
|
240
|
+
"#{name} (#{short_path(file)}:#{line.to_i})"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# App paths lose the Rails root, installed-gem paths become
|
|
244
|
+
# "<gem>/relative/path" (version dropped: it repeats on every frame of
|
|
245
|
+
# every line and the deploy already records it), Ruby's own lib
|
|
246
|
+
# becomes "ruby/...". A frame with no file at all is a C function.
|
|
247
|
+
def short_path(file)
|
|
248
|
+
file = file.to_s
|
|
249
|
+
return CFUNC if file.empty? || file == CFUNC
|
|
250
|
+
return file.delete_prefix(Backtrace.app_root) if file.start_with?(Backtrace.app_root)
|
|
251
|
+
return "ruby/#{file.delete_prefix(RUBY_LIB_PREFIX)}" if file.start_with?(RUBY_LIB_PREFIX)
|
|
252
|
+
return file unless Backtrace.installed_gem_path?(file)
|
|
253
|
+
|
|
254
|
+
dir = Gem.path.find { |path| file.start_with?("#{path}/") }
|
|
255
|
+
rest = file.delete_prefix("#{dir}/").delete_prefix("gems/")
|
|
256
|
+
rest.sub(/\A([^\/]+?)-\d[^\/]*\//, '\1/')
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def format_counts(counts)
|
|
260
|
+
text = +""
|
|
261
|
+
counts.sort_by { |stack, count| [ -count, stack ] }.each do |stack, count|
|
|
262
|
+
line = "#{stack} #{count}\n"
|
|
263
|
+
break if text.bytesize + line.bytesize > MAX_COLLAPSED_BYTES
|
|
264
|
+
text << line
|
|
265
|
+
end
|
|
266
|
+
text
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|