brainzlab-rails 0.1.1
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/CLAUDE.md +144 -0
- data/IMPLEMENTATION_PLAN.md +370 -0
- data/Rakefile +8 -0
- data/brainzlab-rails.gemspec +42 -0
- data/lib/brainzlab/rails/analyzers/cache_efficiency.rb +123 -0
- data/lib/brainzlab/rails/analyzers/n_plus_one_detector.rb +90 -0
- data/lib/brainzlab/rails/analyzers/slow_query_analyzer.rb +118 -0
- data/lib/brainzlab/rails/collectors/action_cable.rb +212 -0
- data/lib/brainzlab/rails/collectors/action_controller.rb +299 -0
- data/lib/brainzlab/rails/collectors/action_mailer.rb +187 -0
- data/lib/brainzlab/rails/collectors/action_view.rb +176 -0
- data/lib/brainzlab/rails/collectors/active_job.rb +374 -0
- data/lib/brainzlab/rails/collectors/active_record.rb +250 -0
- data/lib/brainzlab/rails/collectors/active_storage.rb +306 -0
- data/lib/brainzlab/rails/collectors/base.rb +129 -0
- data/lib/brainzlab/rails/collectors/cache.rb +384 -0
- data/lib/brainzlab/rails/configuration.rb +121 -0
- data/lib/brainzlab/rails/event_router.rb +67 -0
- data/lib/brainzlab/rails/railtie.rb +98 -0
- data/lib/brainzlab/rails/subscriber.rb +164 -0
- data/lib/brainzlab/rails/version.rb +7 -0
- data/lib/brainzlab-rails.rb +72 -0
- metadata +178 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module Rails
|
|
5
|
+
module Analyzers
|
|
6
|
+
# Detects N+1 query patterns by tracking similar queries within a request
|
|
7
|
+
class NPlusOneDetector
|
|
8
|
+
THRESHOLD = 3 # Minimum repeated queries to flag as N+1
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@query_tracker = {}
|
|
12
|
+
@request_id = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def check(sql, name, unique_id)
|
|
16
|
+
# Reset tracker on new request
|
|
17
|
+
reset_if_new_request(unique_id)
|
|
18
|
+
|
|
19
|
+
# Skip non-SELECT queries
|
|
20
|
+
return nil unless sql.to_s.strip.upcase.start_with?('SELECT')
|
|
21
|
+
|
|
22
|
+
# Skip SCHEMA queries
|
|
23
|
+
return nil if name == 'SCHEMA'
|
|
24
|
+
|
|
25
|
+
# Normalize query for comparison (remove specific values)
|
|
26
|
+
normalized = normalize_query(sql)
|
|
27
|
+
|
|
28
|
+
# Track query occurrences
|
|
29
|
+
@query_tracker[normalized] ||= { count: 0, first_seen: Time.now, sql: sql }
|
|
30
|
+
@query_tracker[normalized][:count] += 1
|
|
31
|
+
|
|
32
|
+
# Check if threshold exceeded
|
|
33
|
+
count = @query_tracker[normalized][:count]
|
|
34
|
+
if count == THRESHOLD
|
|
35
|
+
{
|
|
36
|
+
query: truncate_sql(sql),
|
|
37
|
+
normalized: normalized,
|
|
38
|
+
count: count,
|
|
39
|
+
model: extract_model_from_query(sql),
|
|
40
|
+
location: extract_caller_location
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def reset_if_new_request(unique_id)
|
|
50
|
+
if @request_id != unique_id
|
|
51
|
+
@request_id = unique_id
|
|
52
|
+
@query_tracker = {}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_query(sql)
|
|
57
|
+
sql
|
|
58
|
+
.gsub(/\d+/, '?') # Replace numbers with ?
|
|
59
|
+
.gsub(/'[^']*'/, '?') # Replace strings with ?
|
|
60
|
+
.gsub(/"[^"]*"/, '?') # Replace quoted strings with ?
|
|
61
|
+
.gsub(/\s+/, ' ') # Normalize whitespace
|
|
62
|
+
.strip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_model_from_query(sql)
|
|
66
|
+
# Try to extract table name from SELECT ... FROM table_name
|
|
67
|
+
match = sql.match(/FROM\s+["`']?(\w+)["`']?/i)
|
|
68
|
+
if match
|
|
69
|
+
table_name = match[1]
|
|
70
|
+
# Convert to likely model name
|
|
71
|
+
table_name.singularize.camelize rescue table_name
|
|
72
|
+
else
|
|
73
|
+
'Unknown'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def extract_caller_location
|
|
78
|
+
# Find the first application frame in the backtrace
|
|
79
|
+
caller.find do |frame|
|
|
80
|
+
frame.include?('/app/') && !frame.include?('/gems/')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def truncate_sql(sql, max_length = 200)
|
|
85
|
+
sql.length > max_length ? "#{sql[0, max_length]}..." : sql
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module Rails
|
|
5
|
+
module Analyzers
|
|
6
|
+
# Analyzes slow queries and provides optimization suggestions
|
|
7
|
+
class SlowQueryAnalyzer
|
|
8
|
+
def initialize(configuration)
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
@slow_queries = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def analyze(sql, duration_ms, payload)
|
|
14
|
+
query_info = {
|
|
15
|
+
sql: truncate_sql(sql),
|
|
16
|
+
duration_ms: duration_ms,
|
|
17
|
+
name: payload[:name],
|
|
18
|
+
timestamp: Time.now.utc,
|
|
19
|
+
suggestions: generate_suggestions(sql, payload)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Log slow query
|
|
23
|
+
log_slow_query(query_info)
|
|
24
|
+
|
|
25
|
+
# Track for reporting
|
|
26
|
+
@slow_queries << query_info
|
|
27
|
+
|
|
28
|
+
query_info
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def recent_slow_queries(limit = 10)
|
|
32
|
+
@slow_queries.last(limit)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear!
|
|
36
|
+
@slow_queries = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def generate_suggestions(sql, payload)
|
|
42
|
+
suggestions = []
|
|
43
|
+
|
|
44
|
+
# Check for missing index indicators
|
|
45
|
+
if sql.include?('WHERE') && !sql.include?('INDEX')
|
|
46
|
+
suggestions << 'Consider adding an index for the WHERE clause columns'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check for SELECT *
|
|
50
|
+
if sql.match?(/SELECT\s+\*/i)
|
|
51
|
+
suggestions << 'Avoid SELECT * - specify only needed columns'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check for large LIMIT
|
|
55
|
+
if sql.match?(/LIMIT\s+(\d+)/i) && Regexp.last_match(1).to_i > 1000
|
|
56
|
+
suggestions << 'Large LIMIT detected - consider pagination'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check for ORDER BY without LIMIT
|
|
60
|
+
if sql.include?('ORDER BY') && !sql.include?('LIMIT')
|
|
61
|
+
suggestions << 'ORDER BY without LIMIT may be slow on large tables'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check for multiple JOINs
|
|
65
|
+
join_count = sql.scan(/JOIN/i).size
|
|
66
|
+
if join_count > 3
|
|
67
|
+
suggestions << "#{join_count} JOINs detected - consider query optimization"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check for subqueries
|
|
71
|
+
if sql.scan(/SELECT/i).size > 1
|
|
72
|
+
suggestions << 'Subquery detected - consider using JOINs or CTEs'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check for LIKE with leading wildcard
|
|
76
|
+
if sql.match?(/LIKE\s+['"]%/i)
|
|
77
|
+
suggestions << 'Leading wildcard in LIKE prevents index usage'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check for OR in WHERE
|
|
81
|
+
if sql.match?(/WHERE.*\bOR\b/i)
|
|
82
|
+
suggestions << 'OR in WHERE clause may prevent index usage - consider UNION'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
suggestions
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def log_slow_query(query_info)
|
|
89
|
+
if BrainzLab.configuration&.recall_effectively_enabled?
|
|
90
|
+
BrainzLab::Recall.warn('Slow query detected', **{
|
|
91
|
+
sql: query_info[:sql],
|
|
92
|
+
duration_ms: query_info[:duration_ms],
|
|
93
|
+
name: query_info[:name],
|
|
94
|
+
suggestions: query_info[:suggestions]
|
|
95
|
+
})
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if BrainzLab.configuration&.reflex_effectively_enabled?
|
|
99
|
+
BrainzLab::Reflex.add_breadcrumb(
|
|
100
|
+
"Slow query: #{query_info[:duration_ms]}ms",
|
|
101
|
+
category: 'db.slow_query',
|
|
102
|
+
level: :warning,
|
|
103
|
+
data: {
|
|
104
|
+
sql: query_info[:sql],
|
|
105
|
+
duration_ms: query_info[:duration_ms],
|
|
106
|
+
suggestions: query_info[:suggestions]
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def truncate_sql(sql, max_length = 500)
|
|
113
|
+
sql.length > max_length ? "#{sql[0, max_length]}..." : sql
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BrainzLab
|
|
4
|
+
module Rails
|
|
5
|
+
module Collectors
|
|
6
|
+
# Collects Action Cable WebSocket events
|
|
7
|
+
# This is a KEY differentiator - most APM tools have weak WebSocket support
|
|
8
|
+
class ActionCable < Base
|
|
9
|
+
def process(event_data)
|
|
10
|
+
case event_data[:name]
|
|
11
|
+
when 'perform_action.action_cable'
|
|
12
|
+
handle_perform_action(event_data)
|
|
13
|
+
when 'transmit.action_cable'
|
|
14
|
+
handle_transmit(event_data)
|
|
15
|
+
when 'transmit_subscription_confirmation.action_cable'
|
|
16
|
+
handle_subscription_confirmation(event_data)
|
|
17
|
+
when 'transmit_subscription_rejection.action_cable'
|
|
18
|
+
handle_subscription_rejection(event_data)
|
|
19
|
+
when 'broadcast.action_cable'
|
|
20
|
+
handle_broadcast(event_data)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def handle_perform_action(event_data)
|
|
27
|
+
payload = event_data[:payload]
|
|
28
|
+
channel_class = payload[:channel_class]
|
|
29
|
+
action = payload[:action]
|
|
30
|
+
duration_ms = event_data[:duration_ms]
|
|
31
|
+
|
|
32
|
+
# === PULSE: WebSocket action span ===
|
|
33
|
+
send_to_pulse(event_data, {
|
|
34
|
+
name: "cable.#{channel_class}##{action}",
|
|
35
|
+
category: 'websocket.action',
|
|
36
|
+
attributes: {
|
|
37
|
+
channel: channel_class,
|
|
38
|
+
action: action,
|
|
39
|
+
data_size: payload[:data]&.to_json&.bytesize
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
# === FLUX: Metrics ===
|
|
44
|
+
tags = { channel: channel_class, action: action }
|
|
45
|
+
send_to_flux(:increment, 'rails.cable.actions', 1, tags)
|
|
46
|
+
send_to_flux(:timing, 'rails.cable.action_ms', duration_ms, tags)
|
|
47
|
+
|
|
48
|
+
# === RECALL: Log ===
|
|
49
|
+
send_to_recall(:info, "Cable action: #{channel_class}##{action}", {
|
|
50
|
+
channel: channel_class,
|
|
51
|
+
action: action,
|
|
52
|
+
duration_ms: duration_ms
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
# === REFLEX: Breadcrumb ===
|
|
56
|
+
add_breadcrumb(
|
|
57
|
+
"Cable action: #{channel_class}##{action}",
|
|
58
|
+
category: 'websocket.action',
|
|
59
|
+
level: :info,
|
|
60
|
+
data: {
|
|
61
|
+
channel: channel_class,
|
|
62
|
+
action: action,
|
|
63
|
+
duration_ms: duration_ms
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_transmit(event_data)
|
|
69
|
+
payload = event_data[:payload]
|
|
70
|
+
channel_class = payload[:channel_class]
|
|
71
|
+
via = payload[:via]
|
|
72
|
+
duration_ms = event_data[:duration_ms]
|
|
73
|
+
|
|
74
|
+
# Calculate data size for bandwidth tracking
|
|
75
|
+
data_size = payload[:data]&.to_json&.bytesize || 0
|
|
76
|
+
|
|
77
|
+
# === PULSE: Transmit span ===
|
|
78
|
+
send_to_pulse(event_data, {
|
|
79
|
+
name: "cable.transmit.#{channel_class}",
|
|
80
|
+
category: 'websocket.transmit',
|
|
81
|
+
attributes: {
|
|
82
|
+
channel: channel_class,
|
|
83
|
+
via: via,
|
|
84
|
+
data_size_bytes: data_size
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
# === FLUX: Metrics ===
|
|
89
|
+
send_to_flux(:increment, 'rails.cable.transmissions', 1, {
|
|
90
|
+
channel: channel_class
|
|
91
|
+
})
|
|
92
|
+
send_to_flux(:histogram, 'rails.cable.transmit_bytes', data_size, {
|
|
93
|
+
channel: channel_class
|
|
94
|
+
})
|
|
95
|
+
send_to_flux(:timing, 'rails.cable.transmit_ms', duration_ms, {
|
|
96
|
+
channel: channel_class
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
# === REFLEX: Breadcrumb ===
|
|
100
|
+
add_breadcrumb(
|
|
101
|
+
"Cable transmit: #{channel_class}",
|
|
102
|
+
category: 'websocket.transmit',
|
|
103
|
+
level: :debug,
|
|
104
|
+
data: {
|
|
105
|
+
channel: channel_class,
|
|
106
|
+
via: via,
|
|
107
|
+
data_size_bytes: data_size
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def handle_subscription_confirmation(event_data)
|
|
113
|
+
payload = event_data[:payload]
|
|
114
|
+
channel_class = payload[:channel_class]
|
|
115
|
+
|
|
116
|
+
# === FLUX: Subscription metrics ===
|
|
117
|
+
send_to_flux(:increment, 'rails.cable.subscriptions', 1, {
|
|
118
|
+
channel: channel_class,
|
|
119
|
+
status: 'confirmed'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
# === RECALL: Log ===
|
|
123
|
+
send_to_recall(:info, "Cable subscription confirmed", {
|
|
124
|
+
channel: channel_class
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
# === REFLEX: Breadcrumb ===
|
|
128
|
+
add_breadcrumb(
|
|
129
|
+
"Cable subscribed: #{channel_class}",
|
|
130
|
+
category: 'websocket.subscribe',
|
|
131
|
+
level: :info,
|
|
132
|
+
data: { channel: channel_class }
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_subscription_rejection(event_data)
|
|
137
|
+
payload = event_data[:payload]
|
|
138
|
+
channel_class = payload[:channel_class]
|
|
139
|
+
|
|
140
|
+
# === FLUX: Rejection metrics ===
|
|
141
|
+
send_to_flux(:increment, 'rails.cable.subscriptions', 1, {
|
|
142
|
+
channel: channel_class,
|
|
143
|
+
status: 'rejected'
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
# === RECALL: Log rejection (potential auth issue) ===
|
|
147
|
+
send_to_recall(:warn, "Cable subscription rejected", {
|
|
148
|
+
channel: channel_class
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
# === REFLEX: Breadcrumb ===
|
|
152
|
+
add_breadcrumb(
|
|
153
|
+
"Cable subscription rejected: #{channel_class}",
|
|
154
|
+
category: 'websocket.subscribe',
|
|
155
|
+
level: :warning,
|
|
156
|
+
data: { channel: channel_class }
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def handle_broadcast(event_data)
|
|
161
|
+
payload = event_data[:payload]
|
|
162
|
+
broadcasting = payload[:broadcasting]
|
|
163
|
+
coder = payload[:coder]
|
|
164
|
+
duration_ms = event_data[:duration_ms]
|
|
165
|
+
|
|
166
|
+
# Calculate message size
|
|
167
|
+
message_size = payload[:message]&.to_json&.bytesize || 0
|
|
168
|
+
|
|
169
|
+
# === PULSE: Broadcast span ===
|
|
170
|
+
send_to_pulse(event_data, {
|
|
171
|
+
name: "cable.broadcast.#{broadcasting}",
|
|
172
|
+
category: 'websocket.broadcast',
|
|
173
|
+
attributes: {
|
|
174
|
+
broadcasting: broadcasting,
|
|
175
|
+
coder: coder.to_s,
|
|
176
|
+
message_size_bytes: message_size
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
# === FLUX: Broadcast metrics ===
|
|
181
|
+
send_to_flux(:increment, 'rails.cable.broadcasts', 1, {
|
|
182
|
+
broadcasting: broadcasting
|
|
183
|
+
})
|
|
184
|
+
send_to_flux(:histogram, 'rails.cable.broadcast_bytes', message_size, {
|
|
185
|
+
broadcasting: broadcasting
|
|
186
|
+
})
|
|
187
|
+
send_to_flux(:timing, 'rails.cable.broadcast_ms', duration_ms, {
|
|
188
|
+
broadcasting: broadcasting
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
# === RECALL: Log broadcast ===
|
|
192
|
+
send_to_recall(:info, "Cable broadcast", {
|
|
193
|
+
broadcasting: broadcasting,
|
|
194
|
+
message_size_bytes: message_size,
|
|
195
|
+
duration_ms: duration_ms
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
# === REFLEX: Breadcrumb ===
|
|
199
|
+
add_breadcrumb(
|
|
200
|
+
"Cable broadcast: #{broadcasting}",
|
|
201
|
+
category: 'websocket.broadcast',
|
|
202
|
+
level: :info,
|
|
203
|
+
data: {
|
|
204
|
+
broadcasting: broadcasting,
|
|
205
|
+
message_size_bytes: message_size
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|