trailguide 0.1.31 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +73 -8
- data/app/assets/javascripts/trail_guide/admin/application.js +20 -1
- data/app/assets/stylesheets/trail_guide/admin/application.css +22 -0
- data/app/assets/stylesheets/trail_guide/admin/experiments.css +36 -3
- data/app/controllers/trail_guide/admin/application_controller.rb +59 -8
- data/app/controllers/trail_guide/admin/experiments_controller.rb +209 -16
- data/app/controllers/trail_guide/admin/groups_controller.rb +34 -0
- data/app/controllers/trail_guide/experiments_controller.rb +1 -1
- data/app/views/layouts/trail_guide/admin/_calculator.erb +24 -0
- data/app/views/layouts/trail_guide/admin/_footer.html.erb +27 -0
- data/app/views/layouts/trail_guide/admin/_header.html.erb +147 -0
- data/app/views/layouts/trail_guide/admin/_import_modal.html.erb +45 -0
- data/app/views/layouts/trail_guide/admin/application.html.erb +17 -3
- data/app/views/trail_guide/admin/experiments/_alert_peek.html.erb +19 -0
- data/app/views/trail_guide/admin/experiments/_alert_state.html.erb +49 -0
- data/app/views/trail_guide/admin/experiments/_btn_analyze.html.erb +11 -0
- data/app/views/trail_guide/admin/experiments/_btn_analyze_goal.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_convert.html.erb +33 -0
- data/app/views/trail_guide/admin/experiments/_btn_enroll.html.erb +3 -0
- data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +11 -0
- data/app/views/trail_guide/admin/experiments/_btn_leave.html.erb +7 -0
- data/app/views/trail_guide/admin/experiments/_btn_pause.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_peek.html.erb +13 -0
- data/app/views/trail_guide/admin/experiments/_btn_reset.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_restart.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_resume.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_schedule.html.erb +6 -0
- data/app/views/trail_guide/admin/experiments/_btn_start.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_stop.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_experiment.html.erb +68 -172
- data/app/views/trail_guide/admin/experiments/_header.html.erb +87 -0
- data/app/views/trail_guide/admin/experiments/_start_modal.html.erb +57 -0
- data/app/views/trail_guide/admin/experiments/_tbody.html.erb +112 -0
- data/app/views/trail_guide/admin/experiments/_thead.html.erb +38 -0
- data/app/views/trail_guide/admin/experiments/index.html.erb +8 -16
- data/app/views/trail_guide/admin/experiments/show.html.erb +78 -0
- data/app/views/trail_guide/admin/groups/index.html.erb +40 -0
- data/app/views/trail_guide/admin/groups/show.html.erb +9 -0
- data/app/views/trail_guide/admin/orphans/_alert.html.erb +44 -0
- data/config/initializers/trailguide.rb +72 -9
- data/config/routes/admin.rb +19 -12
- data/lib/trail_guide/admin/engine.rb +2 -0
- data/lib/trail_guide/admin.rb +2 -0
- data/lib/trail_guide/calculators/bayesian.rb +98 -0
- data/lib/trail_guide/calculators/calculator.rb +58 -0
- data/lib/trail_guide/calculators/score.rb +68 -0
- data/lib/trail_guide/calculators.rb +8 -0
- data/lib/trail_guide/catalog.rb +134 -19
- data/lib/trail_guide/combined_experiment.rb +8 -3
- data/lib/trail_guide/config.rb +6 -1
- data/lib/trail_guide/engine.rb +2 -0
- data/lib/trail_guide/errors.rb +30 -1
- data/lib/trail_guide/experiment.rb +0 -1
- data/lib/trail_guide/experiments/base.rb +189 -53
- data/lib/trail_guide/experiments/config.rb +82 -13
- data/lib/trail_guide/experiments/participant.rb +21 -1
- data/lib/trail_guide/helper.rb +59 -32
- data/lib/trail_guide/metrics/checkpoint.rb +24 -0
- data/lib/trail_guide/metrics/config.rb +45 -0
- data/lib/trail_guide/metrics/funnel.rb +24 -0
- data/lib/trail_guide/metrics/goal.rb +89 -0
- data/lib/trail_guide/metrics.rb +9 -0
- data/lib/trail_guide/participant.rb +54 -21
- data/lib/trail_guide/variant.rb +34 -12
- data/lib/trail_guide/version.rb +2 -2
- data/lib/trailguide.rb +4 -0
- metadata +112 -7
- data/app/views/layouts/trail_guide/admin/_footer.erb +0 -10
- data/app/views/layouts/trail_guide/admin/_header.erb +0 -42
- data/app/views/trail_guide/admin/experiments/_combined_experiment.html.erb +0 -189
- data/config/routes.rb +0 -5
data/lib/trail_guide/helper.rb
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
module TrailGuide
|
2
2
|
module Helper
|
3
|
-
def trailguide(
|
3
|
+
def trailguide(key=nil, **opts, &block)
|
4
4
|
@trailguide_proxy ||= HelperProxy.new(self)
|
5
5
|
@trailguide_proxy = HelperProxy.new(self) if @trailguide_proxy.context != self
|
6
|
-
return @trailguide_proxy if
|
7
|
-
@trailguide_proxy.choose!(
|
6
|
+
return @trailguide_proxy if key.nil?
|
7
|
+
@trailguide_proxy.choose!(key, **opts, &block)
|
8
8
|
end
|
9
9
|
|
10
10
|
def trailguide_participant
|
@@ -70,42 +70,42 @@ module TrailGuide
|
|
70
70
|
@participant = participant
|
71
71
|
end
|
72
72
|
|
73
|
-
def new(
|
74
|
-
|
73
|
+
def new(key)
|
74
|
+
ExperimentProxy.new(context, key, participant: participant)
|
75
75
|
end
|
76
76
|
|
77
|
-
def choose!(
|
78
|
-
new(
|
77
|
+
def choose!(key, **opts, &block)
|
78
|
+
new(key).choose!(**opts, &block)
|
79
79
|
end
|
80
80
|
alias_method :enroll!, :choose!
|
81
81
|
|
82
|
-
def choose(
|
83
|
-
new(
|
82
|
+
def choose(key, **opts, &block)
|
83
|
+
new(key).choose(**opts, &block)
|
84
84
|
end
|
85
85
|
alias_method :enroll, :choose
|
86
86
|
|
87
|
-
def run!(
|
88
|
-
new(
|
87
|
+
def run!(key, **opts)
|
88
|
+
new(key).run!(**opts)
|
89
89
|
end
|
90
90
|
|
91
|
-
def run(
|
92
|
-
new(
|
91
|
+
def run(key, **opts)
|
92
|
+
new(key).run(**opts)
|
93
93
|
end
|
94
94
|
|
95
|
-
def render!(
|
96
|
-
new(
|
95
|
+
def render!(key, **opts)
|
96
|
+
new(key).render!(**opts)
|
97
97
|
end
|
98
98
|
|
99
|
-
def render(
|
100
|
-
new(
|
99
|
+
def render(key, **opts)
|
100
|
+
new(key).render(**opts)
|
101
101
|
end
|
102
102
|
|
103
|
-
def convert!(
|
104
|
-
new(
|
103
|
+
def convert!(key, checkpoint=nil, **opts, &block)
|
104
|
+
new(key).convert!(checkpoint, **opts, &block)
|
105
105
|
end
|
106
106
|
|
107
|
-
def convert(
|
108
|
-
new(
|
107
|
+
def convert(key, checkpoint=nil, **opts, &block)
|
108
|
+
new(key).convert(checkpoint, **opts, &block)
|
109
109
|
end
|
110
110
|
|
111
111
|
def participant
|
@@ -121,17 +121,18 @@ module TrailGuide
|
|
121
121
|
end
|
122
122
|
end
|
123
123
|
|
124
|
-
class
|
125
|
-
attr_reader :
|
124
|
+
class ExperimentProxy < HelperProxy
|
125
|
+
attr_reader :key
|
126
126
|
|
127
|
-
def initialize(context,
|
127
|
+
def initialize(context, key, **opts)
|
128
128
|
super(context, **opts)
|
129
|
-
@
|
130
|
-
raise NoExperimentsError, "Could not find any experiments matching `#{metric}`." if experiments.empty?
|
129
|
+
@key = key.to_s.underscore.to_sym
|
131
130
|
end
|
132
131
|
|
133
132
|
def choose!(**opts, &block)
|
134
|
-
raise
|
133
|
+
raise NoExperimentsError, key if experiments.empty?
|
134
|
+
raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` matches more than one experiment." if experiments.length > 1
|
135
|
+
raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` refers to a combined experiment." if experiment.combined?
|
135
136
|
opts = {override: override_variant, excluded: exclude_visitor?}.merge(opts)
|
136
137
|
variant = experiment.choose!(**opts)
|
137
138
|
if block_given?
|
@@ -144,6 +145,8 @@ module TrailGuide
|
|
144
145
|
|
145
146
|
def choose(**opts, &block)
|
146
147
|
choose!(**opts, &block)
|
148
|
+
rescue NoExperimentsError => e
|
149
|
+
raise e
|
147
150
|
rescue => e
|
148
151
|
TrailGuide.logger.error e
|
149
152
|
experiment.control
|
@@ -158,13 +161,13 @@ module TrailGuide
|
|
158
161
|
unless context.respond_to?(varmeth, true)
|
159
162
|
if context_type == :controller
|
160
163
|
raise NoVariantMethodError,
|
161
|
-
"Undefined local method `#{varmeth}`. You must define a controller method matching the variant `#{variant.name}` in your experiment `#{
|
164
|
+
"Undefined local method `#{varmeth}`. You must define a controller method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
162
165
|
elsif context_type == :template
|
163
166
|
raise NoVariantMethodError,
|
164
|
-
"Undefined local method `#{varmeth}`. You must define a helper method matching the variant `#{variant.name}` in your experiment `#{
|
167
|
+
"Undefined local method `#{varmeth}`. You must define a helper method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define ApplicationHelper##{varmeth}(metadata={})"
|
165
168
|
else
|
166
169
|
raise NoVariantMethodError,
|
167
|
-
"Undefined local method `#{varmeth}`. You must define a method matching the variant `#{variant.name}` in your experiment `#{
|
170
|
+
"Undefined local method `#{varmeth}`. You must define a method matching the variant `#{variant.name}` in your experiment `#{key}`. In this case it looks like you need to define #{context.class.name}##{varmeth}(metadata={})"
|
168
171
|
end
|
169
172
|
end
|
170
173
|
|
@@ -181,6 +184,8 @@ module TrailGuide
|
|
181
184
|
|
182
185
|
def run(methods: nil, **opts)
|
183
186
|
run!(methods: methods, **opts)
|
187
|
+
rescue NoExperimentsError => e
|
188
|
+
raise e
|
184
189
|
rescue => e
|
185
190
|
TrailGuide.logger.error e
|
186
191
|
false
|
@@ -202,19 +207,41 @@ module TrailGuide
|
|
202
207
|
|
203
208
|
def render(prefix: nil, templates: nil, locals: {}, **opts)
|
204
209
|
render!(prefix: prefix, templates: templates, locals: locals, **opts)
|
210
|
+
rescue NoExperimentsError => e
|
211
|
+
raise e
|
205
212
|
rescue => e
|
206
213
|
TrailGuide.logger.error e
|
207
214
|
false
|
208
215
|
end
|
209
216
|
|
210
217
|
def convert!(checkpoint=nil, **opts, &block)
|
211
|
-
|
218
|
+
raise NoExperimentsError, key if experiments.empty?
|
219
|
+
checkpoints = experiments.map do |experiment|
|
220
|
+
ckpt = checkpoint || experiment.goals.find { |g| g == key }
|
221
|
+
if experiment.combined?
|
222
|
+
experiment.combined_experiments.map do |combo|
|
223
|
+
combo.convert!(ckpt, **opts)
|
224
|
+
end
|
225
|
+
else
|
226
|
+
experiment.convert!(ckpt, **opts)
|
227
|
+
end
|
228
|
+
end.flatten
|
229
|
+
|
212
230
|
return false unless checkpoints.any?
|
231
|
+
|
213
232
|
if block_given?
|
214
233
|
yield checkpoints, opts[:metadata]
|
215
234
|
else
|
216
235
|
checkpoints
|
217
236
|
end
|
237
|
+
rescue NoExperimentsError => e
|
238
|
+
unless TrailGuide.configuration.ignore_orphaned_groups?
|
239
|
+
trace = e.backtrace.find { |t| !t.match?(Regexp.new(__FILE__)) }
|
240
|
+
.to_s.split(Rails.root.to_s).last
|
241
|
+
.split(':').first(2).join(':')
|
242
|
+
TrailGuide.catalog.orphaned(key, trace)
|
243
|
+
end
|
244
|
+
false
|
218
245
|
end
|
219
246
|
|
220
247
|
def convert(checkpoint=nil, **opts, &block)
|
@@ -225,7 +252,7 @@ module TrailGuide
|
|
225
252
|
end
|
226
253
|
|
227
254
|
def experiments
|
228
|
-
@experiments ||= TrailGuide.catalog.select(
|
255
|
+
@experiments ||= TrailGuide.catalog.select(key).map do |experiment|
|
229
256
|
experiment.new(participant)
|
230
257
|
end
|
231
258
|
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Metrics
|
3
|
+
# represents a checkpoint in a funnel
|
4
|
+
# TODO this is mostly a placeholder for now
|
5
|
+
class Checkpoint < Goal
|
6
|
+
attr_reader :experiment, :name
|
7
|
+
|
8
|
+
def dup(experiment)
|
9
|
+
self.class.new(experiment, name)
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(experiment, name, checkpoints=[])
|
13
|
+
@experiment = experiment
|
14
|
+
@name = name.to_s.underscore.to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
def as_json(opts={})
|
18
|
+
{
|
19
|
+
name: name,
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Metrics
|
3
|
+
class Config < Canfig::Config
|
4
|
+
attr_reader :metric
|
5
|
+
|
6
|
+
INHERIT_KEYS = [ :allow_multiple_conversions, :allow_conversion, :on_convert ]
|
7
|
+
CALLBACK_KEYS = [ :allow_conversion, :on_convert ]
|
8
|
+
|
9
|
+
def initialize(metric, *args, **opts, &block)
|
10
|
+
@metric = metric
|
11
|
+
|
12
|
+
opts = INHERIT_KEYS.map do |key|
|
13
|
+
val = experiment.configuration[key]
|
14
|
+
val = val.dup if val
|
15
|
+
[ key, val ]
|
16
|
+
end.to_h.merge(opts)
|
17
|
+
|
18
|
+
super(*args, **opts, &block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def experiment
|
22
|
+
metric.experiment
|
23
|
+
end
|
24
|
+
|
25
|
+
def callbacks
|
26
|
+
to_h.slice(*CALLBACK_KEYS).map { |k,v| [k, [v].flatten.compact] }.to_h
|
27
|
+
end
|
28
|
+
|
29
|
+
def allow_multiple_conversions?
|
30
|
+
!!allow_multiple_conversions
|
31
|
+
end
|
32
|
+
|
33
|
+
# TODO do we allow a method here? do we call it on the experiment?
|
34
|
+
def allow_conversion(meth=nil, &block)
|
35
|
+
self[:allow_conversion] ||= []
|
36
|
+
self[:allow_conversion] << (meth || block)
|
37
|
+
end
|
38
|
+
|
39
|
+
def on_convert(meth=nil, &block)
|
40
|
+
self[:on_convert] ||= []
|
41
|
+
self[:on_convert] << (meth || block)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Metrics
|
3
|
+
# represents a funnel of goal checkpoints
|
4
|
+
# TODO this is mostly a placeholder for now
|
5
|
+
class Funnel < Goal
|
6
|
+
attr_reader :experiment, :name
|
7
|
+
|
8
|
+
def dup(experiment)
|
9
|
+
self.class.new(experiment, name)
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(experiment, name, checkpoints=[])
|
13
|
+
@experiment = experiment
|
14
|
+
@name = name.to_s.underscore.to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
def as_json(opts={})
|
18
|
+
{
|
19
|
+
name: name,
|
20
|
+
}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Metrics
|
3
|
+
# represents a simple conversion goal
|
4
|
+
class Goal
|
5
|
+
attr_reader :experiment, :name
|
6
|
+
|
7
|
+
delegate :allow_multiple_conversions?, :callbacks, to: :configuration
|
8
|
+
|
9
|
+
def dup(experiment)
|
10
|
+
self.class.new(experiment, name, **configuration.to_h.map { |k,v| [k, v.try(:dup)] }.to_h)
|
11
|
+
end
|
12
|
+
|
13
|
+
def configuration
|
14
|
+
@configuration ||= Metrics::Config.new(self)
|
15
|
+
end
|
16
|
+
|
17
|
+
def configure(*args, &block)
|
18
|
+
configuration.configure(*args, &block)
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(experiment, name, **config, &block)
|
22
|
+
@experiment = experiment
|
23
|
+
@name = name.to_s.underscore.to_sym
|
24
|
+
configure(**config, &block)
|
25
|
+
end
|
26
|
+
|
27
|
+
def ==(other)
|
28
|
+
if other.is_a?(self.class)
|
29
|
+
return name == other.name
|
30
|
+
elsif other.is_a?(String) || other.is_a?(Symbol)
|
31
|
+
other = other.to_s.underscore
|
32
|
+
return name == other.to_sym || to_s == other
|
33
|
+
elsif other.is_a?(Array)
|
34
|
+
return to_s == other.flatten.map { |o| o.to_s.underscore }.join('/')
|
35
|
+
elsif other.is_a?(Hash)
|
36
|
+
# TODO "flatten" it out and compare it to_s
|
37
|
+
return false
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def ===(other)
|
42
|
+
return false unless other.is_a?(self.class)
|
43
|
+
return name == other.name && experiment == other.experiment
|
44
|
+
end
|
45
|
+
|
46
|
+
def allow_conversion?(trial, variant, metadata=nil)
|
47
|
+
return true if callbacks[:allow_conversion].empty?
|
48
|
+
run_callbacks(:allow_conversion, trial, variant, trial.participant, metadata)
|
49
|
+
end
|
50
|
+
|
51
|
+
def run_callbacks(hook, trial, *args)
|
52
|
+
return unless callbacks[hook]
|
53
|
+
if [:allow_conversion].include?(hook)
|
54
|
+
callbacks[hook].reduce(args.slice!(0,1)[0]) do |result, callback|
|
55
|
+
if callback.respond_to?(:call)
|
56
|
+
callback.call(trial, result, self, *args)
|
57
|
+
else
|
58
|
+
trial.send(callback, trial, result, self, *args)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
else
|
62
|
+
args.unshift(self)
|
63
|
+
args.unshift(trial)
|
64
|
+
callbacks[hook].each do |callback|
|
65
|
+
if callback.respond_to?(:call)
|
66
|
+
callback.call(*args)
|
67
|
+
else
|
68
|
+
trial.send(callback, *args)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def as_json(opts={})
|
75
|
+
{
|
76
|
+
name: name,
|
77
|
+
}
|
78
|
+
end
|
79
|
+
|
80
|
+
def to_s
|
81
|
+
name.to_s
|
82
|
+
end
|
83
|
+
|
84
|
+
def storage_key
|
85
|
+
"#{experiment.experiment_name}:#{name}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -6,7 +6,7 @@ module TrailGuide
|
|
6
6
|
def initialize(context, adapter: nil)
|
7
7
|
@context = context
|
8
8
|
@adapter = adapter.new(context) if adapter.present?
|
9
|
-
cleanup_inactive_experiments! if TrailGuide.configuration.cleanup_participant_experiments
|
9
|
+
cleanup_inactive_experiments! if TrailGuide.configuration.cleanup_participant_experiments == true
|
10
10
|
end
|
11
11
|
|
12
12
|
def adapter
|
@@ -36,14 +36,15 @@ module TrailGuide
|
|
36
36
|
end
|
37
37
|
|
38
38
|
def variant(experiment)
|
39
|
-
return nil unless experiment.started?
|
39
|
+
return nil unless experiment.calibrating? || experiment.started?
|
40
40
|
return nil unless adapter.key?(experiment.storage_key)
|
41
41
|
varname = adapter[experiment.storage_key]
|
42
42
|
variant = experiment.variants.find { |var| var == varname }
|
43
43
|
return nil unless variant && adapter.key?(variant.storage_key)
|
44
44
|
|
45
45
|
chosen_at = Time.at(adapter[variant.storage_key].to_i)
|
46
|
-
|
46
|
+
started_at = experiment.started_at
|
47
|
+
return variant if (variant.control? && experiment.calibrating?) || (started_at && chosen_at >= started_at)
|
47
48
|
end
|
48
49
|
|
49
50
|
def participating?(experiment, include_control=true)
|
@@ -54,27 +55,29 @@ module TrailGuide
|
|
54
55
|
end
|
55
56
|
|
56
57
|
def converted?(experiment, checkpoint=nil)
|
57
|
-
|
58
|
+
variant = variant(experiment)
|
59
|
+
|
60
|
+
return false unless experiment.started? || (experiment.calibrating? && variant.try(:control?))
|
61
|
+
|
58
62
|
if experiment.goals.empty?
|
59
63
|
raise InvalidGoalError, "You provided the checkpoint `#{checkpoint}` but the experiment `#{experiment.experiment_name}` does not have any goals defined." unless checkpoint.nil?
|
60
64
|
storage_key = "#{experiment.storage_key}:converted"
|
61
65
|
return false unless adapter.key?(storage_key)
|
62
66
|
|
63
67
|
converted_at = Time.at(adapter[storage_key].to_i)
|
64
|
-
converted_at >= experiment.started_at
|
68
|
+
(experiment.calibrating? && variant.try(:control?)) || converted_at >= experiment.started_at
|
65
69
|
elsif !checkpoint.nil?
|
66
|
-
|
67
|
-
|
68
|
-
return false unless adapter.key?(storage_key)
|
70
|
+
goal = experiment.goals.find { |g| g == checkpoint }
|
71
|
+
raise InvalidGoalError, "Invalid goal checkpoint `#{checkpoint}` for experiment `#{experiment.experiment_name}`." if goal.nil?
|
72
|
+
return false unless adapter.key?(goal.storage_key)
|
69
73
|
|
70
|
-
converted_at = Time.at(adapter[storage_key].to_i)
|
71
|
-
converted_at >= experiment.started_at
|
74
|
+
converted_at = Time.at(adapter[goal.storage_key].to_i)
|
75
|
+
(experiment.calibrating? && variant.try(:control?)) || converted_at >= experiment.started_at
|
72
76
|
else
|
73
77
|
experiment.goals.each do |goal|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
return true if converted_at >= experiment.started_at
|
78
|
+
next unless adapter.key?(goal.storage_key)
|
79
|
+
converted_at = Time.at(adapter[goal.storage_key].to_i)
|
80
|
+
return true if (experiment.calibrating? && variant.try(:control?)) || converted_at >= experiment.started_at
|
78
81
|
end
|
79
82
|
return false
|
80
83
|
end
|
@@ -86,16 +89,18 @@ module TrailGuide
|
|
86
89
|
end
|
87
90
|
|
88
91
|
def converted!(variant, checkpoint=nil, reset: false)
|
89
|
-
checkpoint
|
90
|
-
|
92
|
+
if checkpoint.nil?
|
93
|
+
storage_key = "#{variant.experiment.storage_key}:converted"
|
94
|
+
else
|
95
|
+
storage_key = variant.experiment.goals.find { |g| g == checkpoint }.storage_key
|
96
|
+
end
|
91
97
|
|
92
98
|
if reset
|
93
99
|
adapter.delete(variant.experiment.storage_key)
|
94
100
|
adapter.delete(variant.storage_key)
|
95
101
|
adapter.delete(storage_key)
|
96
102
|
variant.experiment.goals.each do |goal|
|
97
|
-
|
98
|
-
adapter.delete(goal_key)
|
103
|
+
adapter.delete(goal.storage_key)
|
99
104
|
end
|
100
105
|
else
|
101
106
|
adapter[storage_key] = Time.now.to_i
|
@@ -107,17 +112,45 @@ module TrailGuide
|
|
107
112
|
return true if chosen.nil?
|
108
113
|
adapter.delete(experiment.storage_key)
|
109
114
|
adapter.delete(chosen.storage_key)
|
115
|
+
adapter.delete("#{experiment.storage_key}:converted")
|
110
116
|
experiment.goals.each do |goal|
|
111
|
-
adapter.delete(
|
117
|
+
adapter.delete(goal.storage_key)
|
112
118
|
end
|
113
119
|
return true
|
114
120
|
end
|
115
121
|
|
116
122
|
def active_experiments(include_control=true)
|
117
123
|
return false if adapter.keys.empty?
|
124
|
+
|
125
|
+
inactive = []
|
126
|
+
active = adapter.keys.map { |key| key.to_s.split(":").first.to_sym }.uniq.map do |key|
|
127
|
+
experiment = TrailGuide.catalog.find(key)
|
128
|
+
next unless experiment
|
129
|
+
|
130
|
+
if !experiment.started? && !experiment.calibrating?
|
131
|
+
inactive << key
|
132
|
+
next
|
133
|
+
else
|
134
|
+
next unless !experiment.combined? && experiment.running? && participating?(experiment, include_control)
|
135
|
+
[ experiment.experiment_name, adapter[experiment.storage_key] ]
|
136
|
+
end
|
137
|
+
end.compact.to_h
|
138
|
+
|
139
|
+
if TrailGuide.configuration.cleanup_participant_experiments == :inline && !inactive.empty?
|
140
|
+
adapter.keys.select do |key|
|
141
|
+
inactive.include?(key.to_s.split(":").first.to_sym)
|
142
|
+
end.each { |key| adapter.delete(key) }
|
143
|
+
end
|
144
|
+
|
145
|
+
return active
|
146
|
+
end
|
147
|
+
|
148
|
+
def calibrating_experiments
|
149
|
+
return false if adapter.keys.empty?
|
150
|
+
|
118
151
|
adapter.keys.map { |key| key.to_s.split(":").first.to_sym }.uniq.map do |key|
|
119
152
|
experiment = TrailGuide.catalog.find(key)
|
120
|
-
next unless experiment &&
|
153
|
+
next unless experiment && experiment.calibrating?
|
121
154
|
[ experiment.experiment_name, adapter[experiment.storage_key] ]
|
122
155
|
end.compact.to_h
|
123
156
|
end
|
@@ -138,7 +171,7 @@ module TrailGuide
|
|
138
171
|
adapter.keys.each do |key|
|
139
172
|
experiment_name = key.to_s.split(":").first.to_sym
|
140
173
|
experiment = TrailGuide.catalog.find(experiment_name)
|
141
|
-
if !experiment || !experiment.started?
|
174
|
+
if !experiment || (!experiment.started? && !experiment.calibrating?)
|
142
175
|
adapter.delete(key)
|
143
176
|
end
|
144
177
|
end
|
data/lib/trail_guide/variant.rb
CHANGED
@@ -16,12 +16,19 @@ module TrailGuide
|
|
16
16
|
|
17
17
|
def ==(other)
|
18
18
|
if other.is_a?(self.class)
|
19
|
+
# TODO eventually remove the experiment requirement here once we start
|
20
|
+
# taking advantage of === below
|
19
21
|
return name == other.name && experiment == other.experiment
|
20
22
|
elsif other.is_a?(String) || other.is_a?(Symbol)
|
21
23
|
return name == other.to_s.underscore.to_sym
|
22
24
|
end
|
23
25
|
end
|
24
26
|
|
27
|
+
def ===(other)
|
28
|
+
return false unless other.is_a?(self.class)
|
29
|
+
return name == other.name && experiment == other.experiment
|
30
|
+
end
|
31
|
+
|
25
32
|
# TODO maybe track the control on the experiment itself, rather than as a
|
26
33
|
# flag on the variants like this?
|
27
34
|
|
@@ -66,11 +73,12 @@ module TrailGuide
|
|
66
73
|
raise InvalidGoalError, "You provided the checkpoint `#{checkpoint}` but the experiment `#{experiment.experiment_name}` does not have any goals defined." unless checkpoint.nil?
|
67
74
|
(TrailGuide.redis.hget(storage_key, 'converted') || 0).to_i
|
68
75
|
elsif !checkpoint.nil?
|
69
|
-
|
70
|
-
|
76
|
+
goal = experiment.goals.find { |g| g == checkpoint }
|
77
|
+
raise InvalidGoalError, "Invalid goal checkpoint `#{checkpoint}` for experiment `#{experiment.experiment_name}`." if goal.nil?
|
78
|
+
(TrailGuide.redis.hget(storage_key, goal.to_s) || 0).to_i
|
71
79
|
else
|
72
|
-
experiment.goals.sum do |
|
73
|
-
(TrailGuide.redis.hget(storage_key,
|
80
|
+
experiment.goals.sum do |goal|
|
81
|
+
(TrailGuide.redis.hget(storage_key, goal.to_s) || 0).to_i
|
74
82
|
end
|
75
83
|
end
|
76
84
|
end
|
@@ -79,22 +87,36 @@ module TrailGuide
|
|
79
87
|
participants - converted
|
80
88
|
end
|
81
89
|
|
90
|
+
def measure(goal=nil, against=nil)
|
91
|
+
superset = against ? converted(against) : participants
|
92
|
+
converts = converted(goal)
|
93
|
+
return 0 if superset.zero? || converts.zero?
|
94
|
+
converts.to_f / superset.to_f
|
95
|
+
end
|
96
|
+
|
82
97
|
def increment_participation!
|
83
98
|
TrailGuide.redis.hincrby(storage_key, 'participants', 1)
|
84
99
|
end
|
85
100
|
|
86
101
|
def increment_conversion!(checkpoint=nil)
|
87
|
-
checkpoint
|
88
|
-
|
102
|
+
if checkpoint.nil?
|
103
|
+
checkpoint = 'converted'
|
104
|
+
else
|
105
|
+
checkpoint = experiment.goals.find { |g| g == checkpoint }.to_s
|
106
|
+
end
|
107
|
+
TrailGuide.redis.hincrby(storage_key, checkpoint, 1)
|
89
108
|
end
|
90
109
|
|
110
|
+
# export the variant state (not config) as json
|
91
111
|
def as_json(opts={})
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
112
|
+
if experiment.goals.empty?
|
113
|
+
conversions = converted
|
114
|
+
else
|
115
|
+
conversions = experiment.goals.map { |g| [g.name, converted(g)] }.to_h
|
116
|
+
end
|
117
|
+
|
118
|
+
{ name => { participants: participants,
|
119
|
+
converted: conversions } }
|
98
120
|
end
|
99
121
|
|
100
122
|
def to_s
|
data/lib/trail_guide/version.rb
CHANGED
data/lib/trailguide.rb
CHANGED
@@ -5,6 +5,8 @@ require "trail_guide/config"
|
|
5
5
|
require "trail_guide/errors"
|
6
6
|
require "trail_guide/adapters"
|
7
7
|
require "trail_guide/algorithms"
|
8
|
+
require "trail_guide/metrics"
|
9
|
+
require "trail_guide/calculators"
|
8
10
|
require "trail_guide/participant"
|
9
11
|
require "trail_guide/variant"
|
10
12
|
require "trail_guide/experiment"
|
@@ -18,6 +20,8 @@ module TrailGuide
|
|
18
20
|
include Canfig::Module
|
19
21
|
@@configuration = TrailGuide::Config.new
|
20
22
|
|
23
|
+
SCHEDULE_DATE_FORMAT = "%m/%d/%Y %I:%M %p %Z"
|
24
|
+
|
21
25
|
class << self
|
22
26
|
delegate :logger, :redis, to: :configuration
|
23
27
|
end
|