trailguide 0.2.1 → 0.3.0

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +191 -293
  3. data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +1 -1
  4. data/app/views/trail_guide/admin/experiments/_header.html.erb +3 -3
  5. data/config/initializers/admin.rb +19 -0
  6. data/config/initializers/experiment.rb +261 -0
  7. data/config/initializers/trailguide.rb +6 -279
  8. data/lib/trail_guide/adapters.rb +2 -0
  9. data/lib/trail_guide/adapters/experiments.rb +8 -0
  10. data/lib/trail_guide/adapters/experiments/redis.rb +48 -0
  11. data/lib/trail_guide/adapters/participants/cookie.rb +1 -0
  12. data/lib/trail_guide/adapters/participants/unity.rb +9 -1
  13. data/lib/trail_guide/adapters/variants.rb +8 -0
  14. data/lib/trail_guide/adapters/variants/redis.rb +52 -0
  15. data/lib/trail_guide/admin/engine.rb +1 -0
  16. data/lib/trail_guide/algorithms.rb +4 -0
  17. data/lib/trail_guide/algorithms/algorithm.rb +29 -0
  18. data/lib/trail_guide/algorithms/bandit.rb +9 -18
  19. data/lib/trail_guide/algorithms/distributed.rb +8 -15
  20. data/lib/trail_guide/algorithms/random.rb +2 -12
  21. data/lib/trail_guide/algorithms/static.rb +34 -0
  22. data/lib/trail_guide/algorithms/weighted.rb +5 -17
  23. data/lib/trail_guide/catalog.rb +79 -35
  24. data/lib/trail_guide/config.rb +2 -4
  25. data/lib/trail_guide/engine.rb +2 -1
  26. data/lib/trail_guide/experiments/base.rb +41 -24
  27. data/lib/trail_guide/experiments/combined_config.rb +4 -0
  28. data/lib/trail_guide/experiments/config.rb +59 -30
  29. data/lib/trail_guide/experiments/participant.rb +4 -2
  30. data/lib/trail_guide/helper.rb +4 -216
  31. data/lib/trail_guide/helper/experiment_proxy.rb +160 -0
  32. data/lib/trail_guide/helper/helper_proxy.rb +62 -0
  33. data/lib/trail_guide/metrics/config.rb +2 -0
  34. data/lib/trail_guide/metrics/goal.rb +17 -15
  35. data/lib/trail_guide/participant.rb +10 -2
  36. data/lib/trail_guide/unity.rb +17 -8
  37. data/lib/trail_guide/variant.rb +15 -11
  38. data/lib/trail_guide/version.rb +2 -2
  39. metadata +13 -3
@@ -13,10 +13,8 @@ module TrailGuide
13
13
  super(*args, **opts, &block)
14
14
  end
15
15
 
16
- def configure(*args, &block)
17
- super(*args) do |config|
18
- yield(config, TrailGuide::Experiment.configuration) if block_given?
19
- end
16
+ def paths
17
+ @paths ||= Struct.new(:configs, :classes).new([],[])
20
18
  end
21
19
 
22
20
  def redis
@@ -7,9 +7,10 @@ module TrailGuide
7
7
  end
8
8
 
9
9
  paths["config/routes.rb"] = "config/routes/engine.rb"
10
+ paths["config/initializers"] = ["config/initializers/trailguide.rb", "config/initializers/experiment.rb"]
10
11
 
11
12
  initializer "trailguide" do |app|
12
- TrailGuide::Catalog.load_experiments!
13
+ TrailGuide::Catalog.load_experiments!(**TrailGuide.configuration.paths.to_h)
13
14
  if TrailGuide.configuration.include_helpers
14
15
  ActionController::Base.send :include, TrailGuide::Helper
15
16
  ActionController::Base.helper TrailGuide::Helper
@@ -23,6 +23,11 @@ module TrailGuide
23
23
  configuration.configure(*args, &block)
24
24
  end
25
25
 
26
+ def adapter
27
+ @adapter ||= TrailGuide::Adapters::Experiments::Redis.new(self)
28
+ end
29
+
30
+ # TODO alias name once specs have solid coverage
26
31
  def experiment_name
27
32
  configuration.name
28
33
  end
@@ -45,7 +50,7 @@ module TrailGuide
45
50
 
46
51
  def run_callbacks(hook, *args)
47
52
  return unless callbacks[hook]
