apm_bro 0.1.15 → 0.1.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 26626be56405c7aa4561db094b0e1bf27b1469cef67275786ec1ece6e28947e3
4
- data.tar.gz: c07ec1b39ff873b5cccfa909fb726aa5971bc87b6abdae8a59de4bd4550b898b
3
+ metadata.gz: 7bf4fc110108b143a2f2e608090ae8c75a5882f8634ef1c01d0ddb949d41e54a
4
+ data.tar.gz: 7c5bd6915f805032ca63b73ab9325ce64b262d8489c2842b45c28a02e84b961d
5
5
  SHA512:
6
- metadata.gz: 8c8dc70fec2841c841e71b945f9e6db3aaf40f35bd7e750f3386130829a0733e44e91c16da05b0eeb5e52175bad335af9990942ed66385ff5926460fc98b64f9
7
- data.tar.gz: 356a4e343d70b3bac7aee65d285fabe765dbb91d0ba74c4a2b594ca6459e13b7cfc68e8a96fbe43c525f5252e4a3f0692028dbcd38c01a0d7ad0d52b6d4cbda5
6
+ metadata.gz: 601ed7213bb2dc1b551ba8d8e47944492bd67b727b0747aaad9c60b5ce13fb2b728b218a43eb0775e1479f5684ea1721dd0b9310bd25d2ccfcfcfad8cfc9e439
7
+ data.tar.gz: b8a2e25e5c0369d7bd58fe058ef7d524bdc42c7cc22739d30f9ad0956338370f63578aa10688869c817148451b46d7b9e1dab8835c4e7f33531cd9146a04ed88
data/README.md CHANGED
@@ -108,6 +108,54 @@ ApmBro automatically tracks SQL queries executed during each request and job. Ea
108
108
  - `cached` - Whether the query was cached
109
109
  - `connection_id` - Database connection ID
110
110
  - `trace` - Call stack showing where the query was executed
111
+ - `explain_plan` - Query execution plan (when EXPLAIN ANALYZE is enabled, see below)
112
+
113
+ ## Automatic EXPLAIN ANALYZE for Slow Queries
114
+
115
+ ApmBro can automatically run `EXPLAIN ANALYZE` on slow SQL queries to help you understand query performance and identify optimization opportunities. This feature runs in the background and doesn't block your application requests.
116
+
117
+ ### How It Works
118
+
119
+ - **Automatic Detection**: When a query exceeds the configured threshold, ApmBro automatically captures its execution plan
120
+ - **Background Execution**: EXPLAIN ANALYZE runs in a separate thread using a dedicated database connection, so it never blocks your application
121
+ - **Database Support**: Works with PostgreSQL, MySQL, SQLite, and other databases
122
+ - **Smart Filtering**: Automatically skips transaction queries (BEGIN, COMMIT, ROLLBACK) and other queries that don't benefit from EXPLAIN
123
+
124
+ ### Configuration
125
+
126
+ - **`explain_analyze_enabled`** (default: `false`) - Set to `true` to enable automatic EXPLAIN ANALYZE
127
+ - **`slow_query_threshold_ms`** (default: `500`) - Queries taking longer than this threshold will have their execution plan captured
128
+
129
+ ### Example Configuration
130
+
131
+ ```ruby
132
+ ApmBro.configure do |config|
133
+ config.api_key = ENV['APM_BRO_API_KEY']
134
+ config.enabled = true
135
+
136
+ # Enable EXPLAIN ANALYZE for queries slower than 500ms
137
+ config.explain_analyze_enabled = true
138
+ config.slow_query_threshold_ms = 500
139
+
140
+ # Or use a higher threshold for production
141
+ # config.slow_query_threshold_ms = 1000 # Only explain queries > 1 second
142
+ end
143
+ ```
144
+
145
+ ### What You Get
146
+
147
+ When a slow query is detected, the `explain_plan` field in the SQL query data will contain:
148
+ - **PostgreSQL**: Full EXPLAIN ANALYZE output with buffer usage statistics
149
+ - **MySQL**: EXPLAIN ANALYZE output showing actual execution times
150
+ - **SQLite**: EXPLAIN QUERY PLAN output
151
+ - **Other databases**: Standard EXPLAIN output
152
+
153
+ This execution plan helps you:
154
+ - Identify missing indexes
155
+ - Understand query execution order
156
+ - Spot full table scans
157
+ - Optimize JOIN operations
158
+ - Analyze buffer and cache usage (PostgreSQL)
111
159
 
