Skip to content

Commit c602edc

Browse files
committed
[rails] introduce abstract log subscriber
1 parent 84dc861 commit c602edc

File tree

5 files changed

+531
-0
lines changed

5 files changed

+531
-0
lines changed
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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
module Rails
5+
module LogSubscribers
6+
# Shared utility module for filtering sensitive parameters in log subscribers.
7+
#
8+
# This module provides consistent parameter filtering across all Sentry Rails
9+
# log subscribers, leveraging Rails' built-in parameter filtering when available.
10+
# It automatically detects the correct Rails parameter filtering API based on
11+
# the Rails version and includes the appropriate implementation module.
12+
#
13+
# @example Usage in a log subscriber
14+
# class MySubscriber < Sentry::Rails::LogSubscriber
15+
# include Sentry::Rails::LogSubscribers::ParameterFilter
16+
#
17+
# def my_event(event)
18+
# if Sentry.configuration.send_default_pii && event.payload[:params]
19+
# filtered_params = filter_sensitive_params(event.payload[:params])
20+
# attributes[:params] = filtered_params unless filtered_params.empty?
21+
# end
22+
# end
23+
# end
24+
module ParameterFilter
25+
EMPTY_HASH = {}.freeze
26+
27+
if ::Rails.version >= 6.0
28+
def self.backend
29+
ActiveSupport::ParameterFilter
30+
end
31+
else
32+
def self.backend
33+
ActionDispatch::Http::ParameterFilter
34+
end
35+
end
36+
37+
# Filter sensitive parameters from a hash, respecting Rails configuration.
38+
#
39+
# @param params [Hash] The parameters to filter
40+
# @return [Hash] Filtered parameters with sensitive data removed
41+
def filter_sensitive_params(params)
42+
return EMPTY_HASH unless params.is_a?(Hash)
43+
44+
filter_parameters = ::Rails.application.config.filter_parameters
45+
parameter_filter = ParameterFilter.backend.new(filter_parameters)
46+
47+
parameter_filter.filter(params)
48+
end
49+
end
50+
end
51+
end
52+
end

sentry-rails/spec/dummy/test_rails_app/app.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,17 @@ def self.name
7171

7272
configure_app(app)
7373

