trailguide 0.1.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.
@@ -0,0 +1,110 @@
1
+ module TrailGuide
2
+ class Participant
3
+ attr_reader :context
4
+ delegate :key?, :keys, :[], :[]=, :delete, :destroy!, :to_h, to: :adapter
5
+
6
+ def initialize(context, adapter: nil)
7
+ @context = context
8
+ @adapter = adapter.new(context) unless adapter.nil?
9
+ end
10
+
11
+ def adapter
12
+ @adapter ||= begin
13
+ config_adapter = TrailGuide.configuration.adapter
14
+ case config_adapter
15
+ when :cookie
16
+ config_adapter = TrailGuide::Adapters::Participants::Cookie
17
+ when :session
18
+ config_adapter = TrailGuide::Adapters::Participants::Session
19
+ when :redis
20
+ config_adapter = TrailGuide::Adapters::Participants::Redis
21
+ when :anonymous
22
+ config_adapter = TrailGuide::Adapters::Participants::Anonymous
23
+ when :multi
24
+ config_adapter = TrailGuide::Adapters::Participants::Multi
25
+ else
26
+ config_adapter = config_adapter.constantize if config_adapter.is_a?(String)
27
+ end
28
+ config_adapter.new(context)
29
+ end
30
+ end
31
+
32
+ def participating?(experiment, include_control=true)
33
+ return false unless adapter.key?(experiment.storage_key)
34
+ varname = adapter[experiment.storage_key]
35
+ variant = experiment.variants.find { |var| var == varname }
36
+ return false if !include_control && variant.control?
37
+ return false unless variant && adapter.key?(variant.storage_key)
38
+
39
+ chosen_at = Time.at(adapter[variant.storage_key].to_i)
40
+ chosen_at >= experiment.started_at
41
+ end
42
+
43
+ def converted?(experiment, checkpoint=nil)
44
+ if experiment.funnels.empty?
45
+ raise ArgumentError, "This experiment does not have any defined goal checkpoints" unless checkpoint.nil?
46
+ storage_key = "#{experiment.storage_key}:converted"
47
+ return false unless adapter.key?(storage_key)
48
+
49
+ converted_at = Time.at(adapter[storage_key].to_i)
50
+ converted_at >= experiment.started_at
51
+ elsif !checkpoint.nil?
52
+ raise ArgumentError, "Invalid goal checkpoint: #{checkpoint}" unless experiment.funnels.any? { |funnel| funnel == checkpoint.to_s.underscore.to_sym }
53
+ storage_key = "#{experiment.storage_key}:#{checkpoint.to_s.underscore}"
54
+ return false unless adapter.key?(storage_key)
55
+
56
+ converted_at = Time.at(adapter[storage_key].to_i)
57
+ converted_at >= experiment.started_at
58
+ else
59
+ experiment.funnels.each do |funnel|
60
+ storage_key = "#{experiment.storage_key}:#{funnel.to_s}"
61
+ next unless adapter.key?(storage_key)
62
+ converted_at = Time.at(adapter[storage_key].to_i)
63
+ return true if converted_at >= experiment.started_at
64
+ end
65
+ return false
66
+ end
67
+ end
68
+
69
+ def participating!(variant)
70
+ adapter[variant.experiment.storage_key] = variant.name
71
+ adapter[variant.storage_key] = Time.now.to_i
72
+ end
73
+
74
+ def converted!(variant, checkpoint=nil, reset: false)
75
+ checkpoint ||= :converted
76
+ storage_key = "#{variant.experiment.storage_key}:#{checkpoint.to_s.underscore}"
77
+
78
+ if reset
79
+ adapter.delete(variant.experiment.storage_key)
80
+ adapter.delete(variant.storage_key)
81
+ adapter.delete(storage_key)
82
+ experiment.funnels.each do |funnel|
83
+ funnel_key = "#{variant.experiment.storage_key}:#{funnel.to_s}"
84
+ adapter.delete(funnel_key)
85
+ end
86
+ else
87
+ adapter[storage_key] = Time.now.to_i
88
+ end
89
+ end
90
+
91
+ def active_experiments(include_control=true)
92
+ return false if adapter.keys.empty?
93
+ adapter.keys.map { |key| key.to_s.split(":").first.to_sym }.uniq.map do |key|
94
+ experiment = TrailGuide.catalog.find(key)
95
+ next unless experiment && experiment.started? && participating?(experiment, include_control)
96
+ [ experiment.experiment_name, adapter[experiment.storage_key] ]
97
+ end.compact.to_h
98
+ end
99
+
100
+ def participating_in_active_experiments?(include_control=true)
101
+ return false if adapter.keys.empty?
102
+
103
+ adapter.keys.any? do |key|
104
+ experiment_name = key.to_s.split(":").first.to_sym
105
+ experiment = TrailGuide.catalog.find(experiment_name)
106
+ experiment && experiment.started? && participating?(experiment, include_control)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,87 @@
1
+ module TrailGuide
2
+ class Unity
3
+ NAMESPACE = :unity
4
+
5
+ def self.clear!
6
+ keys = TrailGuide.redis.keys("#{NAMESPACE}:*")
7
+ TrailGuide.redis.del *keys unless keys.empty?
8
+ end
9
+
10
+ attr_reader :visitor_id, :user_id
11
+
12
+ def initialize(user_id: nil, visitor_id: nil)
13
+ @user_id = user_id.to_s if user_id.present?
14
+ @visitor_id = visitor_id.to_s if visitor_id.present?
15
+ end
16
+
17
+ def user_id=(uid)
18
+ @user_id = uid.to_s
19
+ end
20
+
21
+ def visitor_id=(vid)
22
+ @visitor_id = vid.to_s
23
+ end
24
+
25
+ def stored_user_id
26
+ TrailGuide.redis.get(visitor_key) if visitor_id.present?
27
+ end
28
+
29
+ def stored_visitor_id
30
+ TrailGuide.redis.get(user_key) if user_id.present?
31
+ end
32
+
33
+ def valid?
34
+ visitor_id.present? && user_id.present?
35
+ end
36
+
37
+ def stored?
38
+ stored_visitor_id.present? && stored_user_id.present?
39
+ end
40
+
41
+ def synced?
42
+ valid? && stored? &&
43
+ stored_visitor_id == visitor_id &&
44
+ stored_user_id == user_id
45
+ end
46
+
47
+ def sync!
48
+ @user_id ||= stored_user_id
49
+ @visitor_id ||= stored_visitor_id
50
+ delete!
51
+ save!
52
+ end
53
+
54
+ def save!
55
+ return false unless valid?
56
+ TrailGuide.redis.set(user_key, visitor_id)
57
+ TrailGuide.redis.set(visitor_key, user_id)
58
+ end
59
+
60
+ def delete!
61
+ keys = []
62
+ keys << stored_user_key if stored_user_id.present?
63
+ keys << stored_visitor_key if stored_visitor_id.present?
64
+ keys << user_key if user_id.present?
65
+ keys << visitor_key if visitor_id.present?
66
+ TrailGuide.redis.del(*keys) unless keys.empty?
67
+ end
68
+
69
+ protected
70
+
71
+ def user_key
72
+ "#{NAMESPACE}:uids:#{user_id}"
73
+ end
74
+
75
+ def visitor_key
76
+ "#{NAMESPACE}:vids:#{visitor_id}"
77
+ end
78
+
79
+ def stored_user_key
80
+ "#{NAMESPACE}:uids:#{stored_user_id}"
81
+ end
82
+
83
+ def stored_visitor_key
84
+ "#{NAMESPACE}:vids:#{stored_visitor_id}"
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,103 @@
1
+ module TrailGuide
2
+ class Variant
3
+ attr_reader :experiment, :name, :metadata, :weight
4
+
5
+ def initialize(experiment, name, metadata: {}, weight: 1, control: false)
6
+ @experiment = experiment
7
+ @name = name.to_s.underscore.to_sym
8
+ @metadata = metadata
9
+ @weight = weight
10
+ @control = control
11
+ end
12
+
13
+ def ==(other)
14
+ if other.is_a?(self.class)
15
+ return name == other.name && experiment == other.experiment
16
+ elsif other.is_a?(String) || other.is_a?(Symbol)
17
+ return name == other.to_s.underscore.to_sym
18
+ end
19
+ end
20
+
21
+ # TODO maybe track the control on the experiment itself, rather than as a
22
+ # flag on the variants like this?
23
+
24
+ # mark this variant as the control
25
+ def control!
26
+ @control = true
27
+ end
28
+
29
+ # check if this variant is the control
30
+ def control?
31
+ !!@control
32
+ end
33
+
34
+ # unmark this variant as the control
35
+ def variant!
36
+ @control = false
37
+ end
38
+
39
+ def persisted?
40
+ TrailGuide.redis.exists(storage_key)
41
+ end
42
+
43
+ def save!
44
+ TrailGuide.redis.hsetnx(storage_key, 'name', name)
45
+ end
46
+
47
+ def delete!
48
+ TrailGuide.redis.del(storage_key)
49
+ end
50
+
51
+ def reset!
52
+ delete! && save!
53
+ end
54
+
55
+ def participants
56
+ (TrailGuide.redis.hget(storage_key, 'participants') || 0).to_i
57
+ end
58
+
59
+ def converted(checkpoint=nil)
60
+ if experiment.funnels.empty?
61
+ raise ArgumentError, "This experiment does not have any defined goal checkpoints" unless checkpoint.nil?
62
+ (TrailGuide.redis.hget(storage_key, 'converted') || 0).to_i
63
+ elsif !checkpoint.nil?
64
+ raise ArgumentError, "Invalid goal checkpoint: #{checkpoint}" unless experiment.funnels.any? { |funnel| funnel == checkpoint.to_s.underscore.to_sym }
65
+ (TrailGuide.redis.hget(storage_key, checkpoint.to_s.underscore) || 0).to_i
66
+ else
67
+ experiment.funnels.sum do |checkpoint|
68
+ (TrailGuide.redis.hget(storage_key, checkpoint.to_s.underscore) || 0).to_i
69
+ end
70
+ end
71
+ end
72
+
73
+ def unconverted
74
+ participants - converted
75
+ end
76
+
77
+ def increment_participation!
78
+ TrailGuide.redis.hincrby(storage_key, 'participants', 1)
79
+ end
80
+
81
+ def increment_conversion!(checkpoint=nil)
82
+ checkpoint ||= :converted
83
+ TrailGuide.redis.hincrby(storage_key, checkpoint.to_s.underscore, 1)
84
+ end
85
+
86
+ def as_json(opts={})
87
+ {
88
+ name: name,
89
+ control: control?,
90
+ weight: weight,
91
+ metadata: metadata.as_json,
92
+ }
93
+ end
94
+
95
+ def to_s
96
+ name.to_s
97
+ end
98
+
99
+ def storage_key
100
+ "#{experiment.experiment_name}:#{name}"
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,14 @@
1
+ module TrailGuide
2
+ module Version
3
+ MAJOR = 0
4
+ MINOR = 1
5
+ PATCH = 0
6
+ VERSION = "#{MAJOR}.#{MINOR}.#{PATCH}"
7
+
8
+ class << self
9
+ def inspect
10
+ VERSION
11
+ end
12
+ end
13
+ end
14
+ end
data/lib/trailguide.rb ADDED
@@ -0,0 +1,57 @@
1
+ require "canfig"
2
+ require "redis"
3
+ require "trail_guide/adapters"
4
+ require "trail_guide/algorithms"
5
+ require "trail_guide/participant"
6
+ require "trail_guide/variant"
7
+ require "trail_guide/experiment"
8
+ require "trail_guide/catalog"
9
+ require "trail_guide/helper"
10
+ require "trail_guide/engine"
11
+
12
+ module TrailGuide
13
+ include Canfig::Module
14
+
15
+ configure do |config|
16
+ config.redis = ENV['REDIS_URL']
17
+ config.disabled = false
18
+ config.start_manually = true
19
+ config.reset_manually = true
20
+ config.store_override = false
21
+ config.override_parameter = :experiment
22
+ config.allow_multiple_experiments = true # false / :control
23
+ config.algorithm = :weighted
24
+ config.adapter = :multi
25
+
26
+ config.on_experiment_choose = nil # -> (experiment, variant, metadata) { ... }
27
+ config.on_experiment_use = nil # -> (experiment, variant, metadata) { ... }
28
+ config.on_experiment_convert = nil # -> (experiment, variant, checkpoint, metadata) { ... }
29
+
30
+ config.on_experiment_start = nil # -> (experiment) { ... }
31
+ config.on_experiment_stop = nil # -> (experiment) { ... }
32
+ config.on_experiment_reset = nil # -> (experiment) { ... }
33
+ config.on_experiment_delete = nil # -> (experiment) { ... }
34
+
35
+ config.filtered_user_agents = []
36
+ config.filtered_ip_addresses = []
37
+ config.request_filter = -> (context) do
38
+ is_preview? ||
39
+ is_filtered_user_agent? ||
40
+ is_filtered_ip_address?
41
+ end
42
+ end
43
+
44
+ def self.catalog
45
+ TrailGuide::Catalog.catalog
46
+ end
47
+
48
+ def self.redis
49
+ @redis ||= begin
50
+ if ['Redis', 'Redis::Namespace'].include?(configuration.redis.class.name)
51
+ configuration.redis
52
+ else
53
+ Redis.new(url: configuration.redis)
54
+ end
55
+ end
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: trailguide
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mark Rebec
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-03-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: canfig
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 0.0.7
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 0.0.7
41
+ - !ruby/object:Gem::Dependency
42
+ name: redis
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simple-random
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 0.9.3
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 0.9.3
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.3.6
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.3.6
83
+ - !ruby/object:Gem::Dependency
84
+ name: rspec-rails
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Perform user experiments and A/B tests in your rails apps
98
+ email:
99
+ - mark@markrebec.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - MIT-LICENSE
105
+ - README.md
106
+ - config/routes.rb
107
+ - lib/trail_guide/adapters.rb
108
+ - lib/trail_guide/adapters/participants.rb
109
+ - lib/trail_guide/adapters/participants/anonymous.rb
110
+ - lib/trail_guide/adapters/participants/cookie.rb
111
+ - lib/trail_guide/adapters/participants/multi.rb
112
+ - lib/trail_guide/adapters/participants/redis.rb
113
+ - lib/trail_guide/adapters/participants/session.rb
114
+ - lib/trail_guide/adapters/participants/unity.rb
115
+ - lib/trail_guide/algorithms.rb
116
+ - lib/trail_guide/algorithms/bandit.rb
117
+ - lib/trail_guide/algorithms/distributed.rb
118
+ - lib/trail_guide/algorithms/random.rb
119
+ - lib/trail_guide/algorithms/weighted.rb
120
+ - lib/trail_guide/catalog.rb
121
+ - lib/trail_guide/engine.rb
122
+ - lib/trail_guide/experiment.rb
123
+ - lib/trail_guide/helper.rb
124
+ - lib/trail_guide/participant.rb
125
+ - lib/trail_guide/unity.rb
126
+ - lib/trail_guide/variant.rb
127
+ - lib/trail_guide/version.rb
128
+ - lib/trailguide.rb
129
+ homepage: https://github.com/markrebec/trailguide
130
+ licenses:
131
+ - MIT
132
+ metadata: {}
133
+ post_install_message:
134
+ rdoc_options: []
135
+ require_paths:
136
+ - lib
137
+ required_ruby_version: !ruby/object:Gem::Requirement
138
+ requirements:
139
+ - - ">="
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ required_rubygems_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: '0'
147
+ requirements: []
148
+ rubyforge_project:
149
+ rubygems_version: 2.7.7
150
+ signing_key:
151
+ specification_version: 4
152
+ summary: User experiments for rails
153
+ test_files: []