growthbook 0.0.1

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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 91514b6437d92dc4d5ff6464f6ae4088dd2f57974fe60ac24d001eeca58ca498
4
+ data.tar.gz: 5ecb57a051072fd053838e8dfb0733bf879a44eddb68687583f1c7245496aae6
5
+ SHA512:
6
+ metadata.gz: 77a2244bec748a0d55666716b0e532430fc84c3f0d268bf44a54e60fd19ea92329af4cb2cdd19e9af90606845ae8d6a7db6d7ca066d1e1ee2af8a2b126593280
7
+ data.tar.gz: af03f9ed8278aaf28705b3eed68e81e9fe0f58e482e476ecfc8bc00c073663db68dcd7f70e2902be56a2debf2eb0e465ac29c6730d2b6377f71c862ee924b8b7
@@ -0,0 +1,9 @@
1
+ module Growthbook
2
+ end
3
+
4
+ require 'growthbook/client'
5
+ require 'growthbook/experiment_result'
6
+ require 'growthbook/lookup_result'
7
+ require 'growthbook/experiment'
8
+ require 'growthbook/util'
9
+ require 'growthbook/user'
@@ -0,0 +1,67 @@
1
+ module Growthbook
2
+ class Client
3
+ # @returns [Boolean]
4
+ attr_accessor :enabled
5
+
6
+ # @returns [Array<Growthbook::Experiment>]
7
+ attr_accessor :experiments
8
+
9
+ # @param config [Hash]
10
+ # @option config [Boolean] :enabled (true) Set to false to disable all experiments
11
+ # @option config [Array<Growthbook::Experiment>] :experiments ([]) Array of Growthbook::Experiment objects
12
+ def initialize(config = {})
13
+ @enabled = config.has_key?(:enabled) ? config[:enabled] : true
14
+ @experiments = config[:experiments] || []
15
+ @resultsToTrack = []
16
+ end
17
+
18
+ # Look up a pre-configured experiment by id
19
+ #
20
+ # @param id [String] The experiment id to look up
21
+ # @return [Growthbook::Experiment, nil] the experiment object or nil if not found
22
+ def getExperiment(id)
23
+ match = nil;
24
+ @experiments.each do |exp|
25
+ if exp.id == id
26
+ match = exp
27
+ break
28
+ end
29
+ end
30
+ return match
31
+ end
32
+
33
+ # Get a User object you can run experiments against
34
+ #
35
+ # @param params [Hash]
36
+ # @option params [String, nil] :id The logged-in user id
37
+ # @option params [String, nil] :anonId The anonymous id (session id, ip address, cookie, etc.)
38
+ # @option params [Hash, nil] :attributes Any user attributes you want to use for experiment targeting
39
+ # Values can be any type, even nested arrays and hashes
40
+ # @return [Growthbook::User] the User object
41
+ def user(params = {})
42
+ Growthbook::User.new(
43
+ params[:anonId] || nil,
44
+ params[:id] || nil,
45
+ params[:attributes] || nil,
46
+ self
47
+ )
48
+ end
49
+
50
+ def importExperimentsHash(experimentsHash = {})
51
+ @experiments = []
52
+ experimentsHash.each do |id, data|
53
+ variations = data["variations"]
54
+
55
+ options = {}
56
+ options[:coverage] = data["coverage"] if data.has_key?("coverage")
57
+ options[:weights] = data["weights"] if data.has_key?("weights")
58
+ options[:force] = data["force"] if data.has_key?("force")
59
+ options[:anon] = data["anon"] if data.has_key?("anon")
60
+ options[:targeting] = data["targeting"] if data.has_key?("targeting")
61
+ options[:data] = data["data"] if data.has_key?("data")
62
+
63
+ @experiments << Growthbook::Experiment.new(id, variations, options)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,72 @@
1
+ module Growthbook
2
+ class Experiment
3
+ # @returns [String]
4
+ attr_accessor :id
5
+
6
+ # @returns [Integer]
7
+ attr_accessor :variations
8
+
9
+ # @returns [Float]
10
+ attr_accessor :coverage
11
+
12
+ # @returns [Array<Float>]
13
+ attr_accessor :weights
14
+
15
+ # @returns [Boolean]
16
+ attr_accessor :anon
17
+
18
+ # @returns [Array<String>]
19
+ attr_accessor :targeting
20
+
21
+ # @returns [Integer, nil]
22
+ attr_accessor :force
23
+
24
+ # @returns [Hash]
25
+ attr_accessor :data
26
+
27
+ # Constructor for an Experiment
28
+ #
29
+ # @param id [String] The unique id for this experiment
30
+ # @param variations [Integer] The number of variations in this experiment (including the Control)
31
+ # @param options [Hash]
32
+ # @option options [Float] :coverage (1.0) The percent of elegible traffic to include in the experiment
33
+ # @option options [Array<Float>] :weights The relative weights of the variations.
34
+ # Length must be the same as the number of variations. Total should add to 1.0.
35
+ # Default is an even split between variations
36
+ # @option options [Boolean] :anon (false) If false, the experiment uses the logged-in user id for bucketing
37
+ # If true, the experiment uses the anonymous id for bucketing
38
+ # @option options [Array<String>] :targeting Array of targeting rules in the format "key op value"
39
+ # where op is one of: =, !=, <, >, ~, !~
40
+ # @option options [Integer, nil] :force If an integer, force all users to get this variation
41
+ # @option options [Hash] :data Data to attach to the variations
42
+ def initialize(id, variations, options = {})
43
+ @id = id
44
+ @variations = variations
45
+ @coverage = options[:coverage] || 1
46
+ @weights = options[:weights] || getEqualWeights()
47
+ @force = options.has_key?(:force) ? options[:force] : nil
48
+ @anon = options.has_key?(:anon) ? options[:anon] : false
49
+ @targeting = options[:targeting] || []
50
+ @data = options[:data] || {}
51
+ end
52
+
53
+ def getScaledWeights
54
+ scaled = @weights.map do |n|
55
+ n*@coverage
56
+ end
57
+
58
+ return scaled
59
+ end
60
+
61
+ private
62
+
63
+ def getEqualWeights
64
+ weights = []
65
+ n = @variations
66
+ for i in 1..n
67
+ weights << (1.0 / n)
68
+ end
69
+ return weights
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,43 @@
1
+ module Growthbook
2
+ class ExperimentResult
3
+ # The experiment that was performed
4
+ # @return [Growthbook::Experiment, nil] If nil, then the experiment with the required id could not be found
5
+ attr_reader :experiment
6
+
7
+ # The user that was experimented on
8
+ # @return [Growthbook::User]
9
+ attr_reader :user
10
+
11
+ # The chosen variation. -1 for "not in experiment", 0 for control, 1 for 1st variation, etc.
12
+ # @return [Integer]
13
+ attr_reader :variation
14
+
15
+ # The data tied to the chosen variation
16
+ # @return [Hash]
17
+ attr_reader :data
18
+
19
+ @forced = false
20
+
21
+ def forced?
22
+ @forced
23
+ end
24
+
25
+ def shouldTrack?
26
+ !@forced && @variation >= 0
27
+ end
28
+
29
+ def initialize(user = nil, experiment = nil, variation = -1, forced = false)
30
+ @experiment = experiment
31
+ @variation = variation
32
+ @forced = forced
33
+
34
+ @data = {}
35
+ if experiment && experiment.data
36
+ var = variation < 0 ? 0 : variation
37
+ experiment.data.each do |k, v|
38
+ @data[k] = v[var]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ module Growthbook
2
+ class LookupResult
3
+ # The first matching experiment
4
+ # @return [Growthbook::Experiment]
5
+ attr_reader :experiment
6
+
7
+ # The chosen variation. -1 for "not in experiment", 0 for control, 1 for 1st variation, etc.
8
+ # @return [Integer]
9
+ attr_reader :variation
10
+
11
+ # The data tied to the chosen variation
12
+ # @return [Hash]
13
+ attr_reader :data
14
+
15
+ # The value of the data key that was used to lookup the experiment
16
+ attr_reader :value
17
+
18
+ @forced
19
+
20
+ def forced?
21
+ @forced
22
+ end
23
+
24
+ def shouldTrack?
25
+ !@forced && @variation >= 0
26
+ end
27
+
28
+ def initialize(result, key)
29
+ @experiment = result.experiment
30
+ @variation = result.variation
31
+ @forced = result.forced?
32
+
33
+ @data = {}
34
+ if @experiment && @experiment.data
35
+ var = @variation <0 ? 0 : @variation
36
+ @experiment.data.each do |k, v|
37
+ @data[k] = v[var]
38
+ end
39
+ end
40
+
41
+ @value = @data[key] || nil
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,165 @@
1
+ require 'set'
2
+
3
+ module Growthbook
4
+ class User
5
+ # @returns [String, nil]
6
+ attr_accessor :id
7
+
8
+ # @returns [String, nil]
9
+ attr_accessor :anonId
10
+
11
+ # @returns [Hash, nil]
12
+ attr_reader :attributes
13
+
14
+ # @returns [Array<Growthbook::ExperimentResult>]
15
+ attr_reader :resultsToTrack
16
+
17
+ @client
18
+ @attributeMap = {}
19
+ @experimentsTracked
20
+
21
+ def initialize(anonId, id, attributes, client)
22
+ @anonId = anonId
23
+ @id = id
24
+ @attributes = attributes
25
+ @client = client
26
+ updateAttributeMap
27
+
28
+ @resultsToTrack = []
29
+ @experimentsTracked = Set[]
30
+ end
31
+
32
+ # Set the user attributes
33
+ #
34
+ # @params attributes [Hash, nil] Any user attributes you want to use for experiment targeting
35
+ # Values can be any type, even nested arrays and hashes
36
+ def attributes=(attributes)
37
+ @attributes = attributes
38
+ updateAttributeMap
39
+ end
40
+
41
+ # Run an experiment on this user
42
+ # @param experiment [Growthbook::Experiment, String] If string, will lookup the experiment by id in the client
43
+ # @return [Growthbook::ExperimentResult]
44
+ def experiment(experiment)
45
+ # If experiments are disabled globally
46
+ return getExperimentResult unless @client.enabled
47
+
48
+ # Make sure experiment is always an object (or nil)
49
+ id = ""
50
+ if experiment.is_a? String
51
+ id = experiment
52
+ experiment = @client.getExperiment(id)
53
+ else
54
+ id = experiment.id
55
+ override = @client.getExperiment(id)
56
+ experiment = override if override
57
+ end
58
+
59
+ # No experiment found
60
+ return getExperimentResult unless experiment
61
+
62
+ # User missing required user id type
63
+ userId = experiment.anon ? @anonId : @id
64
+ if !userId
65
+ return getExperimentResult(experiment)
66
+ end
67
+
68
+ # Experiment has targeting rules, check if user passes
69
+ if experiment.targeting
70
+ return getExperimentResult(experiment) unless isTargeted(experiment.targeting)
71
+ end
72
+
73
+ # Experiment has a specific variation forced
74
+ if experiment.force != nil
75
+ return getExperimentResult(experiment, experiment.force, true)
76
+ end
77
+
78
+ # Choose a variation for the user
79
+ variation = Growthbook::Util.chooseVariation(userId, experiment)
80
+ result = getExperimentResult(experiment, variation)
81
+
82
+ # Add to the list of experiments that should be tracked in analytics
83
+ if result.shouldTrack? && !@experimentsTracked.include?(experiment.id)
84
+ @experimentsTracked << experiment.id
85
+ @resultsToTrack << result
86
+ end
87
+
88
+ return result
89
+ end
90
+
91
+ # Run the first matching experiment that defines variation data for the requested key
92
+ # @param key [String, Symbol] The key to look up
93
+ # @return [Growthbook::LookupResult, nil] If nil, no matching experiments found
94
+ def lookupByDataKey(key)
95
+ @client.experiments.each do |exp|
96
+ if exp.data && exp.data.key?(key)
97
+ ret = experiment(exp)
98
+ if ret.variation >= 0
99
+ return Growthbook::LookupResult.new(ret, key)
100
+ end
101
+ end
102
+ end
103
+
104
+ return nil
105
+ end
106
+
107
+ private
108
+
109
+ def getExperimentResult(experiment = nil, variation = -1, forced = false)
110
+ Growthbook::ExperimentResult.new(self, experiment, variation, forced)
111
+ end
112
+
113
+ def flattenUserValues(prefix, val)
114
+ if val.nil?
115
+ return []
116
+ end
117
+
118
+ if val.is_a? Hash
119
+ ret = []
120
+ val.each do |k, v|
121
+ ret.concat(flattenUserValues(prefix.length>0 ? prefix.to_s + "." + k.to_s : k.to_s, v))
122
+ end
123
+ return ret
124
+ end
125
+
126
+ if val.is_a? Array
127
+ val = val.join ","
128
+ elsif !!val == val
129
+ val = val ? "true" : "false"
130
+ end
131
+
132
+ return [
133
+ {
134
+ "k" => prefix.to_s,
135
+ "v" => val.to_s
136
+ }
137
+ ]
138
+ end
139
+
140
+ def updateAttributeMap
141
+ @attributeMap = {}
142
+ flat = flattenUserValues("", @attributes)
143
+ flat.each do |item|
144
+ @attributeMap[item["k"]] = item["v"]
145
+ end
146
+ end
147
+
148
+ def isTargeted(rules)
149
+ pass = true
150
+ rules.each do |rule|
151
+ parts = rule.split(" ", 3)
152
+ if parts.length == 3
153
+ key = parts[0].strip
154
+ actual = @attributeMap[key] || ""
155
+ if !Growthbook::Util.checkRule(actual, parts[1].strip, parts[2].strip)
156
+ pass = false
157
+ break
158
+ end
159
+ end
160
+ end
161
+
162
+ return pass
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,51 @@
1
+ require "fnv"
2
+
3
+ module Growthbook
4
+ class Util
5
+ def self.checkRule(actual, op, desired)
6
+ # Check if both strings are numeric so we can do natural ordering
7
+ # for greater than / less than operators
8
+ numeric = (Float(actual) != nil && Float(desired) != nil) rescue false
9
+
10
+ case op
11
+ when "="
12
+ numeric ? Float(actual) == Float(desired) : actual == desired
13
+ when "!="
14
+ numeric ? Float(actual) != Float(desired) : actual != desired
15
+ when ">"
16
+ numeric ? Float(actual) > Float(desired) : actual > desired
17
+ when "<"
18
+ numeric ? Float(actual) < Float(desired) : actual < desired
19
+ when "~"
20
+ !!(actual =~ Regexp.new(desired)) rescue false
21
+ when "!~"
22
+ !(actual =~ Regexp.new(desired)) rescue false
23
+ else
24
+ true
25
+ end
26
+ end
27
+
28
+ def self.chooseVariation(userId, experiment)
29
+ testId = experiment.id
30
+ weights = experiment.getScaledWeights()
31
+
32
+ # Hash the user id and testName to a number from 0 to 1
33
+ n = (FNV.new.fnv1a_32(userId + testId)%1000)/1000.0
34
+
35
+ cumulativeWeight = 0
36
+
37
+ match = -1
38
+ i = 0
39
+ weights.each do |weight|
40
+ cumulativeWeight += weight
41
+ if n < cumulativeWeight
42
+ match = i
43
+ break
44
+ end
45
+ i+=1
46
+ end
47
+
48
+ return match
49
+ end
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: growthbook
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - GrowthBook
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-12-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.2'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: fnv
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.2.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.2.0
41
+ description: Official GrowthBook SDK for Ruby
42
+ email: jeremy@growthbook.io
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/growthbook.rb
48
+ - lib/growthbook/client.rb
49
+ - lib/growthbook/experiment.rb
50
+ - lib/growthbook/experiment_result.rb
51
+ - lib/growthbook/lookup_result.rb
52
+ - lib/growthbook/user.rb
53
+ - lib/growthbook/util.rb
54
+ homepage: https://github.com/growthbook/growthbook-ruby
55
+ licenses:
56
+ - MIT
57
+ metadata: {}
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.1.2
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: GrowthBook SDK for Ruby
77
+ test_files: []