112
160
  ## View Rendering Tracking
113
161
 
@@ -4,7 +4,7 @@ module ApmBro
4
4
  class Configuration
5
5
  DEFAULT_ENDPOINT_PATH = "/v1/metrics"
6
6
 
7
- attr_accessor :api_key, :endpoint_url, :open_timeout, :read_timeout, :enabled, :ruby_dev, :memory_tracking_enabled, :allocation_tracking_enabled, :circuit_breaker_enabled, :circuit_breaker_failure_threshold, :circuit_breaker_recovery_timeout, :circuit_breaker_retry_timeout, :sample_rate, :excluded_controllers, :excluded_jobs, :excluded_controller_actions, :deploy_id
7
+ attr_accessor :api_key, :endpoint_url, :open_timeout, :read_timeout, :enabled, :ruby_dev, :memory_tracking_enabled, :allocation_tracking_enabled, :circuit_breaker_enabled, :circuit_breaker_failure_threshold, :circuit_breaker_recovery_timeout, :circuit_breaker_retry_timeout, :sample_rate, :excluded_controllers, :excluded_jobs, :excluded_controller_actions, :deploy_id, :slow_query_threshold_ms, :explain_analyze_enabled
8
8
 
9
9
  def initialize
10
10
  @api_key = nil
@@ -24,6 +24,8 @@ module ApmBro
24
24
  @excluded_jobs = []
25
25
  @excluded_controller_actions = []
26
26
  @deploy_id = resolve_deploy_id
27
+ @slow_query_threshold_ms = 500 # Default: 500ms
28
+ @explain_analyze_enabled = false # Enable EXPLAIN ANALYZE for slow queries by default
27
29
  end
28
30
 
29
31
  def resolve_api_key
@@ -112,15 +114,45 @@ module ApmBro
112
114
  end
113
115
 
114
116
  def resolve_excluded_controller_actions
115
- return @excluded_controller_actions if @excluded_controller_actions && !@excluded_controller_actions.empty?
117
+ # Collect patterns from @excluded_controller_actions
118
+ patterns = []
119
+ if @excluded_controller_actions && !@excluded_controller_actions.empty?
120
+ patterns.concat(Array(@excluded_controller_actions))
121
+ end
122
+
123
+ # Also check @excluded_controllers for patterns containing '#' (controller action patterns)
124
+ if @excluded_controllers && !@excluded_controllers.empty?
125
+ action_patterns = Array(@excluded_controllers).select { |pat| pat.to_s.include?("#") }
126
+ patterns.concat(action_patterns)
127
+ end
128
+
129
+ return patterns if !patterns.empty?
116
130
 
117
131
  if defined?(Rails)
118
132
  list = fetch_from_rails_settings(%w[apm_bro excluded_controller_actions])
119
- return Array(list) if list
133
+ if list
134
+ rails_patterns = Array(list)
135
+ # Also check excluded_controllers from Rails settings for action patterns
136
+ controllers_list = fetch_from_rails_settings(%w[apm_bro excluded_controllers])
137
+ if controllers_list
138
+ action_patterns = Array(controllers_list).select { |pat| pat.to_s.include?("#") }
139
+ rails_patterns.concat(action_patterns)
140
+ end
141
+ return rails_patterns if !rails_patterns.empty?
142
+ end
120
143
  end
121
144
 
122
145
  env = ENV["APM_BRO_EXCLUDED_CONTROLLER_ACTIONS"]
