|
| 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 |
0 commit comments