# 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+ # # 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 mattr_accessor :taggings, instance_accessor: false, default: {} mattr_accessor :tags, instance_accessor: false, default: [ :application ] mattr_accessor :prepend_comment, instance_accessor: false, default: false mattr_accessor :cache_query_log_tags, instance_accessor: false, default: false mattr_accessor :tags_formatter, instance_accessor: false thread_mattr_accessor :cached_comment, instance_accessor: false class << self 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 # Updates the formatter to be what the passed in format is. def update_formatter(format) self.tags_formatter = case format when :legacy LegacyFormatter.new when :sqlcommenter SQLCommenter.new else raise ArgumentError, "Formatter is unsupported: #{formatter}" end end ActiveSupport::ExecutionContext.after_change { ActiveRecord::QueryLogs.clear_cache } private # 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 formatter self.tags_formatter || self.update_formatter(:legacy) 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 = tags.flat_map { |i| [*i] }.filter_map do |tag| key, handler = tag handler ||= taggings[key] val = if handler.nil? context[key] elsif handler.respond_to?(:call) if handler.arity == 0 handler.call else handler.call(context) end else handler end [key, val] unless val.nil? end self.formatter.format(pairs) end end end end