field_test 0.3.0 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of field_test might be problematic. Click here for more details.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5303485194bd6850672facf548ba671a2e661e242edfc4a4e3ad90beeed7485d
4
- data.tar.gz: c694d25c71464323daf17e7cd9844d8aa8f51eb3d65e0f788e92e0f5998f18b5
3
+ metadata.gz: 5935d4b9a0cca9bd7d08fb1e0acaf5ab6e0e7f8c8c44b71efae5a3667a0d37a6
4
+ data.tar.gz: e839006d04adfe1e06d27c2b32b8ea601ada47106b05537bf6a11168c8b0864a
5
5
  SHA512:
6
- metadata.gz: df92ba63ed13eaec202546d497b2408288b9b4c8901e3a8a25fff96fbb098fa4c3f6acbb9edcd713f94b4ff95540c9542a3c026b4842ca519b76b7f31df2888b
7
- data.tar.gz: b9407161a151713987f2db017200ad673cff0eba08f5c114d5787bb322dc4eb87f431fac708b90b42653bed90d34a71a595763cbd32cb107a2a631bcfb7f198c
6
+ metadata.gz: 0ab03821f1b5a95f8cf2681820ab4d13c10bde98889e2d5995cd05f7426f815d207bfcdd894db0da90acb1e1743adf6f7256c75d5f62572e27f7183a0992a722
7
+ data.tar.gz: 84009fba31741d3c737f0623b580512bed5dadd455c35691fbe0388899dcf706a8d5ed1740f3d94cb877058e19740aa5321a5b51ef7595c31cea483b85dfd1bc
@@ -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
@@ -1,4 +1,4 @@
1
- Copyright (c) 2016 Andrew Kane
1
+ Copyright (c) 2016-2019 Andrew Kane
2
2
 
3
3
  MIT License
4
4
 
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
@@ -1,6 +1,7 @@
1
1
  <% experiments.each do |experiment| %>
2
2
  <h2>
3
3
  <%= experiment.name %>
4
+ <% if experiment.active? && experiment.closed? %><small class="closed">Closed</small><% end %>
4
5
  <small><%= link_to "Details", experiment_path(experiment.id) %></small>
5
6
  </h2>
6
7
 
@@ -66,6 +66,10 @@
66
66
  color: #5cb85c;
67
67
  }
68
68
 
69
+ .closed {
70
+ color: orange;
71
+ }
72
+
69
73
  .pagination {
70
74
  text-align: center;
71
75
  }
@@ -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
- else
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 variants.first if options[:exclude]
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) || variants.first
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?
@@ -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, options)
6
+ participants = FieldTest::Participant.standardize(options[:participant] || field_test_participant)
7
7
 
8
8
  if try(:request)
9
- if !options[:variant] && params[:field_test] && params[:field_test][experiment]
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, options)
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, options)
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(participants, options = {})
35
- Array(options[:participant] || participants).compact.map { |v| FieldTest::Participant.new(v) }
34
+ def self.standardize(participant)
35
+ Array(participant).compact.map { |v| FieldTest::Participant.new(v) }
36
36
  end
37
37
  end
38
38
  end
@@ -1,3 +1,3 @@
1
1
  module FieldTest
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
@@ -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 Rails::Generators::Migration
10
- source_root File.expand_path("../templates", __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 Rails::Generators::Migration
10
- source_root File.expand_path("../templates", __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.0
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-06-02 00:00:00.000000000 Z
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.3
208
+ rubygems_version: 3.0.4
209
209
  signing_key:
210
210
  specification_version: 4
211
211
  summary: A/B testing for Rails