turniere-backend/app/services/metrics_registry.rb

213 lines
6.5 KiB
Ruby

# frozen_string_literal: true
class MetricsRegistry
HTTP_LABELS = %i[method controller action status].freeze
REQUEST_DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10].freeze
STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC)
class << self
def record_http_request(method:, controller:, action:, status:, duration:)
labels = http_labels(method:, controller:, action:, status:)
synchronize do
@http_requests[labels] += 1
histogram = @http_request_duration[labels]
histogram[:count] += 1
histogram[:sum] += duration.to_f
REQUEST_DURATION_BUCKETS.each do |bucket|
histogram[:buckets][bucket] += 1 if duration.to_f <= bucket
end
end
end
def action_cable_connected
synchronize do
@action_cable_connections += 1
@action_cable_connections_total += 1
end
end
def action_cable_disconnected
synchronize do
@action_cable_connections = [@action_cable_connections - 1, 0].max
end
end
def render_prometheus
snapshot = snapshot_metrics
PrometheusRenderer.new(snapshot).render
end
def reset!
@mutex = Mutex.new
@http_requests = Hash.new(0)
@http_request_duration = Hash.new do |hash, labels|
hash[labels] = {
count: 0,
sum: 0.0,
buckets: REQUEST_DURATION_BUCKETS.index_with(0)
}
end
@action_cable_connections = 0
@action_cable_connections_total = 0
end
private
def synchronize(&block)
reset! if @mutex.nil?
@mutex.synchronize(&block)
end
def snapshot_metrics
synchronize do
{
http_requests: @http_requests.dup,
http_request_duration: @http_request_duration.transform_values do |histogram|
{
count: histogram.fetch(:count),
sum: histogram.fetch(:sum),
buckets: histogram.fetch(:buckets).dup
}
end,
action_cable_connections: @action_cable_connections,
action_cable_connections_total: @action_cable_connections_total,
uptime_seconds: Process.clock_gettime(Process::CLOCK_MONOTONIC) - STARTED_AT,
active_record_pool: active_record_pool_stats,
git_commit_sha: ENV.fetch('GIT_COMMIT_SHA', 'unknown')
}
end
end
def http_labels(method:, controller:, action:, status:)
{
method: method.to_s.upcase,
controller: controller.to_s,
action: action.to_s,
status: status.to_i.to_s
}.freeze
end
def active_record_pool_stats
return {} unless defined?(ActiveRecord::Base)
ActiveRecord::Base.connection_pool.stat
rescue StandardError
{}
end
end
class PrometheusRenderer
def initialize(snapshot)
@snapshot = snapshot
@lines = []
end
def render
render_build_info
render_http_requests
render_http_request_duration
render_action_cable_connections
render_process_metrics
render_active_record_pool
"#{lines.join("\n")}\n"
end
private
attr_reader :snapshot, :lines
def render_build_info
help('turniere_build_info', 'Build metadata for this process.')
type('turniere_build_info', 'gauge')
sample('turniere_build_info', 1, git_commit_sha: snapshot.fetch(:git_commit_sha))
end
def render_http_requests
help('turniere_http_requests_total', 'Total HTTP requests handled by Rails controllers.')
type('turniere_http_requests_total', 'counter')
snapshot.fetch(:http_requests).each do |labels, count|
sample('turniere_http_requests_total', count, labels)
end
end
def render_http_request_duration
help('turniere_http_request_duration_seconds', 'HTTP request duration in seconds.')
type('turniere_http_request_duration_seconds', 'histogram')
snapshot.fetch(:http_request_duration).each do |labels, histogram|
histogram.fetch(:buckets).each do |bucket, count|
sample('turniere_http_request_duration_seconds_bucket', count, labels.merge(le: format_number(bucket)))
end
sample('turniere_http_request_duration_seconds_bucket', histogram.fetch(:count), labels.merge(le: '+Inf'))
sample('turniere_http_request_duration_seconds_sum', histogram.fetch(:sum), labels)
sample('turniere_http_request_duration_seconds_count', histogram.fetch(:count), labels)
end
end
def render_action_cable_connections
help('turniere_action_cable_connections', 'Currently open ActionCable websocket connections.')
type('turniere_action_cable_connections', 'gauge')
sample('turniere_action_cable_connections', snapshot.fetch(:action_cable_connections))
help('turniere_action_cable_connections_total', 'Total ActionCable websocket connections opened.')
type('turniere_action_cable_connections_total', 'counter')
sample('turniere_action_cable_connections_total', snapshot.fetch(:action_cable_connections_total))
end
def render_process_metrics
help('turniere_process_uptime_seconds', 'Process uptime in seconds.')
type('turniere_process_uptime_seconds', 'gauge')
sample('turniere_process_uptime_seconds', snapshot.fetch(:uptime_seconds))
help('turniere_ruby_threads', 'Ruby threads visible in this process.')
type('turniere_ruby_threads', 'gauge')
sample('turniere_ruby_threads', Thread.list.count)
end
def render_active_record_pool
help('turniere_active_record_connection_pool', 'ActiveRecord connection pool state.')
type('turniere_active_record_connection_pool', 'gauge')
snapshot.fetch(:active_record_pool).each do |state, value|
sample('turniere_active_record_connection_pool', value, state:)
end
end
def help(name, text)
lines << "# HELP #{name} #{text}"
end
def type(name, type)
lines << "# TYPE #{name} #{type}"
end
def sample(name, value, labels = {})
lines << "#{name}#{labels_string(labels)} #{format_number(value)}"
end
def labels_string(labels)
return '' if labels.empty?
formatted = labels.map do |key, value|
%(#{key}="#{escape_label_value(value)}")
end
"{#{formatted.join(',')}}"
end
def escape_label_value(value)
value.to_s.gsub('\\', '\\\\\\').gsub("\n", '\\n').gsub('"', '\"')
end
def format_number(value)
return value.to_s if value.is_a?(Integer)
format('%.12g', value)
end
end
end
MetricsRegistry.reset!