123
- return env.split(",").map(&:strip) if env && !env.strip.empty?
146
+ if env && !env.strip.empty?
147
+ env_patterns = env.split(",").map(&:strip)
148
+ # Also check excluded_controllers env var for action patterns
149
+ controllers_env = ENV["APM_BRO_EXCLUDED_CONTROLLERS"]
150
+ if controllers_env && !controllers_env.strip.empty?
151
+ action_patterns = controllers_env.split(",").map(&:strip).select { |pat| pat.include?("#") }
152
+ env_patterns.concat(action_patterns)
153
+ end
154
+ return env_patterns if !env_patterns.empty?
155
+ end
124
156
 
125
157
  []
126
158
  end
@@ -186,8 +218,16 @@ module ApmBro
186
218
  return false if name.nil? || pattern.nil?
187
219
  pat = pattern.to_s
188
220
  return !!(name.to_s == pat) unless pat.include?("*")
189
- # Convert simple wildcard pattern (e.g., "Admin::*") to regex
190
- regex = Regexp.new("^" + Regexp.escape(pat).gsub("\\*", "[^:]*") + "$")
221
+
222
+ # For controller action patterns (containing '#'), use .* to match any characters including colons
223
+ # For controller-only patterns, use [^:]* to match namespace segments
224
+ if pat.include?("#")
225
+ # Controller action pattern: allow * to match any characters including colons
226
+ regex = Regexp.new("^" + Regexp.escape(pat).gsub("\\*", ".*") + "$")
227
+ else
228
+ # Controller-only pattern: use [^:]* to match namespace segments
229
+ regex = Regexp.new("^" + Regexp.escape(pat).gsub("\\*", "[^:]*") + "$")
230
+ end
191
231
  !!(name.to_s =~ regex)
192
232
  rescue
193
233
  false
@@ -13,6 +13,7 @@ module ApmBro
13
13
  THREAD_LOCAL_ALLOC_START_KEY = :apm_bro_sql_alloc_start
14
14
  THREAD_LOCAL_ALLOC_RESULTS_KEY = :apm_bro_sql_alloc_results
15
15
  THREAD_LOCAL_BACKTRACE_KEY = :apm_bro_sql_backtraces
16
+ THREAD_LOCAL_EXPLAIN_PENDING_KEY = :apm_bro_explain_pending
16
17
 
17
18
  def self.subscribe!
18
19
  # Subscribe with a start/finish listener to measure allocations per query
@@ -40,15 +41,26 @@ module ApmBro
40
41
  rescue
41
42
  end
42
43
 
44
+ duration_ms = ((finished - started) * 1000.0).round(2)
45
+ original_sql = data[:sql]
46
+
43
47
  query_info = {
44
- sql: sanitize_sql(data[:sql]),
48
+ sql: sanitize_sql(original_sql),
45
49
  name: data[:name],
46
- duration_ms: ((finished - started) * 1000.0).round(2),
50
+ duration_ms: duration_ms,
47
51
  cached: data[:cached] || false,
48
52
  connection_id: data[:connection_id],
49
53
  trace: safe_query_trace(data, captured_backtrace),
50
54
  allocations: allocations
51
55
  }
56
+
57
+ # Run EXPLAIN ANALYZE for slow queries in the background
58
+ if should_explain_query?(duration_ms, original_sql)
59
+ # Store reference to query_info so we can update it when EXPLAIN completes
60
+ query_info[:explain_plan] = nil # Placeholder
61
+ start_explain_analyze_background(original_sql, data[:connection_id], query_info)
62
+ end
63
+
52
64
  # Add to thread-local storage
53
65
  Thread.current[THREAD_LOCAL_KEY] << query_info
54
66
  end
@@ -59,17 +71,43 @@ module ApmBro
59
71
  Thread.current[THREAD_LOCAL_ALLOC_START_KEY] = {}
60
72
  Thread.current[THREAD_LOCAL_ALLOC_RESULTS_KEY] = {}
