activerabbit-ai 0.1.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.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRabbit
4
+ module Client
5
+ class PiiScrubber
6
+ SCRUBBED_VALUE = "[FILTERED]"
7
+
8
+ attr_reader :configuration
9
+
10
+ def initialize(configuration)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def scrub(data)
15
+ case data
16
+ when Hash
17
+ scrub_hash(data)
18
+ when Array
19
+ scrub_array(data)
20
+ when String
21
+ scrub_string(data)
22
+ else
23
+ data
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def scrub_hash(hash)
30
+ return hash unless hash.is_a?(Hash)
31
+
32
+ hash.each_with_object({}) do |(key, value), scrubbed|
33
+ if should_scrub_key?(key)
34
+ scrubbed[key] = SCRUBBED_VALUE
35
+ else
36
+ scrubbed[key] = scrub(value)
37
+ end
38
+ end
39
+ end
40
+
41
+ def scrub_array(array)
42
+ return array unless array.is_a?(Array)
43
+
44
+ array.map { |item| scrub(item) }
45
+ end
46
+
47
+ def scrub_string(string)
48
+ return string unless string.is_a?(String)
49
+
50
+ scrubbed = string.dup
51
+
52
+ # Scrub common PII patterns
53
+ scrubbed = scrub_email_addresses(scrubbed)
54
+ scrubbed = scrub_phone_numbers(scrubbed)
55
+ scrubbed = scrub_credit_cards(scrubbed)
56
+ scrubbed = scrub_social_security_numbers(scrubbed)
57
+ scrubbed = scrub_ip_addresses(scrubbed)
58
+
59
+ scrubbed
60
+ end
61
+
62
+ def should_scrub_key?(key)
63
+ return false unless key
64
+
65
+ key_str = key.to_s.downcase
66
+
67
+ configuration.pii_fields.any? do |pii_field|
68
+ case pii_field
69
+ when String
70
+ key_str.include?(pii_field.downcase)
71
+ when Regexp
72
+ key_str =~ pii_field
73
+ else
74
+ false
75
+ end
76
+ end
77
+ end
78
+
79
+ def scrub_email_addresses(string)
80
+ # Match email addresses
81
+ string.gsub(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, SCRUBBED_VALUE)
82
+ end
83
+
84
+ def scrub_phone_numbers(string)
85
+ # Match various phone number formats
86
+ patterns = [
87
+ /\(\d{3}\)\s?\d{3}[-.]?\d{4}/, # (123) 456-7890, (123)456-7890
88
+ /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, # 123-456-7890, 123.456.7890, 1234567890
89
+ /\b\+1[-.\s]?\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/, # +1-123-456-7890
90
+ /\b\d{3}\s\d{3}\s\d{4}\b/ # 123 456 7890
91
+ ]
92
+
93
+ patterns.reduce(string) do |str, pattern|
94
+ str.gsub(pattern, SCRUBBED_VALUE)
95
+ end
96
+ end
97
+
98
+ def scrub_credit_cards(string)
99
+ # Match credit card patterns - be more permissive for testing
100
+ patterns = [
101
+ /\b\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}\b/, # 1234-5678-9012-3456
102
+ /\b\d{13,19}\b/ # 13-19 consecutive digits
103
+ ]
104
+
105
+ patterns.reduce(string) do |str, pattern|
106
+ str.gsub(pattern) do |match|
107
+ digits = match.gsub(/\D/, '')
108
+ # Only scrub if it looks like a credit card (passes basic Luhn check)
109
+ if digits.length >= 13 && digits.length <= 19 && luhn_valid?(digits)
110
+ SCRUBBED_VALUE
111
+ else
112
+ match
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ def scrub_social_security_numbers(string)
119
+ # Match SSN patterns
120
+ patterns = [
121
+ /\b\d{3}[-.\s]?\d{2}[-.\s]?\d{4}\b/, # 123-45-6789, 123.45.6789, 123 45 6789
122
+ /\b\d{9}\b/ # 123456789 (9 consecutive digits)
123
+ ]
124
+
125
+ patterns.reduce(string) do |str, pattern|
126
+ str.gsub(pattern) do |match|
127
+ # Only scrub 9-digit sequences that look like SSNs
128
+ digits = match.gsub(/\D/, '')
129
+ if digits.length == 9 && !digits.match(/^0{9}$|^1{9}$|^2{9}$|^3{9}$|^4{9}$|^5{9}$|^6{9}$|^7{9}$|^8{9}$|^9{9}$/)
130
+ SCRUBBED_VALUE
131
+ else
132
+ match
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ def scrub_ip_addresses(string)
139
+ # Match IPv4 addresses
140
+ string.gsub(/\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/) do |match|
141
+ # Only scrub if it's a valid IP address
142
+ octets = match.split('.')
143
+ if octets.all? { |octet| octet.to_i <= 255 }
144
+ # Keep first octet for debugging purposes
145
+ "#{octets.first}.xxx.xxx.xxx"
146
+ else
147
+ match
148
+ end
149
+ end
150
+ end
151
+
152
+ def luhn_valid?(number)
153
+ # Basic Luhn algorithm check for credit card validation
154
+ digits = number.reverse.chars.map(&:to_i)
155
+
156
+ sum = digits.each_with_index.sum do |digit, index|
157
+ if index.odd?
158
+ doubled = digit * 2
159
+ doubled > 9 ? doubled - 9 : doubled
160
+ else
161
+ digit
162
+ end
163
+ end
164
+
165
+ sum % 10 == 0
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "securerandom"
5
+
6
+ module ActiveRabbit
7
+ module Client
8
+ class Railtie < Rails::Railtie
9
+ config.active_rabbit = ActiveSupport::OrderedOptions.new
10
+
11
+ initializer "active_rabbit.configure" do |app|
12
+ # Configure ActiveRabbit from Rails configuration
13
+ ActiveRabbit::Client.configure do |config|
14
+ config.environment = Rails.env
15
+ config.logger = Rails.logger
16
+ config.release = detect_release(app)
17
+ end
18
+
19
+ # Set up exception tracking
20
+ setup_exception_tracking(app) if ActiveRabbit::Client.configured?
21
+ end
22
+
23
+ initializer "active_rabbit.subscribe_to_notifications" do
24
+ next unless ActiveRabbit::Client.configured?
25
+
26
+ # Subscribe to Action Controller events
27
+ subscribe_to_controller_events
28
+
29
+ # Subscribe to Active Record events
30
+ subscribe_to_active_record_events
31
+
32
+ # Subscribe to Action View events
33
+ subscribe_to_action_view_events
34
+
35
+ # Subscribe to Action Mailer events (if available)
36
+ subscribe_to_action_mailer_events if defined?(ActionMailer)
37
+
38
+ # Subscribe to exception notifications
39
+ subscribe_to_exception_notifications
40
+ end
41
+
42
+ initializer "active_rabbit.add_middleware" do |app|
43
+ next unless ActiveRabbit::Client.configured?
44
+
45
+ # Add request context middleware
46
+ app.middleware.insert_before ActionDispatch::ShowExceptions, RequestContextMiddleware
47
+
48
+ # Add exception catching middleware
49
+ app.middleware.insert_before ActionDispatch::ShowExceptions, ExceptionMiddleware
50
+ end
51
+
52
+ private
53
+
54
+ def setup_exception_tracking(app)
55
+ # Handle uncaught exceptions in development
56
+ if Rails.env.development? || Rails.env.test?
57
+ app.config.consider_all_requests_local = false if Rails.env.test?
58
+ end
59
+ end
60
+
61
+ def subscribe_to_controller_events
62
+ ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, started, finished, unique_id, payload|
63
+ begin
64
+ duration_ms = ((finished - started) * 1000).round(2)
65
+
66
+ ActiveRabbit::Client.track_performance(
67
+ "controller.action",
68
+ duration_ms,
69
+ metadata: {
70
+ controller: payload[:controller],
71
+ action: payload[:action],
72
+ format: payload[:format],
73
+ method: payload[:method],
74
+ path: payload[:path],
75
+ status: payload[:status],
76
+ view_runtime: payload[:view_runtime],
77
+ db_runtime: payload[:db_runtime]
78
+ }
79
+ )
80
+
81
+ # Track slow requests
82
+ if duration_ms > 1000 # Slower than 1 second
83
+ ActiveRabbit::Client.track_event(
84
+ "slow_request",
85
+ {
86
+ controller: payload[:controller],
87
+ action: payload[:action],
88
+ duration_ms: duration_ms,
89
+ method: payload[:method],
90
+ path: payload[:path]
91
+ }
92
+ )
93
+ end
94
+ rescue => e
95
+ Rails.logger.error "[ActiveRabbit] Error tracking controller action: #{e.message}"
96
+ end
97
+ end
98
+ end
99
+
100
+ def subscribe_to_active_record_events
101
+ # Track database queries for N+1 detection
102
+ ActiveSupport::Notifications.subscribe "sql.active_record" do |name, started, finished, unique_id, payload|
103
+ begin
104
+ next if payload[:name] == "SCHEMA" || payload[:name] == "CACHE"
105
+
106
+ duration_ms = ((finished - started) * 1000).round(2)
107
+
108
+ # Track query for N+1 detection
109
+ if ActiveRabbit::Client.configuration.enable_n_plus_one_detection
110
+ n_plus_one_detector.track_query(
111
+ payload[:sql],
112
+ payload[:bindings],
113
+ payload[:name],
114
+ duration_ms
115
+ )
116
+ end
117
+
118
+ # Track slow queries
119
+ if duration_ms > 100 # Slower than 100ms
120
+ ActiveRabbit::Client.track_event(
121
+ "slow_query",
122
+ {
123
+ sql: payload[:sql],
124
+ duration_ms: duration_ms,
125
+ name: payload[:name]
126
+ }
127
+ )
128
+ end
129
+ rescue => e
130
+ Rails.logger.error "[ActiveRabbit] Error tracking SQL query: #{e.message}"
131
+ end
132
+ end
133
+ end
134
+
135
+ def subscribe_to_action_view_events
136
+ ActiveSupport::Notifications.subscribe "render_template.action_view" do |name, started, finished, unique_id, payload|
137
+ begin
138
+ duration_ms = ((finished - started) * 1000).round(2)
139
+
140
+ # Track slow template renders
141
+ if duration_ms > 50 # Slower than 50ms
142
+ ActiveRabbit::Client.track_event(
143
+ "slow_template_render",
144
+ {
145
+ template: payload[:identifier],
146
+ duration_ms: duration_ms,
147
+ layout: payload[:layout]
148
+ }
149
+ )
150
+ end
151
+ rescue => e
152
+ Rails.logger.error "[ActiveRabbit] Error tracking template render: #{e.message}"
153
+ end
154
+ end
155
+ end
156
+
157
+ def subscribe_to_action_mailer_events
158
+ ActiveSupport::Notifications.subscribe "deliver.action_mailer" do |name, started, finished, unique_id, payload|
159
+ begin
160
+ duration_ms = ((finished - started) * 1000).round(2)
161
+
162
+ ActiveRabbit::Client.track_event(
163
+ "email_sent",
164
+ {
165
+ mailer: payload[:mailer],
166
+ action: payload[:action],
167
+ duration_ms: duration_ms
168
+ }
169
+ )
170
+ rescue => e
171
+ Rails.logger.error "[ActiveRabbit] Error tracking email delivery: #{e.message}"
172
+ end
173
+ end
174
+ end
175
+
176
+ def subscribe_to_exception_notifications
177
+ # Subscribe to Rails exception notifications
178
+ ActiveSupport::Notifications.subscribe "process_action.action_controller" do |name, started, finished, unique_id, data|
179
+ next unless ActiveRabbit::Client.configured?
180
+ next unless data[:exception]
181
+
182
+ exception_class, exception_message = data[:exception]
183
+
184
+ puts "[ActiveRabbit] Exception notification received: #{exception_class}: #{exception_message}"
185
+ puts "[ActiveRabbit] Available data keys: #{data.keys.inspect}"
186
+
187
+ # Create exception with proper backtrace
188
+ exception = exception_class.constantize.new(exception_message)
189
+
190
+ # Try to get backtrace from the original exception if available
191
+ if data[:exception_object]
192
+ exception.set_backtrace(data[:exception_object].backtrace)
193
+ end
194
+
195
+ ActiveRabbit::Client.track_exception(
196
+ exception,
197
+ context: {
198
+ request: {
199
+ method: data[:method],
200
+ path: data[:path],
201
+ controller: data[:controller],
202
+ action: data[:action],
203
+ status: data[:status],
204
+ format: data[:format]
205
+ }
206
+ }
207
+ )
208
+ end
209
+ end
210
+
211
+ def detect_release(app)
212
+ # Try to detect release from various sources
213
+ ENV["HEROKU_SLUG_COMMIT"] ||
214
+ ENV["GITHUB_SHA"] ||
215
+ ENV["GITLAB_COMMIT_SHA"] ||
216
+ app.config.active_rabbit.release ||
217
+ detect_git_sha
218
+ end
219
+
220
+ def detect_git_sha
221
+ return unless Rails.root.join(".git").directory?
222
+
223
+ `git rev-parse HEAD 2>/dev/null`.chomp
224
+ rescue
225
+ nil
226
+ end
227
+
228
+ def n_plus_one_detector
229
+ @n_plus_one_detector ||= NPlusOneDetector.new(ActiveRabbit::Client.configuration)
230
+ end
231
+ end
232
+
233
+ # Middleware for adding request context
234
+ class RequestContextMiddleware
235
+ def initialize(app)
236
+ @app = app
237
+ end
238
+
239
+ def call(env)
240
+ request = ActionDispatch::Request.new(env)
241
+
242
+ # Skip certain requests
243
+ return @app.call(env) if should_skip_request?(request)
244
+
245
+ # Set request context
246
+ request_context = build_request_context(request)
247
+ Thread.current[:active_rabbit_request_context] = request_context
248
+
249
+ # Start N+1 detection for this request
250
+ request_id = SecureRandom.uuid
251
+ n_plus_one_detector.start_request(request_id)
252
+
253
+ begin
254
+ @app.call(env)
255
+ ensure
256
+ # Clean up request context
257
+ Thread.current[:active_rabbit_request_context] = nil
258
+ n_plus_one_detector.finish_request(request_id)
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ def should_skip_request?(request)
265
+ # Skip requests from ignored user agents
266
+ user_agent = request.headers["User-Agent"]
267
+ return true if ActiveRabbit::Client.configuration.should_ignore_user_agent?(user_agent)
268
+
269
+ # Skip asset requests
270
+ return true if request.path.start_with?("/assets/")
271
+
272
+ # Skip health checks
273
+ return true if request.path.match?(/\/(health|ping|status)/)
274
+
275
+ false
276
+ end
277
+
278
+ def build_request_context(request)
279
+ {
280
+ method: request.method,
281
+ path: request.path,
282
+ query_string: request.query_string,
283
+ user_agent: request.headers["User-Agent"],
284
+ ip_address: request.remote_ip,
285
+ referer: request.referer,
286
+ request_id: request.headers["X-Request-ID"] || SecureRandom.uuid
287
+ }
288
+ end
289
+
290
+ def n_plus_one_detector
291
+ @n_plus_one_detector ||= NPlusOneDetector.new(ActiveRabbit::Client.configuration)
292
+ end
293
+ end
294
+
295
+ # Middleware for catching unhandled exceptions
296
+ class ExceptionMiddleware
297
+ def initialize(app)
298
+ @app = app
299
+ end
300
+
301
+ def call(env)
302
+ puts "[ActiveRabbit] ExceptionMiddleware called for: #{env['REQUEST_METHOD']} #{env['PATH_INFO']}"
303
+ @app.call(env)
304
+ rescue Exception => exception
305
+ # Track the exception
306
+ puts "[ActiveRabbit] ExceptionMiddleware caught: #{exception.class}: #{exception.message}"
307
+ request = ActionDispatch::Request.new(env)
308
+
309
+ ActiveRabbit::Client.track_exception(
310
+ exception,
311
+ context: {
312
+ request: {
313
+ method: request.method,
314
+ path: request.path,
315
+ query_string: request.query_string,
316
+ user_agent: request.headers["User-Agent"],
317
+ ip_address: request.remote_ip,
318
+ referer: request.referer
319
+ }
320
+ }
321
+ )
322
+
323
+ # Re-raise the exception so Rails can handle it normally
324
+ raise exception
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRabbit
4
+ module Client
5
+ class SidekiqMiddleware
6
+ def call(worker, job, queue)
7
+ start_time = Time.now
8
+ job_context = build_job_context(worker, job, queue)
9
+
10
+ # Set job context for the duration of the job
11
+ Thread.current[:active_rabbit_job_context] = job_context
12
+
13
+ begin
14
+ result = yield
15
+
16
+ # Track successful job completion
17
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
18
+ track_job_performance(worker, job, queue, duration_ms, "completed")
19
+
20
+ result
21
+ rescue Exception => exception
22
+ # Track job failure
23
+ duration_ms = ((Time.now - start_time) * 1000).round(2)
24
+ track_job_performance(worker, job, queue, duration_ms, "failed")
25
+ track_job_exception(exception, worker, job, queue)
26
+
27
+ # Re-raise the exception so Sidekiq can handle it
28
+ raise exception
29
+ ensure
30
+ # Clean up job context
31
+ Thread.current[:active_rabbit_job_context] = nil
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def build_job_context(worker, job, queue)
38
+ {
39
+ worker_class: worker.class.name,
40
+ job_id: job["jid"],
41
+ queue: queue,
42
+ args: scrub_job_args(job["args"]),
43
+ retry_count: job["retry_count"] || 0,
44
+ enqueued_at: job["enqueued_at"] ? Time.at(job["enqueued_at"]) : nil,
45
+ created_at: job["created_at"] ? Time.at(job["created_at"]) : nil
46
+ }
47
+ end
48
+
49
+ def track_job_performance(worker, job, queue, duration_ms, status)
50
+ return unless ActiveRabbit::Client.configured?
51
+
52
+ ActiveRabbit::Client.track_performance(
53
+ "sidekiq.job",
54
+ duration_ms,
55
+ metadata: {
56
+ worker_class: worker.class.name,
57
+ queue: queue,
58
+ status: status,
59
+ job_id: job["jid"],
60
+ retry_count: job["retry_count"] || 0,
61
+ args_count: job["args"]&.size || 0
62
+ }
63
+ )
64
+
65
+ # Track slow jobs
66
+ if duration_ms > 30_000 # Slower than 30 seconds
67
+ ActiveRabbit::Client.track_event(
68
+ "slow_sidekiq_job",
69
+ {
70
+ worker_class: worker.class.name,
71
+ queue: queue,
72
+ duration_ms: duration_ms,
73
+ job_id: job["jid"]
74
+ }
75
+ )
76
+ end
77
+
78
+ # Track job completion event
79
+ ActiveRabbit::Client.track_event(
80
+ "sidekiq_job_#{status}",
81
+ {
82
+ worker_class: worker.class.name,
83
+ queue: queue,
84
+ duration_ms: duration_ms,
85
+ retry_count: job["retry_count"] || 0
86
+ }
87
+ )
88
+ end
89
+
90
+ def track_job_exception(exception, worker, job, queue)
91
+ return unless ActiveRabbit::Client.configured?
92
+
93
+ ActiveRabbit::Client.track_exception(
94
+ exception,
95
+ context: {
96
+ job: {
97
+ worker_class: worker.class.name,
98
+ queue: queue,
99
+ job_id: job["jid"],
100
+ args: scrub_job_args(job["args"]),
101
+ retry_count: job["retry_count"] || 0,
102
+ enqueued_at: job["enqueued_at"] ? Time.at(job["enqueued_at"]) : nil
103
+ }
104
+ },
105
+ tags: {
106
+ component: "sidekiq",
107
+ queue: queue,
108
+ worker: worker.class.name
109
+ }
110
+ )
111
+ end
112
+
113
+ def scrub_job_args(args)
114
+ return args unless ActiveRabbit::Client.configuration.enable_pii_scrubbing
115
+ return args unless args.is_a?(Array)
116
+
117
+ PiiScrubber.new(ActiveRabbit::Client.configuration).scrub(args)
118
+ end
119
+ end
120
+
121
+ # Auto-register the middleware if Sidekiq is available
122
+ if defined?(Sidekiq)
123
+ Sidekiq.configure_server do |config|
124
+ config.server_middleware do |chain|
125
+ chain.add ActiveRabbit::Client::SidekiqMiddleware
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRabbit
4
+ module Client
5
+ VERSION = "0.1.0"
6
+ end
7
+ end