Module: LogStruct::Integrations::ActiveRecord

Extended by:
IntegrationInterface, T::Sig
Defined in:
lib/log_struct/integrations/active_record.rb

Overview

ActiveRecord Integration for SQL Query Logging

This integration captures and structures all SQL queries executed through ActiveRecord, providing detailed performance and debugging information in a structured format.

Features:

  • Captures all SQL queries with execution time
  • Safely filters sensitive data from bind parameters
  • Extracts database operation metadata
  • Provides connection pool monitoring information
  • Identifies query types and table names

Performance Considerations:

  • Minimal overhead on query execution
  • Async logging prevents I/O blocking
  • Configurable to disable in production if needed
  • Smart filtering reduces log volume for repetitive queries

Security:

  • SQL queries are always parameterized (safe)
  • Bind parameters filtered through LogStruct's param filters
  • Sensitive patterns automatically scrubbed

Configuration:

LogStruct.configure do |config|
  config.integrations.enable_sql_logging = true
  config.integrations.sql_slow_query_threshold = 100.0 # ms
  config.integrations.sql_log_bind_params = false # disable in production
end

Defined Under Namespace

Classes: State

Constant Summary collapse

STATE =
T.let(State.new(false, nil), State)

Class Method Summary collapse

Methods included from IntegrationInterface

setup

Class Method Details

.extract_active_connections(payload) ⇒ Integer?

Extract active connection count

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (Integer, nil)


217
218
219
220
221
222
223
224
225
# File 'lib/log_struct/integrations/active_record.rb', line 217

def self.extract_active_connections(payload)
  connection = payload[:connection]
  return nil unless connection

  pool = connection.pool if connection.respond_to?(:pool)
  pool&.stat&.[](:busy)
rescue
  nil
end

.extract_adapter_name(payload) ⇒ String?

Extract database adapter name

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (String, nil)


165
166
167
168
169
170
171
# File 'lib/log_struct/integrations/active_record.rb', line 165

def self.extract_adapter_name(payload)
  connection = payload[:connection]
  return nil unless connection

  adapter_name = connection.class.name
  adapter_name&.split("::")&.last
end

.extract_and_filter_binds(payload) ⇒ Array<T.untyped>?

Extract and filter bind parameters

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (Array<T.untyped>, nil)


175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/log_struct/integrations/active_record.rb', line 175

def self.extract_and_filter_binds(payload)
  return nil unless LogStruct.config.integrations.sql_log_bind_params

  # Prefer type_casted_binds as they're more readable
  binds = payload[:type_casted_binds] || payload[:binds]
  return nil unless binds

  # Filter sensitive data from bind parameters
  binds.map do |bind|
    filter_bind_parameter(bind)
  end
end

.extract_database_name(payload) ⇒ String?

Extract database name from connection

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (String, nil)


190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/log_struct/integrations/active_record.rb', line 190

def self.extract_database_name(payload)
  connection = payload[:connection]
  return nil unless connection

  if connection.respond_to?(:current_database)
    connection.current_database
  elsif connection.respond_to?(:database)
    connection.database
  end
rescue
  nil
end

.extract_operation_type(payload) ⇒ String?

Extract SQL operation type (SELECT, INSERT, etc.)

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (String, nil)


229
230
231
232
233
234
235
236
# File 'lib/log_struct/integrations/active_record.rb', line 229

def self.extract_operation_type(payload)
  sql = payload[:sql]
  return nil unless sql

  # Extract first word of SQL query
  match = sql.strip.match(/\A\s*(\w+)/i)
  match&.captures&.first&.upcase
end

.extract_pool_size(payload) ⇒ Integer?

Extract connection pool size

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (Integer, nil)


205
206
207
208
209
210
211
212
213
# File 'lib/log_struct/integrations/active_record.rb', line 205

def self.extract_pool_size(payload)
  connection = payload[:connection]
  return nil unless connection

  pool = connection.pool if connection.respond_to?(:pool)
  pool&.size
rescue
  nil
end

.extract_row_count(payload) ⇒ Integer?

Extract row count from payload

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (Integer, nil)


158
159
160
161
# File 'lib/log_struct/integrations/active_record.rb', line 158

def self.extract_row_count(payload)
  row_count = payload[:row_count]
  row_count.is_a?(Integer) ? row_count : nil
end

.extract_table_names(payload) ⇒ Array<String>?

Extract table names from SQL query

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (Array<String>, nil)


240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/log_struct/integrations/active_record.rb', line 240

