apm_bro 0.1.9 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f5e04ee9c91623872f5d3b5ee06f99ea4acb4da38f99efc9048d2e61a674723
4
- data.tar.gz: dae5536d6035f67546136f12ffe1fc52d602bdffc8dd5eeba120fec2313c4cd7
3
+ metadata.gz: 4dabb799711a90879b5f520c0838398a048afeee6b4cb79a000e9d86e1b47614
4
+ data.tar.gz: 1cbb04c4e1453a006821a77ae4bc394f3a75f24c0b91e49f3893a1cc9fd5fdcd
5
5
  SHA512:
6
- metadata.gz: e7df9304f1d6ab9be869b9856648e67382623bec5ce59d727de9e5490234d95c1f10c857e924fea02977810dbdc387564b3790d96bb68f3de680980d6e6a92b4
7
- data.tar.gz: 8cf8d948c812075b068479e25737a0d43999831f2dea87a018d6d62fe8c1049d96bb27076f88173ed91d4bd4ae1939669d768402ee9e8ce8e5c215c14014d909
6
+ metadata.gz: 6e112276ac70979ada88695ea6b975309e2017147d8731672c58b1f1730d13fc0d6f7f36257c5d30ef3cd9d4cd76a774cbafae75f1d0328aa2d5bd7e1668b74e
7
+ data.tar.gz: '097cc98f1ed31a4936f6a385ec031db5292225ae71020c252cf64961ca26f8bb89e25fe360ac9a5077aa0b7f152a6989deae2c83bba0fbccda66bb1eec60b918'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-08-28
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # ApmBro (Beta Version)
2
+
3
+ Minimal APM for Rails apps. Automatically measures each controller action's total time, tracks SQL queries, monitors view rendering performance, tracks memory usage and detects leaks, monitors background jobs, and posts metrics to a remote endpoint with an API key read from your app's settings/credentials/env.
4
+
5
+ To use the gem you need to have a free account with [DeadBro - Rails APM](https://www.deadbro.com)
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "apm_bro", git: "https://github.com/rubydevro/apm_bro.git"
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ By default, if Rails is present, ApmBro auto-subscribes to `process_action.action_controller` and posts metrics asynchronously.
18
+
19
+ ### Configuration settings
20
+
21
+ You can set via an initializer:
22
+
23
+
24
+ ```ruby
25
+ ApmBro.configure do |cfg|
26
+ cfg.api_key = ENV["APM_BRO_API_KEY"]
27
+ cfg.enabled = true
28
+ end
29
+ ```
30
+
31
+ ## User Email Tracking
32
+
33
+ ApmBro can track the email of the user making requests, which is useful for debugging user-specific issues and understanding user behavior patterns.
34
+
35
+ ### Configuration
36
+
37
+ Enable user email tracking in your Rails configuration:
38
+
39
+ ```ruby
40
+ # In config/application.rb or environments/*.rb
41
+ ApmBro.configure do |config|
42
+ config.user_email_tracking_enabled = true
43
+ end
44
+ ```
45
+
46
+ ### Default Email Extraction
47
+
48
+ By default, ApmBro will try to extract user email from these sources (in order of priority):
49
+
50
+ 1. **`current_user.email`** - Most common in Rails apps with authentication
51
+ 2. **Request parameters** - `user_email` or `email` in params
52
+ 3. **HTTP headers** - `X-User-Email` or `HTTP_X_USER_EMAIL`
53
+ 4. **Session data** - `user_email` in session
54
+
55
+ ### Custom Email Extractor
56
+
57
+ In progress
58
+
59
+ ### Security Considerations
60
+
61
+ - User email tracking is **disabled by default** for privacy
62
+ - Only enable when necessary for debugging or analytics
63
+ - Consider your data privacy requirements and regulations
64
+ - The email is included in all request payloads sent to our APM endpoint
65
+
66
+ ## Request Sampling
67
+
68
+ ApmBro supports configurable request sampling to reduce the volume of metrics sent to your APM endpoint, which is useful for high-traffic applications.
69
+
70
+ ### Configuration
71
+
72
+ Set the sample rate as a percentage (1-100):
73
+
74
+ ```ruby
75
+ # Track 50% of requests
76
+ ApmBro.configure do |config|
77
+ config.sample_rate = 50
78
+ end
79
+
80
+ # Track 10% of requests (useful for high-traffic apps)
81
+ ApmBro.configure do |config|
82
+ config.sample_rate = 10
83
+ end
84
+
85
+ # Track all requests (default)
86
+ ApmBro.configure do |config|
87
+ config.sample_rate = 100
88
+ end
89
+ ```
90
+
91
+ ### How It Works
92
+
93
+ - **Random Sampling**: Each request has a random chance of being tracked based on the sample rate
94
+ - **Consistent Per-Request**: The sampling decision is made once per request and applies to all metrics for that request
95
+ - **Debug Logging**: Skipped requests do not count towards the montly limit
96
+ - **Error Tracking**: Errors are still tracked regardless of sampling
97
+
98
+ ### Use Cases
99
+
100
+ - **High-Traffic Applications**: Reduce APM data volume and costs
101
+ - **Development/Staging**: Sample fewer requests to reduce noise
102
+ - **Performance Testing**: Track a subset of requests during load testing
103
+ - **Cost Optimization**: Balance monitoring coverage with data costs
104
+
105
+
106
+ ## Excluding Controllers and Jobs
107
+
108
+ You can exclude specific controllers and jobs from APM tracking.
109
+
110
+ ### Configuration
111
+
112
+
113
+ ```ruby
114
+ ApmBro.configure do |config|
115
+ config.excluded_controllers = [
116
+ "HealthChecksController",
117
+ "Admin::*" # wildcard supported
118
+ ]
119
+
120
+ config.excluded_controller_actions = [
121
+ "UsersController#show",
122
+ "Admin::ReportsController#index",
123
+ "Admin::*#*" # wildcard supported for controller and action
124
+ ]
125
+
126
+ config.excluded_jobs = [
127
+ "ActiveStorage::AnalyzeJob",
128
+ "Admin::*"
129
+ ]
130
+ end
131
+ ```
132
+
133
+ Notes:
134
+ - Wildcards `*` are supported for controller and action (e.g., `Admin::*#*`).
135
+ - Matching is done against full names like `UsersController`, `Admin::ReportsController#index`, `MyJob`.
136
+
137
+ ## SQL Query Tracking
138
+
139
+ ApmBro automatically tracks SQL queries executed during each request and job. Each request will include a `sql_queries` array containing:
140
+ - `sql` - The SQL query (always sanitized)
141
+ - `name` - Query name (e.g., "User Load", "User Update")
142
+ - `duration_ms` - Query execution time in milliseconds
143
+ - `cached` - Whether the query was cached
144
+ - `connection_id` - Database connection ID
145
+ - `trace` - Call stack showing where the query was executed
146
+
147
+ ## View Rendering Tracking
148
+
149
+ ApmBro automatically tracks view rendering performance for each request. This includes:
150
+
151
+ - **Individual view events**: Templates, partials, and collections rendered
152
+ - **Performance metrics**: Rendering times for each view component
153
+ - **Cache analysis**: Cache hit rates for partials and collections
154
+ - **Slow view detection**: Identification of the slowest rendering views
155
+ - **Frequency analysis**: Most frequently rendered views
156
+
157
+ ## Memory Tracking & Leak Detection
158
+
159
+ ApmBro automatically tracks memory usage and detects memory leaks with minimal performance impact. This includes:
160
+
161
+ ### Performance-Optimized Memory Tracking
162
+
163
+ By default, ApmBro uses **lightweight memory tracking** that has minimal performance impact:
164
+
165
+ - **Memory Usage Monitoring**: Track memory consumption per request (using GC stats, not system calls)
166
+ - **Memory Leak Detection**: Detect growing memory patterns over time
167
+ - **GC Efficiency Analysis**: Monitor garbage collection effectiveness
168
+ - **Zero Allocation Tracking**: No object allocation tracking by default (can be enabled)
169
+
170
+ ### Configuration Options
171
+
172
+ ```ruby
173
+ # In your Rails configuration
174
+ ApmBro.configure do |config|
175
+ config.memory_tracking_enabled = true # Enable lightweight memory tracking (default: true)
176
+ config.allocation_tracking_enabled = false # Enable detailed allocation tracking (default: false)
177
+
178
+ # Sampling configuration
179
+ config.sample_rate = 100 # Percentage of requests to track (1-100, default: 100)
180
+ end
181
+ ```
182
+
183
+ **Performance Impact:**
184
+ - **Lightweight mode**: ~0.1ms overhead per request
185
+ - **Allocation tracking**: ~2-5ms overhead per request (only enable when needed)
186
+
187
+ ## Job Tracking
188
+
189
+ ApmBro automatically tracks ActiveJob background jobs when ActiveJob is available. Each job execution is tracked with:
190
+
191
+ - `job_class` - The job class name (e.g., "UserMailer::WelcomeEmail")
192
+ - `job_id` - Unique job identifier
193
+ - `queue_name` - The queue the job was processed from
194
+ - `arguments` - Sanitized job arguments (sensitive data filtered)
195
+ - `duration_ms` - Job execution time in milliseconds
196
+ - `status` - "completed" or "failed"
197
+ - `sql_queries` - Array of SQL queries executed during the job
198
+ - `exception_class` - Exception class name (for failed jobs)
199
+ - `message` - Exception message (for failed jobs)
200
+ - `backtrace` - Exception backtrace (for failed jobs)
201
+
202
+
203
+ ## Development
204
+
205
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
206
+
207
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`.
208
+
209
+ ## Contributing
210
+
211
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rubydevro/apm_bro.
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module ApmBro
6
+ class CacheSubscriber
7
+ THREAD_LOCAL_KEY = :apm_bro_cache_events
8
+
9
+ EVENTS = [
10
+ "cache_read.active_support",
11
+ "cache_write.active_support",
12
+ "cache_delete.active_support",
13
+ "cache_exist?.active_support",
14
+ "cache_fetch_hit.active_support",
15
+ "cache_generate.active_support",
16
+ "cache_read_multi.active_support",
17
+ "cache_write_multi.active_support"
18
+ ].freeze
19
+
20
+ def self.subscribe!
21
+ EVENTS.each do |event_name|
22
+ begin
23
+ ActiveSupport::Notifications.subscribe(event_name) do |name, started, finished, _unique_id, data|
24
+ next unless Thread.current[THREAD_LOCAL_KEY]
25
+
26
+ duration_ms = ((finished - started) * 1000.0).round(2)
27
+ event = build_event(name, data, duration_ms)
28
+ Thread.current[THREAD_LOCAL_KEY] << event if event
29
+ end
30
+ rescue StandardError
31
+ end
32
+ end
33
+ rescue StandardError
34
+ # Never raise from instrumentation install
35
+ end
36
+
37
+ def self.start_request_tracking
38
+ Thread.current[THREAD_LOCAL_KEY] = []
39
+ end
40
+
41
+ def self.stop_request_tracking
42
+ events = Thread.current[THREAD_LOCAL_KEY]
43
+ Thread.current[THREAD_LOCAL_KEY] = nil
44
+ events || []
45
+ end
46
+
47
+ def self.build_event(name, data, duration_ms)
48
+ return nil unless data.is_a?(Hash)
49
+
50
+ base = {
51
+ event: name,
52
+ duration_ms: duration_ms,
53
+ key: safe_key(data[:key]),
54
+ keys_count: safe_keys_count(data[:keys]),
55
+ hit: infer_hit(name, data),
56
+ store: safe_store_name(data[:store]),
57
+ namespace: safe_namespace(data[:namespace]),
58
+ at: Time.now.utc.to_i
59
+ }
60
+
61
+ base
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ def self.safe_key(key)
67
+ return nil if key.nil?
68
+ s = key.to_s
69
+ s.length > 200 ? s[0, 200] + "…" : s
70
+ rescue StandardError
71
+ nil
72
+ end
73
+
74
+ def self.safe_keys_count(keys)
75
+ if keys.respond_to?(:size)
76
+ keys.size
77
+ else
78
+ nil
79
+ end
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def self.safe_store_name(store)
85
+ return nil unless store
86
+ if store.respond_to?(:name)
87
+ store.name
88
+ else
89
+ store.class.name
90
+ end
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ def self.safe_namespace(ns)
96
+ ns.to_s[0, 100]
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ def self.infer_hit(name, data)
102
+ case name
103
+ when "cache_fetch_hit.active_support"
104
+ true
105
+ when "cache_read.active_support"
106
+ !!data[:hit]
107
+ else
108
+ nil
109
+ end
110
+ rescue StandardError
111
+ nil
112
+ end
113
+ end
114
+ end
115
+
116
+
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApmBro
4
+ class CircuitBreaker
5
+ # Circuit breaker states
6
+ CLOSED = :closed
7
+ OPEN = :open
8
+ HALF_OPEN = :half_open
9
+
10
+ # Default configuration
11
+ DEFAULT_FAILURE_THRESHOLD = 3
12
+ DEFAULT_RECOVERY_TIMEOUT = 60 # seconds
13
+ DEFAULT_RETRY_TIMEOUT = 300 # seconds for retry attempts
14
+
15
+ def initialize(
16
+ failure_threshold: DEFAULT_FAILURE_THRESHOLD,
17
+ recovery_timeout: DEFAULT_RECOVERY_TIMEOUT,
18
+ retry_timeout: DEFAULT_RETRY_TIMEOUT
19
+ )
20
+ @failure_threshold = failure_threshold
21
+ @recovery_timeout = recovery_timeout
22
+ @retry_timeout = retry_timeout
23
+
24
+ @state = CLOSED
25
+ @failure_count = 0
26
+ @last_failure_time = nil
27
+ @last_success_time = nil
28
+ end
29
+
30
+ def call(&block)
31
+ case @state
32
+ when CLOSED
33
+ execute_with_monitoring(&block)
34
+ when OPEN
35
+ if should_attempt_reset?
36
+ @state = HALF_OPEN
37
+ execute_with_monitoring(&block)
38
+ else
39
+ :circuit_open
40
+ end
41
+ when HALF_OPEN
42
+ execute_with_monitoring(&block)
43
+ end
44
+ end
45
+
46
+ def state
47
+ @state
48
+ end
49
+
50
+ def failure_count
51
+ @failure_count
52
+ end
53
+
54
+ def last_failure_time
55
+ @last_failure_time
56
+ end
57
+
58
+ def last_success_time
59
+ @last_success_time
60
+ end
61
+
62
+ def reset!
63
+ @state = CLOSED
64
+ @failure_count = 0
65
+ @last_failure_time = nil
66
+ end
67
+
68
+ def open!
69
+ @state = OPEN
70
+ @last_failure_time = Time.now
71
+ end
72
+
73
+ def transition_to_half_open!
74
+ @state = HALF_OPEN
75
+ end
76
+
77
+ def should_attempt_reset?
78
+ return false unless @last_failure_time
79
+
80
+ # Try to reset after recovery timeout
81
+ elapsed = Time.now - @last_failure_time
82
+ elapsed >= @recovery_timeout
83
+ end
84
+
85
+ private
86
+
87
+ def execute_with_monitoring(&block)
88
+ result = block.call
89
+
90
+ if success?(result)
91
+ on_success
92
+ result
93
+ else
94
+ on_failure
95
+ result
96
+ end
97
+ rescue StandardError => e
98
+ on_failure
99
+ raise e
100
+ end
101
+
102
+ def success?(result)
103
+ # Consider 2xx status codes as success
104
+ result.is_a?(Net::HTTPSuccess)
105
+ end
106
+
107
+ def on_success
108
+ @failure_count = 0
109
+ @last_success_time = Time.now
110
+ @state = CLOSED
111
+ end
112
+
113
+ def on_failure
114
+ @failure_count += 1
115
+ @last_failure_time = Time.now
116
+
117
+ # If we're in half-open state and get a failure, go back to open
118
+ if @state == HALF_OPEN
119
+ @state = OPEN
120
+ elsif @failure_count >= @failure_threshold
121
+ @state = OPEN
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "timeout"
7
+
8
+ module ApmBro
9
+ class Client
10
+ def initialize(configuration = ApmBro.configuration)
11
+ @configuration = configuration
12
+ @circuit_breaker = create_circuit_breaker
13
+ end
14
+
15
+ def post_metric(event_name:, payload:)
16
+ unless @configuration.enabled
17
+ return
18
+ end
19
+
20
+ # Check sampling rate - skip if not selected for sampling
21
+ unless @configuration.should_sample?
22
+ return
23
+ end
24
+
25
+ api_key = @configuration.resolve_api_key
26
+
27
+ if api_key.nil?
28
+ return
29
+ end
30
+
31
+ # Check circuit breaker before making request
32
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
33
+ if @circuit_breaker.state == :open
34
+ # Check if we should attempt a reset to half-open state
35
+ if @circuit_breaker.should_attempt_reset?
36
+ @circuit_breaker.transition_to_half_open!
37
+ else
38
+ return
39
+ end
40
+ end
41
+ end
42
+
43
+ # Make the HTTP request (async)
44
+ make_http_request(event_name, payload, api_key)
45
+
46
+ nil
47
+ end
48
+
49
+ private
50
+
51
+ def create_circuit_breaker
52
+ return nil unless @configuration.circuit_breaker_enabled
53
+
54
+ CircuitBreaker.new(
55
+ failure_threshold: @configuration.circuit_breaker_failure_threshold,
56
+ recovery_timeout: @configuration.circuit_breaker_recovery_timeout,
57
+ retry_timeout: @configuration.circuit_breaker_retry_timeout
58
+ )
59
+ end
60
+
61
+ def make_http_request(event_name, payload, api_key)
62
+ endpoint_url = @configuration.respond_to?(:ruby_dev) && @configuration.ruby_dev ?
63
+ 'http://localhost:3100/apm/v1/metrics' :
64
+ "https://www.deadbro.com/apm/v1/metrics"
65
+
66
+ uri = URI.parse(endpoint_url)
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = (uri.scheme == "https")
69
+ http.open_timeout = @configuration.open_timeout
70
+ http.read_timeout = @configuration.read_timeout
71
+
72
+ request = Net::HTTP::Post.new(uri.request_uri)
73
+ request["Content-Type"] = "application/json"
74
+ request["Authorization"] = "Bearer #{api_key}"
75
+ body = { event: event_name, payload: payload, sent_at: Time.now.utc.iso8601, revision: @configuration.resolve_deploy_id }
76
+ request.body = JSON.dump(body)
77
+
78
+ # Fire-and-forget using a short-lived thread to avoid blocking the request cycle.
79
+ Thread.new do
80
+ begin
81
+ response = http.request(request)
82
+
83
+ if response
84
+ # Update circuit breaker based on response
85
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
86
+ if response.is_a?(Net::HTTPSuccess)
87
+ @circuit_breaker.send(:on_success)
88
+ else
89
+ @circuit_breaker.send(:on_failure)
90
+ end
91
+ end
92
+ else
93
+ # Treat nil response as failure for circuit breaker
94
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
95
+ @circuit_breaker.send(:on_failure)
96
+ end
97
+ end
98
+
99
+ response
100
+ rescue Timeout::Error => e
101
+
102
+ # Update circuit breaker on timeout
103
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
104
+ @circuit_breaker.send(:on_failure)
105
+ end
106
+ rescue StandardError => e
107
+
108
+ # Update circuit breaker on exception
109
+ if @circuit_breaker && @configuration.circuit_breaker_enabled
110
+ @circuit_breaker.send(:on_failure)
111
+ end
112
+ end
113
+ end
114
+
115
+ nil
116
+ end
117
+
118
+ def log_debug(message)
119
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
120
+ Rails.logger.debug(message)
121
+ else
122
+ $stdout.puts(message)
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+