growthbook 0.3.0 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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'
|