Skip to content

Commit e982601

Browse files
committed
added sql logging support
1 parent 52b8e82 commit e982601

File tree

13 files changed

+1267
-2
lines changed

13 files changed

+1267
-2
lines changed

.cspell/project-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ carrierwave
1111
creds
1212
dalli
1313
favicons
14+
Fanout
1415
gettime
1516
goodjob
1617
Healthcheck

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# LogStruct Development Guide
22

3+
## 🚨 CRITICAL RULES - MUST ALWAYS BE FOLLOWED 🚨
4+
5+
1. **NEVER mark a feature as done until `./bin/all_check` is passing (all linters and tests)**
6+
2. **NO EXCEPTIONS to the above rules - features are NOT complete until all checks pass**
7+
3. **This rule must ALWAYS be followed no matter what**
8+
39
## Commands
410

511
### Core Commands

bin/prettier

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ set -euo pipefail
44
PRETTIER_ARG="${1:---write}"
55

66
# Format / check files with Prettier
7-
npx prettier . "$PRETTIER_ARG"
7+
pnpm exec prettier . "$PRETTIER_ARG"

lib/log_struct/config_struct/integrations.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ class Integrations < T::Struct
7171
# Filter noisy loggers (ActionView, etc.)
7272
# Default: false
7373
prop :filter_noisy_loggers, T::Boolean, default: false
74+
75+
# Enable SQL query logging through ActiveRecord instrumentation
76+
# Default: false (can be resource intensive)
77+
prop :enable_sql_logging, T::Boolean, default: false
78+
79+
# Only log SQL queries slower than this threshold (in milliseconds)
80+
# Set to 0 or nil to log all queries
81+
# Default: 100.0 (log queries taking >100ms)
82+
prop :sql_slow_query_threshold, T.nilable(Float), default: 100.0
83+
84+
# Include bind parameters in SQL logs (disable in production for security)
85+
# Default: true in development/test, false in production
86+
prop :sql_log_bind_params, T::Boolean, factory: -> { !defined?(::Rails) || !::Rails.respond_to?(:env) || !::Rails.env.production? }
7487
end
7588
end
7689
end

lib/log_struct/enums/event.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class Event < T::Enum
3535
CSRFViolation = new(:csrf_violation)
3636
BlockedHost = new(:blocked_host)
3737

38+
# Database events
39+
Database = new(:database)
40+
3841
# Error events
3942
Error = new(:error)
4043