48
- return args[0] if hook == :rollout_winner
53
+ return args[0] if hook == :rollout_winner # TODO do we need to account for this case here at the class level?
49
54
  args.unshift(self)
50
55
  callbacks[hook].each do |callback|
51
56
  if callback.respond_to?(:call)
@@ -59,7 +64,7 @@ module TrailGuide
59
64
  def start!(context=nil)
60
65
  return false if started?
61
66
  save! unless persisted?
62
- started = TrailGuide.redis.hset(storage_key, 'started_at', Time.now.to_i)
67
+ started = adapter.set(:started_at, Time.now.to_i)
63
68
  run_callbacks(:on_start, context)
64
69
  started
65
70
  end
@@ -67,45 +72,45 @@ module TrailGuide
67
72
  def schedule!(start_at, stop_at=nil, context=nil)
68
73
  return false if started?
69
74
  save! unless persisted?
70
- scheduled = TrailGuide.redis.hset(storage_key, 'started_at', start_at.to_i)
71
- TrailGuide.redis.hset(storage_key, 'stopped_at', stop_at.to_i) if stop_at
75
+ scheduled = adapter.set(:started_at, start_at.to_i)
76
+ adapter.set(:stopped_at, stop_at.to_i) if stop_at
72
77
  run_callbacks(:on_schedule, start_at, stop_at, context)
73
78
  scheduled
74
79
  end
75
80
 
76
81
  def pause!(context=nil)
77
82
  return false unless running? && configuration.can_resume?
78
- paused = TrailGuide.redis.hset(storage_key, 'paused_at', Time.now.to_i)
83
+ paused = adapter.set(:paused_at, Time.now.to_i)
79
84
  run_callbacks(:on_pause, context)
80
85
  paused
81
86
  end
82
87
 
83
88
  def stop!(context=nil)
84
89
  return false unless started? && !stopped?
85
- stopped = TrailGuide.redis.hset(storage_key, 'stopped_at', Time.now.to_i)
90
+ stopped = adapter.set(:stopped_at, Time.now.to_i)
86
91
  run_callbacks(:on_stop, context)
87
92
  stopped
88
93
  end
89
94
 
90
95
  def resume!(context=nil)
91
96
  return false unless paused? && configuration.can_resume?
92
- resumed = TrailGuide.redis.hdel(storage_key, 'paused_at')
97
+ resumed = adapter.delete(:paused_at)
93
98
  run_callbacks(:on_resume, context)
94
- resumed
99
+ !!resumed
95
100
  end
96
101
 
97
102
  def started_at
98
- started = TrailGuide.redis.hget(storage_key, 'started_at')
103
+ started = adapter.get(:started_at)
99
104
  return Time.at(started.to_i) if started
100
105
  end
101
106
 
102
107
  def paused_at
103
- paused = TrailGuide.redis.hget(storage_key, 'paused_at')
108
+ paused = adapter.get(:paused_at)
104
109
  return Time.at(paused.to_i) if paused
105
110
  end
106
111
 
107
112
  def stopped_at
108
- stopped = TrailGuide.redis.hget(storage_key, 'stopped_at')
113
+ stopped = adapter.get(:stopped_at)
109
114
  return Time.at(stopped.to_i) if stopped
110
115
  end
111
116
 
@@ -137,18 +142,24 @@ module TrailGuide
137
142
  enable_calibration? && start_manually? && !started?
138
143
  end
139
144
 
145
+ def fresh?
146
+ !started? && !scheduled? && !winner?
147
+ end
148
+
140
149
  def declare_winner!(variant, context=nil)
141
150
  variant = variants.find { |var| var == variant } unless variant.is_a?(Variant)
151
+ return false unless variant.present? && variant.experiment == self
142
152
  run_callbacks(:on_winner, variant, context)
143
- TrailGuide.redis.hset(storage_key, 'winner', variant.name.to_s.underscore)
153
+ adapter.set(:winner, variant.name)
154
+ variant
144
155
  end
145
156
 
146
157
  def clear_winner!
147
- TrailGuide.redis.hdel(storage_key, 'winner')
158
+ adapter.delete(:winner)
148
159
  end
149
160
 
150
161
  def winner
151
- winner = TrailGuide.redis.hget(storage_key, 'winner')
162
+ winner = adapter.get(:winner)
152
163
  return variants.find { |var| var == winner } if winner
153
164
  end
154
165
 
