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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +130 -0
- data/Rakefile +8 -0
- data/lib/railswatch_gem/client.rb +145 -0
- data/lib/railswatch_gem/configuration.rb +53 -0
- data/lib/railswatch_gem/helpers.rb +71 -0
- data/lib/railswatch_gem/instrumentation/cache_instrumenter.rb +64 -0
- data/lib/railswatch_gem/instrumentation/commands_instrumenter.rb +93 -0
- data/lib/railswatch_gem/instrumentation/errors_instrumenter.rb +66 -0
- data/lib/railswatch_gem/instrumentation/jobs_instrumenter.rb +74 -0
- data/lib/railswatch_gem/instrumentation/logs_instrumenter.rb +104 -0
- data/lib/railswatch_gem/instrumentation/mail_instrumenter.rb +67 -0
- data/lib/railswatch_gem/instrumentation/models_instrumenter.rb +91 -0
- data/lib/railswatch_gem/instrumentation/notifications_instrumenter.rb +71 -0
- data/lib/railswatch_gem/instrumentation/outgoing_requests_instrumenter.rb +96 -0
- data/lib/railswatch_gem/instrumentation/queries_instrumenter.rb +58 -0
- data/lib/railswatch_gem/instrumentation/registry.rb +49 -0
- data/lib/railswatch_gem/instrumentation/requests_instrumenter.rb +145 -0
- data/lib/railswatch_gem/instrumentation/scheduled_tasks_instrumenter.rb +82 -0
- data/lib/railswatch_gem/middleware/request_context.rb +28 -0
- data/lib/railswatch_gem/railtie.rb +22 -0
- data/lib/railswatch_gem/version.rb +5 -0
- data/lib/railswatch_gem.rb +75 -0
- data/manual_test.rb +103 -0
- data/sig/railswatch_gem.rbs +4 -0
- metadata +72 -0
|
@@ -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
|