trailguide 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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,5 @@
1
+ require "trail_guide/adapters/experiments"
2
+ require "trail_guide/adapters/variants"
1
3
  require "trail_guide/adapters/participants"
2
4
 
3
5
  module TrailGuide
@@ -0,0 +1,8 @@
1
+ require 'trail_guide/adapters/experiments/redis'
2
+
3
+ module TrailGuide
4
+ module Adapters
5
+ module Experiments
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,48 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Experiments
4
+ class Redis
5
+ attr_reader :experiment
6
+ delegate :storage_key, to: :experiment
7
+
8
+ def initialize(experiment, redis: nil)
9
+ @experiment = experiment
10
+ @redis = redis
11
+ end
12
+
13
+ def redis
14
+ @redis ||= TrailGuide.redis
15
+ end
16
+
17
+ def get(attr)
18
+ redis.hget(storage_key, attr.to_s)
19
+ end
20
+
21
+ def set(attr, val)
22
+ redis.hset(storage_key, attr.to_s, val.to_s)
23
+ val.to_s
24
+ end
25
+
26
+ def setnx(attr, val)
27
+ val.to_s if redis.hsetnx(storage_key, attr.to_s, val.to_s)
28
+ end
29
+
30
+ def delete(attr)
31
+ redis.hdel(storage_key, attr.to_s)
32
+ end
33
+
34
+ def exists?(attr)
35
+ redis.hexists(storage_key, attr.to_s)
36
+ end
37
+
38
+ def persisted?
39
+ redis.exists(storage_key)
40
+ end
41
+
42
+ def destroy
43
+ redis.del(storage_key)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -35,6 +35,7 @@ module TrailGuide
35
35
  end
36
36
 
37
37
  def destroy!
38
+ @cookie = nil
38
39
  cookies.delete(config.cookie.to_s)
39
40
  end
40
41
 
@@ -8,6 +8,10 @@ module TrailGuide
8
8
 
9
9
  def initialize(&block)
10
10
  configure do |config|
11
+ # TODO make visitor/user configuration more flexible, allow stuff like:
12
+ #
13
+ # config.visitor = -> (context) { context.cookies['visitor_id'] }
14
+ # config.user = -> (context) { context.current_user.try(:id) }
11
15
  config.visitor_cookie = nil
12
16
  config.user_id_key = :id
13
17
 
@@ -17,6 +21,7 @@ module TrailGuide
17
21
  config.expiration = 1.year.seconds
18
22
  end
19
23
 
24
+ # TODO use cookie or session adapter by default instead?
20
25
  config.visitor_adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
21
26
  config.namespace = 'unity:visitors'
22
27
  config.lookup = -> (visitor_id) { visitor_id }
@@ -43,7 +48,7 @@ module TrailGuide
43
48
  end
44
49
 
45
50
  if logged_out_context?
