appsignal/appsignal

View on GitHub
lib/appsignal/config.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

require "erb"
require "yaml"
require "uri"
require "socket"
require "tmpdir"

module Appsignal
  class Config
    include Appsignal::Utils::DeprecationMessage

    DEFAULT_CONFIG = {
      :debug                          => false,
      :log                            => "file",
      :ignore_actions                 => [],
      :ignore_errors                  => [],
      :ignore_namespaces              => [],
      :filter_parameters              => [],
      :filter_session_data            => [],
      :send_params                    => true,
      :request_headers                => %w[
        HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING
        HTTP_ACCEPT_LANGUAGE HTTP_CACHE_CONTROL HTTP_CONNECTION
        CONTENT_LENGTH PATH_INFO HTTP_RANGE
        REQUEST_METHOD REQUEST_URI SERVER_NAME SERVER_PORT
        SERVER_PROTOCOL
      ],
      :endpoint                       => "https://push.appsignal.com",
      :instrument_net_http            => true,
      :instrument_redis               => true,
      :instrument_sequel              => true,
      :skip_session_data              => false,
      :enable_frontend_error_catching => false,
      :frontend_error_catching_path   => "/appsignal_error_catcher",
      :enable_allocation_tracking     => true,
      :enable_gc_instrumentation      => false,
      :enable_host_metrics            => true,
      :enable_minutely_probes         => true,
      :ca_file_path                   => File.expand_path(File.join("../../../resources/cacert.pem"), __FILE__),
      :dns_servers                    => [],
      :files_world_accessible         => true,
      :transaction_debug_mode         => false
    }.freeze

    ENV_TO_KEY_MAPPING = {
      "APPSIGNAL_ACTIVE"                         => :active,
      "APPSIGNAL_PUSH_API_KEY"                   => :push_api_key,
      "APPSIGNAL_APP_NAME"                       => :name,
      "APPSIGNAL_PUSH_API_ENDPOINT"              => :endpoint,
      "APPSIGNAL_FRONTEND_ERROR_CATCHING_PATH"   => :frontend_error_catching_path,
      "APPSIGNAL_DEBUG"                          => :debug,
      "APPSIGNAL_LOG"                            => :log,
      "APPSIGNAL_LOG_PATH"                       => :log_path,
      "APPSIGNAL_INSTRUMENT_NET_HTTP"            => :instrument_net_http,
      "APPSIGNAL_INSTRUMENT_REDIS"               => :instrument_redis,
      "APPSIGNAL_INSTRUMENT_SEQUEL"              => :instrument_sequel,
      "APPSIGNAL_SKIP_SESSION_DATA"              => :skip_session_data,
      "APPSIGNAL_ENABLE_FRONTEND_ERROR_CATCHING" => :enable_frontend_error_catching,
      "APPSIGNAL_IGNORE_ACTIONS"                 => :ignore_actions,
      "APPSIGNAL_IGNORE_ERRORS"                  => :ignore_errors,
      "APPSIGNAL_IGNORE_NAMESPACES"              => :ignore_namespaces,
      "APPSIGNAL_FILTER_PARAMETERS"              => :filter_parameters,
      "APPSIGNAL_FILTER_SESSION_DATA"            => :filter_session_data,
      "APPSIGNAL_SEND_PARAMS"                    => :send_params,
      "APPSIGNAL_HTTP_PROXY"                     => :http_proxy,
      "APPSIGNAL_ENABLE_ALLOCATION_TRACKING"     => :enable_allocation_tracking,
      "APPSIGNAL_ENABLE_GC_INSTRUMENTATION"      => :enable_gc_instrumentation,
      "APPSIGNAL_RUNNING_IN_CONTAINER"           => :running_in_container,
      "APPSIGNAL_WORKING_DIR_PATH"               => :working_dir_path,
      "APPSIGNAL_WORKING_DIRECTORY_PATH"         => :working_directory_path,
      "APPSIGNAL_ENABLE_HOST_METRICS"            => :enable_host_metrics,
      "APPSIGNAL_ENABLE_MINUTELY_PROBES"         => :enable_minutely_probes,
      "APPSIGNAL_HOSTNAME"                       => :hostname,
      "APPSIGNAL_CA_FILE_PATH"                   => :ca_file_path,
      "APPSIGNAL_DNS_SERVERS"                    => :dns_servers,
      "APPSIGNAL_FILES_WORLD_ACCESSIBLE"         => :files_world_accessible,
      "APPSIGNAL_REQUEST_HEADERS"                => :request_headers,
      "APPSIGNAL_TRANSACTION_DEBUG_MODE"         => :transaction_debug_mode,
      "APP_REVISION"                             => :revision
    }.freeze

    # Mapping of old and deprecated AppSignal configuration keys
    DEPRECATED_CONFIG_KEY_MAPPING = {
      :api_key => :push_api_key,
      :ignore_exceptions => :ignore_errors
    }.freeze

    # @attribute [r] system_config
    #   Config detected on the system level.
    #   Used in diagnose report.
    #   @api private
    #   @return [Hash]
    # @!attribute [r] initial_config
    #   Config detected on the system level.
    #   Used in diagnose report.
    #   @api private
    #   @return [Hash]
    # @!attribute [r] file_config
    #   Config loaded from `config/appsignal.yml` config file.
    #   Used in diagnose report.
    #   @api private
    #   @return [Hash]
    # @!attribute [r] env_config
    #   Config loaded from the system environment.
    #   Used in diagnose report.
    #   @api private
    #   @return [Hash]
    # @!attribute [r] config_hash
    #   Config used by the AppSignal gem.
    #   Combined Hash of the {system_config}, {initial_config}, {file_config},
    #   {env_config} attributes.
    #   @see #[]
    #   @see #[]=
    #   @api private
    #   @return [Hash]

    attr_reader :root_path, :env, :config_hash, :system_config,
      :initial_config, :file_config, :env_config
    attr_accessor :logger

    def initialize(root_path, env, initial_config = {}, logger = Appsignal.logger)
      @root_path      = root_path
      @logger         = logger
      @valid          = false
      @config_hash    = Hash[DEFAULT_CONFIG]
      env_loaded_from_initial = env.to_s
      @env =
        if ENV.key?("APPSIGNAL_APP_ENV".freeze)
          env_loaded_from_env = ENV["APPSIGNAL_APP_ENV".freeze]
        else
          env_loaded_from_initial
        end

      # Set config based on the system
      @system_config = detect_from_system
      merge(system_config)
      # Initial config
      @initial_config = initial_config
      merge(initial_config)
      # Load the config file if it exists
      @file_config = load_from_disk || {}
      merge(file_config)
      # Load config from environment variables
      @env_config = load_from_environment
      merge(env_config)
      # Validate that we have a correct config
      validate
      # Track origin of env
      @initial_config[:env] = env_loaded_from_initial if env_loaded_from_initial
      @env_config[:env] = env_loaded_from_env if env_loaded_from_env
    end

    # @api private
    # @return [String] System's tmp directory.
    def self.system_tmp_dir
      if Gem.win_platform?
        Dir.tmpdir
      else
        File.realpath("/tmp")
      end
    end

    def [](key)
      config_hash[key]
    end

    def []=(key, value)
      config_hash[key] = value
    end

    def log_file_path
      path = config_hash[:log_path] || root_path && File.join(root_path, "log")
      if path && File.writable?(path)
        return File.join(File.realpath(path), "appsignal.log")
      end

      system_tmp_dir = self.class.system_tmp_dir
      if File.writable? system_tmp_dir
        $stdout.puts "appsignal: Unable to log to '#{path}'. Logging to "\
          "'#{system_tmp_dir}' instead. Please check the "\
          "permissions for the application's (log) directory."
        File.join(system_tmp_dir, "appsignal.log")
      else
        $stdout.puts "appsignal: Unable to log to '#{path}' or the "\
          "'#{system_tmp_dir}' fallback. Please check the permissions "\
          "for the application's (log) directory."
      end
    end

    def valid?
      @valid
    end

    def active?
      @valid && config_hash[:active]
    end

    def write_to_environment # rubocop:disable Metrics/AbcSize
      ENV["_APPSIGNAL_ACTIVE"]                       = active?.to_s
      ENV["_APPSIGNAL_APP_PATH"]                     = root_path.to_s
      ENV["_APPSIGNAL_AGENT_PATH"]                   = File.expand_path("../../../ext", __FILE__).to_s
      ENV["_APPSIGNAL_ENVIRONMENT"]                  = env
      ENV["_APPSIGNAL_LANGUAGE_INTEGRATION_VERSION"] = "ruby-#{Appsignal::VERSION}"
      ENV["_APPSIGNAL_DEBUG_LOGGING"]                = config_hash[:debug].to_s
      ENV["_APPSIGNAL_LOG"]                          = config_hash[:log]
      ENV["_APPSIGNAL_LOG_FILE_PATH"]                = log_file_path.to_s if log_file_path
      ENV["_APPSIGNAL_PUSH_API_ENDPOINT"]            = config_hash[:endpoint]
      ENV["_APPSIGNAL_PUSH_API_KEY"]                 = config_hash[:push_api_key]
      ENV["_APPSIGNAL_APP_NAME"]                     = config_hash[:name]
      ENV["_APPSIGNAL_HTTP_PROXY"]                   = config_hash[:http_proxy]
      ENV["_APPSIGNAL_IGNORE_ACTIONS"]               = config_hash[:ignore_actions].join(",")
      ENV["_APPSIGNAL_IGNORE_ERRORS"]                = config_hash[:ignore_errors].join(",")
      ENV["_APPSIGNAL_IGNORE_NAMESPACES"]            = config_hash[:ignore_namespaces].join(",")
      ENV["_APPSIGNAL_RUNNING_IN_CONTAINER"]         = config_hash[:running_in_container].to_s
      ENV["_APPSIGNAL_WORKING_DIR_PATH"]             = config_hash[:working_dir_path] if config_hash[:working_dir_path]
      ENV["_APPSIGNAL_WORKING_DIRECTORY_PATH"]       = config_hash[:working_directory_path] if config_hash[:working_directory_path]
      ENV["_APPSIGNAL_ENABLE_HOST_METRICS"]          = config_hash[:enable_host_metrics].to_s
      ENV["_APPSIGNAL_HOSTNAME"]                     = config_hash[:hostname].to_s
      ENV["_APPSIGNAL_PROCESS_NAME"]                 = $PROGRAM_NAME
      ENV["_APPSIGNAL_CA_FILE_PATH"]                 = config_hash[:ca_file_path].to_s
      ENV["_APPSIGNAL_DNS_SERVERS"]                  = config_hash[:dns_servers].join(",")
      ENV["_APPSIGNAL_FILES_WORLD_ACCESSIBLE"]       = config_hash[:files_world_accessible].to_s
      ENV["_APPSIGNAL_TRANSACTION_DEBUG_MODE"]       = config_hash[:transaction_debug_mode].to_s
      ENV["_APP_REVISION"]                           = config_hash[:revision].to_s
    end

    def validate
      # Strip path from endpoint so we're backwards compatible with
      # earlier versions of the gem.
      # TODO: Move to its own method, maybe in `#[]=`?
      endpoint_uri = URI(config_hash[:endpoint])
      config_hash[:endpoint] =
        if endpoint_uri.port == 443
          "#{endpoint_uri.scheme}://#{endpoint_uri.host}"
        else
          "#{endpoint_uri.scheme}://#{endpoint_uri.host}:#{endpoint_uri.port}"
        end

      push_api_key = config_hash[:push_api_key] || ""
      if push_api_key.strip.empty?
        @valid = false
        @logger.error "Push API key not set after loading config"
      else
        @valid = true
      end
    end

    private

    def config_file
      @config_file ||=
        root_path.nil? ? nil : File.join(root_path, "config", "appsignal.yml")
    end

    def detect_from_system
      {}.tap do |hash|
        hash[:log] = "stdout" if Appsignal::System.heroku?

        # Make AppSignal active by default if APPSIGNAL_PUSH_API_KEY
        # environment variable is present and not empty.
        env_push_api_key = ENV["APPSIGNAL_PUSH_API_KEY"] || ""
        hash[:active] = true unless env_push_api_key.strip.empty?
      end
    end

    def load_from_disk
      return if !config_file || !File.exist?(config_file)

      configurations = YAML.load(ERB.new(IO.read(config_file)).result)
      config_for_this_env = configurations[env]
      if config_for_this_env
        config_for_this_env =
          config_for_this_env.each_with_object({}) do |(key, value), hash|
            hash[key.to_sym] = value # convert keys to symbols
          end

        maintain_backwards_compatibility(config_for_this_env)
      else
        logger.error "Not loading from config file: config for '#{env}' not found"
        nil
      end
    rescue => e
      message = "An error occured while loading the AppSignal config file." \
        " Skipping file config.\n" \
        "File: #{config_file.inspect}\n" \
        "#{e.class.name}: #{e}"
      $stderr.puts "appsignal: #{message}"
      logger.error "#{message}\n#{e.backtrace.join("\n")}"
      nil
    end

    # Maintain backwards compatibility with config files generated by earlier
    # versions of the gem
    #
    # Used by {#load_from_disk}. No compatibility for env variables or initial config currently.
    def maintain_backwards_compatibility(configuration)
      configuration.tap do |config|
        DEPRECATED_CONFIG_KEY_MAPPING.each do |old_key, new_key|
          old_config_value = config.delete(old_key)
          next unless old_config_value
          deprecation_message \
            "Old configuration key found. Please update the "\
            "'#{old_key}' to '#{new_key}'.",
            logger

          next if config[new_key] # Skip if new key is already in use
          config[new_key] = old_config_value
        end

        if config.include?(:working_dir_path)
          deprecation_message \
            "'working_dir_path' is deprecated, please use " \
            "'working_directory_path' instead and specify the " \
            "full path to the working directory",
            logger
        end
      end
    end

    def load_from_environment
      config = {}

      # Configuration with string type
      %w[APPSIGNAL_PUSH_API_KEY APPSIGNAL_APP_NAME APPSIGNAL_PUSH_API_ENDPOINT
         APPSIGNAL_FRONTEND_ERROR_CATCHING_PATH APPSIGNAL_HTTP_PROXY
         APPSIGNAL_LOG APPSIGNAL_LOG_PATH APPSIGNAL_WORKING_DIR_PATH
         APPSIGNAL_HOSTNAME APPSIGNAL_CA_FILE_PATH APP_REVISION].each do |var|
        env_var = ENV[var]
        next unless env_var
        config[ENV_TO_KEY_MAPPING[var]] = env_var
      end

      # Configuration with boolean type
      %w[APPSIGNAL_ACTIVE APPSIGNAL_DEBUG APPSIGNAL_INSTRUMENT_NET_HTTP
         APPSIGNAL_INSTRUMENT_REDIS APPSIGNAL_INSTRUMENT_SEQUEL
         APPSIGNAL_SKIP_SESSION_DATA APPSIGNAL_ENABLE_FRONTEND_ERROR_CATCHING
         APPSIGNAL_ENABLE_ALLOCATION_TRACKING APPSIGNAL_ENABLE_GC_INSTRUMENTATION
         APPSIGNAL_RUNNING_IN_CONTAINER APPSIGNAL_ENABLE_HOST_METRICS
         APPSIGNAL_SEND_PARAMS APPSIGNAL_ENABLE_MINUTELY_PROBES
         APPSIGNAL_FILES_WORLD_ACCESSIBLE APPSIGNAL_TRANSACTION_DEBUG_MODE].each do |var|
        env_var = ENV[var]
        next unless env_var
        config[ENV_TO_KEY_MAPPING[var]] = env_var.casecmp("true").zero?
      end

      # Configuration with array of strings type
      %w[APPSIGNAL_IGNORE_ACTIONS APPSIGNAL_IGNORE_ERRORS
         APPSIGNAL_IGNORE_NAMESPACES APPSIGNAL_FILTER_PARAMETERS
         APPSIGNAL_FILTER_SESSION_DATA APPSIGNAL_REQUEST_HEADERS].each do |var|
        env_var = ENV[var]
        next unless env_var
        config[ENV_TO_KEY_MAPPING[var]] = env_var.split(",")
      end

      config
    end

    def merge(new_config)
      new_config.each do |key, value|
        unless config_hash[key].nil?
          @logger.debug("Config key '#{key}' is being overwritten")
        end
        config_hash[key] = value
      end
    end
  end
end