lib/log_struct/integrations.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
require_relative "integrations/integration_interface"
55
require_relative "integrations/active_job"
6+
require_relative "integrations/active_record"
67
require_relative "integrations/rack_error_handler"
78
require_relative "integrations/host_authorization"
89
require_relative "integrations/action_mailer"
@@ -26,6 +27,7 @@ def self.setup_integrations
2627
Integrations::Lograge.setup(config) if config.integrations.enable_lograge
2728
Integrations::ActionMailer.setup(config) if config.integrations.enable_actionmailer
2829
Integrations::ActiveJob.setup(config) if config.integrations.enable_activejob
30+
Integrations::ActiveRecord.setup(config) if config.integrations.enable_sql_logging
2931
Integrations::Sidekiq.setup(config) if config.integrations.enable_sidekiq
3032
Integrations::GoodJob.setup(config) if config.integrations.enable_goodjob
3133
Integrations::HostAuthorization.setup(config) if config.integrations.enable_host_authorization
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "active_support/notifications"
5+
6+
module LogStruct
7+
module Integrations
8+
# ActiveRecord Integration for SQL Query Logging
9+
#
10+
# This integration captures and structures all SQL queries executed through ActiveRecord,
11+
# providing detailed performance and debugging information in a structured format.
12+
#
13+
# ## Features:
14+
# - Captures all SQL queries with execution time
15+
# - Safely filters sensitive data from bind parameters
16+
# - Extracts database operation metadata
17+
# - Provides connection pool monitoring information
18+
# - Identifies query types and table names
19+
#
20+
# ## Performance Considerations:
21+
# - Minimal overhead on query execution
22+
# - Async logging prevents I/O blocking
23+
# - Configurable to disable in production if needed
24+
# - Smart filtering reduces log volume for repetitive queries
25+
#
26+
# ## Security:
27+
# - SQL queries are always parameterized (safe)
28+
# - Bind parameters filtered through LogStruct's param filters
29+
# - Sensitive patterns automatically scrubbed
30+
#
31+
# ## Configuration:
32+
# ```ruby
33+
# LogStruct.configure do |config|
34+
# config.integrations.enable_sql_logging = true
35+
# config.integrations.sql_slow_query_threshold = 100.0 # ms
36+
# config.integrations.sql_log_bind_params = false # disable in production
37+
# end
38+
# ```
39+
module ActiveRecord
40+
extend T::Sig
41+
extend IntegrationInterface
42+
43+
# Set up SQL query logging integration
44+
sig { override.params(config: LogStruct::Configuration).returns(T.nilable(T::Boolean)) }
45+
def self.setup(config)
46+
return nil unless config.integrations.enable_sql_logging
47+
return nil unless defined?(::ActiveRecord::Base)
48+
49+
subscribe_to_sql_notifications
50+
true
51+
end
52+
53+
private_class_method
54+
55+
# Subscribe to ActiveRecord's sql.active_record notifications
56+
sig { void }
57+
def self.subscribe_to_sql_notifications
58+
::ActiveSupport::Notifications.subscribe("sql.active_record") do |name, start, finish, id, payload|
59+
handle_sql_event(name, start, finish, id, payload)
60+
rescue => error
61+
LogStruct.handle_exception(error, source: LogStruct::Source::LogStruct)
62+
end
63+
end
64+
65+
# Process SQL notification event and create structured log
66+
sig { params(name: String, start: T.untyped, finish: T.untyped, id: String, payload: T::Hash[Symbol, T.untyped]).void }
67+
def self.handle_sql_event(name, start, finish, id, payload)
68+
# Skip schema queries and Rails internal queries
69+
return if skip_query?(payload)
70+
71+
duration = ((finish - start) * 1000.0).round(2)
72+
73+
# Skip fast queries if threshold is configured
74+
config = LogStruct.config
75+
if config.integrations.sql_slow_query_threshold&.positive?
76+
return if duration < config.integrations.sql_slow_query_threshold
77+
end
78+
79+
sql_log = Log::SQL.new(
80+
message: format_sql_message(payload),
81+
source: Source::App,
82+
event: Event::Database,
83+
sql: payload[:sql]&.strip || "",
84+
name: payload[:name] || "SQL Query",
85+
duration: duration,
86+
row_count: extract_row_count(payload),
87+
connection_adapter: extract_adapter_name(payload),
88+
bind_params: extract_and_filter_binds(payload),
89+
database_name: extract_database_name(payload),
90+
connection_pool_size: extract_pool_size(payload),
91+
active_connections: extract_active_connections(payload),
92+
operation_type: extract_operation_type(payload),
93+
table_names: extract_table_names(payload)
94+
)
95+
96+
LogStruct.info(sql_log)
97+
end
98+
99+
# Determine if query should be skipped from logging
100+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
101+
def self.skip_query?(payload)
102+
query_name = payload[:name]
103+
sql = payload[:sql]
104+
105+
# Skip Rails schema queries
106+
return true if query_name&.include?("SCHEMA")
107+
return true if query_name&.include?("CACHE")
108+
109+
# Skip common Rails internal queries
110+
return true if sql&.include?("schema_migrations")
111+
return true if sql&.include?("ar_internal_metadata")
112+
113+
# Skip SHOW/DESCRIBE queries
114+
return true if sql&.match?(/\A\s*(SHOW|DESCRIBE|EXPLAIN)\s/i)
115+
116+
false
117+
end
118+
119+
# Format a readable message for the SQL log
120+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(String) }
121+
def self.format_sql_message(payload)
122+
operation_name = payload[:name] || "SQL Query"
123+
"#{operation_name} executed"
124+
end
125+
126+
# Extract row count from payload
127+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(Integer)) }
128+
def self.extract_row_count(payload)
129+
row_count = payload[:row_count]
130+
row_count.is_a?(Integer) ? row_count : nil
131+
end
132+
133+
# Extract database adapter name
134+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
135+
def self.extract_adapter_name(payload)
136+
connection = payload[:connection]
137+
return nil unless connection
138+
139+
adapter_name = connection.class.name
140+
adapter_name&.split("::")&.last
141+
end
142+
143+
# Extract and filter bind parameters
144+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Array[T.untyped])) }
145+
def self.extract_and_filter_binds(payload)
146+
return nil unless LogStruct.config.integrations.sql_log_bind_params
147+
148+
# Prefer type_casted_binds as they're more readable
149+
binds = payload[:type_casted_binds] || payload[:binds]
150+
return nil unless binds
151+
152+
# Filter sensitive data from bind parameters
153+
binds.map do |bind|
154+
filter_bind_parameter(bind)
155+
end
156+
end
157+
158+
# Extract database name from connection
159+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
160+
def self.extract_database_name(payload)
161+
connection = payload[:connection]
162+
return nil unless connection
163+
164+
if connection.respond_to?(:current_database)
165+
connection.current_database
166+
elsif connection.respond_to?(:database)
167+
connection.database
168+
end
169+
rescue
170+
nil
171+
end
172+
173+
# Extract connection pool size
174+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(Integer)) }
175+
def self.extract_pool_size(payload)
176+
connection = payload[:connection]
177+
return nil unless connection
178+
179+
pool = connection.pool if connection.respond_to?(:pool)
180+
pool&.size
181+
rescue
182+
nil
183+
end
184+
185+
# Extract active connection count
186+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(Integer)) }
187+
def self.extract_active_connections(payload)
188+
connection = payload[:connection]
189+
return nil unless connection
190+
191+
pool = connection.pool if connection.respond_to?(:pool)
192+
pool&.stat&.[](:busy)
193+
rescue
194+
nil
195+
end
196+
197+
# Extract SQL operation type (SELECT, INSERT, etc.)
198+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(String)) }
199+
def self.extract_operation_type(payload)
200+
sql = payload[:sql]
201+
return nil unless sql
202+
203+
# Extract first word of SQL query
204+
match = sql.strip.match(/\A\s*(\w+)/i)
205+
match&.captures&.first&.upcase
206+
end
207+
208+
# Extract table names from SQL query
209+
sig { params(payload: T::Hash[Symbol, T.untyped]).returns(T.nilable(T::Array[String])) }
210+
def self.extract_table_names(payload)
211+
sql = payload[:sql]
212+
return nil unless sql
213+
214+
# Simple regex to extract table names (basic implementation)
215+
# This covers most common cases but could be enhanced
216+
tables = []
217+
218+
# Match FROM, JOIN, UPDATE, INSERT INTO, DELETE FROM patterns
219+
sql.scan(/(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+["`]?(\w+)["`]?/i) do |match|
220+
table_name = match[0]
221+
tables << table_name unless tables.include?(table_name)
222+
end
223+
224+
tables.empty? ? nil : tables
225+
end
226+
227+
# Filter individual bind parameter values to remove sensitive data
228+
sig { params(value: T.untyped).returns(T.untyped) }
229+
def self.filter_bind_parameter(value)
230+
case value
231+
when String
232+
# Filter strings that look like passwords, tokens, secrets, etc.
233+
if looks_sensitive?(value)
234+
"[FILTERED]"
235+
else
236+
value
237+
end
238+
else
239+
value
240+
end
241+
end
242+
243+
# Check if a string value looks sensitive and should be filtered
244+
sig { params(value: String).returns(T::Boolean) }
245+
def self.looks_sensitive?(value)
246+
# Filter very long strings that might be tokens
247+
return true if value.length > 50
248+
249+
# Filter strings that look like hashed passwords, API keys, tokens
250+
return true if value.match?(/\A[a-f0-9]{32,}\z/i) # MD5, SHA, etc.
251+
return true if value.match?(/\A[A-Za-z0-9+\/]{20,}={0,2}\z/) # Base64
252+
return true if value.match?(/(password|secret|token|key|auth)/i)
253+
254+
false
255+
end
256+
end
257+
end
258+
end

lib/log_struct/log.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
require_relative "log/active_storage"
1313
require_relative "log/active_job"
1414
require_relative "log/error"
15+
require_relative "log/good_job"
1516
require_relative "log/plain"
1617
require_relative "log/request"
1718
require_relative "log/security"
1819
require_relative "log/shrine"
1920
require_relative "log/sidekiq"
21+
require_relative "log/sql"
2022

2123
module LogStruct
2224
# Type aliases for all possible log types
@@ -29,11 +31,13 @@ module LogStruct
2931
T.class_of(LogStruct::Log::ActiveStorage),
3032
T.class_of(LogStruct::Log::ActiveJob),
3133
T.class_of(LogStruct::Log::Error),
34+
T.class_of(LogStruct::Log::GoodJob),
3235
T.class_of(LogStruct::Log::Plain),
3336
T.class_of(LogStruct::Log::Request),
3437
T.class_of(LogStruct::Log::Security),
3538
T.class_of(LogStruct::Log::Shrine),
36-
T.class_of(LogStruct::Log::Sidekiq)
39+
T.class_of(LogStruct::Log::Sidekiq),
40+
T.class_of(LogStruct::Log::SQL)
3741
)
3842
end
3943
end

lib/log_struct/log/shared/serialize_common.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ def serialize_common(strict = true)
2424
LOG_KEYS.fetch(:timestamp) => timestamp.iso8601(3)
2525
}
2626
end
27+
28+
# Override as_json to use our custom serialize method instead of default T::Struct serialization
29+
sig { params(options: T.untyped).returns(T::Hash[String, T.untyped]) }
30+
def as_json(options = nil)
31+
# Convert symbol keys to strings for JSON
32+
serialize.transform_keys(&:to_s)
33+
end
2734
end
2835
end
2936
end

0 commit comments

Comments
 (0)