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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +340 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/rails_vitals/application.css +180 -0
- data/app/controllers/rails_vitals/application_controller.rb +30 -0
- data/app/controllers/rails_vitals/associations_controller.rb +8 -0
- data/app/controllers/rails_vitals/dashboard_controller.rb +59 -0
- data/app/controllers/rails_vitals/heatmap_controller.rb +39 -0
- data/app/controllers/rails_vitals/models_controller.rb +65 -0
- data/app/controllers/rails_vitals/n_plus_ones_controller.rb +43 -0
- data/app/controllers/rails_vitals/requests_controller.rb +44 -0
- data/app/helpers/rails_vitals/application_helper.rb +63 -0
- data/app/jobs/rails_vitals/application_job.rb +4 -0
- data/app/mailers/rails_vitals/application_mailer.rb +6 -0
- data/app/models/rails_vitals/application_record.rb +5 -0
- data/app/views/layouts/rails_vitals/application.html.erb +27 -0
- data/app/views/rails_vitals/associations/index.html.erb +370 -0
- data/app/views/rails_vitals/dashboard/index.html.erb +158 -0
- data/app/views/rails_vitals/heatmap/index.html.erb +66 -0
- data/app/views/rails_vitals/models/index.html.erb +117 -0
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +49 -0
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +139 -0
- data/app/views/rails_vitals/requests/index.html.erb +60 -0
- data/app/views/rails_vitals/requests/show.html.erb +396 -0
- data/config/routes.rb +9 -0
- data/lib/rails_vitals/analyzers/association_mapper.rb +121 -0
- data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +116 -0
- data/lib/rails_vitals/analyzers/sql_tokenizer.rb +240 -0
- data/lib/rails_vitals/collector.rb +78 -0
- data/lib/rails_vitals/configuration.rb +27 -0
- data/lib/rails_vitals/engine.rb +25 -0
- data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +30 -0
- data/lib/rails_vitals/middleware/panel_injector.rb +75 -0
- data/lib/rails_vitals/notifications/subscriber.rb +59 -0
- data/lib/rails_vitals/panel_renderer.rb +233 -0
- data/lib/rails_vitals/request_record.rb +51 -0
- data/lib/rails_vitals/scorers/base_scorer.rb +25 -0
- data/lib/rails_vitals/scorers/composite_scorer.rb +36 -0
- data/lib/rails_vitals/scorers/n_plus_one_scorer.rb +43 -0
- data/lib/rails_vitals/scorers/query_scorer.rb +42 -0
- data/lib/rails_vitals/store.rb +34 -0
- data/lib/rails_vitals/version.rb +3 -0
- data/lib/rails_vitals.rb +33 -0
- data/lib/tasks/rails_vitals_tasks.rake +4 -0
- 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
|