dead_bro 0.2.8 → 0.2.10

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.
@@ -12,30 +12,21 @@ module DeadBro
12
12
  @circuit_breaker = create_circuit_breaker
13
13
  end
14
14
 
15
- def post_metric(event_name:, payload:)
15
+ def post_metric(event_name:, payload:, force: false)
16
16
  return if @configuration.api_key.nil?
17
17
  return unless @configuration.enabled
18
+ return if !force && !@configuration.should_sample?
19
+ return if circuit_open?
18
20
 
19
- # Check sampling rate - skip if not selected for sampling
20
- return unless @configuration.should_sample?
21
-
22
- # Check circuit breaker before making request
23
- if @circuit_breaker && @configuration.circuit_breaker_enabled
24
- if @circuit_breaker.state == :open
25
- # Check if we should attempt a reset to half-open state
26
- if @circuit_breaker.should_attempt_reset?
27
- @circuit_breaker.transition_to_half_open!
28
- else
29
- return
30
- end
31
- end
32
- end
33
-
34
- # Truncate large arrays to avoid 413 Request Entity Too Large
35
21
  payload = truncate_payload_for_request(payload)
22
+ body = {event: event_name, payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
36
23
 
37
- # Make the HTTP request (async)
38
- make_http_request(event_name, payload, @configuration.api_key)
24
+ dispatch_request(
25
+ url: metrics_endpoint_url,
26
+ body: body,
27
+ event_name: event_name,
28
+ apply_settings: true
29
+ )
39
30
 
40
31
  nil
41
32
  end
@@ -44,7 +35,15 @@ module DeadBro
44
35
  return if @configuration.api_key.nil?
45
36
 
46
37
  @configuration.last_heartbeat_attempt_at = Time.now.utc
47
- make_http_request("heartbeat", {}, @configuration.api_key)
38
+ body = {event: "heartbeat", payload: {}, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
39
+
40
+ dispatch_request(
41
+ url: metrics_endpoint_url,
42
+ body: body,
43
+ event_name: "heartbeat",
44
+ apply_settings: true
45
+ )
46
+
48
47
  nil
49
48
  end
50
49
 
@@ -52,27 +51,108 @@ module DeadBro
52
51
  return if @configuration.api_key.nil?
53
52
  return unless @configuration.enabled
54
53
  return unless @configuration.job_queue_monitoring_enabled
54
+ return if circuit_open?
55
+
56
+ body = {payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
57
+
58
+ dispatch_request(
59
+ url: monitor_endpoint_url,
60
+ body: body,
61
+ event_name: nil,
62
+ apply_settings: false
63
+ )
64
+
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ # Returns true (and short-circuits) when the circuit is open and not ready
71
+ # to probe. Transitions to HALF_OPEN when the recovery timeout has elapsed.
72
+ def circuit_open?
73
+ return false unless @circuit_breaker && @configuration.circuit_breaker_enabled
74
+ return false unless @circuit_breaker.state == :open
75
+
76
+ if @circuit_breaker.should_attempt_reset?
77
+ @circuit_breaker.transition_to_half_open!
78
+ false
79
+ else
80
+ true
81
+ end
82
+ end
83
+
84
+ def metrics_endpoint_url
85
+ if @configuration.ruby_dev
86
+ "http://localhost:3100/apm/v1/metrics"
87
+ elsif ENV["USE_STAGING_ENDPOINT"] && !ENV["USE_STAGING_ENDPOINT"].empty?
88
+ "https://deadbro.aberatii.com/apm/v1/metrics"
89
+ else
90
+ "https://www.deadbro.com/apm/v1/metrics"
91
+ end
92
+ end
55
93
 
56
- # Check circuit breaker before making request
57
- if @circuit_breaker && @configuration.circuit_breaker_enabled
58
- if @circuit_breaker.state == :open
59
- # Check if we should attempt a reset to half-open state
60
- if @circuit_breaker.should_attempt_reset?
61
- @circuit_breaker.transition_to_half_open!
94
+ def monitor_endpoint_url
95
+ if @configuration.ruby_dev
96
+ "http://localhost:3100/apm/v1/monitor"
97
+ elsif ENV["USE_STAGING_ENDPOINT"] && !ENV["USE_STAGING_ENDPOINT"].empty?
98
+ "https://deadbro.aberatii.com/apm/v1/monitor"
99
+ else
100
+ "https://www.deadbro.com/apm/v1/monitor"
101
+ end
102
+ end
103
+
104
+ def dispatch_request(url:, body:, event_name:, apply_settings: false)
105
+ uri = URI.parse(url)
106
+ http = Net::HTTP.new(uri.host, uri.port)
107
+ http.use_ssl = (uri.scheme == "https")
108
+ http.open_timeout = @configuration.open_timeout
109
+ http.read_timeout = @configuration.read_timeout
110
+
111
+ request = Net::HTTP::Post.new(uri.request_uri)
112
+ request["Content-Type"] = "application/json"
113
+ request["Authorization"] = "Bearer #{@configuration.api_key}"
114
+ if @configuration.settings_received_at
115
+ request["X-Settings-Received-At"] = @configuration.settings_received_at.utc.iso8601
116
+ end
117
+ request.body = JSON.dump(body)
118
+
119
+ DeadBro::Dispatcher.instance.dispatch do
120
+ perform_request(http, request, event_name: event_name, apply_settings: apply_settings)
121
+ end
122
+ end
123
+
124
+ def perform_request(http, request, event_name:, apply_settings: false)
125
+ response = http.request(request)
126
+
127
+ if response
128
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
129
+ if response.is_a?(Net::HTTPSuccess)
130
+ @circuit_breaker.record_success
62
131
  else
63
- return
132
+ @circuit_breaker.record_failure
64
133
  end
65
134
  end
66
- end
67
135
 
68
- # Make the HTTP request (async) to jobs endpoint
69
- make_monitor_request(payload, @configuration.api_key)
136
+ if apply_settings
137
+ apply_settings_from_response(response)
138
+
139
+ if response.is_a?(Net::HTTPSuccess) && event_name == "heartbeat"
140
+ @configuration.last_heartbeat_at = Time.now.utc
141
+ end
142
+ end
143
+ elsif @circuit_breaker && @configuration.circuit_breaker_enabled
144
+ @circuit_breaker.record_failure
145
+ end
70
146
 
147
+ response
148
+ rescue Timeout::Error
149
+ @circuit_breaker&.record_failure if @configuration.circuit_breaker_enabled
150
+ nil
151
+ rescue
152
+ @circuit_breaker&.record_failure if @configuration.circuit_breaker_enabled
71
153
  nil
72
154
  end
73
155
 
74
- private
75
-
76
156
  def apply_settings_from_response(response)
77
157
  return unless response.is_a?(Net::HTTPSuccess)
78
158
 
@@ -87,7 +167,6 @@ module DeadBro
87
167
  # Malformed response — ignore, settings stay as-is
88
168
  end
89
169
 
90
- # Limit payload size to avoid 413 from nginx/reverse proxies. Returns a new hash.
91
170
  def truncate_payload_for_request(payload)
92
171
  return payload unless payload.is_a?(Hash)
93
172
 
@@ -119,116 +198,6 @@ module DeadBro
119
198
  )
120
199
  end
121
200
 
122
- def make_http_request(event_name, payload, api_key)
123
- use_staging = ENV["USE_STAGING_ENDPOINT"] && !ENV["USE_STAGING_ENDPOINT"].empty?
124
- production_url = use_staging ? "https://deadbro.aberatii.com/apm/v1/metrics" : "https://www.deadbro.com/apm/v1/metrics"
125
- endpoint_url = @configuration.ruby_dev ? "http://localhost:3100/apm/v1/metrics" : production_url
126
- uri = URI.parse(endpoint_url)
127
- http = Net::HTTP.new(uri.host, uri.port)
128
- http.use_ssl = (uri.scheme == "https")
129
- http.open_timeout = @configuration.open_timeout
130
- http.read_timeout = @configuration.read_timeout
131
-
132
- request = Net::HTTP::Post.new(uri.request_uri)
133
- request["Content-Type"] = "application/json"
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
138
- body = {event: event_name, payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
139
- request.body = JSON.dump(body)
140
-
141
- # Fire-and-forget using a short-lived thread to avoid blocking the request cycle.
142
- Thread.new do
143
- response = http.request(request)
144
-
145
- if response
146
- # Update circuit breaker based on response
147
- if @circuit_breaker && @configuration.circuit_breaker_enabled
148
- if response.is_a?(Net::HTTPSuccess)
149
- @circuit_breaker.send(:on_success)
150
- else
151
- @circuit_breaker.send(:on_failure)
152
- end
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
161
- elsif @circuit_breaker && @configuration.circuit_breaker_enabled
162
- # Treat nil response as failure for circuit breaker
163
- @circuit_breaker.send(:on_failure)
164
- end
165
-
166
- response
167
- rescue Timeout::Error
168
- # Update circuit breaker on timeout
169
- if @circuit_breaker && @configuration.circuit_breaker_enabled
170
- @circuit_breaker.send(:on_failure)
171
- end
172
- rescue
173
- # Update circuit breaker on exception
174
- if @circuit_breaker && @configuration.circuit_breaker_enabled
175
- @circuit_breaker.send(:on_failure)
176
- end
177
- end
178
-
179
- nil
180
- end
181
-
182
- def make_monitor_request(payload, api_key)
183
- use_staging = ENV["USE_STAGING_ENDPOINT"] && !ENV["USE_STAGING_ENDPOINT"].empty?
184
- production_url = use_staging ? "https://deadbro.aberatii.com/apm/v1/monitor" : "https://www.deadbro.com/apm/v1/monitor"
185
- endpoint_url = @configuration.ruby_dev ? "http://localhost:3100/apm/v1/monitor" : production_url
186
- uri = URI.parse(endpoint_url)
187
- http = Net::HTTP.new(uri.host, uri.port)
188
- http.use_ssl = (uri.scheme == "https")
189
- http.open_timeout = @configuration.open_timeout
190
- http.read_timeout = @configuration.read_timeout
191
-
192
- request = Net::HTTP::Post.new(uri.request_uri)
193
- request["Content-Type"] = "application/json"
194
- request["Authorization"] = "Bearer #{api_key}"
195
- body = {payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id}
196
- request.body = JSON.dump(body)
197
-
198
- # Fire-and-forget using a short-lived thread to avoid blocking
199
- Thread.new do
200
- response = http.request(request)
201
-
202
- if response
203
- # Update circuit breaker based on response
204
- if @circuit_breaker && @configuration.circuit_breaker_enabled
205
- if response.is_a?(Net::HTTPSuccess)
206
- @circuit_breaker.send(:on_success)
207
- else
208
- @circuit_breaker.send(:on_failure)
209
- end
210
- end
211
- elsif @circuit_breaker && @configuration.circuit_breaker_enabled
212
- # Treat nil response as failure for circuit breaker
213
- @circuit_breaker.send(:on_failure)
214
- end
215
-
216
- response
217
- rescue Timeout::Error
218
- # Update circuit breaker on timeout
219
- if @circuit_breaker && @configuration.circuit_breaker_enabled
220
- @circuit_breaker.send(:on_failure)
221
- end
222
- rescue
223
- # Update circuit breaker on exception
224
- if @circuit_breaker && @configuration.circuit_breaker_enabled
225
- @circuit_breaker.send(:on_failure)
226
- end
227
- end
228
-
229
- nil
230
- end
231
-
232
201
  def log_debug(message)
233
202
  if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
234
203
  Rails.logger.debug(message)
@@ -11,11 +11,14 @@ module DeadBro
11
11
 
12
12
  # Remote-managed settings (overwritten by backend JSON `settings` on successful API responses)
13
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,
14
+ :sample_rate, :slow_query_threshold_ms, :explain_analyze_enabled,
16
15
  :job_queue_monitoring_enabled, :enable_db_stats, :enable_process_stats, :enable_system_stats,
17
16
  :max_sql_queries_to_send, :max_logs_to_send
18
17
 
18
+ # Readers for exclusion lists. Writers are defined below so we can compile
19
+ # and cache the regex form once, instead of rebuilding it per request.
20
+ attr_reader :excluded_controllers, :excluded_jobs, :exclusive_controllers, :exclusive_jobs
21
+
19
22
  # Tracks when we last received settings from the backend (in-memory only)
20
23
  attr_accessor :settings_received_at
21
24
 
@@ -56,10 +59,10 @@ module DeadBro
56
59
  @slow_query_threshold_ms = 500
57
60
  @max_sql_queries_to_send = 500
58
61
  @max_logs_to_send = 100
59
- @excluded_controllers = []
60
- @excluded_jobs = []
61
- @exclusive_controllers = []
62
- @exclusive_jobs = []
62
+ self.excluded_controllers = []
63
+ self.excluded_jobs = []
64
+ self.exclusive_controllers = []
65
+ self.exclusive_jobs = []
63
66
  @job_queue_monitoring_enabled = false
64
67
  @enable_db_stats = false
65
68
  @enable_process_stats = false
@@ -71,6 +74,26 @@ module DeadBro
71
74
  @settings_mutex = Mutex.new
72
75
  end
73
76
 
77
+ def excluded_controllers=(value)
78
+ @excluded_controllers = Array(value).map(&:to_s)
79
+ @compiled_excluded_controllers = compile_patterns(@excluded_controllers)
80
+ end
81
+
82
+ def excluded_jobs=(value)
83
+ @excluded_jobs = Array(value).map(&:to_s)
84
+ @compiled_excluded_jobs = compile_patterns(@excluded_jobs)
85
+ end
86
+
87
+ def exclusive_controllers=(value)
88
+ @exclusive_controllers = Array(value).map(&:to_s)
89
+ @compiled_exclusive_controllers = compile_patterns(@exclusive_controllers)
90
+ end
91
+
92
+ def exclusive_jobs=(value)
93
+ @exclusive_jobs = Array(value).map(&:to_s)
94
+ @compiled_exclusive_jobs = compile_patterns(@exclusive_jobs)
95
+ end
96
+
74
97
  # Apply a settings hash received from the backend response.
75
98
  # Only known keys are applied; unknown keys are silently ignored.
76
99
  # Serialized so concurrent HTTP threads do not interleave writes with request-thread reads.
@@ -105,45 +128,45 @@ module DeadBro
105
128
  end
106
129
 
107
130
  def excluded_controller?(controller_name, action_name = nil)
108
- return false if @excluded_controllers.empty?
131
+ compiled = @compiled_excluded_controllers
132
+ return false if compiled.nil? || compiled.empty?
109
133
 
110
- # If action_name is provided, check both controller#action patterns and controller-only patterns
111
134
  if action_name
112
135
  target = "#{controller_name}##{action_name}"
113
- # Check controller#action patterns (patterns containing '#')
114
- action_patterns = @excluded_controllers.select { |pat| pat.to_s.include?("#") }
115
- if action_patterns.any? { |pat| match_name_or_pattern?(target, pat) }
116
- return true
117
- end
118
- # Check controller-only patterns (patterns without '#')
119
- # If the controller itself is excluded, all its actions are excluded
120
- controller_patterns = @excluded_controllers.reject { |pat| pat.to_s.include?("#") }
121
- if controller_patterns.any? { |pat| match_name_or_pattern?(controller_name, pat) }
122
- return true
136
+ compiled.each do |entry|
137
+ if entry[:has_hash]
138
+ return true if match_compiled?(target, entry)
139
+ elsif match_compiled?(controller_name, entry)
140
+ return true
141
+ end
123
142
  end
124
143
  return false
125
144
  end
126
145
 
127
- # When action_name is nil, only check controller-only patterns (no #)
128
- controller_patterns = @excluded_controllers.reject { |pat| pat.to_s.include?("#") }
129
- return false if controller_patterns.empty?
130
- controller_patterns.any? { |pat| match_name_or_pattern?(controller_name, pat) }
146
+ compiled.each do |entry|
147
+ next if entry[:has_hash]
148
+ return true if match_compiled?(controller_name, entry)
149
+ end
150
+ false
131
151
  end
132
152
 
133
153
  def excluded_job?(job_class_name)
134
- return false if @excluded_jobs.empty?
135
- @excluded_jobs.any? { |pat| match_name_or_pattern?(job_class_name, pat) }
154
+ compiled = @compiled_excluded_jobs
155
+ return false if compiled.nil? || compiled.empty?
156
+ compiled.any? { |entry| match_compiled?(job_class_name, entry) }
136
157
  end
137
158
 
138
159
  def exclusive_job?(job_class_name)
139
- return true if @exclusive_jobs.empty? # If not defined, allow all (default behavior)
140
- @exclusive_jobs.any? { |pat| match_name_or_pattern?(job_class_name, pat) }
160
+ compiled = @compiled_exclusive_jobs
161
+ return true if compiled.nil? || compiled.empty?
162
+ compiled.any? { |entry| match_compiled?(job_class_name, entry) }
141
163
  end
142
164
 
143
165
  def exclusive_controller?(controller_name, action_name)
144
- return true if @exclusive_controllers.empty? # If not defined, allow all (default behavior)
166
+ compiled = @compiled_exclusive_controllers
167
+ return true if compiled.nil? || compiled.empty?
145
168
  target = "#{controller_name}##{action_name}"
146
- @exclusive_controllers.any? { |pat| match_name_or_pattern?(target, pat) }
169
+ compiled.any? { |entry| match_compiled?(target, entry) }
147
170
  end
148
171
 
149
172
  def should_sample?
@@ -170,21 +193,34 @@ module DeadBro
170
193
 
171
194
  private
172
195
 
173
- def match_name_or_pattern?(name, pattern)
174
- return false if name.nil? || pattern.nil?
175
- pat = pattern.to_s
176
- return !!(name.to_s == pat) unless pat.include?("*")
196
+ # Turn a list of user-facing patterns into {pattern, has_hash, regex}
197
+ # entries. Regex is nil when the pattern is a plain literal (cheaper eq
198
+ # compare). Compiling up-front removes per-request regex allocation.
199
+ def compile_patterns(patterns)
200
+ Array(patterns).map do |pat|
201
+ s = pat.to_s
202
+ has_hash = s.include?("#")
203
+ regex = if s.include?("*")
204
+ if has_hash
205
+ Regexp.new("\\A" + Regexp.escape(s).gsub("\\*", ".*") + "\\z")
206
+ else
207
+ Regexp.new("\\A" + Regexp.escape(s).gsub("\\*", "[^:]*") + "\\z")
208
+ end
209
+ end
210
+ {pattern: s, has_hash: has_hash, regex: regex}
211
+ end
212
+ rescue
213
+ []
214
+ end
177
215
 
178
- # For controller action patterns (containing '#'), use .* to match any characters including colons
179
- # For controller-only patterns, use [^:]* to match namespace segments
180
- regex = if pat.include?("#")
181
- # Controller action pattern: allow * to match any characters including colons
182
- Regexp.new("^" + Regexp.escape(pat).gsub("\\*", ".*") + "$")
216
+ def match_compiled?(name, entry)
217
+ return false if name.nil? || entry.nil?
218
+ n = name.to_s
219
+ if entry[:regex]
220
+ !!(n =~ entry[:regex])
183
221
  else
184
- # Controller-only pattern: use [^:]* to match namespace segments
185
- Regexp.new("^" + Regexp.escape(pat).gsub("\\*", "[^:]*") + "$")
222
+ n == entry[:pattern]
186
223
  end
187
- !!(name.to_s =~ regex)
188
224
  rescue
189
225
  false
190
226
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module DeadBro
6
+ # Background worker pool that runs HTTP posts for Client off the request
7
+ # thread. Replaces the previous `Thread.new` per metric. One shared pool per
8
+ # process; re-initializes after fork (Puma, Unicorn).
9
+ class Dispatcher
10
+ DEFAULT_QUEUE_SIZE = 500
11
+ DEFAULT_WORKERS = 2
12
+ SHUTDOWN = Object.new
13
+
14
+ class << self
15
+ def instance
16
+ @instance ||= new
17
+ end
18
+
19
+ # Exposed for tests.
20
+ def reset!
21
+ @instance&.shutdown
22
+ @instance = nil
23
+ end
24
+
25
+ # Test hook — when true, `dispatch` runs the block inline on the caller
26
+ # thread instead of handing it to a worker. Keeps specs deterministic
27
+ # without having to stub `Thread.new` or poll for queue drain.
28
+ attr_accessor :inline
29
+ end
30
+
31
+ def initialize(queue_size: DEFAULT_QUEUE_SIZE, workers: DEFAULT_WORKERS)
32
+ @queue_size = queue_size
33
+ @worker_count = workers
34
+ @mutex = Mutex.new
35
+ @dropped = 0
36
+ @shutting_down = false
37
+ boot_workers(Process.pid)
38
+ install_at_exit_hook
39
+ end
40
+
41
+ # Schedule a block for background execution. Never blocks the caller: if the
42
+ # queue is full the job is dropped and `dropped_count` is incremented.
43
+ def dispatch(&block)
44
+ return false unless block_given?
45
+ return false if @shutting_down
46
+
47
+ if self.class.inline
48
+ begin
49
+ block.call
50
+ rescue
51
+ # Match worker semantics — swallow job errors.
52
+ end
53
+ return true
54
+ end
55
+
56
+ ensure_workers_alive!
57
+ @queue.push(block, true) # non-blocking
58
+ true
59
+ rescue ThreadError
60
+ @mutex.synchronize { @dropped += 1 }
61
+ false
62
+ end
63
+
64
+ def dropped_count
65
+ @mutex.synchronize { @dropped }
66
+ end
67
+
68
+ def shutdown
69
+ return if @shutting_down
70
+ @shutting_down = true
71
+ workers = @workers || []
72
+ workers.length.times do
73
+ begin
74
+ @queue.push(SHUTDOWN)
75
+ rescue
76
+ end
77
+ end
78
+ workers.each do |t|
79
+ begin
80
+ t.join(2)
81
+ rescue
82
+ end
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def boot_workers(pid)
89
+ @pid = pid
90
+ @queue = SizedQueue.new(@queue_size)
91
+ @workers = Array.new(@worker_count) do
92
+ t = Thread.new { run }
93
+ begin
94
+ t.name = "dead_bro-dispatcher"
95
+ rescue
96
+ end
97
+ t.abort_on_exception = false
98
+ t
99
+ end
100
+ end
101
+
102
+ def ensure_workers_alive!
103
+ return if @pid == Process.pid && @workers && @workers.all?(&:alive?)
104
+
105
+ @mutex.synchronize do
106
+ return if @pid == Process.pid && @workers && @workers.all?(&:alive?)
107
+ # Post-fork (new PID) or a worker died — bring up a fresh pool.
108
+ boot_workers(Process.pid)
109
+ @shutting_down = false
110
+ end
111
+ end
112
+
113
+ def install_at_exit_hook
114
+ at_exit { shutdown }
115
+ rescue
116
+ end
117
+
118
+ def run
119
+ loop do
120
+ job = @queue.pop
121
+ break if job.equal?(SHUTDOWN)
122
+ begin
123
+ job.call
124
+ rescue
125
+ # Never let a job crash the worker.
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end