61
73
  Thread.current[THREAD_LOCAL_BACKTRACE_KEY] = {}
74
+ Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY] = []
62
75
  end
63
76
 
64
77
  def self.stop_request_tracking
78
+ # Wait for any pending EXPLAIN ANALYZE queries to complete (with timeout)
79
+ # This must happen BEFORE we get the queries array reference to ensure
80
+ # all explain_plan fields are populated
81
+ wait_for_pending_explains(5.0) # 5 second timeout
82
+
83
+ # Get queries after waiting for EXPLAIN to complete
65
84
  queries = Thread.current[THREAD_LOCAL_KEY]
85
+
66
86
  Thread.current[THREAD_LOCAL_KEY] = nil
67
87
  Thread.current[THREAD_LOCAL_ALLOC_START_KEY] = nil
68
88
  Thread.current[THREAD_LOCAL_ALLOC_RESULTS_KEY] = nil
69
89
  Thread.current[THREAD_LOCAL_BACKTRACE_KEY] = nil
90
+ Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY] = nil
70
91
  queries || []
71
92
  end
72
93
 
94
+ def self.wait_for_pending_explains(timeout_seconds)
95
+ pending = Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY]
96
+ return unless pending && !pending.empty?
97
+
98
+ start_time = Time.now
99
+ pending.each do |thread|
100
+ remaining_time = timeout_seconds - (Time.now - start_time)
101
+ break if remaining_time <= 0
102
+
103
+ begin
104
+ thread.join(remaining_time)
105
+ rescue => e
106
+ ApmBro.logger.debug("Error waiting for EXPLAIN ANALYZE: #{e.message}")
107
+ end
108
+ end
109
+ end
110
+
73
111
  def self.sanitize_sql(sql)
74
112
  return sql unless sql.is_a?(String)
75
113
 
@@ -86,6 +124,215 @@ module ApmBro
86
124
  (sql.length > 1000) ? sql[0..1000] + "..." : sql
87
125
  end
88
126
 
