field_test 0.2.4 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -8
- data/LICENSE.txt +1 -1
- data/README.md +242 -22
- data/app/controllers/field_test/base_controller.rb +1 -1
- data/app/controllers/field_test/memberships_controller.rb +2 -2
- data/app/controllers/field_test/participants_controller.rb +10 -2
- data/app/helpers/field_test/base_helper.rb +12 -0
- data/app/models/field_test/membership.rb +2 -1
- data/app/views/field_test/experiments/_experiments.html.erb +3 -2
- data/app/views/field_test/experiments/show.html.erb +1 -1
- data/app/views/layouts/field_test/application.html.erb +4 -0
- data/config/routes.rb +2 -1
- data/lib/field_test.rb +47 -12
- data/lib/field_test/controller.rb +76 -0
- data/lib/field_test/experiment.rb +81 -48
- data/lib/field_test/helpers.rb +24 -53
- data/lib/field_test/mailer.rb +20 -0
- data/lib/field_test/participant.rb +33 -2
- data/lib/field_test/version.rb +1 -1
- data/lib/generators/field_test/events_generator.rb +3 -18
- data/lib/generators/field_test/install_generator.rb +3 -18
- data/lib/generators/field_test/templates/events.rb.tt +2 -4
- data/lib/generators/field_test/templates/memberships.rb.tt +5 -4
- metadata +59 -19
- data/.gitignore +0 -9
- data/Gemfile +0 -4
- data/Rakefile +0 -10
- data/field_test.gemspec +0 -30
@@ -2,7 +2,7 @@ module FieldTest
|
|
2
2
|
class BaseController < ActionController::Base
|
3
3
|
layout "field_test/application"
|
4
4
|
|
5
|
-
protect_from_forgery
|
5
|
+
protect_from_forgery with: :exception
|
6
6
|
|
7
7
|
http_basic_authenticate_with name: ENV["FIELD_TEST_USERNAME"], password: ENV["FIELD_TEST_PASSWORD"] if ENV["FIELD_TEST_PASSWORD"]
|
8
8
|
end
|
@@ -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.
|
6
|
-
|
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 =
|
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
|
@@ -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
|
|
@@ -38,7 +39,7 @@
|
|
38
39
|
<td><%= result[:converted] %></td>
|
39
40
|
<td>
|
40
41
|
<% if result[:conversion_rate] %>
|
41
|
-
<%= (100.0 * result[:conversion_rate]).round %>%
|
42
|
+
<%= (100.0 * result[:conversion_rate]).round(FieldTest.precision) %>%
|
42
43
|
<% else %>
|
43
44
|
-
|
44
45
|
<% end %>
|
@@ -48,7 +49,7 @@
|
|
48
49
|
<% if result[:prob_winning] < 0.01 %>
|
49
50
|
< 1%
|
50
51
|
<% else %>
|
51
|
-
<%= (100.0 * result[:prob_winning]).round %>%
|
52
|
+
<%= (100.0 * result[:prob_winning]).round(FieldTest.precision) %>%
|
52
53
|
<% end %>
|
53
54
|
<% end %>
|
54
55
|
</td>
|
@@ -22,7 +22,7 @@
|
|
22
22
|
<tbody>
|
23
23
|
<% @memberships.each do |membership| %>
|
24
24
|
<tr>
|
25
|
-
<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: :
|
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,40 @@
|
|
1
|
-
|
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
|
|
21
|
+
# same as ahoy
|
22
|
+
UUID_NAMESPACE = "a82ae811-5011-45ab-a728-569df7499c5f"
|
23
|
+
|
24
|
+
def self.config_path
|
25
|
+
path = defined?(Rails) ? Rails.root : File
|
26
|
+
path.join("config", "field_test.yml")
|
27
|
+
end
|
28
|
+
|
15
29
|
def self.config
|
16
|
-
|
17
|
-
|
30
|
+
@config ||= YAML.load(ERB.new(File.read(config_path)).result)
|
31
|
+
end
|
18
32
|
|
19
|
-
|
33
|
+
def self.excluded_ips
|
34
|
+
@excluded_ips ||= Array(config["exclude"] && config["exclude"]["ips"]).map { |ip| IPAddr.new(ip) }
|
20
35
|
end
|
21
36
|
|
22
37
|
def self.exclude_bots?
|
23
|
-
config = self.config # dev performance
|
24
38
|
config["exclude"] && config["exclude"]["bots"]
|
25
39
|
end
|
26
40
|
|
@@ -28,6 +42,18 @@ module FieldTest
|
|
28
42
|
config["cache"]
|
29
43
|
end
|
30
44
|
|
45
|
+
def self.cookies
|
46
|
+
config.key?("cookies") ? config["cookies"] : true
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.legacy_participants
|
50
|
+
config["legacy_participants"]
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.precision
|
54
|
+
config["precision"] || 0
|
55
|
+
end
|
56
|
+
|
31
57
|
def self.events_supported?
|
32
58
|
unless defined?(@events_supported)
|
33
59
|
connection = FieldTest::Membership.connection
|
@@ -41,16 +67,25 @@ module FieldTest
|
|
41
67
|
end
|
42
68
|
@events_supported
|
43
69
|
end
|
44
|
-
end
|
45
70
|
|
46
|
-
|
47
|
-
|
71
|
+
def self.mask_ip(ip)
|
72
|
+
addr = IPAddr.new(ip)
|
73
|
+
if addr.ipv4?
|
74
|
+
# set last octet to 0
|
75
|
+
addr.mask(24).to_s
|
76
|
+
else
|
77
|
+
# set last 80 bits to zeros
|
78
|
+
addr.mask(48).to_s
|
79
|
+
end
|
80
|
+
end
|
48
81
|
end
|
49
82
|
|
50
|
-
ActiveSupport.on_load(:
|
51
|
-
|
83
|
+
ActiveSupport.on_load(:action_controller) do
|
84
|
+
require "field_test/controller"
|
85
|
+
include FieldTest::Controller
|
52
86
|
end
|
53
87
|
|
54
88
|
ActiveSupport.on_load(:action_mailer) do
|
55
|
-
|
89
|
+
require "field_test/mailer"
|
90
|
+
include FieldTest::Mailer
|
56
91
|
end
|
@@ -0,0 +1,76 @@
|
|
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
|
+
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
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def field_test_participant
|
31
|
+
participants = []
|
32
|
+
|
33
|
+
if respond_to?(:current_user, true)
|
34
|
+
user = send(:current_user)
|
35
|
+
participants << user if user
|
36
|
+
end
|
37
|
+
|
38
|
+
cookie_key = "field_test"
|
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
|
+
|
45
|
+
if request.headers["Field-Test-Visitor"]
|
46
|
+
token = request.headers["Field-Test-Visitor"]
|
47
|
+
elsif FieldTest.cookies && cookies_supported
|
48
|
+
token = cookies[cookie_key]
|
49
|
+
|
50
|
+
if participants.empty? && !token
|
51
|
+
token = SecureRandom.uuid
|
52
|
+
cookies[cookie_key] = {value: token, expires: 30.days.from_now}
|
53
|
+
end
|
54
|
+
elsif !FieldTest.cookies
|
55
|
+
# anonymity set
|
56
|
+
# note: hashing does not conceal input
|
57
|
+
token = Digest::UUID.uuid_v5(FieldTest::UUID_NAMESPACE, ["visitor", FieldTest.mask_ip(request.remote_ip), request.user_agent].join("/"))
|
58
|
+
|
59
|
+
# delete cookie if present
|
60
|
+
cookies.delete(cookie_key) if cookies_supported && cookies[cookie_key]
|
61
|
+
end
|
62
|
+
|
63
|
+
# sanitize tokens
|
64
|
+
token = token.gsub(/[^a-z0-9\-]/i, "") if token
|
65
|
+
|
66
|
+
if token.present?
|
67
|
+
participants << token
|
68
|
+
|
69
|
+
# backwards compatibility
|
70
|
+
participants << "cookie:#{token}" if FieldTest.legacy_participants
|
71
|
+
end
|
72
|
+
|
73
|
+
participants
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -10,52 +10,54 @@ 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
|
+
@goals_defined = !attributes[:goals].nil?
|
16
19
|
@use_events = attributes[:use_events]
|
17
20
|
end
|
18
21
|
|
19
22
|
def variant(participants, options = {})
|
20
|
-
return winner if winner
|
21
|
-
return
|
23
|
+
return winner if winner && !keep_variant?
|
24
|
+
return control if options[:exclude]
|
22
25
|
|
23
26
|
participants = FieldTest::Participant.standardize(participants)
|
24
27
|
check_participants(participants)
|
25
28
|
membership = membership_for(participants) || FieldTest::Membership.new(experiment: id)
|
26
29
|
|
30
|
+
if winner # and keep_variant?
|
31
|
+
return membership.variant || winner
|
32
|
+
end
|
33
|
+
|
27
34
|
if options[:variant] && variants.include?(options[:variant])
|
28
35
|
membership.variant = options[:variant]
|
29
36
|
else
|
30
|
-
membership.variant ||= weighted_variant
|
37
|
+
membership.variant ||= closed? ? control : weighted_variant
|
31
38
|
end
|
32
39
|
|
40
|
+
participant = participants.first
|
41
|
+
|
33
42
|
# upgrade to preferred participant
|
34
|
-
membership.participant =
|
43
|
+
membership.participant = participant.participant if membership.respond_to?(:participant=)
|
44
|
+
membership.participant_type = participant.type if membership.respond_to?(:participant_type=)
|
45
|
+
membership.participant_id = participant.id if membership.respond_to?(:participant_id=)
|
35
46
|
|
36
|
-
if membership.changed?
|
47
|
+
if membership.changed? && (!closed? || membership.persisted?)
|
37
48
|
begin
|
38
49
|
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
50
|
rescue ActiveRecord::RecordNotUnique
|
51
|
-
membership = memberships.find_by(participant
|
51
|
+
membership = memberships.find_by(participant.where_values)
|
52
52
|
end
|
53
53
|
end
|
54
54
|
|
55
|
-
membership.try(:variant) ||
|
55
|
+
membership.try(:variant) || control
|
56
56
|
end
|
57
57
|
|
58
58
|
def convert(participants, goal: nil)
|
59
|
+
return false if winner
|
60
|
+
|
59
61
|
goal ||= goals.first
|
60
62
|
|
61
63
|
participants = FieldTest::Participant.standardize(participants)
|
@@ -100,16 +102,29 @@ module FieldTest
|
|
100
102
|
relation = relation.where("field_test_memberships.created_at >= ?", started_at) if started_at
|
101
103
|
relation = relation.where("field_test_memberships.created_at <= ?", ended_at) if ended_at
|
102
104
|
|
103
|
-
if use_events?
|
105
|
+
if use_events? && @goals_defined
|
104
106
|
data = {}
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
107
|
+
|
108
|
+
participated = relation.count
|
109
|
+
|
110
|
+
adapter_name = relation.connection.adapter_name
|
111
|
+
column =
|
112
|
+
if FieldTest.legacy_participants
|
113
|
+
:participant
|
114
|
+
elsif adapter_name =~ /postg/i # postgres
|
115
|
+
"(participant_type, participant_id)"
|
116
|
+
elsif adapter_name =~ /mysql/i
|
117
|
+
"participant_type, participant_id"
|
118
|
+
else
|
119
|
+
# not perfect, but it'll do
|
120
|
+
"COALESCE(participant_type, '') || ':' || participant_id"
|
121
|
+
end
|
122
|
+
|
123
|
+
converted = events.merge(relation).where(field_test_events: {name: goal}).distinct.count(column)
|
124
|
+
|
125
|
+
(participated.keys + converted.keys).uniq.each do |variant|
|
126
|
+
data[[variant, true]] = converted[variant].to_i
|
127
|
+
data[[variant, false]] = participated[variant].to_i - converted[variant].to_i
|
113
128
|
end
|
114
129
|
else
|
115
130
|
data = relation.group(:converted).count
|
@@ -125,6 +140,7 @@ module FieldTest
|
|
125
140
|
conversion_rate: participated > 0 ? converted.to_f / participated : nil
|
126
141
|
}
|
127
142
|
end
|
143
|
+
|
128
144
|
case variants.size
|
129
145
|
when 1, 2, 3
|
130
146
|
total = 0.0
|
@@ -166,6 +182,18 @@ module FieldTest
|
|
166
182
|
!winner
|
167
183
|
end
|
168
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
|
+
|
169
197
|
def use_events?
|
170
198
|
if @use_events.nil?
|
171
199
|
FieldTest.events_supported?
|
@@ -189,32 +217,37 @@ module FieldTest
|
|
189
217
|
|
190
218
|
private
|
191
219
|
|
192
|
-
|
193
|
-
|
194
|
-
|
220
|
+
def check_participants(participants)
|
221
|
+
raise FieldTest::UnknownParticipant, "Use the :participant option to specify a participant" if participants.empty?
|
222
|
+
end
|
195
223
|
|
196
|
-
|
197
|
-
|
198
|
-
|
224
|
+
# TODO fetch in single query
|
225
|
+
def membership_for(participants)
|
226
|
+
membership = nil
|
227
|
+
participants.each do |participant|
|
228
|
+
membership = self.memberships.find_by(participant.where_values)
|
229
|
+
break if membership
|
199
230
|
end
|
231
|
+
membership
|
232
|
+
end
|
200
233
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
end
|
209
|
-
variants.last
|
234
|
+
def weighted_variant
|
235
|
+
total = weights.sum.to_f
|
236
|
+
pick = rand
|
237
|
+
n = 0
|
238
|
+
weights.map { |w| w / total }.each_with_index do |w, i|
|
239
|
+
n += w
|
240
|
+
return variants[i] if n >= pick
|
210
241
|
end
|
242
|
+
variants.last
|
243
|
+
end
|
211
244
|
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
end
|
245
|
+
def cache_fetch(key)
|
246
|
+
if FieldTest.cache
|
247
|
+
Rails.cache.fetch(key.join("/")) { yield }
|
248
|
+
else
|
249
|
+
yield
|
218
250
|
end
|
251
|
+
end
|
219
252
|
end
|
220
253
|
end
|