# frozen_string_literal: true
require "active_support/core_ext/module/attribute_accessors_per_thread"
require "active_record/query_logs_formatter"
module ActiveRecord
# = Active Record Query Logs
#
# Automatically append comments to SQL queries with runtime information tags. This can be used to trace troublesome
# SQL statements back to the application code that generated these statements.
#
# Query logs can be enabled via \Rails configuration in config/application.rb or an initializer:
#
# config.active_record.query_log_tags_enabled = true
#
# By default the name of the application, the name and action of the controller, or the name of the job are logged.
# The default format is {SQLCommenter}[https://open-telemetry.github.io/opentelemetry-sqlcommenter/].
# The tags shown in a query comment can be configured via \Rails configuration:
#
# config.active_record.query_log_tags = [ :application, :controller, :action, :job ]
#
# Active Record defines default tags available for use:
#
# * +application+
# * +pid+
# * +socket+
# * +db_host+
# * +database+
# * +source_location+
#
# Action Controller adds default tags when loaded:
#
# * +controller+
# * +action+
# * +namespaced_controller+
#
# Active Job adds default tags when loaded:
#
# * +job+
#
# New comment tags can be defined by adding them in a +Hash+ to the tags +Array+. Tags can have dynamic content by
# setting a +Proc+ or lambda value in the +Hash+, and can reference any value stored by \Rails in the +context+ object.
# ActiveSupport::CurrentAttributes can be used to store application values. Tags with +nil+ values are
# omitted from the query comment.
#
# Escaping is performed on the string returned, however untrusted user input should not be used.
#
# Example:
#
# config.active_record.query_log_tags = [
# :namespaced_controller,
# :action,
# :job,
# {
# request_id: ->(context) { context[:controller]&.request&.request_id },
# job_id: ->(context) { context[:job]&.job_id },
# tenant_id: -> { Current.tenant&.id },
# static: "value",
# },
# ]
#
# By default the name of the application, the name and action of the controller, or the name of the job are logged
# using the {SQLCommenter}[https://open-telemetry.github.io/opentelemetry-sqlcommenter/] format. This can be changed
# via {config.active_record.query_log_tags_format}[https://guides.rubyonrails.org/configuring.html#config-active-record-query-log-tags-format]
#
# Tag comments can be prepended to the query:
#
# ActiveRecord::QueryLogs.prepend_comment = true
#
# For applications where the content will not change during the lifetime of
# the request or job execution, the tags can be cached for reuse in every query:
#
# config.active_record.cache_query_log_tags = true
module QueryLogs
class GetKeyHandler # :nodoc:
def initialize(name)
@name = name
end
def call(context)
context[@name]
end
end
class IdentityHandler # :nodoc:
def initialize(value)
@value = value
end
def call(_context)
@value
end
end
class ZeroArityHandler # :nodoc:
def initialize(proc)
@proc = proc
end
def call(_context)
@proc.call
end
end
@taggings = {}.freeze
@tags = [ :application ].freeze
@prepend_comment = false
@cache_query_log_tags = false
@tags_formatter = false
thread_mattr_accessor :cached_comment, instance_accessor: false
class << self
attr_reader :tags, :taggings, :tags_formatter # :nodoc:
attr_accessor :prepend_comment, :cache_query_log_tags # :nodoc:
def taggings=(taggings) # :nodoc:
@taggings = taggings.freeze
@handlers = rebuild_handlers
end
def tags=(tags) # :nodoc:
@tags = tags.freeze
@handlers = rebuild_handlers
end
def tags_formatter=(format) # :nodoc:
@formatter = case format
when :legacy
LegacyFormatter
when :sqlcommenter
SQLCommenter
else
raise ArgumentError, "Formatter is unsupported: #{format}"
end
@tags_formatter = format
end
def call(sql, connection) # :nodoc:
comment = self.comment(connection)
if comment.blank?
sql
elsif prepend_comment
"#{comment} #{sql}"
else
"#{sql} #{comment}"
end
end
def clear_cache # :nodoc:
self.cached_comment = nil
end
if Thread.respond_to?(:each_caller_location)
def query_source_location # :nodoc:
Thread.each_caller_location do |location|
frame = LogSubscriber.backtrace_cleaner.clean_frame(location.path)
return frame if frame
end
nil
end
else
def query_source_location # :nodoc:
LogSubscriber.backtrace_cleaner.clean(caller_locations(1).each).first
end
end
ActiveSupport::ExecutionContext.after_change { ActiveRecord::QueryLogs.clear_cache }
private
def rebuild_handlers
handlers = []
@tags.each do |i|
if i.is_a?(Hash)
i.each do |k, v|
handlers << [k, build_handler(k, v)]
end
else
handlers << [i, build_handler(i)]
end
end
handlers.sort_by! { |(key, _)| key.to_s }
end
def build_handler(name, handler = nil)
handler ||= @taggings[name]
if handler.nil?
GetKeyHandler.new(name)
elsif handler.respond_to?(:call)
if handler.arity == 0
ZeroArityHandler.new(handler)
else
handler
end
else
IdentityHandler.new(handler)
end
end
# Returns an SQL comment +String+ containing the query log tags.
# Sets and returns a cached comment if cache_query_log_tags is +true+.
def comment(connection)
if cache_query_log_tags
self.cached_comment ||= uncached_comment(connection)
else
uncached_comment(connection)
end
end
def uncached_comment(connection)
content = tag_content(connection)
if content.present?
"/*#{escape_sql_comment(content)}*/"
end
end
def escape_sql_comment(content)
# Sanitize a string to appear within a SQL comment
# For compatibility, this also surrounding "/*+", "/*", and "*/"
# characters, possibly with single surrounding space.
# Then follows that by replacing any internal "*/" or "/ *" with
# "* /" or "/ *"
comment = content.to_s.dup
comment.gsub!(%r{\A\s*/\*\+?\s?|\s?\*/\s*\Z}, "")
comment.gsub!("*/", "* /")
comment.gsub!("/*", "/ *")
comment
end
def tag_content(connection)
context = ActiveSupport::ExecutionContext.to_h
context[:connection] ||= connection
pairs = @handlers.filter_map do |(key, handler)|
val = handler.call(context)
@formatter.format(key, val) unless val.nil?
end
@formatter.join(pairs)
end
end
@handlers = rebuild_handlers
self.tags_formatter = :legacy
end
end