# encoding: utf-8 # This file is distributed under New Relic's license terms. # See https://github.com/newrelic/rpm/blob/master/LICENSE for complete details. require 'singleton' require 'new_relic/agent/database/obfuscation_helpers' require 'new_relic/agent/database/obfuscator' require 'new_relic/agent/database/postgres_explain_obfuscator' module NewRelic # columns for a mysql explain plan MYSQL_EXPLAIN_COLUMNS = [ "Id", "Select Type", "Table", "Type", "Possible Keys", "Key", "Key Length", "Ref", "Rows", "Extra" ].freeze module Agent module Database MAX_QUERY_LENGTH = 16384 extend self def capture_query(query) Helper.correctly_encoded(truncate_query(query)) end def truncate_query(query) if query.length > (MAX_QUERY_LENGTH - 4) query[0..MAX_QUERY_LENGTH - 4] + '...' else query end end def obfuscate_sql(sql) Obfuscator.instance.obfuscator.call(sql) end def set_sql_obfuscator(type, &block) Obfuscator.instance.set_sql_obfuscator(type, &block) end def record_sql_method(config_section=:transaction_tracer) key = record_sql_method_key(config_section) case Agent.config[key].to_s when 'off' :off when 'none' :off when 'false' :off when 'raw' :raw else :obfuscated end end def record_sql_method_key(config_section) case config_section when :transaction_tracer :'transaction_tracer.record_sql' when :slow_sql :'slow_sql.record_sql' else "#{config_section}.record_sql".to_sym end end RECORD_FOR = [:raw, :obfuscated].freeze def should_record_sql?(config_section=:transaction_tracer) RECORD_FOR.include?(record_sql_method(config_section)) end def should_collect_explain_plans?(config_section=:transaction_tracer) should_record_sql?(config_section) && Agent.config["#{config_section}.explain_enabled".to_sym] end def get_connection(config, &connector) ConnectionManager.instance.get_connection(config, &connector) end def close_connections ConnectionManager.instance.close_connections end # This takes a connection config hash from ActiveRecord or Sequel and # returns a string describing the associated database adapter def adapter_from_config(config) if config[:adapter] return config[:adapter].to_s elsif config[:uri] && config[:uri].to_s =~ /^jdbc:([^:]+):/ # This case is for Sequel with the jdbc-mysql, jdbc-postgres, or # jdbc-sqlite3 gems. return $1 end end # Perform this in the runtime environment of a managed # application, to explain the sql statement executed within a # node of a transaction sample. Returns an array of # explanations (which is an array rows consisting of an array of # strings for each column returned by the the explain query) # Note this happens only for statements whose execution time # exceeds a threshold (e.g. 500ms) and only within the slowest # transaction in a report period, selected for shipment to New # Relic def explain_sql(sql, connection_config, explainer=nil) return nil unless sql && explainer && connection_config statement = sql.split(";\n")[0] # only explain the first explain_plan = explain_statement(statement, connection_config, explainer) return explain_plan || [] end SUPPORTED_ADAPTERS_FOR_EXPLAIN = %w[postgres postgresql mysql2 mysql sqlite].freeze def explain_statement(statement, config, explainer) return unless explainer && is_select?(statement) if statement[-3,3] == '...' NewRelic::Agent.logger.debug('Unable to collect explain plan for truncated query.') return end if parameterized?(statement) NewRelic::Agent.logger.debug('Unable to collect explain plan for parameterized query.') return end adapter = adapter_from_config(config) if !SUPPORTED_ADAPTERS_FOR_EXPLAIN.include?(adapter) NewRelic::Agent.logger.debug("Not collecting explain plan because an unknown connection adapter ('#{adapter}') was used.") return end handle_exception_in_explain do start = Time.now plan = explainer.call(config, statement) ::NewRelic::Agent.record_metric("Supportability/Database/execute_explain_plan", Time.now - start) return process_resultset(plan, adapter) if plan end end def process_resultset(results, adapter) case adapter.to_s when 'postgres', 'postgresql' process_explain_results_postgres(results) when 'mysql2' process_explain_results_mysql2(results) when 'mysql' process_explain_results_mysql(results) when 'sqlite' process_explain_results_sqlite(results) end end QUERY_PLAN = 'QUERY PLAN'.freeze def process_explain_results_postgres(results) if results.is_a?(String) query_plan_string = results else lines = [] results.each { |row| lines << row[QUERY_PLAN] } query_plan_string = lines.join("\n") end unless record_sql_method == :raw query_plan_string = NewRelic::Agent::Database::PostgresExplainObfuscator.obfuscate(query_plan_string) end values = query_plan_string.split("\n").map { |line| [line] } [[QUERY_PLAN], values] end # Sequel returns explain plans as just one big pre-formatted String # In that case, we send a nil headers array, and the single string # wrapped in an array for the values. # Note that we don't use this method for Postgres explain plans, since # they need to be passed through the explain plan obfuscator first. def string_explain_plan_results(results) [nil, [results]] end def process_explain_results_mysql(results) return string_explain_plan_results(results) if results.is_a?(String) headers = [] values = [] if results.is_a?(Array) # We're probably using the jdbc-mysql gem for JRuby, which will give # us an array of hashes. headers = results.first.keys results.each do |row| values << headers.map { |h| row[h] } end else # We're probably using the native mysql driver gem, which will give us # a Mysql::Result object that responds to each_hash results.each_hash do |row| headers = row.keys values << headers.map { |h| row[h] } end end [headers, values] end def process_explain_results_mysql2(results) return string_explain_plan_results(results) if results.is_a?(String) headers = results.fields values = [] results.each { |row| values << row } [headers, values] end SQLITE_EXPLAIN_COLUMNS = %w[addr opcode p1 p2 p3 p4 p5 comment] def process_explain_results_sqlite(results) return string_explain_plan_results(results) if results.is_a?(String) headers = SQLITE_EXPLAIN_COLUMNS values = [] results.each do |row| values << headers.map { |h| row[h] } end [headers, values] end def handle_exception_in_explain yield rescue => e begin # guarantees no throw from explain_sql ::NewRelic::Agent.logger.error("Error getting query plan:", e) nil rescue # double exception. throw up your hands nil end end KNOWN_OPERATIONS = [ 'alter', 'select', 'update', 'delete', 'insert', 'create', 'show', 'set', 'exec', 'execute', 'call' ] SQL_COMMENT_REGEX = Regexp.new('/\*.*?\*/', Regexp::MULTILINE).freeze EMPTY_STRING = ''.freeze def parse_operation_from_query(sql) sql = Helper.correctly_encoded(sql).gsub(SQL_COMMENT_REGEX, EMPTY_STRING) if sql =~ /(\w+)/ op = $1.downcase return op if KNOWN_OPERATIONS.include?(op) end end def is_select?(statement) parse_operation_from_query(statement) == 'select' end def parameterized?(statement) Obfuscator.instance.obfuscate_single_quote_literals(statement) =~ /\$\d+/ end class ConnectionManager include Singleton # Returns a cached connection for a given ActiveRecord # configuration - these are stored or reopened as needed, and if # we cannot get one, we ignore it and move on without explaining # the sql def get_connection(config, &connector) @connections ||= {} connection = @connections[config] return connection if connection begin @connections[config] = connector.call(config) rescue => e ::NewRelic::Agent.logger.error("Caught exception trying to get connection to DB for explain.", e) nil end end # Closes all the connections in the internal connection cache def close_connections @connections ||= {} @connections.values.each do |connection| begin connection.disconnect! rescue end end @connections = {} end end class Statement attr_accessor :sql, :config, :explainer def initialize(sql, config={}, explainer=nil) @sql = Database.capture_query(sql) @config = config @explainer = explainer end def adapter config && config[:adapter] end end end end end