Skip to content

Commit 6c9d5ca

Browse files
committed
[rails] add structured logger subscribers
1 parent cdd3d15 commit 6c9d5ca

24 files changed

+1987
-11
lines changed

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: 36 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,23 @@ 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 = []
179215
end
180216
end
181217
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
# Calculate duration in milliseconds from an event
62+
#
63+
# @param event [ActiveSupport::Notifications::Event] The event
64+
# @return [Float] Duration in milliseconds
65+
def duration_ms(event)
66+
event.duration.round(2)
67+
end
68+
69+
# Determine log level based on duration (for performance-sensitive events)
70+
#
71+
# @param duration_ms [Float] Duration in milliseconds
72+
# @param slow_threshold [Float] Threshold in milliseconds to consider "slow"
73+
# @return [Symbol] Log level (:info or :warn)
74+
def level_for_duration(duration_ms, slow_threshold = 1000.0)
75+
duration_ms > slow_threshold ? :warn : :info
76+
end
77+
end
78+
end
79+
end
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
payload = event.payload
31+
duration = event.time.round(2)
32+
33+
controller = payload[:controller]
34+
action = payload[:action]
35+
36+
status = extract_status(payload)
37+
38+
attributes = {
39+
controller: controller,
40+
action: action,
41+
status: status,
42+
duration_ms: duration,
43+
method: payload[:method],
44+
path: payload[:path],
45+
format: payload[:format]
46+
}
47+
48+
if payload[:view_runtime]
49+
attributes[:view_runtime_ms] = payload[:view_runtime].round(2)
50+
end
51+
52+
if payload[:db_runtime]
53+
attributes[:db_runtime_ms] = payload[:db_runtime].round(2)
54+
end
55+
56+
if Sentry.configuration.send_default_pii && payload[:params]
57+
filtered_params = filter_sensitive_params(payload[:params])
58+
attributes[:params] = filtered_params unless filtered_params.empty?
59+
end
60+
61+
level = level_for_request(payload)
62+
message = "#{controller}##{action}"
63+
64+
log_structured_event(
65+
message: message,
66+
level: level,
67+
attributes: attributes
68+
)
69+
end
70+
71+
private
72+
73+
def extract_status(payload)
74+
if payload[:status]
75+
payload[:status]
76+
elsif payload[:exception]
77+
case payload[:exception].first
78+
when "ActionController::RoutingError"
79+
404
80+
when "ActionController::BadRequest"
81+
400
82+
else
83+
500
84+
end
85+
end
86+
end
87+
88+
def level_for_request(payload)
89+
status = payload[:status]
90+
91+
# In Rails < 6.0 status is not set when an action raised an exception
92+
if status.nil? && payload[:exception]
93+
case payload[:exception].first
94+
when "ActionController::RoutingError"
95+
:warn
96+
when "ActionController::BadRequest"
97+
:warn
98+
else
99+
:error
100+
end
101+
elsif status >= 200 && status < 400
102+
:info
103+
elsif status >= 400 && status < 500
104+
:warn
105+
elsif status >= 500
106+
:error
107+
else
108+
:info
109+
end
110+
end
111+
end
112+
end
113+
end
114+
end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
payload = event.payload
30+
mailer = payload[:mailer]
31+
duration = duration_ms(event)
32+
33+
attributes = {
34+
mailer: mailer,
35+
duration_ms: duration,
36+
perform_deliveries: payload[:perform_deliveries]
37+
}
38+
39+
attributes[:delivery_method] = payload[:delivery_method] if payload[:delivery_method]
40+
attributes[:date] = payload[:date].to_s if payload[:date]
41+
42+
if Sentry.configuration.send_default_pii
43+
attributes[:message_id] = payload[:message_id] if payload[:message_id]
44+
end
45+
46+
message = "Email delivered via #{mailer}"
47+
48+
# Log the structured event
49+
log_structured_event(
50+
message: message,
51+
level: :info,
52+
attributes: attributes
53+
)
54+
end
55+
56+
# Handle process.action_mailer events
57+
#
58+
# @param event [ActiveSupport::Notifications::Event] The email processing event
59+
def process(event)
60+
payload = event.payload
61+
62+
mailer = payload[:mailer]
63+
action = payload[:action]
64+
duration = duration_ms(event)
65+
66+
attributes = {
67+
mailer: mailer,
68+
action: action,
69+
duration_ms: duration
70+
}
71+
72+
if Sentry.configuration.send_default_pii && payload[:params]
73+
filtered_params = filter_sensitive_params(payload[:params])
74+
attributes[:params] = filtered_params unless filtered_params.empty?
75+
end
76+
77+
message = "#{mailer}##{action}"
78+
79+
log_structured_event(
80+
message: message,
81+
level: :info,
82+
attributes: attributes
83+
)
84+
end
85+
end
86+
end
87+
end
88+
end

0 commit comments

Comments
 (0)