Add field test gem to establish a/b testing (#6283) [deploy]

* Initial field test spec work

* Clean up specs

* Fix codeclimage

* Cleanups

* Update names

* Rename and remove need to pass logged in status

* Leadup to prod: two experiments

* Move test logic to controller and add tests

Co-authored-by: Josh Puetz <josh@grorichpuetz.com>
This commit is contained in:
Ben Halpern 2020-03-03 10:21:29 -05:00 committed by GitHub
parent 3b2b9cb77b
commit 1dc1723709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 393 additions and 32 deletions

View file

@ -41,6 +41,7 @@ gem "fast_jsonapi", "~> 1.5" # Serializer for Ruby objects
gem "fastly", "~> 1.15" # Client library for the Fastly acceleration system
gem "fastly-rails", "~> 0.8" # Fastly dynamic caching integration for Rails
gem "feedjira", "~> 3.1" # A feed fetching and parsing library
gem "field_test", "~> 0.3" # A/B testing
gem "figaro", "~> 1.1" # Simple, Heroku-friendly Rails app configuration using ENV and a single YAML file
gem "fog-aws", "~> 3.5" # 'fog' gem to support Amazon Web Services
gem "front_matter_parser", "~> 0.2" # Parse a front matter from syntactically correct strings or files

View file

@ -133,6 +133,7 @@ GEM
bootsnap (1.4.6)
msgpack (~> 1.0)
brakeman (4.8.0)
browser (2.7.1)
buffer (0.1.3)
addressable
environs
@ -223,6 +224,7 @@ GEM
responders
warden (~> 1.2.3)
diff-lcs (1.3)
distribution (0.7.3)
docile (1.3.2)
dogstatsd-ruby (4.7.0)
domain_name (0.5.20190701)
@ -314,6 +316,11 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
field_test (0.3.1)
activerecord (>= 5)
browser (~> 2.0)
distribution
railties (>= 5)
figaro (1.1.1)
thor (~> 0.14)
fix-db-schema-conflicts (3.0.3)
@ -891,6 +898,7 @@ DEPENDENCIES
fastly (~> 1.15)
fastly-rails (~> 0.8)
feedjira (~> 3.1)
field_test (~> 0.3)
figaro (~> 1.1)
fix-db-schema-conflicts (~> 3.0)
fog-aws (~> 3.5)

View file

@ -13,9 +13,27 @@ class Stories::FeedsController < ApplicationController
feed.top_articles_by_timeframe(timeframe: params[:timeframe])
elsif params[:timeframe] == Timeframer::LATEST_TIMEFRAME
feed.latest_feed
elsif user_signed_in?
ab_test_user_signed_in_feed(feed)
else
feed.default_home_feed(user_signed_in: user_signed_in?)
end
ArticleDecorator.decorate_collection(stories)
end
def ab_test_user_signed_in_feed(feed)
test_variant = field_test(:user_home_feed, participant: current_user)
case test_variant
when "base"
feed.default_home_feed(user_signed_in: true)
when "more_random"
feed.default_home_feed_with_more_randomness
when "mix_base_and_more_random"
feed.mix_default_and_more_random
when "more_tag_weight"
feed.more_tag_weight
else
feed.default_home_feed(user_signed_in: true)
end
end
end

View file

@ -20,8 +20,9 @@ class Comment < ApplicationRecord
validates :commentable_type, inclusion: { in: %w[Article PodcastEpisode] }
validates :user_id, presence: true
after_create :after_create_checks
after_commit :calculate_score
after_create :after_create_checks
after_create_commit :record_field_test_event
after_commit :calculate_score
after_update_commit :update_notifications, if: proc { |comment| comment.saved_changes.include? "body_markdown" }
after_save :bust_cache
after_save :synchronous_bust
@ -304,4 +305,8 @@ class Comment < ApplicationRecord
def permissions
errors.add(:commentable_id, "is not valid.") if commentable_type == "Article" && !commentable.published
end
def record_field_test_event
Users::RecordFieldTestEventWorker.perform_async(user_id, :user_home_feed, "user_creates_comment")
end
end

View file

@ -3,6 +3,7 @@ class PageView < ApplicationRecord
belongs_to :article
before_create :extract_domain_and_path
after_create_commit :record_field_test_event
private
@ -25,4 +26,10 @@ class PageView < ApplicationRecord
def article_tags
article.decorate.cached_tag_list_array
end
def record_field_test_event
return unless user_id
Users::RecordFieldTestEventWorker.perform_async(user_id, :user_home_feed, "user_views_article_four_days_in_week")
end
end

View file

@ -23,6 +23,7 @@ class Reaction < ApplicationRecord
validate :permissions
before_save :assign_points
after_create_commit :record_field_test_event
after_commit :async_bust, :bust_reactable_cache, :update_reactable
after_save :index_to_algolia
after_save :touch_user
@ -179,4 +180,8 @@ class Reaction < ApplicationRecord
def negative?
category == "vomit" || category == "thumbsdown"
end
def record_field_test_event
Users::RecordFieldTestEventWorker.perform_async(user_id, :user_home_feed, "user_creates_reaction")
end
end

View file

@ -5,6 +5,8 @@ module Articles
@number_of_articles = number_of_articles
@page = page
@tag = tag
@randomness = 3 # default number for randomly adjusting feed
@tag_weight = 1 # default weight tags play in rankings
end
def published_articles_by_tag
@ -25,28 +27,41 @@ module Articles
page(@page).per(@number_of_articles)
end
def default_home_feed_and_featured_story(user_signed_in: false)
hot_stories = published_articles_by_tag.
where("score > ? OR featured = ?", 9, true).
order("hotness_score DESC")
featured_story = hot_stories.where.not(main_image: nil).first
if user_signed_in
offset = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11].sample # random offset, weighted more towards zero
hot_stories = hot_stories.offset(offset)
new_stories = Article.published.
where("published_at > ? AND score > ?", rand(2..6).hours.ago, -15).
limited_column_select.order("published_at DESC").limit(rand(15..80))
hot_stories = hot_stories.to_a + new_stories.to_a
end
hot_stories = rank_and_sort_articles(hot_stories) if @user
def default_home_feed_and_featured_story(user_signed_in: false, ranking: true)
featured_story, hot_stories = globally_cached_hot_articles(user_signed_in)
hot_stories = rank_and_sort_articles(hot_stories) if @user && ranking
[featured_story, hot_stories]
end
# Test variation: Base
def default_home_feed(user_signed_in: false)
_featured_story, stories = default_home_feed_and_featured_story(user_signed_in: user_signed_in)
_featured_story, stories = default_home_feed_and_featured_story(user_signed_in: user_signed_in, ranking: true)
stories
end
# Test variation: More random
def default_home_feed_with_more_randomness
@randomness = 7
_featured_story, stories = default_home_feed_and_featured_story(user_signed_in: true)
stories
end
# Test variation: tags make bigger impact
def more_tag_weight
@tag_weight = 2
_featured_story, stories = default_home_feed_and_featured_story(user_signed_in: true)
stories
end
# Test variation: Base half the time, more random other half. Varies on impressions.
def mix_default_and_more_random
if rand(2) == 1
default_home_feed(user_signed_in: true)
else
default_home_feed_with_more_randomness
end
end
def rank_and_sort_articles(articles)
ranked_articles = articles.each_with_object({}) do |article, result|
article_points = score_single_article(article)
@ -76,7 +91,7 @@ module Articles
article_tags = article.decorate.cached_tag_list_array
@user.decorate.cached_followed_tags.sum do |tag|
article_tags.include?(tag.name) ? tag.points : 0
article_tags.include?(tag.name) ? tag.points * @tag_weight : 0
end
end
@ -85,22 +100,35 @@ module Articles
end
def score_randomness
random_number = rand
if random_number < 0.3
3
elsif random_number >= 0.3 && random_number < 0.6
6
else
0
end
rand(3) * @randomness
end
def score_language(article)
@user&.preferred_languages_array&.include?(article.language || "en") ? 1 : -10
@user&.preferred_languages_array&.include?(article.language || "en") ? 1 : -15
end
def score_experience_level(article)
- ((article.experience_level_rating - (@user&.experience_level || 5).abs) / 2)
end
def globally_cached_hot_articles(user_signed_in)
# If these query is shared by the all users and fetched often, we can cache it and fetch cold
# only every x seconds.
Rails.cache.fetch("globally-cached-hot-articles-#{user_signed_in}", expires_in: 20.seconds) do
hot_stories = published_articles_by_tag.
where("score > ? OR featured = ?", 9, true).
order("hotness_score DESC")
featured_story = hot_stories.where.not(main_image: nil).first
if user_signed_in
offset = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5, 6, 7, 8, 9, 10, 11].sample # random offset, weighted more towards zero
hot_stories = hot_stories.offset(offset)
new_stories = Article.published.
where("published_at > ? AND score > ?", rand(2..6).hours.ago, -15).
limited_column_select.order("published_at DESC").limit(rand(15..80))
hot_stories = hot_stories.to_a + new_stories.to_a
end
[featured_story, hot_stories.to_a]
end
end
end
end

