rails_vitals 0.2.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +340 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/rails_vitals/application.css +180 -0
  6. data/app/controllers/rails_vitals/application_controller.rb +30 -0
  7. data/app/controllers/rails_vitals/associations_controller.rb +8 -0
  8. data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
  9. data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
  10. data/app/controllers/rails_vitals/models_controller.rb +65 -0
  11. data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
  12. data/app/controllers/rails_vitals/requests_controller.rb +44 -0
  13. data/app/helpers/rails_vitals/application_helper.rb +63 -0
  14. data/app/jobs/rails_vitals/application_job.rb +4 -0
  15. data/app/mailers/rails_vitals/application_mailer.rb +6 -0
  16. data/app/models/rails_vitals/application_record.rb +5 -0
  17. data/app/views/layouts/rails_vitals/application.html.erb +27 -0
  18. data/app/views/rails_vitals/associations/index.html.erb +370 -0
  19. data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
  20. data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
  21. data/app/views/rails_vitals/models/index.html.erb +117 -0
  22. data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
  23. data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
  24. data/app/views/rails_vitals/requests/index.html.erb +60 -0
  25. data/app/views/rails_vitals/requests/show.html.erb +396 -0
  26. data/config/routes.rb +9 -0
  27. data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
  28. data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
  29. data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
  30. data/lib/rails_vitals/collector.rb +78 -0
  31. data/lib/rails_vitals/configuration.rb +27 -0
  32. data/lib/rails_vitals/engine.rb +25 -0
  33. data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
  34. data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
  35. data/lib/rails_vitals/notifications/subscriber.rb +59 -0
  36. data/lib/rails_vitals/panel_renderer.rb +233 -0
  37. data/lib/rails_vitals/request_record.rb +51 -0
  38. data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
  39. data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
  40. data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
  41. data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
  42. data/lib/rails_vitals/store.rb +34 -0
  43. data/lib/rails_vitals/version.rb +3 -0
  44. data/lib/rails_vitals.rb +33 -0
  45. data/lib/tasks/rails_vitals_tasks.rake +4 -0
  46. metadata +113 -0
