dead_bro 0.2.27 → 0.2.28

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: 780f95894158f81ca92c1c1778ba4807f1dce3db4f0f3734606cf47aea3db0e4
4
- data.tar.gz: 7ab0fbc93effdd1fbc0590aa30a9abc7fb1e7274f7c7f1513bdcfd2604e9200c
3
+ metadata.gz: 6b96d1dee673157c1b4c46ad86f7e0d4a4feb51774038de260c5464353ff292e
4
+ data.tar.gz: f922d71e3c1f132642bf5756aff381a5d372538672c3e0affd010cb31f30b397
5
5
  SHA512:
6
- metadata.gz: 6552ad55cf89e93470103ee33654249c796246f416f072b9d358e3f81a49aaf5651cf52593f931ed5989800c19aea97f0857782e68989c577211b9a5d7eadb42
7
- data.tar.gz: d5001a9656d7bc5407694f209fb3355d76cafc93331ae46c87485d4e187aad9592c03b0beeb305eaecafd5368ad5096ca6bb79a06ac58daadf5907e9fb0811a2
6
+ metadata.gz: 20f9d81a03bb468cc4ded983ad77b1ad9191dde3ba5c5320b7dd1e11bb5295b5d90289f597df036770cb0d6740ec10f426cf75ba5c941a1922cce8785ee21fe8
7
+ data.tar.gz: c5325f9e3c8126236fb4386b9214d9637a4086176ae6e2675d4344958bc09d16bbb5718f6461f8bb8eaf75da4ef7e295d3ae012b15a5bb0ab1fbdea8ef0b8f48
data/README.md CHANGED
@@ -184,31 +184,35 @@ DeadBro automatically tracks SQL queries executed during each request and job. E
184
184
  - `cached` - Whether the query was cached
185
185
  - `connection_id` - Database connection ID
186
186
  - `trace` - Call stack showing where the query was executed
187
- - `explain_plan` - Query execution plan (when EXPLAIN ANALYZE is enabled, see below)
187
+ - `explain_plan` - Query execution plan (when EXPLAIN capture is enabled, see below)
188
188
 
189
- ## Automatic EXPLAIN ANALYZE for Slow Queries
189
+ ## Automatic EXPLAIN for Slow Queries
190
190
 
191
- DeadBro 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.
191
+ DeadBro can automatically capture the query plan (`EXPLAIN`) of slow SELECT queries to help you understand query performance and identify optimization opportunities.
192
+
193
+ ### Safety guarantees
194
+
195
+ - **Plan-only, never executed**: DeadBro runs plain `EXPLAIN` (never `EXPLAIN ANALYZE`), which asks the database for its plan without executing the statement. Your queries are never re-run and your data is never touched.
196
+ - **SELECT-only allowlist**: only `SELECT` statements (and read-only `WITH ... SELECT` CTEs) are ever explained. INSERT/UPDATE/DELETE, DDL, and multi-statement strings are always skipped.
197
+ - **Local opt-in required**: the feature is off unless you enable it in your own `DeadBro.configure` block. The DeadBro backend can remotely *disable* it (e.g. plan gating), but can never switch it on.
198
+ - **Value scrubbing**: quoted string literals echoed in plan text (e.g. `Filter: email = 'a@b.com'`) are replaced with `?` before the plan leaves your app.
199
+ - **Bounded overhead**: plans are captured on background threads (max 3 concurrent per request); at request end DeadBro waits at most 0.5s for stragglers, then drops them.
192
200
 
193
201
  ### How It Works
194
202
 
195
- - **Automatic Detection**: When a query exceeds the configured threshold, DeadBro automatically captures its execution plan
196
- - **Background Execution**: EXPLAIN ANALYZE runs in a separate thread using a dedicated database connection, so it never blocks your application
197
- - **Database Support**: Works with PostgreSQL, MySQL, SQLite, and other databases
198
- - **Smart Filtering**: Automatically skips transaction queries (BEGIN, COMMIT, ROLLBACK) and other queries that don't benefit from EXPLAIN
203
+ - **Automatic Detection**: when a SELECT exceeds `slow_query_threshold_ms`, DeadBro captures its plan in the background
204
+ - **Database Support**: PostgreSQL, MySQL, SQLite (`EXPLAIN QUERY PLAN`), and any adapter with a standard `EXPLAIN`
199
205
 
