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)
Add to your Gemfile:
gem "request_metrics"No initializer needed. A Railtie installs all registered subclasses into ActionController::Base automatically.
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
endms = ActiveSupport::Benchmark.realtime(:float_millisecond) { response = do_request }
MyServiceMetrics.log(ms:, url:, status: response.code)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/") }
# ...
endThese are the two metrics classes from a Rails + Shopify app.
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
endCalled 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)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
endResult 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)
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.
Called per event. Must be implemented by subclasses. Raise NotImplementedError if not.
Called once per request after process_action. Return a String to append to the log summary, or nil to skip. Default implementation returns nil.
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.
Each subclass gets its own ActiveSupport::BacktraceCleaner instance (empty by default). Add silencers to filter which stack frame appears in the ↳ hint.
Boolean (default: true). Set to false to suppress the ↳ source hint entirely.
bin/setup # install dependencies
rake test # run tests
bin/console # interactive promptMIT.