Tuesday, November 11, 2025

Building Free Performance Monitoring: Detecting Slow Queries and Requests

Paul (Founder)
Development
Developer building automated monitoring infrastructure that watches performance while you sleep

Performance problems hide in plain sight. A controller action taking 800ms feels "fast enough" during development testing single requests, yet becomes unacceptable when fifty concurrent users generate hundreds of requests per minute. Database queries running in 150ms barely register during development with small datasets, yet balloon to seconds when production databases grow to thousands of records. Without visibility into actual performance, degradation sneaks in gradually until users complain.

Week 46 implemented comprehensive performance monitoring infrastructure providing automatic detection of slow queries and requests without expensive third-party services. The system logs slow queries with stack traces identifying problematic code, times every request measuring controller action duration, sends email alerts when configurable thresholds exceed limits, and provides rake tasks analyzing logs to suggest missing database indexes. This article explores the monitoring implementation, email alert system, profiling tools integration, and database performance analysis.

What Your Team Will Notice

Development logs now show clear performance warnings whenever queries exceed 100ms or requests exceed 500ms (configurable thresholds). Slow query warnings include full stack traces showing exactly which model method or controller action triggered the problem, the SQL query itself for analysis, and execution time highlighting how slow the query ran.

Email alerts arrive immediately when performance problems occur in production. Slow request alerts show the controller action, execution time, request parameters (sanitized), and timestamp. Slow query alerts include the SQL statement, execution time, stack trace showing calling code, and affected table information. These alerts enable rapid response—developers know about performance problems within seconds rather than waiting for user reports.

New rake tasks provide database optimization guidance: rake performance:check_indexes analyzes models suggesting which indexes might improve query performance based on common query patterns, and rake performance:analyze_queries processes Rails logs identifying the slowest queries and suggesting optimization approaches. These tasks turn abstract performance monitoring into actionable optimization tasks.

Development profiling tools integrate seamlessly: flamegraph and stackprof gems enable detailed CPU profiling when investigating specific performance problems, generating visual flamegraph SVGs showing where code spends time, and providing detailed profiling reports for optimization work. These tools sit dormant until needed, available instantly when investigation requires them.

The monitoring infrastructure operates transparently—no code changes required in controllers or models, no manual instrumentation scattered through business logic, and no performance overhead when everything runs quickly. Monitoring activates only when problems occur, making itself invisible during normal operation whilst providing crucial visibility during performance degradation.

Under the Bonnet: Request Timing with Around Action

Controller request timing uses around_action callbacks:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  around_action :log_performance

  private

  def log_performance
    start_time = Time.current
    yield
  ensure
    duration = ((Time.current - start_time) * 1000).round(2)

    if duration > slow_request_threshold
      log_slow_request(duration)
      send_slow_request_alert(duration) if Rails.env.production?
    end
  end

  def slow_request_threshold
    ENV.fetch('SLOW_REQUEST_THRESHOLD', 500).to_i
  end

  def log_slow_request(duration)
    Rails.logger.warn(
      "[SLOW REQUEST] #{controller_name}##{action_name} " \
      "took #{duration}ms " \
      "(threshold: #{slow_request_threshold}ms)"
    )

    Rails.logger.warn(
      "  Parameters: #{sanitize_params(params.to_unsafe_h)}"
    )
  end

  def send_slow_request_alert(duration)
    PerformanceMailer.slow_request_alert(
      controller: controller_name,
      action: action_name,
      duration: duration,
      params: sanitize_params(params.to_unsafe_h),
      timestamp: Time.current
    ).deliver_later
  end

  def sanitize_params(params_hash)
    # Remove sensitive parameters
    params_hash.except('password', 'password_confirmation', 'token', 'api_key')
  end
end

This callback measures every request transparently, logging slow requests and sending alerts when thresholds exceed, without polluting controller code with timing logic.

Automatic Slow Query Detection

Database query monitoring patches ActiveRecord:

# config/initializers/performance_monitoring.rb
module QueryPerformanceMonitoring
  def exec_query(sql, name = nil, binds = [], prepare: false)
    start_time = Time.current
    result = super
  ensure
    duration = ((Time.current - start_time) * 1000).round(2)

    if duration > slow_query_threshold
      log_slow_query(sql, duration, caller)
      send_slow_query_alert(sql, duration, caller) if Rails.env.production?
    end

    result
  end

  private

  def slow_query_threshold
    ENV.fetch('SLOW_QUERY_THRESHOLD', 100).to_i
  end

  def log_slow_query(sql, duration, call_stack)
    Rails.logger.warn(
      "[SLOW QUERY] #{duration}ms (threshold: #{slow_query_threshold}ms)"
    )
    Rails.logger.warn("  SQL: #{sql}")
    Rails.logger.warn("  Called from:")

    # Show relevant stack trace (skip framework internals)
    relevant_stack = call_stack
                      .reject { |line| line.include?('activerecord') }
                      .reject { |line| line.include?('activesupport') }
                      .first(5)

    relevant_stack.each do |line|
      Rails.logger.warn("    #{line}")
    end
  end

  def send_slow_query_alert(sql, duration, call_stack)
    EmailNotifier.slow_query_alert(
      sql: sql,
      duration: duration,
      stack_trace: call_stack.first(10),
      threshold: slow_query_threshold,
      timestamp: Time.current
    )
  end
end

# Patch ActiveRecord connection adapters
ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(QueryPerformanceMonitoring)

This monkey patch intercepts all database queries, timing them automatically and alerting when slow queries occur without requiring explicit instrumentation.

Email Alert System

The email notifier handles alert delivery:

# app/services/email_notifier.rb
class EmailNotifier
  def self.slow_query_alert(sql:, duration:, stack_trace:, threshold:, timestamp:)
    return unless should_send_alert?

    PerformanceMailer.slow_query_alert(
      sql: sql,
      duration: duration,
      stack_trace: stack_trace,
      threshold: threshold,
      timestamp: timestamp
    ).deliver_later
  end

  def self.slow_request_alert(controller:, action:, duration:, params:, timestamp:)
    return unless should_send_alert?

    PerformanceMailer.slow_request_alert(
      controller: controller,
      action: action,
      duration: duration,
      params: params,
      timestamp: timestamp
    ).deliver_later
  end

  private

  def self.should_send_alert?
    Rails.env.production? &&
      ENV['PERFORMANCE_ALERT_EMAIL'].present? &&
      ENV['PERFORMANCE_ALERT_FROM_EMAIL'].present?
  end
end

# app/mailers/performance_mailer.rb
class PerformanceMailer < ApplicationMailer
  def slow_query_alert(sql:, duration:, stack_trace:, threshold:, timestamp:)
    @sql = sql
    @duration = duration
    @stack_trace = stack_trace
    @threshold = threshold
    @timestamp = timestamp
    @environment = Rails.env

    mail(
      to: ENV['PERFORMANCE_ALERT_EMAIL'],
      from: ENV['PERFORMANCE_ALERT_FROM_EMAIL'],
      subject: "[#{@environment.upcase}] Slow Query Alert: #{@duration}ms"
    )
  end

  def slow_request_alert(controller:, action:, duration:, params:, timestamp:)
    @controller = controller
    @action = action
    @duration = duration
    @params = params
    @timestamp = timestamp
    @environment = Rails.env

    mail(
      to: ENV['PERFORMANCE_ALERT_EMAIL'],
      from: ENV['PERFORMANCE_ALERT_FROM_EMAIL'],
      subject: "[#{@environment.upcase}] Slow Request Alert: #{@controller}##{@action} (#{@duration}ms)"
    )
  end
end

This service centralizes alert logic, ensuring consistent alert behaviour whilst respecting environment configuration.

Email Templates

Alert emails provide actionable information:

