Skip to content

Commit 06ca452

Browse files
committed
[rails] add structured logger subscribers
1 parent 7315962 commit 06ca452

24 files changed

Lines changed: 2057 additions & 11 deletions

Gemfile.dev

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ if RUBY_VERSION >= "3.4"
2626
gem "benchmark"
2727
gem "base64"
2828
gem "ostruct"
29-
gem "psych"
3029
end
3130

3231
# For RSpec

sentry-rails/Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ if ruby_version < Gem::Version.new("2.5.0")
6363
gem "loofah", "2.20.0"
6464
end
6565

66+
if rails_version >= Gem::Version.new("7.1")
67+
gem "psych", "~> 4.0.0"
68+
end
69+
6670
gem "mini_magick"
6771

6872
gem "sprockets-rails"

sentry-rails/lib/sentry/rails.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require "sentry/integrable"
66
require "sentry/rails/tracing"
77
require "sentry/rails/configuration"
8+
require "sentry/rails/structured_logging"
89
require "sentry/rails/engine"
910
require "sentry/rails/railtie"
1011

sentry-rails/lib/sentry/rails/configuration.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,25 @@ class Configuration
159159
# Set this option to true if you want Sentry to capture each retry failure
160160
attr_accessor :active_job_report_on_retry_error
161161

