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
@@ -1,3 +1,6 @@
1
+ require 'trail_guide/helper/helper_proxy'
2
+ require 'trail_guide/helper/experiment_proxy'
3
+
1
4
  module TrailGuide
2
5
  module Helper
3
6
  def trailguide(key=nil, **opts, &block)
@@ -13,6 +16,7 @@ module TrailGuide
13
16
  @trailguide_participant
14
17
  end
15
18
 
19
+ # TODO maybe move this to the experiment proxy so it can be configured per-experiment
16
20
  def trailguide_excluded_request?
17
21
  @trailguide_excluded_request ||= instance_exec(self, &TrailGuide.configuration.request_filter)
18
22
  end
@@ -61,221 +65,5 @@ module TrailGuide
61
65
  instance_exec(&@ip_address_filter_proc)
62
66
  end
63
67
  end
64
-
65
- class HelperProxy
66
- attr_reader :context
67
-
68
- def initialize(context, participant: nil)
69
- @context = context
70
- @participant = participant
71
- end
72
-
73
- def new(key)
74
- ExperimentProxy.new(context, key, participant: participant)
75
- end
76
-
77
- def choose!(key, **opts, &block)
78
- new(key).choose!(**opts, &block)
79
- end
80
- alias_method :enroll!, :choose!
81
-
82
- def choose(key, **opts, &block)
83
- new(key).choose(**opts, &block)
84
- end
85
- alias_method :enroll, :choose
86
-
87
- def run!(key, **opts)
88
- new(key).run!(**opts)
89
- end
90
-
91
- def run(key, **opts)
92
- new(key).run(**opts)
93
- end
94
-
95
- def render!(key, **opts)
96
- new(key).render!(**opts)
97
- end
98
-
99
- def render(key, **opts)
100
- new(key).render(**opts)
101
- end
102
-
103
- def convert!(key, checkpoint=nil, **opts, &block)
104
- new(key).convert!(checkpoint, **opts, &block)
105
- end
106
-
107
- def convert(key, checkpoint=nil, **opts, &block)
108
- new(key).convert(checkpoint, **opts, &block)
109
- end
110
-
111
- def participant
112
- @participant ||= context.send(:trailguide_participant)
113
- end
114
-
115
- def context_type
116
- if context.is_a?(ActionView::Context)
117
- :template
118
- elsif context.is_a?(ActionController::Base)
119
- :controller
120
- end
121
- end
122
- end
123
-
124
- class ExperimentProxy < HelperProxy
125
- attr_reader :key
126
-
127
- def initialize(context, key, **opts)
128
- super(context, **opts)
129
- @key = key.to_s.underscore.to_sym
130
- end
131
-
132
- def choose!(**opts, &block)
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?
136
- opts = {override: override_variant, excluded: exclude_visitor?}.merge(opts)
137
- variant = experiment.choose!(**opts)
138
- if block_given?
139
- yield variant, opts[:metadata]
140
- else
141
- variant
142
- end
143
- end
144
- alias_method :enroll!, :choose!
145
-
146
- def choose(**opts, &block)
147
- choose!(**opts, &block)
148
- rescue NoExperimentsError => e
149
- raise e
150
- rescue => e
151
- TrailGuide.logger.error e
152
- experiment.control
153
- end
154
- alias_method :enroll, :choose
155
-
156
- def run!(methods: nil, **opts)
157
- choose!(**opts) do |variant, metadata|
158
- varmeth = methods[variant.name] if methods
159
- varmeth ||= variant.name
160
-
161
- unless context.respond_to?(varmeth, true)
162
- if context_type == :controller
163
- raise NoVariantMethodError,
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={})"
165
- elsif context_type == :template
166
- raise NoVariantMethodError,
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={})"
168
- else
169
- raise NoVariantMethodError,
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={})"
171
- end
172
- end
173
-
174
- arguments = context.method(varmeth).parameters
175
- if arguments.empty?
176
- context.send(varmeth)
177
- elsif arguments.length > 1 || arguments[0][0] == :rest
178
- context.send(varmeth, variant, **variant.metadata)
179
- elsif arguments.length == 1
180
- context.send(varmeth, **variant.metadata)
181
- end
182
- end
183
- end
184
-
185
- def run(methods: nil, **opts)
186
- run!(methods: methods, **opts)
187
- rescue NoExperimentsError => e
188
- raise e
189
- rescue => e
190
- TrailGuide.logger.error e
191
- false
192
- end
193
-
194
- def render!(prefix: nil, templates: nil, locals: {}, **opts)
195
- raise UnsupportedContextError, "The current context (#{context}) does not support rendering. Rendering is only available in controllers and views." unless context.respond_to?(:render, true)
196
- choose!(**opts) do |variant, metadata|
197
- locals = { variant: variant, metadata: variant.metadata }.merge(locals)
198
- locals = { locals: locals } if context_type == :controller
199
-
200
- template = templates[variant.name] if templates
201
- prefix ||= (context.try(:view_context) || context).lookup_context.prefixes.first + '/'
202
- template ||= "#{prefix.to_s}#{variant.experiment.experiment_name.to_s.underscore}/#{variant.name.to_s.underscore}"
203
-
204
- context.send(:render, template.to_s, **locals)
205
- end
206
- end
207
-
208
- def render(prefix: nil, templates: nil, locals: {}, **opts)
209
- render!(prefix: prefix, templates: templates, locals: locals, **opts)
210
- rescue NoExperimentsError => e
211
- raise e
212
- rescue => e
213
- TrailGuide.logger.error e
214
- false
215
- end
216
-
217
- def convert!(checkpoint=nil, **opts, &block)
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
-
230
- return false unless checkpoints.any?
231
-
232
- if block_given?
233
- yield checkpoints, opts[:metadata]
234
- else
235
- checkpoints
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
245
- end
246
-
247
- def convert(checkpoint=nil, **opts, &block)
248
- convert!(checkpoint, **opts, &block)
249
- rescue => e
250
- TrailGuide.logger.error e
251
- false
252
- end
253
-
254
- def experiments
255
- @experiments ||= TrailGuide.catalog.select(key).map do |experiment|
256
- experiment.new(participant)
257
- end
258
- end
259
-
260
- def experiment
261
- @experiment ||= experiments.first
262
- end
263
-
264
- def override_variant
265
- return unless context.respond_to?(:params, true)
266
- params = context.send(:params)
267
- return unless params.key?(TrailGuide.configuration.override_parameter)
268
- experiment_params = params[TrailGuide.configuration.override_parameter]
269
- return unless experiment_params.key?(experiment.experiment_name.to_s)
270
- varname = experiment_params[experiment.experiment_name.to_s]
271
- variant = experiment.variants.find { |var| var == varname }
272
- variant.try(:name)
273
- end
274
-
275
- def exclude_visitor?
276
- return false if experiment.configuration.skip_request_filter?
277
- context.send(:trailguide_excluded_request?)
278
- end
279
- end
280
68
  end
