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,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