Skip to content

Commit d78238c

Browse files
committed
WIP - introduce Rails::LogSubscriber
1 parent b31298f commit d78238c

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed

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/log_subscriber"
89
require "sentry/rails/engine"
910
require "sentry/rails/railtie"
1011

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
# Override attach_to to ensure our logger is set
32+
def attach_to(namespace, subscriber = new, notifier = ActiveSupport::Notifications, inherit_all: false)
33+
# Set the logger to nil to prevent Rails from logging to the standard logger
34+
# We'll handle logging through Sentry's structured logger instead
35+
@logger = nil
36+
super
37+
end
38+
39+
# Override detach_from to properly clean up subscriptions
40+
def detach_from(namespace, notifier = ActiveSupport::Notifications)
41+
super
42+
end
43+
44+
# Override logger to return nil, preventing standard Rails logging
45+
def logger
46+
nil
47+
end
48+
end
49+
50+
protected
51+
52+
# Log a structured event using Sentry's structured logger
53+
#
54+
# @param message [String] The log message
55+
# @param level [Symbol] The log level (:trace, :debug, :info, :warn, :error, :fatal)
56+
# @param attributes [Hash] Additional structured attributes to include
57+
def log_structured_event(message:, level: :info, attributes: {})
58+
return unless Sentry.configuration.enable_logs
59+
60+
Sentry.logger.public_send(level, message, **attributes)
61+
rescue => e
62+
# Silently handle any errors in logging to avoid breaking the application
63+
Sentry.configuration.sdk_logger.debug("Failed to log structured event: #{e.message}")
64+
end
65+
66+
# Check if an event should be excluded from logging
67+
#
68+
# @param event [ActiveSupport::Notifications::Event] The event to check
69+
# @return [Boolean] true if the event should be excluded
70+
def excluded_event?(event)
71+
# Skip Rails' internal events
72+
return true if event.name.start_with?("!")
73+
74+
false
75+
end
76+
77+
# Calculate duration in milliseconds from an event
78+
#
79+
# @param event [ActiveSupport::Notifications::Event] The event
80+
# @return [Float] Duration in milliseconds
81+
def duration_ms(event)
82+
event.duration.round(2)
83+
end
84+
85+
# Determine log level based on duration (for performance-sensitive events)
86+
#
87+
# @param duration_ms [Float] Duration in milliseconds
88+
# @param slow_threshold [Float] Threshold in milliseconds to consider "slow"
89+
# @return [Symbol] Log level (:info or :warn)
90+
def level_for_duration(duration_ms, slow_threshold = 1000.0)
91+
duration_ms > slow_threshold ? :warn : :info
92+
end
93+
end
94+
end
95+
end
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Sentry::Rails::LogSubscriber do
6+
let(:test_subscriber_class) do
7+
Class.new(described_class) do
8+
def test_event(event)
9+
return if excluded_event?(event)
10+
11+
duration = duration_ms(event)
12+
13+
log_structured_event(
14+
message: "Test event processed",
15+
level: :info,
16+
attributes: {
17+
event_name: event.name,
18+
duration_ms: duration,
19+
payload_data: event.payload[:data]
20+
}
21+
)
22+
end
23+
24+
def custom_event(event)
25+
return if excluded_event?(event)
26+
27+
log_structured_event(
28+
message: "Custom event: #{event.payload[:action]}",
29+
level: :info,
30+
attributes: {
31+
action: event.payload[:action],
32+
user_id: event.payload[:user_id],
33+
metadata: event.payload[:metadata]
34+
}
35+
)
36+
end
37+
end
38+
end
39+
40+
before do
41+
make_basic_app do |config|
42+
config.enable_logs = true
43+
end
44+
end
45+
46+
describe "integration with ActiveSupport::Notifications" do
47+
let(:subscriber) { test_subscriber_class.new }
48+
49+
before do
50+
test_subscriber_class.attach_to :test_component
51+
end
52+
53+
after do
54+
test_subscriber_class.detach_from :test_component
55+
end
56+
57+
it "logs events when notifications are published" do
58+
ActiveSupport::Notifications.instrument("test_event.test_component", data: "test_data") do
59+
sleep(0.01)
60+
end
61+
62+
Sentry.get_current_client.log_event_buffer.flush
63+
64+
expect(sentry_logs).not_to be_empty
65+
66+
log_event = sentry_logs.find { |log| log[:body] == "Test event processed" }
67+
expect(log_event).not_to be_nil
68+
expect(log_event[:level]).to eq("info")
69+
expect(log_event[:attributes][:event_name]).to eq({ value: "test_event.test_component", type: "string" })
70+
expect(log_event[:attributes][:duration_ms][:value]).to be > 0
71+
expect(log_event[:attributes][:payload_data]).to eq({ value: "test_data", type: "string" })
72+
end
73+
74+
it "logs custom events with different attributes" do
75+
ActiveSupport::Notifications.instrument("custom_event.test_component",
76+
action: "user_login",
77+
user_id: 123,
78+
metadata: { ip: "192.168.1.1" }
79+
)
80+
81+
Sentry.get_current_client.log_event_buffer.flush
82+
83+
expect(sentry_logs).not_to be_empty
84+
85+
log_event = sentry_logs.find { |log| log[:body] == "Custom event: user_login" }
86+
expect(log_event).not_to be_nil
87+
expect(log_event[:level]).to eq("info")
88+
expect(log_event[:attributes][:action]).to eq({ value: "user_login", type: "string" })
89+
expect(log_event[:attributes][:user_id]).to eq({ value: 123, type: "integer" })
90+
expect(log_event[:attributes][:metadata][:value]).to eq({ ip: "192.168.1.1" })
91+
end
92+
93+
it "excludes events starting with !" do
94+
ActiveSupport::Notifications.instrument("!excluded_event.test_component", data: "should_not_log")
95+
96+
Sentry.get_current_client.log_event_buffer.flush
97+
98+
excluded_logs = sentry_logs.select { |log| log[:body]&.include?("should_not_log") }
99+
expect(excluded_logs).to be_empty
100+
end
101+
end
102+
103+
describe "attach_to behavior" do
104+
it "sets logger to nil to prevent standard Rails logging" do
105+
subscriber_class = Class.new(described_class)
106+
subscriber_class.attach_to :test_component
107+
108+
expect(subscriber_class.logger).to be_nil
109+
end
110+
end
111+
112+
describe "when logging is disabled" do
113+
before do
114+
make_basic_app do |config|
115+
config.enable_logs = false
116+
end
117+
end
118+
119+
let(:subscriber) { test_subscriber_class.new }
120+
121+
before do
122+
test_subscriber_class.attach_to :test_component
123+
end
124+
125+
after do
126+
test_subscriber_class.detach_from :test_component
127+
end
128+
129+
it "does not log events when logging is disabled" do
130+
initial_log_count = sentry_logs.count
131+
132+
ActiveSupport::Notifications.instrument("test_event.test_component", data: "test_data")
133+
134+
if Sentry.get_current_client&.log_event_buffer
135+
Sentry.get_current_client.log_event_buffer.flush
136+
end
137+
138+
expect(sentry_logs.count).to eq(initial_log_count)
139+
end
140+
end
141+
end

0 commit comments

Comments
 (0)