field_test 0.2.4 → 0.3.0

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: 91cef396b68f0ed43abcce42c80290ba216fe77105a947f0317925f7abb4d0fc
4
- data.tar.gz: d2119a7219f278d182bf5484f959f788b03f40c129f23a58211ff8c9bfcfaeb5
3
+ metadata.gz: 5303485194bd6850672facf548ba671a2e661e242edfc4a4e3ad90beeed7485d
4
+ data.tar.gz: c694d25c71464323daf17e7cd9844d8aa8f51eb3d65e0f788e92e0f5998f18b5
5
5
  SHA512:
6
- metadata.gz: fce4404e2f2c90215f14414bc9cd5e8c8cb3fb023e1adaea5a116e63e096ad52f25b1a74130cbebbd2d97e80c294b41289249172ca0c02ffb44a6d0ccaddcfe5
7
- data.tar.gz: 8dcd8402e676d67c15552b93277007f4cc2fc5210a4c7beef72a5b0a0acd9e6c1f4c171cc65d8300e810acb5fd524d19f67eabef4da3b1cb86aa83e4d0c5c943
6
+ metadata.gz: df92ba63ed13eaec202546d497b2408288b9b4c8901e3a8a25fff96fbb098fa4c3f6acbb9edcd713f94b4ff95540c9542a3c026b4842ca519b76b7f31df2888b
7
+ data.tar.gz: b9407161a151713987f2db017200ad673cff0eba08f5c114d5787bb322dc4eb87f431fac708b90b42653bed90d34a71a595763cbd32cb107a2a631bcfb7f198c
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 0.3.0
2
+
3
+ - Added support for native apps
4
+ - Added `cookies` option
5
+ - Added `precision` option
6
+ - Fixed bug in results with multiple goals
7
+ - Fixed issue where metrics disappeared from dashboard when moving to multiple goals
8
+ - Dropped support for Rails < 5
9
+
10
+ Breaking changes
11
+
12
+ - Split out participant id and type
13
+ - Changed participant logic for emails
14
+
1
15
  ## 0.2.4
2
16
 
3
17
  - Fixed `PG::AmbiguousColumn` error
data/README.md CHANGED
@@ -3,11 +3,13 @@
3
3
  :maple_leaf: A/B testing for Rails
4
4
 
5
5
  - Designed for web and email
