18F/identity-idp

View on GitHub
lib/ab_test.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

class AbTest
  include ::NewRelic::Agent::MethodTracer

  attr_reader :buckets, :experiment_name, :default_bucket, :should_log

  MAX_SHA = (16 ** 64) - 1

  # @param [Proc<String>,Regexp,string,Boolean,nil] should_log Controls whether bucket data for this
  #                                                            A/B test is logged with specific
  #                                                            events.
  # @yieldparam [ActionDispatch::Request] request
  # @yieldparam [String,nil] service_provider Issuer string for the service provider associated with
  #                                           the current session.
  # @yieldparam [User] user
  # @yieldparam [Hash] user_session
  def initialize(
    experiment_name:,
    buckets: {},
    should_log: nil,
    default_bucket: :default,
    &discriminator
  )
    @buckets = buckets
    @discriminator = discriminator
    @experiment_name = experiment_name
    @default_bucket = default_bucket
    @should_log = should_log
    raise 'invalid bucket data structure' unless valid_bucket_data_structure?
    ensure_numeric_percentages
    raise 'bucket percentages exceed 100' unless within_100_percent?
  end

  # @param [ActionDispatch::Request] request
  # @param [String,nil] service_provider Issuer string for the service provider associated with
  #                                      the current session.
  # @params [Hash] session
  # @param [User] user
  # @param [Hash] user_session
  def bucket(request:, service_provider:, session:, user:, user_session:)
    return nil if no_percentages?

    discriminator = resolve_discriminator(
      request:, service_provider:, session:, user:,
      user_session:
    )
    return nil if discriminator.blank?

    user_value = percent(discriminator)

    min = 0
    buckets.keys.each do |key|
      max = min + buckets[key]
      return key if user_value > min && user_value <= max
      min = max
    end

    @default_bucket
  end

  def include_in_analytics_event?(event_name)
    if should_log.is_a?(Regexp)
      should_log.match?(event_name)
    elsif should_log.respond_to?(:include?)
      should_log.include?(event_name)
    elsif !should_log.nil?
      raise 'Unexpected value used for should_log'
    else
      true
    end
  end

  private

  def resolve_discriminator(user:, **)
    if @discriminator
      @discriminator.call(user:, **)
    elsif !user.is_a?(AnonymousUser)
      user&.uuid
    end
  end

  def no_percentages?
    buckets.empty? || buckets.values.all? { |pct| pct == 0 }
  end

  def percent(discriminator)
    Digest::SHA256.hexdigest("#{discriminator}:#{experiment_name}").to_i(16).to_f / MAX_SHA * 100
  end

  def valid_bucket_data_structure?
    return false if !buckets.is_a?(Hash)

    buckets.values.each { |v| Float(v) }

    true
  rescue ArgumentError
    false
  end

  def ensure_numeric_percentages
    buckets.keys.each do |key|
      buckets[key] = buckets[key].to_f if buckets[key].is_a?(String)
    end
  end

  def within_100_percent?
    valid_bucket_data_structure? && buckets.values.sum <= 100
  end

  add_method_tracer :bucket, "Custom/#{name}/bucket"
  add_method_tracer :percent, "Custom/#{name}/percent"
end