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 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