field_test 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of field_test might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +1 -1
- data/README.md +20 -0
- data/app/views/field_test/experiments/_experiments.html.erb +1 -0
- data/app/views/layouts/field_test/application.html.erb +4 -0
- data/lib/field_test/controller.rb +22 -3
- data/lib/field_test/experiment.rb +25 -5
- data/lib/field_test/helpers.rb +9 -7
- data/lib/field_test/participant.rb +2 -2
- data/lib/field_test/version.rb +1 -1
- data/lib/generators/field_test/events_generator.rb +2 -15
- data/lib/generators/field_test/install_generator.rb +2 -15
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5935d4b9a0cca9bd7d08fb1e0acaf5ab6e0e7f8c8c44b71efae5a3667a0d37a6
|
4
|
+
data.tar.gz: e839006d04adfe1e06d27c2b32b8ea601ada47106b05537bf6a11168c8b0864a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0ab03821f1b5a95f8cf2681820ab4d13c10bde98889e2d5995cd05f7426f815d207bfcdd894db0da90acb1e1743adf6f7256c75d5f62572e27f7183a0992a722
|
7
|
+
data.tar.gz: 84009fba31741d3c737f0623b580512bed5dadd455c35691fbe0388899dcf706a8d5ed1740f3d94cb877058e19740aa5321a5b51ef7595c31cea483b85dfd1bc
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,14 @@
|
|
1
|
+
## 0.3.1 [unreleased]
|
2
|
+
|
3
|
+
- Added `closed` and `keep_variant`
|
4
|
+
- Added `field_test_upgrade_memberships` method
|
5
|
+
- Fixed API controller error
|
6
|
+
- Fixed bug where conversions were recorded after winner
|
7
|
+
|
8
|
+
Security
|
9
|
+
|
10
|
+
- Fixed arbitrary variants via query parameters - see [#17](https://github.com/ankane/field_test/issues/17)
|
11
|
+
|
1
12
|
## 0.3.0
|
2
13
|
|
3
14
|
- Added support for native apps
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -4,6 +4,7 @@
|
|
4
4
|
|
5
5
|
- Designed for web and email
|
6
6
|
- Comes with a [dashboard](https://fieldtest.dokkuapp.com/) to view results and update variants
|
7
|
+
- Uses your database for storage
|
7
8
|
- Seamlessly handles the transition from anonymous visitor to logged in user
|
8
9
|
|
9
10
|
Uses [Bayesian statistics](https://www.evanmiller.org/bayesian-ab-testing.html) to evaluate results so you don’t need to choose a sample size ahead of time.
|
@@ -76,6 +77,25 @@ experiments:
|
|
76
77
|
|
77
78
|
All calls to `field_test` will now return the winner, and metrics will stop being recorded.
|
78
79
|
|
80
|
+
You can keep returning the variant for existing participants after a winner is declared:
|
81
|
+
|
82
|
+
```yml
|
83
|
+
experiments:
|
84
|
+
button_color:
|
85
|
+
winner: green
|
86
|
+
keep_variant: true
|
87
|
+
```
|
88
|
+
|
89
|
+
You can also close an experiment to new participants without declaring a winner while still recording metrics for existing participants:
|
90
|
+
|
91
|
+
```yml
|
92
|
+
experiments:
|
93
|
+
button_color:
|
94
|
+
closed: true
|
95
|
+
```
|
96
|
+
|
97
|
+
Calls to `field_test` for new participants will return the control, and they won’t be added to the experiment.
|
98
|
+
|
79
99
|
You can get the list of experiments and variants for a user with:
|
80
100
|
|
81
101
|
```ruby
|
@@ -11,6 +11,20 @@ module FieldTest
|
|
11
11
|
end
|
12
12
|
end
|
13
13
|
|
14
|
+
def field_test_upgrade_memberships(options = {})
|
15
|
+
participants = FieldTest::Participant.standardize(options[:participant] || field_test_participant)
|
16
|
+
preferred = participants.first
|
17
|
+
Array(participants[1..-1]).each do |participant|
|
18
|
+
# can do this in single query once legacy_participants is removed
|
19
|
+
FieldTest::Membership.where(participant.where_values).each do |membership|
|
20
|
+
membership.participant = preferred.participant if membership.respond_to?(:participant=)
|
21
|
+
membership.participant_type = preferred.type if membership.respond_to?(:participant_type=)
|
22
|
+
membership.participant_id = preferred.id if membership.respond_to?(:participant_id=)
|
23
|
+
membership.save!
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
14
28
|
private
|
15
29
|
|
16
30
|
def field_test_participant
|
@@ -23,22 +37,27 @@ module FieldTest
|
|
23
37
|
|
24
38
|
cookie_key = "field_test"
|
25
39
|
|
40
|
+
# name not entirely accurate
|
41
|
+
# can still set cookies in ActionController::API through request.cookie_jar
|
42
|
+
# however, best to prompt developer to pass participant manually
|
43
|
+
cookies_supported = respond_to?(:cookies, true)
|
44
|
+
|
26
45
|
if request.headers["Field-Test-Visitor"]
|
27
46
|
token = request.headers["Field-Test-Visitor"]
|
28
|
-
elsif FieldTest.cookies
|
47
|
+
elsif FieldTest.cookies && cookies_supported
|
29
48
|
token = cookies[cookie_key]
|
30
49
|
|
31
50
|
if participants.empty? && !token
|
32
51
|
token = SecureRandom.uuid
|
33
52
|
cookies[cookie_key] = {value: token, expires: 30.days.from_now}
|
34
53
|
end
|
35
|
-
|
54
|
+
elsif !FieldTest.cookies
|
36
55
|
# anonymity set
|
37
56
|
# note: hashing does not conceal input
|
38
57
|
token = Digest::UUID.uuid_v5(FieldTest::UUID_NAMESPACE, ["visitor", FieldTest.mask_ip(request.remote_ip), request.user_agent].join("/"))
|
39
58
|
|
40
59
|
# delete cookie if present
|
41
|
-
cookies.delete(cookie_key) if cookies[cookie_key]
|
60
|
+
cookies.delete(cookie_key) if cookies_supported && cookies[cookie_key]
|
42
61
|
end
|
43
62
|
|
44
63
|
# sanitize tokens
|
@@ -10,6 +10,8 @@ module FieldTest
|
|
10
10
|
@variants = attributes[:variants]
|
11
11
|
@weights = @variants.size.times.map { |i| attributes[:weights].to_a[i] || 1 }
|
12
12
|
@winner = attributes[:winner]
|
13
|
+
@closed = attributes[:closed]
|
14
|
+
@keep_variant = attributes[:keep_variant]
|
13
15
|
@started_at = Time.zone.parse(attributes[:started_at].to_s) if attributes[:started_at]
|
14
16
|
@ended_at = Time.zone.parse(attributes[:ended_at].to_s) if attributes[:ended_at]
|
15
17
|
@goals = attributes[:goals] || ["conversion"]
|
@@ -18,17 +20,21 @@ module FieldTest
|
|
18
20
|
end
|
19
21
|
|
20
22
|
def variant(participants, options = {})
|
21
|
-
return winner if winner
|
22
|
-
return
|
23
|
+
return winner if winner && !keep_variant?
|
24
|
+
return control if options[:exclude]
|
23
25
|
|
24
26
|
participants = FieldTest::Participant.standardize(participants)
|
25
27
|
check_participants(participants)
|
26
28
|
membership = membership_for(participants) || FieldTest::Membership.new(experiment: id)
|
27
29
|
|
30
|
+
if winner # and keep_variant?
|
31
|
+
return membership.variant || winner
|
32
|
+
end
|
33
|
+
|
28
34
|
if options[:variant] && variants.include?(options[:variant])
|
29
35
|
membership.variant = options[:variant]
|
30
36
|
else
|
31
|
-
membership.variant ||= weighted_variant
|
37
|
+
membership.variant ||= closed? ? control : weighted_variant
|
32
38
|
end
|
33
39
|
|
34
40
|
participant = participants.first
|
@@ -38,7 +44,7 @@ module FieldTest
|
|
38
44
|
membership.participant_type = participant.type if membership.respond_to?(:participant_type=)
|
39
45
|
membership.participant_id = participant.id if membership.respond_to?(:participant_id=)
|
40
46
|
|
41
|
-
if membership.changed?
|
47
|
+
if membership.changed? && (!closed? || membership.persisted?)
|
42
48
|
begin
|
43
49
|
membership.save!
|
44
50
|
rescue ActiveRecord::RecordNotUnique
|
@@ -46,10 +52,12 @@ module FieldTest
|
|
46
52
|
end
|
47
53
|
end
|
48
54
|
|
49
|
-
membership.try(:variant) ||
|
55
|
+
membership.try(:variant) || control
|
50
56
|
end
|
51
57
|
|
52
58
|
def convert(participants, goal: nil)
|
59
|
+
return false if winner
|
60
|
+
|
53
61
|
goal ||= goals.first
|
54
62
|
|
55
63
|
participants = FieldTest::Participant.standardize(participants)
|
@@ -174,6 +182,18 @@ module FieldTest
|
|
174
182
|
!winner
|
175
183
|
end
|
176
184
|
|
185
|
+
def closed?
|
186
|
+
@closed
|
187
|
+
end
|
188
|
+
|
189
|
+
def keep_variant?
|
190
|
+
@keep_variant
|
191
|
+
end
|
192
|
+
|
193
|
+
def control
|
194
|
+
variants.first
|
195
|
+
end
|
196
|
+
|
177
197
|
def use_events?
|
178
198
|
if @use_events.nil?
|
179
199
|
FieldTest.events_supported?
|
data/lib/field_test/helpers.rb
CHANGED
@@ -1,12 +1,14 @@
|
|
1
1
|
module FieldTest
|
2
2
|
module Helpers
|
3
|
-
def field_test(experiment, options
|
3
|
+
def field_test(experiment, **options)
|
4
4
|
exp = FieldTest::Experiment.find(experiment)
|
5
5
|
|
6
|
-
participants = FieldTest::Participant.standardize(field_test_participant
|
6
|
+
participants = FieldTest::Participant.standardize(options[:participant] || field_test_participant)
|
7
7
|
|
8
8
|
if try(:request)
|
9
|
-
|
9
|
+
options = options.dup
|
10
|
+
|
11
|
+
if !options[:variant] && params[:field_test] && params[:field_test][experiment] && exp.variants.include?(params[:field_test][experiment])
|
10
12
|
params_variant = params[:field_test][experiment]
|
11
13
|
end
|
12
14
|
|
@@ -25,17 +27,17 @@ module FieldTest
|
|
25
27
|
@field_test_cache[experiment] ||= params_variant || exp.variant(participants, options)
|
26
28
|
end
|
27
29
|
|
28
|
-
def field_test_converted(experiment, options
|
30
|
+
def field_test_converted(experiment, **options)
|
29
31
|
exp = FieldTest::Experiment.find(experiment)
|
30
32
|
|
31
|
-
participants = FieldTest::Participant.standardize(field_test_participant
|
33
|
+
participants = FieldTest::Participant.standardize(options[:participant] || field_test_participant)
|
32
34
|
|
33
35
|
exp.convert(participants, goal: options[:goal])
|
34
36
|
end
|
35
37
|
|
36
38
|
# TODO fetch in single query
|
37
|
-
def field_test_experiments(options
|
38
|
-
participants = FieldTest::Participant.standardize(field_test_participant
|
39
|
+
def field_test_experiments(**options)
|
40
|
+
participants = FieldTest::Participant.standardize(options[:participant] || field_test_participant)
|
39
41
|
experiments = {}
|
40
42
|
participants.each do |participant|
|
41
43
|
FieldTest::Membership.where(participant.where_values).each do |membership|
|
@@ -31,8 +31,8 @@ module FieldTest
|
|
31
31
|
end
|
32
32
|
end
|
33
33
|
|
34
|
-
def self.standardize(
|
35
|
-
Array(
|
34
|
+
def self.standardize(participant)
|
35
|
+
Array(participant).compact.map { |v| FieldTest::Participant.new(v) }
|
36
36
|
end
|
37
37
|
end
|
38
38
|
end
|
data/lib/field_test/version.rb
CHANGED
@@ -1,23 +1,10 @@
|
|
1
|
-
require "rails/generators"
|
2
|
-
require "rails/generators/migration"
|
3
|
-
require "active_record"
|
4
1
|
require "rails/generators/active_record"
|
5
2
|
|
6
3
|
module FieldTest
|
7
4
|
module Generators
|
8
5
|
class EventsGenerator < Rails::Generators::Base
|
9
|
-
include
|
10
|
-
source_root File.
|
11
|
-
|
12
|
-
# Implement the required interface for Rails::Generators::Migration.
|
13
|
-
def self.next_migration_number(dirname) #:nodoc:
|
14
|
-
next_migration_number = current_migration_number(dirname) + 1
|
15
|
-
if ::ActiveRecord::Base.timestamped_migrations
|
16
|
-
[Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max
|
17
|
-
else
|
18
|
-
"%.3d" % next_migration_number
|
19
|
-
end
|
20
|
-
end
|
6
|
+
include ActiveRecord::Generators::Migration
|
7
|
+
source_root File.join(__dir__, "templates")
|
21
8
|
|
22
9
|
def copy_migration
|
23
10
|
migration_template "events.rb", "db/migrate/create_field_test_events.rb", migration_version: migration_version
|
@@ -1,23 +1,10 @@
|
|
1
|
-
require "rails/generators"
|
2
|
-
require "rails/generators/migration"
|
3
|
-
require "active_record"
|
4
1
|
require "rails/generators/active_record"
|
5
2
|
|
6
3
|
module FieldTest
|
7
4
|
module Generators
|
8
5
|
class InstallGenerator < Rails::Generators::Base
|
9
|
-
include
|
10
|
-
source_root File.
|
11
|
-
|
12
|
-
# Implement the required interface for Rails::Generators::Migration.
|
13
|
-
def self.next_migration_number(dirname) #:nodoc:
|
14
|
-
next_migration_number = current_migration_number(dirname) + 1
|
15
|
-
if ::ActiveRecord::Base.timestamped_migrations
|
16
|
-
[Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max
|
17
|
-
else
|
18
|
-
"%.3d" % next_migration_number
|
19
|
-
end
|
20
|
-
end
|
6
|
+
include ActiveRecord::Generators::Migration
|
7
|
+
source_root File.join(__dir__, "templates")
|
21
8
|
|
22
9
|
def copy_migration
|
23
10
|
migration_template "memberships.rb", "db/migrate/create_field_test_memberships.rb", migration_version: migration_version
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: field_test
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-07-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: railties
|
@@ -205,7 +205,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
205
205
|
- !ruby/object:Gem::Version
|
206
206
|
version: '0'
|
207
207
|
requirements: []
|
208
|
-
rubygems_version: 3.0.
|
208
|
+
rubygems_version: 3.0.4
|
209
209
|
signing_key:
|
210
210
|
specification_version: 4
|
211
211
|
summary: A/B testing for Rails
|