# 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 if config_hash[:push_api_key] @valid = true else @valid = false @logger.error "Push api key not set after loading config" 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 active by default if APPSIGNAL_PUSH_API_KEY is present hash[:active] = true if ENV["APPSIGNAL_PUSH_API_KEY"] 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 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