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.
- checksums.yaml +4 -4
- data/README.md +191 -293
- data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +1 -1
- data/app/views/trail_guide/admin/experiments/_header.html.erb +3 -3
- data/config/initializers/admin.rb +19 -0
- data/config/initializers/experiment.rb +261 -0
- data/config/initializers/trailguide.rb +6 -279
- data/lib/trail_guide/adapters.rb +2 -0
- data/lib/trail_guide/adapters/experiments.rb +8 -0
- data/lib/trail_guide/adapters/experiments/redis.rb +48 -0
- data/lib/trail_guide/adapters/participants/cookie.rb +1 -0
- data/lib/trail_guide/adapters/participants/unity.rb +9 -1
- data/lib/trail_guide/adapters/variants.rb +8 -0
- data/lib/trail_guide/adapters/variants/redis.rb +52 -0
- data/lib/trail_guide/admin/engine.rb +1 -0
- data/lib/trail_guide/algorithms.rb +4 -0
- data/lib/trail_guide/algorithms/algorithm.rb +29 -0
- data/lib/trail_guide/algorithms/bandit.rb +9 -18
- data/lib/trail_guide/algorithms/distributed.rb +8 -15
- data/lib/trail_guide/algorithms/random.rb +2 -12
- data/lib/trail_guide/algorithms/static.rb +34 -0
- data/lib/trail_guide/algorithms/weighted.rb +5 -17
- data/lib/trail_guide/catalog.rb +79 -35
- data/lib/trail_guide/config.rb +2 -4
- data/lib/trail_guide/engine.rb +2 -1
- data/lib/trail_guide/experiments/base.rb +41 -24
- data/lib/trail_guide/experiments/combined_config.rb +4 -0
- data/lib/trail_guide/experiments/config.rb +59 -30
- data/lib/trail_guide/experiments/participant.rb +4 -2
- data/lib/trail_guide/helper.rb +4 -216
- data/lib/trail_guide/helper/experiment_proxy.rb +160 -0
- data/lib/trail_guide/helper/helper_proxy.rb +62 -0
- data/lib/trail_guide/metrics/config.rb +2 -0
- data/lib/trail_guide/metrics/goal.rb +17 -15
- data/lib/trail_guide/participant.rb +10 -2
- data/lib/trail_guide/unity.rb +17 -8
- data/lib/trail_guide/variant.rb +15 -11
- data/lib/trail_guide/version.rb +2 -2
- metadata +13 -3
data/lib/trail_guide/config.rb
CHANGED
@@ -13,10 +13,8 @@ module TrailGuide
|
|
13
13
|
super(*args, **opts, &block)
|
14
14
|
end
|
15
15
|
|
16
|
-
def
|
17
|
-
|
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
|
data/lib/trail_guide/engine.rb
CHANGED
@@ -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 =
|
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 =
|
71
|
-
|
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 =
|
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 =
|
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 =
|
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 =
|
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 =
|
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 =
|
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
|
-
|
153
|
+
adapter.set(:winner, variant.name)
|
154
|
+
variant
|
144
155
|
end
|
145
156
|
|
146
157
|
def clear_winner!
|
147
|
-
|
158
|
+
adapter.delete(:winner)
|
148
159
|
end
|
149
160
|
|
150
161
|
def winner
|
151
|
-
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
|
-
|
170
|
+
adapter.exists?(:winner)
|
160
171
|
end
|
161
172
|
end
|
162
173
|
|
163
174
|
def persisted?
|
164
|
-
|
175
|
+
adapter.persisted?
|
165
176
|
end
|
166
177
|
|
167
178
|
def save!
|
168
|
-
|
179
|
+
combined_experiments.each(&:save!)
|
169
180
|
variants.each(&:save!)
|
170
|
-
|
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 =
|
187
|
+
deleted = adapter.destroy
|
177
188
|
run_callbacks(:on_delete, context)
|
178
|
-
|
189
|
+
true
|
179
190
|
end
|
180
191
|
|
181
192
|
def reset!(context=nil)
|
182
|
-
|
193
|
+
delete!(context)
|
194
|
+
save!
|
183
195
|
run_callbacks(:on_reset, context)
|
184
|
-
|
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
|
-
|
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
|
-
|
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)
|
@@ -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, :
|
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 =
|
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
|
62
|
-
!!
|
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
|
94
|
-
self[:
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
114
|
-
|
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
|
191
|
-
self.goals
|
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.
|
18
|
+
participant.participating!(variant) if experiment.configuration.sticky_assignment?
|
19
19
|
end
|
20
20
|
|
21
21
|
def converted?(checkpoint=nil)
|
22
22
|
@converted ||= {}
|
23
|
-
|
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)
|