Skip to content

Commit ec50db4

Browse files
authored
Rails active support log subscribers (#2690)
* Better warning in Sentry.logger * Add DebugStructuredLogger * [rails] introduce abstract log subscriber * [rails] add structured logging config/activation * [rails] add ActionController log subscriber * [rails] add ActiveRecord log subscriber * [rails] add ActiveJob log subscriber * [rails] add ActionMailer log subscriber * [rails] add e2e specs for rails structured logging * [rails] move specs * [rails] add requires * Update CHANGELOG * Simplify config * Fix debug transport clean up * Skip including db attributes if connection is not in the payload * Fix handling of duration * Set action controller and active record as default log subscribers * Update CHANGELOG * Use duration_ms helper * Clean up specs organization This avoids double-setup of test rails apps which makes specs faster and fixes warnings * More coverage
1 parent 337a4ca commit ec50db4

31 files changed

Lines changed: 3009 additions & 8 deletions

CHANGELOG.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,73 @@
33
### Feature
44

55
- Propagated sampling rates as specified in [Traces](https://develop.sentry.dev/sdk/telemetry/traces/#propagated-random-value) docs ([#2671](https://github.com/getsentry/sentry-ruby/pull/2671))
6+
- Support for Rails ActiveSupport log subscribers ([#2690](https://github.com/getsentry/sentry-ruby/pull/2690))
7+
- Support for defining custom Rails log subscribers that work with Sentry Structured Logging ([#2689](https://github.com/getsentry/sentry-ruby/pull/2689))
8+
9+
Rails applications can now define custom log subscribers that integrate with Sentry's structured logging system. The feature includes built-in subscribers for ActionController, ActiveRecord, ActiveJob, and ActionMailer events, with automatic parameter filtering that respects Rails' `config.filter_parameters` configuration.
10+
11+
To enable structured logging with Rails log subscribers:
12+
13+
```ruby
14+
Sentry.init do |config|
15+
# ... your setup ...
16+
17+
# Make sure structured logging is enabled
18+
config.enable_logs = true
19+
20+
# Enable default Rails log subscribers (ActionController and ActiveRecord)
21+
config.rails.structured_logging.enabled = true
22+
end
23+
```
24+
25+
To configure all subscribers:
26+
27+
```ruby
28+
Sentry.init do |config|
29+
# ... your setup ...
30+
31+
# Make sure structured logging is enabled
32+
config.enable_logs = true
33+
34+
# Enable Rails log subscribers
35+
config.rails.structured_logging.enabled = true
36+
37+
# Add ActionMailer and ActiveJob subscribers
38+
config.rails.structured_logging.subscribers.update(
39+
action_mailer: Sentry::Rails::LogSubscribers::ActionMailerSubscriber,
40+
active_job: Sentry::Rails::LogSubscribers::ActiveJobSubscriber
41+
)
42+
end
43+
```
44+
45+
You can also define custom log subscribers by extending the base class:
46+
47+
```ruby
48+
class MyCustomSubscriber < Sentry::Rails::LogSubscriber
49+
attach_to :my_component
50+
51+
def my_event(event)
52+
log_structured_event(
53+
message: "Custom event occurred",
54+
level: :info,
55+
attributes: { duration_ms: event.duration }
56+
)
57+
end
58+
end
59+
60+
Sentry.init do |config|
61+
# ... your setup ...
62+
63+
# Make sure structured logging is enabled
64+
config.enable_logs = true
65+
66+
# Enable Rails log subscribers
67+
config.rails.structured_logging.enabled = true
68+
69+
# Add custom subscriber
70+
config.rails.structured_logging.subscribers[:my_component] = MyCustomSubscriber
71+
end
72+
```
673

774
### Internal
875

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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
require "sentry/rails/tracing/active_storage_subscriber"
77
require "sentry/rails/tracing/active_support_subscriber"
88

9+
require "sentry/rails/log_subscribers/active_record_subscriber"
10+
require "sentry/rails/log_subscribers/action_controller_subscriber"
11+
912
module Sentry
1013
class Configuration
1114
attr_reader :rails
@@ -159,6 +162,10 @@ class Configuration
159162
# Set this option to true if you want Sentry to capture each retry failure
160163
attr_accessor :active_job_report_on_retry_error
161164

165+
# Configuration for structured logging feature
166+
# @return [StructuredLoggingConfiguration]
167+
attr_reader :structured_logging
168+
162169
def initialize
163170
@register_error_subscriber = false
164171
@report_rescued_exceptions = true
@@ -176,6 +183,27 @@ def initialize
176183
@db_query_source_threshold_ms = 100
177184
@active_support_logger_subscription_items = Sentry::Rails::ACTIVE_SUPPORT_LOGGER_SUBSCRIPTION_ITEMS_DEFAULT.dup
178185
@active_job_report_on_retry_error = false
186+
@structured_logging = StructuredLoggingConfiguration.new
187+
end
188+
end
189+
190+
class StructuredLoggingConfiguration
191+
# Enable or disable structured logging
192+
# @return [Boolean]
193+
attr_accessor :enabled
194+
195+
# Hash of components to subscriber classes for structured logging
196+
# @return [Hash<Symbol, Class>]
197+
attr_accessor :subscribers
198+
199+
DEFAULT_SUBSCRIBERS = {
200+
active_record: Sentry::Rails::LogSubscribers::ActiveRecordSubscriber,
201+
action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber
202+
}.freeze
203+
204+
def initialize
205+
@enabled = false
206+
@subscribers = DEFAULT_SUBSCRIBERS.dup
179207
end
180208
end
181209
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
end
69+
end
70+
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.subscribers = { action_controller: Sentry::Rails::LogSubscribers::ActionControllerSubscriber }
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+
32+
controller = payload[:controller]
33+
action = payload[:action]
34+
35+
status = extract_status(payload)
36+
37+
attributes = {
38+
controller: controller,
39+
action: action,
40+
duration_ms: duration_ms(event),
41+
method: payload[:method],
42+
path: payload[:path],
43+
format: payload[:format]
44+
}
45+
46+
attributes[:status] = status if status
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.nil?
102+
:info
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

0 commit comments

Comments
 (0)