growthbook 0.3.0 → 1.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.
- checksums.yaml +4 -4
- data/lib/growthbook/conditions.rb +15 -14
- data/lib/growthbook/context.rb +172 -50
- data/lib/growthbook/decryption_util.rb +35 -0
- data/lib/growthbook/feature.rb +4 -3
- data/lib/growthbook/feature_repository.rb +87 -0
- data/lib/growthbook/feature_result.rb +3 -2
- data/lib/growthbook/feature_rule.rb +95 -33
- data/lib/growthbook/fnv.rb +23 -0
- data/lib/growthbook/inline_experiment.rb +71 -48
- data/lib/growthbook/inline_experiment_result.rb +57 -38
- data/lib/growthbook/tracking_callback.rb +8 -0
- data/lib/growthbook/util.rb +42 -49
- data/lib/growthbook.rb +5 -5
- metadata +56 -26
- data/lib/growthbook/client.rb +0 -67
- data/lib/growthbook/experiment.rb +0 -72
- data/lib/growthbook/experiment_result.rb +0 -43
- data/lib/growthbook/lookup_result.rb +0 -44
- data/lib/growthbook/user.rb +0 -165
- data/spec/cases.json +0 -2923
- data/spec/client_spec.rb +0 -57
- data/spec/context_spec.rb +0 -124
- data/spec/json_spec.rb +0 -160
- data/spec/user_spec.rb +0 -213
- data/spec/util_spec.rb +0 -154
@@ -1,37 +1,82 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Growthbook
|
4
|
+
# Internal class that overrides the default value of a Feature based on a set of requirements.
|
4
5
|
class FeatureRule
|
5
|
-
# @return [Hash , nil]
|
6
|
+
# @return [Hash , nil] Optional targeting condition
|
6
7
|
attr_reader :condition
|
7
|
-
|
8
|
+
|
9
|
+
# @return [Float , nil] What percent of users should be included in the experiment (between 0 and 1, inclusive)
|
8
10
|
attr_reader :coverage
|
9
|
-
|
11
|
+
|
12
|
+
# @return [T , nil] Immediately force a specific value (ignore every other option besides condition and coverage)
|
10
13
|
attr_reader :force
|
11
|
-
|
14
|
+
|
15
|
+
# @return [T[] , nil] Run an experiment (A/B test) and randomly choose between these variations
|
12
16
|
attr_reader :variations
|
13
|
-
|
17
|
+
|
18
|
+
# @return [String , nil] The globally unique tracking key for the experiment (default to the feature key)
|
14
19
|
attr_reader :key
|
15
|
-
|
20
|
+
|
21
|
+
# @return [Float[] , nil] How to weight traffic between variations. Must add to 1.
|
16
22
|
attr_reader :weights
|
17
|
-
|
23
|
+
|
24
|
+
# @return [String , nil] Adds the experiment to a namespace
|
18
25
|
attr_reader :namespace
|
19
|
-
|
26
|
+
|
27
|
+
# @return [String , nil] What user attribute should be used to assign variations (defaults to id)
|
20
28
|
attr_reader :hash_attribute
|
21
29
|
|
30
|
+
# @return [Integer , nil] The hash version to use (default to 1)
|
31
|
+
attr_reader :hash_version
|
32
|
+
|
33
|
+
# @return [BucketRange , nil] A more precise version of coverage
|
34
|
+
attr_reader :range
|
35
|
+
|
36
|
+
# @return [BucketRanges[] , nil] Ranges for experiment variations
|
37
|
+
attr_reader :ranges
|
38
|
+
|
39
|
+
# @return [VariationMeta[] , nil] Meta info about the experiment variations
|
40
|
+
attr_reader :meta
|
41
|
+
|
42
|
+
# @return [Filter[] , nil] Array of filters to apply to the rule
|
43
|
+
attr_reader :filters
|
44
|
+
|
45
|
+
# @return [String , nil] Seed to use for hashing
|
46
|
+
attr_reader :seed
|
47
|
+
|
48
|
+
# @return [String , nil] Human-readable name for the experiment
|
49
|
+
attr_reader :name
|
50
|
+
|
51
|
+
# @return [String , nil] The phase id of the experiment
|
52
|
+
attr_reader :phase
|
53
|
+
|
54
|
+
# @return [TrackData[] , nil] Array of tracking calls to fire
|
55
|
+
attr_reader :tracks
|
56
|
+
|
22
57
|
def initialize(rule)
|
23
|
-
@coverage =
|
24
|
-
@force =
|
25
|
-
@variations =
|
26
|
-
@key =
|
27
|
-
@weights =
|
28
|
-
@namespace =
|
29
|
-
@hash_attribute =
|
30
|
-
|
31
|
-
|
58
|
+
@coverage = get_option(rule, :coverage)
|
59
|
+
@force = get_option(rule, :force)
|
60
|
+
@variations = get_option(rule, :variations)
|
61
|
+
@key = get_option(rule, :key)
|
62
|
+
@weights = get_option(rule, :weights)
|
63
|
+
@namespace = get_option(rule, :namespace)
|
64
|
+
@hash_attribute = get_option(rule, :hash_attribute) || get_option(rule, :hashAttribute)
|
65
|
+
@hash_version = get_option(rule, :hash_version) || get_option(rule, :hashVersion)
|
66
|
+
@range = get_option(rule, :range)
|
67
|
+
@ranges = get_option(rule, :ranges)
|
68
|
+
@meta = get_option(rule, :meta)
|
69
|
+
@filters = get_option(rule, :filters)
|
70
|
+
@seed = get_option(rule, :seed)
|
71
|
+
@name = get_option(rule, :name)
|
72
|
+
@phase = get_option(rule, :phase)
|
73
|
+
@tracks = get_option(rule, :tracks)
|
74
|
+
|
75
|
+
cond = get_option(rule, :condition)
|
32
76
|
@condition = Growthbook::Conditions.parse_condition(cond) unless cond.nil?
|
33
77
|
end
|
34
78
|
|
79
|
+
# @return [Growthbook::InlineExperiment, nil]
|
35
80
|
def to_experiment(feature_key)
|
36
81
|
return nil unless @variations
|
37
82
|
|
@@ -41,34 +86,51 @@ module Growthbook
|
|
41
86
|
coverage: @coverage,
|
42
87
|
weights: @weights,
|
43
88
|
hash_attribute: @hash_attribute,
|
44
|
-
|
89
|
+
hash_version: @hash_version,
|
90
|
+
namespace: @namespace,
|
91
|
+
meta: @meta,
|
92
|
+
ranges: @ranges,
|
93
|
+
filters: @filters,
|
94
|
+
name: @name,
|
95
|
+
phase: @phase,
|
96
|
+
seed: @seed
|
45
97
|
)
|
46
98
|
end
|
47
99
|
|
48
|
-
def
|
49
|
-
|
100
|
+
def experiment?
|
101
|
+
return false if @variations.nil?
|
102
|
+
|
103
|
+
!@variations&.empty?
|
50
104
|
end
|
51
105
|
|
52
|
-
def
|
53
|
-
!
|
106
|
+
def force?
|
107
|
+
!experiment? && !@force.nil?
|
54
108
|
end
|
55
109
|
|
56
110
|
def to_json(*_args)
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
111
|
+
{
|
112
|
+
'condition' => @condition,
|
113
|
+
'coverage' => @coverage,
|
114
|
+
'force' => @force,
|
115
|
+
'variations' => @variations,
|
116
|
+
'key' => @key,
|
117
|
+
'weights' => @weights,
|
118
|
+
'namespace' => @namespace,
|
119
|
+
'hashAttribute' => @hash_attribute,
|
120
|
+
'range' => @range,
|
121
|
+
'ranges' => @ranges,
|
122
|
+
'meta' => @meta,
|
123
|
+
'filters' => @filters,
|
124
|
+
'seed' => @seed,
|
125
|
+
'name' => @name,
|
126
|
+
'phase' => @phase,
|
127
|
+
'tracks' => @tracks
|
128
|
+
}.compact
|
67
129
|
end
|
68
130
|
|
69
131
|
private
|
70
132
|
|
71
|
-
def
|
133
|
+
def get_option(hash, key)
|
72
134
|
return hash[key.to_sym] if hash.key?(key.to_sym)
|
73
135
|
return hash[key.to_s] if hash.key?(key.to_s)
|
74
136
|
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# FNV
|
4
|
+
# {https://github.com/jakedouglas/fnv-ruby Source}
|
5
|
+
class FNV
|
6
|
+
INIT32 = 0x811c9dc5
|
7
|
+
INIT64 = 0xcbf29ce484222325
|
8
|
+
PRIME32 = 0x01000193
|
9
|
+
PRIME64 = 0x100000001b3
|
10
|
+
MOD32 = 4_294_967_296
|
11
|
+
MOD64 = 18_446_744_073_709_551_616
|
12
|
+
|
13
|
+
def fnv1a_32(data)
|
14
|
+
hash = INIT32
|
15
|
+
|
16
|
+
data.bytes.each do |byte|
|
17
|
+
hash = hash ^ byte
|
18
|
+
hash = (hash * PRIME32) % MOD32
|
19
|
+
end
|
20
|
+
|
21
|
+
hash
|
22
|
+
end
|
23
|
+
end
|
@@ -1,80 +1,103 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Growthbook
|
4
|
+
# Class for creating an inline experiment for evaluating
|
4
5
|
class InlineExperiment
|
5
|
-
# @
|
6
|
+
# @return [String] The globally unique identifier for the experiment
|
6
7
|
attr_accessor :key
|
7
8
|
|
8
|
-
# @
|
9
|
+
# @return [Array<String, Integer, Hash>] The different variations to choose between
|
9
10
|
attr_accessor :variations
|
10
11
|
|
11
|
-
# @
|
12
|
-
attr_accessor :active
|
13
|
-
|
14
|
-
# @returns [Integer, nil]
|
15
|
-
attr_accessor :force
|
16
|
-
|
17
|
-
# @returns [Array<Float>, nil]
|
12
|
+
# @return [Array<Float>] How to weight traffic between variations. Must add to 1.
|
18
13
|
attr_accessor :weights
|
19
14
|
|
20
|
-
# @
|
15
|
+
# @return [true, false] If set to false, always return the control (first variation)
|
16
|
+
attr_accessor :active
|
17
|
+
|
18
|
+
# @return [Float] What percent of users should be included in the experiment (between 0 and 1, inclusive)
|
21
19
|
attr_accessor :coverage
|
22
20
|
|
23
|
-
# @
|
21
|
+
# @return [Array<Hash>] Array of ranges, one per variation
|
22
|
+
attr_accessor :ranges
|
23
|
+
|
24
|
+
# @return [Hash] Optional targeting condition
|
24
25
|
attr_accessor :condition
|
25
26
|
|
26
|
-
# @
|
27
|
+
# @return [String, nil] Adds the experiment to a namespace
|
27
28
|
attr_accessor :namespace
|
28
29
|
|
29
|
-
# @
|
30
|
+
# @return [integer, nil] All users included in the experiment will be forced into the specific variation index
|
31
|
+
attr_accessor :force
|
32
|
+
|
33
|
+
# @return [String] What user attribute should be used to assign variations (defaults to id)
|
30
34
|
attr_accessor :hash_attribute
|
31
35
|
|
32
|
-
#
|
33
|
-
|
34
|
-
# @param options [Hash]
|
35
|
-
# @option options [Array<Any>] :variations The variations to pick between
|
36
|
-
# @option options [String] :key The unique identifier for this experiment
|
37
|
-
# @option options [Float] :coverage (1.0) The percent of elegible traffic to include in the experiment
|
38
|
-
# @option options [Array<Float>] :weights The relative weights of the variations.
|
39
|
-
# Length must be the same as the number of variations. Total should add to 1.0.
|
40
|
-
# Default is an even split between variations
|
41
|
-
# @option options [Boolean] :anon (false) If false, the experiment uses the logged-in user id for bucketing
|
42
|
-
# If true, the experiment uses the anonymous id for bucketing
|
43
|
-
# @option options [Array<String>] :targeting Array of targeting rules in the format "key op value"
|
44
|
-
# where op is one of: =, !=, <, >, ~, !~
|
45
|
-
# @option options [Integer, nil] :force If an integer, force all users to get this variation
|
46
|
-
# @option options [Hash] :data Data to attach to the variations
|
47
|
-
def initialize(options = {})
|
48
|
-
@key = getOption(options, :key, '').to_s
|
49
|
-
@variations = getOption(options, :variations, [])
|
50
|
-
@active = getOption(options, :active, true)
|
51
|
-
@force = getOption(options, :force)
|
52
|
-
@weights = getOption(options, :weights)
|
53
|
-
@coverage = getOption(options, :coverage, 1)
|
54
|
-
@condition = getOption(options, :condition)
|
55
|
-
@namespace = getOption(options, :namespace)
|
56
|
-
@hash_attribute = getOption(options, :hash_attribute) || getOption(options, :hashAttribute) || 'id'
|
57
|
-
end
|
36
|
+
# @return [Integer] The hash version to use (default to 1)
|
37
|
+
attr_accessor :hash_version
|
58
38
|
|
59
|
-
|
60
|
-
|
61
|
-
return hash[key.to_s] if hash.key?(key.to_s)
|
39
|
+
# @return [Array<Hash>] Meta info about the variations
|
40
|
+
attr_accessor :meta
|
62
41
|
|
63
|
-
|
42
|
+
# @return [Array<Hash>] Array of filters to apply
|
43
|
+
attr_accessor :filters
|
44
|
+
|
45
|
+
# @return [String, nil] The hash seed to use
|
46
|
+
attr_accessor :seed
|
47
|
+
|
48
|
+
# @return [String] Human-readable name for the experiment
|
49
|
+
attr_accessor :name
|
50
|
+
|
51
|
+
# @return [String, nil] Id of the current experiment phase
|
52
|
+
attr_accessor :phase
|
53
|
+
|
54
|
+
def initialize(options = {})
|
55
|
+
@key = get_option(options, :key, '').to_s
|
56
|
+
@variations = get_option(options, :variations, [])
|
57
|
+
@weights = get_option(options, :weights)
|
58
|
+
@active = get_option(options, :active, true)
|
59
|
+
@coverage = get_option(options, :coverage, 1.0)
|
60
|
+
@ranges = get_option(options, :ranges)
|
61
|
+
@condition = get_option(options, :condition)
|
62
|
+
@namespace = get_option(options, :namespace)
|
63
|
+
@force = get_option(options, :force)
|
64
|
+
@hash_attribute = get_option(options, :hash_attribute) || get_option(options, :hashAttribute) || 'id'
|
65
|
+
@hash_version = get_option(options, :hash_version) || get_option(options, :hashVersion)
|
66
|
+
@meta = get_option(options, :meta)
|
67
|
+
@filters = get_option(options, :filters)
|
68
|
+
@seed = get_option(options, :seed)
|
69
|
+
@name = get_option(options, :name)
|
70
|
+
@phase = get_option(options, :phase)
|
64
71
|
end
|
65
72
|
|
66
73
|
def to_json(*_args)
|
67
74
|
res = {}
|
68
75
|
res['key'] = @key
|
69
76
|
res['variations'] = @variations
|
70
|
-
res['active'] = @active if @active != true && !@active.nil?
|
71
|
-
res['force'] = @force unless @force.nil?
|
72
77
|
res['weights'] = @weights unless @weights.nil?
|
78
|
+
res['active'] = @active if @active != true && !@active.nil?
|
73
79
|
res['coverage'] = @coverage if @coverage != 1 && !@coverage.nil?
|
74
|
-
res['
|
75
|
-
res['
|
80
|
+
res['ranges'] = @ranges
|
81
|
+
res['condition'] = @condition
|
82
|
+
res['namespace'] = @namespace
|
83
|
+
res['force'] = @force unless @force.nil?
|
76
84
|
res['hashAttribute'] = @hash_attribute if @hash_attribute != 'id' && !@hash_attribute.nil?
|
77
|
-
res
|
85
|
+
res['hashVersion'] = @hash_version
|
86
|
+
res['meta'] = @meta
|
87
|
+
res['filters'] = @filters
|
88
|
+
res['seed'] = @seed
|
89
|
+
res['name'] = @name
|
90
|
+
res['phase'] = @phase
|
91
|
+
res.compact
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def get_option(hash, key, default = nil)
|
97
|
+
return hash[key.to_sym] if hash.key?(key.to_sym)
|
98
|
+
return hash[key.to_s] if hash.key?(key.to_s)
|
99
|
+
|
100
|
+
default
|
78
101
|
end
|
79
102
|
end
|
80
103
|
end
|
@@ -1,62 +1,81 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Growthbook
|
4
|
+
# Result of running an experiment.
|
4
5
|
class InlineExperimentResult
|
5
|
-
# Whether or not the user is
|
6
|
-
# @return [Bool]
|
6
|
+
# @return [Boolean] Whether or not the user is part of the experiment
|
7
7
|
attr_reader :in_experiment
|
8
8
|
|
9
|
-
# The array index of the assigned variation
|
10
|
-
# @return [Integer]
|
9
|
+
# @return [Integer] The array index of the assigned variation
|
11
10
|
attr_reader :variation_id
|
12
11
|
|
13
|
-
# The assigned variation
|
14
|
-
# @return [Any]
|
12
|
+
# @return [Any] The array value of the assigned variation
|
15
13
|
attr_reader :value
|
16
14
|
|
17
|
-
# If
|
18
|
-
# @return [Bool]
|
15
|
+
# @return [Bool] If a hash was used to assign a variation
|
19
16
|
attr_reader :hash_used
|
20
17
|
|
21
|
-
# The attribute used to
|
22
|
-
# @return [String]
|
18
|
+
# @return [String] The user attribute used to assign a variation
|
23
19
|
attr_reader :hash_attribute
|
24
20
|
|
25
|
-
# The value of
|
26
|
-
# @return [String]
|
21
|
+
# @return [String] The value of that attribute
|
27
22
|
attr_reader :hash_value
|
28
23
|
|
24
|
+
# @return [String, nil] The id of the feature (if any) that the experiment came from
|
29
25
|
attr_reader :feature_id
|
30
26
|
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
@
|
45
|
-
@
|
46
|
-
@
|
47
|
-
@
|
27
|
+
# @return [String] The unique key for the assigned variation
|
28
|
+
attr_reader :key
|
29
|
+
|
30
|
+
# @return [Float] The hash value used to assign a variation (float from 0 to 1)
|
31
|
+
attr_reader :bucket
|
32
|
+
|
33
|
+
# @return [String , nil] Human-readable name for the experiment
|
34
|
+
attr_reader :name
|
35
|
+
|
36
|
+
# @return [Boolean] Used for holdout groups
|
37
|
+
attr_accessor :passthrough
|
38
|
+
|
39
|
+
def initialize(options = {})
|
40
|
+
@key = options[:key]
|
41
|
+
@in_experiment = options[:in_experiment]
|
42
|
+
@variation_id = options[:variation_id]
|
43
|
+
@value = options[:value]
|
44
|
+
@hash_used = options[:hash_used]
|
45
|
+
@hash_attribute = options[:hash_attribute]
|
46
|
+
@hash_value = options[:hash_value]
|
47
|
+
@feature_id = options[:feature_id]
|
48
|
+
@bucket = options[:bucket]
|
49
|
+
@name = options[:name]
|
50
|
+
@passthrough = options[:passthrough]
|
51
|
+
end
|
52
|
+
|
53
|
+
# If the variation was randomly assigned based on user attribute hashes
|
54
|
+
# @return [Bool]
|
55
|
+
def hash_used?
|
56
|
+
@hash_used
|
57
|
+
end
|
58
|
+
|
59
|
+
# Whether or not the user is in the experiment
|
60
|
+
# @return [Bool]
|
61
|
+
def in_experiment?
|
62
|
+
@in_experiment
|
48
63
|
end
|
49
64
|
|
50
65
|
def to_json(*_args)
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
66
|
+
{
|
67
|
+
'inExperiment' => @in_experiment,
|
68
|
+
'variationId' => @variation_id,
|
69
|
+
'value' => @value,
|
70
|
+
'hashUsed' => @hash_used,
|
71
|
+
'hashAttribute' => @hash_attribute,
|
72
|
+
'hashValue' => @hash_value,
|
73
|
+
'featureId' => @feature_id.to_s,
|
74
|
+
'key' => @key.to_s,
|
75
|
+
'bucket' => @bucket,
|
76
|
+
'name' => @name,
|
77
|
+
'passthrough' => @passthrough
|
78
|
+
}.compact
|
60
79
|
end
|
61
80
|
end
|
62
81
|
end
|
data/lib/growthbook/util.rb
CHANGED
@@ -1,10 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'base64'
|
4
|
+
require 'bigdecimal'
|
5
|
+
require 'bigdecimal/util'
|
4
6
|
|
5
7
|
module Growthbook
|
8
|
+
# internal use only
|
6
9
|
class Util
|
7
|
-
def self.
|
10
|
+
def self.check_rule(actual, op, desired)
|
8
11
|
# Check if both strings are numeric so we can do natural ordering
|
9
12
|
# for greater than / less than operators
|
10
13
|
numeric = begin
|
@@ -15,9 +18,9 @@ module Growthbook
|
|
15
18
|
|
16
19
|
case op
|
17
20
|
when '='
|
18
|
-
numeric ? Float(actual) == Float(desired) : actual == desired
|
21
|
+
numeric ? Float(actual).to_d == Float(desired).to_d : actual == desired
|
19
22
|
when '!='
|
20
|
-
numeric ? Float(actual) != Float(desired) : actual != desired
|
23
|
+
numeric ? Float(actual).to_d != Float(desired).to_d : actual != desired
|
21
24
|
when '>'
|
22
25
|
numeric ? Float(actual) > Float(desired) : actual > desired
|
23
26
|
when '<'
|
@@ -39,100 +42,86 @@ module Growthbook
|
|
39
42
|
end
|
40
43
|
end
|
41
44
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
+
# @return [Float, nil] Hash, or nil if the hash version is invalid
|
46
|
+
def self.get_hash(seed:, value:, version:)
|
47
|
+
return (FNV.new.fnv1a_32(value + seed) % 1000) / 1000.0 if version == 1
|
48
|
+
return (FNV.new.fnv1a_32(FNV.new.fnv1a_32(seed + value).to_s) % 10_000) / 10_000.0 if version == 2
|
45
49
|
|
46
|
-
|
47
|
-
n = (FNV.new.fnv1a_32(userId + testId) % 1000) / 1000.0
|
48
|
-
|
49
|
-
cumulativeWeight = 0
|
50
|
-
|
51
|
-
match = -1
|
52
|
-
i = 0
|
53
|
-
weights.each do |weight|
|
54
|
-
cumulativeWeight += weight
|
55
|
-
if n < cumulativeWeight
|
56
|
-
match = i
|
57
|
-
break
|
58
|
-
end
|
59
|
-
i += 1
|
60
|
-
end
|
61
|
-
|
62
|
-
match
|
50
|
+
nil
|
63
51
|
end
|
64
52
|
|
65
|
-
def self.
|
66
|
-
|
67
|
-
|
53
|
+
def self.in_namespace?(hash_value, namespace)
|
54
|
+
return false if namespace.nil?
|
55
|
+
|
56
|
+
n = get_hash(seed: "__#{namespace[0]}", value: hash_value, version: 1)
|
57
|
+
return false if n.nil?
|
68
58
|
|
69
|
-
def self.in_namespace(userId, namespace)
|
70
|
-
n = hash("#{userId}__#{namespace[0]}")
|
71
59
|
n >= namespace[1] && n < namespace[2]
|
72
60
|
end
|
73
61
|
|
74
|
-
def self.get_equal_weights(
|
75
|
-
return [] if
|
62
|
+
def self.get_equal_weights(num_variations)
|
63
|
+
return [] if num_variations < 1
|
76
64
|
|
77
65
|
weights = []
|
78
|
-
(1..
|
79
|
-
weights << (1.0 /
|
66
|
+
(1..num_variations).each do |_i|
|
67
|
+
weights << (1.0 / num_variations)
|
80
68
|
end
|
81
69
|
weights
|
82
70
|
end
|
83
71
|
|
84
72
|
# Determine bucket ranges for experiment variations
|
85
|
-
def self.get_bucket_ranges(
|
73
|
+
def self.get_bucket_ranges(num_variations, coverage, weights)
|
86
74
|
# Make sure coverage is within bounds
|
87
|
-
coverage = 1 if coverage.nil?
|
88
|
-
coverage = 0 if coverage.negative?
|
89
|
-
coverage = 1 if coverage > 1
|
75
|
+
coverage = 1.0 if coverage.nil?
|
76
|
+
coverage = 0.0 if coverage.negative?
|
77
|
+
coverage = 1.0 if coverage > 1
|
90
78
|
|
91
79
|
# Default to equal weights
|
92
|
-
weights = get_equal_weights(
|
80
|
+
weights = get_equal_weights(num_variations) if !weights || weights.length != num_variations
|
93
81
|
|
94
82
|
# If weights don't add up to 1 (or close to it), default to equal weights
|
95
83
|
total = weights.sum
|
96
|
-
weights = get_equal_weights(
|
84
|
+
weights = get_equal_weights(num_variations) if total < 0.99 || total > 1.01
|
97
85
|
|
98
86
|
# Convert weights to ranges
|
99
|
-
cumulative = 0
|
87
|
+
cumulative = 0.0
|
100
88
|
ranges = []
|
101
89
|
weights.each do |w|
|
102
90
|
start = cumulative
|
103
91
|
cumulative += w
|
104
|
-
ranges << [start, start + coverage * w]
|
92
|
+
ranges << [start, start + (coverage * w)]
|
105
93
|
end
|
106
94
|
|
107
95
|
ranges
|
108
96
|
end
|
109
97
|
|
110
98
|
# Chose a variation based on a hash and range
|
111
|
-
def self.choose_variation(
|
99
|
+
def self.choose_variation(num, ranges)
|
112
100
|
ranges.each_with_index do |range, i|
|
113
|
-
return i if
|
101
|
+
return i if num >= range[0] && num < range[1]
|
114
102
|
end
|
115
103
|
-1
|
116
104
|
end
|
117
105
|
|
118
106
|
# Get an override variation from a url querystring
|
119
107
|
# e.g. http://localhost?my-test=1 will return `1` for id `my-test`
|
120
|
-
def self.get_query_string_override(id, url,
|
108
|
+
def self.get_query_string_override(id, url, num_variations)
|
121
109
|
# Skip if url is empty
|
122
|
-
return nil if url == ''
|
110
|
+
return nil if url == '' || id.nil?
|
123
111
|
|
124
112
|
# Parse out the query string
|
125
113
|
parsed = URI(url)
|
126
|
-
|
114
|
+
parsed_query = parsed.query
|
115
|
+
return nil if parsed_query.nil?
|
127
116
|
|
128
|
-
qs = URI.decode_www_form(
|
117
|
+
qs = URI.decode_www_form(parsed_query)
|
129
118
|
|
130
119
|
# Look for `id` in the querystring and get the value
|
131
120
|
vals = qs.assoc(id)
|
132
121
|
return nil unless vals
|
133
122
|
|
134
123
|
val = vals.last
|
135
|
-
return
|
124
|
+
return nil unless val
|
136
125
|
|
137
126
|
# Parse the value as an integer
|
138
127
|
n = begin
|
@@ -144,9 +133,13 @@ module Growthbook
|
|
144
133
|
# Make sure the integer is within range
|
145
134
|
return nil if n.nil?
|
146
135
|
return nil if n.negative?
|
147
|
-
return nil if n >=
|
136
|
+
return nil if n >= num_variations
|
148
137
|
|
149
138
|
n
|
150
139
|
end
|
140
|
+
|
141
|
+
def self.in_range?(num, range)
|
142
|
+
num >= range[0] && num < range[1]
|
143
|
+
end
|
151
144
|
end
|
152
145
|
end
|
data/lib/growthbook.rb
CHANGED
@@ -1,18 +1,18 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# The GrowthBook SDK
|
3
4
|
module Growthbook
|
4
5
|
end
|
5
6
|
|
6
|
-
require 'growthbook/client'
|
7
7
|
require 'growthbook/conditions'
|
8
8
|
require 'growthbook/context'
|
9
|
-
require 'growthbook/
|
10
|
-
require 'growthbook/experiment_result'
|
9
|
+
require 'growthbook/decryption_util'
|
11
10
|
require 'growthbook/feature'
|
11
|
+
require 'growthbook/feature_repository'
|
12
12
|
require 'growthbook/feature_result'
|
13
13
|
require 'growthbook/feature_rule'
|
14
|
+
require 'growthbook/fnv'
|
14
15
|
require 'growthbook/inline_experiment'
|
15
16
|
require 'growthbook/inline_experiment_result'
|
16
|
-
require 'growthbook/
|
17
|
-
require 'growthbook/user'
|
17
|
+
require 'growthbook/tracking_callback'
|
18
18
|
require 'growthbook/util'
|