@@ -0,0 +1,240 @@
1
+ module RailsVitals
2
+ module Analyzers
3
+ class SqlTokenizer
4
+ TOKEN_DEFINITIONS = [
5
+ {
6
+ type: :select_star,
7
+ pattern: /\bSELECT\s+\*\b/i,
8
+ label: "SELECT *",
9
+ color: "#4299e1",
10
+ risk: :warning,
11
+ explanation: "Fetches all columns from the table. In Rails this is the default " \
12
+ "behavior of Model.all and most queries. Can be wasteful when you " \
13
+ "only need specific attributes. Use .select(:id, :name) to fetch " \
14
+ "only what you need, especially on wide tables."
15
+ },
16
+ {
17
+ type: :select,
18
+ pattern: /\bSELECT\b(?!\s+\*)/i,
19
+ label: "SELECT",
20
+ color: "#4299e1",
21
+ risk: :healthy,
22
+ explanation: "Fetches specific columns. More efficient than SELECT * when your " \
23
+ "table has many columns or large text/json fields you don't need."
24
+ },
25
+ {
26
+ type: :count,
27
+ pattern: /\bCOUNT\s*\(/i,
28
+ label: "COUNT(*)",
29
+ color: "#ed8936",
30
+ risk: :warning,
31
+ explanation: "Counts rows matching the condition. When this appears in a loop " \
32
+ "(N+1 pattern), Rails fires one COUNT query per record. The fix is " \
33
+ "a counter cache column or loading the association and calling .size " \
34
+ "which uses the already-loaded records instead of hitting the DB."
35
+ },
36
+ {
37
+ type: :aggregation,
38
+ pattern: /\b(SUM|AVG|MIN|MAX)\s*\(/i,
39
+ label: "AGGREGATE",
40
+ color: "#ed8936",
41
+ risk: :warning,
42
+ explanation: "Aggregation function (SUM/AVG/MIN/MAX). Like COUNT, these are " \
43
+ "dangerous in loops. Each call fires a separate query. Consider " \
44
+ "loading the association once and using Ruby's .sum/.min/.max on " \
45
+ "the already-loaded collection instead."
46
+ },
47
+ {
48
+ type: :from,
49
+ pattern: /\bFROM\s+"?(\w+)"?/i,
50
+ label: "FROM",
51
+ color: "#68d391",
52
+ risk: :healthy,
53
+ explanation: "Identifies which table (and therefore which ActiveRecord model) " \
54
+ "is being queried. In an N+1, you'll see the same FROM table " \
55
+ "repeated many times, once per parent record."
56
+ },
57
+ {
58
+ type: :where_fk,
59
+ pattern: /\bWHERE\s+.*\w+_id\s*=/i,
60
+ label: "WHERE fk =",
61
+ color: "#fc8181",
62
+ risk: :danger,
63
+ explanation: "WHERE condition on a foreign key with a single value. This is " \
64
+ "the N+1 signature, loading one associated record at a time. " \
65
+ "When you see this pattern repeated, it means Rails is fetching " \
66
+ "records individually instead of in batch. The fix is includes() " \
67
+ "which replaces this with a single WHERE fk IN (...) query."
68
+ },
69
+ {
70
+ type: :where,
71
+ pattern: /\bWHERE\b/i,
72
+ label: "WHERE",
73
+ color: "#f6ad55",
74
+ risk: :neutral,
75
+ explanation: "Filters rows by condition. Efficient when the condition column " \
76
+ "has an index. Slow when it doesn't. DB engine will scan every " \
77
+ "row in the table (Sequential Scan). Check the EXPLAIN output to " \
78
+ "verify an index is being used."
79
+ },
80
+ {
81
+ type: :where_in,
82
+ pattern: /\bIN\s*\(/i,
83
+ label: "IN (...)",
84
+ color: "#68d391",
85
+ risk: :healthy,
86
+ explanation: "Batch lookup fetches multiple records in one query using a list " \
87
+ "of values. This is what eager loading (includes/preload) generates " \
88
+ "instead of repeated WHERE fk = ? queries. Seeing IN (...) means " \
89
+ "your associations are being loaded efficiently."
90
+ },
91
+ {
92
+ type: :inner_join,
93
+ pattern: /\bINNER\s+JOIN\b/i,
94
+ label: "INNER JOIN",
95
+ color: "#9f7aea",
96
+ risk: :neutral,
97
+ explanation: "Combines rows from two tables where the join condition matches. " \
98
+ "In Rails this is what .joins() generates. Records without a " \
99
+ "matching association are excluded from results. Note: .joins() " \
100
+ "does NOT load the association, use .includes() if you need " \
101
+ "to access associated data."
102
+ },
103
+ {
104
+ type: :left_join,
105
+ pattern: /\bLEFT\s+(OUTER\s+)?JOIN\b/i,
106
+ label: "LEFT JOIN",
107
+ color: "#9f7aea",
108
+ risk: :neutral,
109
+ explanation: "Like INNER JOIN but keeps all rows from the left table even " \
110
+ "when there's no matching row on the right. In Rails this is " \
111
+ "what .eager_load() and .left_joins() generate. Use when you " \
112
+ "need to include records that have no associated data."
113
+ },
114
+ {
115
+ type: :order,
116
+ pattern: /\bORDER\s+BY\b/i,
117
+ label: "ORDER BY",
118
+ color: "#76e4f7",
119
+ risk: :warning,
120
+ explanation: "Sorts results by a column. Fast when sorting on an indexed " \
121
+ "column. Slow when sorting on an unindexed column. DB engine " \
122
+ "must sort all matching rows in memory. Default Rails scopes " \
123
+ "often add ORDER BY created_at DESC, make sure created_at " \
124
+ "has an index if your table is large."
125
+ },
126
+ {
127
+ type: :limit,
128
+ pattern: /\bLIMIT\s+\d+/i,
129
+ label: "LIMIT",
130
+ color: "#a0aec0",
131
+ risk: :healthy,
132
+ explanation: "Restricts the number of rows returned. Always use LIMIT in " \
133
+ "production feeds and lists, never load unbounded data. " \
134
+ "Note: LIMIT with OFFSET becomes slower as OFFSET grows " \
135
+ "because DB engine must scan and discard all preceding rows."
136
+ },
137
+ {
138
+ type: :offset,
139
+ pattern: /\bOFFSET\s+\d+/i,
140
+ label: "OFFSET",
141
+ color: "#fc8181",
142
+ risk: :warning,
143
+ explanation: "Skips N rows before returning results. Common in pagination " \
144
+ "(page 2, page 3...). The hidden cost: DB engine must read " \
145
+ "and discard all rows before the offset, at page 100 with " \
146
+ "20 per page, it scans 2,000 rows to return 20. Use " \
147
+ "cursor-based pagination (WHERE id > last_id) for large datasets."
148
+ },
149
+ {
150
+ type: :group_by,
151
+ pattern: /\bGROUP\s+BY\b/i,
152
+ label: "GROUP BY",
153
+ color: "#76e4f7",
154
+ risk: :neutral,
155
+ explanation: "Groups rows sharing a value and applies aggregate functions " \
156
+ "per group. Common with COUNT, SUM, AVG. Used in Rails with " \
157
+ ".group(:column). Consider a counter cache column if you're " \
158
+ "grouping and counting frequently, it replaces a GROUP BY " \
159
+ "query with a single column read."
160
+ }
161
+ ].freeze
162
+
163
+ COMPLEXITY_RULES = [
164
+ { tokens: [ :left_join, :inner_join ], points: 2 },
165
+ { tokens: [ :where_fk ], points: 3 },
166
+ { tokens: [ :count, :aggregation ], points: 2 },
167
+ { tokens: [ :offset ], points: 2 },
168
+ { tokens: [ :group_by ], points: 1 },
169
+ { tokens: [ :order ], points: 1 }
170
+ ].freeze
171
+
172
+ Result = Struct.new(:tokens, :complexity, :complexity_label,
173
+ :risk, :repetition_count, :repetition_bar,
174
+ keyword_init: true)
175
+
176
+ def self.tokenize(sql, all_queries: [])
177
+ matched = TOKEN_DEFINITIONS.select { |td| sql.match?(td[:pattern]) }
178
+
179
+ complexity = calculate_complexity(matched)
180
+ risk = highest_risk(matched)
181
+ repetition = calculate_repetition(sql, all_queries)
182
+
183
+ Result.new(
184
+ tokens: matched,
185
+ complexity: complexity,
186
+ complexity_label: complexity_label(complexity),
187
+ risk: risk,
188
+ repetition_count: repetition,
189
+ repetition_bar: repetition_bar(repetition, all_queries.size)
190
+ )
191
+ end
192
+
193
+ private
194
+
195
+ def self.calculate_complexity(matched_tokens)
196
+ types = matched_tokens.map { |t| t[:type] }
197
+ base = 1
198
+ COMPLEXITY_RULES.each do |rule|
199
+ base += rule[:points] if (rule[:tokens] & types).any?
200
+ end
201
+ base.clamp(1, 10)
202
+ end
203
+
204
+ def self.complexity_label(score)
205
+ case score
206
+ when 1..2 then { label: "Simple", color: "#68d391" }
207
+ when 3..5 then { label: "Moderate", color: "#f6ad55" }
208
+ else { label: "Complex", color: "#fc8181" }
209
+ end
210
+ end
211
+
212
+ def self.highest_risk(matched_tokens)
213
+ risks = { healthy: 0, neutral: 1, warning: 2, danger: 3 }
214
+ matched_tokens.max_by { |t| risks[t[:risk]] || 0 }&.dig(:risk) || :healthy
215
+ end
216
+
217
+ def self.calculate_repetition(sql, all_queries)
218
+ return 0 if all_queries.empty?
219
+
220
+ normalized = normalize(sql)
221
+ all_queries.count { |q| normalize(q[:sql]) == normalized }
222
+ end
223
+
224
+ def self.repetition_bar(count, total)
225
+ return [] if count <= 1
226
+
227
+ filled = total > 0 ? ((count.to_f / total) * 20).ceil : 0
228
+ { count: count, filled: filled, empty: 20 - filled }
229
+ end
230
+
231
+ def self.normalize(sql)
232
+ sql.gsub(/\b\d+\b/, "?")
233
+ .gsub(/'[^']*'/, "?")
234
+ .gsub(/\s+/, " ")
235
+ .strip
236
+ .downcase
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,78 @@
1
+ module RailsVitals
2
+ class Collector
3
+ attr_reader :queries, :callbacks, :controller, :action, :http_method,
4
+ :response_status, :duration_ms, :started_at
5
+
6
+ def initialize
7
+ @queries = []
8
+ @callbacks = []
9
+ @controller = nil
10
+ @action = nil
11
+ @http_method = nil
12
+ @response_status = nil
13
+ @duration_ms = nil
14
+ @started_at = Time.now
15
+ end
16
+
17
+ # Called by the sql.active_record subscriber
18
+ def add_query(sql:, duration_ms:, source:)
19
+ @queries << {
20
+ sql: sql,
21
+ duration_ms: duration_ms,
22
+ source: source,
23
+ called_at: Time.now
24
+ }
25
+ end
26
+
27
+ def add_callback(model:, kind:, duration_ms:)
28
+ @callbacks << {
29
+ model: model,
30
+ kind: kind,
31
+ duration_ms: duration_ms,
32
+ called_at: Time.now
33
+ }
34
+ end
35
+
36
+ # Called by the process_action.action_controller subscriber
37
+ def finalize!(event)
38
+ @controller = event.payload[:controller]
39
+ @action = event.payload[:action]
40
+ @http_method = event.payload[:method]
41
+ @response_status = event.payload[:status]
42
+ @duration_ms = event.duration
43
+ end
44
+
45
+ def total_query_count
46
+ @queries.size
47
+ end
48
+
49
+ def total_db_time_ms
50
+ @queries.sum { |q| q[:duration_ms] }
51
+ end
52
+
53
+ def slowest_queries(limit = 5)
54
+ @queries.sort_by { |q| -q[:duration_ms] }.first(limit)
55
+ end
56
+
57
+ def total_callback_time_ms
58
+ @callbacks.sum { |c| c[:duration_ms] }
59
+ end
60
+
61
+ def callbacks_by_model
62
+ @callbacks.group_by { |c| c[:model] }
63
+ end
64
+
65
+ # Thread-local storage accessors
66
+ def self.current
67
+ Thread.current[:rails_vitals_collector]
68
+ end
69
+
70
+ def self.current=(collector)
71
+ Thread.current[:rails_vitals_collector] = collector
72
+ end
73
+
74
+ def self.reset!
75
+ Thread.current[:rails_vitals_collector] = nil
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,27 @@
1
+ module RailsVitals
2
+ class Configuration
3
+ attr_accessor :enabled,
4
+ :store_size,
5
+ :store_enabled,
6
+ :auth,
7
+ :basic_auth_username,
8
+ :basic_auth_password,
9
+ :query_warn_threshold,
10
+ :query_critical_threshold,
11
+ :db_time_warn_ms,
12
+ :db_time_critical_ms
13
+
14
+ def initialize
15
+ @enabled = defined?(Rails) && !Rails.env.production?
16
+ @store_size = 200
17
+ @store_enabled = true
18
+ @auth = :none
19
+ @basic_auth_username = nil
20
+ @basic_auth_password = nil
21
+ @query_warn_threshold = 10
22
+ @query_critical_threshold = 25
23
+ @db_time_warn_ms = 100
24
+ @db_time_critical_ms = 500
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ module RailsVitals
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace RailsVitals
4
+
5
+ initializer "rails_vitals.middleware" do |app|
6
+ if RailsVitals.config.enabled
7
+ app.middleware.use RailsVitals::Middleware::PanelInjector
8
+ end
9
+ end
10
+
11
+ initializer "rails_vitals.notifications" do
12
+ if RailsVitals.config.enabled
13
+ RailsVitals::Notifications::Subscriber.attach
14
+ end
15
+ end
16
+
17
+ config.to_prepare do
18
+ if RailsVitals.config.enabled
19
+ ActiveRecord::Base.prepend(
20
+ RailsVitals::Instrumentation::CallbackInstrumentation
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ module RailsVitals
2
+ module Instrumentation
3
+ module CallbackInstrumentation
4
+ TRACKED_CALLBACKS = %i[
5
+ validation save create update destroy commit rollback
6
+ ].freeze
7
+
8
+ def run_callbacks(kind, *args, &block)
9
+ collector = RailsVitals::Collector.current
10
+
11
+ unless collector && RailsVitals.config.enabled &&
12
+ TRACKED_CALLBACKS.include?(kind)
13
+ return super
14
+ end
15
+
16
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
17
+ result = super
18
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
19
+
20
+ collector.add_callback(
21
+ model: self.class.name,
22
+ kind: kind,
23
+ duration_ms: duration.round(2)
24
+ )
25
+
26
+ result
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,75 @@
1
+ module RailsVitals
2
+ module Middleware
3
+ class PanelInjector
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ Thread.current[:rails_vitals_path] = env["PATH_INFO"]
10
+ RailsVitals::Collector.current = RailsVitals::Collector.new
11
+
12
+ status, headers, response = @app.call(env)
13
+
14
+ return [ status, headers, response ] unless injectable?(headers, env)
15
+
16
+ collector = RailsVitals::Collector.current
17
+ scorer = Scorers::CompositeScorer.new(collector)
18
+ record = RequestRecord.new(collector: collector, scorer: scorer)
19
+
20
+ RailsVitals.store.push(record) if RailsVitals.config.store_enabled
21
+
22
+ body = extract_body(response)
23
+ body = inject_panel(body, collector, scorer)
24
+
25
+ headers["Content-Length"] = body.bytesize.to_s
26
+
27
+ [ status, headers, [ body ] ]
28
+ ensure
29
+ Thread.current[:rails_vitals_own_request] = nil
30
+ RailsVitals::Collector.reset!
31
+ end
32
+
33
+ private
34
+
35
+ def injectable?(headers, env)
36
+ html_response?(headers) &&
37
+ !xhr_request?(env) &&
38
+ !turbo_frame_request?(env) &&
39
+ !rails_vitals_request?(env)
40
+ end
41
+
42
+ def html_response?(headers)
43
+ content_type = headers["Content-Type"] || ""
44
+ content_type.include?("text/html")
45
+ end
46
+
47
+ def xhr_request?(env)
48
+ env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest"
49
+ end
50
+
51
+ def turbo_frame_request?(env)
52
+ env["HTTP_TURBO_FRAME"].present?
53
+ end
54
+
55
+ def rails_vitals_request?(env)
56
+ env["SCRIPT_NAME"].to_s.start_with?("/rails_vitals")
57
+ end
58
+
59
+ def extract_body(response)
60
+ body = ""
61
+ response.each { |chunk| body << chunk }
62
+ body
63
+ end
64
+
65
+ def inject_panel(body, collector, scorer)
66
+ return body unless body.include?("</body>")
67
+
68
+ Rails.logger.debug "RailsVitals PATH_INFO: #{Thread.current[:rails_vitals_path]}"
69
+
70
+ panel_html = RailsVitals::PanelRenderer.render(collector, scorer)
71
+ body.sub("</body>", "#{panel_html}</body>")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,59 @@
1
+ module RailsVitals
2
+ module Notifications
3
+ class Subscriber
4
+ def self.attach
5
+ attach_sql_subscriber
6
+ attach_action_controller_subscriber
7
+ end
8
+
9
+ private_class_method def self.attach_sql_subscriber
10
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
11
+ next unless RailsVitals.config.enabled
12
+ next if (collector = RailsVitals::Collector.current).nil?
13
+ next if internal_query?(event.payload[:sql])
14
+ next if rails_vitals_request?
15
+
16
+ collector.add_query(
17
+ sql: event.payload[:sql],
18
+ duration_ms: event.duration,
19
+ source: extract_source(event.payload[:binds])
20
+ )
21
+ end
22
+ end
23
+
24
+ private_class_method def self.attach_action_controller_subscriber
25
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |event|
26
+ next unless RailsVitals.config.enabled
27
+ next if (collector = RailsVitals::Collector.current).nil?
28
+ next if event.payload[:controller].to_s.start_with?("RailsVitals::")
29
+
30
+ collector.finalize!(event)
31
+ end
32
+ end
33
+
34
+ # Skip Rails internal queries — schema lookups, explain, etc.
35
+ private_class_method def self.internal_query?(sql)
36
+ sql =~ /\A\s*(SCHEMA|EXPLAIN|PRAGMA|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)/i ||
37
+ sql.include?("pg_class") ||
38
+ sql.include?("pg_attribute") ||
39
+ sql.include?("pg_type") ||
40
+ sql.include?("t.typname") ||
41
+ sql.include?("t.oid") ||
42
+ sql.include?("information_schema") ||
43
+ sql.include?("pg_namespace") ||
44
+ sql.include?("SHOW search_path") ||
45
+ sql.include?("SHOW max_identifier_length")
46
+ end
47
+
48
+ private_class_method def self.rails_vitals_request?
49
+ Thread.current[:rails_vitals_own_request]
50
+ end
51
+
52
+ private_class_method def self.extract_source(binds)
53
+ # binds is an array of ActiveRecord::Relation::QueryAttribute
54
+ # We just use it as a hook point for now — real caller detection comes later
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end