rails_vitals 0.4.0 → 0.4.2
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 +4 -4
- data/README.md +10 -1
- data/app/assets/stylesheets/rails_vitals/application.css +43 -0
- data/app/helpers/rails_vitals/application_helper.rb +23 -1
- data/app/views/rails_vitals/dashboard/index.html.erb +8 -16
- data/app/views/rails_vitals/explains/show.html.erb +9 -40
- data/app/views/rails_vitals/heatmap/index.html.erb +3 -4
- data/app/views/rails_vitals/models/index.html.erb +2 -3
- data/app/views/rails_vitals/n_plus_ones/index.html.erb +1 -3
- data/app/views/rails_vitals/n_plus_ones/show.html.erb +7 -8
- data/app/views/rails_vitals/playgrounds/index.html.erb +2 -0
- data/app/views/rails_vitals/requests/index.html.erb +6 -12
- data/app/views/rails_vitals/requests/show.html.erb +23 -25
- data/app/views/rails_vitals/shared/_empty_state.html.erb +3 -0
- data/app/views/rails_vitals/shared/_n1_indicator.html.erb +9 -0
- data/app/views/rails_vitals/shared/_page_header.html.erb +15 -0
- data/app/views/rails_vitals/shared/_score_badge.html.erb +3 -0
- data/lib/rails_vitals/analyzers/association_mapper.rb +1 -1
- data/lib/rails_vitals/analyzers/explain_analyzer.rb +59 -57
- data/lib/rails_vitals/analyzers/n_plus_one_aggregator.rb +13 -13
- data/lib/rails_vitals/analyzers/sql_tokenizer.rb +81 -81
- data/lib/rails_vitals/collector.rb +18 -18
- data/lib/rails_vitals/instrumentation/callback_instrumentation.rb +3 -3
- data/lib/rails_vitals/notifications/subscriber.rb +3 -3
- data/lib/rails_vitals/panel_renderer.rb +7 -11
- data/lib/rails_vitals/playground/sandbox.rb +2 -1
- data/lib/rails_vitals/request_record.rb +12 -12
- data/lib/rails_vitals/scorers/base_scorer.rb +3 -3
- data/lib/rails_vitals/scorers/composite_scorer.rb +3 -3
- data/lib/rails_vitals/scorers/query_scorer.rb +3 -3
- data/lib/rails_vitals/store.rb +2 -2
- data/lib/rails_vitals/version.rb +1 -1
- metadata +5 -1
|
@@ -13,104 +13,104 @@ module RailsVitals
|
|
|
13
13
|
# Node types and their risk/education metadata
|
|
14
14
|
NODE_METADATA = {
|
|
15
15
|
"Seq Scan" => {
|
|
16
|
-
risk:
|
|
17
|
-
color:
|
|
18
|
-
label:
|
|
16
|
+
risk: :danger,
|
|
17
|
+
color: COLOR_DANGER,
|
|
18
|
+
label: "Sequential Scan",
|
|
19
19
|
explanation: "PostgreSQL read every row in the table to find matches. " \
|
|
20
20
|
"No index was used. This gets linearly slower as the table grows, " \
|
|
21
21
|
"at 1M rows it scans 1M rows for every query hit.",
|
|
22
|
-
fix_type:
|
|
22
|
+
fix_type: :missing_index
|
|
23
23
|
},
|
|
24
24
|
"Index Scan" => {
|
|
25
|
-
risk:
|
|
26
|
-
color:
|
|
27
|
-
label:
|
|
25
|
+
risk: :healthy,
|
|
26
|
+
color: COLOR_HEALTHY,
|
|
27
|
+
label: "Index Scan",
|
|
28
28
|
explanation: "PostgreSQL used an index to locate matching rows directly. " \
|
|
29
29
|
"Fast and consistent regardless of table size.",
|
|
30
|
-
fix_type:
|
|
30
|
+
fix_type: nil
|
|
31
31
|
},
|
|
32
32
|
"Index Only Scan" => {
|
|
33
|
-
risk:
|
|
34
|
-
color:
|
|
35
|
-
label:
|
|
33
|
+
risk: :healthy,
|
|
34
|
+
color: COLOR_HEALTHY,
|
|
35
|
+
label: "Index Only Scan",
|
|
36
36
|
explanation: "PostgreSQL satisfied the query entirely from the index " \
|
|
37
37
|
"without touching the table. The most efficient scan type.",
|
|
38
|
-
fix_type:
|
|
38
|
+
fix_type: nil
|
|
39
39
|
},
|
|
40
40
|
"Bitmap Heap Scan" => {
|
|
41
|
-
risk:
|
|
42
|
-
color:
|
|
43
|
-
label:
|
|
41
|
+
risk: :neutral,
|
|
42
|
+
color: COLOR_WARNING,
|
|
43
|
+
label: "Bitmap Heap Scan",
|
|
44
44
|
explanation: "PostgreSQL built a bitmap of matching index entries, then " \
|
|
45
45
|
"fetched the actual rows. Common with IN (...) conditions and " \
|
|
46
46
|
"multiple indexes. Generally acceptable.",
|
|
47
|
-
fix_type:
|
|
47
|
+
fix_type: nil
|
|
48
48
|
},
|
|
49
49
|
"Bitmap Index Scan" => {
|
|
50
|
-
risk:
|
|
51
|
-
color:
|
|
52
|
-
label:
|
|
50
|
+
risk: :neutral,
|
|
51
|
+
color: COLOR_WARNING,
|
|
52
|
+
label: "Bitmap Index Scan",
|
|
53
53
|
explanation: "Builds a bitmap of row locations from an index. " \
|
|
54
54
|
"Works in conjunction with Bitmap Heap Scan.",
|
|
55
|
-
fix_type:
|
|
55
|
+
fix_type: nil
|
|
56
56
|
},
|
|
57
57
|
"Hash Join" => {
|
|
58
|
-
risk:
|
|
59
|
-
color:
|
|
60
|
-
label:
|
|
58
|
+
risk: :neutral,
|
|
59
|
+
color: COLOR_INFO,
|
|
60
|
+
label: "Hash Join",
|
|
61
61
|
explanation: "Builds a hash table from one side of the JOIN, then probes " \
|
|
62
62
|
"it for each row from the other side. Common with includes() " \
|
|
63
63
|
"and eager_load(). Efficient for large datasets.",
|
|
64
|
-
fix_type:
|
|
64
|
+
fix_type: nil
|
|
65
65
|
},
|
|
66
66
|
"Nested Loop" => {
|
|
67
|
-
risk:
|
|
68
|
-
color:
|
|
69
|
-
label:
|
|
67
|
+
risk: :warning,
|
|
68
|
+
color: COLOR_WARNING,
|
|
69
|
+
label: "Nested Loop",
|
|
70
70
|
explanation: "For each row on the outer side, scans the inner side. " \
|
|
71
71
|
"Fast when the inner side is small or uses an index. " \
|
|
72
72
|
"Dangerous when both sides are large, O(n²) complexity.",
|
|
73
|
-
fix_type:
|
|
73
|
+
fix_type: :check_join_indexes
|
|
74
74
|
},
|
|
75
75
|
"Merge Join" => {
|
|
76
|
-
risk:
|
|
77
|
-
color:
|
|
78
|
-
label:
|
|
76
|
+
risk: :neutral,
|
|
77
|
+
color: COLOR_COOL,
|
|
78
|
+
label: "Merge Join",
|
|
79
79
|
explanation: "Joins two pre-sorted datasets by merging them in order. " \
|
|
80
80
|
"Efficient when both sides are already sorted on the join key.",
|
|
81
|
-
fix_type:
|
|
81
|
+
fix_type: nil
|
|
82
82
|
},
|
|
83
83
|
"Aggregate" => {
|
|
84
|
-
risk:
|
|
85
|
-
color:
|
|
86
|
-
label:
|
|
84
|
+
risk: :neutral,
|
|
85
|
+
color: COLOR_NEUTRAL,
|
|
86
|
+
label: "Aggregate",
|
|
87
87
|
explanation: "Computes an aggregate function (COUNT, SUM, AVG, etc.) " \
|
|
88
88
|
"over a set of rows. Generated by .count, .sum, .average in Rails.",
|
|
89
|
-
fix_type:
|
|
89
|
+
fix_type: nil
|
|
90
90
|
},
|
|
91
91
|
"Hash" => {
|
|
92
|
-
risk:
|
|
93
|
-
color:
|
|
94
|
-
label:
|
|
92
|
+
risk: :neutral,
|
|
93
|
+
color: COLOR_NEUTRAL,
|
|
94
|
+
label: "Hash",
|
|
95
95
|
explanation: "Builds a hash table in memory for use by a Hash Join node above it.",
|
|
96
|
-
fix_type:
|
|
96
|
+
fix_type: nil
|
|
97
97
|
},
|
|
98
98
|
"Sort" => {
|
|
99
|
-
risk:
|
|
100
|
-
color:
|
|
101
|
-
label:
|
|
99
|
+
risk: :warning,
|
|
100
|
+
color: COLOR_WARNING,
|
|
101
|
+
label: "Sort",
|
|
102
102
|
explanation: "PostgreSQL sorted rows in memory (or on disk if large). " \
|
|
103
103
|
"Generated by ORDER BY on an unindexed column. " \
|
|
104
104
|
"An index on the sort column eliminates this node entirely.",
|
|
105
|
-
fix_type:
|
|
105
|
+
fix_type: :index_sort_column
|
|
106
106
|
},
|
|
107
107
|
"Limit" => {
|
|
108
|
-
risk:
|
|
109
|
-
color:
|
|
110
|
-
label:
|
|
108
|
+
risk: :healthy,
|
|
109
|
+
color: COLOR_HEALTHY,
|
|
110
|
+
label: "Limit",
|
|
111
111
|
explanation: "Stops fetching rows once the LIMIT is reached. " \
|
|
112
112
|
"Good, always paginate large result sets.",
|
|
113
|
-
fix_type:
|
|
113
|
+
fix_type: nil
|
|
114
114
|
}
|
|
115
115
|
}.freeze
|
|
116
116
|
|
|
@@ -147,18 +147,18 @@ module RailsVitals
|
|
|
147
147
|
exec_time = plan_json.first["Execution Time"]&.round(2)
|
|
148
148
|
|
|
149
149
|
root_node = build_node(root_plan)
|
|
150
|
-
warnings
|
|
150
|
+
warnings = extract_warnings(root_node)
|
|
151
151
|
suggestions = build_suggestions(warnings, root_node)
|
|
152
152
|
|
|
153
153
|
result = Result.new(
|
|
154
|
-
sql:
|
|
155
|
-
plan:
|
|
156
|
-
warnings:
|
|
157
|
-
suggestions:
|
|
158
|
-
total_cost:
|
|
154
|
+
sql: safe_sql,
|
|
155
|
+
plan: root_node,
|
|
156
|
+
warnings: warnings,
|
|
157
|
+
suggestions: suggestions,
|
|
158
|
+
total_cost: root_plan["Total Cost"]&.round(2),
|
|
159
159
|
actual_time_ms: exec_time,
|
|
160
|
-
rows_examined:
|
|
161
|
-
error:
|
|
160
|
+
rows_examined: count_rows_examined(root_plan),
|
|
161
|
+
error: nil
|
|
162
162
|
)
|
|
163
163
|
|
|
164
164
|
result.interpretation = interpret(result)
|
|
@@ -186,6 +186,7 @@ module RailsVitals
|
|
|
186
186
|
value = bind.is_a?(String) ? "'#{bind.gsub("'", "''")}'" : bind.to_s
|
|
187
187
|
result = result.gsub("$#{i + 1}", value)
|
|
188
188
|
end
|
|
189
|
+
|
|
189
190
|
# Replace any remaining ? placeholders with NULL
|
|
190
191
|
result.gsub("?", "NULL")
|
|
191
192
|
end
|
|
@@ -263,7 +264,7 @@ module RailsVitals
|
|
|
263
264
|
migration: fk ?
|
|
264
265
|
"add_index :#{w[:table]}, :#{fk}" :
|
|
265
266
|
"add_index :#{w[:table]}, :COLUMN_NAME",
|
|
266
|
-
command:
|
|
267
|
+
command: "rails g migration Add#{fk&.camelize || 'Index'}To#{w[:table].camelize}"
|
|
267
268
|
}
|
|
268
269
|
when :sort_without_index
|
|
269
270
|
suggestions << {
|
|
@@ -291,6 +292,7 @@ module RailsVitals
|
|
|
291
292
|
|
|
292
293
|
def self.extract_fk_from_filter(filter)
|
|
293
294
|
return nil unless filter
|
|
295
|
+
|
|
294
296
|
match = filter.match(/\((\w+)\s*=\s*/i)
|
|
295
297
|
match ? match[1] : nil
|
|
296
298
|
end
|
|
@@ -4,11 +4,11 @@ module RailsVitals
|
|
|
4
4
|
def self.aggregate(records)
|
|
5
5
|
pattern_data = Hash.new do |h, k|
|
|
6
6
|
h[k] = {
|
|
7
|
-
pattern:
|
|
8
|
-
occurrences:
|
|
9
|
-
endpoints:
|
|
10
|
-
table:
|
|
11
|
-
foreign_key:
|
|
7
|
+
pattern: k,
|
|
8
|
+
occurrences: 0,
|
|
9
|
+
endpoints: Hash.new(0),
|
|
10
|
+
table: nil,
|
|
11
|
+
foreign_key: nil
|
|
12
12
|
}
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -17,11 +17,11 @@ module RailsVitals
|
|
|
17
17
|
|
|
18
18
|
record.n_plus_one_patterns.each do |sql, count|
|
|
19
19
|
normalized = normalize(sql)
|
|
20
|
-
Rails.logger.debug "Processing SQL: #{sql} → normalized: #{normalized}"
|
|
20
|
+
Rails.logger.debug "#{self.name}: Processing SQL: #{sql} → normalized: #{normalized}"
|
|
21
21
|
|
|
22
22
|
pattern_data[normalized][:occurrences] += count
|
|
23
23
|
pattern_data[normalized][:endpoints][record.endpoint] += 1
|
|
24
|
-
pattern_data[normalized][:table]
|
|
24
|
+
pattern_data[normalized][:table] ||= extract_table(sql)
|
|
25
25
|
pattern_data[normalized][:foreign_key] ||= extract_foreign_key(sql)
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -39,7 +39,7 @@ module RailsVitals
|
|
|
39
39
|
|
|
40
40
|
def self.normalize(sql)
|
|
41
41
|
sql
|
|
42
|
-
.gsub('\\"', '"')
|
|
42
|
+
.gsub('\\"', '"') # unescape stored escaped quotes
|
|
43
43
|
.gsub(/\b\d+\b/, "?")
|
|
44
44
|
.gsub(/'[^']*'/, "?")
|
|
45
45
|
.gsub(/\s+/, " ")
|
|
@@ -57,7 +57,7 @@ module RailsVitals
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def self.build_suggestion(pattern)
|
|
60
|
-
table
|
|
60
|
+
table = pattern[:table]
|
|
61
61
|
foreign_key = pattern[:foreign_key]
|
|
62
62
|
|
|
63
63
|
return generic_suggestion(table) unless table && foreign_key
|
|
@@ -68,9 +68,9 @@ module RailsVitals
|
|
|
68
68
|
|
|
69
69
|
if owner_model && assoc_name
|
|
70
70
|
{
|
|
71
|
-
code:
|
|
71
|
+
code: "#{owner_model}.includes(:#{assoc_name})",
|
|
72
72
|
description: "Eager load :#{assoc_name} on #{owner_model} to eliminate this N+1",
|
|
73
|
-
owner:
|
|
73
|
+
owner: owner_model,
|
|
74
74
|
association: assoc_name
|
|
75
75
|
}
|
|
76
76
|
else
|
|
@@ -105,9 +105,9 @@ module RailsVitals
|
|
|
105
105
|
|
|
106
106
|
def self.generic_suggestion(table)
|
|
107
107
|
{
|
|
108
|
-
code:
|
|
108
|
+
code: "includes(:#{table&.singularize})",
|
|
109
109
|
description: "Use includes(), eager_load(), or preload() to batch this query",
|
|
110
|
-
owner:
|
|
110
|
+
owner: nil,
|
|
111
111
|
association: table&.singularize
|
|
112
112
|
}
|
|
113
113
|
end
|
|
@@ -3,63 +3,63 @@ module RailsVitals
|
|
|
3
3
|
class SqlTokenizer
|
|
4
4
|
TOKEN_DEFINITIONS = [
|
|
5
5
|
{
|
|
6
|
-
type:
|
|
7
|
-
pattern:
|
|
8
|
-
label:
|
|
9
|
-
color:
|
|
10
|
-
risk:
|
|
6
|
+
type: :select_star,
|
|
7
|
+
pattern: /\bSELECT\s+\*\b/i,
|
|
8
|
+
label: "SELECT *",
|
|
9
|
+
color: "#4299e1",
|
|
10
|
+
risk: :warning,
|
|
11
11
|
explanation: "Fetches all columns from the table. In Rails this is the default " \
|
|
12
12
|
"behavior of Model.all and most queries. Can be wasteful when you " \
|
|
13
13
|
"only need specific attributes. Use .select(:id, :name) to fetch " \
|
|
14
14
|
"only what you need, especially on wide tables."
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
|
-
type:
|
|
18
|
-
pattern:
|
|
19
|
-
label:
|
|
20
|
-
color:
|
|
21
|
-
risk:
|
|
17
|
+
type: :select,
|
|
18
|
+
pattern: /\bSELECT\b(?!\s+\*)/i,
|
|
19
|
+
label: "SELECT",
|
|
20
|
+
color: "#4299e1",
|
|
21
|
+
risk: :healthy,
|
|
22
22
|
explanation: "Fetches specific columns. More efficient than SELECT * when your " \
|
|
23
23
|
"table has many columns or large text/json fields you don't need."
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
|
-
type:
|
|
27
|
-
pattern:
|
|
28
|
-
label:
|
|
29
|
-
color:
|
|
30
|
-
risk:
|
|
26
|
+
type: :count,
|
|
27
|
+
pattern: /\bCOUNT\s*\(/i,
|
|
28
|
+
label: "COUNT(*)",
|
|
29
|
+
color: "#ed8936",
|
|
30
|
+
risk: :warning,
|
|
31
31
|
explanation: "Counts rows matching the condition. When this appears in a loop " \
|
|
32
32
|
"(N+1 pattern), Rails fires one COUNT query per record. The fix is " \
|
|
33
33
|
"a counter cache column or loading the association and calling .size " \
|
|
34
34
|
"which uses the already-loaded records instead of hitting the DB."
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
|
-
type:
|
|
38
|
-
pattern:
|
|
39
|
-
label:
|
|
40
|
-
color:
|
|
41
|
-
risk:
|
|
37
|
+
type: :aggregation,
|
|
38
|
+
pattern: /\b(SUM|AVG|MIN|MAX)\s*\(/i,
|
|
39
|
+
label: "AGGREGATE",
|
|
40
|
+
color: "#ed8936",
|
|
41
|
+
risk: :warning,
|
|
42
42
|
explanation: "Aggregation function (SUM/AVG/MIN/MAX). Like COUNT, these are " \
|
|
43
43
|
"dangerous in loops. Each call fires a separate query. Consider " \
|
|
44
44
|
"loading the association once and using Ruby's .sum/.min/.max on " \
|
|
45
45
|
"the already-loaded collection instead."
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
|
-
type:
|
|
49
|
-
pattern:
|
|
50
|
-
label:
|
|
51
|
-
color:
|
|
52
|
-
risk:
|
|
48
|
+
type: :from,
|
|
49
|
+
pattern: /\bFROM\s+"?(\w+)"?/i,
|
|
50
|
+
label: "FROM",
|
|
51
|
+
color: "#68d391",
|
|
52
|
+
risk: :healthy,
|
|
53
53
|
explanation: "Identifies which table (and therefore which ActiveRecord model) " \
|
|
54
54
|
"is being queried. In an N+1, you'll see the same FROM table " \
|
|
55
55
|
"repeated many times, once per parent record."
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
|
-
type:
|
|
59
|
-
pattern:
|
|
60
|
-
label:
|
|
61
|
-
color:
|
|
62
|
-
risk:
|
|
58
|
+
type: :where_fk,
|
|
59
|
+
pattern: /\bWHERE\s+.*\w+_id\s*=/i,
|
|
60
|
+
label: "WHERE fk =",
|
|
61
|
+
color: "#fc8181",
|
|
62
|
+
risk: :danger,
|
|
63
63
|
explanation: "WHERE condition on a foreign key with a single value. This is " \
|
|
64
64
|
"the N+1 signature, loading one associated record at a time. " \
|
|
65
65
|
"When you see this pattern repeated, it means Rails is fetching " \
|
|
@@ -67,33 +67,33 @@ module RailsVitals
|
|
|
67
67
|
"which replaces this with a single WHERE fk IN (...) query."
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
|
-
type:
|
|
71
|
-
pattern:
|
|
72
|
-
label:
|
|
73
|
-
color:
|
|
74
|
-
risk:
|
|
70
|
+
type: :where,
|
|
71
|
+
pattern: /\bWHERE\b/i,
|
|
72
|
+
label: "WHERE",
|
|
73
|
+
color: "#f6ad55",
|
|
74
|
+
risk: :neutral,
|
|
75
75
|
explanation: "Filters rows by condition. Efficient when the condition column " \
|
|
76
76
|
"has an index. Slow when it doesn't. DB engine will scan every " \
|
|
77
77
|
"row in the table (Sequential Scan). Check the EXPLAIN output to " \
|
|
78
78
|
"verify an index is being used."
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
|
-
type:
|
|
82
|
-
pattern:
|
|
83
|
-
label:
|
|
84
|
-
color:
|
|
85
|
-
risk:
|
|
81
|
+
type: :where_in,
|
|
82
|
+
pattern: /\bIN\s*\(/i,
|
|
83
|
+
label: "IN (...)",
|
|
84
|
+
color: "#68d391",
|
|
85
|
+
risk: :healthy,
|
|
86
86
|
explanation: "Batch lookup fetches multiple records in one query using a list " \
|
|
87
87
|
"of values. This is what eager loading (includes/preload) generates " \
|
|
88
88
|
"instead of repeated WHERE fk = ? queries. Seeing IN (...) means " \
|
|
89
89
|
"your associations are being loaded efficiently."
|
|
90
90
|
},
|
|
91
91
|
{
|
|
92
|
-
type:
|
|
93
|
-
pattern:
|
|
94
|
-
label:
|
|
95
|
-
color:
|
|
96
|
-
risk:
|
|
92
|
+
type: :inner_join,
|
|
93
|
+
pattern: /\bINNER\s+JOIN\b/i,
|
|
94
|
+
label: "INNER JOIN",
|
|
95
|
+
color: "#9f7aea",
|
|
96
|
+
risk: :neutral,
|
|
97
97
|
explanation: "Combines rows from two tables where the join condition matches. " \
|
|
98
98
|
"In Rails this is what .joins() generates. Records without a " \
|
|
99
99
|
"matching association are excluded from results. Note: .joins() " \
|
|
@@ -101,22 +101,22 @@ module RailsVitals
|
|
|
101
101
|
"to access associated data."
|
|
102
102
|
},
|
|
103
103
|
{
|
|
104
|
-
type:
|
|
105
|
-
pattern:
|
|
106
|
-
label:
|
|
107
|
-
color:
|
|
108
|
-
risk:
|
|
104
|
+
type: :left_join,
|
|
105
|
+
pattern: /\bLEFT\s+(OUTER\s+)?JOIN\b/i,
|
|
106
|
+
label: "LEFT JOIN",
|
|
107
|
+
color: "#9f7aea",
|
|
108
|
+
risk: :neutral,
|
|
109
109
|
explanation: "Like INNER JOIN but keeps all rows from the left table even " \
|
|
110
110
|
"when there's no matching row on the right. In Rails this is " \
|
|
111
111
|
"what .eager_load() and .left_joins() generate. Use when you " \
|
|
112
112
|
"need to include records that have no associated data."
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
|
-
type:
|
|
116
|
-
pattern:
|
|
117
|
-
label:
|
|
118
|
-
color:
|
|
119
|
-
risk:
|
|
115
|
+
type: :order,
|
|
116
|
+
pattern: /\bORDER\s+BY\b/i,
|
|
117
|
+
label: "ORDER BY",
|
|
118
|
+
color: "#76e4f7",
|
|
119
|
+
risk: :warning,
|
|
120
120
|
explanation: "Sorts results by a column. Fast when sorting on an indexed " \
|
|
121
121
|
"column. Slow when sorting on an unindexed column. DB engine " \
|
|
122
122
|
"must sort all matching rows in memory. Default Rails scopes " \
|
|
@@ -124,22 +124,22 @@ module RailsVitals
|
|
|
124
124
|
"has an index if your table is large."
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
|
-
type:
|
|
128
|
-
pattern:
|
|
129
|
-
label:
|
|
130
|
-
color:
|
|
131
|
-
risk:
|
|
127
|
+
type: :limit,
|
|
128
|
+
pattern: /\bLIMIT\s+\d+/i,
|
|
129
|
+
label: "LIMIT",
|
|
130
|
+
color: "#a0aec0",
|
|
131
|
+
risk: :healthy,
|
|
132
132
|
explanation: "Restricts the number of rows returned. Always use LIMIT in " \
|
|
133
133
|
"production feeds and lists, never load unbounded data. " \
|
|
134
134
|
"Note: LIMIT with OFFSET becomes slower as OFFSET grows " \
|
|
135
135
|
"because DB engine must scan and discard all preceding rows."
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
|
-
type:
|
|
139
|
-
pattern:
|
|
140
|
-
label:
|
|
141
|
-
color:
|
|
142
|
-
risk:
|
|
138
|
+
type: :offset,
|
|
139
|
+
pattern: /\bOFFSET\s+\d+/i,
|
|
140
|
+
label: "OFFSET",
|
|
141
|
+
color: "#fc8181",
|
|
142
|
+
risk: :warning,
|
|
143
143
|
explanation: "Skips N rows before returning results. Common in pagination " \
|
|
144
144
|
"(page 2, page 3...). The hidden cost: DB engine must read " \
|
|
145
145
|
"and discard all rows before the offset, at page 100 with " \
|
|
@@ -147,11 +147,11 @@ module RailsVitals
|
|
|
147
147
|
"cursor-based pagination (WHERE id > last_id) for large datasets."
|
|
148
148
|
},
|
|
149
149
|
{
|
|
150
|
-
type:
|
|
151
|
-
pattern:
|
|
152
|
-
label:
|
|
153
|
-
color:
|
|
154
|
-
risk:
|
|
150
|
+
type: :group_by,
|
|
151
|
+
pattern: /\bGROUP\s+BY\b/i,
|
|
152
|
+
label: "GROUP BY",
|
|
153
|
+
color: "#76e4f7",
|
|
154
|
+
risk: :neutral,
|
|
155
155
|
explanation: "Groups rows sharing a value and applies aggregate functions " \
|
|
156
156
|
"per group. Common with COUNT, SUM, AVG. Used in Rails with " \
|
|
157
157
|
".group(:column). Consider a counter cache column if you're " \
|
|
@@ -162,11 +162,11 @@ module RailsVitals
|
|
|
162
162
|
|
|
163
163
|
COMPLEXITY_RULES = [
|
|
164
164
|
{ tokens: [ :left_join, :inner_join ], points: 2 },
|
|
165
|
-
{ tokens: [ :where_fk ],
|
|
166
|
-
{ tokens: [ :count, :aggregation ],
|
|
167
|
-
{ tokens: [ :offset ],
|
|
168
|
-
{ tokens: [ :group_by ],
|
|
169
|
-
{ tokens: [ :order ],
|
|
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
170
|
].freeze
|
|
171
171
|
|
|
172
172
|
Result = Struct.new(:tokens, :complexity, :complexity_label,
|
|
@@ -181,12 +181,12 @@ module RailsVitals
|
|
|
181
181
|
repetition = calculate_repetition(sql, all_queries)
|
|
182
182
|
|
|
183
183
|
Result.new(
|
|
184
|
-
tokens:
|
|
185
|
-
complexity:
|
|
184
|
+
tokens: matched,
|
|
185
|
+
complexity: complexity,
|
|
186
186
|
complexity_label: complexity_label(complexity),
|
|
187
|
-
risk:
|
|
187
|
+
risk: risk,
|
|
188
188
|
repetition_count: repetition,
|
|
189
|
-
repetition_bar:
|
|
189
|
+
repetition_bar: repetition_bar(repetition, all_queries.size)
|
|
190
190
|
)
|
|
191
191
|
end
|
|
192
192
|
|
|
@@ -203,9 +203,9 @@ module RailsVitals
|
|
|
203
203
|
|
|
204
204
|
def self.complexity_label(score)
|
|
205
205
|
case score
|
|
206
|
-
when 1..2 then { label: "Simple",
|
|
206
|
+
when 1..2 then { label: "Simple", color: "#68d391" }
|
|
207
207
|
when 3..5 then { label: "Moderate", color: "#f6ad55" }
|
|
208
|
-
else { label: "Complex",
|
|
208
|
+
else { label: "Complex", color: "#fc8181" }
|
|
209
209
|
end
|
|
210
210
|
end
|
|
211
211
|
|
|
@@ -4,43 +4,43 @@ module RailsVitals
|
|
|
4
4
|
:response_status, :duration_ms, :started_at
|
|
5
5
|
|
|
6
6
|
def initialize
|
|
7
|
-
@queries
|
|
8
|
-
@callbacks
|
|
9
|
-
@controller
|
|
10
|
-
@action
|
|
11
|
-
@http_method
|
|
7
|
+
@queries = []
|
|
8
|
+
@callbacks = []
|
|
9
|
+
@controller = nil
|
|
10
|
+
@action = nil
|
|
11
|
+
@http_method = nil
|
|
12
12
|
@response_status = nil
|
|
13
|
-
@duration_ms
|
|
14
|
-
@started_at
|
|
13
|
+
@duration_ms = nil
|
|
14
|
+
@started_at = Time.now
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# Called by the sql.active_record subscriber
|
|
18
18
|
def add_query(sql:, duration_ms:, source:, binds: [])
|
|
19
19
|
@queries << {
|
|
20
|
-
sql:
|
|
20
|
+
sql: sql,
|
|
21
21
|
duration_ms: duration_ms,
|
|
22
|
-
source:
|
|
23
|
-
binds:
|
|
24
|
-
called_at:
|
|
22
|
+
source: source,
|
|
23
|
+
binds: binds,
|
|
24
|
+
called_at: Time.now
|
|
25
25
|
}
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def add_callback(model:, kind:, duration_ms:)
|
|
29
29
|
@callbacks << {
|
|
30
|
-
model:
|
|
31
|
-
kind:
|
|
30
|
+
model: model,
|
|
31
|
+
kind: kind,
|
|
32
32
|
duration_ms: duration_ms,
|
|
33
|
-
called_at:
|
|
33
|
+
called_at: Time.now
|
|
34
34
|
}
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Called by the process_action.action_controller subscriber
|
|
38
38
|
def finalize!(event)
|
|
39
|
-
@controller
|
|
40
|
-
@action
|
|
41
|
-
@http_method
|
|
39
|
+
@controller = event.payload[:controller]
|
|
40
|
+
@action = event.payload[:action]
|
|
41
|
+
@http_method = event.payload[:method]
|
|
42
42
|
@response_status = event.payload[:status]
|
|
43
|
-
@duration_ms
|
|
43
|
+
@duration_ms = event.duration
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def total_query_count
|
|
@@ -13,13 +13,13 @@ module RailsVitals
|
|
|
13
13
|
return super
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
start
|
|
16
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
|
|
17
17
|
result = super
|
|
18
18
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - start
|
|
19
19
|
|
|
20
20
|
collector.add_callback(
|
|
21
|
-
model:
|
|
22
|
-
kind:
|
|
21
|
+
model: self.class.name,
|
|
22
|
+
kind: kind,
|
|
23
23
|
duration_ms: duration.round(2)
|
|
24
24
|
)
|
|
25
25
|
|
|
@@ -28,10 +28,10 @@ module RailsVitals
|
|
|
28
28
|
next if rails_vitals_request?
|
|
29
29
|
|
|
30
30
|
collector.add_query(
|
|
31
|
-
sql:
|
|
31
|
+
sql: event.payload[:sql],
|
|
32
32
|
duration_ms: event.duration,
|
|
33
|
-
source:
|
|
34
|
-
binds:
|
|
33
|
+
source: extract_source(event.payload[:binds]),
|
|
34
|
+
binds: event.payload[:binds]&.map(&:value) || []
|
|
35
35
|
)
|
|
36
36
|
end
|
|
37
37
|
end
|