Class: TrailGuide::Experiments::Base

Inherits:
Object
  • Object
show all
Defined in:
lib/trail_guide/experiments/base.rb

Direct Known Subclasses

CombinedExperiment, TrailGuide::Experiment

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(participant) ⇒ Base

Returns a new instance of Base.



199
200
201
# File 'lib/trail_guide/experiments/base.rb', line 199

def initialize(participant)
  @participant = TrailGuide::Experiments::Participant.new(self, participant)
end

Instance Attribute Details

#participantObject (readonly)

Returns the value of attribute participant.



193
194
195
# File 'lib/trail_guide/experiments/base.rb', line 193

def participant
  @participant
end

Class Method Details

.as_json(opts = {}) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/trail_guide/experiments/base.rb', line 168

def as_json(opts={})
  { experiment_name => {
    configuration: {
      metric: metric,
      algorithm: algorithm.name,
      variants: variants.as_json,
      goals: goals.as_json,
      start_manually: start_manually?,
      reset_manually: reset_manually?,
      allow_multiple_conversions: allow_multiple_conversions?,
      allow_multiple_goals: allow_multiple_goals?
    },
    statistics: {
      # TODO expand on this for variants/goals
      participants: variants.sum(&:participants),
      converted: variants.sum(&:converted)
    }
  } }
end

.clear_winner!Object



113
114
115
# File 'lib/trail_guide/experiments/base.rb', line 113

def clear_winner!
  TrailGuide.redis.hdel(storage_key, 'winner')
end

.configurationObject



14
15
16
# File 'lib/trail_guide/experiments/base.rb', line 14

def configuration
  @configuration ||= Experiments::Config.new(self)
end

.configure(*args, &block) ⇒ Object



18
19
20
# File 'lib/trail_guide/experiments/base.rb', line 18

def configure(*args, &block)
  configuration.configure(*args, &block)
end

.converted(checkpoint = nil) ⇒ Object



154
155
156
# File 'lib/trail_guide/experiments/base.rb', line 154

def converted(checkpoint=nil)
  variants.sum { |var| var.converted(checkpoint) }
end

.declare_winner!(variant, context = nil) ⇒ Object



107
108
109
110
111
# File 'lib/trail_guide/experiments/base.rb', line 107

def declare_winner!(variant, context=nil)
  variant = variants.find { |var| var == variant } unless variant.is_a?(Variant)
  run_callbacks(:on_winner, variant, context)
  TrailGuide.redis.hset(storage_key, 'winner', variant.name.to_s.underscore)
end

.delete!(context = nil) ⇒ Object



136
137
138
139
140
141
142
# File 'lib/trail_guide/experiments/base.rb', line 136

def delete!(context=nil)
  combined.each { |combo| TrailGuide.catalog.find(combo).delete! }
  variants.each(&:delete!)
  deleted = TrailGuide.redis.del(storage_key)
  run_callbacks(:on_delete, context)
  deleted
end

.experiment_nameObject



22
23
24
# File 'lib/trail_guide/experiments/base.rb', line 22

def experiment_name
  configuration.name
end

.participantsObject



150
151
152
# File 'lib/trail_guide/experiments/base.rb', line 150

def participants
  variants.sum(&:participants)
end

.pause!(context = nil) ⇒ Object



55
56
57
58
59
60
# File 'lib/trail_guide/experiments/base.rb', line 55

def pause!(context=nil)
  return false unless running? && configuration.can_resume?
  paused = TrailGuide.redis.hset(storage_key, 'paused_at', Time.now.to_i)
  run_callbacks(:on_pause, context)
  paused
end

.paused?Boolean

Returns:

  • (Boolean)


95
96
97
# File 'lib/trail_guide/experiments/base.rb', line 95

def paused?
  paused_at && paused_at <= Time.now
end

.paused_atObject



81
82
83
84
# File 'lib/trail_guide/experiments/base.rb', line 81

def paused_at
  paused = TrailGuide.redis.hget(storage_key, 'paused_at')
  return Time.at(paused.to_i) if paused
end

.persisted?Boolean

Returns:

  • (Boolean)


126
127
128
# File 'lib/trail_guide/experiments/base.rb', line 126

def persisted?
  TrailGuide.redis.exists(storage_key)
end

.reset!(context = nil) ⇒ Object



144
145
146
147
148
# File 'lib/trail_guide/experiments/base.rb', line 144

def reset!(context=nil)
  reset = (delete! && save!)
  run_callbacks(:on_reset, context)
  reset
end

.resume!(context = nil) ⇒ Object



69
70
71
72
73
74
# File 'lib/trail_guide/experiments/base.rb', line 69

