feat: add detailed logging for ActiveRecord::ConnectionTimeoutError

- Add comprehensive connection pool and active query diagnostics
- Track database locks and blocking queries
- Include thread/process information for troubleshooting
- Add detailed PostgreSQL query wait event tracking
- Update exception tracker to support additional context
This commit is contained in:
Sojan
2025-03-19 16:56:27 -07:00
parent b34c526c51
commit 8b7c9173e2
2 changed files with 174 additions and 1 deletions
@@ -3,6 +3,7 @@ module RequestExceptionHandler
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::ConnectionTimeoutError, with: :handle_connection_timeout
end
private
@@ -18,10 +19,171 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::ConnectionTimeoutError => e
handle_connection_timeout(e)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
end
def log_connection_pool_stats(connection_pool)
{
pool_size: connection_pool.size,
active_connections: connection_pool.connections.count(&:in_use?),
total_connections: connection_pool.connections.count,
waiting_threads: connection_pool.num_waiting_in_queue,
checkout_timeout: connection_pool.checkout_timeout
}
end
def fetch_active_queries
query_data = ActiveRecord::Base.connection.execute(<<~SQL.squish)
SELECT pid,
now() - pg_stat_activity.query_start AS duration,
query,
state,
wait_event_type,
wait_event,
backend_type,
application_name,
client_addr,
usename
FROM pg_stat_activity
WHERE state <> 'idle'
AND query NOT ILIKE '%pg_stat_activity%'
ORDER BY duration DESC;
SQL
query_data.map do |row|
{
pid: row['pid'],
duration: row['duration'].to_s,
state: row['state'],
query: row['query'],
wait_event_type: row['wait_event_type'],
wait_event: row['wait_event'],
backend_type: row['backend_type'],
application_name: row['application_name'],
client_addr: row['client_addr'],
username: row['usename']
}
end
end
def fetch_locked_queries
lock_data = ActiveRecord::Base.connection.execute(<<~SQL.squish)
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_statement,
blocking_activity.query AS blocking_statement,
now() - blocking_activity.query_start AS blocking_duration
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
SQL
return [] if lock_data.count.zero?
lock_data.map do |row|
{
blocked_pid: row['blocked_pid'],
blocked_user: row['blocked_user'],
blocking_pid: row['blocking_pid'],
blocking_user: row['blocking_user'],
blocked_statement: row['blocked_statement'],
blocking_statement: row['blocking_statement'],
blocking_duration: row['blocking_duration'].to_s
}
end
end
def log_active_queries(active_queries)
if active_queries.any?
Rails.logger.error "Active Database Queries (#{active_queries.count}):"
active_queries.each_with_index do |query_info, index|
Rails.logger.error "Query ##{index + 1} [PID: #{query_info[:pid]}] [Duration: #{query_info[:duration]}] [State: #{query_info[:state]}]:"
Rails.logger.error "App: #{query_info[:application_name]} User: #{query_info[:username]} Client: #{query_info[:client_addr]}"
Rails.logger.error "Waiting: #{query_info[:wait_event_type]} / #{query_info[:wait_event]}" if query_info[:wait_event_type].present?
Rails.logger.error query_info[:query]
end
else
Rails.logger.error 'No active queries found or unable to retrieve query information'
end
end
def log_locked_queries(locked_queries)
return unless locked_queries.present?
Rails.logger.error "Locked Queries (#{locked_queries.count}):"
locked_queries.each_with_index do |lock_info, index|
Rails.logger.error "Lock ##{index + 1}: PID #{lock_info[:blocked_pid]} blocked by PID #{lock_info[:blocking_pid]}"
Rails.logger.error "Duration: #{lock_info[:blocking_duration]}"
Rails.logger.error "Blocked query: #{lock_info[:blocked_statement]}"
Rails.logger.error "Blocking query: #{lock_info[:blocking_statement]}"
end
end
def handle_connection_timeout(exception)
connection_pool = ActiveRecord::Base.connection_pool
connection_info = log_connection_pool_stats(connection_pool)
active_queries = []
locked_queries = []
begin
active_queries = fetch_active_queries
locked_queries = fetch_locked_queries
connection_info[:connection_status] = ActiveRecord::Base.connection.execute(<<~SQL.squish).to_a
SELECT count(*) as connection_count, state
FROM pg_stat_activity
GROUP BY state;
SQL
rescue StandardError => e
Rails.logger.error "Error fetching active query data: #{e.message}"
end
connection_info[:active_queries] = active_queries
connection_info[:locked_queries] = locked_queries
# Get the process/thread ID that's experiencing the timeout
connection_info[:caller_info] = {
process_id: Process.pid,
thread_id: Thread.current.object_id,
backtrace: exception.backtrace&.first(15) || []
}
# Log details about the error
Rails.logger.error "ActiveRecord::ConnectionTimeoutError: #{exception.message}"
Rails.logger.error "Connection Pool Stats: #{connection_info.except(:active_queries, :locked_queries).inspect}"
log_active_queries(active_queries)
log_locked_queries(locked_queries)
# Report to exception tracker with additional context
ChatwootExceptionTracker.new(
exception,
user: Current.user,
account: Current.account,
additional_context: { connection_info: connection_info }
).capture_exception
render_service_unavailable('Database connection timeout. Please try again later.')
end
def render_unauthorized(message)
render json: { error: message }, status: :unauthorized
@@ -42,6 +204,10 @@ module RequestExceptionHandler
def render_internal_server_error(message)
render json: { error: message }, status: :internal_server_error
end
def render_service_unavailable(message)
render json: { error: message }, status: :service_unavailable
end
def render_record_invalid(exception)
log_handled_error(exception)
+8 -1
View File
@@ -5,10 +5,11 @@
############
class ChatwootExceptionTracker
def initialize(exception, user: nil, account: nil)
def initialize(exception, user: nil, account: nil, additional_context: {})
@exception = exception
@user = user
@account = account
@additional_context = additional_context
end
def capture_exception
@@ -26,6 +27,12 @@ class ChatwootExceptionTracker
end
scope.set_user(id: @user.id, email: @user.email) if @user.is_a?(User)
# Add additional context if provided
@additional_context.each do |context_name, context_data|
scope.set_context(context_name.to_s, context_data)
end
Sentry.capture_exception(@exception)
end
end