74+
# Configure parameter filtering for consistent test behavior
75+
app.config.filter_parameters.concat(
76+
[:custom_secret,
77+
:api_key,
78+
:credit_card,
79+
:authorization,
80+
:password,
81+
:token]
82+
)
83+
app.config.filter_parameters.uniq!
84+
7485
app.routes.append do
7586
get "/exception", to: "hello#exception"
7687
get "/view_exception", to: "hello#view_exception"
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
require "sentry/rails/log_subscriber"
6+
require "sentry/rails/log_subscribers/parameter_filter"
7+
8+
RSpec.describe Sentry::Rails::LogSubscriber, type: :request do
9+
let!(:test_subscriber) { test_subscriber_class.new }
10+
11+
after do
12+
Sentry.logger.clear if Sentry.logger.respond_to?(:clear)
13+
test_subscriber_class.detach_from(:test_component)
14+
end
15+
16+
context "with no parameter filtering" do
17+
let(:test_subscriber_class) do
18+
Class.new(described_class) do
19+
attach_to :test_component
20+
21+
def test_event(event)
22+
log_structured_event(
23+
message: "Test event occurred",
24+
attributes: {
25+
duration_ms: duration_ms(event),
26+
test_data: event.payload[:test_data],
27+
component: "test_component"
28+
}
29+
)
30+
end
31+
32+
def error_test_event(event)
33+
log_structured_event(
34+
message: "Error test event",
35+
level: :error,
36+
attributes: {
37+
duration_ms: duration_ms(event),
38+
error_data: event.payload[:error_data]
39+
}
40+
)
41+
end
42+
end
43+
end
44+
45+
before do
46+
make_basic_app do |config|
47+
config.enable_logs = true
48+
config.structured_logger_class = Sentry::DebugStructuredLogger
49+
end
50+
end
51+
52+
describe "ActiveSupport notifications integration" do
53+
it "responds to real ActiveSupport notifications and logs structured events" do
54+
ActiveSupport::Notifications.instrument("test_event.test_component", test_data: "sample_data") do
55+
sleep(0.01)
56+
end
57+
58+
logged_events = Sentry.logger.logged_events
59+
expect(logged_events).not_to be_empty
60+
61+
log_event = logged_events.first
62+
expect(log_event["level"]).to eq("info")
63+
expect(log_event["message"]).to eq("Test event occurred")
64+
expect(log_event["attributes"]["test_data"]).to eq("sample_data")
65+
expect(log_event["attributes"]["component"]).to eq("test_component")
66+
expect(log_event["attributes"]["duration_ms"]).to be_a(Float)
67+
expect(log_event["attributes"]["duration_ms"]).to be > 0
68+
expect(log_event["timestamp"]).to be_a(String)
69+
end
70+
71+
it "uses appropriate log level based on duration thresholds" do
72+
ActiveSupport::Notifications.instrument("test_event.test_component", test_data: "fast") do
73+
sleep(0.1)
74+
end
75+
76+
logged_events = Sentry.logger.logged_events
77+
expect(logged_events.size).to eq(1)
78+
79+
log_event = logged_events.first
80+
expect(log_event["level"]).to eq("info")
81+
expect(log_event["attributes"]["test_data"]).to eq("fast")
82+
expect(log_event["attributes"]["duration_ms"]).to be > 50
83+
end
84+
85+
it "handles events with various payload data types" do
86+
test_payloads = [
87+
{ test_data: "string_value" },
88+
{ test_data: { nested: "hash" } },
89+
{ test_data: [1, 2, 3] },
90+
{ test_data: nil }
91+
]
92+
93+
expected_values = [
94+
"string_value",
95+
{ "nested" => "hash" },
96+
[1, 2, 3],
97+
nil
98+
]
99+
100+
test_payloads.each do |payload|
101+
ActiveSupport::Notifications.instrument("test_event.test_component", payload) do
102+
sleep 0.01
103+
end
104+
end
105+
106+
logged_events = Sentry.logger.logged_events
107+
expect(logged_events.size).to eq(test_payloads.size)
108+
109+
logged_events.each_with_index do |log_event, index|
110+
expect(log_event["message"]).to eq("Test event occurred")
111+
expect(log_event["attributes"]["test_data"]).to eq(expected_values[index])
112+
expect(log_event["level"]).to eq("info")
113+
end
114+
end
115+
116+
it "calculates duration correctly from real events" do
117+
ActiveSupport::Notifications.instrument("test_event.test_component", test_data: "duration_test") do
118+
sleep(0.05) # 50ms
119+
end
120+
121+
logged_events = Sentry.logger.logged_events
122+
log_event = logged_events.first
123+
duration = log_event["attributes"]["duration_ms"]
124+
125+
expect(duration).to be_a(Float)
126+
expect(duration).to be >= 40.0
127+
expect(duration).to be < 100.0
128+
expect(duration.round(2)).to eq(duration)
129+
end
130+
end
131+
132+
describe "error handling" do
133+
it "handles logging errors gracefully and logs to sdk_logger" do
134+
failing_logger = double("failing_logger")
135+
136+
allow(failing_logger).to receive(:error).and_raise(StandardError.new("Logging failed"))
137+
138+
sdk_logger_output = StringIO.new
139+
sdk_logger = ::Logger.new(sdk_logger_output)
140+
141+
allow(Sentry).to receive(:logger).and_return(failing_logger)
142+
allow(Sentry).to receive(:configuration).and_return(double("configuration", sdk_logger: sdk_logger))
143+
144+
expect {
145+
ActiveSupport::Notifications.instrument("error_test_event.test_component", error_data: "error_test") do
146+
sleep 0.01
147+
end
148+
}.not_to raise_error
149+
150+
sdk_output = sdk_logger_output.string
151+
expect(sdk_output).to include("Failed to log structured event: Logging failed")
152+
end
153+
end
154+
155+
describe "Rails version compatibility" do
156+
context "when Rails version is less than 6.0", skip: Rails.version.to_f >= 6.0 ? "Rails 6.0+" : false do
157+
it "provides custom detach_from implementation" do
158+
temp_subscriber_class = Class.new(described_class) do
159+
attach_to :temp_test
160+
161+
def temp_event(event)
162+
log_structured_event(message: "Temp event", attributes: { data: event.payload[:data] })
163+
end
164+
end
165+
166+
ActiveSupport::Notifications.instrument("temp_event.temp_test", data: "before_detach") do
167+
sleep 0.01
168+
end
169+
170+
initial_log_count = Sentry.logger.logged_events.size
171+
expect(initial_log_count).to be > 0
172+
173+
temp_subscriber_class.detach_from(:temp_test)
174+
175+
Sentry.logger.clear
176+
177+
ActiveSupport::Notifications.instrument("temp_event.temp_test", data: "after_detach") do
178+
sleep 0.01
179+
end
180+
181+
expect(Sentry.logger.logged_events).to be_empty
182+
end
183+
end
184+
185+
context "when Rails version is 6.0 or higher", skip: Rails.version.to_f < 6.0 ? "Rails 5.x" : false do
186+
it "uses Rails built-in detach_from method" do
187+
expect(described_class).to respond_to(:detach_from)
188+
189+
temp_subscriber_class = Class.new(described_class) do
190+
attach_to :temp_test_rails6
191+
192+
def temp_event(event)
193+
log_structured_event(message: "Temp event Rails 6+", attributes: { data: event.payload[:data] })
194+
end
195+
end
196+
197+
ActiveSupport::Notifications.instrument("temp_event.temp_test_rails6", data: "test") do
198+
sleep 0.01
199+
end
200+
201+
initial_log_count = Sentry.logger.logged_events.size
202+
expect(initial_log_count).to be > 0
203+
204+
temp_subscriber_class.detach_from(:temp_test_rails6)
205+
206+
Sentry.logger.clear
207+
208+
ActiveSupport::Notifications.instrument("temp_event.temp_test_rails6", data: "after_detach") do
209+
sleep 0.01
210+
end
211+
212+
expect(Sentry.logger.logged_events).to be_empty
213+
end
214+
end
215+
end
216+
end
217+
218+
context "parameter filtering integration" do
219+
let(:test_subscriber_class) do
220+
Class.new(described_class) do
221+
include Sentry::Rails::LogSubscribers::ParameterFilter
222+
223+
attach_to :filtering_test
224+
225+
def filtering_event(event)
226+
attributes = {
227+
duration_ms: duration_ms(event),
228+
component: "filtering_test"
229+
}
230+
231+
if Sentry.configuration.send_default_pii && event.payload[:params]
232+
filtered_params = filter_sensitive_params(event.payload[:params])
233+
attributes[:params] = filtered_params unless filtered_params.empty?
234+
end
235+
236+
log_structured_event(
237+
message: "Filtering event occurred",
238+
attributes: attributes
239+
)
240+
end
241+
end
242+
end
243+
244+
before do
245+
make_basic_app do |config, app|
246+
config.enable_logs = true
247+
config.structured_logger_class = Sentry::DebugStructuredLogger
248+
config.send_default_pii = true
249+
app.config.filter_parameters += [:custom_secret, :api_key, :credit_card, :authorization]
250+
end
251+
end
252+
253+
it_behaves_like "parameter filtering" do
254+
let(:test_instance) { test_subscriber }
255+
end
256+
end
257+
end

0 commit comments

Comments
 (0)