lib/elastic_apm/config.rb



# frozen_string_literal: true

require 'logger'
require 'yaml'
require 'erb'

require 'elastic_apm/util/prefixed_logger'
require 'elastic_apm/config/duration'
require 'elastic_apm/config/size'

module ElasticAPM
  class ConfigError < StandardError; end

  # rubocop:disable Metrics/ClassLength
  # @api private
  class Config
    DEFAULTS = {
      config_file: 'config/elastic_apm.yml',

      server_url: 'http://localhost:8200',

      active: true,
      api_buffer_size: 256,
      api_request_size: '750kb',
      api_request_time: '10s',
      capture_body: 'off',
      capture_headers: true,
      capture_env: true,
      current_user_email_method: :email,
      current_user_id_method: :id,
      current_user_username_method: :username,
      custom_key_filters: [],
      default_tags: {},
      disable_send: false,
      disable_start_message: false,
      disabled_spies: %w[json],
      environment: ENV['RAILS_ENV'] || ENV['RACK_ENV'],
      filter_exception_types: [],
      http_compression: true,
      ignore_url_patterns: [],
      instrument: true,
      instrumented_rake_tasks: [],
      log_level: Logger::INFO,
      log_path: nil,
      metrics_interval: '30s',
      pool_size: 1,
      source_lines_error_app_frames: 5,
      source_lines_error_library_frames: 0,
      source_lines_span_app_frames: 5,
      source_lines_span_library_frames: 0,
      span_frames_min_duration: '5ms',
      stack_trace_limit: 999_999,
      transaction_max_spans: 500,
      transaction_sample_rate: 1.0,
      verify_server_cert: true,

      view_paths: [],
      root_path: Dir.pwd
    }.freeze

    ENV_TO_KEY = {
      'ELASTIC_APM_SERVER_URL' => 'server_url',
      'ELASTIC_APM_SECRET_TOKEN' => 'secret_token',

      'ELASTIC_APM_ACTIVE' => [:bool, 'active'],
      'ELASTIC_APM_API_BUFFER_SIZE' => [:int, 'api_buffer_size'],
      'ELASTIC_APM_API_REQUEST_SIZE' => [:int, 'api_request_size'],
      'ELASTIC_APM_API_REQUEST_TIME' => 'api_request_time',
      'ELASTIC_APM_CAPTURE_BODY' => 'capture_body',
      'ELASTIC_APM_CAPTURE_HEADERS' => [:bool, 'capture_headers'],
      'ELASTIC_APM_CAPTURE_ENV' => [:bool, 'capture_env'],
      'ELASTIC_APM_CONFIG_FILE' => 'config_file',
      'ELASTIC_APM_CUSTOM_KEY_FILTERS' => [:list, 'custom_key_filters'],
      'ELASTIC_APM_DEFAULT_TAGS' => [:dict, 'default_tags'],
      'ELASTIC_APM_DISABLED_SPIES' => [:list, 'disabled_spies'],
      'ELASTIC_APM_DISABLE_SEND' => [:bool, 'disable_send'],
      'ELASTIC_APM_DISABLE_START_MESSAGE' => [:bool, 'disable_start_message'],
      'ELASTIC_APM_ENVIRONMENT' => 'environment',
      'ELASTIC_APM_FRAMEWORK_NAME' => 'framework_name',
      'ELASTIC_APM_FRAMEWORK_VERSION' => 'framework_version',
      'ELASTIC_APM_HOSTNAME' => 'hostname',
      'ELASTIC_APM_IGNORE_URL_PATTERNS' => [:list, 'ignore_url_patterns'],
      'ELASTIC_APM_INSTRUMENT' => [:bool, 'instrument'],
      'ELASTIC_APM_INSTRUMENTED_RAKE_TASKS' =>
        [:list, 'instrumented_rake_tasks'],
      'ELASTIC_APM_LOG_LEVEL' => [:int, 'log_level'],
      'ELASTIC_APM_LOG_PATH' => 'log_path',
      'ELASTIC_APM_METRICS_INTERVAL' => 'metrics_interval',
      'ELASTIC_APM_PROXY_ADDRESS' => 'proxy_address',
      'ELASTIC_APM_PROXY_HEADERS' => [:dict, 'proxy_headers'],
      'ELASTIC_APM_PROXY_PASSWORD' => 'proxy_password',
      'ELASTIC_APM_PROXY_PORT' => [:int, 'proxy_port'],
      'ELASTIC_APM_PROXY_USERNAME' => 'proxy_username',
      'ELASTIC_APM_POOL_SIZE' => [:int, 'pool_size'],
      'ELASTIC_APM_SERVER_CA_CERT' => 'server_ca_cert',
      'ELASTIC_APM_SERVICE_NAME' => 'service_name',
      'ELASTIC_APM_SERVICE_VERSION' => 'service_version',
      'ELASTIC_APM_SOURCE_LINES_ERROR_APP_FRAMES' =>
        [:int, 'source_lines_error_app_frames'],
      'ELASTIC_APM_SOURCE_LINES_ERROR_LIBRARY_FRAMES' =>
        [:int, 'source_lines_error_library_frames'],
      'ELASTIC_APM_SOURCE_LINES_SPAN_APP_FRAMES' =>
        [:int, 'source_lines_span_app_frames'],
      'ELASTIC_APM_SOURCE_LINES_SPAN_LIBRARY_FRAMES' =>
        [:int, 'source_lines_span_library_frames'],
      'ELASTIC_APM_SPAN_FRAMES_MIN_DURATION' => 'span_frames_min_duration',
      'ELASTIC_APM_STACK_TRACE_LIMIT' => [:int, 'stack_trace_limit'],
      'ELASTIC_APM_TRANSACTION_MAX_SPANS' => [:int, 'transaction_max_spans'],
      'ELASTIC_APM_TRANSACTION_SAMPLE_RATE' =>
        [:float, 'transaction_sample_rate'],
      'ELASTIC_APM_VERIFY_SERVER_CERT' => [:bool, 'verify_server_cert']
    }.freeze

    DURATION_KEYS = %i[
      api_request_time
      span_frames_min_duration
      metrics_interval
    ].freeze
    DURATION_DEFAULT_UNITS = { # default is 's'
      span_frames_min_duration: 'ms'
    }.freeze

    SIZE_KEYS = %i[api_request_size].freeze
    SIZE_DEFAULT_UNITS = { api_request_size: 'kb' }.freeze

    def initialize(options = {})
      set_defaults

      set_from_args(options)
      set_from_config_file
      set_from_env

      normalize_durations
      normalize_sizes

      yield self if block_given?

      build_logger if logger.nil?
    end

    attr_accessor :config_file

    attr_accessor :server_url
    attr_accessor :secret_token

    attr_accessor :active
    attr_accessor :api_buffer_size
    attr_accessor :api_request_size
    attr_accessor :api_request_time
    attr_accessor :capture_env
    attr_accessor :capture_headers
    attr_accessor :current_user_email_method
    attr_accessor :current_user_id_method
    attr_accessor :current_user_method
    attr_accessor :current_user_username_method
    attr_accessor :default_tags
    attr_accessor :disable_send
    attr_accessor :disable_start_message
    attr_accessor :disabled_spies
    attr_accessor :environment
    attr_accessor :filter_exception_types
    attr_accessor :framework_name
    attr_accessor :framework_version
    attr_accessor :hostname
    attr_accessor :http_compression
    attr_accessor :instrument
    attr_accessor :instrumented_rake_tasks
    attr_accessor :log_level
    attr_accessor :log_path
    attr_accessor :logger
    attr_accessor :metrics_interval
    attr_accessor :pool_size
    attr_accessor :proxy_address
    attr_accessor :proxy_headers
    attr_accessor :proxy_password
    attr_accessor :proxy_port
    attr_accessor :proxy_username
    attr_accessor :server_ca_cert
    attr_accessor :service_name
    attr_accessor :service_version
    attr_accessor :source_lines_error_app_frames
    attr_accessor :source_lines_error_library_frames
    attr_accessor :source_lines_span_app_frames
    attr_accessor :source_lines_span_library_frames
    attr_accessor :stack_trace_limit
    attr_accessor :transaction_max_spans
    attr_accessor :transaction_sample_rate
    attr_accessor :verify_server_cert

    attr_reader :capture_body
    attr_reader :custom_key_filters
    attr_reader :ignore_url_patterns
    attr_reader :span_frames_min_duration
    attr_reader :span_frames_min_duration_us

    attr_writer :alert_logger

    attr_accessor :view_paths
    attr_accessor :root_path

    alias :active? :active
    alias :capture_body? :capture_body
    alias :capture_headers? :capture_headers
    alias :capture_env? :capture_env
    alias :disable_send? :disable_send
    alias :disable_start_message? :disable_start_message
    alias :http_compression? :http_compression
    alias :instrument? :instrument
    alias :verify_server_cert? :verify_server_cert

    def alert_logger
      @alert_logger ||= PrefixedLogger.new($stdout, prefix: Logging::PREFIX)
    end

    def app=(app)
      case app_type?(app)
      when :sinatra
        set_sinatra(app)
      when :rails
        set_rails(app)
      else
        self.service_name = 'ruby'
      end
    end

    def app_type?(app)
      if defined?(Rails::Application) && app.is_a?(Rails::Application)
        return :rails
      end

      if app.is_a?(Class) && app.superclass.to_s == 'Sinatra::Base'
        return :sinatra
      end

      nil
    end

    def use_ssl?
      server_url.start_with?('https')
    end

    def custom_key_filters=(filters)
      @custom_key_filters = Array(filters).map(&Regexp.method(:new))
    end

    def ignore_url_patterns=(strings)
      @ignore_url_patterns = Array(strings).map(&Regexp.method(:new))
    end

    # rubocop:disable Metrics/MethodLength
    def available_spies
      %w[
        delayed_job
        elasticsearch
        faraday
        http
        json
        mongo
        net_http
        redis
        sequel
        sidekiq
        sinatra
        tilt
        rake
      ]
    end
    # rubocop:enable Metrics/MethodLength

    def enabled_spies
      available_spies - disabled_spies
    end

    def span_frames_min_duration=(duration)
      @span_frames_min_duration = duration
      @span_frames_min_duration_us = duration * 1_000_000
    end

    def span_frames_min_duration?
      span_frames_min_duration != 0
    end

    DEPRECATED_OPTIONS = %i[
      compression_level=
      compression_minimum_size=
      debug_http=
      debug_transactions=
      flush_interval=
      http_open_timeout=
      http_read_timeout=
      enabled_environments=
      disable_environment_warning=
    ].freeze

    def respond_to_missing?(name)
      return true if DEPRECATED_OPTIONS.include? name
      return true if name.to_s.end_with?('=')
      false
    end

    def method_missing(name, *args)
      if DEPRECATED_OPTIONS.include?(name)
        alert_logger.warn "The option `#{name}' has been removed."
        return
      end

      if name.to_s.end_with?('=')
        raise ConfigError, "No such option `#{name.to_s.delete('=')}'"
      end

      super
    end

    def collect_metrics?
      metrics_interval > 0
    end

    # rubocop:disable Metrics/MethodLength
    def capture_body=(value)
      if value =~ /(all|transactions|errors|off)/
        @capture_body = value
        return
      end

      case value
      when true
        alert_logger.warn "Boolean value for option `capture_body' has " \
          "been deprecated. Setting to 'all'"
        @capture_body = 'all'
      when false
        alert_logger.warn "Boolean value for option `capture_body' has " \
          "been deprecated. Setting to 'off'"
        @capture_body = 'off'
      else
        default = DEFAULTS[:capture_body]
        alert_logger.warn "Unknown value `#{value}' for option "\
          "`capture_body'. Defaulting to `#{default}'"
        @capture_body = default
      end
    end
    # rubocop:enable Metrics/MethodLength

    private

    def assign(options)
      options.each do |key, value|
        send("#{key}=", value)
      end
    end

    def set_defaults
      assign(DEFAULTS)
    end

    # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
    # rubocop:disable Metrics/AbcSize
    def set_from_env
      ENV_TO_KEY.each do |env_key, key|
        next unless (value = ENV[env_key])

        type, key = key if key.is_a? Array

        value =
          case type
          when :int then value.to_i
          when :float then value.to_f
          when :bool then !%w[0 false].include?(value.strip.downcase)
          when :list then value.split(/[ ,]/)
          when :dict then Hash[value.split(/[&,]/).map { |kv| kv.split('=') }]
          else value
          end

        send("#{key}=", value)
      end
    end
    # rubocop:enable Metrics/AbcSize
    # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity

    def set_from_args(options)
      assign(options)
    rescue ConfigError => e
      alert_logger.warn format(
        'Failed to configure from arguments: %s',
        e.message
      )
    end

    def set_from_config_file
      return unless File.exist?(config_file)
      assign(YAML.safe_load(ERB.new(File.read(config_file)).result) || {})
    rescue ConfigError => e
      alert_logger.warn format(
        'Failed to configure from config file: %s',
        e.message
      )
    end

    def set_sinatra(app)
      self.service_name = format_name(service_name || app.to_s)
      self.framework_name = framework_name || 'Sinatra'
      self.framework_version = framework_version || Sinatra::VERSION
      self.root_path = Dir.pwd
    end

    def set_rails(app) # rubocop:disable Metrics/AbcSize
      self.service_name ||= format_name(service_name || app.class.parent_name)
      self.framework_name ||= 'Ruby on Rails'
      self.framework_version ||= Rails::VERSION::STRING
      self.logger ||= Rails.logger

      self.root_path = Rails.root.to_s
      self.view_paths = app.config.paths['app/views'].existent
    end

    def build_logger
      logger = Logger.new(log_path == '-' ? STDOUT : log_path)
      logger.level = log_level

      self.logger = logger
    end

    def format_name(str)
      str && str.gsub('::', '_')
    end

    def normalize_durations
      DURATION_KEYS.each do |key|
        value = send(key).to_s
        default_unit = DURATION_DEFAULT_UNITS.fetch(key, 's')
        duration = Duration.parse(value, default_unit: default_unit)
        send("#{key}=", duration.seconds)
      end
    end

    def normalize_sizes
      SIZE_KEYS.each do |key|
        value = send(key).to_s
        default_unit = SIZE_DEFAULT_UNITS.fetch(key, 'b')
        size = Size.parse(value, default_unit: default_unit)
        send("#{key}=", size.bytes)
      end
    end
  end
  # rubocop:enable Metrics/ClassLength
end