127
+ def self.should_explain_query?(duration_ms, sql)
128
+ return false unless ApmBro.configuration.explain_analyze_enabled
129
+ return false if duration_ms < ApmBro.configuration.slow_query_threshold_ms
130
+ return false unless sql.is_a?(String)
131
+ return false if sql.strip.empty?
132
+
133
+ # Skip EXPLAIN for certain query types that don't benefit from it
134
+ sql_upper = sql.upcase.strip
135
+ return false if sql_upper.start_with?("EXPLAIN")
136
+ return false if sql_upper.start_with?("BEGIN")
137
+ return false if sql_upper.start_with?("COMMIT")
138
+ return false if sql_upper.start_with?("ROLLBACK")
139
+ return false if sql_upper.start_with?("SAVEPOINT")
140
+ return false if sql_upper.start_with?("RELEASE")
141
+
142
+ true
143
+ end
144
+
145
+ def self.start_explain_analyze_background(sql, connection_id, query_info)
146
+ return unless defined?(ActiveRecord)
147
+ return unless ActiveRecord::Base.respond_to?(:connection)
148
+
149
+ # Capture the main thread reference to append logs to the correct thread
150
+ main_thread = Thread.current
151
+
152
+ # Run EXPLAIN in a background thread to avoid blocking the main request
153
+ explain_thread = Thread.new do
154
+ connection = nil
155
+ begin
156
+ # Use a separate connection to avoid interfering with the main query
157
+ if ActiveRecord::Base.connection_pool.respond_to?(:checkout)
158
+ connection = ActiveRecord::Base.connection_pool.checkout
159
+ else
160
+ connection = ActiveRecord::Base.connection
161
+ end
162
+
163
+ # Build EXPLAIN query based on database adapter
164
+ explain_sql = build_explain_query(sql, connection)
165
+
166
+ # Execute the EXPLAIN query
167
+ # For PostgreSQL, use select_all which returns ActiveRecord::Result
168
+ # For other databases, use execute
169
+ adapter_name = connection.adapter_name.downcase
170
+ if adapter_name == "postgresql" || adapter_name == "postgis"
171
+ # PostgreSQL: select_all returns ActiveRecord::Result with rows
172
+ result = connection.select_all(explain_sql)
173
+ else
174
+ # Other databases: use execute
175
+ result = connection.execute(explain_sql)
176
+ end
177
+
178
+ # Format the result based on database adapter
179
+ explain_plan = format_explain_result(result, connection)
180
+
181
+ # Update the query_info with the explain plan
182
+ # This updates the hash that's already in the queries array
183
+ if explain_plan && !explain_plan.to_s.strip.empty?
184
+ query_info[:explain_plan] = explain_plan
185
+ append_log_to_thread(main_thread, :debug, "Captured EXPLAIN ANALYZE for slow query (#{query_info[:duration_ms]}ms): #{explain_plan[0..1000]}...")
186
+ else
187
+ query_info[:explain_plan] = nil
188
+ append_log_to_thread(main_thread, :debug, "EXPLAIN ANALYZE returned empty result. Result type: #{result.class}, Result: #{result.inspect[0..200]}")
189
+ end
190
+ rescue => e
191
+ # Silently fail - don't let EXPLAIN break the application
192
+ append_log_to_thread(main_thread, :debug, "Failed to capture EXPLAIN ANALYZE: #{e.message}")
193
+ query_info[:explain_plan] = nil
194
+ ensure
195
+ # Return connection to pool if we checked it out
196
+ if connection && ActiveRecord::Base.connection_pool.respond_to?(:checkin)
197
+ ActiveRecord::Base.connection_pool.checkin(connection) rescue nil
198
+ end
199
+ end
200
+ end
201
+
202
+ # Track the thread so we can wait for it when stopping request tracking
203
+ pending = Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY] ||= []
204
+ pending << explain_thread
205
+ rescue => e
206
+ # Use ApmBro.logger here since we're still in the main thread
207
+ ApmBro.logger.debug("Failed to start EXPLAIN ANALYZE thread: #{e.message}")
208
+ end
209
+
210
+ # Append a log entry directly to a specific thread's log storage
211
+ # This is used when logging from background threads to ensure logs
212
+ # are collected with the main request thread's logs
213
+ def self.append_log_to_thread(thread, severity, message)
214
+ timestamp = Time.now.utc
215
+ log_entry = {
216
+ sev: severity.to_s,
217
+ msg: message.to_s,
218
+ time: timestamp.iso8601(3)
219
+ }
220
+
221
+ # Append to the specified thread's log storage
222
+ thread[:apm_bro_logs] ||= []
223
+ thread[:apm_bro_logs] << log_entry
224
+
225
+ # Also print the message immediately (using current thread's logger)
226
+ begin
227
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
228
+ formatted_message = "[ApmBro] #{timestamp.iso8601(3)} #{severity.to_s.upcase}: #{message}"
229
+ case severity
230
+ when :debug
231
+ Rails.logger.debug(formatted_message)
232
+ when :info
233
+ Rails.logger.info(formatted_message)
234
+ when :warn
235
+ Rails.logger.warn(formatted_message)
236
+ when :error
237
+ Rails.logger.error(formatted_message)
238
+ when :fatal
239
+ Rails.logger.fatal(formatted_message)
240
+ end
241
+ else
242
+ # Fallback to stdout
243
+ $stdout.puts("[ApmBro] #{timestamp.iso8601(3)} #{severity.to_s.upcase}: #{message}")
244
+ end
245
+ rescue
246
+ # Never let logging break the application
247
+ $stdout.puts("[ApmBro] #{severity.to_s.upcase}: #{message}")
248
+ end
249
+ end
250
+
251
+ def self.build_explain_query(sql, connection)
252
+ adapter_name = connection.adapter_name.downcase
253
+
254
+ case adapter_name
255
+ when "postgresql", "postgis"
256
+ # PostgreSQL supports ANALYZE and BUFFERS
257
+ "EXPLAIN (ANALYZE, BUFFERS) #{sql}"
258
+ when "mysql", "mysql2", "trilogy"
259
+ # MySQL uses different syntax - ANALYZE is a separate keyword
260
+ "EXPLAIN ANALYZE #{sql}"
261
+ when "sqlite3"
262
+ # SQLite supports EXPLAIN QUERY PLAN
263
+ "EXPLAIN QUERY PLAN #{sql}"
264
+ else
265
+ # Generic fallback - just EXPLAIN
266
+ "EXPLAIN #{sql}"
267
+ end
268
+ end
269
+
270
+ def self.format_explain_result(result, connection)
271
+ adapter_name = connection.adapter_name.downcase
272
+
273
+ case adapter_name
274
+ when "postgresql", "postgis"
275
+ # PostgreSQL returns ActiveRecord::Result from select_all
276
+ if result.respond_to?(:rows)
277
+ # ActiveRecord::Result object - rows is an array of arrays
278
+ # Each row is [query_plan_string]
279
+ plan_text = result.rows.map { |row| row.is_a?(Array) ? row.first.to_s : row.to_s }.join("\n")
280
+ return plan_text unless plan_text.strip.empty?
281
+ end
282
+
283
+ # Try alternative methods to extract the plan
284
+ if result.respond_to?(:each) && result.respond_to?(:columns)
285
+ # ActiveRecord::Result with columns
286
+ plan_column = result.columns.find { |col| col.downcase.include?("plan") || col.downcase.include?("query") } || result.columns.first
287
+ plan_text = result.map { |row|
288
+ if row.is_a?(Hash)
289
+ row[plan_column] || row[plan_column.to_sym] || row.values.first
290
+ else
291
+ row
292
+ end
293
+ }.join("\n")
294
+ return plan_text unless plan_text.strip.empty?
295
+ end
296
+
297
+ if result.is_a?(Array)
298
+ # Array of hashes or arrays
299
+ plan_text = result.map do |row|
300
+ if row.is_a?(Hash)
301
+ row["QUERY PLAN"] || row["query plan"] || row[:query_plan] || row.values.first.to_s
302
+ elsif row.is_a?(Array)
303
+ row.first.to_s
304
+ else
305
+ row.to_s
306
+ end
307
+ end.join("\n")
308
+ return plan_text unless plan_text.strip.empty?
309
+ end
310
+
311
+ # Fallback to string representation
312
+ result.to_s
313
+ when "mysql", "mysql2", "trilogy"
314
+ # MySQL returns rows
315
+ if result.is_a?(Array)
316
+ result.map { |row| row.is_a?(Hash) ? row.values.join(" | ") : row.to_s }.join("\n")
317
+ else
318
+ result.to_s
319
+ end
320
+ when "sqlite3"
321
+ # SQLite returns rows
322
+ if result.is_a?(Array)
323
+ result.map { |row| row.is_a?(Hash) ? row.values.join(" | ") : row.to_s }.join("\n")
324
+ else
325
+ result.to_s
326
+ end
327
+ else
328
+ # Generic fallback
329
+ result.to_s
330
+ end
331
+ rescue => e
332
+ # Fallback to string representation
333
+ result.to_s
334
+ end
335
+
89
336
  def self.safe_query_trace(data, captured_backtrace = nil)
90
337
  return [] unless data.is_a?(Hash)
91
338
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApmBro
4
- VERSION = "0.1.15"
4
+ VERSION = "0.1.16"
5
5
  end
data/lib/apm_bro.rb CHANGED
@@ -51,4 +51,13 @@ module ApmBro
51
51
  def self.logger
52
52
  @logger ||= Logger.new
53
53
  end
54
+
55
+ # Returns the current environment (Rails.env or ENV fallback)
56
+ def self.env
57
+ if defined?(Rails) && Rails.respond_to?(:env)
58
+ Rails.env
59
+ else
60
+ ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development"
61
+ end
62
+ end
54
63
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apm_bro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa