RequestMetrics

Per-request metric tracking and structured log summaries for Rails controllers.

Subclass RequestMetrics::Base, declare counters with metric_accessor, implement #log to record each event, and Rails will append a summary to every process_action log line — zero boilerplate.

Completed 200 OK in 142ms (Views: 0.5ms | GQL: 87.3ms, 44 cost | Loop API: 31.1ms)

Installation

Add to your Gemfile:

gem "request_metrics"

No initializer needed. A Railtie installs all registered subclasses into ActionController::Base automatically.

Usage

1. Subclass RequestMetrics::Base

class MyServiceMetrics < RequestMetrics::Base
  metric_accessor :my_service_runtime  # declares counter + thread-safe accessors

  # Called on each tracked event — add to the counter and log the line
  def log(ms:, url:, status:)
    add_my_service_runtime(ms)
    name = color("  MyService (#{ms.round(1)}ms)", YELLOW, bold: true)
    debug "#{name}  #{url} (status: #{status})"
  end

  # Optional — return a string to append to the controller summary log line
  def self.summary_log(payload)
    ms = payload[:my_service_runtime] || 0
    "MyService: #{ms.round(1)}ms" if ms > 0
  end
end

2. Call .log from your HTTP client or wherever the event occurs

ms = ActiveSupport::Benchmark.realtime(:float_millisecond) { response = do_request }
MyServiceMetrics.log(ms:, url:, status: response.code)

3. Filter noise from source location output

verbose_query_logs (default true) appends a ↳ app/... caller hint below each log line. Add silencers to suppress framework internals:

class MyServiceMetrics < RequestMetrics::Base
  backtrace_cleaner.add_silencer { |line| line.include?("app/clients/") }
  backtrace_cleaner.add_silencer { |line| line.include?("app/models/concerns/") }
  # ...
end

Real-world examples

These are the two metrics classes from a Rails + Shopify app.

Loop API (HTTP client tracking)

Tracks every outbound call to the Loop Subscriptions API, including cached responses.

class LoopControllerMetrics < RequestMetrics::Base
  backtrace_cleaner.add_silencer { |line| line.include?(__FILE__) }
  backtrace_cleaner.add_silencer { |line| line.include?("app/clients/") }
  backtrace_cleaner.add_silencer { |line| line.include?("app/models/concerns/") }
  backtrace_cleaner.add_silencer { |line| line.include?("config/initializers/") }

  metric_accessor :loop_runtime

  def log(method:, url:, ms:, status:, data: nil, cached: false)
    add_loop_runtime(ms)

    http_color =
      case status.to_i
      when 200..299 then GREEN
      when 300..399 then CYAN
      when 400..499 then YELLOW
      when 500..599 then RED
      else               MAGENTA
      end

    name    = color("  #{cached ? "CACHE " : ""}Loop API (#{ms.round(1)}ms)", YELLOW, bold: true)
    request = color("#{method} #{url}", http_color, bold: true)

    debug "#{name}  #{request} #{data&.to_json} (status: #{status})"
  end

  def self.summary_log(payload)
    ms = payload[:loop_runtime] || 0
    "Loop API: #{ms.round(1)}ms" if ms > 0
  end
end

Called from the HTTP client:

ms = ActiveSupport::Benchmark.realtime(:float_millisecond) { perform.call }
LoopControllerMetrics.log(method: request.method, url: uri.to_s, ms:, status: response.code, data:)

Cached hits are logged with zero ms and CACHE prefix:

LoopControllerMetrics.log(method: method.upcase, url:, ms: 0, status: 200, cached: true)

Shopify GraphQL (API client patching)

Tracks every Shopify Admin API GraphQL call, including query cost. Patches the official shopify_api gem's client via prepend.

class ShopifyGraphqlMetrics < RequestMetrics::Base
  backtrace_cleaner.add_silencer { |line| line.include?(__FILE__) }
  backtrace_cleaner.add_silencer { |line| line.include?("app/models/shop.rb") }
  backtrace_cleaner.add_silencer { |line| line.include?("app/models/concerns/") }
  backtrace_cleaner.add_silencer { |line| line.include?("config/initializers/") }

  metric_accessor :graphql_runtime
  metric_accessor :graphql_cost

  def self.install!
    super
    require "shopify_api/clients/graphql/admin"
    ShopifyAPI::Clients::Graphql::Client.prepend(ShopifyAPIClientLoggingPatch)
  end

  module ShopifyAPIClientLoggingPatch
    def query(query:, variables: nil, headers: nil, tries: 1, response_as_struct: ShopifyAPI::Context.response_as_struct, debug: false)
      response = nil
      ms = ActiveSupport::Benchmark.realtime(:float_millisecond) { response = super }
      cost = response.body.dig("extensions", "cost")
      ShopifyGraphqlMetrics.log(query:, ms:, cost:, variables:)
      response
    end
  end

  def log(query:, ms:, cost:, variables:, cached: false)
    add_graphql_runtime(ms)
    add_graphql_cost(cost.dig("requestedQueryCost")) if cost

    graphql_color =
      case query
      when /\A\s*mutation/i    then GREEN
      when /\A\s*query/i       then BLUE
      when /\A\s*subscription/i then CYAN
      else                          MAGENTA
      end

    name          = color("  #{cached ? "CACHE " : ""}GraphQL (#{ms.round(1)}ms)", YELLOW, bold: true)
    colored_query = color(query.gsub(/\s+/, " ").strip, graphql_color, bold: true)
    binds         = variables.present? ? "  #{variables.inspect}" : ""
    cost_info     = "\n  ↳ cost: #{cost}" if cost

    debug "#{name}  #{colored_query}#{binds}#{cost_info}"
  end

  def self.summary_log(payload)
    runtime = payload[:graphql_runtime]
    cost    = payload[:graphql_cost]

    if runtime && runtime > 0
      cost_info = cost && cost > 0 ? ", #{cost.round} cost" : ""
      "GQL: #{runtime.round(1)}ms#{cost_info}"
    end
  end
end

Result in logs:

  GraphQL (87.3ms)  query GetSubscription { ... }  { id: "gid://shopify/..." }
  ↳ cost: {"requestedQueryCost"=>44, ...}
  ↳ app/models/concerns/loop/subscription/persistence.rb:23:in `find'

Completed 200 OK in 142ms (Views: 0.5ms | GQL: 87.3ms, 44 cost | Loop API: 31.1ms)

API reference

metric_accessor(name)

Declares a per-request counter stored in a thread-local. Generates:

Method Description
MyMetrics.my_metric Read current value (default: 0)
MyMetrics.my_metric = n Set value
MyMetrics.add_my_metric(delta) Increment
MyMetrics.reset_my_metric Return current value and reset to 0

Thread-local keys are namespaced by subclass name, so two subclasses can both declare metric_accessor :runtime without collision.

#log(**kwargs) (instance, delegated to class)

Called per event. Must be implemented by subclasses. Raise NotImplementedError if not.

.summary_log(payload) (class)

Called once per request after process_action. Return a String to append to the log summary, or nil to skip. Default implementation returns nil.

.install! (class)

Called automatically by the Railtie. Can be overridden to do additional setup (e.g., patching a third-party client) — call super to preserve the ActionController hook.

backtrace_cleaner

Each subclass gets its own ActiveSupport::BacktraceCleaner instance (empty by default). Add silencers to filter which stack frame appears in the hint.

verbose_query_logs

Boolean (default: true). Set to false to suppress the ↳ source hint entirely.


Development

bin/setup    # install dependencies
rake test    # run tests
bin/console  # interactive prompt

License

MIT.