activerabbit-ai 0.4.1 → 0.4.4
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 +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +59 -0
- data/lib/active_rabbit/client/action_mailer_patch.rb +40 -0
- data/lib/active_rabbit/client/active_job_extensions.rb +66 -0
- data/lib/active_rabbit/client/configuration.rb +16 -3
- data/lib/active_rabbit/client/dedupe.rb +42 -0
- data/lib/active_rabbit/client/error_reporter.rb +89 -0
- data/lib/active_rabbit/client/event_processor.rb +5 -0
- data/lib/active_rabbit/client/exception_tracker.rb +90 -19
- data/lib/active_rabbit/client/http_client.rb +134 -16
- data/lib/active_rabbit/client/railtie.rb +488 -44
- data/lib/active_rabbit/client/version.rb +1 -1
- data/lib/active_rabbit/client.rb +23 -4
- data/lib/active_rabbit/middleware/error_capture_middleware.rb +24 -0
- data/lib/active_rabbit/reporting.rb +63 -0
- data/lib/active_rabbit/routing/not_found_app.rb +19 -0
- data/lib/active_rabbit-client.gemspec +0 -0
- data/lib/active_rabbit.rb +10 -2
- data/setup_local_gem_testing.sh +48 -0
- data/test_net_http.rb +47 -0
- data/test_with_api.rb +169 -0
- data/trigger_errors.rb +64 -0
- metadata +14 -2
|
@@ -31,9 +31,42 @@ module ActiveRabbit
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def post_exception(exception_data)
|
|
34
|
-
#
|
|
35
|
-
exception_data_with_type = exception_data.merge(event_type: 'error')
|
|
36
|
-
|
|
34
|
+
# Sanitize data before any processing
|
|
35
|
+
exception_data_with_type = stringify_and_sanitize(exception_data.merge(event_type: 'error'))
|
|
36
|
+
|
|
37
|
+
# Primary endpoint path
|
|
38
|
+
path = "/api/v1/events/errors"
|
|
39
|
+
|
|
40
|
+
configuration.logger&.info("[ActiveRabbit] Sending exception to API...")
|
|
41
|
+
configuration.logger&.debug("[ActiveRabbit] Exception payload (pre-JSON): #{safe_preview(exception_data_with_type)}")
|
|
42
|
+
configuration.logger&.debug("[ActiveRabbit] Target path: #{path}")
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
# Primary endpoint attempt
|
|
46
|
+
configuration.logger&.info("[ActiveRabbit] Making request to primary endpoint: POST #{path}")
|
|
47
|
+
response = make_request(:post, path, exception_data_with_type)
|
|
48
|
+
configuration.logger&.info("[ActiveRabbit] Exception sent successfully (errors endpoint)")
|
|
49
|
+
return response
|
|
50
|
+
rescue => e
|
|
51
|
+
configuration.logger&.error("[ActiveRabbit] Primary send failed: #{e.class}: #{e.message}")
|
|
52
|
+
configuration.logger&.error("[ActiveRabbit] Primary error backtrace: #{e.backtrace&.first(3)}")
|
|
53
|
+
configuration.logger&.debug("[ActiveRabbit] Falling back to /api/v1/events with type=error")
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
# Fallback to generic events endpoint
|
|
57
|
+
fallback_path = "/api/v1/events"
|
|
58
|
+
fallback_body = { type: "error", data: exception_data_with_type }
|
|
59
|
+
configuration.logger&.info("[ActiveRabbit] Making request to fallback endpoint: POST #{fallback_path}")
|
|
60
|
+
response = make_request(:post, fallback_path, fallback_body)
|
|
61
|
+
configuration.logger&.info("[ActiveRabbit] Exception sent via fallback endpoint")
|
|
62
|
+
return response
|
|
63
|
+
rescue => e2
|
|
64
|
+
configuration.logger&.error("[ActiveRabbit] Fallback send failed: #{e2.class}: #{e2.message}")
|
|
65
|
+
configuration.logger&.error("[ActiveRabbit] Fallback error backtrace: #{e2.backtrace&.first(3)}")
|
|
66
|
+
configuration.logger&.error("[ActiveRabbit] All exception sending attempts failed")
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
37
70
|
end
|
|
38
71
|
|
|
39
72
|
def post_performance(performance_data)
|
|
@@ -43,11 +76,24 @@ module ActiveRabbit
|
|
|
43
76
|
end
|
|
44
77
|
|
|
45
78
|
def post_batch(batch_data)
|
|
46
|
-
|
|
79
|
+
# Transform batch data into the format the API expects
|
|
80
|
+
events = batch_data.map do |event|
|
|
81
|
+
{
|
|
82
|
+
type: event[:data][:event_type] || event[:event_type] || event[:type],
|
|
83
|
+
data: event[:data]
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Send batch to API
|
|
88
|
+
configuration.logger&.info("[ActiveRabbit] Sending batch of #{events.length} events...")
|
|
89
|
+
response = make_request(:post, "/api/v1/events/batch", { events: events })
|
|
90
|
+
configuration.logger&.info("[ActiveRabbit] Batch sent successfully")
|
|
91
|
+
configuration.logger&.debug("[ActiveRabbit] Batch response: #{response.inspect}")
|
|
92
|
+
response
|
|
47
93
|
end
|
|
48
94
|
|
|
49
95
|
def test_connection
|
|
50
|
-
response = make_request(:post, "api/v1/test/connection", {
|
|
96
|
+
response = make_request(:post, "/api/v1/test/connection", {
|
|
51
97
|
gem_version: ActiveRabbit::Client::VERSION,
|
|
52
98
|
timestamp: Time.now.iso8601
|
|
53
99
|
})
|
|
@@ -59,13 +105,18 @@ module ActiveRabbit
|
|
|
59
105
|
def flush
|
|
60
106
|
return if @request_queue.empty?
|
|
61
107
|
|
|
108
|
+
configuration.logger&.info("[ActiveRabbit] Starting flush with #{@request_queue.length} items")
|
|
62
109
|
batch = @request_queue.shift(@request_queue.length)
|
|
63
110
|
return if batch.empty?
|
|
64
111
|
|
|
65
112
|
begin
|
|
66
|
-
|
|
113
|
+
configuration.logger&.info("[ActiveRabbit] Sending batch of #{batch.length} items")
|
|
114
|
+
response = post_batch(batch)
|
|
115
|
+
configuration.logger&.info("[ActiveRabbit] Batch sent successfully: #{response.inspect}")
|
|
116
|
+
response
|
|
67
117
|
rescue => e
|
|
68
118
|
configuration.logger&.error("[ActiveRabbit] Failed to send batch: #{e.message}")
|
|
119
|
+
configuration.logger&.error("[ActiveRabbit] Backtrace: #{e.backtrace&.first(3)}")
|
|
69
120
|
raise APIError, "Failed to send batch: #{e.message}"
|
|
70
121
|
end
|
|
71
122
|
end
|
|
@@ -81,13 +132,19 @@ module ActiveRabbit
|
|
|
81
132
|
def enqueue_request(method, path, data)
|
|
82
133
|
return if @shutdown
|
|
83
134
|
|
|
84
|
-
|
|
135
|
+
# Format data for batch processing
|
|
136
|
+
formatted_data = {
|
|
85
137
|
method: method,
|
|
86
138
|
path: path,
|
|
87
139
|
data: data,
|
|
88
140
|
timestamp: Time.now.to_f
|
|
89
141
|
}
|
|
90
142
|
|
|
143
|
+
configuration.logger&.info("[ActiveRabbit] Enqueueing request: #{method} #{path}")
|
|
144
|
+
configuration.logger&.debug("[ActiveRabbit] Request data: #{data.inspect}")
|
|
145
|
+
|
|
146
|
+
@request_queue << formatted_data
|
|
147
|
+
|
|
91
148
|
# Start batch timer if not already running
|
|
92
149
|
start_batch_timer if @batch_timer.nil? || @batch_timer.shutdown?
|
|
93
150
|
|
|
@@ -107,7 +164,14 @@ module ActiveRabbit
|
|
|
107
164
|
end
|
|
108
165
|
|
|
109
166
|
def make_request(method, path, data)
|
|
110
|
-
|
|
167
|
+
# Always rebuild base from current configuration to respect runtime changes
|
|
168
|
+
current_base = URI(configuration.api_url)
|
|
169
|
+
# Ensure path starts with a single leading slash
|
|
170
|
+
normalized_path = path.start_with?("/") ? path : "/#{path}"
|
|
171
|
+
uri = URI.join(current_base, normalized_path)
|
|
172
|
+
configuration.logger&.info("[ActiveRabbit] Making request: #{method.upcase} #{uri}")
|
|
173
|
+
configuration.logger&.debug("[ActiveRabbit] Request headers: X-Project-Token=#{configuration.api_key}, X-Project-ID=#{configuration.project_id}")
|
|
174
|
+
configuration.logger&.debug("[ActiveRabbit] Request body: #{safe_preview(data)}")
|
|
111
175
|
|
|
112
176
|
# Retry logic with exponential backoff
|
|
113
177
|
retries = 0
|
|
@@ -115,10 +179,17 @@ module ActiveRabbit
|
|
|
115
179
|
|
|
116
180
|
begin
|
|
117
181
|
response = perform_request(uri, method, data)
|
|
118
|
-
|
|
182
|
+
configuration.logger&.info("[ActiveRabbit] Response status: #{response.code}")
|
|
183
|
+
configuration.logger&.debug("[ActiveRabbit] Response headers: #{response.to_hash.inspect}")
|
|
184
|
+
configuration.logger&.debug("[ActiveRabbit] Response body: #{response.body}")
|
|
185
|
+
|
|
186
|
+
result = handle_response(response)
|
|
187
|
+
configuration.logger&.debug("[ActiveRabbit] Parsed response: #{result.inspect}")
|
|
188
|
+
result
|
|
119
189
|
rescue RetryableError => e
|
|
120
190
|
if retries < max_retries
|
|
121
191
|
retries += 1
|
|
192
|
+
configuration.logger&.info("[ActiveRabbit] Retrying request (#{retries}/#{max_retries})")
|
|
122
193
|
sleep(configuration.retry_delay * (2 ** (retries - 1)))
|
|
123
194
|
retry
|
|
124
195
|
end
|
|
@@ -126,6 +197,7 @@ module ActiveRabbit
|
|
|
126
197
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
127
198
|
if retries < max_retries && should_retry_error?(e)
|
|
128
199
|
retries += 1
|
|
200
|
+
configuration.logger&.info("[ActiveRabbit] Retrying request after timeout (#{retries}/#{max_retries})")
|
|
129
201
|
sleep(configuration.retry_delay * (2 ** (retries - 1))) # Exponential backoff
|
|
130
202
|
retry
|
|
131
203
|
end
|
|
@@ -133,19 +205,24 @@ module ActiveRabbit
|
|
|
133
205
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
134
206
|
if retries < max_retries
|
|
135
207
|
retries += 1
|
|
208
|
+
configuration.logger&.info("[ActiveRabbit] Retrying request after connection error (#{retries}/#{max_retries})")
|
|
136
209
|
sleep(configuration.retry_delay * (2 ** (retries - 1)))
|
|
137
210
|
retry
|
|
138
211
|
end
|
|
139
212
|
raise APIError, "Connection failed after #{retries} retries: #{e.message}"
|
|
140
213
|
rescue APIError, RateLimitError => e
|
|
141
214
|
# Re-raise API errors as-is
|
|
215
|
+
configuration.logger&.error("[ActiveRabbit] API error: #{e.class}: #{e.message}")
|
|
142
216
|
raise e
|
|
143
217
|
rescue => e
|
|
218
|
+
configuration.logger&.error("[ActiveRabbit] Request failed: #{e.class}: #{e.message}")
|
|
219
|
+
configuration.logger&.error("[ActiveRabbit] Backtrace: #{e.backtrace&.first(3)}")
|
|
144
220
|
raise APIError, "Request failed: #{e.message}"
|
|
145
221
|
end
|
|
146
222
|
end
|
|
147
223
|
|
|
148
224
|
def perform_request(uri, method, data)
|
|
225
|
+
configuration.logger&.debug("[ActiveRabbit] Making HTTP request: #{method.upcase} #{uri}")
|
|
149
226
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
150
227
|
|
|
151
228
|
# Configure SSL if HTTPS
|
|
@@ -158,20 +235,22 @@ module ActiveRabbit
|
|
|
158
235
|
http.open_timeout = configuration.open_timeout
|
|
159
236
|
http.read_timeout = configuration.timeout
|
|
160
237
|
|
|
161
|
-
# Create request
|
|
238
|
+
# Create request with full path
|
|
162
239
|
request = case method.to_s.downcase
|
|
163
240
|
when 'post'
|
|
164
|
-
Net::HTTP::Post.new(uri.
|
|
241
|
+
Net::HTTP::Post.new(uri.request_uri)
|
|
165
242
|
when 'get'
|
|
166
|
-
Net::HTTP::Get.new(uri.
|
|
243
|
+
Net::HTTP::Get.new(uri.request_uri)
|
|
167
244
|
when 'put'
|
|
168
|
-
Net::HTTP::Put.new(uri.
|
|
245
|
+
Net::HTTP::Put.new(uri.request_uri)
|
|
169
246
|
when 'delete'
|
|
170
|
-
Net::HTTP::Delete.new(uri.
|
|
247
|
+
Net::HTTP::Delete.new(uri.request_uri)
|
|
171
248
|
else
|
|
172
249
|
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
173
250
|
end
|
|
174
251
|
|
|
252
|
+
configuration.logger&.debug("[ActiveRabbit] Request path: #{uri.request_uri}")
|
|
253
|
+
|
|
175
254
|
# Set headers
|
|
176
255
|
request['Content-Type'] = 'application/json'
|
|
177
256
|
request['Accept'] = 'application/json'
|
|
@@ -191,31 +270,44 @@ module ActiveRabbit
|
|
|
191
270
|
end
|
|
192
271
|
|
|
193
272
|
def handle_response(response)
|
|
273
|
+
configuration.logger&.debug("[ActiveRabbit] Response code: #{response.code}")
|
|
274
|
+
configuration.logger&.debug("[ActiveRabbit] Response body: #{response.body}")
|
|
275
|
+
|
|
194
276
|
case response.code.to_i
|
|
195
277
|
when 200..299
|
|
196
278
|
# Parse JSON response if present
|
|
197
279
|
if response.body && !response.body.empty?
|
|
198
280
|
begin
|
|
199
|
-
JSON.parse(response.body)
|
|
200
|
-
|
|
281
|
+
parsed = JSON.parse(response.body)
|
|
282
|
+
configuration.logger&.debug("[ActiveRabbit] Parsed response: #{parsed.inspect}")
|
|
283
|
+
parsed
|
|
284
|
+
rescue JSON::ParserError => e
|
|
285
|
+
configuration.logger&.error("[ActiveRabbit] Failed to parse response: #{e.message}")
|
|
286
|
+
configuration.logger&.error("[ActiveRabbit] Raw response: #{response.body}")
|
|
201
287
|
response.body
|
|
202
288
|
end
|
|
203
289
|
else
|
|
290
|
+
configuration.logger&.debug("[ActiveRabbit] Empty response body")
|
|
204
291
|
{}
|
|
205
292
|
end
|
|
206
293
|
when 429
|
|
294
|
+
configuration.logger&.error("[ActiveRabbit] Rate limit exceeded")
|
|
207
295
|
raise RateLimitError, "Rate limit exceeded"
|
|
208
296
|
when 400..499
|
|
209
297
|
error_message = extract_error_message(response)
|
|
298
|
+
configuration.logger&.error("[ActiveRabbit] Client error (#{response.code}): #{error_message}")
|
|
210
299
|
raise APIError, "Client error (#{response.code}): #{error_message}"
|
|
211
300
|
when 500..599
|
|
212
301
|
error_message = extract_error_message(response)
|
|
213
302
|
if should_retry_status?(response.code.to_i)
|
|
303
|
+
configuration.logger&.warn("[ActiveRabbit] Retryable server error (#{response.code}): #{error_message}")
|
|
214
304
|
raise RetryableError, "Server error (#{response.code}): #{error_message}"
|
|
215
305
|
else
|
|
306
|
+
configuration.logger&.error("[ActiveRabbit] Server error (#{response.code}): #{error_message}")
|
|
216
307
|
raise APIError, "Server error (#{response.code}): #{error_message}"
|
|
217
308
|
end
|
|
218
309
|
else
|
|
310
|
+
configuration.logger&.error("[ActiveRabbit] Unexpected response code: #{response.code}")
|
|
219
311
|
raise APIError, "Unexpected response code: #{response.code}"
|
|
220
312
|
end
|
|
221
313
|
end
|
|
@@ -244,6 +336,32 @@ module ActiveRabbit
|
|
|
244
336
|
# Retry on server errors and rate limits
|
|
245
337
|
[429, 500, 502, 503, 504].include?(status_code)
|
|
246
338
|
end
|
|
339
|
+
|
|
340
|
+
def stringify_and_sanitize(obj, depth: 0)
|
|
341
|
+
return nil if obj.nil?
|
|
342
|
+
return obj if obj.is_a?(Numeric) || obj.is_a?(TrueClass) || obj.is_a?(FalseClass)
|
|
343
|
+
return obj.to_s if obj.is_a?(Symbol) || obj.is_a?(Time) || obj.is_a?(Date) || obj.is_a?(URI) || obj.is_a?(Exception)
|
|
344
|
+
|
|
345
|
+
if obj.is_a?(Hash)
|
|
346
|
+
return obj.each_with_object({}) do |(k,v), h|
|
|
347
|
+
# limit depth to avoid accidental deep object graphs
|
|
348
|
+
h[k.to_s] = depth > 5 ? v.to_s : stringify_and_sanitize(v, depth: depth + 1)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
if obj.is_a?(Array)
|
|
353
|
+
return obj.first(200).map { |v| depth > 5 ? v.to_s : stringify_and_sanitize(v, depth: depth + 1) }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Fallback: best-effort string
|
|
357
|
+
obj.to_s
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def safe_preview(obj)
|
|
361
|
+
# keep logs readable and safe
|
|
362
|
+
s = obj.inspect
|
|
363
|
+
s.length > 2000 ? s[0,2000] + "...(truncated)" : s
|
|
364
|
+
end
|
|
247
365
|
end
|
|
248
366
|
|
|
249
367
|
end
|