Skip to content

Commit 7187b84

Browse files
committed
WIP - add LogSubscribers::ActiveRecordSubscriber
1 parent d78238c commit 7187b84

File tree

2 files changed

+308
-0
lines changed

2 files changed

+308
-0
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
cached = event.payload.fetch(:cached, false)
34+
connection_id = event.payload[:connection_id]
35+
duration = duration_ms(event)
36+
37+
db_config = extract_db_config(event.payload)
38+
39+
attributes = {
40+
sql: sql,
41+
duration_ms: duration,
42+
cached: cached
43+
}
44+
45+
attributes[:statement_name] = statement_name if statement_name && statement_name != "SQL"
46+
attributes[:connection_id] = connection_id if connection_id
47+
48+
add_db_config_attributes(attributes, db_config)
49+
50+
message = build_log_message(statement_name)
51+
52+
log_structured_event(
53+
message: message,
54+
level: :info,
55+
attributes: attributes
56+
)
57+
end
58+
59+
protected
60+
61+
def excluded_event?(event)
62+
return true if super
63+
return true if EXCLUDED_EVENTS.include?(event.payload[:name])
64+
65+
false
66+
end
67+
68+
private
69+
70+
def build_log_message(statement_name)
71+
if statement_name && statement_name != "SQL"
72+
"Database query: #{statement_name}"
73+
else
74+
"Database query"
75+
end
76+
end
77+
78+
def extract_db_config(payload)
79+
connection = payload[:connection]
80+
81+
if payload[:connection_id] && !connection
82+
connection = ActiveRecord::Base.connection_pool.connections.find do |conn|
83+
conn.object_id == payload[:connection_id]
84+
end
85+
end
86+
87+
return unless connection
88+
89+
extract_db_config_from_connection(connection)
90+
rescue => e
91+
Sentry.configuration.sdk_logger.debug("Failed to extract db config: #{e.message}")
92+
nil
93+
end
94+
95+
def add_db_config_attributes(attributes, db_config)
96+
return unless db_config
97+
98+
attributes[:db_system] = db_config[:adapter] if db_config[:adapter]
99+
attributes[:db_name] = db_config[:database] if db_config[:database]
100+
attributes[:server_address] = db_config[:host] if db_config[:host]
101+
attributes[:server_port] = db_config[:port] if db_config[:port]
102+
attributes[:server_socket_address] = db_config[:socket] if db_config[:socket]
103+
end
104+
105+
if ::Rails.version.to_f >= 6.1
106+
def extract_db_config_from_connection(connection)
107+
if connection.pool.respond_to?(:db_config)
108+
db_config = connection.pool.db_config
109+
if db_config.respond_to?(:configuration_hash)
110+
return db_config.configuration_hash
111+
elsif db_config.respond_to?(:config)
112+
return db_config.config
113+
end
114+
end
115+
116+
extract_db_config_fallback(connection)
117+
end
118+
else
119+
# Rails 6.0 and earlier use spec API
120+
def extract_db_config_from_connection(connection)
121+
if connection.pool.respond_to?(:spec)
122+
spec = connection.pool.spec
123+
if spec.respond_to?(:config)
124+
return spec.config
125+
end
126+
end
127+
128+
extract_db_config_fallback(connection)
129+
end
130+
end
131+
132+
def extract_db_config_fallback(connection)
133+
connection.config if connection.respond_to?(:config)
134+
end
135+
end
136+
end
137+
end
138+
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" 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)