View file

@ -108,6 +108,7 @@
!url.href.includes('/robots.txt') && // Skip robots for web crawlers
!url.href.includes('/shell_') && // Don't fetch for shell.
!url.href.includes('/sidekiq') && // Skip for Sidekiq dashboard
!url.href.includes('/abtests') && // Skip for field_test dashboard
!url.href.includes('/social_previews') && // Skip for social previews
!url.href.includes('/users/auth') && // Don't run on authentication.
!url.href.includes('/enter') && // Don't run on registration.

View file

@ -0,0 +1,29 @@
module Users
class RecordFieldTestEventWorker
include Sidekiq::Worker
include FieldTest::Helpers
sidekiq_options queue: :low_priority, retry: 10
def perform(user_id, experiment, goal)
user = User.find(user_id)
if goal == "user_views_article_four_days_in_week"
determine_pageview_goal(user, experiment)
else
field_test_converted(experiment, participant: user, goal: goal)
end
end
private
def determine_pageview_goal(user, experiment)
past_week_page_view_counts = user.page_views.where("created_at > ?", 7.days.ago).
group("DATE(created_at)").count.values
past_week_page_view_counts.delete(0)
return unless past_week_page_view_counts.size > 3
field_test_converted(experiment, participant: user, goal: "user_views_article_four_days_in_week")
end
end
end

