dead_bro 0.2.7 → 0.2.8

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: 9a78b3a99d00159acb1bf2fd97a0eed54ba0e03a24d7bbd2ec840e4bf779107f
4
- data.tar.gz: 53aba3dcf00e53f210020529413561812de25b3399db8ce040b9976ab798e8d1
3
+ metadata.gz: 7d8e1d14cc8e5511d143de228afefcb1a7557b8ce0308b7302ba40f4bc78bc90
4
+ data.tar.gz: 1d215f5ff69110be5c10e965bcf2ac2f08f1f6cb9a2be72b602f143c3e3ac5e5
5
5
  SHA512:
6
- metadata.gz: c150a7a452b46c4a600afbccb4a520a2f52b0c0bb3a85fec8f0a41f0ece56ea5161575a30a15002c18a8fa2b82e5692ccf13e49920ea3fffc031f7d23188027b
7
- data.tar.gz: 22b3ea56482f38005ccbd98eae13be751fade782a69f355573ccd49fb2524e3f9bd2c9e40128df5bbb0c2e521924d7f8cba2c49c41a88ad67efafb6041c2f50d
6
+ metadata.gz: 9cab5ed48ea05f086512683b1d8d9fdb05030328f4fb4cb908884f074bc23b361ba7914774106bb5f1eba511435cdb506b123b2c4d2ece41634b539e925576a9
7
+ data.tar.gz: '0695e7e7bc6f5835fde69586aa2bc03c134ca9a2515a4bda4e55a0c78242f776315e07018834a2ce66c9a828849c05d807c38e183f99ab77e00ac4d239144943'
@@ -40,6 +40,14 @@ module DeadBro
40
40
  nil
41
41
  end
42
42
 
43
+ def post_heartbeat
44
+ return if @configuration.api_key.nil?
45
+
46
+ @configuration.last_heartbeat_attempt_at = Time.now.utc
47
+ make_http_request("heartbeat", {}, @configuration.api_key)
48
+ nil
49
+ end
50
+
43
51
  def post_monitor_stats(payload)
44
52
  return if @configuration.api_key.nil?
45
53
  return unless @configuration.enabled
@@ -65,12 +73,26 @@ module DeadBro
65
73
 
66
74
  private
67
75
 
76
+ def apply_settings_from_response(response)
77
+ return unless response.is_a?(Net::HTTPSuccess)
78
+
79
+ body = JSON.parse(response.body)
80
+ return unless body.is_a?(Hash) && body["settings"].is_a?(Hash)
81
+
82
+ @configuration.apply_remote_settings(body["settings"])
83
+
84
+ updated_at_str = body["settings_updated_at"]
85
+ @configuration.settings_received_at = updated_at_str ? Time.iso8601(updated_at_str) : Time.now.utc
86
+ rescue JSON::ParserError, ArgumentError
87
+ # Malformed response — ignore, settings stay as-is
88
+ end
89
+
68
90
  # Limit payload size to avoid 413 from nginx/reverse proxies. Returns a new hash.
69
91
  def truncate_payload_for_request(payload)
70
92
  return payload unless payload.is_a?(Hash)
71
93
 
72
- max_sql = @configuration.respond_to?(:max_sql_queries_to_send) ? @configuration.max_sql_queries_to_send : 500
73
- max_logs = @configuration.respond_to?(:max_logs_to_send) ? @configuration.max_logs_to_send : 100
94
+ max_sql = @configuration.max_sql_queries_to_send
95
+ max_logs = @configuration.max_logs_to_send
74
96
 
75
97
  out = payload.dup
76
98
 
@@ -110,6 +132,9 @@ module DeadBro
110
132
  request = Net::HTTP::Post.new(uri.request_uri)
111
133
  request["Content-Type"] = "application/json"
112
134
  request["Authorization"] = "Bearer #{api_key}"
135
+ if @configuration.settings_received_at
136
+ request["X-Settings-Received-At"] = @configuration.settings_received_at.utc.iso8601
137
+ end
113
138
  body = {event: event_name, payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
114
139
  request.body = JSON.dump(body)
115
140
 
@@ -126,6 +151,13 @@ module DeadBro
126
151
  @circuit_breaker.send(:on_failure)
127
152
  end
128
153
  end
154
+
155
+ # Apply remote settings if the backend included them in the response
156
+ apply_settings_from_response(response)
157
+
158
+ if response.is_a?(Net::HTTPSuccess) && event_name == "heartbeat"
159
+ @configuration.last_heartbeat_at = Time.now.utc
160
+ end
129
161
  elsif @circuit_breaker && @configuration.circuit_breaker_enabled
130
162
  # Treat nil response as failure for circuit breaker
131
163
  @circuit_breaker.send(:on_failure)
@@ -2,42 +2,102 @@
2
2
 
3
3
  module DeadBro
4
4
  class Configuration
5
- attr_accessor :api_key, :open_timeout, :read_timeout, :enabled, :ruby_dev, :memory_tracking_enabled,
6
- :allocation_tracking_enabled, :circuit_breaker_enabled, :circuit_breaker_failure_threshold, :circuit_breaker_recovery_timeout,
7
- :circuit_breaker_retry_timeout, :sample_rate, :excluded_controllers, :excluded_jobs,
8
- :exclusive_controllers, :exclusive_jobs, :deploy_id, :slow_query_threshold_ms, :explain_analyze_enabled,
5
+ # Local-only settings (not overwritten by API `settings` payloads).
6
+ # Note: `enabled` may still be updated remotely via apply_remote_settings when the backend
7
+ # returns it in a response; local configure() values apply until the next remote update.
8
+ attr_accessor :api_key, :open_timeout, :read_timeout, :enabled, :ruby_dev,
9
+ :circuit_breaker_enabled, :circuit_breaker_failure_threshold, :circuit_breaker_recovery_timeout,
10
+ :circuit_breaker_retry_timeout, :deploy_id, :disk_paths, :interfaces_ignore
11
+
12
+ # Remote-managed settings (overwritten by backend JSON `settings` on successful API responses)
13
+ attr_accessor :memory_tracking_enabled, :allocation_tracking_enabled,
14
+ :sample_rate, :excluded_controllers, :excluded_jobs,
15
+ :exclusive_controllers, :exclusive_jobs, :slow_query_threshold_ms, :explain_analyze_enabled,
9
16
  :job_queue_monitoring_enabled, :enable_db_stats, :enable_process_stats, :enable_system_stats,
10
- :disk_paths, :interfaces_ignore, :max_sql_queries_to_send, :max_logs_to_send
17
+ :max_sql_queries_to_send, :max_logs_to_send
18
+
19
+ # Tracks when we last received settings from the backend (in-memory only)
20
+ attr_accessor :settings_received_at
21
+
22
+ # Last successful heartbeat HTTP response time while disabled (in-memory only)
23
+ attr_accessor :last_heartbeat_at
24
+
25
+ # Throttles heartbeat attempts to HEARTBEAT_INTERVAL (set when a heartbeat request is started)
26
+ attr_accessor :last_heartbeat_attempt_at
27
+
28
+ HEARTBEAT_INTERVAL = 60 # seconds
29
+
30
+ REMOTE_SETTING_KEYS = %w[
31
+ enabled sample_rate memory_tracking_enabled allocation_tracking_enabled
32
+ explain_analyze_enabled slow_query_threshold_ms max_sql_queries_to_send max_logs_to_send
33
+ excluded_controllers excluded_jobs exclusive_controllers exclusive_jobs
34
+ job_queue_monitoring_enabled enable_db_stats enable_process_stats enable_system_stats
35
+ ].freeze
11
36
 
12
37
  def initialize
13
38
  @api_key = nil
14
- @endpoint_url = nil
15
39
  @open_timeout = 1.0
16
40
  @read_timeout = 1.0
17
41
  @enabled = true
18
42
  @ruby_dev = false
19
- @memory_tracking_enabled = true
20
- @allocation_tracking_enabled = false # Disabled by default for performance
21
43
  @circuit_breaker_enabled = true
22
44
  @circuit_breaker_failure_threshold = 3
23
- @circuit_breaker_recovery_timeout = 60 # seconds
24
- @circuit_breaker_retry_timeout = 300 # seconds
45
+ @circuit_breaker_recovery_timeout = 60
46
+ @circuit_breaker_retry_timeout = 300
47
+ @deploy_id = resolve_deploy_id
48
+ @disk_paths = ["/"]
49
+ @interfaces_ignore = %w[lo lo0 docker0]
50
+
51
+ # Remote-managed defaults (used until backend sends real values)
25
52
  @sample_rate = 100
53
+ @memory_tracking_enabled = true
54
+ @allocation_tracking_enabled = false
55
+ @explain_analyze_enabled = false
56
+ @slow_query_threshold_ms = 500
57
+ @max_sql_queries_to_send = 500
58
+ @max_logs_to_send = 100
26
59
  @excluded_controllers = []
27
60
  @excluded_jobs = []
28
61
  @exclusive_controllers = []
29
62
  @exclusive_jobs = []
30
- @deploy_id = resolve_deploy_id
31
- @slow_query_threshold_ms = 500 # Default: 500ms
32
- @explain_analyze_enabled = false # Enable EXPLAIN ANALYZE for slow queries by default
33
- @job_queue_monitoring_enabled = false # Disabled by default
63
+ @job_queue_monitoring_enabled = false
34
64
  @enable_db_stats = false
35
65
  @enable_process_stats = false
36
66
  @enable_system_stats = false
37
- @disk_paths = ["/"]
38
- @interfaces_ignore = %w[lo lo0 docker0]
39
- @max_sql_queries_to_send = 500 # Cap to avoid 413 Request Entity Too Large
40
- @max_logs_to_send = 100
67
+
68
+ @settings_received_at = nil
69
+ @last_heartbeat_at = nil
70
+ @last_heartbeat_attempt_at = nil
71
+ @settings_mutex = Mutex.new
72
+ end
73
+
74
+ # Apply a settings hash received from the backend response.
75
+ # Only known keys are applied; unknown keys are silently ignored.
76
+ # Serialized so concurrent HTTP threads do not interleave writes with request-thread reads.
77
+ def apply_remote_settings(hash)
78
+ return unless hash.is_a?(Hash)
79
+
80
+ @settings_mutex.synchronize do
81
+ hash.each do |key, value|
82
+ k = key.to_s
83
+ next unless REMOTE_SETTING_KEYS.include?(k)
84
+
85
+ case k
86
+ when "sample_rate", "slow_query_threshold_ms", "max_sql_queries_to_send", "max_logs_to_send"
87
+ send(:"#{k}=", value.to_i)
88
+ when "enabled", "memory_tracking_enabled", "allocation_tracking_enabled", "explain_analyze_enabled",
89
+ "job_queue_monitoring_enabled", "enable_db_stats", "enable_process_stats", "enable_system_stats"
90
+ send(:"#{k}=", !!value)
91
+ when "excluded_controllers", "excluded_jobs", "exclusive_controllers", "exclusive_jobs"
92
+ send(:"#{k}=", Array(value).map(&:to_s))
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ def heartbeat_due?
99
+ return false if api_key.nil?
100
+ last_heartbeat_attempt_at.nil? || (Time.now.utc - last_heartbeat_attempt_at) >= HEARTBEAT_INTERVAL
41
101
  end
42
102
 
43
103
  def resolve_deploy_id
@@ -88,6 +148,8 @@ module DeadBro
88
148
 
89
149
  def should_sample?
90
150
  sample_rate = resolve_sample_rate
151
+ sample_rate = 100 if sample_rate.nil?
152
+
91
153
  return true if sample_rate >= 100
92
154
  return false if sample_rate <= 0
93
155
 
@@ -95,22 +157,9 @@ module DeadBro
95
157
  rand(1..100) <= sample_rate
96
158
  end
97
159
 
160
+ # Returns the configured sample_rate only (no ENV fallback). Use DeadBro.configure or remote settings.
98
161
  def resolve_sample_rate
99
- return @sample_rate unless @sample_rate.nil?
100
-
101
- if ENV["dead_bro_SAMPLE_RATE"]
102
- env_value = ENV["dead_bro_SAMPLE_RATE"].to_s.strip
103
- # Validate that it's a valid integer string
104
- if env_value.match?(/^\d+$/)
105
- parsed = env_value.to_i
106
- # Ensure it's in valid range (0-100)
107
- (parsed >= 0 && parsed <= 100) ? parsed : 100
108
- else
109
- 100 # Invalid format, fall back to default
110
- end
111
- else
112
- 100 # default
113
- end
162
+ @sample_rate
114
163
  end
115
164
 
116
165
  def resolve_api_key
@@ -119,17 +168,6 @@ module DeadBro
119
168
  ENV["DEAD_BRO_API_KEY"]
120
169
  end
121
170
 
122
- def sample_rate=(value)
123
- # Allow nil to use default/resolved value
124
- return @sample_rate = nil if value.nil?
125
-
126
- # Allow 0 to disable sampling, or 1-100 for percentage
127
- unless value.is_a?(Integer) && value >= 0 && value <= 100
128
- raise ArgumentError, "Sample rate must be an integer between 0 and 100, got: #{value.inspect}"
129
- end
130
- @sample_rate = value
131
- end
132
-
133
171
  private
134
172
 
135
173
  def match_name_or_pattern?(name, pattern)
@@ -103,7 +103,7 @@ module DeadBro
103
103
 
104
104
  duration_ms = ((finished - started) * 1000.0).round(2)
105
105
  exception = data[:exception_object]
106
- job_class = data[:job].class.name
106
+ data[:job].class.name
107
107
 
108
108
  # Ensure tracking was started (fallback if perform_start.active_job didn't fire)
109
109
  unless DeadBro::SqlSubscriber.tracking_active?
@@ -80,7 +80,7 @@ module DeadBro
80
80
 
81
81
  def self.stop_request_tracking
82
82
  stack = Thread.current[THREAD_LOCAL_KEY]
83
- events = stack.is_a?(Array) && stack.any? ? stack.pop : nil
83
+ events = (stack.is_a?(Array) && stack.any?) ? stack.pop : nil
84
84
  Thread.current[THREAD_LOCAL_KEY] = nil if stack.nil? || stack.empty?
85
85
 
86
86
  if events
@@ -191,10 +191,10 @@ module DeadBro
191
191
  # Group allocations by class
192
192
  allocations_by_class = allocations.group_by { |a| a[:class_name] }
193
193
  .transform_values { |allocs|
194
- {
195
- count: allocs.sum { |a| a[:count] },
196
- size: allocs.sum { |a| a[:size] }
197
- }
194
+ {
195
+ count: allocs.sum { |a| a[:count] },
196
+ size: allocs.sum { |a| a[:size] }
197
+ }
198
198
  }
199
199
 
200
200
  # Find top allocating classes
@@ -25,8 +25,8 @@ module DeadBro
25
25
  def self.install_redis_client!
26
26
  # Only instrument if Redis::Client actually has the call method
27
27
  # Check both public and private methods
28
- has_call = ::Redis::Client.instance_methods(false).include?(:call) ||
29
- ::Redis::Client.private_instance_methods(false).include?(:call)
28
+ has_call = ::Redis::Client.method_defined?(:call, false) ||
29
+ ::Redis::Client.private_method_defined?(:call, false)
30
30
  return unless has_call
31
31
 
32
32
  mod = Module.new do
@@ -118,7 +118,7 @@ module DeadBro
118
118
  wait_for_pending_explains(5.0) # 5 second timeout
119
119
 
120
120
  stack = Thread.current[THREAD_LOCAL_KEY]
121
- queries = stack.is_a?(Array) && stack.any? ? stack.pop : []
121
+ queries = (stack.is_a?(Array) && stack.any?) ? stack.pop : []
122
122
  # Clear thread locals when stack is empty so "tracking not started" behaves correctly
123
123
  if stack.nil? || stack.empty?
124
124
  Thread.current[THREAD_LOCAL_KEY] = nil
@@ -8,21 +8,23 @@ module DeadBro
8
8
 
9
9
  def self.subscribe!(client: Client.new)
10
10
  ActiveSupport::Notifications.subscribe(EVENT_NAME) do |name, started, finished, _unique_id, data|
11
+ # When disabled remotely, fire a heartbeat at most once per minute so the gem
12
+ # can detect when tracking has been re-enabled, then skip all tracking.
13
+ unless DeadBro.configuration.enabled
14
+ client.post_heartbeat if DeadBro.configuration.heartbeat_due?
15
+ next
16
+ end
17
+
11
18
  # Skip excluded controllers or controller#action pairs
12
19
  # Also check exclusive_controller_actions - if defined, only track those
20
+ notification = data.is_a?(Hash) ? data : {}
21
+ controller_name = notification[:controller].to_s
22
+ action_name = notification[:action].to_s
13
23
  begin
14
- controller_name = data[:controller].to_s
15
- action_name = data[:action].to_s
16
- if DeadBro.configuration.excluded_controller?(controller_name, action_name)
17
- puts "excluded controller"
18
- next
19
- end
20
- # If exclusive_controller_actions is defined and not empty, only track matching actions
21
- unless DeadBro.configuration.exclusive_controller?(controller_name, action_name)
22
- puts "exclusive controller"
23
- next
24
- end
24
+ next if DeadBro.configuration.excluded_controller?(controller_name, action_name)
25
+ next unless DeadBro.configuration.exclusive_controller?(controller_name, action_name)
25
26
  rescue
27
+ next
26
28
  end
27
29
 
28
30
  duration_ms = ((finished - started) * 1000.0).round(2)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeadBro
4
- VERSION = "0.2.7"
4
+ VERSION = "0.2.8"
5
5
  end
data/lib/dead_bro.rb CHANGED
@@ -120,7 +120,7 @@ module DeadBro
120
120
 
121
121
  begin
122
122
  if defined?(DeadBro::MemoryTrackingSubscriber) &&
123
- !Thread.current[DeadBro::MemoryTrackingSubscriber::THREAD_LOCAL_KEY]
123
+ !Thread.current[DeadBro::MemoryTrackingSubscriber::THREAD_LOCAL_KEY]
124
124
  DeadBro::MemoryTrackingSubscriber.start_request_tracking
125
125
  memory_tracking_started = true
126
126
  end
@@ -128,10 +128,10 @@ module DeadBro
128
128
  end
129
129
 
130
130
  begin
131
- if defined?(DeadBro::MemoryTrackingSubscriber)
132
- memory_before_mb = DeadBro::MemoryTrackingSubscriber.memory_usage_mb
131
+ memory_before_mb = if defined?(DeadBro::MemoryTrackingSubscriber)
132
+ DeadBro::MemoryTrackingSubscriber.memory_usage_mb
133
133
  else
134
- memory_before_mb = 0.0
134
+ 0.0
135
135
  end
136
136
  rescue
137
137
  memory_before_mb = 0.0
@@ -148,7 +148,6 @@ module DeadBro
148
148
  begin
149
149
  if defined?(ActiveSupport) && defined?(ActiveSupport::Notifications)
150
150
  # Ensure SqlSubscriber is loaded so SQL_EVENT_NAME is defined
151
- DeadBro::SqlSubscriber
152
151
  event_name = DeadBro::SqlSubscriber::SQL_EVENT_NAME
153
152
 
154
153
  sql_notification_subscription =
@@ -197,11 +196,9 @@ module DeadBro
197
196
  block_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
197
 
199
198
  error = nil
200
- result = nil
201
- analysis_result = nil
202
199
 
203
200
  begin
204
- result = yield
201
+ yield
205
202
  rescue => e
206
203
  error = e
207
204
  ensure
@@ -225,7 +222,7 @@ module DeadBro
225
222
  sql_time_ms = local_sql_queries.sum { |q| (q[:duration_ms] || 0.0).to_f }.round(2)
226
223
 
227
224
  # Group SQL queries by normalized pattern to show frequency and cost
228
- query_signatures = Hash.new { |h, k| h[k] = { count: 0, total_time_ms: 0.0, type: nil } }
225
+ query_signatures = Hash.new { |h, k| h[k] = {count: 0, total_time_ms: 0.0, type: nil} }
229
226
  local_sql_queries.each do |q|
230
227
  sig = (q[:sql] || "UNKNOWN").to_s
231
228
  entry = query_signatures[sig]
@@ -237,7 +234,6 @@ module DeadBro
237
234
  top_query_signatures = query_signatures.sort_by { |_, data| -data[:count] }.first(3)
238
235
 
239
236
  memory_after_mb = memory_before_mb
240
- memory_delta_mb = 0.0
241
237
  detailed_memory_summary = nil
242
238
 
243
239
  raw_events = {}
@@ -255,14 +251,12 @@ module DeadBro
255
251
  memory_before_mb = raw_events[:memory_before]
256
252
  end
257
253
 
258
- if raw_events[:memory_after]
259
- memory_after_mb = raw_events[:memory_after]
254
+ memory_after_mb = if raw_events[:memory_after]
255
+ raw_events[:memory_after]
256
+ elsif defined?(DeadBro::MemoryTrackingSubscriber)
257
+ DeadBro::MemoryTrackingSubscriber.memory_usage_mb
260
258
  else
261
- if defined?(DeadBro::MemoryTrackingSubscriber)
262
- memory_after_mb = DeadBro::MemoryTrackingSubscriber.memory_usage_mb
263
- else
264
- memory_after_mb = memory_before_mb
265
- end
259
+ memory_before_mb
266
260
  end
267
261
  rescue
268
262
  memory_after_mb = memory_before_mb
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.7
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emanuel Comsa
@@ -69,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
69
  - !ruby/object:Gem::Version
70
70
  version: '0'
71
71
  requirements: []
72
- rubygems_version: 4.0.3
72
+ rubygems_version: 4.0.9
73
73
  specification_version: 4
74
74
  summary: Minimal APM for Rails apps.
75
75
  test_files: []