162+
# Configuration for structured logging feature
163+
# @return [StructuredLoggingConfiguration]
164+
attr_reader :structured_logging
165+
166+
# Allow setting structured_logging as a boolean for convenience
167+
# @param value [Boolean, StructuredLoggingConfiguration]
168+
def structured_logging=(value)
169+
case value
170+
when true
171+
@structured_logging.enable = true
172+
when false
173+
@structured_logging.enabled = false
174+
when StructuredLoggingConfiguration
175+
@structured_logging = value
176+
else
177+
raise ArgumentError, "structured_logging must be a boolean or StructuredLoggingConfiguration"
178+
end
179+
end
180+
162181
def initialize
163182
@register_error_subscriber = false
164183
@report_rescued_exceptions = true
@@ -176,6 +195,29 @@ def initialize
176195
@db_query_source_threshold_ms = 100
177196
@active_support_logger_subscription_items = Sentry::Rails::ACTIVE_SUPPORT_LOGGER_SUBSCRIPTION_ITEMS_DEFAULT.dup
178197
@active_job_report_on_retry_error = false
198+
@structured_logging = StructuredLoggingConfiguration.new
199+
end
200+
end
201+
202+
class StructuredLoggingConfiguration
203+
# Enable or disable structured logging
204+
# @return [Boolean]
205+
attr_accessor :enabled
206+
207+
# Array of components to attach structured logging to
208+
# Supported values: [:active_record, :action_controller, :action_mailer, :active_job]
209+
# @return [Array<Symbol>]
210+
attr_accessor :attach_to
211+
212+
def initialize
213+
@enabled = false
214+
@attach_to = []
215+
end
216+
217+
# Check if structured logging is enabled
218+
# @return [Boolean]
219+
def enabled?
220+
@enabled
179221
end
180222
end
181223
end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/log_subscriber"
4+
5+
module Sentry
6+
module Rails
7+
# Base class for Sentry log subscribers that extends ActiveSupport::LogSubscriber
8+
# to provide structured logging capabilities for Rails components.
9+
#
10+
# This class follows Rails' LogSubscriber pattern and provides common functionality
11+
# for capturing Rails instrumentation events and logging them through Sentry's
12+
# structured logging system.
13+
#
14+
# @example Creating a custom log subscriber
15+
# class MySubscriber < Sentry::Rails::LogSubscriber
16+
# attach_to :my_component
17+
#
18+
# def my_event(event)
19+
# log_structured_event(
20+
# message: "My event occurred",
21+
# level: :info,
22+
# attributes: {
23+
# duration_ms: event.duration,
24+
# custom_data: event.payload[:custom_data]
25+
# }
26+
# )
27+
# end
28+
# end
29+
class LogSubscriber < ActiveSupport::LogSubscriber
30+
class << self
31+
if ::Rails.version.to_f < 6.0
32+
# Rails 5.x does not provide detach_from
33+
def detach_from(namespace, notifications = ActiveSupport::Notifications)
34+
listeners = public_instance_methods(false)
35+
.flat_map { |key|
36+
notifications.notifier.listeners_for("#{key}.#{namespace}")
37+
}
38+
.select { |listener| listener.instance_variable_get(:@delegate).is_a?(self) }
39+
40+
listeners.map do |listener|
41+
notifications.notifier.unsubscribe(listener)
42+
end
43+
end
44+
end
45+
end
46+
47+
protected
48+
49+
# Log a structured event using Sentry's structured logger
50+
#
51+
# @param message [String] The log message
52+
# @param level [Symbol] The log level (:trace, :debug, :info, :warn, :error, :fatal)
53+
# @param attributes [Hash] Additional structured attributes to include
54+
def log_structured_event(message:, level: :info, attributes: {})
55+
Sentry.logger.public_send(level, message, **attributes)
56+
rescue => e
57+
# Silently handle any errors in logging to avoid breaking the application
58+
Sentry.configuration.sdk_logger.debug("Failed to log structured event: #{e.message}")
59+
end
60+
61+
# Check if an event should be excluded from logging
62+
#
63+
# @param event [ActiveSupport::Notifications::Event] The event to check
64+
# @return [Boolean] true if the event should be excluded
65+
def excluded_event?(event)
66+
# Skip Rails' internal events
67+
return true if event.name.start_with?("!")
68+
69+
false
70+
end
71+
72+
# Calculate duration in milliseconds from an event
73+
#
74+
# @param event [ActiveSupport::Notifications::Event] The event
75+
# @return [Float] Duration in milliseconds
76+
def duration_ms(event)
77+
event.duration.round(2)
78+
end
79+
80+
# Determine log level based on duration (for performance-sensitive events)
81+
#
82+
# @param duration_ms [Float] Duration in milliseconds
83+
# @param slow_threshold [Float] Threshold in milliseconds to consider "slow"
84+
# @return [Symbol] Log level (:info or :warn)
85+
def level_for_duration(duration_ms, slow_threshold = 1000.0)
86+
duration_ms > slow_threshold ? :warn : :info
87+
end
88+
end
89+
end
90+
end
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
require "sentry/rails/log_subscriber"
4+
require "sentry/rails/log_subscribers/parameter_filter"
5+
6+
module Sentry
7+
module Rails
8+
module LogSubscribers
9+
# LogSubscriber for ActionController events that captures HTTP request processing
10+
# and logs them using Sentry's structured logging system.
11+
#
12+
# This subscriber captures process_action.action_controller events and formats them
13+
# with relevant request information including controller, action, HTTP status,
14+
# request parameters, and performance metrics.
15+
#
16+
# @example Usage
17+
# # Enable structured logging for ActionController
18+
# Sentry.init do |config|
19+
# config.enable_logs = true
20+
# config.rails.structured_logging = true
21+
# config.rails.structured_logging.attach_to = [:action_controller]
22+
# end
23+
class ActionControllerSubscriber < Sentry::Rails::LogSubscriber
24+
include ParameterFilter
25+
26+
# Handle process_action.action_controller events
27+
#
28+
# @param event [ActiveSupport::Notifications::Event] The controller action event
29+
def process_action(event)
30+
return if excluded_event?(event)
31+
32+
payload = event.payload
33+
duration = event.time.round(2)
34+
35+
controller = payload[:controller]
36+
action = payload[:action]
37+
38+
status = extract_status(payload)
39+
40+
attributes = {
41+
controller: controller,
42+
action: action,
43+
status: status,
44+
duration_ms: duration,
45+
method: payload[:method],
46+
path: payload[:path],
47+
format: payload[:format]
48+
}
49+
50+
if payload[:view_runtime]
51+
attributes[:view_runtime_ms] = payload[:view_runtime].round(2)
52+
end
53+
54+
if payload[:db_runtime]
55+
attributes[:db_runtime_ms] = payload[:db_runtime].round(2)
56+
end
57+
58+
if Sentry.configuration.send_default_pii && payload[:params]
59+
filtered_params = filter_sensitive_params(payload[:params])
60+
attributes[:params] = filtered_params unless filtered_params.empty?
61+
end
62+
63+
level = level_for_request(payload)
64+
message = "#{controller}##{action}"
65+
66+
log_structured_event(
67+
message: message,
68+
level: level,
69+
attributes: attributes
70+
)
71+
end
72+
73+
private
74+
75+
def extract_status(payload)
76+
if payload[:status]
77+
payload[:status]
78+
elsif payload[:exception]
79+
case payload[:exception].first
80+
when "ActionController::RoutingError"
81+
404
82+
when "ActionController::BadRequest"
83+
400
84+
else
85+
500
86+
end
87+
end
88+
end
89+
90+
def level_for_request(payload)
91+
status = payload[:status]
92+
93+
# In Rails < 6.0 status is not set when an action raised an exception
94+
if status.nil? && payload[:exception]
95+
case payload[:exception].first
96+
when "ActionController::RoutingError"
97+
:warn
98+
when "ActionController::BadRequest"
99+
:warn
100+
else
101+
:error
102+
end
103+
elsif status >= 200 && status < 400
104+
:info
105+
elsif status >= 400 && status < 500
106+
:warn
107+
elsif status >= 500
108+
:error
109+
else
110+
:info
111+
end
112+
end
113+
end
114+
end
115+
end
116+
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# frozen_string_literal: true
2+
3+
require "sentry/rails/log_subscriber"
4+
require "sentry/rails/log_subscribers/parameter_filter"
5+
6+
module Sentry
7+
module Rails
8+
module LogSubscribers
9+
# LogSubscriber for ActionMailer events that captures email delivery
10+
# and processing events using Sentry's structured logging system.
11+
#
12+
# This subscriber captures deliver.action_mailer and process.action_mailer events
13+
# and formats them with relevant email information while respecting PII settings.
14+
#
15+
# @example Usage
16+
# # Enable structured logging for ActionMailer
17+
# Sentry.init do |config|
18+
# config.enable_logs = true
19+
# config.rails.structured_logging = true
20+
# config.rails.structured_logging.attach_to = [:action_mailer]
21+
# end
22+
class ActionMailerSubscriber < Sentry::Rails::LogSubscriber
23+
include ParameterFilter
24+
25+
# Handle deliver.action_mailer events
26+
#
27+
# @param event [ActiveSupport::Notifications::Event] The email delivery event
28+
def deliver(event)
29+
return if excluded_event?(event)
30+
31+
payload = event.payload
32+
mailer = payload[:mailer]
33+
duration = duration_ms(event)
34+
35+
# Prepare structured attributes
36+
attributes = {
37+
mailer: mailer,
38+
duration_ms: duration,
39+
perform_deliveries: payload[:perform_deliveries]
40+
}
41+
42+
# Add delivery method if available
43+
attributes[:delivery_method] = payload[:delivery_method] if payload[:delivery_method]
44+
45+
# Add date if available
46+
attributes[:date] = payload[:date].to_s if payload[:date]
47+
48+
# Only include email details if PII is allowed
49+
if Sentry.configuration.send_default_pii
50+
# Note: We're being very conservative here and not including
51+
# to, from, subject, or body to avoid PII leakage
52+
# Users can customize this behavior by extending the subscriber
53+
attributes[:message_id] = payload[:message_id] if payload[:message_id]
54+
end
55+
56+
message = "Email delivered via #{mailer}"
57+
58+
# Log the structured event
59+
log_structured_event(
60+
message: message,
61+
level: :info,
62+
attributes: attributes
63+
)
64+
end
65+
66+
# Handle process.action_mailer events
67+
#
68+
# @param event [ActiveSupport::Notifications::Event] The email processing event
69+
def process(event)
70+
return if excluded_event?(event)
71+
72+
payload = event.payload
73+
mailer = payload[:mailer]
74+
action = payload[:action]
75+
duration = duration_ms(event)
76+
77+
# Prepare structured attributes
78+
attributes = {
79+
mailer: mailer,
80+
action: action,
81+
duration_ms: duration
82+
}
83+
84+
# Add parameters if PII is allowed and they exist
85+
if Sentry.configuration.send_default_pii && payload[:params]
86+
# Filter sensitive parameters
87+
filtered_params = filter_sensitive_params(payload[:params])
88+
attributes[:params] = filtered_params unless filtered_params.empty?
89+
end
90+
91+
message = "#{mailer}##{action}"
92+
93+
# Log the structured event
94+
log_structured_event(
95+
message: message,
96+
level: :info,
97+
attributes: attributes
98+
)
99+
end
100+
101+
private
102+
end
103+
end
104+
end
105+
end

0 commit comments

Comments
 (0)