@@ -156,32 +167,33 @@ module TrailGuide
156
167
  if combined?
157
168
  combined.all? { |combo| TrailGuide.catalog.find(combo).winner? }
158
169
  else
159
- TrailGuide.redis.hexists(storage_key, 'winner')
170
+ adapter.exists?(:winner)
160
171
  end
161
172
  end
162
173
 
163
174
  def persisted?
164
- TrailGuide.redis.exists(storage_key)
175
+ adapter.persisted?
165
176
  end
166
177
 
167
178
  def save!
168
- combined.each { |combo| TrailGuide.catalog.find(combo).save! }
179
+ combined_experiments.each(&:save!)
169
180
  variants.each(&:save!)
170
- TrailGuide.redis.hsetnx(storage_key, 'name', experiment_name)
181
+ adapter.setnx(:name, experiment_name)
171
182
  end
172
183
 
173
184
  def delete!(context=nil)
174
185
  combined.each { |combo| TrailGuide.catalog.find(combo).delete! }
175
186
  variants.each(&:delete!)
176
- deleted = TrailGuide.redis.del(storage_key)
187
+ deleted = adapter.destroy
177
188
  run_callbacks(:on_delete, context)
178
- deleted
189
+ true
179
190
  end
180
191
 
181
192
  def reset!(context=nil)
182
- reset = (delete! && save!)
193
+ delete!(context)
194
+ save!
183
195
  run_callbacks(:on_reset, context)
184
- reset
196
+ true
185
197
  end
186
198
 
187
199
  def participants
@@ -233,7 +245,8 @@ module TrailGuide
233
245
  end
234
246
 
235
247
  def winning_variant
236
- run_callbacks(:rollout_winner, self.class.winner, participant)
248
+ return nil unless winner?
249
+ run_callbacks(:rollout_winner, winner, participant)
237
250
  end
238
251
 
239
252
  def choose!(override: nil, metadata: nil, **opts)
@@ -293,7 +306,10 @@ module TrailGuide
293
306
  start! unless started? || scheduled?
294
307
  return control unless running?
295
308
 
296
- if participant.participating?
309
+ # only re-use the variant for experiments that store participation,
310
+ # all other (i.e. content-based) experiments should re-select and
311
+ # re-assign on enrollment
312
+ if configuration.sticky_assignment? && participant.participating?
297
313
  variant = participant.variant
298
314
  participant.participating!(variant)
299
315
  return variant
@@ -369,6 +385,7 @@ module TrailGuide
369
385
  def allow_conversion?(variant, checkpoint=nil, metadata=nil)
370
386
  if checkpoint.nil?
371
387
  return true if callbacks[:allow_conversion].empty?
388
+ # TODO why pass checkpoint through here if checkpoints are handled by their own method? it will always be nil here given current logic
372
389
  run_callbacks(:allow_conversion, true, checkpoint, variant, participant, metadata)
373
390
  else
374
391
  checkpoint.allow_conversion?(self, variant, metadata)
@@ -7,6 +7,10 @@ module TrailGuide
7
7
  args.push(:parent)
8
8
  super(experiment, *args, **opts, &block)
9
9
  end
10
+
11
+ def parent
12
+ self[:parent]
13
+ end
10
14
  end
11
15
  end
12
16
  end
@@ -3,7 +3,7 @@ module TrailGuide
3
3
  class Config < Canfig::Config