25
config/field_test.yml Normal file
View file

@ -0,0 +1,25 @@
experiments:
user_home_feed: # Home feed collection for logged in user
variants:
- base
- more_random
- more_tag_weight
- mix_base_more_random
weights:
- 65
- 10
- 10
- 15
goals:
- user_creates_comment
- user_creates_reaction
- user_views_article_four_days_in_week
exclude:
bots: true
# Dashboard performance
cache: true
# For logged-in users we'll explicitely pass participant.
# If we experiment on non-logged in, we don't want to collect cookies anyway.
cookies: false

View file

@ -18,6 +18,7 @@ Rails.application.routes.draw do
use Rack::Protection, origin_whitelist: ["https://dev.to"] # resolve Rack Protection HttpOrigin
end
mount Sidekiq::Web => "/sidekiq"
mount FieldTest::Engine, at: "abtests"
end
devise_scope :user do

View file

@ -0,0 +1,16 @@
class CreateFieldTestMemberships < ActiveRecord::Migration[5.2]
def change
create_table :field_test_memberships do |t|
t.string :participant_type
t.string :participant_id
t.string :experiment
t.string :variant
t.timestamp :created_at
t.boolean :converted, default: false
end
add_index :field_test_memberships, [:participant_type, :participant_id, :experiment],
unique: true, name: "index_field_test_memberships_on_participant"
add_index :field_test_memberships, [:experiment, :created_at]
end
end

View file

@ -0,0 +1,9 @@
class CreateFieldTestEvents < ActiveRecord::Migration[5.2]
def change
create_table :field_test_events do |t|
t.references :field_test_membership
t.string :name
t.timestamp :created_at
end
end
end

View file

@ -433,6 +433,24 @@ ActiveRecord::Schema.define(version: 2020_02_27_214321) do
t.index ["reporter_id"], name: "index_feedback_messages_on_reporter_id"
end
create_table "field_test_events", force: :cascade do |t|
t.datetime "created_at"
t.bigint "field_test_membership_id"
t.string "name"
t.index ["field_test_membership_id"], name: "index_field_test_events_on_field_test_membership_id"
end
create_table "field_test_memberships", force: :cascade do |t|
t.boolean "converted", default: false
t.datetime "created_at"
t.string "experiment"
t.string "participant_id"
t.string "participant_type"
t.string "variant"
t.index ["experiment", "created_at"], name: "index_field_test_memberships_on_experiment_and_created_at"
t.index ["participant_type", "participant_id", "experiment"], name: "index_field_test_memberships_on_participant", unique: true
end
create_table "follows", id: :serial, force: :cascade do |t|
t.boolean "blocked", default: false, null: false
t.datetime "created_at"

