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