281
69
  end
@@ -0,0 +1,160 @@
1
+ module TrailGuide
2
+ module Helper
3
+ class ExperimentProxy < HelperProxy
4
+ attr_reader :key
5
+
6
+ def initialize(context, key, **opts)
7
+ super(context, **opts)
8
+ @key = key.to_s.underscore.to_sym
9
+ end
10
+
11
+ def choose!(**opts, &block)
12
+ raise NoExperimentsError, key if experiments.empty?
13
+ raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` matches more than one experiment." if experiments.length > 1
14
+ raise TooManyExperimentsError, "Selecting a variant requires a single experiment, but `#{key}` refers to a combined experiment." if experiment.combined?
15
+ opts = {override: override_variant, excluded: exclude_visitor?}.merge(opts)
16
+ variant = experiment.choose!(**opts)
17
+ if block_given?
18
+ yield variant, opts[:metadata]
19
+ else
20
+ variant
21
+ end
22
+ end
23
+ alias_method :enroll!, :choose!
24
+
25
+ def choose(**opts, &block)
26
+ choose!(**opts, &block)
27
+ rescue NoExperimentsError => e
28
+ raise e
29
+ rescue => e
30
+ TrailGuide.logger.error e
31
+ experiment.control
32
+ end
33
+ alias_method :enroll, :choose
34
+
35
+ def run!(methods: nil, **opts)
36
+ choose!(**opts) do |variant, metadata|
37
+ varmeth = methods[variant.name] if methods
38
+ varmeth ||= variant.name
39
+
40
+ unless context.respond_to?(varmeth, true)
41
+ if context_type == :controller
42
+ raise NoVariantMethodError,
43
+ "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={})"
44
+ elsif context_type == :template
45
+ raise NoVariantMethodError,
46
+ "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={})"
47
+ else
48
+ raise NoVariantMethodError,
49
+ "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={})"
50
+ end
51
+ end
52
+
53
+ arguments = context.method(varmeth).parameters
54
+ if arguments.empty?
55
+ context.send(varmeth)
56
+ elsif arguments.length > 1 || arguments[0][0] == :rest
57
+ context.send(varmeth, variant, **variant.metadata)
58
+ elsif arguments.length == 1
59
+ context.send(varmeth, **variant.metadata)
60
+ end
61
+ end
62
+ end
63
+
64
+ def run(methods: nil, **opts)
65
+ run!(methods: methods, **opts)
66
+ rescue NoExperimentsError => e
67
+ raise e
68
+ rescue => e
69
+ TrailGuide.logger.error e
70
+ false
71
+ end
72
+
73
+ def render!(prefix: nil, templates: nil, locals: {}, **opts)
74
+ raise UnsupportedContextError, "The current context (#{context}) does not support rendering. Rendering is only available in controllers and views." unless context.respond_to?(:render, true)
75
+ choose!(**opts) do |variant, metadata|
76
+ locals = { variant: variant, metadata: variant.metadata }.merge(locals)
77
+ locals = { locals: locals } if context_type == :controller
78
+
79
+ template = templates[variant.name] if templates
80
+ prefix ||= (context.try(:view_context) || context).lookup_context.prefixes.first + '/' rescue ''
81
+ template ||= "#{prefix.to_s}#{variant.experiment.experiment_name.to_s.underscore}/#{variant.name.to_s.underscore}"
82
+
83
+ context.send(:render, template.to_s, **locals)
84
+ end
85
+ end
86
+
87
+ def render(prefix: nil, templates: nil, locals: {}, **opts)
88
+ render!(prefix: prefix, templates: templates, locals: locals, **opts)
89
+ rescue NoExperimentsError => e
90
+ raise e
91
+ rescue => e
92
+ TrailGuide.logger.error e
93
+ false
94
+ end
95
+
96
+ def convert!(checkpoint=nil, **opts, &block)
97
+ raise NoExperimentsError, key if experiments.empty?
98
+ checkpoints = experiments.map do |experiment|
99
+ ckpt = checkpoint || experiment.goals.find { |g| g == key }
100
+ if experiment.combined?
101
+ experiment.combined_experiments.map do |combo|
102
+ combo.convert!(ckpt, **opts)
103
+ end
104
+ else
105
+ experiment.convert!(ckpt, **opts)
106
+ end
107
+ end.flatten
108
+
109
+ return false unless checkpoints.any?
110
+
111
+ if block_given?
112
+ yield checkpoints, opts[:metadata]
113
+ else
114
+ checkpoints
115
+ end
116
+ rescue NoExperimentsError => e
117
+ unless TrailGuide.configuration.ignore_orphaned_groups?
118
+ trace = e.backtrace.find { |t| !t.match?(Regexp.new(__FILE__)) }
119
+ .to_s.split(Rails.root.to_s).last
120
+ .split(':').first(2).join(':')
121
+ TrailGuide.catalog.orphaned(key, trace)
122
+ end
123
+ false
124
+ end
125
+
126
+ def convert(checkpoint=nil, **opts, &block)
127
+ convert!(checkpoint, **opts, &block)
128
+ rescue => e
129
+ TrailGuide.logger.error e
130
+ false
131
+ end
132
+
133
+ def experiments
134
+ @experiments ||= TrailGuide.catalog.select(key).map do |experiment|
135
+ experiment.new(participant)
136
+ end
137
+ end
138
+
139
+ def experiment
140
+ @experiment ||= experiments.first
141
+ end
142
+
143
+ def override_variant
144
+ return unless context.respond_to?(:params, true)
145
+ params = context.send(:params)
146
+ return unless params.key?(TrailGuide.configuration.override_parameter)
147
+ experiment_params = params[TrailGuide.configuration.override_parameter]
148
+ return unless experiment_params.key?(experiment.experiment_name.to_s)
149
+ varname = experiment_params[experiment.experiment_name.to_s]
150
+ variant = experiment.variants.find { |var| var == varname }
151
+ variant.try(:name)
152
+ end
153
+
154
+ def exclude_visitor?
155
+ return false if experiment.configuration.skip_request_filter?
156
+ context.send(:trailguide_excluded_request?)
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,62 @@
1
+ module TrailGuide
2
+ module Helper
3
+ class HelperProxy
4
+ attr_reader :context
5
+
6
+ def initialize(context, participant: nil)
7
+ @context = context
8
+ @participant = participant
9
+ end
10
+
11
+ def new(key)
12
+ ExperimentProxy.new(context, key, participant: participant)
13
+ end
14
+
15
+ def choose!(key, **opts, &block)
16
+ new(key).choose!(**opts, &block)
17
+ end
18
+ alias_method :enroll!, :choose!
19
+
20
+ def choose(key, **opts, &block)
21
+ new(key).choose(**opts, &block)
22
+ end
23
+ alias_method :enroll, :choose
24
+
25
+ def run!(key, **opts)
26
+ new(key).run!(**opts)
27
+ end
28
+
29
+ def run(key, **opts)
30
+ new(key).run(**opts)
31
+ end
32
+
33
+ def render!(key, **opts)
34
+ new(key).render!(**opts)
35
+ end
36
+
37
+ def render(key, **opts)
38
+ new(key).render(**opts)
39
+ end
40
+
41
+ def convert!(key, checkpoint=nil, **opts, &block)
42
+ new(key).convert!(checkpoint, **opts, &block)
43
+ end
44
+
45
+ def convert(key, checkpoint=nil, **opts, &block)
46
+ new(key).convert(checkpoint, **opts, &block)
47
+ end
48
+
49
+ def participant
50
+ @participant ||= context.send(:trailguide_participant)
51
+ end
52
+
53
+ def context_type
54
+ if context.is_a?(ActionView::Context)
55
+ :template
56
+ elsif context.is_a?(ActionController::Base)
57
+ :controller
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end