View file

@ -6,6 +6,16 @@ RSpec.describe "Comments", type: :request do
let(:article) { create(:article, user_id: user.id) }
let(:podcast) { create(:podcast) }
let(:podcast_episode) { create(:podcast_episode, podcast_id: podcast.id) }
let(:base_comment_params) do
{
comment: {
commentable_id: article.id,
commentable_type: "Article",
user_id: user.id,
body_markdown: "New comment #{rand(10)}"
}
}
end
let!(:comment) do
create(:comment,
commentable_id: article.id,
@ -290,6 +300,20 @@ RSpec.describe "Comments", type: :request do
end
end
describe "POST /comments" do
context "when part of field test" do
before do
sign_in user
allow(Users::RecordFieldTestEventWorker).to receive(:perform_async)
end
it "converts field test" do
post "/comments", params: base_comment_params
expect(Users::RecordFieldTestEventWorker).to have_received(:perform_async).with(user.id, :user_home_feed, "user_creates_comment")
end
end
end
describe "PATCH /comments/:comment_id/hide" do
include_examples "PATCH /comments/:comment_id/hide or unhide", path: "hide", hidden: "true"
end

View file

@ -37,6 +37,21 @@ RSpec.describe "PageViews", type: :request do
end
end
context "when part of field test" do
before do
sign_in user
allow(Users::RecordFieldTestEventWorker).to receive(:perform_async)
end
it "converts field test" do
post "/page_views", params: {
article_id: article.id,
referrer: "test"
}
expect(Users::RecordFieldTestEventWorker).to have_received(:perform_async).with(user.id, :user_home_feed, "user_views_article_four_days_in_week")
end
end
context "when user not signed in" do
it "creates a new page view" do
post "/page_views", params: {

View file

@ -181,5 +181,17 @@ RSpec.describe "Reactions", type: :request do
expect(Reaction.all.size).to eq(0)
end
end
context "when part of field test" do
before do
sign_in user
allow(Users::RecordFieldTestEventWorker).to receive(:perform_async)
end
it "converts field test" do
post "/reactions", params: article_params
expect(Users::RecordFieldTestEventWorker).to have_received(:perform_async).with(user.id, :user_home_feed, "user_creates_reaction")
end
end
end
end

View file

@ -91,5 +91,25 @@ RSpec.describe "Stories::FeedsIndex", type: :request do
expect(feed_service).to have_received(:latest_feed)
end
end
context "when there are no params passed (base feed) and user is signed in" do
before do
sign_in user
end
it "sets a field test" do
get "/stories/feed"
expect(FieldTest::Membership.all.size).to be(1)
expect(FieldTest::Membership.last.participant_id).to eq(user.id.to_s)
expect(FieldTest::Membership.last.experiment).to eq("user_home_feed")
end
end
context "when there are no params passed (base feed) and user is signed not in" do
it "sets a field test" do
get "/stories/feed"
expect(FieldTest::Membership.all.size).to be(0)
end
end
end
end

View file

@ -85,6 +85,30 @@ RSpec.describe Articles::Feed, type: :service do
expect(featured_story).to eq(hot_story)
end
end
context "when ranking is true" do
it "performs article ranking" do
allow(feed).to receive(:rank_and_sort_articles).and_call_original
feed.default_home_feed_and_featured_story(ranking: true)
expect(feed).to have_received(:rank_and_sort_articles)
end
end
context "when ranking is false" do
it "does not perform article ranking" do
allow(feed).to receive(:rank_and_sort_articles).and_call_original
feed.default_home_feed_and_featured_story(ranking: false)
expect(feed).not_to have_received(:rank_and_sort_articles)
end
end
context "when ranking not passed" do
it "performs article ranking" do
allow(feed).to receive(:rank_and_sort_articles).and_call_original
feed.default_home_feed_and_featured_story
expect(feed).to have_received(:rank_and_sort_articles)
end
end
end
describe "#default_home_feed" do
@ -115,6 +139,36 @@ RSpec.describe Articles::Feed, type: :service do
end
end
describe "#default_home_feed_with_more_randomness" do
let!(:new_story) { create(:article, published_at: 10.minutes.ago, score: 10) }
let(:stories) { feed.default_home_feed_with_more_randomness }
it "includes stories from between 2 and 6 hours ago" do
expect(stories).not_to include(old_story)
expect(stories).to include(new_story)
end
end
describe "#mix_default_and_more_random" do
let!(:new_story) { create(:article, published_at: 10.minutes.ago, score: 10) }
let(:stories) { feed.mix_default_and_more_random }
it "includes stories from between 2 and 6 hours ago" do
expect(stories).not_to include(old_story)
expect(stories).to include(new_story)
end
end
describe "#more_tag_weight" do
let!(:new_story) { create(:article, published_at: 10.minutes.ago, score: 10) }
let(:stories) { feed.more_tag_weight }
it "includes stories from between 2 and 6 hours ago" do
expect(stories).not_to include(old_story)
expect(stories).to include(new_story)
end
end
describe "#score_followed_user" do
context "when article is written by a followed user" do
before { user.follow(article.user) }
@ -161,21 +215,21 @@ RSpec.describe Articles::Feed, type: :service do
describe "#score_randomness" do
context "when random number is less than 0.6 but greater than 0.3" do
it "returns 6" do
allow(feed).to receive(:rand).and_return(0.5)
allow(feed).to receive(:rand).and_return(2)
expect(feed.score_randomness).to eq 6
end
end
context "when random number is less than 0.3" do
it "returns 3" do
allow(feed).to receive(:rand).and_return(0.1)
allow(feed).to receive(:rand).and_return(1)
expect(feed.score_randomness).to eq 3
end
end
context "when random number is greater than 0.6" do
it "returns 0" do
allow(feed).to receive(:rand).and_return(0.9)
allow(feed).to receive(:rand).and_return(0)
expect(feed.score_randomness).to eq 0
end
end
@ -192,7 +246,7 @@ RSpec.describe Articles::Feed, type: :service do
before { article.language = "de" }
it "returns a score of -10" do
expect(feed.score_language(article)).to eq(-10)
expect(feed.score_language(article)).to eq(-15)
end
end

View file

@ -2,7 +2,6 @@ RSpec.shared_examples "#enqueues_on_correct_queue" do |queue_name, args|
describe "#perform_async" do
it "enqueues the job" do
Sidekiq::Testing.fake!
expect do
described_class.perform_async(args)
end.to change { Sidekiq::Queues[queue_name].size }.by(1)

View file

@ -0,0 +1,66 @@
require "rails_helper"
RSpec.describe Users::RecordFieldTestEventWorker, type: :worker do
include_examples "#enqueues_on_correct_queue", "low_priority", 1
include FieldTest::Helpers
describe "#perform" do
let(:worker) { subject }
let_it_be(:user) { create(:user) }
context "with user who is part of field test" do
before do
field_test(:user_home_feed, participant: user)
end
it "records user_creates_reaction field test conversion" do
worker.perform(user.id, "user_home_feed", "user_creates_reaction")
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
expect(FieldTest::Event.last.name).to eq("user_creates_reaction")
end
it "records user_creates_comment field test conversion" do
worker.perform(user.id, "user_home_feed", "user_creates_comment")
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
expect(FieldTest::Event.last.name).to eq("user_creates_comment")
end
it "records user_views_article_four_days_in_week field test conversion if qualifies" do
7.times do |n|
create(:page_view, user_id: user.id, created_at: n.day.ago)
end
worker.perform(user.id, "user_home_feed", "user_views_article_four_days_in_week")
expect(FieldTest::Event.last.field_test_membership.participant_id).to eq(user.id.to_s)
expect(FieldTest::Event.last.name).to eq("user_views_article_four_days_in_week")
end
it "does not record user_views_article_four_days_in_week field test conversion if not qualifying" do
2.times do |n|
create(:page_view, user_id: user.id, created_at: n.day.ago)
end
worker.perform(user.id, "user_home_feed", "user_views_article_four_days_in_week")
expect(FieldTest::Event.all.size).to be(0)
end
end
context "with user who is not part of field test" do
it "records user_creates_reaction field test conversion" do
worker.perform(user.id, "user_home_feed", "user_creates_reaction")
expect(FieldTest::Event.all.size).to be(0)
end
it "records user_creates_comment field test conversion" do
worker.perform(user.id, "user_home_feed", "user_creates_comment")
expect(FieldTest::Event.all.size).to be(0)
end
it "records user_views_article_four_days_in_week field test conversion if qualifies" do
7.times do |n|
create(:page_view, user_id: user.id, created_at: n.day.ago)
end
expect(FieldTest::Event.all.size).to be(0)
end
end
end
end