def resume!(context=nil)
  return false unless paused? && configuration.can_resume?
  resumed = TrailGuide.redis.hdel(storage_key, 'paused_at')
  run_callbacks(:on_resume, context)
  resumed
end

.run_callbacks(hook, *args) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/trail_guide/experiments/base.rb', line 34

def run_callbacks(hook, *args)
  return unless callbacks[hook]
  return args[0] if hook == :rollout_winner
  args.unshift(self)
  callbacks[hook].each do |callback|
    if callback.respond_to?(:call)
      callback.call(*args)
    else
      send(callback, *args)
    end
  end
end

.running?Boolean

Returns:

  • (Boolean)


103
104
105
# File 'lib/trail_guide/experiments/base.rb', line 103

def running?
  started? && !paused? && !stopped?
end

.save!Object



130
131
132
133
134
# File 'lib/trail_guide/experiments/base.rb', line 130

def save!
  combined.each { |combo| TrailGuide.catalog.find(combo).save! }
  variants.each(&:save!)
  TrailGuide.redis.hsetnx(storage_key, 'name', experiment_name)
end

.start!(context = nil) ⇒ Object



47
48
49
50
51
52
53
# File 'lib/trail_guide/experiments/base.rb', line 47

def start!(context=nil)
  return false if started?
  save! unless persisted?
  started = TrailGuide.redis.hset(storage_key, 'started_at', Time.now.to_i)
  run_callbacks(:on_start, context)
  started
end

.started?Boolean

Returns:

  • (Boolean)


91
92
93
# File 'lib/trail_guide/experiments/base.rb', line 91

def started?
  started_at && started_at <= Time.now
end

.started_atObject



76
77
78
79
# File 'lib/trail_guide/experiments/base.rb', line 76

def started_at
  started = TrailGuide.redis.hget(storage_key, 'started_at')
  return Time.at(started.to_i) if started
end

.stop!(context = nil) ⇒ Object



62
63
64
65
66
67
# File 'lib/trail_guide/experiments/base.rb', line 62

def stop!(context=nil)
  return false unless started? && !stopped?
  stopped = TrailGuide.redis.hset(storage_key, 'stopped_at', Time.now.to_i)
  run_callbacks(:on_stop, context)
  stopped
end

.stopped?Boolean

Returns:

  • (Boolean)


99
100
101
# File 'lib/trail_guide/experiments/base.rb', line 99

def stopped?
  stopped_at && stopped_at <= Time.now
end

.stopped_atObject



86
87
88
89
# File 'lib/trail_guide/experiments/base.rb', line 86

def stopped_at
  stopped = TrailGuide.redis.hget(storage_key, 'stopped_at')
  return Time.at(stopped.to_i) if stopped
end

.storage_keyObject



188
189
190
# File 'lib/trail_guide/experiments/base.rb', line 188

def storage_key
  configuration.name
end

.target_sample_size_reached?Boolean

Returns:

  • (Boolean)


162
163
164
165
166
# File 'lib/trail_guide/experiments/base.rb', line 162

def target_sample_size_reached?
  return true unless configuration.target_sample_size
  return true if participants >= configuration.target_sample_size
  return false
end

.unconvertedObject



158
159
160
# File 'lib/trail_guide/experiments/base.rb', line 158

def unconverted
  participants - converted
end

.variants(include_control = true) ⇒ Object



26
27
28
29
30
31
32
# File 'lib/trail_guide/experiments/base.rb', line 26

def variants(include_control=true)
  if include_control
    configuration.variants
  else
    configuration.variants.select { |var| !var.control? }
  end
end

.winnerObject



117
118
119
120
# File 'lib/trail_guide/experiments/base.rb', line 117

def winner
  winner = TrailGuide.redis.hget(storage_key, 'winner')
  return variants.find { |var| var == winner } if winner
end

.winner?Boolean

Returns:

  • (Boolean)


122
123
124
# File 'lib/trail_guide/experiments/base.rb', line 122

def winner?
  TrailGuide.redis.hexists(storage_key, 'winner')
end

Instance Method Details

#algorithmObject



203
204
205
# File 'lib/trail_guide/experiments/base.rb', line 203

def algorithm
  @algorithm ||= self.class.algorithm.new(self)
end

#algorithm_choose!(metadata: nil) ⇒ Object



260
261
262
# File 'lib/trail_guide/experiments/base.rb', line 260

def algorithm_choose!(metadata: nil)
  algorithm.choose!(metadata: )
end

#allow_conversion?(checkpoint = nil, metadata = nil) ⇒ Boolean

Returns:

  • (Boolean)


299
300
301
302
# File 'lib/trail_guide/experiments/base.rb', line 299

