miniapm 1.0.0

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +174 -0
  5. data/lib/generators/miniapm/install_generator.rb +27 -0
  6. data/lib/generators/miniapm/templates/README +19 -0
  7. data/lib/generators/miniapm/templates/initializer.rb +60 -0
  8. data/lib/miniapm/configuration.rb +176 -0
  9. data/lib/miniapm/context.rb +138 -0
  10. data/lib/miniapm/error_event.rb +130 -0
  11. data/lib/miniapm/exporters/errors.rb +67 -0
  12. data/lib/miniapm/exporters/otlp.rb +90 -0
  13. data/lib/miniapm/instrumentations/activejob.rb +271 -0
  14. data/lib/miniapm/instrumentations/activerecord.rb +123 -0
  15. data/lib/miniapm/instrumentations/base.rb +61 -0
  16. data/lib/miniapm/instrumentations/cache.rb +85 -0
  17. data/lib/miniapm/instrumentations/http/faraday.rb +112 -0
  18. data/lib/miniapm/instrumentations/http/httparty.rb +84 -0
  19. data/lib/miniapm/instrumentations/http/net_http.rb +99 -0
  20. data/lib/miniapm/instrumentations/rails/controller.rb +129 -0
  21. data/lib/miniapm/instrumentations/rails/railtie.rb +42 -0
  22. data/lib/miniapm/instrumentations/redis/redis.rb +135 -0
  23. data/lib/miniapm/instrumentations/redis/redis_client.rb +116 -0
  24. data/lib/miniapm/instrumentations/registry.rb +90 -0
  25. data/lib/miniapm/instrumentations/search/elasticsearch.rb +121 -0
  26. data/lib/miniapm/instrumentations/search/opensearch.rb +120 -0
  27. data/lib/miniapm/instrumentations/search/searchkick.rb +119 -0
  28. data/lib/miniapm/instrumentations/sidekiq.rb +185 -0
  29. data/lib/miniapm/middleware/error_handler.rb +120 -0
  30. data/lib/miniapm/middleware/rack.rb +103 -0
  31. data/lib/miniapm/span.rb +289 -0
  32. data/lib/miniapm/testing.rb +209 -0
  33. data/lib/miniapm/trace.rb +26 -0
  34. data/lib/miniapm/transport/batch_sender.rb +345 -0
  35. data/lib/miniapm/transport/http.rb +45 -0
  36. data/lib/miniapm/version.rb +5 -0
  37. data/lib/miniapm.rb +184 -0
  38. metadata +183 -0
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniAPM
4
+ # Testing utilities for capturing and inspecting spans/errors in tests
5
+ #
6
+ # Usage:
7
+ # require 'miniapm/testing'
8
+ #
9
+ # RSpec.describe "MyFeature" do
10
+ # before { MiniAPM::Testing.enable! }
11
+ # after { MiniAPM::Testing.disable! }
12
+ #
13
+ # it "tracks spans" do
14
+ # perform_action
15
+ # expect(MiniAPM::Testing.spans).to include(
16
+ # having_attributes(name: /process_action/)
17
+ # )
18
+ # end
19
+ # end
20
+ #
21
+ module Testing
22
+ class << self
23
+ # Enable test mode - captures spans/errors instead of sending them
24
+ def enable!
25
+ @enabled = true
26
+ @spans = []
27
+ @errors = []
28
+ @mutex = Mutex.new
29
+
30
+ # Stub the record methods to capture instead of send
31
+ install_test_hooks!
32
+ end
33
+
34
+ # Disable test mode and restore normal behavior
35
+ def disable!
36
+ @enabled = false
37
+ clear!
38
+ uninstall_test_hooks!
39
+ end
40
+
41
+ # Check if test mode is enabled
42
+ def enabled?
43
+ @enabled || false
44
+ end
45
+
46
+ # Get all captured spans
47
+ def spans
48
+ @mutex.synchronize { @spans.dup }
49
+ end
50
+
51
+ # Get all captured errors
52
+ def errors
53
+ @mutex.synchronize { @errors.dup }
54
+ end
55
+
56
+ # Alias for spans
57
+ def recorded_spans
58
+ spans
59
+ end
60
+
61
+ # Alias for errors
62
+ def recorded_errors
63
+ errors
64
+ end
65
+
66
+ # Clear all captured data
67
+ def clear!
68
+ @mutex&.synchronize do
69
+ @spans = []
70
+ @errors = []
71
+ end
72
+ end
73
+
74
+ # Find spans matching criteria
75
+ def find_spans(name: nil, category: nil, &block)
76
+ result = spans
77
+ result = result.select { |s| s.name.match?(name) } if name
78
+ result = result.select { |s| s.category == category } if category
79
+ result = result.select(&block) if block_given?
80
+ result
81
+ end
82
+
83
+ # Find errors matching criteria
84
+ def find_errors(exception_class: nil, message: nil, &block)
85
+ result = errors
86
+ result = result.select { |e| e.exception_class == exception_class } if exception_class
87
+ result = result.select { |e| e.message.match?(message) } if message
88
+ result = result.select(&block) if block_given?
89
+ result
90
+ end
91
+
92
+ # Check if any span matches
93
+ def span_recorded?(name: nil, category: nil)
94
+ find_spans(name: name, category: category).any?
95
+ end
96
+
97
+ # Check if any error matches
98
+ def error_recorded?(exception_class: nil, message: nil)
99
+ find_errors(exception_class: exception_class, message: message).any?
100
+ end
101
+
102
+ # Record a span (called by test hooks)
103
+ def record_span(span)
104
+ return unless enabled?
105
+
106
+ @mutex.synchronize { @spans << span }
107
+ end
108
+
109
+ # Record an error (called by test hooks)
110
+ def record_error(error)
111
+ return unless enabled?
112
+
113
+ @mutex.synchronize { @errors << error }
114
+ end
115
+
116
+ private
117
+
118
+ def install_test_hooks!
119
+ # Guard against double installation
120
+ return if @hooks_installed
121
+
122
+ # Store original methods
123
+ @original_record_span = MiniAPM.method(:record_span)
124
+ @original_record_error = MiniAPM.method(:record_error)
125
+
126
+ # Replace with test versions (suppress warnings)
127
+ testing = self
128
+ original_verbose = $VERBOSE
129
+ begin
130
+ $VERBOSE = nil
131
+ MiniAPM.define_singleton_method(:record_span) do |span|
132
+ testing.record_span(span)
133
+ end
134
+
135
+ MiniAPM.define_singleton_method(:record_error) do |exception, context: {}|
136
+ error_event = ErrorEvent.from_exception(exception, context)
137
+ testing.record_error(error_event)
138
+ end
139
+ ensure
140
+ $VERBOSE = original_verbose
141
+ end
142
+
143
+ @hooks_installed = true
144
+ end
145
+
146
+ def uninstall_test_hooks!
147
+ return unless @hooks_installed
148
+
149
+ original_verbose = $VERBOSE
150
+ begin
151
+ $VERBOSE = nil
152
+ MiniAPM.define_singleton_method(:record_span, @original_record_span)
153
+ MiniAPM.define_singleton_method(:record_error, @original_record_error)
154
+ ensure
155
+ $VERBOSE = original_verbose
156
+ end
157
+
158
+ @original_record_span = nil
159
+ @original_record_error = nil
160
+ @hooks_installed = false
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ # RSpec integration
167
+ if defined?(RSpec)
168
+ RSpec.configure do |config|
169
+ config.before(:each, :miniapm) do
170
+ MiniAPM::Testing.enable!
171
+ end
172
+
173
+ config.after(:each, :miniapm) do
174
+ MiniAPM::Testing.disable!
175
+ end
176
+ end
177
+ end
178
+
179
+ # Minitest integration
180
+ if defined?(Minitest)
181
+ module MiniAPM
182
+ module Testing
183
+ module MinitestHooks
184
+ def before_setup
185
+ super
186
+ MiniAPM::Testing.enable! if self.class.miniapm_enabled?
187
+ end
188
+
189
+ def after_teardown
190
+ MiniAPM::Testing.disable! if self.class.miniapm_enabled?
191
+ super
192
+ end
193
+ end
194
+
195
+ module MinitestClassMethods
196
+ def miniapm_enabled?
197
+ @miniapm_enabled || false
198
+ end
199
+
200
+ def enable_miniapm!
201
+ @miniapm_enabled = true
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ Minitest::Test.prepend(MiniAPM::Testing::MinitestHooks)
208
+ Minitest::Test.extend(MiniAPM::Testing::MinitestClassMethods)
209
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module MiniAPM
6
+ class Trace
7
+ attr_reader :trace_id
8
+ attr_accessor :sampled
9
+
10
+ def initialize(trace_id: nil, sampled: nil)
11
+ @trace_id = trace_id || SecureRandom.hex(16)
12
+ @sampled = sampled.nil? ? should_sample? : sampled
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def sampled?
17
+ @sampled
18
+ end
19
+
20
+ private
21
+
22
+ def should_sample?
23
+ rand < MiniAPM.configuration.sample_rate
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module MiniAPM
6
+ module Transport
7
+ class BatchSender
8
+ SHUTDOWN_TIMEOUT = 5 # seconds to wait for flush on shutdown
9
+ MAX_RETRY_ATTEMPTS = 3
10
+ BASE_RETRY_DELAY = 1.0 # seconds
11
+ MAX_CONCURRENT_SENDS = 4
12
+
13
+ # Circuit breaker settings
14
+ CIRCUIT_FAILURE_THRESHOLD = 5 # failures before opening circuit
15
+ CIRCUIT_RESET_TIMEOUT = 60 # seconds before trying again
16
+
17
+ class << self
18
+ def start!
19
+ @start_mutex ||= Mutex.new
20
+
21
+ @start_mutex.synchronize do
22
+ return if @started
23
+
24
+ @started = true
25
+ @shutdown = false
26
+
27
+ @queues = {
28
+ span: Queue.new,
29
+ error: Queue.new
30
+ }
31
+
32
+ @mutex = Mutex.new
33
+ @batches = { span: [], error: [] }
34
+ @last_flush = Time.now
35
+
36
+ # Stats for monitoring
37
+ reset_stats!
38
+
39
+ # Bounded thread pool for sending
40
+ @send_queue = Queue.new
41
+ @send_threads = MAX_CONCURRENT_SENDS.times.map do |i|
42
+ Thread.new { send_worker_loop(i) }
43
+ end
44
+
45
+ start_worker_thread!
46
+ setup_shutdown_hook!
47
+
48
+ MiniAPM.logger.debug { "MiniAPM BatchSender started" }
49
+ end
50
+ end
51
+
52
+ def stop!
53
+ @start_mutex ||= Mutex.new
54
+
55
+ @start_mutex.synchronize do
56
+ return unless @started
57
+
58
+ @shutdown = true
59
+
60
+ # Drain queues and flush remaining data
61
+ drain_queues_to_batches!
62
+ flush_all!
63
+
64
+ # Wait for worker thread
65
+ @worker_thread&.join(SHUTDOWN_TIMEOUT)
66
+
67
+ # Signal send threads to stop and wait
68
+ MAX_CONCURRENT_SENDS.times { @send_queue << :shutdown }
69
+ @send_threads&.each { |t| t.join(SHUTDOWN_TIMEOUT) }
70
+
71
+ @started = false
72
+
73
+ MiniAPM.logger.debug { "MiniAPM BatchSender stopped" }
74
+ end
75
+ end
76
+
77
+ def enqueue(type, item)
78
+ return unless @started
79
+
80
+ queue = @queues[type]
81
+ return unless queue
82
+
83
+ config = MiniAPM.configuration
84
+
85
+ # Drop if queue is full (backpressure)
86
+ if queue.size >= config.max_queue_size
87
+ increment_stat(:dropped, type)
88
+ MiniAPM.logger.warn { "MiniAPM: Queue full, dropping #{type}" }
89
+ return
90
+ end
91
+
92
+ queue << item
93
+ increment_stat(:enqueued, type)
94
+ end
95
+
96
+ def started?
97
+ @started || false
98
+ end
99
+
100
+ # Force flush all pending data (useful for testing)
101
+ def flush!
102
+ return unless @started
103
+
104
+ # First, drain queues into batches
105
+ drain_queues_to_batches!
106
+
107
+ # Then flush all batches
108
+ flush_all!
109
+
110
+ # Wait for send queue to drain
111
+ deadline = Time.now + 5 # 5 second timeout
112
+ while @send_queue&.size&.positive? && Time.now < deadline
113
+ sleep 0.1
114
+ end
115
+ end
116
+
117
+ # Get current stats
118
+ def stats
119
+ @mutex.synchronize { @stats.dup }
120
+ end
121
+
122
+ # Reset stats
123
+ def reset_stats!
124
+ @mutex.synchronize do
125
+ @stats = {
126
+ enqueued: { span: 0, error: 0 },
127
+ sent: { span: 0, error: 0 },
128
+ dropped: { span: 0, error: 0 },
129
+ failed: { span: 0, error: 0 },
130
+ retries: 0,
131
+ circuit_opened: 0
132
+ }
133
+ # Circuit breaker state
134
+ @circuit_failures = 0
135
+ @circuit_opened_at = nil
136
+ end
137
+ end
138
+
139
+ # Check if circuit breaker allows requests
140
+ def circuit_open?
141
+ @mutex.synchronize do
142
+ return false if @circuit_failures < CIRCUIT_FAILURE_THRESHOLD
143
+
144
+ # Check if we should try again (half-open state)
145
+ if @circuit_opened_at && (Time.now - @circuit_opened_at) >= CIRCUIT_RESET_TIMEOUT
146
+ MiniAPM.logger.info { "MiniAPM: Circuit breaker half-open, attempting request" }
147
+ return false
148
+ end
149
+
150
+ true
151
+ end
152
+ end
153
+
154
+ def record_circuit_success!
155
+ @mutex.synchronize do
156
+ @circuit_failures = 0
157
+ @circuit_opened_at = nil
158
+ end
159
+ end
160
+
161
+ def record_circuit_failure!
162
+ @mutex.synchronize do
163
+ @circuit_failures += 1
164
+ if @circuit_failures >= CIRCUIT_FAILURE_THRESHOLD && @circuit_opened_at.nil?
165
+ @circuit_opened_at = Time.now
166
+ @stats[:circuit_opened] += 1
167
+ MiniAPM.logger.warn { "MiniAPM: Circuit breaker opened after #{@circuit_failures} failures" }
168
+ end
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def drain_queues_to_batches!
175
+ @queues.each do |type, queue|
176
+ @mutex.synchronize do
177
+ while !queue.empty?
178
+ begin
179
+ item = queue.pop(true) # non-blocking
180
+ @batches[type] << item
181
+ rescue ThreadError
182
+ break
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ def increment_stat(stat, type = nil)
190
+ @mutex.synchronize do
191
+ if type
192
+ @stats[stat][type] += 1
193
+ else
194
+ @stats[stat] += 1
195
+ end
196
+ end
197
+ end
198
+
199
+ def start_worker_thread!
200
+ @worker_thread = Thread.new do
201
+ Thread.current.name = "miniapm-batcher"
202
+ Thread.current.report_on_exception = false
203
+
204
+ until @shutdown
205
+ begin
206
+ process_queues
207
+ rescue StandardError => e
208
+ MiniAPM.logger.error { "MiniAPM worker error: #{e.class}: #{e.message}" }
209
+ end
210
+
211
+ sleep 0.1 # Small sleep to prevent busy-waiting
212
+ end
213
+ end
214
+ end
215
+
216
+ def send_worker_loop(worker_id)
217
+ Thread.current.name = "miniapm-sender-#{worker_id}"
218
+ Thread.current.report_on_exception = false
219
+
220
+ loop do
221
+ work = @send_queue.pop
222
+ break if work == :shutdown
223
+
224
+ type, items = work
225
+ send_with_retry(type, items)
226
+ end
227
+ end
228
+
229
+ def process_queues
230
+ config = MiniAPM.configuration
231
+
232
+ @queues.each do |type, queue|
233
+ # Drain queue into batch
234
+ @mutex.synchronize do
235
+ while !queue.empty? && @batches[type].size < config.batch_size
236
+ begin
237
+ item = queue.pop(true) # non-blocking
238
+ @batches[type] << item
239
+ rescue ThreadError
240
+ break
241
+ end
242
+ end
243
+ end
244
+
245
+ # Flush if batch is full or timer expired
246
+ flush_type!(type) if should_flush?(type)
247
+ end
248
+ end
249
+
250
+ def should_flush?(type)
251
+ @mutex.synchronize do
252
+ batch = @batches[type]
253
+ return false if batch.empty?
254
+
255
+ config = MiniAPM.configuration
256
+ batch.size >= config.batch_size ||
257
+ (Time.now - @last_flush) >= config.flush_interval
258
+ end
259
+ end
260
+
261
+ def flush_type!(type)
262
+ items = nil
263
+
264
+ @mutex.synchronize do
265
+ batch = @batches[type]
266
+ return if batch.empty?
267
+
268
+ items = batch.dup
269
+ batch.clear
270
+ @last_flush = Time.now
271
+ end
272
+
273
+ return unless items && !items.empty?
274
+
275
+ # Queue for sending (bounded by thread pool)
276
+ @send_queue << [type, items]
277
+ end
278
+
279
+ def flush_all!
280
+ @queues.each_key { |type| flush_type!(type) }
281
+ end
282
+
283
+ def send_with_retry(type, items)
284
+ # Check circuit breaker first
285
+ if circuit_open?
286
+ MiniAPM.logger.debug { "MiniAPM: Circuit open, dropping #{items.size} #{type}(s)" }
287
+ increment_stat(:dropped, type)
288
+ return false
289
+ end
290
+
291
+ attempts = 0
292
+
293
+ loop do
294
+ attempts += 1
295
+ result = send_batch(type, items)
296
+
297
+ if result[:success]
298
+ @mutex.synchronize { @stats[:sent][type] += items.size }
299
+ record_circuit_success!
300
+ return true
301
+ end
302
+
303
+ # Don't retry on client errors (4xx)
304
+ if result[:status] && result[:status] >= 400 && result[:status] < 500
305
+ MiniAPM.logger.warn { "MiniAPM: Client error #{result[:status]}, not retrying" }
306
+ increment_stat(:failed, type)
307
+ return false
308
+ end
309
+
310
+ # Check if we should retry
311
+ if attempts >= MAX_RETRY_ATTEMPTS
312
+ MiniAPM.logger.error { "MiniAPM: Failed to send #{type} after #{attempts} attempts" }
313
+ increment_stat(:failed, type)
314
+ record_circuit_failure!
315
+ return false
316
+ end
317
+
318
+ # Exponential backoff
319
+ delay = BASE_RETRY_DELAY * (2 ** (attempts - 1))
320
+ delay += rand * delay * 0.1 # Add jitter
321
+ increment_stat(:retries)
322
+ MiniAPM.logger.debug { "MiniAPM: Retrying #{type} in #{delay.round(2)}s (attempt #{attempts})" }
323
+ sleep delay
324
+ end
325
+ end
326
+
327
+ def send_batch(type, items)
328
+ case type
329
+ when :span
330
+ Exporters::OTLP.export(items)
331
+ when :error
332
+ Exporters::Errors.export_batch(items)
333
+ end
334
+ rescue StandardError => e
335
+ MiniAPM.logger.error { "MiniAPM send error: #{e.class}: #{e.message}" }
336
+ { success: false, error: e }
337
+ end
338
+
339
+ def setup_shutdown_hook!
340
+ at_exit { stop! }
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module MiniAPM
8
+ module Transport
9
+ class HTTP
10
+ DEFAULT_TIMEOUT = 10
11
+ DEFAULT_OPEN_TIMEOUT = 5
12
+
13
+ class << self
14
+ def post(url, payload, headers: {})
15
+ uri = URI.parse(url)
16
+
17
+ http = Net::HTTP.new(uri.host, uri.port)
18
+ http.use_ssl = uri.scheme == "https"
19
+ http.open_timeout = DEFAULT_OPEN_TIMEOUT
20
+ http.read_timeout = DEFAULT_TIMEOUT
21
+ http.write_timeout = DEFAULT_TIMEOUT if http.respond_to?(:write_timeout=)
22
+
23
+ request = Net::HTTP::Post.new(uri.request_uri)
24
+ request["Content-Type"] = "application/json"
25
+ request["User-Agent"] = "miniapm-ruby/#{MiniAPM::VERSION}"
26
+
27
+ headers.each { |key, value| request[key] = value }
28
+
29
+ request.body = payload.is_a?(String) ? payload : JSON.generate(payload)
30
+
31
+ response = http.request(request)
32
+
33
+ {
34
+ status: response.code.to_i,
35
+ body: response.body,
36
+ success: response.is_a?(Net::HTTPSuccess)
37
+ }
38
+ rescue StandardError => e
39
+ MiniAPM.logger.warn { "MiniAPM HTTP error: #{e.class}: #{e.message}" }
40
+ { status: 0, body: e.message, success: false, error: e }
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MiniAPM
4
+ VERSION = "1.0.0"
5
+ end