200
206
  ### Configuration
201
207
 
202
- These options are usually set in the DeadBro UI. In Ruby:
203
-
204
- - **`explain_analyze_enabled`** (default: `false`) - Set to `true` to enable automatic EXPLAIN ANALYZE
205
- - **`slow_query_threshold_ms`** (default: `500`) - Queries taking longer than this threshold will have their execution plan captured
208
+ - **`explain_analyze_enabled`** (default: `false`) - local opt-in for automatic EXPLAIN plan capture. Must be set in Ruby; the DeadBro UI toggle can only turn capture off, not on.
209
+ - **`slow_query_threshold_ms`** (default: `500`) - queries taking longer than this threshold will have their execution plan captured
206
210
 
207
211
  ### Example Configuration
208
212
 
209
213
  ```ruby
210
214
  DeadBro.configure do |config|
211
- # Enable EXPLAIN ANALYZE for queries slower than 500ms
215
+ # Capture EXPLAIN plans for SELECTs slower than 500ms
212
216
  config.explain_analyze_enabled = true
213
217
  config.slow_query_threshold_ms = 500
214
218
 
@@ -220,17 +224,17 @@ end
220
224
  ### What You Get
221
225
 
222
226
  When a slow query is detected, the `explain_plan` field in the SQL query data will contain:
223
- - **PostgreSQL**: Full EXPLAIN ANALYZE output with buffer usage statistics
224
- - **MySQL**: EXPLAIN ANALYZE output showing actual execution times
225
- - **SQLite**: EXPLAIN QUERY PLAN output
226
- - **Other databases**: Standard EXPLAIN output
227
+ - **PostgreSQL / MySQL**: `EXPLAIN` output the planner's chosen strategy, estimated costs and row counts
228
+ - **SQLite**: `EXPLAIN QUERY PLAN` output
229
+ - **Other databases**: standard `EXPLAIN` output
227
230
 
228
231
  This execution plan helps you:
229
232
  - Identify missing indexes
230
233
  - Understand query execution order
231
234
  - Spot full table scans
232
235
  - Optimize JOIN operations
233
- - Analyze buffer and cache usage (PostgreSQL)
236
+
237
+ Because the statement is not executed, plans show the planner's *estimates* rather than actual runtimes — usually exactly what you need to spot a missing index or an unexpected sequential scan.
234
238
 
235
239
  ## View Rendering Tracking
236
240
 
@@ -9,9 +9,15 @@ module DeadBro
9
9
  :circuit_breaker_enabled, :circuit_breaker_failure_threshold, :circuit_breaker_recovery_timeout,
10
10
  :circuit_breaker_retry_timeout, :disk_paths, :interfaces_ignore
11
11
 
12
+ # Local-only opt-in for EXPLAIN plan capture on slow queries. The gem runs
13
+ # DB statements (plan-only EXPLAIN) when this is on, so it must be enabled
14
+ # in the app's own config — remote settings can only turn it OFF, never on
15
+ # (see apply_remote_settings). Effective state: #explain_analyze_active?
16
+ attr_accessor :explain_analyze_enabled
17
+
12
18
  # Remote-managed settings (overwritten by backend JSON `settings` on successful API responses)
13
19
  attr_accessor :memory_tracking_enabled, :allocation_tracking_enabled, :allocation_sample_rate,
14
- :sample_rate, :slow_query_threshold_ms, :explain_analyze_enabled,
20
+ :sample_rate, :slow_query_threshold_ms,
15
21
  :monitor_enabled, :enable_db_stats, :enable_process_stats, :enable_system_stats,
16
22
  :max_sql_queries_to_send, :max_logs_to_send
17
23
 
@@ -89,6 +95,10 @@ module DeadBro
89
95
  # ~2-5ms overhead can be capped without turning the feature fully off.
90
96
  @allocation_sample_rate = 100
91
97
  @explain_analyze_enabled = false
98
+ # Remote kill switch for EXPLAIN capture. Defaults to true so a local
99
+ # opt-in works against older backends that never send the key; any
100
+ # response that sends explain_analyze_enabled: false turns capture off.
101
+ @remote_explain_analyze_enabled = true
92
102
  @slow_query_threshold_ms = 500
93
103
  @max_sql_queries_to_send = 500
94
104
  @max_logs_to_send = 100
@@ -163,7 +173,12 @@ module DeadBro
163
173
  case k
164
174
  when "sample_rate", "allocation_sample_rate", "slow_query_threshold_ms", "max_sql_queries_to_send", "max_logs_to_send"
165
175
  send(:"#{k}=", value.to_i)
166
- when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled", "explain_analyze_enabled",
176
+ when "explain_analyze_enabled"
177
+ # EXPLAIN runs statements against the customer DB, so the backend
178
+ # must never be able to switch it on — only off. The local opt-in
179
+ # (explain_analyze_enabled) stays untouched; see #explain_analyze_active?
180
+ @remote_explain_analyze_enabled = !!value
181
+ when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled",
167
182
  "monitor_enabled", "enable_db_stats", "enable_process_stats", "enable_system_stats"
168
183
  send(:"#{k}=", !!value)
169
184
  when "excluded_controllers", "excluded_jobs", "exclusive_controllers", "exclusive_jobs"
@@ -175,6 +190,12 @@ module DeadBro
175
190
  end
176
191
  end
177
192
 
193
+ # EXPLAIN capture requires BOTH the local opt-in and the remote flag —
194
+ # locally off means off no matter what the backend sends.
195
+ def explain_analyze_active?
196
+ !!(@explain_analyze_enabled && @remote_explain_analyze_enabled)
197
+ end
198
+
178
199
  def heartbeat_due?
179
200
  return false if api_key.nil?
180
201
  last_heartbeat_attempt_at.nil? || (Time.now.utc - last_heartbeat_attempt_at) >= HEARTBEAT_INTERVAL
@@ -237,7 +237,8 @@ module DeadBro
237
237
  # Release job-side thread-local tracking state when we've decided not to
238
238
  # build a payload (excluded job / sampled out). Matches Subscriber.drain_request_tracking.
239
239
  def self.drain_job_tracking
240
- DeadBro::SqlSubscriber.stop_request_tracking if defined?(DeadBro::SqlSubscriber)
240
+ # wait_for_explains: false — result is discarded, don't block on pending plans.
241
+ DeadBro::SqlSubscriber.stop_request_tracking(wait_for_explains: false) if defined?(DeadBro::SqlSubscriber)
241
242
  DeadBro::DbConnectionSubscriber.stop_request_tracking if defined?(DeadBro::DbConnectionSubscriber)
242
243
  DeadBro::GcTracker.stop_request_tracking if defined?(DeadBro::GcTracker)
243
244
  DeadBro::ArObjectTracker.stop_request_tracking if defined?(DeadBro::ArObjectTracker)
@@ -146,7 +146,7 @@ module DeadBro
146
146
  if should_explain_query?(duration_ms, original_sql)
147
147
  query_info[:explain_plan] = nil
148
148
  binds = data[:type_casted_binds] || data[:binds]
149
- start_explain_analyze_background(original_sql, data[:connection_id], query_info, binds)
149
+ start_explain_background(original_sql, data[:connection_id], query_info, binds)
150
150
  end
151
151
 
152
152
  if aggregates_h && normalized_key
@@ -195,8 +195,10 @@ module DeadBro
195
195
  (Thread.current[THREAD_LOCAL_AGGREGATES_KEY] ||= []) << {}
196
196
  end
197
197
 
198
- def self.stop_request_tracking
199
- wait_for_pending_explains(EXPLAIN_WAIT_TIMEOUT_SECONDS)
198
+ # wait_for_explains: false on drain paths (excluded / sampled-out requests)
199
+ # where the result is discarded — no reason to block on pending plans.
200
+ def self.stop_request_tracking(wait_for_explains: true)
201
+ wait_for_pending_explains(EXPLAIN_WAIT_TIMEOUT_SECONDS) if wait_for_explains
200
202
 
201
203
  stack = Thread.current[THREAD_LOCAL_KEY]
202
204
  raw_queries = (stack.is_a?(Array) && stack.any?) ? stack.pop : []
@@ -227,12 +229,14 @@ module DeadBro
227
229
  aggregates_h.values.sort_by { |a| -a[:total_duration_ms] }
228
230
  end
229
231
 
230
- # Upper bound on pending EXPLAIN threads per request stops a slow-query
231
- # storm from spawning unbounded background threads.
232
- MAX_PENDING_EXPLAINS = 20
232
+ # Upper bound on pending EXPLAIN threads per request. Each thread checks
233
+ # out an AR pool connection, so this must stay well below common pool
234
+ # sizes (Rails default is 5) to avoid starving the app under a storm.
235
+ MAX_PENDING_EXPLAINS = 3
233
236
  # Overall wall-clock we're willing to block the request thread for pending
234
- # EXPLAINs. If the plan isn't ready by then, skip it rather than stall the request.
235
- EXPLAIN_WAIT_TIMEOUT_SECONDS = 5.0
237
+ # EXPLAINs. Plan-only EXPLAIN is planning work (typically <10ms); if a plan
238
+ # isn't ready by then, drop it rather than stall the request.
239
+ EXPLAIN_WAIT_TIMEOUT_SECONDS = 0.5
236
240
 
237
241
  def self.wait_for_pending_explains(timeout_seconds)
238
242
  pending = Thread.current[THREAD_LOCAL_EXPLAIN_PENDING_KEY]
@@ -246,7 +250,7 @@ module DeadBro
246
250
  begin
247
251
  thread.join(remaining_time)
248
252
  rescue => e
249
- DeadBro.logger.debug("Error waiting for EXPLAIN ANALYZE: #{e.message}")
253
+ DeadBro.logger.debug("Error waiting for EXPLAIN plan: #{e.message}")
250
254
  end
251
255
  end
252
256
  end
@@ -276,25 +280,31 @@ module DeadBro
276
280
  sql
277
281
  end
278
282
 
283
+ # Read-only statements we are willing to EXPLAIN. Everything else —
284
+ # INSERT/UPDATE/DELETE, DDL, transaction control, EXPLAIN itself — is
285
+ # rejected by not matching (allowlist, not denylist).
286
+ EXPLAIN_ALLOWED_SQL_RE = /\A(SELECT|WITH)\b/i.freeze
287
+ # A WITH statement can hide data-modifying CTEs (WITH x AS (DELETE ...)).
288
+ # Plan-only EXPLAIN wouldn't run them, but keep them out entirely.
289
+ EXPLAIN_DML_KEYWORD_RE = /\b(INSERT|UPDATE|DELETE|MERGE)\b/i.freeze
290
+
279
291
  def self.should_explain_query?(duration_ms, sql)
280
- return false unless DeadBro.configuration.explain_analyze_enabled
292
+ return false unless DeadBro.configuration.explain_analyze_active?
281
293
  return false if duration_ms < DeadBro.configuration.slow_query_threshold_ms
282
294
  return false unless sql.is_a?(String)
283
- return false if sql.strip.empty?
284
295
 
285
- # Skip EXPLAIN for certain query types that don't benefit from it
286
- sql_upper = sql.upcase.strip
287
- return false if sql_upper.start_with?("EXPLAIN")
288
- return false if sql_upper.start_with?("BEGIN")
289
- return false if sql_upper.start_with?("COMMIT")
290
- return false if sql_upper.start_with?("ROLLBACK")
291
- return false if sql_upper.start_with?("SAVEPOINT")
292
- return false if sql_upper.start_with?("RELEASE")
296
+ stripped = sql.strip
297
+ stripped = stripped.chomp(";").rstrip
298
+ return false if stripped.empty?
299
+ # No multi-statement strings — EXPLAIN must cover exactly one statement.
300
+ return false if stripped.include?(";")
301
+ return false unless stripped.match?(EXPLAIN_ALLOWED_SQL_RE)
302
+ return false if stripped.match?(/\AWITH\b/i) && stripped.match?(EXPLAIN_DML_KEYWORD_RE)
293
303
 
294
304
  true
295
305
  end
296
306
 
297
- def self.start_explain_analyze_background(sql, connection_id, query_info, binds = nil)
307
+ def self.start_explain_background(sql, connection_id, query_info, binds = nil)
298
308
  return unless defined?(ActiveRecord)
299
309
  return unless ActiveRecord::Base.respond_to?(:connection)
300
310
 
@@ -327,14 +337,14 @@ module DeadBro
327
337
  connection.execute(explain_sql)
328
338
  end
329
339
 
330
- explain_plan = format_explain_result(result, connection)
340
+ explain_plan = scrub_explain_plan(format_explain_result(result, connection))
331
341
  query_info[:explain_plan] = if explain_plan && !explain_plan.to_s.strip.empty?
332
342
  explain_plan
333
343
  end
334
344
  end
335
345
  rescue => e
336
346
  # Silently fail — don't let EXPLAIN break the application.
337
- append_log_to_thread(main_thread, :debug, "Failed to capture EXPLAIN ANALYZE: #{e.message}")
347
+ append_log_to_thread(main_thread, :debug, "Failed to capture EXPLAIN plan: #{e.message}")
338
348
  query_info[:explain_plan] = nil
339
349
  end
340
350
  end
@@ -342,7 +352,22 @@ module DeadBro
342
352
  pending << explain_thread
343
353
  rescue => e
344
354
  # Use DeadBro.logger here since we're still in the main thread
345
- DeadBro.logger.debug("Failed to start EXPLAIN ANALYZE thread: #{e.message}")
355
+ DeadBro.logger.debug("Failed to start EXPLAIN thread: #{e.message}")
356
+ end
357
+
358
+ # Quoted string literal in plan text, e.g. Filter: ((email)::text = 'a@b.com'::text).
359
+ # '' is SQL escaping for a single quote inside a literal.
360
+ EXPLAIN_PLAN_QUOTED_LITERAL_RE = /'(?:[^']|'')*'/.freeze
361
+
362
+ # Plan text echoes the interpolated bind values in Filter / Index Cond
363
+ # lines, bypassing sanitize_sql. Blank out quoted literals before the plan
364
+ # is shipped. (Bare numbers are left alone — indistinguishable from the
365
+ # plan's own cost/row estimates.)
366
+ def self.scrub_explain_plan(plan)
367
+ return plan unless plan.is_a?(String)
368
+ plan.gsub(EXPLAIN_PLAN_QUOTED_LITERAL_RE, "?")
369
+ rescue
370
+ plan
346
371
  end
347
372
 
348
373
  # Append a log entry directly to a specific thread's log storage
@@ -389,18 +414,13 @@ module DeadBro
389
414
  def self.build_explain_query(sql, connection)
390
415
  adapter_name = connection.adapter_name.downcase
391
416
 
417
+ # Plan-only EXPLAIN everywhere. EXPLAIN ANALYZE *executes* the statement
418
+ # on PostgreSQL and MySQL 8 — a monitoring agent must never re-run
419
+ # customer queries, so ANALYZE is deliberately not used for any adapter.
392
420
  case adapter_name
393
- when "postgresql", "postgis"
394
- # PostgreSQL supports ANALYZE and BUFFERS
395
- "EXPLAIN (ANALYZE, BUFFERS) #{sql}"
396
- when "mysql", "mysql2", "trilogy"
397
- # MySQL uses different syntax - ANALYZE is a separate keyword
398
- "EXPLAIN ANALYZE #{sql}"
399
421
  when "sqlite3"
400
- # SQLite supports EXPLAIN QUERY PLAN
401
422
  "EXPLAIN QUERY PLAN #{sql}"
402
423
  else
403
- # Generic fallback - just EXPLAIN
404
424
  "EXPLAIN #{sql}"
405
425
  end
406
426
  end
@@ -235,7 +235,9 @@ module DeadBro
235
235
  # a payload (disabled / excluded / sampled out). Without this, a subsequent
236
236
  # request reusing the same Puma thread would see stale queries/events.
237
237
  def self.drain_request_tracking
238
- DeadBro::SqlSubscriber.stop_request_tracking if defined?(DeadBro::SqlSubscriber)
238
+ # wait_for_explains: false — the result is discarded, so don't block this
239
+ # thread waiting on pending EXPLAIN plans.
240
+ DeadBro::SqlSubscriber.stop_request_tracking(wait_for_explains: false) if defined?(DeadBro::SqlSubscriber)
239
241
  DeadBro::CacheSubscriber.stop_request_tracking if defined?(DeadBro::CacheSubscriber)
240
242
  DeadBro::RedisSubscriber.stop_request_tracking if defined?(DeadBro::RedisSubscriber)
241
243
  DeadBro::ElasticsearchSubscriber.stop_request_tracking if defined?(DeadBro::ElasticsearchSubscriber)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.27"
4
+ VERSION = "0.2.28"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dead_bro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.27
4
+ version: 0.2.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa