logstruct 0.0.1 → 0.0.2.pre.rc1
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 +26 -2
- data/LICENSE +21 -0
- data/README.md +67 -0
- data/lib/log_struct/concerns/configuration.rb +93 -0
- data/lib/log_struct/concerns/error_handling.rb +94 -0
- data/lib/log_struct/concerns/logging.rb +45 -0
- data/lib/log_struct/config_struct/error_handling_modes.rb +25 -0
- data/lib/log_struct/config_struct/filters.rb +80 -0
- data/lib/log_struct/config_struct/integrations.rb +89 -0
- data/lib/log_struct/configuration.rb +59 -0
- data/lib/log_struct/enums/error_handling_mode.rb +22 -0
- data/lib/log_struct/enums/error_reporter.rb +14 -0
- data/lib/log_struct/enums/event.rb +48 -0
- data/lib/log_struct/enums/level.rb +66 -0
- data/lib/log_struct/enums/source.rb +26 -0
- data/lib/log_struct/enums.rb +9 -0
- data/lib/log_struct/formatter.rb +224 -0
- data/lib/log_struct/handlers.rb +27 -0
- data/lib/log_struct/hash_utils.rb +21 -0
- data/lib/log_struct/integrations/action_mailer/callbacks.rb +100 -0
- data/lib/log_struct/integrations/action_mailer/error_handling.rb +173 -0
- data/lib/log_struct/integrations/action_mailer/event_logging.rb +90 -0
- data/lib/log_struct/integrations/action_mailer/metadata_collection.rb +78 -0
- data/lib/log_struct/integrations/action_mailer.rb +50 -0
- data/lib/log_struct/integrations/active_job/log_subscriber.rb +104 -0
- data/lib/log_struct/integrations/active_job.rb +38 -0
- data/lib/log_struct/integrations/active_record.rb +258 -0
- data/lib/log_struct/integrations/active_storage.rb +94 -0
- data/lib/log_struct/integrations/carrierwave.rb +111 -0
- data/lib/log_struct/integrations/good_job/log_subscriber.rb +228 -0
- data/lib/log_struct/integrations/good_job/logger.rb +73 -0
- data/lib/log_struct/integrations/good_job.rb +111 -0
- data/lib/log_struct/integrations/host_authorization.rb +81 -0
- data/lib/log_struct/integrations/integration_interface.rb +21 -0
- data/lib/log_struct/integrations/lograge.rb +114 -0
- data/lib/log_struct/integrations/rack.rb +31 -0
- data/lib/log_struct/integrations/rack_error_handler/middleware.rb +146 -0
- data/lib/log_struct/integrations/rack_error_handler.rb +32 -0
- data/lib/log_struct/integrations/shrine.rb +75 -0
- data/lib/log_struct/integrations/sidekiq/logger.rb +43 -0
- data/lib/log_struct/integrations/sidekiq.rb +39 -0
- data/lib/log_struct/integrations/sorbet.rb +49 -0
- data/lib/log_struct/integrations.rb +41 -0
- data/lib/log_struct/log/action_mailer.rb +55 -0
- data/lib/log_struct/log/active_job.rb +64 -0
- data/lib/log_struct/log/active_storage.rb +78 -0
- data/lib/log_struct/log/carrierwave.rb +82 -0
- data/lib/log_struct/log/error.rb +76 -0
- data/lib/log_struct/log/good_job.rb +151 -0
- data/lib/log_struct/log/interfaces/additional_data_field.rb +20 -0
- data/lib/log_struct/log/interfaces/common_fields.rb +42 -0
- data/lib/log_struct/log/interfaces/message_field.rb +20 -0
- data/lib/log_struct/log/interfaces/request_fields.rb +36 -0
- data/lib/log_struct/log/plain.rb +53 -0
- data/lib/log_struct/log/request.rb +76 -0
- data/lib/log_struct/log/security.rb +80 -0
- data/lib/log_struct/log/shared/add_request_fields.rb +29 -0
- data/lib/log_struct/log/shared/merge_additional_data_fields.rb +28 -0
- data/lib/log_struct/log/shared/serialize_common.rb +36 -0
- data/lib/log_struct/log/shrine.rb +70 -0
- data/lib/log_struct/log/sidekiq.rb +50 -0
- data/lib/log_struct/log/sql.rb +126 -0
- data/lib/log_struct/log.rb +43 -0
- data/lib/log_struct/log_keys.rb +102 -0
- data/lib/log_struct/monkey_patches/active_support/tagged_logging/formatter.rb +36 -0
- data/lib/log_struct/multi_error_reporter.rb +149 -0
- data/lib/log_struct/param_filters.rb +89 -0
- data/lib/log_struct/railtie.rb +31 -0
- data/lib/log_struct/semantic_logger/color_formatter.rb +209 -0
- data/lib/log_struct/semantic_logger/formatter.rb +94 -0
- data/lib/log_struct/semantic_logger/logger.rb +129 -0
- data/lib/log_struct/semantic_logger/setup.rb +219 -0
- data/lib/log_struct/sorbet/serialize_symbol_keys.rb +23 -0
- data/lib/log_struct/sorbet.rb +13 -0
- data/lib/log_struct/string_scrubber.rb +84 -0
- data/lib/log_struct/version.rb +6 -0
- data/lib/log_struct.rb +37 -0
- data/lib/logstruct.rb +2 -6
- data/logstruct.gemspec +52 -0
- metadata +221 -5
- data/Rakefile +0 -5
@@ -0,0 +1,90 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module LogStruct
|
5
|
+
module Integrations
|
6
|
+
module ActionMailer
|
7
|
+
# Handles logging of email delivery events
|
8
|
+
module EventLogging
|
9
|
+
extend ActiveSupport::Concern
|
10
|
+
extend T::Sig
|
11
|
+
extend T::Helpers
|
12
|
+
requires_ancestor { ::ActionMailer::Base }
|
13
|
+
|
14
|
+
included do
|
15
|
+
T.bind(self, ActionMailer::Callbacks::ClassMethods)
|
16
|
+
|
17
|
+
# Add callbacks for delivery events
|
18
|
+
before_deliver :log_email_delivery
|
19
|
+
after_deliver :log_email_delivered
|
20
|
+
end
|
21
|
+
|
22
|
+
protected
|
23
|
+
|
24
|
+
# Log when an email is about to be delivered
|
25
|
+
sig { void }
|
26
|
+
def log_email_delivery
|
27
|
+
log_mailer_event(Event::Delivery)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Log when an email is delivered
|
31
|
+
sig { void }
|
32
|
+
def log_email_delivered
|
33
|
+
log_mailer_event(Event::Delivered)
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
# Log a mailer event with the given event type
|
39
|
+
sig do
|
40
|
+
params(event_type: Log::ActionMailer::ActionMailerEvent,
|
41
|
+
level: Symbol,
|
42
|
+
additional_data: T::Hash[Symbol, T.untyped]).returns(T.untyped)
|
43
|
+
end
|
44
|
+
def log_mailer_event(event_type, level = :info, additional_data = {})
|
45
|
+
# Get message (self refers to the mailer instance)
|
46
|
+
mailer_message = message if respond_to?(:message)
|
47
|
+
|
48
|
+
# Prepare data for the log entry
|
49
|
+
data = {
|
50
|
+
message_id: extract_message_id,
|
51
|
+
mailer_class: self.class.to_s,
|
52
|
+
mailer_action: action_name.to_s
|
53
|
+
}.compact
|
54
|
+
|
55
|
+
# Add any additional metadata
|
56
|
+
MetadataCollection.add_message_metadata(self, data)
|
57
|
+
MetadataCollection.add_context_metadata(self, data)
|
58
|
+
data.merge!(additional_data) if additional_data.present?
|
59
|
+
|
60
|
+
# Extract email fields (these will be filtered if email_addresses=true)
|
61
|
+
to = mailer_message&.to
|
62
|
+
from = mailer_message&.from&.first
|
63
|
+
subject = mailer_message&.subject
|
64
|
+
|
65
|
+
# Create a structured log entry
|
66
|
+
log_data = Log::ActionMailer.new(
|
67
|
+
event: event_type,
|
68
|
+
to: to,
|
69
|
+
from: from,
|
70
|
+
subject: subject,
|
71
|
+
additional_data: data
|
72
|
+
)
|
73
|
+
LogStruct.info(log_data)
|
74
|
+
log_data
|
75
|
+
end
|
76
|
+
|
77
|
+
# Extract message ID from the mailer
|
78
|
+
sig { returns(T.nilable(String)) }
|
79
|
+
def extract_message_id
|
80
|
+
return nil unless respond_to?(:message)
|
81
|
+
|
82
|
+
mail_message = message
|
83
|
+
return nil unless mail_message.respond_to?(:message_id)
|
84
|
+
|
85
|
+
mail_message.message_id
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module LogStruct
|
5
|
+
module Integrations
|
6
|
+
module ActionMailer
|
7
|
+
# Handles collection of metadata for email logging
|
8
|
+
module MetadataCollection
|
9
|
+
extend T::Sig
|
10
|
+
# Add message-specific metadata to log data
|
11
|
+
sig { params(mailer: T.untyped, log_data: T::Hash[Symbol, T.untyped]).void }
|
12
|
+
def self.add_message_metadata(mailer, log_data)
|
13
|
+
message = mailer.respond_to?(:message) ? mailer.message : nil
|
14
|
+
|
15
|
+
# Add recipient count if message is available
|
16
|
+
if message
|
17
|
+
# Don't log actual email addresses
|
18
|
+
log_data[:recipient_count] = [message.to, message.cc, message.bcc].flatten.compact.count
|
19
|
+
|
20
|
+
# Handle case when attachments might be nil
|
21
|
+
log_data[:has_attachments] = message.attachments&.any? || false
|
22
|
+
log_data[:attachment_count] = message.attachments&.count || 0
|
23
|
+
else
|
24
|
+
log_data[:recipient_count] = 0
|
25
|
+
log_data[:has_attachments] = false
|
26
|
+
log_data[:attachment_count] = 0
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Add context metadata to log data
|
31
|
+
sig { params(mailer: T.untyped, log_data: T::Hash[Symbol, T.untyped]).void }
|
32
|
+
def self.add_context_metadata(mailer, log_data)
|
33
|
+
# Add account ID information if available (but not user email)
|
34
|
+
extract_ids_to_log_data(mailer, log_data)
|
35
|
+
|
36
|
+
# Add any current tags from ActiveJob or ActionMailer
|
37
|
+
add_current_tags_to_log_data(log_data)
|
38
|
+
end
|
39
|
+
|
40
|
+
sig { params(mailer: T.untyped, log_data: T::Hash[Symbol, T.untyped]).void }
|
41
|
+
def self.extract_ids_to_log_data(mailer, log_data)
|
42
|
+
# Extract account ID if available
|
43
|
+
if mailer.instance_variable_defined?(:@account)
|
44
|
+
account = mailer.instance_variable_get(:@account)
|
45
|
+
log_data[:account_id] = account.id if account.respond_to?(:id)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Extract user ID if available
|
49
|
+
return unless mailer.instance_variable_defined?(:@user)
|
50
|
+
|
51
|
+
user = mailer.instance_variable_get(:@user)
|
52
|
+
log_data[:user_id] = user.id if user.respond_to?(:id)
|
53
|
+
end
|
54
|
+
|
55
|
+
sig { params(log_data: T::Hash[Symbol, T.untyped]).void }
|
56
|
+
def self.add_current_tags_to_log_data(log_data)
|
57
|
+
# Get current tags from ActiveSupport::TaggedLogging if available
|
58
|
+
if ::ActiveSupport::TaggedLogging.respond_to?(:current_tags)
|
59
|
+
tags = T.unsafe(::ActiveSupport::TaggedLogging).current_tags
|
60
|
+
log_data[:tags] = tags if tags.present?
|
61
|
+
end
|
62
|
+
|
63
|
+
# Get request_id from ActionDispatch if available
|
64
|
+
if ::ActionDispatch::Request.respond_to?(:current_request_id) &&
|
65
|
+
T.unsafe(::ActionDispatch::Request).current_request_id.present?
|
66
|
+
log_data[:request_id] = T.unsafe(::ActionDispatch::Request).current_request_id
|
67
|
+
end
|
68
|
+
|
69
|
+
# Get job_id from ActiveJob if available
|
70
|
+
if defined?(::ActiveJob::Logging) && ::ActiveJob::Logging.respond_to?(:job_id) &&
|
71
|
+
T.unsafe(::ActiveJob::Logging).job_id.present?
|
72
|
+
log_data[:job_id] = T.unsafe(::ActiveJob::Logging).job_id
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
begin
|
5
|
+
require "action_mailer"
|
6
|
+
rescue LoadError
|
7
|
+
# actionmailer gem is not available, integration will be skipped
|
8
|
+
end
|
9
|
+
|
10
|
+
if defined?(::ActionMailer)
|
11
|
+
require "logger"
|
12
|
+
require_relative "action_mailer/metadata_collection"
|
13
|
+
require_relative "action_mailer/event_logging"
|
14
|
+
require_relative "action_mailer/error_handling"
|
15
|
+
require_relative "action_mailer/callbacks"
|
16
|
+
end
|
17
|
+
|
18
|
+
module LogStruct
|
19
|
+
module Integrations
|
20
|
+
# ActionMailer integration for structured logging
|
21
|
+
module ActionMailer
|
22
|
+
extend T::Sig
|
23
|
+
extend IntegrationInterface
|
24
|
+
|
25
|
+
# Set up ActionMailer structured logging
|
26
|
+
sig { override.params(config: LogStruct::Configuration).returns(T.nilable(T::Boolean)) }
|
27
|
+
def self.setup(config)
|
28
|
+
return nil unless defined?(::ActionMailer)
|
29
|
+
return nil unless config.enabled
|
30
|
+
return nil unless config.integrations.enable_actionmailer
|
31
|
+
|
32
|
+
# Silence default ActionMailer logs (we use our own structured logging)
|
33
|
+
# This is required because we replace the logging using our own callbacks
|
34
|
+
if defined?(::ActionMailer::Base)
|
35
|
+
::ActionMailer::Base.logger = ::Logger.new(File::NULL)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Register our custom observers and handlers
|
39
|
+
# Registering these at the class level means all mailers will use them
|
40
|
+
ActiveSupport.on_load(:action_mailer) { prepend LogStruct::Integrations::ActionMailer::MetadataCollection }
|
41
|
+
ActiveSupport.on_load(:action_mailer) { prepend LogStruct::Integrations::ActionMailer::EventLogging }
|
42
|
+
ActiveSupport.on_load(:action_mailer) { prepend LogStruct::Integrations::ActionMailer::ErrorHandling }
|
43
|
+
ActiveSupport.on_load(:action_mailer) { prepend LogStruct::Integrations::ActionMailer::Callbacks }
|
44
|
+
ActiveSupport.on_load(:action_mailer) { LogStruct::Integrations::ActionMailer::Callbacks.patch_message_delivery }
|
45
|
+
|
46
|
+
true
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require_relative "../../enums/source"
|
5
|
+
require_relative "../../enums/event"
|
6
|
+
require_relative "../../log/active_job"
|
7
|
+
require_relative "../../log/error"
|
8
|
+
|
9
|
+
module LogStruct
|
10
|
+
module Integrations
|
11
|
+
module ActiveJob
|
12
|
+
# Structured logging for ActiveJob
|
13
|
+
class LogSubscriber < ::ActiveJob::LogSubscriber
|
14
|
+
extend T::Sig
|
15
|
+
|
16
|
+
sig { params(event: T.untyped).void }
|
17
|
+
def enqueue(event)
|
18
|
+
job = event.payload[:job]
|
19
|
+
log_job_event(Event::Enqueue, job, event)
|
20
|
+
end
|
21
|
+
|
22
|
+
sig { params(event: T.untyped).void }
|
23
|
+
def enqueue_at(event)
|
24
|
+
job = event.payload[:job]
|
25
|
+
log_job_event(Event::Schedule, job, event, scheduled_at: job.scheduled_at)
|
26
|
+
end
|
27
|
+
|
28
|
+
sig { params(event: T.untyped).void }
|
29
|
+
def perform(event)
|
30
|
+
job = event.payload[:job]
|
31
|
+
exception = event.payload[:exception_object]
|
32
|
+
|
33
|
+
if exception
|
34
|
+
# Log the exception with the job context
|
35
|
+
log_exception(exception, job, event)
|
36
|
+
else
|
37
|
+
log_job_event(Event::Finish, job, event, duration: event.duration.round(2))
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
sig { params(event: T.untyped).void }
|
42
|
+
def perform_start(event)
|
43
|
+
job = event.payload[:job]
|
44
|
+
log_job_event(Event::Start, job, event)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
sig { params(event_type: T.any(Event::Enqueue, Event::Schedule, Event::Start, Event::Finish), job: T.untyped, _event: T.untyped, additional_data: T::Hash[Symbol, T.untyped]).void }
|
50
|
+
def log_job_event(event_type, job, _event, additional_data = {})
|
51
|
+
# Create structured log data
|
52
|
+
log_data = Log::ActiveJob.new(
|
53
|
+
event: event_type,
|
54
|
+
job_id: job.job_id,
|
55
|
+
job_class: job.class.to_s,
|
56
|
+
queue_name: job.queue_name,
|
57
|
+
duration: additional_data[:duration],
|
58
|
+
# Add arguments if the job class allows it
|
59
|
+
arguments: job.class.log_arguments? ? job.arguments : nil,
|
60
|
+
# Store additional data in the data hash
|
61
|
+
additional_data: {
|
62
|
+
executions: job.executions,
|
63
|
+
scheduled_at: additional_data[:scheduled_at],
|
64
|
+
provider_job_id: job.provider_job_id
|
65
|
+
}.compact
|
66
|
+
)
|
67
|
+
|
68
|
+
# Use Rails logger with our structured formatter
|
69
|
+
logger.info(log_data)
|
70
|
+
end
|
71
|
+
|
72
|
+
sig { params(exception: StandardError, job: T.untyped, _event: T.untyped).void }
|
73
|
+
def log_exception(exception, job, _event)
|
74
|
+
# Create job context data for the exception
|
75
|
+
job_context = {
|
76
|
+
job_id: job.job_id,
|
77
|
+
job_class: job.class.to_s,
|
78
|
+
queue_name: job.queue_name,
|
79
|
+
executions: job.executions,
|
80
|
+
provider_job_id: job.provider_job_id
|
81
|
+
}
|
82
|
+
|
83
|
+
# Add arguments if the job class allows it
|
84
|
+
job_context[:arguments] = job.arguments if job.class.log_arguments?
|
85
|
+
|
86
|
+
# Create exception log with job source and context
|
87
|
+
log_data = Log::Error.from_exception(
|
88
|
+
Source::Job,
|
89
|
+
exception,
|
90
|
+
job_context
|
91
|
+
)
|
92
|
+
|
93
|
+
# Use Rails logger with our structured formatter
|
94
|
+
logger.error(log_data)
|
95
|
+
end
|
96
|
+
|
97
|
+
sig { returns(::ActiveSupport::Logger) }
|
98
|
+
def logger
|
99
|
+
::ActiveJob::Base.logger
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
begin
|
5
|
+
require "active_job"
|
6
|
+
require "active_job/log_subscriber"
|
7
|
+
rescue LoadError
|
8
|
+
# ActiveJob gem is not available, integration will be skipped
|
9
|
+
end
|
10
|
+
|
11
|
+
require_relative "active_job/log_subscriber" if defined?(::ActiveJob::LogSubscriber)
|
12
|
+
|
13
|
+
module LogStruct
|
14
|
+
module Integrations
|
15
|
+
# ActiveJob integration for structured logging
|
16
|
+
module ActiveJob
|
17
|
+
extend T::Sig
|
18
|
+
extend IntegrationInterface
|
19
|
+
|
20
|
+
# Set up ActiveJob structured logging
|
21
|
+
sig { override.params(config: LogStruct::Configuration).returns(T.nilable(T::Boolean)) }
|
22
|
+
def self.setup(config)
|
23
|
+
return nil unless defined?(::ActiveJob::LogSubscriber)
|
24
|
+
return nil unless config.enabled
|
25
|
+
return nil unless config.integrations.enable_activejob
|
26
|
+
|
27
|
+
::ActiveSupport.on_load(:active_job) do
|
28
|
+
# Detach the default text formatter
|
29
|
+
::ActiveJob::LogSubscriber.detach_from :active_job
|
30
|
+
|
31
|
+
# Attach our structured formatter
|
32
|
+
Integrations::ActiveJob::LogSubscriber.attach_to :active_job
|
33
|
+
end
|
34
|
+
true
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,258 @@
|
|
1
|
+
# typed: strict
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "active_support/notifications"
|
5
|
+
|
6
|
+
module LogStruct
|
7
|
+
module Integrations
|
8
|
+
# ActiveRecord Integration for SQL Query Logging
|
9
|
+
#
|
10
|
+
# This integration captures and structures all SQL queries executed through ActiveRecord,
|
11
|
+
# providing detailed performance and debugging information in a structured format.
|
12
|
+
#
|
13
|
+
# ## Features:
|
14
|
+
# - Captures all SQL queries with execution time
|
15
|
+
# - Safely filters sensitive data from bind parameters
|
16
|
+
# - Extracts database operation metadata
|
17
|
+
# - Provides connection pool monitoring information
|
18
|
+
# - Identifies query types and table names
|
19
|
+
#
|
20
|
+
# ## Performance Considerations:
|
21
|
+
# - Minimal overhead on query execution
|
22
|
+
# - Async logging prevents I/O blocking
|
23
|
+
# - Configurable to disable in production if needed
|
24
|
+
# - Smart filtering reduces log volume for repetitive queries
|
25
|
+
#
|
26
|
+
# ## Security:
|
27
|
+
# - SQL queries are always parameterized (safe)
|
28
|
+
# - Bind parameters filtered through LogStruct's param filters
|
29
|
+
# - Sensitive patterns automatically scrubbed
|
30
|
+
#
|
31
|
+
# ## Configuration:
|
32
|
+
# ```ruby
|
33
|
+
# LogStruct.configure do |config|
|
34
|
+
# config.integrations.enable_sql_logging = true
|
35
|
+
# config.integrations.sql_slow_query_threshold = 100.0 # ms
|
36
|
+
# config.integrations.sql_log_bind_params = false # disable in production
|
37
|
+
# end
|
38
|
+
# ```
|
39
|
+
module ActiveRecord
|
40
|
+
extend T::Sig
|
41
|
+
extend IntegrationInterface
|
42
|
+
|
43
|
+
# Set up SQL query logging integration
|
44
|
+
sig { override.params(config: LogStruct::Configuration).returns(T.nilable(T::Boolean)) }
|
45
|
+
def self.setup(config)
|
46
|
+
return nil unless config.integrations.enable_sql_logging
|
47
|
+
return nil unless defined?(::ActiveRecord::Base)
|
48
|
+
|
49
|
+
subscribe_to_sql_notifications
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
private_class_method
|
54
|
+
|
55
|
+
# Subscribe to ActiveRecord's sql.active_record notifications
|
56
|
+
sig { void }
|
57
|
+
def self.subscribe_to_sql_notifications
|
58
|
+
::ActiveSupport::Notifications.subscribe("sql.active_record") do |name, start, finish, id, payload|
|
59
|
+
handle_sql_event(name, start, finish, id, payload)
|
60
|
+
rescue => error
|
61
|
+
LogStruct.handle_exception(error, source: LogStruct::Source::LogStruct)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Process SQL notification event and create structured log
|
66
|
+
sig { params(name: String, start: T.untyped, finish: T.untyped, id: String, payload: T::Hash[Symbol, T.untyped]).void }
|
67
|
+
def self.handle_sql_event(name, start, finish, id, payload)
|
68
|
+
# Skip schema queries and Rails internal queries
|
69
|
+
return if skip_query?(payload)
|
70
|
+
|
71
|
+
duration = ((finish - start) * 1000.0).round(2)
|
72
|
+
|
73
|
+
# Skip fast queries if threshold is configured
|
74
|
+
config = LogStruct.config
|
75
|
+
if config.integrations.sql_slow_query_threshold&.positive?
|
76
|
+
return if duration < config.integrations.sql_slow_query_threshold
|
77
|
+
end
|
78
|
+
|
79
|
+
sql_log = Log::SQL.new(
|
80
|
+
message: format_sql_message(payload),
|
81
|
+
source: Source::App,
|
82
|
+
event: Event::Database,
|
83
|
+
sql: payload[:sql]&.strip || "",
|
84
|
+
name: payload[:name] || "SQL Query",
|
85
|
+
duration: duration,
|
86
|
+
row_count: extract_row_count(payload),
|
87
|
+
connection_adapter: extract_adapter_name(payload),
|
88
|
+
bind_params: extract_and_filter_binds(payload),
|
89
|
+
database_name: extract_database_name(payload),
|
90
|
+
connection_pool_size: extract_pool_size(payload),
|
91
|
+
active_connections: extract_active_connections(payload),
|
92
|
+
operation_type: extract_operation_type(payload),
|
93
|
+
table_names: extract_table_names(payload)
|
94
|
+
)
|
95
|
+
|
96
|
+
LogStruct.info(sql_log)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Determine if query should be skipped from logging
|
100
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
101
|
+
def self.skip_query?(payload)
|
102
|
+
query_name = payload[:name]
|
103
|
+
sql = payload[:sql]
|
104
|
+
|
105
|
+
# Skip Rails schema queries
|
106
|
+
return true if query_name&.include?("SCHEMA")
|
107
|
+
return true if query_name&.include?("CACHE")
|
108
|
+
|
109
|
+
# Skip common Rails internal queries
|
110
|
+
return true if sql&.include?("schema_migrations")
|
111
|
+
return true if sql&.include?("ar_internal_metadata")
|
112
|
+
|
113
|
+
# Skip SHOW/DESCRIBE queries
|
114
|
+
return true if sql&.match?(/\A\s*(SHOW|DESCRIBE|EXPLAIN)\s/i)
|
115
|
+
|
116
|
+
false
|
117
|
+
end
|
118
|
+
|
119
|
+
# Format a readable message for the SQL log
|
120
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(String) }
|
121
|
+
def self.format_sql_message(payload)
|
122
|
+
operation_name = payload[:name] || "SQL Query"
|
123
|
+
"#{operation_name} executed"
|
124
|
+
end
|
125
|
+
|
126
|
+
# Extract row count from payload
|
127
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(Integer)) }
|
128
|
+
def self.extract_row_count(payload)
|
129
|
+
row_count = payload[:row_count]
|
130
|
+
row_count.is_a?(Integer) ? row_count : nil
|
131
|
+
end
|
132
|
+
|
133
|
+
# Extract database adapter name
|
134
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
|
135
|
+
def self.extract_adapter_name(payload)
|
136
|
+
connection = payload[:connection]
|
137
|
+
return nil unless connection
|
138
|
+
|
139
|
+
adapter_name = connection.class.name
|
140
|
+
adapter_name&.split("::")&.last
|
141
|
+
end
|
142
|
+
|
143
|
+
# Extract and filter bind parameters
|
144
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Array[T.untyped])) }
|
145
|
+
def self.extract_and_filter_binds(payload)
|
146
|
+
return nil unless LogStruct.config.integrations.sql_log_bind_params
|
147
|
+
|
148
|
+
# Prefer type_casted_binds as they're more readable
|
149
|
+
binds = payload[:type_casted_binds] || payload[:binds]
|
150
|
+
return nil unless binds
|
151
|
+
|
152
|
+
# Filter sensitive data from bind parameters
|
153
|
+
binds.map do |bind|
|
154
|
+
filter_bind_parameter(bind)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
# Extract database name from connection
|
159
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
|
160
|
+
def self.extract_database_name(payload)
|
161
|
+
connection = payload[:connection]
|
162
|
+
return nil unless connection
|
163
|
+
|
164
|
+
if connection.respond_to?(:current_database)
|
165
|
+
connection.current_database
|
166
|
+
elsif connection.respond_to?(:database)
|
167
|
+
connection.database
|
168
|
+
end
|
169
|
+
rescue
|
170
|
+
nil
|
171
|
+
end
|
172
|
+
|
173
|
+
# Extract connection pool size
|
174
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(Integer)) }
|
175
|
+
def self.extract_pool_size(payload)
|
176
|
+
connection = payload[:connection]
|
177
|
+
return nil unless connection
|
178
|
+
|
179
|
+
pool = connection.pool if connection.respond_to?(:pool)
|
180
|
+
pool&.size
|
181
|
+
rescue
|
182
|
+
nil
|
183
|
+
end
|
184
|
+
|
185
|
+
# Extract active connection count
|
186
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(Integer)) }
|
187
|
+
def self.extract_active_connections(payload)
|
188
|
+
connection = payload[:connection]
|
189
|
+
return nil unless connection
|
190
|
+
|
191
|
+
pool = connection.pool if connection.respond_to?(:pool)
|
192
|
+
pool&.stat&.[](:busy)
|
193
|
+
rescue
|
194
|
+
nil
|
195
|
+
end
|
196
|
+
|
197
|
+
# Extract SQL operation type (SELECT, INSERT, etc.)
|
198
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
|
199
|
+
def self.extract_operation_type(payload)
|
200
|
+
sql = payload[:sql]
|
201
|
+
return nil unless sql
|
202
|
+
|
203
|
+
# Extract first word of SQL query
|
204
|
+
match = sql.strip.match(/\A\s*(\w+)/i)
|
205
|
+
match&.captures&.first&.upcase
|
206
|
+
end
|
207
|
+
|
208
|
+
# Extract table names from SQL query
|
209
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Array[String])) }
|
210
|
+
def self.extract_table_names(payload)
|
211
|
+
sql = payload[:sql]
|
212
|
+
return nil unless sql
|
213
|
+
|
214
|
+
# Simple regex to extract table names (basic implementation)
|
215
|
+
# This covers most common cases but could be enhanced
|
216
|
+
tables = []
|
217
|
+
|
218
|
+
# Match FROM, JOIN, UPDATE, INSERT INTO, DELETE FROM patterns
|
219
|
+
sql.scan(/(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+["`]?(\w+)["`]?/i) do |match|
|
220
|
+
table_name = match[0]
|
221
|
+
tables << table_name unless tables.include?(table_name)
|
222
|
+
end
|
223
|
+
|
224
|
+
tables.empty? ? nil : tables
|
225
|
+
end
|
226
|
+
|
227
|
+
# Filter individual bind parameter values to remove sensitive data
|
228
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
229
|
+
def self.filter_bind_parameter(value)
|
230
|
+
case value
|
231
|
+
when String
|
232
|
+
# Filter strings that look like passwords, tokens, secrets, etc.
|
233
|
+
if looks_sensitive?(value)
|
234
|
+
"[FILTERED]"
|
235
|
+
else
|
236
|
+
value
|
237
|
+
end
|
238
|
+
else
|
239
|
+
value
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
# Check if a string value looks sensitive and should be filtered
|
244
|
+
sig { params(value: String).returns(T::Boolean) }
|
245
|
+
def self.looks_sensitive?(value)
|
246
|
+
# Filter very long strings that might be tokens
|
247
|
+
return true if value.length > 50
|
248
|
+
|
249
|
+
# Filter strings that look like hashed passwords, API keys, tokens
|
250
|
+
return true if value.match?(/\A[a-f0-9]{32,}\z/i) # MD5, SHA, etc.
|
251
|
+
return true if value.match?(/\A[A-Za-z0-9+\/]{20,}={0,2}\z/) # Base64
|
252
|
+
return true if value.match?(/(password|secret|token|key|auth)/i)
|
253
|
+
|
254
|
+
false
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
end
|