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,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Captures Rails.logger lines (via a Logger subclass swap on the broadcast
|
|
6
|
+
# logger) and Rails 8.1 structured events (Rails.event). Level filter
|
|
7
|
+
# comes from config.log_level.
|
|
8
|
+
module Logs
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
LEVELS = %w[debug info warn error fatal unknown].freeze
|
|
12
|
+
# Computed once so the hot log record doesn't look this up per call.
|
|
13
|
+
LOG_VERSION = Record::VERSIONS.fetch(:log)
|
|
14
|
+
# Lines Rails itself logs per request/job; the request and job records
|
|
15
|
+
# already carry this information, so they are not stored as logs.
|
|
16
|
+
FRAMEWORK_NOISE = /\A\s*(Started [A-Z]+ "|Processing by |Completed \d{3} |Parameters: \{|Rendered |Rendering |Performing |Performed |Enqueued |\[ActiveJob\]|Cannot render console)/.freeze
|
|
17
|
+
# Literal prefixes for the lines FRAMEWORK_NOISE matches, checked first
|
|
18
|
+
# with cheap start_with? so the regex only runs on lines that could
|
|
19
|
+
# plausibly match, instead of on every captured log line.
|
|
20
|
+
FRAMEWORK_NOISE_PREFIXES = %w[Started Processing Completed Parameters Rendered Rendering Performing Performed Enqueued [ActiveJob] Cannot].freeze
|
|
21
|
+
|
|
22
|
+
# Rails 8.1's own structured events (one per request/job phase); the
|
|
23
|
+
# request and job records already carry this information, so they are
|
|
24
|
+
# dropped unless an app opts in via config.capture_framework_events.
|
|
25
|
+
FRAMEWORK_EVENT_PREFIXES = %w[action_controller. action_dispatch. active_job. active_record.
|
|
26
|
+
action_view. action_mailer. active_storage. action_cable.].freeze
|
|
27
|
+
|
|
28
|
+
class Capture < ::Logger
|
|
29
|
+
# BroadcastLogger#debug? is true when ANY broadcast is at DEBUG, and
|
|
30
|
+
# every framework LogSubscriber (Active Record's SQL line, Action
|
|
31
|
+
# View's render lines, the cache store's) formats its message only
|
|
32
|
+
# when it is. A Logger.new(nil) sits at DEBUG, which made adding
|
|
33
|
+
# Railwatch turn all of that formatting on for lines nobody stored.
|
|
34
|
+
# Starting at config.log_level instead keeps the app's own level in
|
|
35
|
+
# charge. From here on this is an ordinary Logger: `Rails.logger.level
|
|
36
|
+
# =` and `Rails.logger.silence` reach it like any other broadcast, so
|
|
37
|
+
# what the app reads back is what it set.
|
|
38
|
+
def initialize
|
|
39
|
+
super(nil, level: Logs.min_severity)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add(severity, message = nil, progname = nil)
|
|
43
|
+
return true unless Railwatch.enabled?
|
|
44
|
+
return true unless Logs.execution
|
|
45
|
+
severity ||= ::Logger::UNKNOWN
|
|
46
|
+
# Both gates: the Logger's own level (which the app may have raised
|
|
47
|
+
# or silenced) and config.log_level (which may have changed since
|
|
48
|
+
# this logger was built).
|
|
49
|
+
return true if severity < level || severity < Logs.min_severity
|
|
50
|
+
message = progname if message.nil? && !block_given?
|
|
51
|
+
message = yield if message.nil? && block_given?
|
|
52
|
+
Logs.write(LEVELS[severity] || "unknown", message)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class EventSubscriber
|
|
58
|
+
def emit(event)
|
|
59
|
+
return unless Railwatch.enabled?
|
|
60
|
+
name = event[:name].to_s
|
|
61
|
+
return if !Railwatch.config.capture_framework_events && FRAMEWORK_EVENT_PREFIXES.any? { |p| name.start_with?(p) }
|
|
62
|
+
Railwatch.record(:log,
|
|
63
|
+
group: Record.group_hash(name),
|
|
64
|
+
timestamp: event[:timestamp] ? event[:timestamp] / 1_000_000_000.0 : nil,
|
|
65
|
+
level: "event",
|
|
66
|
+
message: name,
|
|
67
|
+
tags: Array(event[:tags]).map(&:to_s),
|
|
68
|
+
context: (JSON.generate(event[:payload]) rescue "{}")[0, 8192],
|
|
69
|
+
source: event.dig(:source_location, :filepath) && "#{event[:source_location][:filepath].delete_prefix(Backtrace.app_root)}:#{event[:source_location][:lineno]}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Keyed by config.log_level so a level change is picked up on the next
|
|
74
|
+
# log line, but the common case (level unchanged) is one hash lookup
|
|
75
|
+
# instead of a #to_s allocation plus a LEVELS#index scan per line.
|
|
76
|
+
@min_severity_cache = {}
|
|
77
|
+
|
|
78
|
+
module_function
|
|
79
|
+
|
|
80
|
+
def install!(_app)
|
|
81
|
+
logger = Rails.logger
|
|
82
|
+
if logger.respond_to?(:broadcast_to)
|
|
83
|
+
logger.broadcast_to(Capture.new)
|
|
84
|
+
elsif logger.respond_to?(:extend)
|
|
85
|
+
logger.extend(ActiveSupport::BroadcastLogger) rescue nil
|
|
86
|
+
logger.broadcast_to(Capture.new) if logger.respond_to?(:broadcast_to)
|
|
87
|
+
end
|
|
88
|
+
Rails.event.subscribe(EventSubscriber.new) if Rails.respond_to?(:event)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def min_severity
|
|
92
|
+
level = Railwatch.config.log_level
|
|
93
|
+
@min_severity_cache[level] ||= (LEVELS.index(level.to_s) || 1)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Cheap literal check before the FRAMEWORK_NOISE regex: most log lines
|
|
97
|
+
# share no prefix with the noise patterns, so this skips the regex
|
|
98
|
+
# engine entirely for them.
|
|
99
|
+
def framework_noise?(text)
|
|
100
|
+
text = text.lstrip if text.start_with?(" ", "\t")
|
|
101
|
+
FRAMEWORK_NOISE_PREFIXES.any? { |p| text.start_with?(p) } && text.match?(FRAMEWORK_NOISE)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def write(level, message)
|
|
105
|
+
exe = execution or return
|
|
106
|
+
text = message.is_a?(String) ? message : message.inspect
|
|
107
|
+
text = text.gsub(/\e\[[\d;]*m/, "") if text.include?("\e")
|
|
108
|
+
return if text.start_with?("[railwatch]") || framework_noise?(text)
|
|
109
|
+
exe.count(:logs)
|
|
110
|
+
return unless recording?
|
|
111
|
+
cfg = Railwatch.config
|
|
112
|
+
# One hash literal instead of kwargs-packing into Railwatch.record --
|
|
113
|
+
# every captured Rails.logger line goes through here.
|
|
114
|
+
Railwatch.push(:log, {
|
|
115
|
+
v: LOG_VERSION,
|
|
116
|
+
t: "log",
|
|
117
|
+
timestamp: Clock.now,
|
|
118
|
+
deploy: cfg.deploy,
|
|
119
|
+
server: cfg.server,
|
|
120
|
+
_group: nil,
|
|
121
|
+
**exe.envelope,
|
|
122
|
+
level: level,
|
|
123
|
+
message: text[0, 8192],
|
|
124
|
+
tags: current_tags,
|
|
125
|
+
context: Context.serialized
|
|
126
|
+
})
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def current_tags
|
|
130
|
+
logger = Rails.logger
|
|
131
|
+
logger.respond_to?(:current_tags) ? logger.current_tags.map(&:to_s) : []
|
|
132
|
+
rescue StandardError
|
|
133
|
+
[]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# process.action_mailer (render) and deliver.action_mailer (send).
|
|
6
|
+
module Mail
|
|
7
|
+
extend Base
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def install!(_app)
|
|
12
|
+
subscribe("deliver.action_mailer") do |event|
|
|
13
|
+
exe = execution
|
|
14
|
+
exe&.count(:mail)
|
|
15
|
+
next unless recording?
|
|
16
|
+
p = event.payload
|
|
17
|
+
mailer = p[:mailer].to_s
|
|
18
|
+
Railwatch.record(:mail,
|
|
19
|
+
group: Record.group_hash(mailer),
|
|
20
|
+
timestamp: started_at(event),
|
|
21
|
+
mailer: mailer,
|
|
22
|
+
subject: p[:subject].to_s[0, 255],
|
|
23
|
+
to: Array(p[:to]).size, cc: Array(p[:cc]).size, bcc: Array(p[:bcc]).size,
|
|
24
|
+
attachments: (p[:mail]&.attachments&.size rescue 0),
|
|
25
|
+
delivery_method: (p[:mail]&.delivery_method&.class&.name&.demodulize rescue nil),
|
|
26
|
+
perform_deliveries: p[:perform_deliveries] != false,
|
|
27
|
+
duration: micros(event),
|
|
28
|
+
failed: p[:exception].present?,
|
|
29
|
+
message_id: p[:message_id].to_s[0, 255])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
subscribe("process.action_mailer") do |event|
|
|
33
|
+
next unless recording?
|
|
34
|
+
p = event.payload
|
|
35
|
+
Railwatch.record(:view_render, group: Record.group_hash("mailer", p[:mailer], p[:action]),
|
|
36
|
+
timestamp: started_at(event), identifier: "#{p[:mailer]}##{p[:action]}",
|
|
37
|
+
kind: "mailer", duration: micros(event))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Noticed gem deliveries, only when the gem is loaded. Noticed delivery
|
|
6
|
+
# methods are Active Jobs, so we hook their perform via the Jobs
|
|
7
|
+
# subscriber and tag them here.
|
|
8
|
+
module Notifications
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def install!(_app)
|
|
14
|
+
return unless defined?(::Noticed)
|
|
15
|
+
|
|
16
|
+
ActiveSupport::Notifications.subscribe("perform.active_job") do |event|
|
|
17
|
+
job = event.payload[:job]
|
|
18
|
+
next unless job.class.name.to_s.start_with?("Noticed::")
|
|
19
|
+
exe = execution
|
|
20
|
+
exe&.count(:notifications)
|
|
21
|
+
next unless recording?
|
|
22
|
+
Railwatch.record(:notification,
|
|
23
|
+
group: Record.group_hash(job.class.name),
|
|
24
|
+
timestamp: started_at(event),
|
|
25
|
+
notifier: (job.arguments.first.is_a?(Hash) ? job.arguments.first[:notification_class] : nil).to_s,
|
|
26
|
+
delivery_method: job.class.name.demodulize,
|
|
27
|
+
channel: job.class.name.demodulize.delete_suffix("Delivery").downcase,
|
|
28
|
+
duration: micros(event),
|
|
29
|
+
failed: event.payload[:exception].present?)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Railwatch.debug { "noticed subscriber: #{e.message}" }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# One `process` record per process boot: role (web / worker / console /
|
|
6
|
+
# command), Ruby and Rails versions, boot time. Gives the platform a
|
|
7
|
+
# server and deploy inventory for free.
|
|
8
|
+
module ProcessInfo
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Keeps the app; the record itself is written from the engine's
|
|
14
|
+
# after_initialize, once the app is fully booted, so boot_seconds
|
|
15
|
+
# covers the app's own initializers rather than stopping short of
|
|
16
|
+
# them.
|
|
17
|
+
def install!(app)
|
|
18
|
+
@app = app
|
|
19
|
+
@database_adapter = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def restart_after_fork!
|
|
23
|
+
record!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record!
|
|
27
|
+
boot = Clock.monotonic - Railwatch::BOOTED_AT
|
|
28
|
+
Railwatch.record(:process,
|
|
29
|
+
pid: Process.pid,
|
|
30
|
+
role: role,
|
|
31
|
+
ruby_version: RUBY_VERSION,
|
|
32
|
+
rails_version: (Rails.version rescue nil),
|
|
33
|
+
railwatch_version: Railwatch::VERSION,
|
|
34
|
+
app: @app&.class&.module_parent_name,
|
|
35
|
+
environment: Railwatch.config.environment_name,
|
|
36
|
+
boot_seconds: boot,
|
|
37
|
+
database_adapter: database_adapter,
|
|
38
|
+
queue_adapter: queue_adapter,
|
|
39
|
+
cache_store: (Rails.cache.class.name rescue nil))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolved from the app's configuration with Rails' own resolvers
|
|
43
|
+
# rather than through ActiveRecord::Base / ActiveJob::Base: in a
|
|
44
|
+
# lazy-loading process (development, or a production boot without
|
|
45
|
+
# eager loading) touching those constants is what loads the
|
|
46
|
+
# frameworks, some 350 ms of boot nothing else asked for. Both
|
|
47
|
+
# resolvers are requirable on their own.
|
|
48
|
+
# Memoised: database.yml is re-read and re-rendered on every
|
|
49
|
+
# database_configuration call, and the adapter cannot change across a
|
|
50
|
+
# fork. Keyed by Rails.env, as Active Record itself does; DATABASE_URL
|
|
51
|
+
# and `url:` entries are resolved the same way it resolves them.
|
|
52
|
+
def database_adapter
|
|
53
|
+
@database_adapter ||= begin
|
|
54
|
+
config = @app&.config&.database_configuration
|
|
55
|
+
if config
|
|
56
|
+
require "active_record/database_configurations"
|
|
57
|
+
ActiveRecord::DatabaseConfigurations.new(config).find_db_config(Rails.env)&.adapter
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The effective adapter lives on ActiveJob::Base, which an app may set
|
|
65
|
+
# directly in an initializer; it is read from there whenever the
|
|
66
|
+
# framework is already loaded (any eager-loaded app with jobs), and
|
|
67
|
+
# from configuration otherwise, so a lazy boot does not load Active
|
|
68
|
+
# Job for one string.
|
|
69
|
+
def queue_adapter
|
|
70
|
+
return ActiveJob::Base.queue_adapter_name if ActiveJob.autoload?(:Base).nil?
|
|
71
|
+
|
|
72
|
+
configured_queue_adapter
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def configured_queue_adapter
|
|
78
|
+
adapter = @app&.config&.active_job&.queue_adapter or return nil
|
|
79
|
+
return adapter.to_s if adapter.is_a?(Symbol) || adapter.is_a?(String)
|
|
80
|
+
|
|
81
|
+
require "active_job/queue_adapter"
|
|
82
|
+
ActiveJob.adapter_name(adapter).underscore
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Puma is loaded in every process of an app that bundles it, so a Solid
|
|
86
|
+
# Queue worker is recognised first, by how it was started (bin/jobs or
|
|
87
|
+
# `rake solid_queue:start`).
|
|
88
|
+
def role
|
|
89
|
+
if defined?(::SolidQueue) && ($PROGRAM_NAME.include?("jobs") || ARGV.first.to_s.start_with?("solid_queue:")) then "worker"
|
|
90
|
+
elsif defined?(::Rails::Console) then "console"
|
|
91
|
+
elsif $PROGRAM_NAME.end_with?("rake") then "command"
|
|
92
|
+
elsif defined?(::Puma) then "web"
|
|
93
|
+
else "process"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# sql.active_record, transactions, model hydration, strict loading.
|
|
6
|
+
# Source location is only computed for slow queries or the first time a
|
|
7
|
+
# group is seen in an execution, to keep the per-query cost tiny.
|
|
8
|
+
module Queries
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
SKIP_NAMES = %w[SCHEMA TRANSACTION EXPLAIN].freeze
|
|
12
|
+
MAX_SQL = 16_384
|
|
13
|
+
# Computed once so the hot query record doesn't look these up per call.
|
|
14
|
+
QUERY_VERSION = Record::VERSIONS.fetch(:query)
|
|
15
|
+
|
|
16
|
+
MAX_EXPLAIN = 4_000
|
|
17
|
+
EXPLAIN_TTL = 600
|
|
18
|
+
MAX_EXPLAINED_GROUPS = 1_000
|
|
19
|
+
SELECT = /\A\s*select\b/i
|
|
20
|
+
|
|
21
|
+
@connection_info = {}.compare_by_identity
|
|
22
|
+
@sources = {}
|
|
23
|
+
@explained = {}
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# adapter name and db config name never change for a connection object.
|
|
28
|
+
# Adapter, database name, and the multi-DB role the connection was
|
|
29
|
+
# checked out for ("writing"/"reading"; Nightwatch calls this the
|
|
30
|
+
# connection type). Memoised per connection object.
|
|
31
|
+
def connection_info(conn)
|
|
32
|
+
@connection_info[conn] ||= begin
|
|
33
|
+
adapter = conn.adapter_name.to_s.downcase
|
|
34
|
+
db = (conn.pool.db_config.name rescue nil)
|
|
35
|
+
role = (conn.role.to_s rescue "writing")
|
|
36
|
+
[ adapter, db, role ].freeze
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError
|
|
39
|
+
[ "", nil, "writing" ].freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The app frame that issues a query shape rarely changes, so the
|
|
43
|
+
# (expensive) caller walk runs once per group per process.
|
|
44
|
+
def source_for(group, slow)
|
|
45
|
+
return @sources[group] if @sources.key?(group) && !slow
|
|
46
|
+
loc = Backtrace.caller_location(skip: 4)
|
|
47
|
+
@sources.clear if @sources.size > 5_000
|
|
48
|
+
@sources[group] = loc
|
|
49
|
+
loc
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The query plan for a slow SELECT, off by default. Runs on the very
|
|
53
|
+
# connection that just ran the query -- the adapter's own #explain, so
|
|
54
|
+
# each database gets its native plan format -- with Railwatch paused, and
|
|
55
|
+
# with the EXPLAIN's own sql.active_record notification named "EXPLAIN"
|
|
56
|
+
# and therefore already dropped by SKIP_NAMES above. Re-entering the
|
|
57
|
+
# connection here is safe: the notification fires after the outer
|
|
58
|
+
# statement's result has been materialized, and the adapter lock is
|
|
59
|
+
# reentrant.
|
|
60
|
+
def explain_for(payload, duration_ms, group)
|
|
61
|
+
return nil unless duration_ms >= Railwatch.config.explain_threshold_ms
|
|
62
|
+
conn = payload[:connection] or return nil
|
|
63
|
+
return nil unless SELECT.match?(payload[:sql])
|
|
64
|
+
return nil unless explain_due?(group)
|
|
65
|
+
|
|
66
|
+
plan = Railwatch.ignore { conn.explain(payload[:sql], payload[:binds] || []) }
|
|
67
|
+
plan&.to_s&.slice(0, MAX_EXPLAIN)
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One EXPLAIN per query shape per process per EXPLAIN_TTL. Racy across
|
|
73
|
+
# threads by design (worst case two threads explain the same shape
|
|
74
|
+
# once), like @sources above.
|
|
75
|
+
def explain_due?(group)
|
|
76
|
+
now = Clock.monotonic
|
|
77
|
+
last = @explained[group]
|
|
78
|
+
return false if last && now - last < EXPLAIN_TTL
|
|
79
|
+
@explained.clear if @explained.size >= MAX_EXPLAINED_GROUPS
|
|
80
|
+
@explained[group] = now
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def install!(_app)
|
|
85
|
+
subscribe("sql.active_record") do |event|
|
|
86
|
+
p = event.payload
|
|
87
|
+
next if SKIP_NAMES.include?(p[:name])
|
|
88
|
+
exe = execution
|
|
89
|
+
if p[:cached]
|
|
90
|
+
exe&.count(:cached_queries)
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
exe&.count(:queries)
|
|
94
|
+
# Statement count for the currently-open transaction, keyed by AR's
|
|
95
|
+
# transaction object identity (the same object the transaction
|
|
96
|
+
# subscriber below sees), so nested/concurrent transactions on the
|
|
97
|
+
# same execution don't collide. Read once at transaction end.
|
|
98
|
+
exe&.count_transaction_statement(p[:transaction].object_id) if p[:transaction]
|
|
99
|
+
next unless recording?
|
|
100
|
+
|
|
101
|
+
sql = p[:sql]
|
|
102
|
+
adapter, db, role = p[:connection] ? connection_info(p[:connection]) : [ "", nil, "writing" ]
|
|
103
|
+
# One cache lookup gets both the group hash and the normalized SQL,
|
|
104
|
+
# so the (rare) n+1 branch below never re-normalizes the same text.
|
|
105
|
+
group, normalized = SqlNormalizer.group_and_normalized(sql, adapter: adapter, connection_name: db)
|
|
106
|
+
exe&.track_query_group(group)
|
|
107
|
+
duration = micros(event)
|
|
108
|
+
cfg = Railwatch.config
|
|
109
|
+
slow = event.duration >= cfg.slow_query_threshold_ms
|
|
110
|
+
n = exe ? exe.query_groups[group] : 0
|
|
111
|
+
|
|
112
|
+
# One hash literal (base keys + envelope + fields) instead of
|
|
113
|
+
# kwargs-packing into Railwatch.record and merging through
|
|
114
|
+
# Record.build -- this is the hottest record type in the gem.
|
|
115
|
+
Railwatch.push(:query, {
|
|
116
|
+
v: QUERY_VERSION,
|
|
117
|
+
t: "query",
|
|
118
|
+
timestamp: started_at(event),
|
|
119
|
+
deploy: cfg.deploy,
|
|
120
|
+
server: cfg.server,
|
|
121
|
+
_group: group,
|
|
122
|
+
**(exe ? exe.envelope : Record::EMPTY_ENVELOPE),
|
|
123
|
+
sql: cfg.capture_sql_values ? SqlNormalizer.raw_for_record(sql, max_characters: MAX_SQL) : normalized[0, MAX_SQL],
|
|
124
|
+
name: p[:name],
|
|
125
|
+
duration: duration,
|
|
126
|
+
connection: db,
|
|
127
|
+
adapter: adapter,
|
|
128
|
+
role: role,
|
|
129
|
+
async: p[:async] ? true : false,
|
|
130
|
+
row_count: p[:row_count],
|
|
131
|
+
affected_rows: p[:affected_rows],
|
|
132
|
+
in_transaction: p[:transaction] ? true : false,
|
|
133
|
+
source: source_for(group, slow),
|
|
134
|
+
allocations: event.allocations,
|
|
135
|
+
# The EXPLAIN still runs on the raw statement -- the plan would be
|
|
136
|
+
# meaningless otherwise -- and capture_query_explain keeps working
|
|
137
|
+
# on its own. Only what is STORED changes: `sql` above is the
|
|
138
|
+
# normalized shape. A plan can echo literal predicates (Postgres
|
|
139
|
+
# does, in Filter/Index Cond lines), so capture_query_explain is
|
|
140
|
+
# its own privacy decision, independent of capture_sql_values;
|
|
141
|
+
# docs/configuration.md says so at both options.
|
|
142
|
+
explain: cfg.capture_query_explain ? explain_for(p, event.duration, group) : nil
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if exe && n == cfg.n_plus_one_threshold
|
|
146
|
+
Railwatch.record(:n_plus_one, group: group, sql: normalized[0, 2048],
|
|
147
|
+
count: n, source: Backtrace.caller_location(skip: 3))
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
subscribe("transaction.active_record") do |event|
|
|
152
|
+
exe = execution
|
|
153
|
+
exe&.count(:transactions)
|
|
154
|
+
p = event.payload
|
|
155
|
+
# Taken (and removed) whether or not a record is built: the
|
|
156
|
+
# statement counter above runs for every execution, so this is what
|
|
157
|
+
# keeps a sampled-out execution from holding one entry per
|
|
158
|
+
# transaction for its whole lifetime.
|
|
159
|
+
statement_count = p[:transaction] ? exe&.transaction_statement_count(p[:transaction].object_id) : nil
|
|
160
|
+
next unless recording?
|
|
161
|
+
connection_name = (p[:connection]&.pool&.db_config&.name rescue nil)
|
|
162
|
+
outcome = p[:outcome].to_s
|
|
163
|
+
Railwatch.record(:transaction, group: Record.group_hash(connection_name, outcome),
|
|
164
|
+
timestamp: started_at(event), duration: micros(event), outcome: outcome,
|
|
165
|
+
connection: connection_name, statement_count: statement_count)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
subscribe_payload("instantiation.active_record") do |payload|
|
|
169
|
+
execution&.count(:hydrated_models, payload[:record_count].to_i)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
subscribe("strict_loading_violation.active_record") do |event|
|
|
173
|
+
exe = execution or next
|
|
174
|
+
exe.count(:lazy_loads)
|
|
175
|
+
next unless recording?
|
|
176
|
+
p = event.payload
|
|
177
|
+
Railwatch.record(:log, level: "warn", message: "Lazy load of #{p[:owner].class.name}##{p[:reflection]&.name}",
|
|
178
|
+
tags: [ "strict_loading" ], context: "{}", source: Backtrace.caller_location(skip: 3))
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Controller lifecycle. The Rack middleware owns the request record; this
|
|
6
|
+
# subscriber fills in route, stage boundaries, and the extras Rails
|
|
7
|
+
# exposes (redirects, halted callbacks, unpermitted params, rate limits).
|
|
8
|
+
module Requests
|
|
9
|
+
extend Base
|
|
10
|
+
|
|
11
|
+
# Only a multipart request can carry an UploadedFile, so the (recursive)
|
|
12
|
+
# params walk below is skipped entirely for the vast majority of
|
|
13
|
+
# requests instead of running once per action.
|
|
14
|
+
EMPTY_FILES = [].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def install!(_app)
|
|
19
|
+
subscribe("start_processing.action_controller") do |event|
|
|
20
|
+
exe = execution or next
|
|
21
|
+
p = event.payload
|
|
22
|
+
exe.enter_stage(:action)
|
|
23
|
+
env = p[:request]&.env
|
|
24
|
+
next unless env
|
|
25
|
+
env["railwatch.route"] = { pattern: route_pattern(p[:request]), controller: p[:controller].to_s.delete_suffix("Controller").underscore, action: p[:action], verb: p[:method] }
|
|
26
|
+
exe.preview = "#{p[:controller]}##{p[:action]}"
|
|
27
|
+
# Captured here, not in the closing middleware, because Rack's
|
|
28
|
+
# TempfileReaper (nested inside us) has already closed and unlinked
|
|
29
|
+
# upload tempfiles by the time the middleware unwinds.
|
|
30
|
+
env["railwatch.files"] = env["CONTENT_TYPE"]&.start_with?("multipart/form-data") ? uploaded_files(p[:request].params) : EMPTY_FILES
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
subscribe("process_action.action_controller") do |event|
|
|
34
|
+
exe = execution or next
|
|
35
|
+
p = event.payload
|
|
36
|
+
env = p[:request]&.env or next
|
|
37
|
+
env["railwatch.view_runtime"] = p[:view_runtime]&.round(2)
|
|
38
|
+
env["railwatch.db_runtime"] = p[:db_runtime]&.round(2)
|
|
39
|
+
exe.enter_stage(:middleware_after)
|
|
40
|
+
resp = p[:response]
|
|
41
|
+
if resp && env["HTTP_X_INERTIA"] == "true"
|
|
42
|
+
env["railwatch.inertia_props_bytes"] = resp.body.bytesize rescue nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
subscribe("redirect_to.action_controller") do |event|
|
|
47
|
+
env = event.payload[:request]&.env or next
|
|
48
|
+
env["railwatch.redirect_to"] = Record.url_without_sensitive_components(event.payload[:location], limit: 512)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
subscribe("halted_callback.action_controller") do |event|
|
|
52
|
+
exe = execution or next
|
|
53
|
+
ActiveSupport::ExecutionContext.to_h[:controller]&.request&.env&.[]=("railwatch.halted_callback", event.payload[:filter].to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
subscribe("unpermitted_parameters.action_controller") do |event|
|
|
57
|
+
env = event.payload.dig(:context, :request)&.env or next
|
|
58
|
+
env["railwatch.unpermitted_parameters"] = Array(event.payload[:keys]).map(&:to_s)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
subscribe("rate_limit.action_controller") do |event|
|
|
62
|
+
env = event.payload[:request]&.env or next
|
|
63
|
+
env["railwatch.rate_limited"] = { name: event.payload[:name].to_s, count: event.payload[:count], to: event.payload[:to] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
subscribe("render_template.action_view") do |_event|
|
|
67
|
+
exe = execution or next
|
|
68
|
+
exe.enter_stage(:render) if exe.stage == :action
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def route_pattern(request)
|
|
73
|
+
request.route_uri_pattern
|
|
74
|
+
rescue StandardError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def uploaded_files(value, name = nil)
|
|
79
|
+
case value
|
|
80
|
+
when ActionDispatch::Http::UploadedFile
|
|
81
|
+
[ { name: name, size: (value.tempfile.size rescue nil), content_type: value.content_type, error: nil } ]
|
|
82
|
+
when Hash
|
|
83
|
+
value.flat_map { |k, v| uploaded_files(v, k.to_s) }
|
|
84
|
+
when Array
|
|
85
|
+
value.flat_map { |v| uploaded_files(v, name) }
|
|
86
|
+
else
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
rescue StandardError
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railwatch
|
|
4
|
+
module Subscribers
|
|
5
|
+
module Storage
|
|
6
|
+
extend Base
|
|
7
|
+
|
|
8
|
+
OPS = %w[service_upload service_download service_streaming_download service_delete
|
|
9
|
+
service_delete_prefixed service_exist service_url service_update_metadata
|
|
10
|
+
analyze transform preview].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def install!(_app)
|
|
15
|
+
OPS.each do |op|
|
|
16
|
+
subscribe("#{op}.active_storage") do |event|
|
|
17
|
+
exe = execution
|
|
18
|
+
exe&.count(:storage_ops)
|
|
19
|
+
next unless recording?
|
|
20
|
+
p = event.payload
|
|
21
|
+
service = p[:service].to_s
|
|
22
|
+
Railwatch.record(:storage_op,
|
|
23
|
+
group: Record.group_hash(service, op),
|
|
24
|
+
timestamp: started_at(event),
|
|
25
|
+
service: service,
|
|
26
|
+
op: op.delete_prefix("service_"),
|
|
27
|
+
key: p[:key].to_s[0, 255],
|
|
28
|
+
duration: micros(event),
|
|
29
|
+
exist: p[:exist])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|