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.
- checksums.yaml +7 -0
- data/lib/growthbook.rb +9 -0
- data/lib/growthbook/client.rb +67 -0
- data/lib/growthbook/experiment.rb +72 -0
- data/lib/growthbook/experiment_result.rb +43 -0
- data/lib/growthbook/lookup_result.rb +44 -0
- data/lib/growthbook/user.rb +165 -0
- data/lib/growthbook/util.rb +51 -0
- metadata +77 -0
checksums.yaml
ADDED
@@ -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
|
data/lib/growthbook.rb
ADDED
@@ -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: []
|