<!-- app/views/performance_mailer/slow_query_alert.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: monospace; line-height: 1.6; }
    .alert-header { background: #dc3545; color: white; padding: 15px; }
    .alert-body { padding: 15px; }
    .sql-query { background: #f8f9fa; padding: 10px; overflow-x: auto; }
    .stack-trace { background: #fff3cd; padding: 10px; font-size: 12px; }
  </style>
</head>
<body>
  <div class="alert-header">
    <h2>⚠️ Slow Query Detected</h2>
    <p><strong><%= @environment.upcase %></strong> environment at <%= @timestamp.strftime('%Y-%m-%d %H:%M:%S') %></p>
  </div>

  <div class="alert-body">
    <h3>Query Performance</h3>
    <p><strong>Execution Time:</strong> <%= @duration %>ms (threshold: <%= @threshold %>ms)</p>

    <h3>SQL Query</h3>
    <div class="sql-query">
      <code><%= @sql %></code>
    </div>

    <h3>Stack Trace</h3>
    <div class="stack-trace">
      <% @stack_trace.each do |line| %>
        <div><%= line %></div>
      <% end %>
    </div>

    <h3>Recommended Actions</h3>
    <ul>
      <li>Check if appropriate database indexes exist</li>
      <li>Consider query optimization or N+1 prevention</li>
      <li>Review recent code changes affecting this query</li>
      <li>Run: <code>rake performance:check_indexes</code></li>
    </ul>
  </div>
</body>
</html>

These templates provide complete context for investigating performance problems without requiring log file access.

Database Performance Analysis Tools

Rake tasks automate performance analysis:

# lib/tasks/performance.rake
namespace :performance do
  desc 'Check for missing database indexes'
  task check_indexes: :environment do
    puts 'Analyzing models for potential missing indexes...'

    missing_indexes = []

    ApplicationRecord.descendants.each do |model|
      next if model.abstract_class?

      # Check for foreign keys without indexes
      model.reflections.each do |name, reflection|
        if reflection.belongs_to?
          foreign_key = reflection.foreign_key

          unless has_index?(model, foreign_key)
            missing_indexes << {
              model: model.name,
              column: foreign_key,
              reason: "Foreign key (#{name})"
            }
          end
        end
      end

      # Check for commonly queried columns
      %w[created_at updated_at status type].each do |column|
        if model.column_names.include?(column)
          unless has_index?(model, column)
            missing_indexes << {
              model: model.name,
              column: column,
              reason: "Commonly queried column"
            }
          end
        end
      end
    end

    if missing_indexes.any?
      puts "\nFound #{missing_indexes.count} potentially missing indexes:"
      puts

      missing_indexes.group_by { |idx| idx[:model] }.each do |model, indexes|
        puts "#{model}:"
        indexes.each do |idx|
          puts "  - #{idx[:column]} (#{idx[:reason]})"
          puts "    add_index :#{model.tableize}, :#{idx[:column]}"
        end
        puts
      end
    else
      puts 'No obviously missing indexes found.'
    end
  end

  desc 'Analyze slow queries from logs'
  task analyze_queries: :environment do
    log_file = Rails.root.join('log', "#{Rails.env}.log")

    unless File.exist?(log_file)
      puts "Log file not found: #{log_file}"
      exit 1
    end

    slow_queries = []
    File.readlines(log_file).each do |line|
      if line.include?('[SLOW QUERY]')
        match = line.match(/\[SLOW QUERY\] (\d+)ms/)
        duration = match[1].to_i if match

        # Extract SQL from next line
        sql = line.split('SQL: ').last&.strip

        slow_queries << { duration: duration, sql: sql } if duration && sql
      end
    end

    if slow_queries.any?
      puts "Found #{slow_queries.count} slow queries in logs:"
      puts

      # Group and sort by frequency
      slow_queries.group_by { |q| q[:sql] }.sort_by { |_sql, queries| -queries.count }.each do |sql, queries|
        avg_duration = queries.sum { |q| q[:duration] } / queries.count
        puts "Occurrences: #{queries.count}, Avg: #{avg_duration}ms"
        puts sql
        puts
      end
    else
      puts 'No slow queries found in logs.'
    end
  end

  private

  def has_index?(model, column)
    model.connection.indexes(model.table_name).any? do |index|
      index.columns.include?(column.to_s) ||
        (index.columns.length == 1 && index.columns.first == column.to_s)
    end
  end
end

These tasks analyze application structure and runtime logs identifying optimization opportunities systematically.

Profiling Tools Integration

Advanced profiling requires additional gems:

# Gemfile
group :development do
  gem 'flamegraph'
  gem 'stackprof'
  gem 'rack-mini-profiler'
end

# config/initializers/profiling.rb
if Rails.env.development?
  require 'rack-mini-profiler'

  # Rack::MiniProfiler shows profiling UI in development
  Rack::MiniProfilerRails.initialize!(Rails.application)
end

Flamegraph generation for specific requests:

# To profile a specific request in development:
# Add ?pp=flamegraph to any URL to generate flamegraph

# Example: http://localhost:3000/properties?pp=flamegraph
# Generates flamegraph SVG showing time spent in each method

These tools provide deep CPU profiling when investigating specific performance bottlenecks requiring method-level analysis.

What's Next

The monitoring foundation enables sophisticated performance management: integrating with third-party APM services (Skylight, Scout, New Relic) for detailed analytics, implementing custom performance dashboards visualizing trends over time, adding database query plan analysis detecting inefficient execution plans, and implementing automatic performance regression detection in CI pipelines.

Future enhancements might include memory profiling detecting memory leaks, background job performance tracking monitoring Sidekiq job duration, API endpoint latency tracking with percentile calculations, and custom business metric instrumentation measuring feature-specific performance.

By implementing comprehensive performance monitoring infrastructure without expensive third-party dependencies, LetAdmin gains crucial visibility into production performance, enabling rapid identification and resolution of performance problems whilst maintaining complete control over monitoring data and infrastructure costs.