railswatch_gem 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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class JobsInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Subscribe to the execution event.
15
+ # "enqueue.active_job" is also available if you want to track queue depth/latency separately.
16
+ ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
17
+ event = ActiveSupport::Notifications::Event.new(*args)
18
+ process_event(event)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def process_event(event)
25
+ payload = event.payload
26
+ job = payload[:job]
27
+
28
+ # Guard against malformed payloads (though rare in ActiveJob)
29
+ return unless job
30
+
31
+ data = {
32
+ event_type: "job",
33
+ # FIX: Handle Float timestamps safely
34
+ timestamp: Time.at(event.end).utc.iso8601,
35
+
36
+ # Job Identity
37
+ job_class: job.class.name,
38
+ job_id: job.job_id,
39
+ queue_name: job.queue_name,
40
+ priority: job.priority,
41
+
42
+ # Execution Context
43
+ adapter: payload[:adapter]&.class&.name,
44
+ executions: job.executions,
45
+
46
+ # Performance
47
+ duration_ms: event.duration.round(2)
48
+ }
49
+
50
+ # Calculate Queue Latency: Time spent waiting in the queue
51
+ if job.enqueued_at
52
+ enqueued_time = Time.iso8601(job.enqueued_at) rescue nil
53
+ if enqueued_time
54
+ # Start of processing - Time enqueued
55
+ # FIX: Convert event.time (Float) to Time before subtraction
56
+ latency = (Time.at(event.time) - enqueued_time) * 1000
57
+ data[:queue_latency_ms] = latency.round(2)
58
+ end
59
+ end
60
+
61
+ # Error handling
62
+ if payload[:exception]
63
+ # payload[:exception] is [ClassName, Message]
64
+ data[:error_class] = payload[:exception].first.to_s
65
+ data[:error_message] = payload[:exception].last.to_s
66
+ end
67
+
68
+ @client.record(data)
69
+ rescue => e
70
+ warn "RailswatchGem: Failed to process job event: #{e.message}"
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class LogsInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Only attach if Rails logger is available
15
+ return unless defined?(::Rails) && ::Rails.logger
16
+
17
+ target_logger = ::Rails.logger
18
+
19
+ # Avoid double patching if called multiple times
20
+ return if target_logger.respond_to?(:_railswatch_instrumented?)
21
+
22
+ # Create the interception module bound to our client
23
+ interceptor = create_interceptor(@client)
24
+
25
+ # Extend the specific logger instance.
26
+ # This inserts the module into the object's singleton class inheritance chain,
27
+ # effectively wrapping the 'add' method.
28
+ target_logger.extend(interceptor)
29
+ end
30
+
31
+ private
32
+
33
+ def create_interceptor(client)
34
+ Module.new do
35
+ # FIX: We use define_method here to create a closure over the 'client' variable.
36
+ # Standard 'def' methods create a new scope and cannot access outer local variables.
37
+ # We expose client as a private method so 'add' can access it.
38
+ define_method(:_railswatch_client) { client }
39
+ private :_railswatch_client
40
+
41
+ # The primary entry point for Ruby Logger
42
+ def add(severity, message = nil, progname = nil, &block)
43
+ # PERFORMANCE: Check level first.
44
+ if respond_to?(:level) && severity < level
45
+ return super
46
+ end
47
+
48
+ begin
49
+ # Resolve the message content.
50
+ log_message = message
51
+ if log_message.nil?
52
+ if block_given?
53
+ log_message = yield
54
+ else
55
+ log_message = progname
56
+ end
57
+ end
58
+
59
+ # INFINITE LOOP PROTECTION
60
+ unless log_message.to_s.include?("[Railswatch]")
61
+
62
+ request_id = Thread.current[:railswatch_request_id]
63
+
64
+ # FIX: Access client via the closure-captured helper method
65
+ _railswatch_client.record({
66
+ event_type: "log",
67
+ timestamp: Time.now.utc.iso8601,
68
+ severity: format_severity_level(severity),
69
+ message: log_message.to_s,
70
+ progname: progname,
71
+ request_id: request_id
72
+ })
73
+ end
74
+
75
+ super(severity, log_message, nil)
76
+ rescue => e
77
+ # Fallback: If our instrumentation fails, ensure the app's logging continues.
78
+ $stderr.puts "RailswatchGem: LogsInstrumenter error: #{e.message}"
79
+ super
80
+ end
81
+ end
82
+
83
+ # Marker method to prevent double-patching
84
+ def _railswatch_instrumented?
85
+ true
86
+ end
87
+
88
+ private
89
+
90
+ def format_severity_level(severity)
91
+ case severity
92
+ when 0, ::Logger::DEBUG then "DEBUG"
93
+ when 1, ::Logger::INFO then "INFO"
94
+ when 2, ::Logger::WARN then "WARN"
95
+ when 3, ::Logger::ERROR then "ERROR"
96
+ when 4, ::Logger::FATAL then "FATAL"
97
+ else "UNKNOWN"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class MailInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Subscribe to the delivery event.
15
+ # "process.action_mailer" is also available if you want to track template rendering time separately.
16
+ ActiveSupport::Notifications.subscribe("deliver.action_mailer") do |*args|
17
+ event = ActiveSupport::Notifications::Event.new(*args)
18
+ process_event(event)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def process_event(event)
25
+ payload = event.payload
26
+
27
+ # Payload keys depend on Rails version, but generally include:
28
+ # :mailer, :message_id, :subject, :to, :from, :bcc, :cc, :date
29
+
30
+ # Calculate recipient count for metrics
31
+ to_count = Array(payload[:to]).size
32
+ cc_count = Array(payload[:cc]).size
33
+ bcc_count = Array(payload[:bcc]).size
34
+ total_recipients = to_count + cc_count + bcc_count
35
+
36
+ data = {
37
+ event_type: "mail",
38
+ # FIX: Handle Float timestamps safely
39
+ timestamp: Time.at(event.end).utc.iso8601,
40
+
41
+ # Identity
42
+ mailer: payload[:mailer], # The class name of the mailer
43
+ message_id: payload[:message_id],
44
+
45
+ # Content (Be careful with PII here)
46
+ subject: payload[:subject],
47
+ from: Array(payload[:from]).join(", "),
48
+ to: Array(payload[:to]).join(", "),
49
+
50
+ # Metrics
51
+ recipient_count: total_recipients,
52
+ duration_ms: event.duration.round(2)
53
+ }
54
+
55
+ # Error handling
56
+ if payload[:exception]
57
+ data[:error_class] = payload[:exception].first.to_s
58
+ data[:error_message] = payload[:exception].last.to_s
59
+ end
60
+
61
+ @client.record(data)
62
+ rescue => e
63
+ warn "RailswatchGem: Failed to process mail event: #{e.message}"
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailswatchGem
4
+ module Instrumentation
5
+ class ModelsInstrumenter
6
+ def initialize(client, config)
7
+ @client = client
8
+ @config = config
9
+ end
10
+
11
+ def start
12
+ return unless defined?(::ActiveRecord::Base)
13
+
14
+ # Inject our tracking module into ActiveRecord
15
+ ::ActiveRecord::Base.include(Tracker)
16
+ end
17
+
18
+ module Tracker
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ # We use after_commit to ensure we only report data that actually persisted.
23
+ # We pass 'self' to the callbacks.
24
+ after_commit :_railswatch_handle_create, on: :create
25
+ after_commit :_railswatch_handle_update, on: :update
26
+ after_commit :_railswatch_handle_destroy, on: :destroy
27
+ end
28
+
29
+ def _railswatch_handle_create
30
+ _railswatch_record_event("model_create")
31
+ end
32
+
33
+ def _railswatch_handle_update
34
+ _railswatch_record_event("model_update")
35
+ end
36
+
37
+ def _railswatch_handle_destroy
38
+ _railswatch_record_event("model_destroy")
39
+ end
40
+
41
+ private
42
+
43
+ def _railswatch_record_event(type)
44
+ # Guard: Avoid tracking internal Rails models or strict excludes
45
+ return if self.class.name.start_with?("ActiveRecord::", "RailswatchGem::")
46
+
47
+ # Retrieve Request ID if available for correlation
48
+ request_id = Thread.current[:railswatch_request_id]
49
+
50
+ # Calculate changes.
51
+ # Note: In after_commit, previous_changes is needed because changes is empty.
52
+ changes_hash = previous_changes.dup
53
+
54
+ # Filter sensitive attributes (naive approach, can be improved with Rails.application.config.filter_parameters)
55
+ _railswatch_filter_attributes!(changes_hash)
56
+
57
+ data = {
58
+ event_type: "model",
59
+ action: type, # model_create, etc.
60
+ timestamp: Time.now.utc.iso8601,
61
+
62
+ # Model Identity
63
+ model: self.class.name,
64
+ key: self.id,
65
+
66
+ # The actual data changed
67
+ changes: changes_hash,
68
+
69
+ # Correlation
70
+ request_id: request_id
71
+ }
72
+
73
+ RailswatchGem.record(data)
74
+ rescue => e
75
+ # Swallow errors to prevent model callbacks from aborting transactions
76
+ warn "RailswatchGem: Failed to record model event: #{e.message}"
77
+ end
78
+
79
+ def _railswatch_filter_attributes!(hash)
80
+ # Basic filtration of common sensitive fields
81
+ sensitive = %w[password password_digest token secret credit_card cc_number]
82
+ hash.each_key do |key|
83
+ if sensitive.any? { |s| key.to_s.include?(s) }
84
+ hash[key] = ["[FILTERED]", "[FILTERED]"]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class NotificationsInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # This instrumenter allows users to track arbitrary ActiveSupport::Notifications.
15
+ # It relies on the user defining 'tracked_notifications' in the configuration.
16
+ #
17
+ # Example Config usage:
18
+ # config.tracked_notifications = ["billing.checkout", /users\..*/]
19
+
20
+ patterns = if @config.respond_to?(:tracked_notifications)
21
+ @config.tracked_notifications
22
+ else
23
+ []
24
+ end
25
+
26
+ # FIX: Use .map instead of .each to return the array of subscribers.
27
+ # This allows tests (and apps) to keep track of subscriptions and unsubscribe if needed.
28
+ Array(patterns).map do |pattern|
29
+ ActiveSupport::Notifications.subscribe(pattern) do |*args|
30
+ event = ActiveSupport::Notifications::Event.new(*args)
31
+ process_event(event)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def process_event(event)
39
+ # Prevent infinite loops if the user accidentally subscribes to our own internal events
40
+ return if event.name.start_with?("railswatch_gem")
41
+
42
+ payload = event.payload
43
+
44
+ data = {
45
+ event_type: "notification",
46
+ name: event.name,
47
+ # FIX: Handle Float timestamps safely
48
+ timestamp: Time.at(event.end).utc.iso8601,
49
+ duration_ms: event.duration.round(2)
50
+ }
51
+
52
+ # Safely merge primitive payload values to avoid serialization issues with complex objects
53
+ safe_payload = payload.select do |_, v|
54
+ v.is_a?(String) || v.is_a?(Numeric) || v.is_a?(TrueClass) || v.is_a?(FalseClass) || v.nil?
55
+ end
56
+
57
+ data[:payload] = safe_payload
58
+
59
+ # Error handling
60
+ if payload[:exception]
61
+ data[:error_class] = payload[:exception].first.to_s
62
+ data[:error_message] = payload[:exception].last.to_s
63
+ end
64
+
65
+ @client.record(data)
66
+ rescue => e
67
+ warn "RailswatchGem: Failed to process notification event: #{e.message}"
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "active_support/notifications"
5
+ require "uri"
6
+
7
+ module RailswatchGem
8
+ module Instrumentation
9
+ class OutgoingRequestsInstrumenter
10
+ def initialize(client, config)
11
+ @client = client
12
+ @config = config
13
+ # Cache ingestion URI components for fast comparison
14
+ @ingest_uri = URI(@config.ingest_url) rescue nil
15
+ end
16
+
17
+ def start
18
+ # Monkey-patch Net::HTTP if we haven't already.
19
+ # This wraps the low-level request method to emit an ActiveSupport notification.
20
+ unless Net::HTTP.include?(NetHttpPatch)
21
+ Net::HTTP.prepend(NetHttpPatch)
22
+ end
23
+
24
+ ActiveSupport::Notifications.subscribe("request.net_http") do |*args|
25
+ event = ActiveSupport::Notifications::Event.new(*args)
26
+ process_event(event)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def process_event(event)
33
+ payload = event.payload
34
+
35
+ # CRITICAL: Ignore requests to the ingestion endpoint.
36
+ # This prevents an infinite loop where reporting metrics generates more metrics.
37
+ if @ingest_uri &&
38
+ payload[:host] == @ingest_uri.host &&
39
+ payload[:port] == @ingest_uri.port
40
+ return
41
+ end
42
+
43
+ data = {
44
+ event_type: "outgoing_request",
45
+ # FIX: Handle Float timestamps safely
46
+ timestamp: Time.at(event.end).utc.iso8601,
47
+
48
+ # Request Details
49
+ method: payload[:method],
50
+ url: payload[:uri].to_s,
51
+ host: payload[:host],
52
+ port: payload[:port],
53
+ scheme: payload[:scheme],
54
+ path: payload[:path],
55
+
56
+ # Performance
57
+ duration_ms: event.duration.round(2)
58
+ }
59
+
60
+ # Error handling
61
+ if payload[:exception]
62
+ data[:error_class] = payload[:exception].first.to_s
63
+ data[:error_message] = payload[:exception].last.to_s
64
+ end
65
+
66
+ @client.record(data)
67
+ rescue => e
68
+ warn "RailswatchGem: Failed to process outgoing request event: #{e.message}"
69
+ end
70
+
71
+ # ----------------------------------------------------------------
72
+ # Internal Patch for Net::HTTP
73
+ # ----------------------------------------------------------------
74
+ module NetHttpPatch
75
+ def request(req, body = nil, &block)
76
+ scheme = use_ssl? ? "https" : "http"
77
+
78
+ # Reconstruct basic URI for context
79
+ # Note: address and port are methods on the Net::HTTP instance
80
+ uri = URI("#{scheme}://#{address}:#{port}#{req.path}")
81
+
82
+ ActiveSupport::Notifications.instrument("request.net_http", {
83
+ host: address,
84
+ port: port,
85
+ scheme: scheme,
86
+ path: req.path,
87
+ method: req.method,
88
+ uri: uri
89
+ }) do
90
+ super
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class QueriesInstrumenter
8
+ # We usually want to ignore internal Rails schema queries
9
+ IGNORED_NAMES = %w[SCHEMA EXPLAIN].freeze
10
+
11
+ def initialize(client, config)
12
+ @client = client
13
+ @config = config
14
+ end
15
+
16
+ def start
17
+ ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
18
+ event = ActiveSupport::Notifications::Event.new(*args)
19
+ process_event(event)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def process_event(event)
26
+ payload = event.payload
27
+
28
+ name = payload[:name]
29
+ return if name && IGNORED_NAMES.include?(name)
30
+
31
+ # Basic PII safety: You might want to sanitize SQL here depending on your needs.
32
+ # payload[:sql] usually contains the raw SQL with bound parameters injected
33
+ # if config.filter_parameters is not applied manually.
34
+
35
+ data = {
36
+ event_type: "query",
37
+ # FIX: Handle Float timestamps safely
38
+ timestamp: Time.at(event.end).utc.iso8601,
39
+
40
+ # Query Details
41
+ name: name || "SQL",
42
+ sql: payload[:sql],
43
+ cached: payload[:cached] || false,
44
+
45
+ # Performance
46
+ duration_ms: event.duration.round(2)
47
+ }
48
+
49
+ # Add connection_id if useful for debugging connection pool issues
50
+ data[:connection_id] = payload[:connection_id] if payload[:connection_id]
51
+
52
+ @client.record(data)
53
+ rescue => e
54
+ warn "RailswatchGem: Failed to process query event: #{e.message}"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailswatchGem
4
+ module Instrumentation
5
+ class Registry
6
+ def initialize
7
+ @instrumenters = {}
8
+ register_builtin_instrumenters
9
+ end
10
+
11
+ def register(name, klass)
12
+ @instrumenters[name.to_sym] = klass
13
+ end
14
+
15
+ def boot!(client, config)
16
+ config.enabled_events.each do |name|
17
+ klass = @instrumenters[name.to_sym]
18
+
19
+ if klass
20
+ klass.new(client, config).start
21
+ else
22
+ warn "RailswatchGem: Instrumenter '#{name}' enabled but class not registered."
23
+ end
24
+ end
25
+
26
+ config.custom_event_blocks.each do |block|
27
+ block.call(client)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def register_builtin_instrumenters
34
+ register :requests, RequestsInstrumenter
35
+ register :queries, QueriesInstrumenter
36
+ register :jobs, JobsInstrumenter
37
+ register :cache, CacheInstrumenter
38
+ register :mail, MailInstrumenter
39
+ register :outgoing_requests, OutgoingRequestsInstrumenter
40
+ register :scheduled_tasks, ScheduledTasksInstrumenter
41
+ register :commands, CommandsInstrumenter
42
+ register :notifications, NotificationsInstrumenter
43
+ register :logs, LogsInstrumenter
44
+ register :errors, ErrorsInstrumenter
45
+ register :models, ModelsInstrumenter
46
+ end
47
+ end
48
+ end
49
+ end