def self.extract_table_names(payload)
  sql = payload[:sql]
  return nil unless sql

  # Simple regex to extract table names (basic implementation)
  # This covers most common cases but could be enhanced
  tables = []

  # Match FROM, JOIN, UPDATE, INSERT INTO, DELETE FROM patterns
  sql.scan(/(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM)\s+["`]?(\w+)["`]?/i) do |match|
    table_name = match[0]
    tables << table_name unless tables.include?(table_name)
  end

  tables.empty? ? nil : tables
end

.filter_bind_parameter(value) ⇒ T.untyped

Filter individual bind parameter values to remove sensitive data

Parameters:

  • value (T.untyped)

Returns:

  • (T.untyped)


259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/log_struct/integrations/active_record.rb', line 259

def self.filter_bind_parameter(value)
  case value
  when String
    # Filter strings that look like passwords, tokens, secrets, etc.
    if looks_sensitive?(value)
      "[FILTERED]"
    else
      value
    end
  else
    value
  end
end

.format_sql_message(payload) ⇒ String

Format a readable message for the SQL log

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (String)


151
152
153
154
# File 'lib/log_struct/integrations/active_record.rb', line 151

def self.format_sql_message(payload)
  operation_name = payload[:name] || "SQL Query"
  "#{operation_name} executed"
end

.handle_sql_event(name, start, finish, id, payload) ⇒ void

This method returns an undefined value.

Process SQL notification event and create structured log

Parameters:

  • name (String)
  • start (T.untyped)
  • finish (T.untyped)
  • id (String)
  • payload (Hash{Symbol => T.untyped})


97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/log_struct/integrations/active_record.rb', line 97

def self.handle_sql_event(name, start, finish, id, payload)
  # Skip schema queries and Rails internal queries
  return if skip_query?(payload)

  duration_ms = ((finish - start) * 1000.0).round(2)

  # Skip fast queries if threshold is configured
  config = LogStruct.config
  if config.integrations.sql_slow_query_threshold&.positive?
    return if duration_ms < config.integrations.sql_slow_query_threshold
  end

  sql_log = Log::SQL.new(
    message: format_sql_message(payload),
    source: Source::App,
    event: Event::Database,
    sql: payload[:sql]&.strip || "",
    name: payload[:name] || "SQL Query",
    duration_ms: duration_ms,
    row_count: extract_row_count(payload),
    adapter: extract_adapter_name(payload),
    bind_params: extract_and_filter_binds(payload),
    database_name: extract_database_name(payload),
    connection_pool_size: extract_pool_size(payload),
    active_connections: extract_active_connections(payload),
    operation_type: extract_operation_type(payload),
    table_names: extract_table_names(payload)
  )

  LogStruct.info(sql_log)
end

.looks_sensitive?(value) ⇒ Boolean

Check if a string value looks sensitive and should be filtered

Parameters:

  • value (String)

Returns:

  • (Boolean)


275
276
277
278
279
280
281
282
283
284
285
# File 'lib/log_struct/integrations/active_record.rb', line 275

def self.looks_sensitive?(value)
  # Filter very long strings that might be tokens
  return true if value.length > 50

  # Filter strings that look like hashed passwords, API keys, tokens
  return true if value.match?(/\A[a-f0-9]{32,}\z/i)  # MD5, SHA, etc.
  return true if value.match?(/\A[A-Za-z0-9+\/]{20,}={0,2}\z/)  # Base64
  return true if value.match?(/(password|secret|token|key|auth)/i)

  false
end

.setup(config) ⇒ Boolean?

Set up SQL query logging integration

Parameters:

Returns:

  • (Boolean, nil)


49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/log_struct/integrations/active_record.rb', line 49

def self.setup(config)
  return nil unless config.integrations.enable_sql_logging
  return nil unless defined?(::ActiveRecord::Base)

  # Detach Rails' default ActiveRecord log subscriber to prevent
  # duplicate/unstructured SQL debug output when LogStruct SQL logging
  # is enabled. We still receive notifications via ActiveSupport.
  if defined?(::ActiveRecord::LogSubscriber)
    begin
      ::ActiveRecord::LogSubscriber.detach_from(:active_record)
    rescue => e
      LogStruct.handle_exception(e, source: LogStruct::Source::Internal)
    end
  end

  # Disable verbose query logs ("↳ caller") since LogStruct provides
  # structured context and these lines are noisy/unstructured.
  if ::ActiveRecord::Base.respond_to?(:verbose_query_logs=)
    T.unsafe(::ActiveRecord::Base).verbose_query_logs = false
  end

  subscribe_to_sql_notifications
  true
end

.skip_query?(payload) ⇒ Boolean

Determine if query should be skipped from logging

Parameters:

  • payload (Hash{Symbol => T.untyped})

Returns:

  • (Boolean)


131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/log_struct/integrations/active_record.rb', line 131

def self.skip_query?(payload)
  query_name = payload[:name]
  sql = payload[:sql]

  # Skip Rails schema queries
  return true if query_name&.include?("SCHEMA")
  return true if query_name&.include?("CACHE")

  # Skip common Rails internal queries
  return true if sql&.include?("schema_migrations")
  return true if sql&.include?("ar_internal_metadata")

  # Skip SHOW/DESCRIBE queries
  return true if sql&.match?(/\A\s*(SHOW|DESCRIBE|EXPLAIN)\s/i)

  false
end

.subscribe_to_sql_notificationsvoid

This method returns an undefined value.

Subscribe to ActiveRecord's sql.active_record notifications



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/log_struct/integrations/active_record.rb', line 78

def self.subscribe_to_sql_notifications
  # Avoid duplicate subscriptions; re-subscribe if the notifier was reset
  notifier = ::ActiveSupport::Notifications.notifier
  current_id = notifier&.object_id
  if STATE.subscribed && STATE.notifier_id == current_id
    return
  end

  ::ActiveSupport::Notifications.subscribe("sql.active_record") do |name, start, finish, id, payload|
    handle_sql_event(name, start, finish, id, payload)
  rescue => error
    LogStruct.handle_exception(error, source: LogStruct::Source::Internal)
  end
  STATE.subscribed = true
  STATE.notifier_id = current_id
end