4
4
  DEFAULT_KEYS = [
5
5
  :name, :summary, :preview_url, :algorithm, :groups, :variants, :goals,
6
- :start_manually, :reset_manually, :store_participation, :store_override,
6
+ :start_manually, :reset_manually, :sticky_assignment, :store_override,
7
7
  :track_override, :combined, :allow_multiple_conversions,
8
8
  :allow_multiple_goals, :track_winner_conversions, :skip_request_filter,
9
9
  :target_sample_size, :can_resume, :enable_calibration
@@ -36,7 +36,7 @@ module TrailGuide
36
36
 
37
37
  def initialize(experiment, *args, **opts, &block)
38
38
  @experiment = experiment
39
- opts = opts.merge(default_config)
39
+ opts = default_config.merge(opts)
40
40
  ancestor = opts.delete(:inherit)
41
41
  if ancestor.present?
42
42
  keys = opts.keys.dup.concat(args).concat(DEFAULT_KEYS).concat(CALLBACK_KEYS).uniq
@@ -58,8 +58,8 @@ module TrailGuide
58
58
  !!reset_manually
59
59
  end
60
60
 
61
- def store_participation?
62
- !!store_participation
61
+ def sticky_assignment?
62
+ !!sticky_assignment
63
63
  end
64
64
 
65
65
  def allow_multiple_conversions?
@@ -90,33 +90,16 @@ module TrailGuide
90
90
  @name ||= (self[:name] || experiment.name).try(:to_s).try(:underscore).try(:to_sym)
91
91
  end
92
92
 
93
- def groups(*grps)
94
- self[:groups] ||= []
95
- unless grps.empty?
96
- self[:groups] = self[:groups].concat([grps].flatten.map { |g| g.to_s.underscore.to_sym })
97
- end
98
- self[:groups]
99
- end
100
-
101
- def groups=(*grps)
102
- self[:groups] = [grps].flatten.map { |g| g.to_s.underscore.to_sym }
103
- end
104
-
105
- def group(grp=nil)
106
- unless grp.nil?
107
- groups << grp.to_s.underscore.to_sym
108
- return groups.last
93
+ def algorithm
94
+ if self[:algorithm].is_a?(Array) && self[:algorithm].last.respond_to?(:call)
95
+ @algorithm ||= TrailGuide::Algorithms.algorithm(self[:algorithm].first).new(&self[:algorithm].last)
96
+ else
97
+ @algorithm ||= TrailGuide::Algorithms.algorithm(self[:algorithm])
109
98
  end
110
- groups.first
111
99
  end
112
100
 
113
- def group=(grp)
114
- groups.unshift(grp.to_s.underscore.to_sym)
115
- groups.first
116
- end
117
-
118
- def algorithm
119
- @algorithm ||= TrailGuide::Algorithms.algorithm(self[:algorithm])
101
+ def variants
102
+ self[:variants]
120
103
  end
121
104
 
122
105
  def variant(varname, metadata: {}, weight: 1, control: false)
@@ -147,6 +130,32 @@ module TrailGuide
147
130
  variant
148
131
  end
149
132
 
133
+ def groups(*grps)
134
+ self[:groups] ||= []
135
+ self[:groups] = self[:groups].map { |g| g.to_s.underscore.to_sym }
136
+ unless grps.empty?
137
+ self[:groups] = self[:groups].concat([grps].flatten.map { |g| g.to_s.underscore.to_sym })
138
+ end
139
+ self[:groups]
140
+ end
141
+
142
+ def groups=(*grps)
143
+ self[:groups] = [grps].flatten.map { |g| g.to_s.underscore.to_sym }
144
+ end
145
+
146
+ def group(grp=nil)
147
+ unless grp.nil?
148
+ groups << grp.to_s.underscore.to_sym
149
+ return groups.last
150
+ end
151
+ groups.first
152
+ end
153
+
154
+ def group=(grp)
155
+ groups.unshift(grp.to_s.underscore.to_sym)
156
+ groups.first
157
+ end
158
+
150
159
  def goal(name, **config, &block)
151
160
  goals << Metrics::Goal.new(experiment, name, **config, &block)
152
161
  end
@@ -164,6 +173,7 @@ module TrailGuide
164
173
 
165
174
  def goals(*names, **config, &block)
166
175
  self[:goals] ||= []
176
+ self[:goals] = self[:goals].map { |g| g.is_a?(Metrics::Goal) ? g : Metrics::Goal.new(experiment, g) }
167
177
  unless names.empty?
168
178
  self[:goals] = self[:goals].concat([names].flatten.map { |g| Metrics::Goal.new(experiment, g, **config, &block) })
169
179
  end
@@ -187,8 +197,12 @@ module TrailGuide
187
197
  end
188
198
 
189
199
  def metrics=(*names)
190
- self.groups = *names
191
- self.goals = *names
200
+ self.send(:groups=, *names.flatten)
201
+ self.send(:goals=, *names.flatten)
202
+ end
203
+
204
+ def combined
205
+ self[:combined]
192
206
  end
193
207
 
194
208
  def combined?
@@ -204,76 +218,91 @@ module TrailGuide
204
218
  end
205
219
 
206
220
  def on_choose(meth=nil, &block)
221
+ raise ArgumentError if meth.nil? && !block_given?
207
222
  self[:on_choose] ||= []
208
223
  self[:on_choose] << (meth || block)
209
224
  end
210
225
 
211
226
  def on_use(meth=nil, &block)
227
+ raise ArgumentError if meth.nil? && !block_given?
212
228
  self[:on_use] ||= []
213
229
  self[:on_use] << (meth || block)
214
230
  end
215
231
 
216
232
  def on_convert(meth=nil, &block)
233
+ raise ArgumentError if meth.nil? && !block_given?
217
234
  self[:on_convert] ||= []
218
235
  self[:on_convert] << (meth || block)
219
236
  end
220
237
 
221
238
  def on_start(meth=nil, &block)
239
+ raise ArgumentError if meth.nil? && !block_given?
222
240
  self[:on_start] ||= []
223
241
  self[:on_start] << (meth || block)
224
242
  end
225
243
 
226
244
  def on_schedule(meth=nil, &block)
245
+ raise ArgumentError if meth.nil? && !block_given?
227
246
  self[:on_schedule] ||= []
228
247
  self[:on_schedule] << (meth || block)
229
248
  end
230
249
 
231
250
  def on_stop(meth=nil, &block)
251
+ raise ArgumentError if meth.nil? && !block_given?
232
252
  self[:on_stop] ||= []
233
253
  self[:on_stop] << (meth || block)
234
254
  end
235
255
 
236
256
  def on_pause(meth=nil, &block)
257
+ raise ArgumentError if meth.nil? && !block_given?
237
258
  self[:on_pause] ||= []
238
259
  self[:on_pause] << (meth || block)
239
260
  end
240
261
 
241
262
  def on_resume(meth=nil, &block)
263
+ raise ArgumentError if meth.nil? && !block_given?
242
264
  self[:on_resume] ||= []
243
265
  self[:on_resume] << (meth || block)
244
266
  end
245
267
 
246
268
  def on_winner(meth=nil, &block)
269
+ raise ArgumentError if meth.nil? && !block_given?
247
270
  self[:on_winner] ||= []
248
271
  self[:on_winner] << (meth || block)
249
272
  end
250
273
 
251
274
  def on_reset(meth=nil, &block)
275
+ raise ArgumentError if meth.nil? && !block_given?
252
276
  self[:on_reset] ||= []
253
277
  self[:on_reset] << (meth || block)
254
278
  end
255
279
 
256
280
  def on_delete(meth=nil, &block)
281
+ raise ArgumentError if meth.nil? && !block_given?
257
282
  self[:on_delete] ||= []
258
283
  self[:on_delete] << (meth || block)
259
284
  end
260
285
 
261
286
  def on_redis_failover(meth=nil, &block)
287
+ raise ArgumentError if meth.nil? && !block_given?
262
288
  self[:on_redis_failover] ||= []
263
289
  self[:on_redis_failover] << (meth || block)
264
290
  end
265
291
 
266
292
  def allow_participation(meth=nil, &block)
293
+ raise ArgumentError if meth.nil? && !block_given?
267
294
  self[:allow_participation] ||= []
268
295
  self[:allow_participation] << (meth || block)
269
296
  end
270
297
 
271
298
  def allow_conversion(meth=nil, &block)
299
+ raise ArgumentError if meth.nil? && !block_given?
272
300
  self[:allow_conversion] ||= []
273
301
  self[:allow_conversion] << (meth || block)
274
302
  end
275
303
 
276
304
  def rollout_winner(meth=nil, &block)
305
+ raise ArgumentError if meth.nil? && !block_given?
277
306
  self[:rollout_winner] ||= []
278
307
  self[:rollout_winner] << (meth || block)
279
308
  end
@@ -15,12 +15,14 @@ module TrailGuide
15
15
  def participating!(variant)
16
16
  @participating = true
17
17
  @variant = variant
18
- participant.participating!(variant) if experiment.configuration.store_participation?
18
+ participant.participating!(variant) if experiment.configuration.sticky_assignment?
19
19
  end
20
20
 
21
21
  def converted?(checkpoint=nil)
22
22
  @converted ||= {}
23
- @converted[checkpoint || :converted] ||= participant.converted?(experiment, checkpoint)
23
+ converted_key = checkpoint || :converted
24
+ @converted[converted_key] = participant.converted?(experiment, checkpoint) unless @converted.key?(converted_key)
25
+ @converted[converted_key]
24
26
  end
25
27
 
26
28
  def converted!(variant, checkpoint, reset: false)