lib/datadog/core/configuration/option.rb in ddtrace-1.12.1 vs lib/datadog/core/configuration/option.rb in ddtrace-1.13.0

- old
+ new

@@ -1,69 +1,319 @@ # frozen_string_literal: true +require_relative '../utils/safe_dup' + module Datadog module Core module Configuration # Represents an instance of an integration configuration option # @public_api class Option - attr_reader \ - :definition + attr_reader :definition + # Option setting precedence. + module Precedence + # Represents an Option precedence level. + # Each precedence has a `numeric` value; higher values means higher precedence. + # `name` is for inspection purposes only. + Value = Struct.new(:numeric, :name) do + include Comparable + + def <=>(other) + return nil unless other.is_a?(Value) + + numeric <=> other.numeric + end + end + + # Remote configuration provided through the Datadog app. + REMOTE_CONFIGURATION = Value.new(2, :remote_configuration).freeze + + # Configuration provided in Ruby code, in this same process + # or via Environment variable + PROGRAMMATIC = Value.new(1, :programmatic).freeze + + # Configuration that comes from default values + DEFAULT = Value.new(0, :default).freeze + + # All precedences, sorted from highest to lowest + LIST = [REMOTE_CONFIGURATION, PROGRAMMATIC, DEFAULT].sort.reverse.freeze + end + def initialize(definition, context) @definition = definition @context = context @value = nil @is_set = false + + # One value is stored per precedence, to allow unsetting a higher + # precedence value and falling back to a lower precedence one. + @value_per_precedence = Hash.new(UNSET) + + # Lowest precedence, to allow for `#set` to always succeed for a brand new `Option` instance. + @precedence_set = Precedence::DEFAULT end - def set(value) - old_value = @value - (@value = context_exec(value, old_value, &definition.setter)).tap do |v| - @is_set = true - context_exec(v, old_value, &definition.on_set) if definition.on_set + # Overrides the current value for this option if the `precedence` is equal or higher than + # the previously set value. + # The first call to `#set` will always store the value regardless of precedence. + # + # @param value [Object] the new value to be associated with this option + # @param precedence [Precedence] from what precedence order this new value comes from + def set(value, precedence: Precedence::PROGRAMMATIC) + # Is there a higher precedence value set? + if @precedence_set > precedence + # This should be uncommon, as higher precedence values tend to + # happen later in the application lifecycle. + Datadog.logger.info do + "Option '#{definition.name}' not changed to '#{value}' (precedence: #{precedence.name}) because the higher " \ + "precedence value '#{@value}' (precedence: #{@precedence_set.name}) was already set." + end + + # But if it happens, we have to store the lower precedence value `value` + # because it's possible to revert to it by `#unset`ting + # the existing, higher-precedence value. + # Effectively, we always store one value pre precedence. + @value_per_precedence[precedence] = value + + return @value end + + internal_set(value, precedence) end + def unset(precedence) + @value_per_precedence[precedence] = UNSET + + # If we are unsetting the currently active value, we have to restore + # a lower precedence one... + if precedence == @precedence_set + # Find a lower precedence value that is already set. + Precedence::LIST.each do |p| + # DEV: This search can be optimized, but the list is small, and unset is + # DEV: only called from direct user interaction in the Datadog UI. + next unless p < precedence + + # Look for value that is set. + # The hash `@value_per_precedence` has a custom default value of `UNSET`. + if (value = @value_per_precedence[p]) != UNSET + internal_set(value, p) + return nil + end + end + + # If no value is left to fall back on, reset this option + reset + end + + # ... otherwise, we are either unsetting a higher precedence value that is not + # yet set, thus there's nothing to do; or we are unsetting a lower precedence + # value, which also does not change the current value. + end + def get if @is_set @value elsif definition.delegate_to context_eval(&definition.delegate_to) else - set(default_value) + set_value_from_env_or_default end end def reset - @value = if definition.resetter - # Don't change @is_set to false; custom resetters are - # responsible for changing @value back to a good state. - # Setting @is_set = false would cause a default to be applied. - context_exec(@value, &definition.resetter) - else - @is_set = false - nil - end + @value = if definition.resetter + # Don't change @is_set to false; custom resetters are + # responsible for changing @value back to a good state. + # Setting @is_set = false would cause a default to be applied. + context_exec(@value, &definition.resetter) + else + @is_set = false + nil + end + + # Reset back to the lowest precedence, to allow all `set`s to succeed right after a reset. + @precedence_set = Precedence::DEFAULT + # Reset all stored values + @value_per_precedence = Hash.new(UNSET) end def default_value - if definition.lazy + if definition.default.instance_of?(Proc) context_eval(&definition.default) else - definition.default + definition.experimental_default_proc || Core::Utils::SafeDup.frozen_or_dup(definition.default) end end + def default_precedence? + precedence_set == Precedence::DEFAULT + end + private + def coerce_env_variable(value) + return context_exec(value, &@definition.env_parser) if @definition.env_parser + + case @definition.type + when :hash + values = value.split(',') # By default we only want to support comma separated strings + + values.map! do |v| + v.gsub!(/\A[\s,]*|[\s,]*\Z/, '') + + v.empty? ? nil : v + end + + values.compact! + values.each.with_object({}) do |v, hash| + pair = v.split(':', 2) + hash[pair[0]] = pair[1] + end + when :int + # DEV-2.0: Change to a more strict coercion method. Integer(value). + value.to_i + when :float + # DEV-2.0: Change to a more strict coercion method. Float(value). + value.to_f + when :array + values = value.split(',') + + values.map! do |v| + v.gsub!(/\A[\s,]*|[\s,]*\Z/, '') + + v.empty? ? nil : v + end + + values.compact! + values + when :bool + string_value = value.strip + string_value = string_value.downcase + string_value == 'true' || string_value == '1' # rubocop:disable Style/MultipleComparison + when :string, NilClass + value + else + raise ArgumentError, + "The option #{@definition.name} is using an unsupported type option for env coercion `#{@definition.type}`" + end + end + + def validate_type(value) + return value if skip_validation? + + raise_error = false + + valid_type = validate(@definition.type, value) + + unless valid_type + raise_error = if @definition.type_options[:nilable] + !value.is_a?(NilClass) + else + true + end + end + + if raise_error + error_msg = if @definition.type_options[:nilable] + "The setting `#{@definition.name}` inside your app's `Datadog.configure` block expects a "\ + "#{@definition.type} or `nil`, but a `#{value.class}` was provided (#{value.inspect})."\ + else + "The setting `#{@definition.name}` inside your app's `Datadog.configure` block expects a "\ + "#{@definition.type}, but a `#{value.class}` was provided (#{value.inspect})."\ + end + + error_msg = "#{error_msg} Please update your `configure` block. "\ + 'Alternatively, you can disable this validation using the '\ + '`DD_EXPERIMENTAL_SKIP_CONFIGURATION_VALIDATION=true`environment variable. '\ + 'For help, please open an issue on <https://github.com/datadog/dd-trace-rb/issues/new/choose>.' + + raise ArgumentError, error_msg + end + + value + end + + def validate(type, value) + case type + when :string + value.is_a?(String) + when :int, :float + value.is_a?(Numeric) + when :array + value.is_a?(Array) + when :hash + value.is_a?(Hash) + when :bool + value.is_a?(TrueClass) || value.is_a?(FalseClass) + when :proc + value.is_a?(Proc) + when :symbol + value.is_a?(Symbol) + when NilClass + true # No validation is performed when option is typeless + else + raise ArgumentError, "The option #{@definition.name} is using an unsupported type option `#{@definition.type}`" + end + end + + # Directly manipulates the current value and currently set precedence. + def internal_set(value, precedence) + old_value = @value + (@value = context_exec(validate_type(value), old_value, &definition.setter)).tap do |v| + @is_set = true + @precedence_set = precedence + # Store original value to ensure we can always safely call `#internal_set` + # when restoring a value from `@value_per_precedence`, and we are only running `definition.setter` + # on the original value, not on a valud that has already been processed by `definition.setter`. + @value_per_precedence[precedence] = value + context_exec(v, old_value, &definition.on_set) if definition.on_set + end + end + def context_exec(*args, &block) @context.instance_exec(*args, &block) end def context_eval(&block) @context.instance_eval(&block) end + + def set_value_from_env_or_default + value = nil + precedence = nil + + if definition.env && ENV[definition.env] + value = coerce_env_variable(ENV[definition.env]) + precedence = Precedence::PROGRAMMATIC + end + + if value.nil? && definition.deprecated_env && ENV[definition.deprecated_env] + value = coerce_env_variable(ENV[definition.deprecated_env]) + precedence = Precedence::PROGRAMMATIC + + Datadog::Core.log_deprecation do + "#{definition.deprecated_env} environment variable is deprecated, use #{definition.env} instead." + end + end + + option_value = value.nil? ? default_value : value + + set(option_value, precedence: precedence || Precedence::DEFAULT) + end + + def skip_validation? + ['true', '1'].include?(ENV.fetch('DD_EXPERIMENTAL_SKIP_CONFIGURATION_VALIDATION', '').strip) + end + + # Used for testing + attr_reader :precedence_set + private :precedence_set + + # Anchor object that represents a value that is not set. + # This is necessary because `nil` is a valid value to be set. + UNSET = Object.new + private_constant :UNSET end end end end