easy_ab 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: bb6587e7ae47d2f3743e6dc6e6c053e40f993ac2
4
+ data.tar.gz: f74132d722d8dbb9f7dc275748770ecef6e406b1
5
+ SHA512:
6
+ metadata.gz: 7c88e4fd12e3c20de12c8a00cae041ca279080501b0f57869b345f651b7b83cb695d21d02ad6b940ed5f9501993afa92e3eb52eb8dafd2fc52718ddb1ff45456
7
+ data.tar.gz: 2e0014482e2275c87bcabeb39cbc90f78012d6338ddf2075e4c22de991c90a7554580c7d4a8f052cc65457724355606c8951c88d7c717c693307cfda92a6184a
data/.editorconfig ADDED
@@ -0,0 +1,8 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ charset = utf-8
6
+ indent_style = space
7
+ indent_size = 2
8
+ trim_trailing_whitespace = true
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ *.gem
2
+ TODO.md
data/CHANGELOG.md ADDED
File without changes
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Chien-Wei Chu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # Easy AB
2
+
3
+ Easy, flexible A/B testing tool for Rails.
4
+
5
+ * Design for web
6
+ * Use your database to keep users' testing info to seamlessly handle the transition from guest to signed in user. You don't need to prepare extra stack like Redis or something else.
7
+ * Grouping your users to your predefined variants with very easy and flexible way:
8
+ * Random with equal weightings.
9
+ * Random with predefined weightings.
10
+ * Define Proc(s) to setup your rules. Something like "sign up for 1 month to variant A, others to variant B", "user with odd id to variant A, with even id to variant B", ...
11
+ Example of using proc to define rules:
12
+
13
+ ```ruby
14
+ # Example 1
15
+ variants: ['variant A', 'variant B']
16
+ rules: [
17
+ -> { current_user.created_at <= 1.month.ago },
18
+ -> { current_user.created_at > 1.month.ago },
19
+ ]
20
+
21
+ # Example 2
22
+ variants: ['variant A', 'variant B']
23
+ rules: [
24
+ -> { current_user.id.odd? },
25
+ -> { current_user.id.even? },
26
+ ]
27
+ ```
28
+ and you can change your rules at any time without affecting existing users, they always see the same variant
29
+ * Convenient APIs to
30
+ * check your view for different variants
31
+ * output all experiments and the corresponding variants for your users. It's very useful when sending data to analytics services like Google Anayltics, Mixpanel, Kissmetrics, ...
32
+ * No DSL, just setup your rules with pure Ruby (and Rails) :)
33
+
34
+ # Notice
35
+ Easy AB is under development. Currently don't use in your production app.
36
+
37
+ # Why Easy AB?
38
+ ## Comparisons
39
+ ### Split
40
+ ### Field Test
41
+ ### Flipper
42
+ ### ...
43
+
44
+ # Installation
45
+
46
+ * Add `gem 'easy_ab'` to your application's Gemfile and run `bundle install`.
47
+ * Run `bin/rails g easy_ab:install`. Migration file and initializer will copy to your app folder.
48
+ * Run `bin/rake db:migrate`
49
+
50
+ # Setup
51
+
52
+ Edit `config/initializers/easy_ab.rb` to setup basic configurations.
53
+
54
+ ```ruby
55
+ EasyAb.configure do |config|
56
+ # Tell the gem how to check whether current user is signed in or not
57
+ config.user_signed_in_method = -> { user_signed_in? }
58
+
59
+ # Tell the gem how to get current user's id
60
+ config.current_user_id = -> { current_user.id }
61
+
62
+ # Tell the gem how to check whether current user is admin or not
63
+ # Only admin can switch variant by url parameters
64
+ config.authorize_admin_with = -> { current_user.admin? }
65
+ end
66
+ ```
67
+
68
+ # Getting Started
69
+
70
+ Setup your experiments in `config/initializers/easy_ab.rb`
71
+
72
+ Say, if you have an experiment named 'button color', with three equal weighted variants: red, blue, green.
73
+
74
+ Define your experiment as follows:
75
+
76
+ ``` ruby
77
+ EasyAb.experiments do |experiment|
78
+ experiment.define :title_color, variants: ['red', 'blue', 'green']
79
+ end
80
+ ```
81
+
82
+ Then you will be able to use the following helpers in controller or view:
83
+
84
+ ```ruby
85
+ color = ab_test(:title_color)
86
+ ```
87
+
88
+ or pass a block
89
+
90
+ ```erb
91
+ <% ab_test(:title_color) do |color| %>
92
+ <h1 class="<%= color %>">Welcome!</h1>
93
+ <% end %>
94
+ ```
95
+
96
+ For admin, specify a variant with url parameters makes debugging super easy:
97
+
98
+ ```
99
+ ?ab_test[title_color]=blue
100
+ ```
@@ -0,0 +1,20 @@
1
+ module EasyAb
2
+ class Grouping < ActiveRecord::Base
3
+ self.table_name = 'easy_ab_groupings'
4
+
5
+ validates :experiment, presence: true
6
+ validates :variant, presence: true
7
+ validates :user_id, uniqueness: { scope: [:experiment] }
8
+ validates :cookie, uniqueness: { scope: [:experiment] }
9
+ validate :user_should_be_present
10
+
11
+ private
12
+
13
+ def user_should_be_present
14
+ if cookie.nil? && user_id.nil?
15
+ errors.add(:user_id, "or cookie can't be blank")
16
+ errors.add(:cookie, "or user_id can't be blank")
17
+ end
18
+ end
19
+ end
20
+ end
data/easy_ab.gemspec ADDED
@@ -0,0 +1,16 @@
1
+ lib = File.expand_path('../lib', __FILE__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'easy_ab/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'easy_ab'
7
+ s.version = EasyAb::VERSION
8
+ s.summary = 'Flexible A/B testing and feature toggle for Rails'
9
+ s.authors = ['Gary Chu']
10
+ s.email = 'icarus4.chu@gmail.com'
11
+ s.files = `git ls-files -z`.split("\x0").reject do |f|
12
+ f.match(%r{^(test|spec|features)/})
13
+ end
14
+ s.homepage = 'https://github.com/icarus4/easy_ab'
15
+ s.license = 'MIT'
16
+ end
@@ -0,0 +1,8 @@
1
+ module EasyAb
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace EasyAb
4
+
5
+ # prevents conflict with field_test method in views
6
+ engine_name "easy_ab_engine"
7
+ end
8
+ end
@@ -0,0 +1,112 @@
1
+ module EasyAb
2
+ class Experiment
3
+ attr_reader :name, :variants, :weights, :rules
4
+
5
+ def initialize(name, options = {})
6
+ @name = name.to_s
7
+ @variants = options[:variants]
8
+ @weights = options[:weights]
9
+ @rules = options[:rules]
10
+
11
+ raise ArgumentError, 'Please define variants' if @variants.blank?
12
+ raise ArgumentError, 'Number of variants and weights should be identical' if @weights.present? && @weights.size != @variants.size
13
+ raise ArgumentError, 'Number of variants and rules should be identical' if @rules.present? && @rules.size != @variants.size
14
+ raise ArgumentError, 'All rules should be a Proc' if @rules.present? && @rules.any? { |rule| !rule.is_a?(Proc) }
15
+ end
16
+
17
+ def self.find_by_name!(experiment_name)
18
+ experiment_name = experiment_name.to_s
19
+ exp = EasyAb.experiments.all.find { |exp| exp.name == experiment_name }
20
+ raise ExperimentNotFound if exp.nil?
21
+ exp
22
+ end
23
+
24
+ def assign_variant(user_recognition, options = {})
25
+ grouping = find_grouping_by_user_recognition(user_recognition) || ::EasyAb::Grouping.new(experiment: name, user_id: user_recognition[:id], cookie: user_recognition[:cookie])
26
+
27
+ if options[:variant] && variants.include?(options[:variant])
28
+ grouping.variant = options[:variant]
29
+ else
30
+ grouping.variant ||= flexible_variant(options[:contexted_rules])
31
+ end
32
+
33
+ if grouping.changed? && !options[:skip_save]
34
+ begin
35
+ grouping.save!
36
+ rescue ActiveRecord::RecordNotUnique
37
+ grouping = find_grouping_by_user_recognition(user_recognition)
38
+ rescue ActiveRecord::RecordInvalid => e
39
+ if grouping.errors[:user_id].present? || grouping.errors[:cookie].present?
40
+ grouping = find_grouping_by_user_recognition(user_recognition)
41
+ else
42
+ raise e
43
+ end
44
+ end
45
+ end
46
+
47
+ grouping.variant
48
+ end
49
+
50
+ def find_grouping_by_user_recognition(user_recognition)
51
+ user_id = user_recognition[:id]
52
+ cookie = user_recognition[:cookie]
53
+ grouping = nil
54
+
55
+ raise 'should assign a cookie' unless cookie
56
+
57
+ if user_id # If user login
58
+ # Case I: User participated experiment with login and return again
59
+ # Case II: user participated experiment with login and return by another device with login
60
+ # Case III: user participated experiment with login and return by the same device, but cookie was cleared between last and this participation
61
+ # => Both II and III already exist a record with the same user_id but different cookie
62
+ # In the above two cases, we update the cookie of the exising record
63
+ return grouping if (grouping = groupings.where(user_id: user_id).first) && ((cookie && grouping.cookie = cookie) || true)
64
+
65
+ # User participated experiment without login, but this time with login => assign user_id to the existing record
66
+ return grouping if (grouping = groupings.where(user_id: nil, cookie: cookie).first) && grouping.user_id = user_id
67
+ else # If user not login
68
+ return grouping if grouping = groupings.where(cookie: cookie).first
69
+ end
70
+
71
+ # User have yet to participate experiment
72
+ nil
73
+ end
74
+
75
+ def groupings
76
+ ::EasyAb::Grouping.where(experiment: name)
77
+ end
78
+
79
+ def flexible_variant(contexted_rules = nil)
80
+ if contexted_rules
81
+ variant_by_rule(contexted_rules)
82
+ elsif weights
83
+ weighted_variant
84
+ else
85
+ equal_weighted_variant
86
+ end
87
+ end
88
+
89
+ def variant_by_rule(contexted_rules)
90
+ contexted_rules.each_with_index do |rule, i|
91
+ return variants[i] if rule.call
92
+ end
93
+ # If all rules not matched, apply the first variatn
94
+ variants.first
95
+ end
96
+
97
+ def weighted_variant
98
+ total = weights.sum
99
+ roll = rand
100
+ sum = 0
101
+ weights.each_with_index do |weight, i|
102
+ sum += weight.to_d / total
103
+ return variants[i] if sum >= roll
104
+ end
105
+ variants.last
106
+ end
107
+
108
+ def equal_weighted_variant
109
+ variants.sample
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,102 @@
1
+ module EasyAb
2
+ module Helpers
3
+ # Return variant of specified experiment for current user
4
+ def ab_test(experiment_name, options = {})
5
+ experiment_name = experiment_name.to_s
6
+ user_recognition = find_ab_test_user_recognition(options)
7
+
8
+ if respond_to?(:request) && params[:ab_test] && params[:ab_test][experiment_name]
9
+ # Check current user is admin or not by proc defined by gem user
10
+ if current_user_is_admin?
11
+ options[:variant] ||= params[:ab_test][experiment_name]
12
+ end
13
+ # TODO: exclude bot
14
+ end
15
+
16
+ experiment = EasyAb::Experiment.find_by_name!(experiment_name)
17
+
18
+ if experiment.rules.present?
19
+ @rules_with_current_context ||= experiment.rules.map { |rule| Proc.new { instance_exec(&rule)} }
20
+ options[:contexted_rules] = @rules_with_current_context
21
+ end
22
+
23
+ @variant_cache ||= {}
24
+ @variant_cache[experiment_name] ||= experiment.assign_variant(user_recognition, options)
25
+ block_given? ? yield(@variant_cache[experiment_name]) : @variant_cache[experiment_name]
26
+ end
27
+
28
+ # Return all participated experiments and the corresponding variants for current user
29
+ # Return format:
30
+ # {
31
+ # 'experiment 1' => 'variant 1',
32
+ # 'experiment 2' => 'variant 2',
33
+ # ...
34
+ # }
35
+ def participated_experiments(options = {})
36
+ user_recognition = find_ab_test_user_recognition(options)
37
+ groupings = if user_recognition[:id]
38
+ EasyAb::Grouping.where("user_id = ? OR cookie = ?", user_recognition[:id], user_recognition[:cookie])
39
+ else
40
+ EasyAb::Grouping.where(cookie: user_recognition[:cookie])
41
+ end
42
+
43
+ experiments = {}
44
+ groupings.each do |grouping|
45
+ experiments[grouping.experiment] = grouping.variant
46
+ end
47
+ experiments
48
+ end
49
+
50
+ private
51
+
52
+ def find_ab_test_user_recognition(options = {})
53
+ user_recognition = {}
54
+
55
+ # TODO:
56
+ # return (raise NotImplementedError) if options[:user] && (users << options[:user])
57
+
58
+ user_recognition[:id] = current_user_id if current_user_signed_in?
59
+ # Controllers and views
60
+ user_recognition[:cookie] = find_or_create_easy_ab_cookie if respond_to?(:request)
61
+
62
+ user_recognition
63
+ end
64
+
65
+ def current_user_signed_in?
66
+ user_signed_in_method_proc.call
67
+ end
68
+
69
+ def current_user_id
70
+ current_user_id_proc.call
71
+ end
72
+
73
+ def current_user_is_admin?
74
+ authorize_admin_with_proc.call
75
+ end
76
+
77
+ def authorize_admin_with_proc
78
+ @authorize_admin_with_proc ||= Proc.new { instance_exec &EasyAb.config.authorize_admin_with }
79
+ end
80
+
81
+ def current_user_id_proc
82
+ @current_user_id_proc ||= Proc.new { instance_exec &EasyAb.config.current_user_id }
83
+ end
84
+
85
+ def user_signed_in_method_proc
86
+ @user_signed_in_method_proc ||= Proc.new { instance_exec &EasyAb.config.user_signed_in_method }
87
+ end
88
+
89
+ def find_or_create_easy_ab_cookie
90
+ cookie_key = :easy_ab
91
+ value = cookies[cookie_key]
92
+ value = if value
93
+ value.gsub(/[^a-z0-9\-]/i, "")
94
+ else
95
+ SecureRandom.uuid
96
+ end
97
+ cookies[cookie_key] = { value: value, expires: 30.days.from_now }
98
+
99
+ value
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,7 @@
1
+ module EasyAb
2
+ class Participant
3
+ def self.normalize(participants)
4
+ participants.map { |p| p.respond_to?(:model_name) ? "#{v.model_name.name}:#{v.id}" : v.to_s }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module EasyAb
2
+ VERSION = '0.0.1'
3
+ end
data/lib/easy_ab.rb ADDED
@@ -0,0 +1,56 @@
1
+ require 'easy_ab/experiment'
2
+ require 'easy_ab/engine' if defined?(Rails)
3
+ require 'easy_ab/helpers'
4
+ require 'easy_ab/version'
5
+
6
+ module EasyAb
7
+ class Error < StandardError; end
8
+ class ExperimentNotFound < Error; end
9
+
10
+ class << self
11
+ def config
12
+ @@config
13
+ end
14
+
15
+ def configure
16
+ @@config ||= Config.new
17
+ yield(@@config)
18
+ end
19
+
20
+ def experiments
21
+ @@experiments ||= Experiments.new
22
+
23
+ if block_given?
24
+ yield(@@experiments)
25
+ else
26
+ @@experiments
27
+ end
28
+ end
29
+ end
30
+
31
+ class Config
32
+ attr_accessor :authorize_admin_with, :user_signed_in_method, :current_user_id
33
+ end
34
+
35
+ class Experiments
36
+ def initialize
37
+ @experiments = []
38
+ end
39
+
40
+ def define(name, options = {})
41
+ @experiments << ::EasyAb::Experiment.new(name, options)
42
+ end
43
+
44
+ def all
45
+ @experiments
46
+ end
47
+ end
48
+ end
49
+
50
+ ActiveSupport.on_load(:action_controller) do
51
+ include ::EasyAb::Helpers
52
+ end
53
+
54
+ ActiveSupport.on_load(:action_view) do
55
+ include ::EasyAb::Helpers
56
+ end
@@ -0,0 +1,36 @@
1
+ module EasyAb
2
+ module Generators
3
+ class InstallGenerator < ::Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path("../templates", __FILE__)
6
+
7
+ # Implement the required interface for Rails::Generators::Migration.
8
+ def self.next_migration_number(dirname)
9
+ next_migration_number = current_migration_number(dirname) + 1
10
+ if ::ActiveRecord::Base.timestamped_migrations
11
+ [Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max
12
+ else
13
+ "%.3d" % next_migration_number
14
+ end
15
+ end
16
+
17
+ def migration_version
18
+ if ActiveRecord::VERSION::MAJOR >= 5
19
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
20
+ end
21
+ end
22
+
23
+ def copy_migration
24
+ migration_template 'grouping.rb', 'db/migrate/create_easy_ab_groupings.rb', migration_version: migration_version
25
+ end
26
+
27
+ # def copy_config
28
+ # template 'easy_ab.yml', 'config/easy_ab.yml'
29
+ # end
30
+
31
+ def copy_initializer_file
32
+ copy_file 'easy_ab.rb', 'config/initializers/easy_ab.rb'
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ EasyAb.configure do |config|
2
+ # Define how to check whether current user is signed in or not
3
+ config.user_signed_in_method = -> { user_signed_in? }
4
+
5
+ # Define how to get current user's id
6
+ config.current_user_id = -> { current_user.id }
7
+
8
+ # Define how to check whether current user is admin or not
9
+ # Only admin can switch variant by url parameters
10
+ config.authorize_admin_with = -> { current_user.admin? }
11
+ end
12
+
13
+ EasyAb.experiments do |experiment|
14
+ experiment.define :button_color,
15
+ variants: ['red', 'blue', 'yellow'],
16
+ weights: [10, 3, 1]
17
+
18
+ experiment.define :title,
19
+ variants: ['hello', 'welcome', 'yo'],
20
+ rules: [
21
+ -> { (1..100).cover?(current_user.id) },
22
+ -> { current_user.id.odd? },
23
+ -> { current_user.id.even? },
24
+ ]
25
+
26
+ end
@@ -0,0 +1,8 @@
1
+ experiments:
2
+ button_color:
3
+ variants:
4
+ - red
5
+ - green
6
+ - blue
7
+ # winner: green
8
+
@@ -0,0 +1,15 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :easy_ab_groupings do |t|
4
+ t.integer :user_id
5
+ t.string :cookie
6
+ t.string :experiment
7
+ t.string :variant
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :easy_ab_groupings, [:experiment, :user_id], unique: true
12
+ add_index :easy_ab_groupings, :user_id
13
+ add_index :easy_ab_groupings, :cookie
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,62 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easy_ab
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Gary Chu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-08-07 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email: icarus4.chu@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - ".editorconfig"
20
+ - ".gitignore"
21
+ - CHANGELOG.md
22
+ - Gemfile
23
+ - LICENSE
24
+ - README.md
25
+ - app/models/easy_ab/grouping.rb
26
+ - easy_ab.gemspec
27
+ - lib/easy_ab.rb
28
+ - lib/easy_ab/engine.rb
29
+ - lib/easy_ab/experiment.rb
30
+ - lib/easy_ab/helpers.rb
31
+ - lib/easy_ab/participant.rb
32
+ - lib/easy_ab/version.rb
33
+ - lib/generators/easy_ab/install_generator.rb
34
+ - lib/generators/easy_ab/templates/easy_ab.rb
35
+ - lib/generators/easy_ab/templates/easy_ab.yml
36
+ - lib/generators/easy_ab/templates/grouping.rb
37
+ homepage: https://github.com/icarus4/easy_ab
38
+ licenses:
39
+ - MIT
40
+ metadata: {}
41
+ post_install_message:
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubyforge_project:
57
+ rubygems_version: 2.6.12
58
+ signing_key:
59
+ specification_version: 4
60
+ summary: Flexible A/B testing and feature toggle for Rails
61
+ test_files: []
62
+ has_rdoc: