docbrown/app/models/ab_experiment.rb
Jeremy Friesen 0d0464be2f
Allowing VariantQuery for Feed Generation (#17382)
* Allowing VariantQuery for Feed Generation

Apologies for the breadth of this pull request, I had considered many
small commits, but felt that would've been more effort for the value
provided.

This commit includes the following:

- Documentation updates to the feed variant (though not the final pass)
- Renaming and adding RelevancyLevers that help differentiate
- Adding RelevancyLever#range to provide documentation
- Reducing redundent controller logic by making a
  `Articles::Feeds.feed_for` method.
- Adding some configuration validation for RelevancyLevers
- Adding constants for better clarification
- Testing unhappy paths for feed configuration
- Adjusting the module namespace of some objects
- Exposing top-level configurations for variants (along with their
  defaults)
- Creating the VariantyQuery that at present inherits from the
  `Articles::Feeds::WeightedQueryStrategy`

As implemented, we can deploy this code to production without using the
new VariantQuery.  Once we toggle on the
`:feed_uses_variant_query_feature` FeatureFlag, it will switch to using
the VariantQuery.  The VariantQuery's two variants and the
internal configuration of `Articles::Feeds::WeightedQueryStrategy`
produce the same query.

The goal of this factor is to allow for a quick on and off toggle of the
feed query; to ensure that what we introduce remains performant.

- Closes forem/forem#17272
- Closes forem/forem#17276
- Closes forem/forem#17216

In addition, I will be recording a code-walkthrough and linking that
recording to the pull request.

* Apply suggestions from code review

Co-authored-by: Mac Siri <krairit.siri@gmail.com>

Co-authored-by: Mac Siri <krairit.siri@gmail.com>
2022-04-21 11:07:09 -04:00

125 lines
5 KiB
Ruby

# This object wraps the FieldTest logic to provide a bit of insulation
# between the FieldTest implementation and the application logic.
#
# This is a SimpleDelegator because the field_test method requires
# controller context when the participating user is nil.
#
# Use the AbExperiment.get method to interact with this class.
class AbExperiment < SimpleDelegator
# Don't allow someone to circumvent the .get class method. I want
# to follow the Law of Demeter and require that we us the @api
# public
private_class_method :new
# @see ./config/feed-variants/README.md
ORIGINAL_VARIANT = "original".freeze
CURRENT_FEED_STRATEGY_EXPERIMENT = FieldTest.config["experiments"]&.keys
&.detect { |e| e.start_with? "feed_strategy" }.freeze
# Sometimes we might want to repurpose the same AbExperiment logic
# for different experiments. This provides the tooling for that
# exact thing.
EXPERIMENT_TO_METHOD_NAME_MAP = {
CURRENT_FEED_STRATEGY_EXPERIMENT => :feed_strategy
}.freeze
# This method helps us leverage existing methods for different
# experiments.
#
# @param experiment [Symbol] the name of the experiment
# @return [Symbol] the method name associated with this experiment.
#
# @see EXPERIMENT_TO_METHOD_NAME_MAP
def self.method_name_for(experiment)
EXPERIMENT_TO_METHOD_NAME_MAP.fetch(experiment, experiment)
end
# A convenience method for retrieving the feed variant
# @param controller [ApplicationController] the request context of
# the experiment.
# @param user [User] who are we running the experiment with
#
# @return [Object] a variant that the experimenter should know what it wants
#
# @see .get
def self.get_feed_variant_for(controller:, user:)
get(
controller: controller,
user: user,
default_value: ORIGINAL_VARIANT,
experiment: CURRENT_FEED_STRATEGY_EXPERIMENT,
)
end
def self.variants_for_experiment(experiment)
configured_experiment = FieldTest.config["experiments"][experiment]
configured_experiment["variants"] if configured_experiment
end
# @api public
#
# A convenience method to insulate against the implementation
# details of the field_test gem.
#
# @param experiment [Symbol] the named method we'll call for an
# experiment. It should be a method name defined on this
# object.
# @param controller [ApplicationController] the request context of
# the experiment.
# @note We need the controller object due to the implementation of
# the `field_test` method. The `field_test` method is defined
# in the FieldTest::Helpers module. If we have a user, the
# field_test method works great with just the
# FieldTest::Helpers methods. However, if we don't have a
# user, then the field_test method calls
# `field_test_participant` which is defined in the
# `FieldTest::Controller` (which calls the `request` and
# `cookies`).
# @param user [User] who are we running the experiment with
# @param config [Hash] container for possible ENV override of strategy.
# @param default_value [Object] the caller is making decisions based
# on a configured set of values. They know which one they
# likely want. Let them give us a hint.
#
# @return [Object] a variant that the experimenter should know what it wants
#
# @see config/field_test.yml file for configured experiments.
#
# @note You can force a named strategy by setting an ENV variable.
# This forced strategy might be super useful for anyone performing
# QA testing on AB Testing scenarios.
#
# @todo If we make heavy use of this class, consider guarding for
# valid experiment methods.
def self.get(experiment:, controller:, user:, default_value:, config: ApplicationConfig)
method_name = method_name_for(experiment)
new(controller: controller)
.public_send(method_name, user: user, default_value: default_value, experiment: experiment, config: config)
end
# @api private
# @param controller [ApplicationController] the current controller
# that's handling the current request.
def initialize(controller:)
super(controller)
end
# @api private
# @note Called via AbExperiment.get
def feed_strategy(user:, config:, default_value:, experiment: :feed_strategy)
return default_value.inquiry unless FeatureFlag.accessible?(:ab_experiment_feed_strategy)
(config["AB_EXPERIMENT_FEED_STRATEGY"] || field_test(experiment, participant: user)).inquiry
rescue FieldTest::ExperimentNotFound
# rubocop:disable Layout/LineLength
Rails.logger.warn do
"Upstream request #{experiment.inspect} experiment. There are no registered #{experiment.inspect} experiments. Using the default value of #{default_value.inspect} for #{experiment.inspect} experiment."
end
# rubocop:enable Layout/LineLength
# Because we should have a fall back plan in case the field test
# has an odd configuration.
default_value.inquiry
end
end