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.
@@ -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