Skip to content

Commit 814c9b4

Browse files
committed
WIP - add LogSubscribers::ActiveRecordSubscriber
1 parent 10c1381 commit 814c9b4

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# frozen_string_literal: true
2+
3+
require "sentry/rails/log_subscriber"
4+
5+
module Sentry
6+
module Rails
7+
module LogSubscribers
8+
# LogSubscriber for ActiveRecord events that captures database queries
9+
# and logs them using Sentry's structured logging system.
10+
#
11+
# This subscriber captures sql.active_record events and formats them
12+
# with relevant database information including SQL queries, duration,
13+
# database configuration, and caching information.
14+
#
15+
# @example Usage
16+
# # Automatically attached when structured logging is enabled for :active_record
17+
# Sentry.init do |config|
18+
# config.enable_logs = true
19+
# config.rails.structured_logging = true
20+
# config.rails.structured_logging.attach_to = [:active_record]
21+
# end
22+
class ActiveRecordSubscriber < Sentry::Rails::LogSubscriber
23+
EXCLUDED_EVENTS = ["SCHEMA", "TRANSACTION"].freeze
24+
25+
# Handle sql.active_record events
26+
#
27+
# @param event [ActiveSupport::Notifications::Event] The SQL event
28+
def sql(event)
29+
return if excluded_event?(event)
30+
31+
sql = event.payload[:sql]
32+
statement_name = event.payload[:name]
33+
# Rails 5.0.0 doesn't include :cached in the payload, it was added in Rails 5.1
34+
cached = event.payload.fetch(:cached, false)
35+
connection_id = event.payload[:connection_id]
36+
duration = duration_ms(event)
37+
38+
db_config = extract_db_config(event.payload)
39+
40+
attributes = {
41+
sql: sql,
42+
duration_ms: duration,
43+
cached: cached
44+
}
45+
46+
attributes[:statement_name] = statement_name if statement_name && statement_name != "SQL"
47+
attributes[:connection_id] = connection_id if connection_id
48+
49+
add_db_config_attributes(attributes, db_config)
50+
51+
message = build_log_message(statement_name)
52+
53+
log_structured_event(
54+
message: message,
55+
level: :info,
56+
attributes: attributes
57+
)
58+
end
59+
60+
protected
61+
62+
def excluded_event?(event)
63+
return true if super
64+
return true if EXCLUDED_EVENTS.include?(event.payload[:name])
65+
66+
false
67+
end
68+
69+
private
70+
71+
def build_log_message(statement_name)
72+
if statement_name && statement_name != "SQL"
73+
"Database query: #{statement_name}"
74+
else
75+
"Database query"
76+
end
77+
end
78+
79+
def extract_db_config(payload)
80+
connection = payload[:connection]
81+
82+
if payload[:connection_id] && !connection
83+
connection = ActiveRecord::Base.connection_pool.connections.find do |conn|
84+
conn.object_id == payload[:connection_id]
85+
end
86+
end
87+
88+
return unless connection
89+
90+
extract_db_config_from_connection(connection)
91+
rescue => e
92+
Sentry.configuration.sdk_logger.debug("Failed to extract db config: #{e.message}")
93+
nil
94+
end
95+
96+
def add_db_config_attributes(attributes, db_config)
97+
return unless db_config
98+
99+
attributes[:db_system] = db_config[:adapter] if db_config[:adapter]
100+
101+
if db_config[:database]
102+
db_name = db_config[:database]
103+
104+
if db_config[:adapter] == "sqlite3" && db_name.include?("/")
105+
db_name = File.basename(db_name)
106+
end
107+
108+
attributes[:db_name] = db_name
109+
end
110+
111+
attributes[:server_address] = db_config[:host] if db_config[:host]
112+
attributes[:server_port] = db_config[:port] if db_config[:port]
113+
attributes[:server_socket_address] = db_config[:socket] if db_config[:socket]
114+
end
115+
116+
if ::Rails.version.to_f >= 6.1
117+
def extract_db_config_from_connection(connection)
118+
if connection.pool.respond_to?(:db_config)
119+
db_config = connection.pool.db_config
120+
if db_config.respond_to?(:configuration_hash)
121+
return db_config.configuration_hash
122+
elsif db_config.respond_to?(:config)
123+
return db_config.config
124+
end
125+
end
126+
127+
extract_db_config_fallback(connection)
128+
end
129+
else
130+
# Rails 6.0 and earlier use spec API
131+
def extract_db_config_from_connection(connection)
132+
if connection.pool.respond_to?(:spec)
133+
spec = connection.pool.spec
134+
if spec.respond_to?(:config)
135+
return spec.config
136+
end
137+
end
138+
139+
extract_db_config_fallback(connection)
140+
end
141+
end
142+
143+
def extract_db_config_fallback(connection)
144+
connection.config if connection.respond_to?(:config)
145+
end
146+
end
147+
end
148+
end
149+
end
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require "sentry/rails/log_subscribers/active_record_subscriber"
5+
6+
RSpec.describe Sentry::Rails::LogSubscribers::ActiveRecordSubscriber do
7+
before do
8+
make_basic_app do |config|
9+
config.enable_logs = true
10+
config.rails.structured_logging.enabled = true
11+
config.rails.structured_logging.attach_to = [:active_record]
12+
end
13+
end
14+
15+
describe "integration with ActiveSupport::Notifications" do
16+
it "logs SQL events when database queries are executed" do
17+
sentry_transport.events.clear
18+
sentry_transport.envelopes.clear
19+
20+
Post.create!
21+
22+
Sentry.get_current_client.log_event_buffer.flush
23+
24+
expect(sentry_logs).not_to be_empty
25+
26+
log_event = sentry_logs.find { |log| log[:body]&.include?("Database query") && log[:attributes][:sql][:value]&.include?("INSERT") }
27+
expect(log_event).not_to be_nil
28+
expect(log_event[:level]).to eq("info")
29+
expect(log_event[:attributes][:sql][:value]).to include("INSERT INTO")
30+
expect(log_event[:attributes][:duration_ms][:value]).to be > 0
31+
end
32+
33+
it "logs SELECT queries with proper attributes" do
34+
post = Post.create!
35+
36+
Sentry.get_current_client.log_event_buffer.flush
37+
sentry_transport.events.clear
38+
sentry_transport.envelopes.clear
39+
40+
Post.find(post.id)
41+
42+
Sentry.get_current_client.log_event_buffer.flush
43+
44+
log_event = sentry_logs.find { |log| log[:body]&.include?("Database query") }
45+
expect(log_event).not_to be_nil
46+
expect(log_event[:attributes][:sql][:value]).to include("SELECT")
47+
expect(log_event[:attributes][:sql][:value]).to include("posts")
48+
end
49+
50+
it "excludes SCHEMA events" do
51+
sentry_transport.events.clear
52+
sentry_transport.envelopes.clear
53+
54+
ActiveSupport::Notifications.instrument("sql.active_record",
55+
sql: "CREATE TABLE temp_test_table (id INTEGER)",
56+
name: "SCHEMA",
57+
connection: ActiveRecord::Base.connection
58+
)
59+
60+
Sentry.get_current_client.log_event_buffer.flush
61+
62+
schema_logs = sentry_logs.select { |log| log[:attributes]&.dig(:sql, :value)&.include?("CREATE TABLE") }
63+
expect(schema_logs).to be_empty
64+
end
65+
66+
it "excludes events starting with !" do
67+
subscriber = described_class.new
68+
event = double("event", name: "!connection.active_record", payload: {})
69+
expect(subscriber.send(:excluded_event?, event)).to be true
70+
end
71+
end
72+
73+
describe "database configuration extraction" do
74+
it "includes database configuration in log attributes" do
75+
sentry_transport.events.clear
76+
sentry_transport.envelopes.clear
77+
78+
Post.create!
79+
80+
Sentry.get_current_client.log_event_buffer.flush
81+
82+
log_event = sentry_logs.find do |log|
83+
log[:body]&.include?("Database query") &&
84+
log[:attributes]&.dig(:sql, :value)&.include?("INSERT")
85+
end
86+
87+
expect(log_event).not_to be_nil
88+
89+
attributes = log_event[:attributes]
90+
expect(attributes[:db_system][:value]).to eq("sqlite3")
91+
expect(attributes[:db_name][:value]).to eq("db")
92+
end
93+
end
94+
95+
describe "statement name handling" do
96+
it "includes statement name in log message when available" do
97+
post = Post.create!
98+
sentry_transport.events.clear
99+
sentry_transport.envelopes.clear
100+
101+
Post.find(post.id)
102+
103+
Sentry.get_current_client.log_event_buffer.flush
104+
105+
log_event = sentry_logs.find do |log|
106+
log[:body]&.include?("Database query") &&
107+
log[:attributes]&.dig(:sql, :value)&.include?("SELECT") &&
108+
log[:attributes]&.dig(:statement_name, :value)&.include?("Load")
109+
end
110+
expect(log_event).not_to be_nil
111+
expect(log_event[:attributes][:statement_name][:value]).to include("Load")
112+
end
113+
114+
it "handles queries without specific statement names" do
115+
sentry_transport.events.clear
116+
sentry_transport.envelopes.clear
117+
118+
ActiveRecord::Base.connection.execute("SELECT 1")
119+
120+
Sentry.get_current_client.log_event_buffer.flush
121+
122+
log_event = sentry_logs.find do |log|
123+
log[:body] == "Database query" &&
124+
log[:attributes]&.dig(:sql, :value) == "SELECT 1"
125+
end
126+
expect(log_event).not_to be_nil
127+
expect(log_event[:attributes][:sql][:value]).to include("SELECT 1")
128+
end
129+
end
130+
131+
describe "caching information" do
132+
it "includes cached flag when query is cached", skip: Rails.version.to_f < 5.1 ? "Rails 5.0.0 doesn't include cached flag in sql.active_record events" : false do
133+
ActiveRecord::Base.cache do
134+
post = Post.create!
135+
sentry_transport.events.clear
136+
sentry_transport.envelopes.clear
137+
138+
Post.find(post.id)
139+
Post.find(post.id)
140+
141+
Sentry.get_current_client.log_event_buffer.flush
142+
143+
cached_log = sentry_logs.find { |log| log[:attributes]&.dig(:cached, :value) == true }
144+
expect(cached_log).not_to be_nil
145+
end
146+
end
147+
end
148+
149+
describe "when logging is disabled" do
150+
before do
151+
make_basic_app do |config|
152+
config.enable_logs = false
153+
config.rails.structured_logging.enabled = true
154+
config.rails.structured_logging.attach_to = [:active_record]
155+
end
156+
end
157+
158+
it "does not log events when logging is disabled" do
159+
initial_log_count = sentry_logs.count
160+
161+
Post.create!
162+
163+
if Sentry.get_current_client&.log_event_buffer
164+
Sentry.get_current_client.log_event_buffer.flush
165+
end
166+
167+
expect(sentry_logs.count).to eq(initial_log_count)
168+
end
169+
end
170+
end

0 commit comments

Comments
 (0)