Adding more goals to feed experiments (#17673)
This commit adds three new feed experiment goals: * user publishes four posts within a week * user reacts with a "heart", "unicorn", or "reading list" to an article * user reacts with a "heart", "unicorn", or "reading list" to an article four times in a week In addition it adds it to the existing experiment. Adding it to the existing experiment is acceptable because: 1. We can ignore the results 2. The experiments are structured such that these new goals could be met with prior data. 3. Both variants are playing by the same rules, so the results relative to each other are valid. **There is a nuanced assumption in how we handle reactions:** This implementation does not count by unique article reactions. However, most folks will ❤️ , 🦄 , and 🔖 in one swoop; thus those 3 reactions are all grouped into happening on one day. Further, it's a reflection of the person taking an action after reading a post, not how much action on that particular post. **Rollback considerations:** This also includes a feature flag that we can explicitly disable if we overload the application with workers handling reaction goals. Using `FeatureFlag.accessible?(:field_test_event_for_reactions)` returns `true` unless we explicitly disable this flag. Closes forem/forem#17669
This commit is contained in:
parent
ba3e619c55
commit
8a4e1ebeba
5 changed files with 162 additions and 1 deletions
|
|
@ -12,6 +12,7 @@ class AbExperiment
|
|||
USER_PUBLISHES_POST_GOAL = "user_publishes_post".freeze
|
||||
USER_CREATES_PAGEVIEW_GOAL = "user_creates_pageview".freeze
|
||||
USER_CREATES_COMMENT_GOAL = "user_creates_comment".freeze
|
||||
USER_CREATES_ARTICLE_REACTION_GOAL = "user_creates_article_reaction".freeze
|
||||
|
||||
def self.call(...)
|
||||
new(...).call
|
||||
|
|
@ -49,6 +50,8 @@ class AbExperiment
|
|||
convert_comment_goal(experiment: experiment, experiment_start_date: experiment_start_date)
|
||||
when USER_PUBLISHES_POST_GOAL
|
||||
convert_post_goal(experiment: experiment, experiment_start_date: experiment_start_date)
|
||||
when USER_CREATES_ARTICLE_REACTION_GOAL
|
||||
convert_reaction_goal(experiment: experiment, experiment_start_date: experiment_start_date)
|
||||
else
|
||||
field_test_converted(experiment, participant: user, goal: goal) # base single comment goal.
|
||||
end
|
||||
|
|
@ -88,6 +91,11 @@ class AbExperiment
|
|||
|
||||
def convert_post_goal(experiment:, experiment_start_date:)
|
||||
field_test_converted(experiment, participant: user, goal: goal) # base is we created a post
|
||||
post_goal_with_group(experiment,
|
||||
[7.days.ago, experiment_start_date].max,
|
||||
"DATE(published_at)",
|
||||
4,
|
||||
"user_publishes_post_on_four_different_days_within_a_week")
|
||||
post_goal(experiment,
|
||||
[7.days.ago, experiment_start_date].max,
|
||||
2,
|
||||
|
|
@ -98,6 +106,15 @@ class AbExperiment
|
|||
"user_publishes_post_at_least_two_times_within_two_weeks")
|
||||
end
|
||||
|
||||
def convert_reaction_goal(experiment:, experiment_start_date:)
|
||||
field_test_converted(experiment, participant: user, goal: goal) # base is we created a post
|
||||
reaction_goal(experiment,
|
||||
[7.days.ago, experiment_start_date].max,
|
||||
"DATE(created_at)",
|
||||
4,
|
||||
"user_creates_article_reaction_on_four_different_days_within_a_week")
|
||||
end
|
||||
|
||||
def pageview_goal(experiment, time_start, group_value, min_count, goal)
|
||||
page_view_counts = user.page_views.where("created_at > ?", time_start)
|
||||
.group(group_value).count.values
|
||||
|
|
@ -113,6 +130,14 @@ class AbExperiment
|
|||
field_test_converted(experiment, participant: user, goal: goal)
|
||||
end
|
||||
|
||||
def post_goal_with_group(experiment, time_start, group_value, min_count, goal)
|
||||
post_publication_counts = user.articles.published.where("published_at > ?", time_start)
|
||||
.group(group_value).count.values
|
||||
return unless post_publication_counts.size >= min_count
|
||||
|
||||
field_test_converted(experiment, participant: user, goal: goal)
|
||||
end
|
||||
|
||||
def comment_goal(experiment, time_start, group_value, min_count, goal)
|
||||
comment_counts = user.comments.where("created_at > ?", time_start)
|
||||
.group(group_value).count.values
|
||||
|
|
@ -121,5 +146,16 @@ class AbExperiment
|
|||
|
||||
field_test_converted(experiment, participant: user, goal: goal)
|
||||
end
|
||||
|
||||
def reaction_goal(experiment, time_start, group_value, min_count, goal)
|
||||
reaction_counts = user.reactions
|
||||
.only_articles # as per `Reaction#record_field_test_event` we only record reactions to articles
|
||||
.public_category # as per `Reaction#record_field_test_event` we only record public category reactions
|
||||
.where("created_at > ?", time_start)
|
||||
.group(group_value).count.values
|
||||
return unless reaction_counts.size >= min_count
|
||||
|
||||
field_test_converted(experiment, participant: user, goal: goal)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ class Reaction < ApplicationRecord
|
|||
# user they might only see readinglist items that are published.
|
||||
# See https://github.com/forem/forem/issues/14796
|
||||
scope :readinglist, -> { where(category: "readinglist") }
|
||||
scope :for_articles, ->(ids) { where(reactable_type: "Article", reactable_id: ids) }
|
||||
scope :for_articles, ->(ids) { only_articles.where(reactable_id: ids) }
|
||||
scope :only_articles, -> { where(reactable_type: "Article") }
|
||||
scope :eager_load_serialized_data, -> { includes(:reactable, :user) }
|
||||
scope :article_vomits, -> { where(category: "vomit", reactable_type: "Article") }
|
||||
scope :comment_vomits, -> { where(category: "vomit", reactable_type: "Comment") }
|
||||
|
|
@ -61,6 +62,7 @@ class Reaction < ApplicationRecord
|
|||
before_destroy :update_reactable_without_delay, unless: :destroyed_by_association
|
||||
after_commit :async_bust
|
||||
after_commit :bust_reactable_cache, :update_reactable, on: %i[create update]
|
||||
after_commit :record_field_test_event, on: %i[create]
|
||||
|
||||
class << self
|
||||
def count_for_article(id)
|
||||
|
|
@ -209,4 +211,18 @@ class Reaction < ApplicationRecord
|
|||
def new_untrusted_user
|
||||
user.registered_at > NEW_USER_RAMPUP_DAYS_COUNT.days.ago && !user.trusted? && !user.any_admin?
|
||||
end
|
||||
|
||||
# @see AbExperiment::GoalConversionHandler
|
||||
def record_field_test_event
|
||||
# TODO: Remove once we know that this test is not over-heating the application. That would be a
|
||||
# few days after the deploy to DEV of this change.
|
||||
return unless FeatureFlag.accessible?(:field_test_event_for_reactions)
|
||||
return if FieldTest.config["experiments"].nil?
|
||||
return unless PUBLIC_CATEGORIES.include?(category)
|
||||
return unless reactable.is_a?(Article)
|
||||
return unless user_id
|
||||
|
||||
Users::RecordFieldTestEventWorker
|
||||
.perform_async(user_id, AbExperiment::GoalConversionHandler::USER_CREATES_ARTICLE_REACTION_GOAL)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ experiments:
|
|||
- user_publishes_post
|
||||
- user_publishes_post_at_least_two_times_within_week
|
||||
- user_publishes_post_at_least_two_times_within_two_weeks
|
||||
- user_publishes_post_on_four_different_days_within_a_week
|
||||
- user_creates_article_reaction
|
||||
- user_creates_article_reaction_on_four_different_days_within_a_week
|
||||
exclude:
|
||||
bots: true
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,24 @@ RSpec.describe AbExperiment::GoalConversionHandler do
|
|||
"user_publishes_post_at_least_two_times_within_week",
|
||||
].sort)
|
||||
end
|
||||
|
||||
it "records a conversion when they post 4 within a week" do
|
||||
create(:article, published_at: 25.hours.ago, user_id: user.id)
|
||||
create(:article, published_at: 49.hours.ago, user_id: user.id)
|
||||
create(:article, published_at: 73.hours.ago, user_id: user.id)
|
||||
create(:article, published_at: 97.hours.ago, user_id: user.id)
|
||||
|
||||
handler.call
|
||||
|
||||
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
|
||||
expect(FieldTest::Event.all.pluck(:name).sort)
|
||||
.to match_array([
|
||||
goal,
|
||||
"user_publishes_post_at_least_two_times_within_two_weeks",
|
||||
"user_publishes_post_at_least_two_times_within_week",
|
||||
"user_publishes_post_on_four_different_days_within_a_week",
|
||||
].sort)
|
||||
end
|
||||
end
|
||||
|
||||
context "with user who is part of field test and user_creates_comment goal" do
|
||||
|
|
@ -169,6 +187,64 @@ RSpec.describe AbExperiment::GoalConversionHandler do
|
|||
end
|
||||
end
|
||||
|
||||
context "with user who is part of field test and user_creates_article_reaction goal" do
|
||||
let(:goal) { described_class::USER_CREATES_ARTICLE_REACTION_GOAL }
|
||||
let(:user) { create(:user, :trusted) } # Because we want to test handling of privileged reactions
|
||||
|
||||
before do
|
||||
field_test(AbExperiment::CURRENT_FEED_STRATEGY_EXPERIMENT, participant: user)
|
||||
end
|
||||
|
||||
it "records a conversion", :aggregate_failures do
|
||||
create(:reaction, user_id: user.id)
|
||||
handler.call
|
||||
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
|
||||
expect(FieldTest::Event.last.name).to eq(goal)
|
||||
end
|
||||
|
||||
it "records a conversion when they react 4 times within a week", :aggregate_failures do
|
||||
create(:reaction, user_id: user.id, created_at: 25.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 49.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 73.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 97.hours.ago)
|
||||
handler.call
|
||||
|
||||
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
|
||||
expect(FieldTest::Event.all.pluck(:name).sort)
|
||||
.to match_array([
|
||||
goal,
|
||||
"user_creates_article_reaction_on_four_different_days_within_a_week",
|
||||
].sort)
|
||||
end
|
||||
|
||||
it "does not record the conversion when they react to non-articles or non-public reactions" do
|
||||
create(:reaction, user_id: user.id, created_at: 25.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 49.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 73.hours.ago, category: "vomit")
|
||||
create(:reaction, user_id: user.id, created_at: 97.hours.ago, reactable: create(:comment))
|
||||
create(:reaction, user_id: user.id, created_at: 121.hours.ago)
|
||||
|
||||
handler.call
|
||||
|
||||
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
|
||||
expect(FieldTest::Event.all.pluck(:name))
|
||||
.to match_array([goal])
|
||||
end
|
||||
|
||||
it "does not record the conversion when their 4 reactions are not within a week" do
|
||||
create(:reaction, user_id: user.id, created_at: 25.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 49.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 73.hours.ago)
|
||||
create(:reaction, user_id: user.id, created_at: 217.hours.ago) # 9 days ago
|
||||
|
||||
handler.call
|
||||
|
||||
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
|
||||
expect(FieldTest::Event.all.pluck(:name))
|
||||
.to match_array([goal])
|
||||
end
|
||||
end
|
||||
|
||||
context "with user who is not part of field test" do
|
||||
let(:goal) { described_class::USER_CREATES_COMMENT_GOAL }
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,36 @@ RSpec.describe Reaction, type: :model do
|
|||
end
|
||||
|
||||
context "when callbacks are called after create" do
|
||||
describe "field tests" do
|
||||
let!(:user) { create(:user, :trusted) }
|
||||
|
||||
before do
|
||||
# making sure there are no other enqueued jobs from other tests
|
||||
sidekiq_perform_enqueued_jobs(only: Users::RecordFieldTestEventWorker)
|
||||
end
|
||||
|
||||
it "enqueues a Users::RecordFieldTestEventWorker for giving a like to an article" do
|
||||
article = create(:article, user: user)
|
||||
sidekiq_assert_enqueued_jobs(1, only: Users::RecordFieldTestEventWorker) do
|
||||
create(:reaction, reactable: article, user: user, category: "like")
|
||||
end
|
||||
end
|
||||
|
||||
it "does not enqueue a Users::RecordFieldTestEventWorker for giving a privileged reaction to an article" do
|
||||
article = create(:article, user: user)
|
||||
sidekiq_assert_enqueued_jobs(0, only: Users::RecordFieldTestEventWorker) do
|
||||
create(:reaction, reactable: article, user: user, category: "thumbsdown")
|
||||
end
|
||||
end
|
||||
|
||||
it "does not enqueue a Users::RecordFieldTestEventWorker for giving a like to a comment" do
|
||||
comment = create(:comment, user: user)
|
||||
sidekiq_assert_enqueued_jobs(0, only: Users::RecordFieldTestEventWorker) do
|
||||
create(:reaction, reactable: comment, user: user, category: "like")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "slack messages" do
|
||||
let!(:user) { create(:user, :trusted) }
|
||||
let!(:article) { create(:article, user: user) }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue