field_test 0.2.4 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.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
@@ -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
  &lt; 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><%= 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 %>
@@ -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
  }
@@ -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
@@ -1,26 +1,40 @@
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
 
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
- # reload in dev
17
- @config = nil if Rails.env.development?
30
+ @config ||= YAML.load(ERB.new(File.read(config_path)).result)
31
+ end
18
32
 
19
- @config ||= YAML.load(ERB.new(File.read("config/field_test.yml")).result)
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
- ActiveSupport.on_load(:action_controller) do
47
- include FieldTest::Helpers
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(:action_view) do
51
- include FieldTest::Helpers
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
- include FieldTest::Helpers
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 variants.first if options[:exclude]
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 = participants.first
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: participants.first)
51
+ membership = memberships.find_by(participant.where_values)
52
52
  end
53
53
  end
54
54
 
55
- membership.try(:variant) || variants.first
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
- 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
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
- def check_participants(participants)
193
- raise FieldTest::UnknownParticipant, "Use the :participant option to specify a participant" if participants.empty?
194
- end
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
- def membership_for(participants)
197
- memberships = self.memberships.where(participant: participants).index_by(&:participant)
198
- participants.map { |part| memberships[part] }.compact.first
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
- 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
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
- def cache_fetch(key)
213
- if FieldTest.cache
214
- Rails.cache.fetch(key.join("/")) { yield }
215
- else
216
- yield
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