lib/posthog/feature_flags.rb



# frozen_string_literal: true

require 'concurrent'
require 'net/http'
require 'json'
require 'posthog/version'
require 'posthog/logging'
require 'posthog/feature_flag'
require 'digest'

module PostHog
  class InconclusiveMatchError < StandardError
  end

  class FeatureFlagsPoller
    include PostHog::Logging
    include PostHog::Utils

    def initialize(
      polling_interval,
      personal_api_key,
      project_api_key,
      host,
      feature_flag_request_timeout_seconds,
      on_error = nil
    )
      @polling_interval = polling_interval || 30
      @personal_api_key = personal_api_key
      @project_api_key = project_api_key
      @host = host
      @feature_flags = Concurrent::Array.new
      @group_type_mapping = Concurrent::Hash.new
      @loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
      @feature_flags_by_key = nil
      @feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
      @on_error = on_error || proc { |status, error| }
      @quota_limited = Concurrent::AtomicBoolean.new(false)
      @task =
        Concurrent::TimerTask.new(
          execution_interval: polling_interval
        ) { _load_feature_flags }

      # If no personal API key, disable local evaluation & thus polling for definitions
      if @personal_api_key.nil?
        logger.info 'No personal API key provided, disabling local evaluation'
        @loaded_flags_successfully_once.make_true
      else
        # load once before timer
        load_feature_flags
        @task.execute
      end
    end

    def load_feature_flags(force_reload = false)
      return unless @loaded_flags_successfully_once.false? || force_reload

      _load_feature_flags
    end

    def get_feature_variants(
      distinct_id,
      groups = {},
      person_properties = {},
      group_properties = {},
      only_evaluate_locally = false,
      raise_on_error = false
    )
      # TODO: Convert to options hash for easier argument passing
      flags_data = get_all_flags_and_payloads(
        distinct_id,
        groups,
        person_properties,
        group_properties,
        only_evaluate_locally,
        raise_on_error
      )

      if flags_data.key?(:featureFlags)
        stringify_keys(flags_data[:featureFlags] || {})
      else
        logger.debug "Missing feature flags key: #{flags_data.to_json}"
        {}
      end
    end

    def get_feature_payloads(
      distinct_id,
      groups = {},
      person_properties = {},
      group_properties = {},
      _only_evaluate_locally = false
    )
      flags_data = get_all_flags_and_payloads(
        distinct_id,
        groups,
        person_properties,
        group_properties
      )

      if flags_data.key?(:featureFlagPayloads)
        stringify_keys(flags_data[:featureFlagPayloads] || {})
      else
        logger.debug "Missing feature flag payloads key: #{flags_data.to_json}"
        {}
      end
    end

    def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
      request_data = {
        distinct_id: distinct_id,
        groups: groups,
        person_properties: person_properties,
        group_properties: group_properties
      }

      flags_response = _request_feature_flag_evaluation(request_data)

      # Only normalize if we have flags in the response
      if flags_response[:flags]
        # v4 format
        flags_hash = flags_response[:flags].transform_values do |flag|
          FeatureFlag.new(flag)
        end
        flags_response[:flags] = flags_hash
        flags_response[:featureFlags] = flags_hash.transform_values(&:get_value).transform_keys(&:to_sym)
        flags_response[:featureFlagPayloads] = flags_hash.transform_values(&:payload).transform_keys(&:to_sym)
      elsif flags_response[:featureFlags]
        # v3 format
        flags_response[:featureFlags] = flags_response[:featureFlags] || {}
        flags_response[:featureFlagPayloads] = flags_response[:featureFlagPayloads] || {}
        flags_response[:flags] = flags_response[:featureFlags].to_h do |key, value|
          [key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
        end
      end
      flags_response
    end

    def get_remote_config_payload(flag_key)
      _request_remote_config_payload(flag_key)
    end

    def get_feature_flag(
      key,
      distinct_id,
      groups = {},
      person_properties = {},
      group_properties = {},
      only_evaluate_locally = false
    )
      # make sure they're loaded on first run
      load_feature_flags

      symbolize_keys! groups
      symbolize_keys! person_properties
      symbolize_keys! group_properties

      group_properties.each_value do |value|
        symbolize_keys!(value)
      end

      response = nil
      feature_flag = nil

      @feature_flags.each do |flag|
        if key == flag[:key]
          feature_flag = flag
          break
        end
      end

      unless feature_flag.nil?
        begin
          response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
          logger.debug "Successfully computed flag locally: #{key} -> #{response}"
        rescue InconclusiveMatchError => e
          logger.debug "Failed to compute flag #{key} locally: #{e}"
        rescue StandardError => e
          @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}")
        end
      end

      flag_was_locally_evaluated = !response.nil?

      request_id = nil

      if !flag_was_locally_evaluated && !only_evaluate_locally
        begin
          flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties, false, true)
          if flags_data.key?(:featureFlags)
            flags = stringify_keys(flags_data[:featureFlags] || {})
            request_id = flags_data[:requestId]
          else
            logger.debug "Missing feature flags key: #{flags_data.to_json}"
            flags = {}
          end

          response = flags[key]
          response = false if response.nil?
          logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
        rescue StandardError => e
          @on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
        end
      end

      [response, flag_was_locally_evaluated, request_id]
    end

    def get_all_flags(
      distinct_id,
      groups = {},
      person_properties = {},
      group_properties = {},
      only_evaluate_locally = false
    )
      if @quota_limited.true?
        logger.debug 'Not fetching flags from server - quota limited'
        return {}
      end

      # returns a string hash of all flags
      response = get_all_flags_and_payloads(
        distinct_id,
        groups,
        person_properties,
        group_properties,
        only_evaluate_locally
      )

      response[:featureFlags]
    end

    def get_all_flags_and_payloads(
      distinct_id,
      groups = {},
      person_properties = {},
      group_properties = {},
      only_evaluate_locally = false,
      raise_on_error = false
    )
      load_feature_flags

      flags = {}
      payloads = {}
      fallback_to_server = @feature_flags.empty?
      request_id = nil # Only for /flags requests

      @feature_flags.each do |flag|
        match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
        flags[flag[:key]] = match_value
        match_payload = _compute_flag_payload_locally(flag[:key], match_value)
        payloads[flag[:key]] = match_payload if match_payload
      rescue InconclusiveMatchError
        fallback_to_server = true
      rescue StandardError => e
        @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
        fallback_to_server = true
      end

      if fallback_to_server && !only_evaluate_locally
        begin
          flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)

          unless flags_and_payloads.key?(:featureFlags)
            raise StandardError, "Error flags response: #{flags_and_payloads}"
          end

          # Check if feature_flags are quota limited
          if flags_and_payloads[:quotaLimited]&.include?('feature_flags')
            logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
            flags = {}
            payloads = {}
          else
            flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
            payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
            request_id = flags_and_payloads[:requestId]
          end
        rescue StandardError => e
          @on_error.call(-1, "Error computing flag remotely: #{e}")
          raise if raise_on_error
        end
      end

      {
        featureFlags: flags,
        featureFlagPayloads: payloads,
        requestId: request_id
      }
    end

    def get_feature_flag_payload(
      key,
      distinct_id,
      match_value = nil,
      groups = {},
      person_properties = {},
      group_properties = {},
      only_evaluate_locally = false
    )
      if match_value.nil?
        match_value = get_feature_flag(
          key,
          distinct_id,
          groups,
          person_properties,
          group_properties,
          true
        )[0]
      end
      response = nil
      response = _compute_flag_payload_locally(key, match_value) unless match_value.nil?
      if response.nil? && !only_evaluate_locally
        flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
        response = flags_payloads[key.downcase] || nil
      end
      response
    end

    def shutdown_poller
      @task.shutdown
    end

    # Class methods

    def self.compare(lhs, rhs, operator)
      case operator
      when 'gt'
        lhs > rhs
      when 'gte'
        lhs >= rhs
      when 'lt'
        lhs < rhs
      when 'lte'
        lhs <= rhs
      else
        raise "Invalid operator: #{operator}"
      end
    end

    def self.relative_date_parse_for_feature_flag_matching(value)
      match = /^-?([0-9]+)([a-z])$/.match(value)
      parsed_dt = DateTime.now.new_offset(0)
      return unless match

      number = match[1].to_i

      if number >= 10_000
        # Guard against overflow, disallow numbers greater than 10_000
        return nil
      end

      interval = match[2]
      case interval
      when 'h'
        parsed_dt -= (number / 24.0)
      when 'd'
        parsed_dt = parsed_dt.prev_day(number)
      when 'w'
        parsed_dt = parsed_dt.prev_day(number * 7)
      when 'm'
        parsed_dt = parsed_dt.prev_month(number)
      when 'y'
        parsed_dt = parsed_dt.prev_year(number)
      else
        return nil
      end

      parsed_dt
    end

    def self.match_property(property, property_values)
      # only looks for matches where key exists in property_values
      # doesn't support operator is_not_set

      PostHog::Utils.symbolize_keys! property
      PostHog::Utils.symbolize_keys! property_values

      key = property[:key].to_sym
      value = property[:value]
      operator = property[:operator] || 'exact'

      if !property_values.key?(key)
        raise InconclusiveMatchError, "Property #{key} not found in property_values"
      elsif operator == 'is_not_set'
        raise InconclusiveMatchError, 'Operator is_not_set not supported'
      end

      override_value = property_values[key]

      case operator
      when 'exact', 'is_not'
        if value.is_a?(Array)
          values_stringified = value.map { |val| val.to_s.downcase }
          return values_stringified.any?(override_value.to_s.downcase) if operator == 'exact'

          return values_stringified.none?(override_value.to_s.downcase)

        end
        if operator == 'exact'
          value.to_s.downcase == override_value.to_s.downcase
        else
          value.to_s.downcase != override_value.to_s.downcase
        end
      when 'is_set'
        property_values.key?(key)
      when 'icontains'
        override_value.to_s.downcase.include?(value.to_s.downcase)
      when 'not_icontains'
        !override_value.to_s.downcase.include?(value.to_s.downcase)
      when 'regex'
        PostHog::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
      when 'not_regex'
        PostHog::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
      when 'gt', 'gte', 'lt', 'lte'
        parsed_value = nil
        begin
          parsed_value = Float(value)
        rescue StandardError # rubocop:disable Lint/SuppressedException
        end
        if !parsed_value.nil? && !override_value.nil?
          if override_value.is_a?(String)
            compare(override_value, value.to_s, operator)
          else
            compare(override_value, parsed_value, operator)
          end
        else
          compare(override_value.to_s, value.to_s, operator)
        end
      when 'is_date_before', 'is_date_after'
        override_date = PostHog::Utils.convert_to_datetime(override_value.to_s)
        parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)

        parsed_date = PostHog::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?

        raise InconclusiveMatchError, 'Invalid date format' unless parsed_date

        if operator == 'is_date_before'
          override_date < parsed_date
        elsif operator == 'is_date_after'
          override_date > parsed_date
        end
      else
        raise InconclusiveMatchError, "Unknown operator: #{operator}"
      end
    end

    private

    def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
      raise InconclusiveMatchError, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]

      return false unless flag[:active]

      flag_filters = flag[:filters] || {}

      aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
      return match_feature_flag_properties(flag, distinct_id, person_properties) if aggregation_group_type_index.nil?

      group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]

      if group_name.nil?
        logger.warn(
          "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
        )
        # failover to `/flags/`
        raise InconclusiveMatchError, 'Flag has unknown group type index'
      end

      group_name_symbol = group_name.to_sym

      unless groups.key?(group_name_symbol)
        # Group flags are never enabled if appropriate `groups` aren't passed in
        # don't failover to `/flags/`, since response will be the same
        logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
        return false
      end

      focused_group_properties = group_properties[group_name_symbol]
      match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties)
    end

    def _compute_flag_payload_locally(key, match_value)
      return nil if @feature_flags_by_key.nil?

      response = nil
      if [true, false].include? match_value
        response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_s.to_sym)
      elsif match_value.is_a? String
        response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_sym)
      end
      response
    end

    def match_feature_flag_properties(flag, distinct_id, properties)
      flag_filters = flag[:filters] || {}

      flag_conditions = flag_filters[:groups] || []
      is_inconclusive = false
      result = nil

      # Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
      # evaluated first, and the variant override is applied to the first matching condition.
      sorted_flag_conditions = flag_conditions.each_with_index.sort_by do |condition, idx|
        [condition[:variant].nil? ? 1 : -1, idx]
      end

      # NOTE: This NEEDS to be `each` because `each_key` breaks
      # This is not a hash, it's just an array with 2 entries
      sorted_flag_conditions.each do |condition, _idx| # rubocop:disable Style/HashEachMethods
        if is_condition_match(flag, distinct_id, condition, properties)
          variant_override = condition[:variant]
          flag_multivariate = flag_filters[:multivariate] || {}
          flag_variants = flag_multivariate[:variants] || []
          variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
                      variant_override
                    else
                      get_matching_variant(flag, distinct_id)
                    end
          result = variant || true
          break
        end
      rescue InconclusiveMatchError
        is_inconclusive = true
      end

      if !result.nil?
        return result
      elsif is_inconclusive
        raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
      end

      # We can only return False when all conditions are False
      false
    end

    # TODO: Rename to `condition_match?` in future version
    def is_condition_match(flag, distinct_id, condition, properties) # rubocop:disable Naming/PredicateName
      rollout_percentage = condition[:rollout_percentage]

      unless (condition[:properties] || []).empty?
        if !condition[:properties].all? do |prop|
          # Skip flag dependencies as they are not supported in local evaluation
          if prop[:type] == 'flag'
            logger.warn(
              '[FEATURE FLAGS] Flag dependency filters are not supported in local evaluation. ' \
              "Skipping condition for flag '#{flag[:key]}' with dependency on flag '#{prop[:key]}'"
            )
            next true
          end
          FeatureFlagsPoller.match_property(prop, properties)
        end
          return false
        elsif rollout_percentage.nil?
          return true
        end
      end

      return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))

      true
    end

    # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
    # Given the same distinct_id and key, it'll always return the same float. These floats are
    # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
    # we can do _hash(key, distinct_id) < 0.2
    def _hash(key, distinct_id, salt = '')
      hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
      (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
    end

    def get_matching_variant(flag, distinct_id)
      hash_value = _hash(flag[:key], distinct_id, 'variant')
      matching_variant = variant_lookup_table(flag).find do |variant|
        hash_value >= variant[:value_min] and hash_value < variant[:value_max]
      end
      matching_variant.nil? ? nil : matching_variant[:key]
    end

    def variant_lookup_table(flag)
      lookup_table = []
      value_min = 0
      flag_filters = flag[:filters] || {}
      variants = flag_filters[:multivariate] || {}
      multivariates = variants[:variants] || []
      multivariates.each do |variant|
        value_max = value_min + (variant[:rollout_percentage].to_f / 100)
        lookup_table << { value_min: value_min, value_max: value_max, key: variant[:key] }
        value_min = value_max
      end
      lookup_table
    end

    def _load_feature_flags
      begin
        res = _request_feature_flag_definitions
      rescue StandardError => e
        @on_error.call(-1, e.to_s)
        return
      end

      # Handle quota limits with 402 status
      if res.is_a?(Hash) && res[:status] == 402
        logger.warn(
          '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
          'Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
        )
        @feature_flags = Concurrent::Array.new
        @feature_flags_by_key = {}
        @group_type_mapping = Concurrent::Hash.new
        @loaded_flags_successfully_once.make_false
        @quota_limited.make_true
        return
      end

      if res.key?(:flags)
        @feature_flags = res[:flags] || []
        @feature_flags_by_key = {}
        @feature_flags.each do |flag|
          @feature_flags_by_key[flag[:key]] = flag unless flag[:key].nil?
        end
        @group_type_mapping = res[:group_type_mapping] || {}

        logger.debug "Loaded #{@feature_flags.length} feature flags"
        @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
      else
        logger.debug "Failed to load feature flags: #{res}"
      end
    end

    def _request_feature_flag_definitions
      uri = URI("#{@host}/api/feature_flag/local_evaluation")
      uri.query = URI.encode_www_form([['token', @project_api_key]])
      req = Net::HTTP::Get.new(uri)
      req['Authorization'] = "Bearer #{@personal_api_key}"

      _request(uri, req)
    end

    def _request_feature_flag_evaluation(data = {})
      uri = URI("#{@host}/flags/?v=2")
      req = Net::HTTP::Post.new(uri)
      req['Content-Type'] = 'application/json'
      data['token'] = @project_api_key
      req.body = data.to_json

      _request(uri, req, @feature_flag_request_timeout_seconds)
    end

    def _request_remote_config_payload(flag_key)
      uri = URI("#{@host}/api/projects/@current/feature_flags/#{flag_key}/remote_config")
      uri.query = URI.encode_www_form([['token', @project_api_key]])
      req = Net::HTTP::Get.new(uri)
      req['Content-Type'] = 'application/json'
      req['Authorization'] = "Bearer #{@personal_api_key}"

      _request(uri, req, @feature_flag_request_timeout_seconds)
    end

    # rubocop:disable Lint/ShadowedException
    def _request(uri, request_object, timeout = nil)
      request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
      request_timeout = timeout || 10

      begin
        Net::HTTP.start(
          uri.hostname,
          uri.port,
          use_ssl: uri.scheme == 'https',
          read_timeout: request_timeout
        ) do |http|
          res = http.request(request_object)

          # Parse response body to hash
          begin
            response = JSON.parse(res.body, { symbolize_names: true })
            # Only add status if response is a hash
            response = response.merge({ status: res.code.to_i }) if response.is_a?(Hash)
            return response
          rescue JSON::ParserError
            # Handle case when response isn't valid JSON
            return { error: 'Invalid JSON response', body: res.body, status: res.code.to_i }
          end
        end
      rescue Timeout::Error,
             Errno::EINVAL,
             Errno::ECONNRESET,
             EOFError,
             Net::HTTPBadResponse,
             Net::HTTPHeaderSyntaxError,
             Net::ReadTimeout,
             Net::WriteTimeout,
             Net::ProtocolError
        logger.debug("Unable to complete request to #{uri}")
        raise
      end
    end
    # rubocop:enable Lint/ShadowedException
  end
end