railwatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +122 -0
  3. data/CHANGELOG.md +462 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +226 -0
  6. data/app/controllers/railwatch/beacon_controller.rb +254 -0
  7. data/config/routes.rb +5 -0
  8. data/docs/ai-and-mcp.md +227 -0
  9. data/docs/configuration.md +931 -0
  10. data/docs/faq.md +230 -0
  11. data/docs/getting-started.md +279 -0
  12. data/docs/records.md +834 -0
  13. data/docs/replacing-nightwatch.md +216 -0
  14. data/docs/replacing-sentry.md +573 -0
  15. data/docs/security.md +94 -0
  16. data/docs/self-hosting.md +60 -0
  17. data/docs/source-maps.md +60 -0
  18. data/docs/testing.md +175 -0
  19. data/docs/troubleshooting.md +319 -0
  20. data/lib/generators/railwatch/install/install_generator.rb +280 -0
  21. data/lib/generators/railwatch/install/templates/initializer.rb +54 -0
  22. data/lib/generators/railwatch/install/templates/post-deploy +98 -0
  23. data/lib/generators/railwatch/install/templates/railwatch.ts +658 -0
  24. data/lib/railwatch/attachments.rb +83 -0
  25. data/lib/railwatch/backtrace.rb +158 -0
  26. data/lib/railwatch/buffer.rb +122 -0
  27. data/lib/railwatch/clock.rb +25 -0
  28. data/lib/railwatch/configuration.rb +334 -0
  29. data/lib/railwatch/console.rb +48 -0
  30. data/lib/railwatch/context.rb +125 -0
  31. data/lib/railwatch/controller_helpers.rb +21 -0
  32. data/lib/railwatch/current.rb +32 -0
  33. data/lib/railwatch/engine.rb +144 -0
  34. data/lib/railwatch/execution.rb +367 -0
  35. data/lib/railwatch/faraday.rb +73 -0
  36. data/lib/railwatch/health.rb +188 -0
  37. data/lib/railwatch/job_tracing.rb +49 -0
  38. data/lib/railwatch/middleware/request.rb +289 -0
  39. data/lib/railwatch/minitest.rb +43 -0
  40. data/lib/railwatch/patches/inertia.rb +34 -0
  41. data/lib/railwatch/patches/net_http.rb +102 -0
  42. data/lib/railwatch/patches/rake_task.rb +88 -0
  43. data/lib/railwatch/patches/runner_command.rb +120 -0
  44. data/lib/railwatch/patches.rb +43 -0
  45. data/lib/railwatch/profiler.rb +270 -0
  46. data/lib/railwatch/record.rb +119 -0
  47. data/lib/railwatch/redactor.rb +67 -0
  48. data/lib/railwatch/release_detector.rb +97 -0
  49. data/lib/railwatch/reporter.rb +539 -0
  50. data/lib/railwatch/rspec.rb +139 -0
  51. data/lib/railwatch/sampler.rb +17 -0
  52. data/lib/railwatch/secret_safety.rb +62 -0
  53. data/lib/railwatch/sessions.rb +162 -0
  54. data/lib/railwatch/source_maps.rb +59 -0
  55. data/lib/railwatch/spec_helper.rb +147 -0
  56. data/lib/railwatch/sql_normalizer.rb +398 -0
  57. data/lib/railwatch/subscribers/base.rb +54 -0
  58. data/lib/railwatch/subscribers/broadcasts.rb +107 -0
  59. data/lib/railwatch/subscribers/cache.rb +107 -0
  60. data/lib/railwatch/subscribers/deprecations.rb +26 -0
  61. data/lib/railwatch/subscribers/exceptions.rb +304 -0
  62. data/lib/railwatch/subscribers/jobs.rb +282 -0
  63. data/lib/railwatch/subscribers/logs.rb +137 -0
  64. data/lib/railwatch/subscribers/mail.rb +42 -0
  65. data/lib/railwatch/subscribers/notifications.rb +36 -0
  66. data/lib/railwatch/subscribers/process_info.rb +98 -0
  67. data/lib/railwatch/subscribers/queries.rb +183 -0
  68. data/lib/railwatch/subscribers/requests.rb +94 -0
  69. data/lib/railwatch/subscribers/storage.rb +35 -0
  70. data/lib/railwatch/subscribers/users.rb +159 -0
  71. data/lib/railwatch/subscribers/views.rb +54 -0
  72. data/lib/railwatch/subscribers.rb +34 -0
  73. data/lib/railwatch/transport/http.rb +208 -0
  74. data/lib/railwatch/version.rb +5 -0
  75. data/lib/railwatch.rb +550 -0
  76. data/lib/tasks/railwatch_tasks.rake +289 -0
  77. data/llms.txt +38 -0
  78. metadata +157 -0
@@ -0,0 +1,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