6
- - Comes with a [handy dashboard](https://fieldtest.dokkuapp.com/)
6
+ - Comes with a [dashboard](https://fieldtest.dokkuapp.com/) to view results and update variants
7
7
  - Seamlessly handles the transition from anonymous visitor to logged in user
8
8
 
9
9
  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.
10
10
 
11
+ [![Build Status](https://travis-ci.org/ankane/field_test.svg?branch=master)](https://travis-ci.org/ankane/field_test)
12
+
11
13
  ## Installation
12
14
 
13
15
  Add this line to your application’s Gemfile:
@@ -19,7 +21,8 @@ gem "field_test"
19
21
  Run:
20
22
 
21
23
  ```sh
22
- rails g field_test:install
24
+ rails generate field_test:install
25
+ rails db:migrate
23
26
  ```
24
27
 
25
28
  And mount the dashboard in your `config/routes.rb`:
@@ -28,7 +31,7 @@ And mount the dashboard in your `config/routes.rb`:
28
31
  mount FieldTest::Engine, at: "field_test"
29
32
  ```
30
33
 
31
- Be sure to [secure the dashboard](#security) in production.
34
+ Be sure to [secure the dashboard](#dashboard-security) in production.
32
35
 
33
36
  ![Screenshot](https://ankane.github.io/field_test/screenshot6.png)
34
37
 
@@ -45,12 +48,18 @@ experiments:
45
48
  - blue
46
49
  ```
47
50
 
48
- Refer to it in views, controllers, and mailers.
51
+ Refer to it in controllers, views, and mailers.
49
52
 
50
53
  ```ruby
51
54
  button_color = field_test(:button_color)
52
55
  ```
53
56
 
57
+ To make testing easier, you can specify a variant with query parameters
58
+
59
+ ```
60
+ http://localhost:3000/?field_test[button_color]=green
61
+ ```
62
+
54
63
  When someone converts, record it with:
55
64
 
56
65
  ```ruby
@@ -67,22 +76,60 @@ experiments:
67
76
 
68
77
  All calls to `field_test` will now return the winner, and metrics will stop being recorded.
69
78
 
70
- ## Features
79
+ You can get the list of experiments and variants for a user with:
80
+
81
+ ```ruby
82
+ field_test_experiments
83
+ ```
71
84
 
72
- You can specify a variant with query parameters to make testing easier
85
+ ## JavaScript and Native Apps
73
86
 
87
+ For JavaScript and native apps, add calls to your normal endpoints.
88
+
89
+ ```ruby
90
+ class CheckoutController < ActionController::API
91
+ def start
92
+ render json: {button_color: field_test(:button_color)}
93
+ end
94
+
95
+ def finish
96
+ field_test_converted(:button_color)
97
+ # ...
98
+ end
99
+ end
74
100
  ```
75
- ?field_test[button_color]=green
101
+
102
+ For anonymous visitors in native apps, pass a `Field-Test-Visitor` header with a unique identifier.
103
+
104
+ ## Participants
105
+
106
+ Any model or string can be a participant in an experiment.
107
+
108
+ For web requests, it uses `current_user` (if it exists) and an anonymous visitor id to determine the participant. Set your own with:
109
+
110
+ ```ruby
111
+ class ApplicationController < ActionController::Base
112
+ def field_test_participant
113
+ current_company
114
+ end
115
+ end
76
116
  ```
77
117
 
78
- Assign a specific variant to a user with:
118
+ For mailers, it tries `@user` then `params[:user]` to determine the participant. Set your own with:
79
119
 
80
120
  ```ruby
81
- experiment = FieldTest::Experiment.find(:button_color)
82
- experiment.variant(participant, variant: "green")
121
+ class ApplicationMailer < ActionMailer::Base
122
+ def field_test_participant
123
+ @company
124
+ end
125
+ end
83
126
  ```
84
127
 
85
- You can also change a user’s variant from the dashboard.
128
+ You can also manually pass a participant with:
129
+
130
+ ```ruby
131
+ field_test(:button_color, participant: company)
132
+ ```
86
133
 
87
134
  ## Config
88
135
 
@@ -126,6 +173,14 @@ experiments:
126
173
  - 15
127
174
  ```
128
175
 
176
+ To help with GDPR compliance, you can switch from cookies to [anonymity sets](https://privacypatterns.org/patterns/Anonymity-set) for anonymous visitors. Visitors with the same IP mask and user agent are grouped together.
177
+
178
+ ```yml
179
+ cookies: false
180
+ ```
181
+
182
+ ## Dashboard Config
183
+
129
184
  If the dashboard gets slow, you can make it faster with:
130
185
 
131
186
  ```yml
@@ -134,12 +189,19 @@ cache: true
134
189
 
135
190
  This will use the Rails cache to speed up winning probability calculations.
136
191
 
137
- ## Funnels
192
+ If you need more precision, set:
193
+
194
+ ```yml
195
+ precision: 1
196
+ ```
197
+
198
+ ## Multiple Goals
138
199
 
139
200
  You can set multiple goals for an experiment to track conversions at different parts of the funnel. First, run:
140
201
 
141
202
  ```sh
142
- rails g field_test:events
203
+ rails generate field_test:events
204
+ rails db:migrate
143
205
  ```
144
206
 
145
207
  And add to your config:
@@ -162,20 +224,30 @@ The results for all goals will appear on the dashboard.
162
224
 
163
225
  ## Analytics Platforms
164
226
 
165
- You can also send experiment data to analytics platforms like [Google Analytics](https://www.google.com/analytics/), [Mixpanel](https://mixpanel.com/), and [Ahoy](https://github.com/ankane/ahoy). Use:
227
+ You may also want to send experiment data as properties to other analytics platforms like [Segment](https://segment.com), [Amplitude](https://amplitude.com), and [Ahoy](https://github.com/ankane/ahoy). Get the list of experiments and variants with:
166
228
 
167
229
  ```ruby
168
230
  field_test_experiments
169
231
  ```
170
232
 
171
- to get all experiments and variants for a participant and pass them as properties.
233
+ ### Ahoy
234
+
235
+ You can configure Field Test to use Ahoy’s visitor token instead of creating its own:
236
+
237
+ ```ruby
238
+ class ApplicationController < ActionController::Base
239
+ def field_test_participant
240
+ [ahoy.user, ahoy.visitor_token]
241
+ end
242
+ end
243
+ ```
172
244
 
173
- ## Security
245
+ ## Dashboard Security
174
246
 
175
247
  #### Devise
176
248
 
177
249
  ```ruby
178
- authenticate :user, -> (user) { user.admin? } do
250
+ authenticate :user, ->(user) { user.admin? } do
179
251
  mount FieldTest::Engine, at: "field_test"
180
252
  end
181
253
  ```
@@ -189,13 +261,104 @@ ENV["FIELD_TEST_USERNAME"] = "moonrise"
189
261
  ENV["FIELD_TEST_PASSWORD"] = "kingdom"
190
262
  ```
191
263
 
192
- ## Credits
264
+ ## Reference
193
265
 
194
- A huge thanks to [Evan Miller](https://www.evanmiller.org/) for deriving the Bayesian formulas.
266
+ Assign a specific variant to a user with:
267
+
268
+ ```ruby
269
+ experiment = FieldTest::Experiment.find(:button_color)
270
+ experiment.variant(participant, variant: "green")
271
+ ```
272
+
273
+ You can also change a user’s variant from the dashboard.
274
+
275
+ To associate models with field test memberships, use:
276
+
277
+ ```ruby
278
+ class User < ApplicationRecord
279
+ has_many :field_test_memberships, class_name: "FieldTest::Membership", as: :participant
280
+ end
281
+ ```
282
+
283
+ Now you can do:
284
+
285
+ ```ruby
286
+ user.field_test_memberships
287
+ ```
288
+
289
+ ## Upgrading
290
+
291
+ ### 0.3.0
292
+
293
+ Upgrade the gem and add to `config/field_test.yml`:
294
+
295
+ ```yml
296
+ legacy_participants: true
297
+ ```
298
+
299
+ Also, if you use Field Test in emails, know that the default way participants are determined has changed. Restore the previous way with:
300
+
301
+ ```ruby
302
+ class ApplicationMailer < ActionMailer::Base
303
+ def field_test_participant
304
+ message.to.first
305
+ end
306
+ end
307
+ ```
308
+
309
+ We also recommend upgrading participants when you have time.
310
+
311
+ #### Upgrading Participants
312
+
313
+ Field Test 0.3.0 splits the `field_test_memberships.participant` column into `participant_type` and `participant_id`.
314
+
315
+ To upgrade without downtime, create a migration:
195
316
 
196
- ## TODO
317
+ ```sh
318
+ rails generate migration upgrade_field_test_participants
319
+ ```
320
+
321
+ with:
197
322
 
198
- - Code samples for analytics platforms
323
+ ```ruby
324
+ class UpgradeFieldTestParticipants < ActiveRecord::Migration[5.2]
325
+ def change
326
+ add_column :field_test_memberships, :participant_type, :string
327
+ add_column :field_test_memberships, :participant_id, :string
328
+
329
+ add_index :field_test_memberships, [:participant_type, :participant_id, :experiment],
330
+ unique: true, name: "index_field_test_memberships_on_participant_and_experiment"
331
+ end
332
+ end
333
+ ```
334
+
335
+ After you run it, writes will go to both the old and new sets of columns.
336
+
337
+ Next, backfill data:
338
+
339
+ ```ruby
340
+ FieldTest::Membership.where(participant_id: nil).find_each do |membership|
341
+ participant = membership.participant
342
+
343
+ if participant.include?(":")
344
+ participant_type, _, participant_id = participant.rpartition(":")
345
+ participant_type = nil if participant_type == "cookie" # legacy
346
+ else
347
+ participant_id = participant
348
+ end
349
+
350
+ membership.update!(
351
+ participant_type: participant_type,
352
+ participant_id: participant_id
353
+ )
354
+ end
355
+ ```
356
+
357
+ Finally, remove `legacy_participants: true` from the config file. Once you confirm it’s working, you can drop the `participant` column (you can rename it first just to be extra safe).
358
+
359
+ ## Credits
360
+
361
+ A huge thanks to [Evan Miller](https://www.evanmiller.org/) for deriving the Bayesian formulas.
199
362
 
200
363
  ## History
201
364
 
@@ -2,8 +2,8 @@ module FieldTest
2
2
  class MembershipsController < BaseController
3
3
  def update
4
4
  membership = FieldTest::Membership.find(params[:id])
5
- membership.update_attributes(membership_params)
6
- redirect_to participant_path(membership.participant)
5
+ membership.update!(membership_params)
6
+ redirect_back(fallback_location: root_path)
7
7
  end
8
8
 
9
9
  private
@@ -1,9 +1,17 @@
1
1
  module FieldTest
2
2
  class ParticipantsController < BaseController
3
3
  def show
4
- @participant = params[:id]
5
4
  # TODO better ordering
6
- @memberships = FieldTest::Membership.where(participant: @participant).order(:id)
5
+ @memberships =
6
+ if FieldTest.legacy_participants
7
+ @participant = params[:id]
8
+ FieldTest::Membership.where(participant: @participant).order(:id)
9
+ else
10
+ id = params[:id]
11
+ type = params[:type]
12
+ @participant = [type, id].compact.join(" ")
13
+ FieldTest::Membership.where(participant_type: type, participant_id: id).order(:id)
14
+ end
7
15
 
8
16
  @events =
9
17
  if FieldTest.events_supported?
@@ -0,0 +1,12 @@
1
+ module FieldTest
2
+ module BaseHelper
3
+ def field_test_participant_link(membership)
4
+ if FieldTest.legacy_participants
5
+ link_to membership.participant, legacy_participant_path(membership.participant)
6
+ else
7
+ text = [membership.participant_type, membership.participant_id].compact.join(" ")
8
+ link_to text, participant_path(type: membership.participant_type, id: membership.participant_id)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -4,7 +4,8 @@ module FieldTest
4
4
 
5
5
  has_many :events, class_name: "FieldTest::Event"
6
6
 
7
- validates :participant, presence: true
7
+ validates :participant, presence: true, if: -> { FieldTest.legacy_participants }
8
+ validates :participant_id, presence: true, if: -> { !FieldTest.legacy_participants }
8
9
  validates :experiment, presence: true
9
10
  validates :variant, presence: true
10
11
  end
@@ -38,7 +38,7 @@
38
38
  <td><%= result[:converted] %></td>
39
39
  <td>
40
40
  <% if result[:conversion_rate] %>
41
- <%= (100.0 * result[:conversion_rate]).round %>%
41
+ <%= (100.0 * result[:conversion_rate]).round(FieldTest.precision) %>%
42
42
  <% else %>
43
43
  -
44
44
  <% end %>
@@ -48,7 +48,7 @@
48
48
  <% if result[:prob_winning] < 0.01 %>
49
49
  &lt; 1%
50
50
  <% else %>
51
- <%= (100.0 * result[:prob_winning]).round %>%
51
+ <%= (100.0 * result[:prob_winning]).round(FieldTest.precision) %>%
52
52
  <% end %>
53
53
  <% end %>
54
54
  </td>
@@ -22,7 +22,7 @@
22
22
  <tbody>
23
23
  <% @memberships.each do |membership| %>
24
24
  <tr>
25
- <td><%= link_to membership.participant, participant_path(membership.participant) %></td>
25
+ <td><%= field_test_participant_link(membership) %></td>
26
26
  <td><%= membership.variant %></td>
27
27
  <td>
28
28
  <% converted = false %>
data/config/routes.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  FieldTest::Engine.routes.draw do
2
2
  resources :experiments, only: [:show]
3
3
  resources :memberships, only: [:update]
4
- get "participants/:id", to: "participants#show", constraints: {id: /.+/}, as: :participant
4
+ get "participants/:id", to: "participants#show", constraints: {id: /.+/}, as: :legacy_participant
5
+ get "participants", to: "participants#show", as: :participant
5
6
  root "experiments#index"
6
7
  end
data/lib/field_test.rb CHANGED
@@ -1,26 +1,36 @@
1
- require "browser"
1
+ # dependencies
2
2
  require "active_support"
3
+ require "browser"
4
+ require "ipaddr"
5
+
6
+ # modules
3
7
  require "field_test/calculations"
4
8
  require "field_test/experiment"
5
- require "field_test/engine" if defined?(Rails)
6
9
  require "field_test/helpers"
7
10
  require "field_test/participant"
8
11
  require "field_test/version"
9
12
 
13
+ # integrations
14
+ require "field_test/engine" if defined?(Rails)
15
+
10
16
  module FieldTest
11
17
  class Error < StandardError; end
12
18
  class ExperimentNotFound < Error; end
13
19
  class UnknownParticipant < Error; end
14
20
 
15
- def self.config
16
- # reload in dev
17
- @config = nil if Rails.env.development?
21
+ # same as ahoy
22
+ UUID_NAMESPACE = "a82ae811-5011-45ab-a728-569df7499c5f"
18
23
 
19
- @config ||= YAML.load(ERB.new(File.read("config/field_test.yml")).result)
24
+ def self.config_path
25
+ path = defined?(Rails) ? Rails.root : File
26
+ path.join("config", "field_test.yml")
27
+ end
28
+
29
+ def self.config
30
+ @config ||= YAML.load(ERB.new(File.read(config_path)).result)
20
31
  end
21
32
 
22
33
  def self.exclude_bots?
23
- config = self.config # dev performance
24
34
  config["exclude"] && config["exclude"]["bots"]
25
35
  end
26
36
 
@@ -28,6 +38,18 @@ module FieldTest
28
38
  config["cache"]
29
39
  end
30
40
 
41
+ def self.cookies
42
+ config.key?("cookies") ? config["cookies"] : true
43
+ end
44
+
45
+ def self.legacy_participants
46
+ config["legacy_participants"]
47
+ end
48
+
49
+ def self.precision
50
+ config["precision"] || 0
51
+ end
52
+
31
53
  def self.events_supported?
32
54
  unless defined?(@events_supported)
33
55
  connection = FieldTest::Membership.connection
@@ -41,16 +63,25 @@ module FieldTest
41
63
  end
42
64
  @events_supported
43
65
  end
44
- end
45
66
 
46
- ActiveSupport.on_load(:action_controller) do
47
- include FieldTest::Helpers
67
+ def self.mask_ip(ip)
68
+ addr = IPAddr.new(ip)
69
+ if addr.ipv4?
70
+ # set last octet to 0
71
+ addr.mask(24).to_s
72
+ else
73
+ # set last 80 bits to zeros
74
+ addr.mask(48).to_s
75
+ end
76
+ end
48
77
  end
49
78
 
50
- ActiveSupport.on_load(:action_view) do
51
- include FieldTest::Helpers
79
+ ActiveSupport.on_load(:action_controller) do
80
+ require "field_test/controller"
81
+ include FieldTest::Controller
52
82
  end
53
83
 
54
84
  ActiveSupport.on_load(:action_mailer) do
55
- include FieldTest::Helpers
85
+ require "field_test/mailer"
86
+ include FieldTest::Mailer
56
87
  end
@@ -0,0 +1,57 @@
1
+ module FieldTest
2
+ module Controller
3
+ extend ActiveSupport::Concern
4
+ include Helpers
5
+
6
+ included do
7
+ if respond_to?(:helper_method)
8
+ helper_method :field_test
9
+ helper_method :field_test_converted
10
+ helper_method :field_test_experiments
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def field_test_participant
17
+ participants = []
18
+
19
+ if respond_to?(:current_user, true)
20
+ user = send(:current_user)
21
+ participants << user if user
22
+ end
23
+
24
+ cookie_key = "field_test"
25
+
26
+ if request.headers["Field-Test-Visitor"]
27
+ token = request.headers["Field-Test-Visitor"]
28
+ elsif FieldTest.cookies
29
+ token = cookies[cookie_key]
30
+
31
+ if participants.empty? && !token
32
+ token = SecureRandom.uuid
33
+ cookies[cookie_key] = {value: token, expires: 30.days.from_now}
34
+ end
35
+ else
36
+ # anonymity set
37
+ # note: hashing does not conceal input
38
+ token = Digest::UUID.uuid_v5(FieldTest::UUID_NAMESPACE, ["visitor", FieldTest.mask_ip(request.remote_ip), request.user_agent].join("/"))
39
+
40
+ # delete cookie if present
41
+ cookies.delete(cookie_key) if cookies[cookie_key]
42
+ end
43
+
44
+ # sanitize tokens
45
+ token = token.gsub(/[^a-z0-9\-]/i, "") if token
46
+
47
+ if token.present?
48
+ participants << token
49
+
50
+ # backwards compatibility
51
+ participants << "cookie:#{token}" if FieldTest.legacy_participants
52
+ end
53
+
54
+ participants
55
+ end
56
+ end
57
+ end
@@ -13,6 +13,7 @@ module FieldTest
13
13
  @started_at = Time.zone.parse(attributes[:started_at].to_s) if attributes[:started_at]
14
14
  @ended_at = Time.zone.parse(attributes[:ended_at].to_s) if attributes[:ended_at]
15
15
  @goals = attributes[:goals] || ["conversion"]
16
+ @goals_defined = !attributes[:goals].nil?
16
17
  @use_events = attributes[:use_events]
17
18
  end
18
19
 
@@ -30,25 +31,18 @@ module FieldTest
30
31
  membership.variant ||= weighted_variant
31
32
  end
32
33
 
34
+ participant = participants.first
35
+
33
36
  # upgrade to preferred participant
34
- membership.participant = participants.first
37
+ membership.participant = participant.participant if membership.respond_to?(:participant=)
38
+ membership.participant_type = participant.type if membership.respond_to?(:participant_type=)
39
+ membership.participant_id = participant.id if membership.respond_to?(:participant_id=)
35
40
 
36
41
  if membership.changed?
37
42
  begin
38
43
  membership.save!
39
-
40
- # log it!
41
- info = {
42
- experiment: id,
43
- variant: membership.variant,
44
- participant: membership.participant
45
- }.merge(options.slice(:ip, :user_agent))
46
-
47
- # sorta logfmt :)
48
- info = info.map { |k, v| v = "\"#{v}\"" if k == :user_agent; "#{k}=#{v}" }.join(" ")
49
- Rails.logger.info "[field test] #{info}"
50
44
  rescue ActiveRecord::RecordNotUnique
51
- membership = memberships.find_by(participant: participants.first)
45
+ membership = memberships.find_by(participant.where_values)
52
46
  end
53
47
  end
54
48
 
@@ -100,16 +94,29 @@ module FieldTest
100
94
  relation = relation.where("field_test_memberships.created_at >= ?", started_at) if started_at
101
95
  relation = relation.where("field_test_memberships.created_at <= ?", ended_at) if ended_at
102
96
 
103
- if use_events?
97
+ if use_events? && @goals_defined
104
98
  data = {}
105
- sql =
106
- relation.joins("LEFT JOIN field_test_events ON field_test_events.field_test_membership_id = field_test_memberships.id")
107
- .select("variant, COUNT(DISTINCT participant) AS participated, COUNT(DISTINCT field_test_membership_id) AS converted")
108
- .where(field_test_events: {name: [goal, nil]})
109
-
110
- FieldTest::Membership.connection.select_all(sql).each do |row|
111
- data[[row["variant"], true]] = row["converted"].to_i
112
- data[[row["variant"], false]] = row["participated"].to_i - row["converted"].to_i
99
+
100
+ participated = relation.count
101
+
102
+ adapter_name = relation.connection.adapter_name
103
+ column =
104
+ if FieldTest.legacy_participants
105
+ :participant
106
+ elsif adapter_name =~ /postg/i # postgres
107
+ "(participant_type, participant_id)"
108
+ elsif adapter_name =~ /mysql/i
109
+ "participant_type, participant_id"
110
+ else
111
+ # not perfect, but it'll do
112
+ "COALESCE(participant_type, '') || ':' || participant_id"
113
+ end
114
+
115
+ converted = events.merge(relation).where(field_test_events: {name: goal}).distinct.count(column)
116
+
117
+ (participated.keys + converted.keys).uniq.each do |variant|
118
+ data[[variant, true]] = converted[variant].to_i
119
+ data[[variant, false]] = participated[variant].to_i - converted[variant].to_i
113
120
  end
114
121
  else
115
122
  data = relation.group(:converted).count
@@ -125,6 +132,7 @@ module FieldTest
125
132
  conversion_rate: participated > 0 ? converted.to_f / participated : nil
126
133
  }
127
134
  end
135
+
128
136
  case variants.size
129
137
  when 1, 2, 3
130
138
  total = 0.0
@@ -189,32 +197,37 @@ module FieldTest
189
197
 
190
198
  private
191
199
 
192
- def check_participants(participants)
193
- raise FieldTest::UnknownParticipant, "Use the :participant option to specify a participant" if participants.empty?
194
- end
200
+ def check_participants(participants)
201
+ raise FieldTest::UnknownParticipant, "Use the :participant option to specify a participant" if participants.empty?
202
+ end
195
203
 
196
- def membership_for(participants)
197
- memberships = self.memberships.where(participant: participants).index_by(&:participant)
198
- participants.map { |part| memberships[part] }.compact.first
204
+ # TODO fetch in single query
205
+ def membership_for(participants)
206
+ membership = nil
207
+ participants.each do |participant|
208
+ membership = self.memberships.find_by(participant.where_values)
209
+ break if membership
199
210
  end
211
+ membership
212
+ end
200
213
 
201
- def weighted_variant
202
- total = weights.sum.to_f
203
- pick = rand
204
- n = 0
205
- weights.map { |w| w / total }.each_with_index do |w, i|
206
- n += w
207
- return variants[i] if n >= pick
208
- end
209
- variants.last
214
+ def weighted_variant
215
+ total = weights.sum.to_f
216
+ pick = rand
217
+ n = 0
218
+ weights.map { |w| w / total }.each_with_index do |w, i|
219
+ n += w
220
+ return variants[i] if n >= pick
210
221
  end
222
+ variants.last
223
+ end
211
224
 
212
- def cache_fetch(key)
213
- if FieldTest.cache
214
- Rails.cache.fetch(key.join("/")) { yield }
215
- else
216
- yield
217
- end
225
+ def cache_fetch(key)
226
+ if FieldTest.cache
227
+ Rails.cache.fetch(key.join("/")) { yield }
228
+ else
229
+ yield
218
230
  end
231
+ end
219
232
  end
220
233
  end
@@ -3,11 +3,11 @@ module FieldTest
3
3
  def field_test(experiment, options = {})
4
4
  exp = FieldTest::Experiment.find(experiment)
5
5
 
6
- participants = field_test_participants(options)
6
+ participants = FieldTest::Participant.standardize(field_test_participant, options)
7
7
 
8
8
  if try(:request)
9
- if params[:field_test] && params[:field_test][experiment]
10
- options[:variant] ||= params[:field_test][experiment]
9
+ if !options[:variant] && params[:field_test] && params[:field_test][experiment]
10
+ params_variant = params[:field_test][experiment]
11
11
  end
12
12
 
13
13
  if FieldTest.exclude_bots?
@@ -20,67 +20,29 @@ module FieldTest
20
20
 
21
21
  # cache results for request
22
22
  @field_test_cache ||= {}
23
- @field_test_cache[experiment] ||= exp.variant(participants, options)
23
+
24
+ # don't update variant when passed via params
25
+ @field_test_cache[experiment] ||= params_variant || exp.variant(participants, options)
24
26
  end
25
27
 
26
28
  def field_test_converted(experiment, options = {})
27
29
  exp = FieldTest::Experiment.find(experiment)
28
30
 
29
- participants = field_test_participants(options)
31
+ participants = FieldTest::Participant.standardize(field_test_participant, options)
30
32
 
31
33
  exp.convert(participants, goal: options[:goal])
32
34
  end
33
35
 
36
+ # TODO fetch in single query
34
37
  def field_test_experiments(options = {})
35
- participants = field_test_participants(options)
36
- memberships = FieldTest::Membership.where(participant: participants).group_by(&:participant)
38
+ participants = FieldTest::Participant.standardize(field_test_participant, options)
37
39
  experiments = {}
38
40
  participants.each do |participant|
39
- memberships[participant].to_a.each do |membership|
41
+ FieldTest::Membership.where(participant.where_values).each do |membership|
40
42
  experiments[membership.experiment] ||= membership.variant
41
43
  end
42
44
  end
43
45
  experiments
44
46
  end
45
-
46
- def field_test_participants(options = {})
47
- participants = []
48
-
49
- if options[:participant]
50
- participants << options[:participant]
51
- else
52
- if respond_to?(:current_user, true) && current_user
53
- participants << current_user
54
- end
55
-
56
- # controllers and views
57
- if try(:request)
58
- # use cookie
59
- cookie_key = "field_test"
60
-
61
- token = cookies[cookie_key]
62
- token = token.gsub(/[^a-z0-9\-]/i, "") if token
63
-
64
- if participants.empty? && !token
65
- token = SecureRandom.uuid
66
- cookies[cookie_key] = {value: token, expires: 30.days.from_now}
67
- end
68
- if token
69
- participants << token
70
-
71
- # backwards compatibility
72
- participants << "cookie:#{token}"
73
- end
74
- end
75
-
76
- # mailers
77
- to = try(:message).try(:to).try(:first)
78
- if to
79
- participants << to
80
- end
81
- end
82
-
83
- FieldTest::Participant.standardize(participants)
84
- end
85
47
  end
86
48
  end
@@ -0,0 +1,20 @@
1
+ module FieldTest
2
+ module Mailer
3
+ extend ActiveSupport::Concern
4
+ include Helpers
5
+
6
+ included do
7
+ helper_method :field_test
8
+ helper_method :field_test_converted
9
+ helper_method :field_test_experiments
10
+ end
11
+
12
+ def field_test_participant
13
+ if @user
14
+ @user
15
+ elsif respond_to?(:params) && params
16
+ params[:user]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,7 +1,38 @@
1
1
  module FieldTest
2
2
  class Participant
3
- def self.standardize(participants)
4
- Array(participants).map { |v| v.respond_to?(:model_name) ? "#{v.model_name.name}:#{v.id}" : v.to_s }
3
+ attr_reader :type, :id
4
+
5
+ def initialize(object)
6
+ if object.is_a?(FieldTest::Participant)
7
+ @type = object.type
8
+ @id = object.id
9
+ elsif object.respond_to?(:model_name)
10
+ @type = object.model_name.name
11
+ @id = object.id.to_s
12
+ else
13
+ @id = object.to_s
14
+ end
15
+ end
16
+
17
+ def participant
18
+ [type, id].compact.join(":")
19
+ end
20
+
21
+ def where_values
22
+ if FieldTest.legacy_participants
23
+ {
24
+ participant: participant
25
+ }
26
+ else
27
+ {
28
+ participant_type: type,
29
+ participant_id: id
30
+ }
31
+ end
32
+ end
33
+
34
+ def self.standardize(participants, options = {})
35
+ Array(options[:participant] || participants).compact.map { |v| FieldTest::Participant.new(v) }
5
36
  end
6
37
  end
7
38
  end
@@ -1,3 +1,3 @@
1
1
  module FieldTest
2
- VERSION = "0.2.4"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -24,9 +24,7 @@ module FieldTest
24
24
  end
25
25
 
26
26
  def migration_version
27
- if ActiveRecord::VERSION::MAJOR >= 5
28
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
29
- end
27
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
30
28
  end
31
29
  end
32
30
  end
@@ -28,9 +28,7 @@ module FieldTest
28
28
  end
29
29
 
30
30
  def migration_version
31
- if ActiveRecord::VERSION::MAJOR >= 5
32
- "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
33
- end
31
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
34
32
  end
35
33
  end
36
34
  end
@@ -1,11 +1,9 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :field_test_events do |t|
4
- t.integer :field_test_membership_id
4
+ t.references :field_test_membership
5
5
  t.string :name
6
6
  t.timestamp :created_at
7
7
  end
8
-
9
- add_index :field_test_events, :field_test_membership_id
10
8
  end
11
9
  end
@@ -1,15 +1,16 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
2
  def change
3
3
  create_table :field_test_memberships do |t|
4
- t.string :participant
4
+ t.string :participant_type
5
+ t.string :participant_id
5
6
  t.string :experiment
6
7
  t.string :variant
7
8
  t.timestamp :created_at
8
9
  t.boolean :converted, default: false
9
10
  end
10
11
 
11
- add_index :field_test_memberships, [:experiment, :participant], unique: true
12
- add_index :field_test_memberships, :participant
12
+ add_index :field_test_memberships, [:participant_type, :participant_id, :experiment],
13
+ unique: true, name: "index_field_test_memberships_on_participant"
13
14
  add_index :field_test_memberships, [:experiment, :created_at]
14
15
  end
15
16
  end
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.2.4
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2019-01-04 00:00:00.000000000 Z
11
+ date: 2019-06-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
19
+ version: '5'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '0'
26
+ version: '5'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activerecord
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '0'
33
+ version: '5'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '0'
40
+ version: '5'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: distribution
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -108,23 +108,62 @@ dependencies:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
110
  version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: combustion
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rails
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: sqlite3
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
111
153
  description:
112
- email:
113
- - andrew@chartkick.com
154
+ email: andrew@chartkick.com
114
155
  executables: []
115
156
  extensions: []
116
157
  extra_rdoc_files: []
117
158
  files:
118
- - ".gitignore"
119
159
  - CHANGELOG.md
120
- - Gemfile
121
160
  - LICENSE.txt
122
161
  - README.md
123
- - Rakefile
124
162
  - app/controllers/field_test/base_controller.rb
125
163
  - app/controllers/field_test/experiments_controller.rb
126
164
  - app/controllers/field_test/memberships_controller.rb
127
165
  - app/controllers/field_test/participants_controller.rb
166
+ - app/helpers/field_test/base_helper.rb
128
167
  - app/models/field_test/event.rb
129
168
  - app/models/field_test/membership.rb
130
169
  - app/views/field_test/experiments/_experiments.html.erb
@@ -133,12 +172,13 @@ files:
133
172
  - app/views/field_test/participants/show.html.erb
134
173
  - app/views/layouts/field_test/application.html.erb
135
174
  - config/routes.rb
136
- - field_test.gemspec
137
175
  - lib/field_test.rb
138
176
  - lib/field_test/calculations.rb
177
+ - lib/field_test/controller.rb
139
178
  - lib/field_test/engine.rb
140
179
  - lib/field_test/experiment.rb
141
180
  - lib/field_test/helpers.rb
181
+ - lib/field_test/mailer.rb
142
182
  - lib/field_test/participant.rb
143
183
  - lib/field_test/version.rb
144
184
  - lib/generators/field_test/events_generator.rb
@@ -147,7 +187,8 @@ files:
147
187
  - lib/generators/field_test/templates/events.rb.tt
148
188
  - lib/generators/field_test/templates/memberships.rb.tt
149
189
  homepage: https://github.com/ankane/field_test
150
- licenses: []
190
+ licenses:
191
+ - MIT
151
192
  metadata: {}
152
193
  post_install_message:
153
194
  rdoc_options: []
@@ -157,15 +198,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
157
198
  requirements:
158
199
  - - ">="
159
200
  - !ruby/object:Gem::Version
160
- version: '0'
201
+ version: '2.4'
161
202
  required_rubygems_version: !ruby/object:Gem::Requirement
162
203
  requirements:
163
204
  - - ">="
164
205
  - !ruby/object:Gem::Version
165
206
  version: '0'
166
207
  requirements: []
167
- rubyforge_project:
168
- rubygems_version: 2.7.6
208
+ rubygems_version: 3.0.3
169
209
  signing_key:
170
210
  specification_version: 4
171
211
  summary: A/B testing for Rails
data/.gitignore DELETED
@@ -1,9 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
data/Gemfile DELETED
@@ -1,4 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- # Specify your gem's dependencies in field_test.gemspec
4
- gemspec
data/Rakefile DELETED
@@ -1,10 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
3
-
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
8
- end
9
-
10
- task default: :test
data/field_test.gemspec DELETED
@@ -1,30 +0,0 @@
1
- # coding: utf-8
2
- lib = File.expand_path("../lib", __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require "field_test/version"
5
-
6
- Gem::Specification.new do |spec|
7
- spec.name = "field_test"
8
- spec.version = FieldTest::VERSION
9
- spec.authors = ["Andrew Kane"]
10
- spec.email = ["andrew@chartkick.com"]
11
-
12
- spec.summary = "A/B testing for Rails"
13
- spec.homepage = "https://github.com/ankane/field_test"
14
-
15
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
- f.match(%r{^(test|spec|features)/})
17
- end
18
- spec.bindir = "exe"
19
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
- spec.require_paths = ["lib"]
21
-
22
- spec.add_dependency "railties"
23
- spec.add_dependency "activerecord"
24
- spec.add_dependency "distribution"
25
- spec.add_dependency "browser", "~> 2.0"
26
-
27
- spec.add_development_dependency "bundler"
28
- spec.add_development_dependency "rake"
29
- spec.add_development_dependency "minitest"
30
- end