46
- unity.visitor_id ||= context.send(:cookies)[configuration.visitor_cookie].gsub(/(%22|")/, '')
51
+ unity.visitor_id ||= context.send(:cookies)[configuration.visitor_cookie]
47
52
  end
48
53
 
49
54
  unity.sync!
@@ -57,9 +62,12 @@ module TrailGuide
57
62
  end
58
63
  end
59
64
 
65
+ # TODO introduce Unity::Adapter class, which wraps BOTH/ALL of the configured adapters (either anonymous, visitor, user, or visitor+user) to keep everything in sync between them
66
+
60
67
  protected
61
68
 
62
69
  def context_type
70
+ # TODO allow a configuration.preference when both visitor and user context are available?
63
71
  if visitor_context?
64
72
  return :visitor
65
73
  end
@@ -0,0 +1,8 @@
1
+ require 'trail_guide/adapters/variants/redis'
2
+
3
+ module TrailGuide
4
+ module Adapters
5
+ module Variants
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,52 @@
1
+ module TrailGuide
2
+ module Adapters
3
+ module Variants
4
+ class Redis
5
+ attr_reader :variant
6
+ delegate :storage_key, to: :variant
7
+
8
+ def initialize(variant, redis: nil)
9
+ @variant = variant
10
+ @redis = redis
11
+ end
12
+
13
+ def redis
14
+ @redis ||= TrailGuide.redis
15
+ end
16
+
17
+ def get(attr)
18
+ redis.hget(storage_key, attr.to_s)
19
+ end
20
+
21
+ def set(attr, val)
22
+ redis.hset(storage_key, attr.to_s, val.to_s)
23
+ val.to_s
24
+ end
25
+
26
+ def setnx(attr, val)
27
+ val.to_s if redis.hsetnx(storage_key, attr.to_s, val.to_s)
28
+ end
29
+
30
+ def increment(attr, cnt=1)
31
+ redis.hincrby(storage_key, attr.to_s, cnt)
32
+ end
33
+
34
+ def delete(attr)
35
+ redis.hdel(storage_key, attr.to_s)
36
+ end
37
+
38
+ def exists?(attr)
39
+ redis.hexists(storage_key, attr.to_s)
40
+ end
41
+
42
+ def persisted?
43
+ redis.exists(storage_key)
44
+ end
45
+
46
+ def destroy
47
+ redis.del(storage_key)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -8,6 +8,7 @@ module TrailGuide
8
8
  end
9
9
 
10
10
  paths["config/routes.rb"] = "config/routes/admin.rb"
11
+ paths["config/initializers"] = ["config/initializers/admin.rb", "config/initializers/assets.rb"]
11
12
  end
12
13
  end
13
14
  end
@@ -1,7 +1,9 @@
1
+ require "trail_guide/algorithms/algorithm"
1
2
  require "trail_guide/algorithms/weighted"
2
3
  require "trail_guide/algorithms/distributed"
3
4
  require "trail_guide/algorithms/bandit"
4
5
  require "trail_guide/algorithms/random"
6
+ require "trail_guide/algorithms/static"
5
7
 
6
8
  module TrailGuide
7
9
  module Algorithms
@@ -15,6 +17,8 @@ module TrailGuide
15
17
  algo = TrailGuide::Algorithms::Distributed
16
18
  when :random
17
19
  algo = TrailGuide::Algorithms::Random
20
+ when :static
21
+ algo = TrailGuide::Algorithms::Static
18
22
  else
19
23
  algo = algo.constantize if algo.is_a?(String)
20
24
  end
@@ -0,0 +1,29 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Algorithm
4
+ attr_reader :experiment
5
+
6
+ def self.choose!(experiment, **opts)
7
+ new(experiment).choose!(**opts)
8
+ end
9
+
10
+ def initialize(experiment)
11
+ @experiment = experiment
12
+ end
13
+
14
+ def choose!(**opts)
15
+ raise NotImplementedError, 'You must define a `#choose!(**opts)` method for your algorithm'
16
+ end
17
+
18
+ protected
19
+
20
+ def control
21
+ experiment.control
22
+ end
23
+
24
+ def variants
25
+ experiment.variants
26
+ end
27
+ end
28
+ end
29
+ end
@@ -2,32 +2,23 @@ require 'simple-random'
2
2
 
3
3
  module TrailGuide
4
4
  module Algorithms
5
- class Bandit
6
- attr_reader :experiment
7
-
8
- def self.choose!(experiment, **opts)
9
- new(experiment).choose!(**opts)
10
- end
11
-
12
- def initialize(experiment)
13
- @experiment = experiment
14
- end
15
-
5
+ class Bandit < Algorithm
16
6
  def choose!(**opts)
17
- guess = best_guess
18
- experiment.variants.find { |var| var == guess }
7
+ variants.find { |var| var == best_guess }
19
8
  end
20
9
 
21
10
  private
22
11
 
12
+ def guesses
13
+ @guesses ||= variants.map do |variant|
14
+ [variant.name, arm_guess(variant.participants, variant.converted)]
15
+ end.to_h
16
+ end
17
+
23
18
  def best_guess
24
19
  @best_guess ||= begin
25
- guesses = {}
26
- experiment.variants.each do |variant|
27
- guesses[variant.name] = arm_guess(variant.participants, variant.converted)
28
- end
29
20
  gmax = guesses.values.max
30
- best = guesses.keys.select { |name| guesses[name] == gmax }
21
+ best = guesses.keys.select { |name| guesses[name] == gmax }
31
22
  best.sample
32
23
  end
33
24
  end
@@ -1,25 +1,18 @@
1
1
  module TrailGuide
2
2
  module Algorithms
3
- class Distributed
4
- attr_reader :experiment
5
-
6
- def self.choose!(experiment, **opts)
7
- new(experiment).choose!(**opts)
8
- end
9
-
10
- def initialize(experiment)
11
- @experiment = experiment
12
- end
13
-
3
+ class Distributed < Algorithm
14
4
  def choose!(**opts)
15
- variants.sample
5
+ options.sample
16
6
  end
17
7
 
18
8
  private
19
9
 
20
- def variants
21
- groups = experiment.variants.group_by(&:participants)
22
- groups.min_by { |c,g| c }.last
10
+ def grouped
11
+ @grouped ||= variants.group_by(&:participants)
12
+ end
13
+
14
+ def options
15
+ @options ||= grouped.min_by { |c,g| c }.last
23
16
  end
24
17
  end
25
18
  end
@@ -1,18 +1,8 @@
1
1
  module TrailGuide
2
2
  module Algorithms
3
- class Random
4
- attr_reader :experiment
5
-
6
- def self.choose!(experiment, **opts)
7
- new(experiment).choose!(**opts)
8
- end
9
-
10
- def initialize(experiment)
11
- @experiment = experiment
12
- end
13
-
3
+ class Random < Algorithm
14
4
  def choose!(**opts)
15
- experiment.variants.sample
5
+ variants.sample
16
6
  end
17
7
  end
18
8
  end
@@ -0,0 +1,34 @@
1
+ module TrailGuide
2
+ module Algorithms
3
+ class Static < Algorithm
4
+ def self.choose!(experiment, metadata: nil, &block)
5
+ new(experiment, &block).choose!(metadata: metadata)
6
+ end
7
+
8
+ def initialize(experiment=nil, &block)
9
+ raise ArgumentError, 'You must provide a comparison block when using the static algorithm' unless block_given?
10
+ @block = block
11
+ super(experiment)
12
+ end
13
+
14
+ def new(experiment)
15
+ TrailGuide.logger.warn "WARNING: Using the Static algorithm for an experiment which is configured with sticky_assignment. You should either use a different algorithm or configure sticky_assignment for the `#{experiment.experiment_name}` experiment." if experiment.configuration.sticky_assignment?
16
+ self.class.new(experiment, &@block)
17
+ end
18
+
19
+ def choose!(metadata: nil)
20
+ return control unless metadata.present?
21
+
22
+ variant = variants.find do |variant|
23
+ @block.call(variant.metadata, metadata)
24
+ end
25
+
26
+ variant || control
27
+ rescue => e
28
+ TrailGuide.logger.error "#{e.class.name}: #{e.message}"
29
+ TrailGuide.logger.error e.backtrace.first
30
+ control
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,23 +1,11 @@
1
1
  module TrailGuide
2
2
  module Algorithms
3
- class Weighted
4
- attr_reader :experiment
5
-
6
- def self.choose!(experiment, **opts)
7
- new(experiment).choose!(**opts)
8
- end
9
-
10
- def initialize(experiment)
11
- @experiment = experiment
12
- end
13
-
3
+ class Weighted < Algorithm
14
4
  def choose!(**opts)
15
- weights = experiment.variants.map(&:weight)
16
- reference = rand * weights.inject(:+)
17
-
18
- experiment.variants.zip(weights).each do |variant,weight|
19
- return variant if weight >= reference
20
- reference -= weight
5
+ reference = rand * variants.sum(&:weight)
6
+ variants.each do |variant|
7
+ return variant if variant.weight >= reference
8
+ reference -= variant.weight
21
9
  end
22
10
  end
23
11
  end
@@ -7,19 +7,23 @@ module TrailGuide
7
7
  @catalog ||= new
8
8
  end
9
9
 
10
- def load_experiments!
10
+ def load_experiments!(configs: [], classes: [])
11
11
  @catalog = nil
12
12
 
13
13
  # Load experiments from YAML configs if any exists
14
- load_yaml_experiments(Rails.root.join("config/experiments.yml"))
15
- Dir[Rails.root.join("config/experiments/**/*.yml")].each { |f| load_yaml_experiments(f) }
14
+ [configs].flatten.each do |path|
15
+ Dir[Rails.root.join(path)].each { |f| load_yaml_experiments(f) if ['.yml', '.yaml'].include?(File.extname(f)) }
16
+ end
16
17
 
17
18
  # Load experiments from ruby configs if any exist
18
- DSL.instance_eval(File.read(Rails.root.join("config/experiments.rb"))) if File.exists?(Rails.root.join("config/experiments.rb"))
19
- Dir[Rails.root.join("config/experiments/**/*.rb")].each { |f| DSL.instance_eval(File.read(f)) }
19
+ [configs].flatten.each do |path|
20
+ Dir[Rails.root.join(path)].each { |f| DSL.instance_eval(File.read(f)) if File.extname(f) == '.rb' }
21
+ end
20
22
 
21
23
  # Load any experiment classes defined in the app
22
- Dir[Rails.root.join("app/experiments/**/*.rb")].each { |f| load f }
24
+ [classes].flatten.each do |path|
25
+ Dir[Rails.root.join(path)].each { |f| load f }
26
+ end
23
27
  end
24
28
 
25
29
  def load_yaml_experiments(file)
@@ -46,7 +50,7 @@ module TrailGuide
46
50
 
47
51
  expgoals.each do |expgoal|
48
52
  goal expgoal
49
- end
53
+ end if expgoals.present?
50
54
 
51
55
  config.control = options[:control] if options[:control]
52
56
  config.groups = options[:groups] if options[:groups]
@@ -76,15 +80,24 @@ module TrailGuide
76
80
  end
77
81
  end
78
82
 
79
- delegate :combined_experiment, to: :class
80
- attr_reader :experiments
83
+ attr_reader :experiments, :combined
84
+ delegate :new, to: :class
85
+ delegate :each, to: :experiments
81
86
 
82
- def initialize(experiments=[])
87
+ def initialize(experiments=[], combined=[])
83
88
  @experiments = experiments
89
+ @combined = combined
84
90
  end
85
91
 
86
- def each(&block)
87
- experiments.each(&block)
92
+ def combined_experiment(exp, name)
93
+ combo = @combined.find do |cex|
94
+ cex.experiment_name == name.to_s.underscore.to_sym &&
95
+ cex.parent.experiment_name == exp.experiment_name
96
+ end
97
+ return combo if combo.present?
98
+ combo = self.class.combined_experiment(exp, name)
99
+ @combined << combo
100
+ combo
88
101
  end
89
102
 
90
103
  def groups
@@ -100,47 +113,49 @@ module TrailGuide
100
113
  end
101
114
  end.flatten
102
115
 
103
- self.class.new(exploded)
116
+ new(exploded, @combined)
104
117
  end
105
118
 
106
119
  def calibrating
107
- self.class.new(to_a.select(&:calibrating?))
120
+ new(to_a.select(&:calibrating?), @combined)
108
121
  end
109
122
 
110
123
  def started
111
- self.class.new(to_a.select { |e| e.started? && !e.winner? })
124
+ new(to_a.select { |e| e.started? && !e.winner? }, @combined)
112
125
  end
113
126
 
114
127
  def scheduled
115
- self.class.new(to_a.select { |e| e.scheduled? && !e.winner? })
128
+ new(to_a.select { |e| e.scheduled? && !e.winner? }, @combined)
116
129
  end
117
130
 
118
131
  def running
119
- self.class.new(to_a.select { |e| e.running? && !e.winner? })
132
+ new(to_a.select { |e| e.running? && !e.winner? }, @combined)
120
133
  end
121
134
 
122
135
  def paused
123
- self.class.new(to_a.select { |e| e.paused? && !e.winner? })
136
+ new(to_a.select { |e| e.paused? && !e.winner? }, @combined)
124
137
  end
125
138
 
126
139
  def stopped
127
- self.class.new(to_a.select { |e| e.stopped? && !e.winner? })
140
+ new(to_a.select { |e| e.stopped? && !e.winner? }, @combined)
128
141
  end
129
142
 
130
143
  def ended
131
- self.class.new(to_a.select(&:winner?))
144
+ new(to_a.select(&:winner?), @combined)
132
145
  end
133
146
 
134
147
  def unstarted
135
- self.class.new(to_a.select { |e| !e.started? && !e.calibrating? && !e.scheduled? && !e.winner? })
148
+ new(to_a.select { |e| !e.started? && !e.calibrating? && !e.scheduled? && !e.winner? }, @combined)
136
149
  end
137
150
 
138
151
  def not_running
139
- self.class.new(to_a.select { |e| !e.running? })
152
+ new(to_a.select { |e| !e.running? }, @combined)
140
153
  end
141
154
 
142
155
  def by_started
143
156
  scoped = to_a.sort do |a,b|
157
+ # TODO finish implementing specs, then implement `experiment.fresh?`, then (maybe) re-work this all
158
+ # into an experiment spaceship operator
144
159
  if !(a.started? || a.scheduled? || a.winner?) && !(b.started? || b.scheduled? || b.winner?)
145
160
  a.experiment_name.to_s <=> b.experiment_name.to_s
146
161
  elsif !(a.started? || a.scheduled? || a.winner?)
@@ -159,32 +174,48 @@ module TrailGuide
159
174
  elsif !a.running? && b.running?
160
175
  1
161
176
  elsif a.running? && b.running?
162
- a.started_at <=> b.started_at
177
+ if a.started_at == b.started_at
178
+ a.experiment_name.to_s <=> b.experiment_name.to_s
179
+ else
180
+ a.started_at <=> b.started_at
181
+ end
163
182
  elsif a.paused? && !b.paused?
164
183
  -1
165
184
  elsif !a.paused? && b.paused?
166
185
  1
167
186
  elsif a.paused? && b.paused?
168
- a.paused_at <=> b.paused_at
187
+ if a.paused_at == b.paused_at
188
+ a.experiment_name.to_s <=> b.experiment_name.to_s
189
+ else
190
+ a.paused_at <=> b.paused_at
191
+ end
169
192
  elsif a.scheduled? && !b.scheduled?
170
193
  -1
171
194
  elsif !a.scheduled? && b.scheduled?
172
195
  1
173
196
  elsif a.scheduled? && b.scheduled?
174
- a.started_at <=> b.started_at
197
+ if a.started_at == b.started_at
198
+ a.experiment_name.to_s <=> b.experiment_name.to_s
199
+ else
200
+ a.started_at <=> b.started_at
201
+ end
175
202
  elsif a.stopped? && !b.stopped?
176
- -1
203
+ -1 # TODO remove unused case
177
204
  elsif !a.stopped? && b.stopped?
178
- 1
205
+ 1 # TODO remove unused case
179
206
  elsif a.stopped? && b.stopped?
180
- a.stopped_at <=> b.stopped_at
207
+ if a.stopped_at == b.stopped_at
208
+ a.experiment_name.to_s <=> b.experiment_name.to_s
209
+ else
210
+ a.stopped_at <=> b.stopped_at
211
+ end
181
212
  else
182
213
  a.experiment_name.to_s <=> b.experiment_name.to_s
183
214
  end
184
215
  end
185
216
  end
186
217
 
187
- self.class.new(scoped)
218
+ new(scoped, @combined)
188
219
  end
189
220
 
190
221
  def find(name)
@@ -227,7 +258,7 @@ module TrailGuide
227
258
  end
228
259
  end
229
260
 
230
- self.class.new(selected)
261
+ new(selected, @combined)
231
262
  end
232
263
 
233
264
  def register(klass)
@@ -235,8 +266,13 @@ module TrailGuide
235
266
  klass
236
267
  end
237
268
 
238
- def deregister(key)
239
- # TODO (mostly only useful for engine specs)
269
+ def deregister(key, remove_const=false)
270
+ klass = find(key)
271
+ return unless klass.present?
272
+ experiments.delete(klass)
273
+ return klass unless remove_const && klass.name.present?
274
+ Object.send(:remove_const, :"#{klass.name}")
275
+ return key
240
276
  end
241
277
 
242
278
  def export
@@ -256,9 +292,9 @@ module TrailGuide
256
292
 
257
293
  experiment.reset!
258
294
  TrailGuide.redis.hsetnx(experiment.storage_key, 'name', experiment.experiment_name)
259
- TrailGuide.redis.hset(experiment.storage_key, 'started_at', est['started_at']) if est['started_at'].present?
260
- TrailGuide.redis.hset(experiment.storage_key, 'paused_at', est['paused_at']) if est['paused_at'].present?
261
- TrailGuide.redis.hset(experiment.storage_key, 'stopped_at', est['stopped_at']) if est['stopped_at'].present?
295
+ TrailGuide.redis.hset(experiment.storage_key, 'started_at', DateTime.parse(est['started_at']).to_i) if est['started_at'].present?
296
+ TrailGuide.redis.hset(experiment.storage_key, 'paused_at', DateTime.parse(est['paused_at']).to_i) if est['paused_at'].present?
297
+ TrailGuide.redis.hset(experiment.storage_key, 'stopped_at', DateTime.parse(est['stopped_at']).to_i) if est['stopped_at'].present?
262
298
  TrailGuide.redis.hset(experiment.storage_key, 'winner', est['winner']) if est['winner'].present?
263
299
 
264
300
  est['variants'].each do |var,vst|
@@ -277,6 +313,13 @@ module TrailGuide
277
313
  end
278
314
  end
279
315
 
316
+ def missing
317
+ TrailGuide.redis.keys.select do |key|
318
+ exp = key.split(':').first
319
+ find(exp).nil?
320
+ end
321
+ end
322
+
280
323
  def orphaned(key, trace)
281
324
  added = TrailGuide.redis.sadd("orphans:#{key}", trace)
282
325
  TrailGuide.redis.expire("orphans:#{key}", 15.minutes.seconds)
@@ -319,6 +362,7 @@ module TrailGuide
319
362
  end
320
363
  end
321
364
 
365
+ # TrailGuide.catalog
322
366
  def self.catalog
323
367
  TrailGuide::Catalog.catalog
324
368
  end