def allow_conversion?(checkpoint=nil, =nil)
  return true if callbacks[:allow_conversion].empty?
  run_callbacks(:allow_conversion, checkpoint, )
end

#allow_participation?(metadata = nil) ⇒ Boolean

Returns:

  • (Boolean)


294
295
296
297
# File 'lib/trail_guide/experiments/base.rb', line 294

def allow_participation?(=nil)
  return true if callbacks[:allow_participation].empty?
  run_callbacks(:allow_participation, )
end

#choose!(override: nil, metadata: nil, **opts) ⇒ Object



211
212
213
214
215
216
217
218
219
220
221
# File 'lib/trail_guide/experiments/base.rb', line 211

def choose!(override: nil, metadata: nil, **opts)
  return control if TrailGuide.configuration.disabled

  variant = choose_variant!(override: override, metadata: , **opts)
  run_callbacks(:on_use, variant, )
  variant
rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
  run_callbacks(:on_redis_failover, e)
  return variants.find { |var| var == override } || control if override.present?
  return control
end

#choose_variant!(override: nil, excluded: false, metadata: nil) ⇒ Object



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/trail_guide/experiments/base.rb', line 223

def choose_variant!(override: nil, excluded: false, metadata: nil)
  return control if TrailGuide.configuration.disabled

  if override.present?
    variant = variants.find { |var| var == override } || control
    if running?
      variant.increment_participation! if configuration.track_override
      participant.participating!(variant) if configuration.store_override
    end
    return variant
  end

  if winner?
    variant = winner
    variant.increment_participation! if track_winner_conversions?
    return variant
  end

  return control if excluded
  return control if !started? && configuration.start_manually
  start! unless started?
  return control unless running?

  if participant.participating?
    variant = participant.variant
    participant.participating!(variant)
    return variant
  end

  return control unless TrailGuide.configuration.allow_multiple_experiments == true || !participant.participating_in_active_experiments?(TrailGuide.configuration.allow_multiple_experiments == false)
  return control unless allow_participation?()

  variant = algorithm_choose!(metadata: )
  variant_chosen!(variant, metadata: )
  variant
end

#convert!(checkpoint = nil, metadata: nil) ⇒ Object



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/trail_guide/experiments/base.rb', line 270

def convert!(checkpoint=nil, metadata: nil)
  return false if !running? || (winner? && !track_winner_conversions?)
  return false unless participant.participating?
  raise InvalidGoalError, "Invalid goal checkpoint `#{checkpoint}` for `#{experiment_name}`." unless checkpoint.present? || goals.empty?
  raise InvalidGoalError, "Invalid goal checkpoint `#{checkpoint}` for `#{experiment_name}`." unless checkpoint.nil? || goals.any? { |goal| goal == checkpoint.to_s.underscore.to_sym }
  # TODO eventually allow progressing through funnel checkpoints towards goals
  if participant.converted?(checkpoint)
    return false unless allow_multiple_conversions?
  elsif participant.converted?
    return false unless allow_multiple_goals?
  end
  return false unless allow_conversion?(checkpoint, )

  variant = participant.variant
  # TODO eventually only reset if we're at the final goal in a funnel
  participant.converted!(variant, checkpoint, reset: !reset_manually?)
  variant.increment_conversion!(checkpoint)
  run_callbacks(:on_convert, variant, checkpoint, )
  variant
rescue Errno::ECONNREFUSED, Redis::BaseError, SocketError => e
  run_callbacks(:on_redis_failover, e)
  return false
end

#run_callbacks(hook, *args) ⇒ Object



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/trail_guide/experiments/base.rb', line 304

def run_callbacks(hook, *args)
  return unless callbacks[hook]
  if [:allow_participation, :allow_conversion, :rollout_winner].include?(hook)
    callbacks[hook].reduce(args.slice!(0,1)[0]) do |result, callback|
      if callback.respond_to?(:call)
        callback.call(self, result, *args)
      else
        send(callback, self, result, *args)
      end
    end
  else
    args.unshift(self)
    callbacks[hook].each do |callback|
      if callback.respond_to?(:call)
        callback.call(*args)
      else
        send(callback, *args)
      end
    end
  end
end

#variant_chosen!(variant, metadata: nil) ⇒ Object



264
265
266
267
268
# File 'lib/trail_guide/experiments/base.rb', line 264

def variant_chosen!(variant, metadata: nil)
  variant.increment_participation!
  participant.participating!(variant)
  run_callbacks(:on_choose, variant, )
end

#winnerObject



207
208
209
# File 'lib/trail_guide/experiments/base.rb', line 207

def winner
  run_callbacks(:rollout_winner, self.class.winner)
end