statsig 1.12.0 → 1.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1590d900487869f3cbd76aae50f38ccb535b372ba0468a446ab5574ccfc782eb
4
- data.tar.gz: 1e94c594d470d0baa21a073177677d6c631297aa1fe51747853ef25106b55c6e
3
+ metadata.gz: 898237740392ece6cde365fd111e6d49daacac47d39317f06435fbfe83df8821
4
+ data.tar.gz: 6075c3333c37aeb94fc24511cea4bb6f1a7689c23d05100c91f9a9afc8525393
5
5
  SHA512:
6
- metadata.gz: 7b2ceb928a74c83e00b783aa46ea45010dbd9daa4a8932a29528e7edc10d9393a6cad9139c26bcd98b13509957565637593c4d0115bd5f929425dc7872f54260
7
- data.tar.gz: db7812f360b4e68b402107fedea8f7d7c591bdb83ad206265b871d22b837c6be04e867c51d879a1730dbcc8cec76097879e47f601d42d8e1c456018dda5dd44e
6
+ metadata.gz: ee1eb3fec70c73522532f44255ef065b2d286382645ad7988cad9d297295548a97f18a50b5f73a046e6987fadbff3184f53a91cbb9f969b805c1cb1681552454
7
+ data.tar.gz: d3818d3492d50cd9da8cdf2f8067140383c25b1d124d205a47303023fb64a10e1f5424fcf08e8b8270c3c7e96a88ee088fa2abc1c8a91918c550dcc280fc7778
@@ -0,0 +1,131 @@
1
+ $empty_eval_result = {
2
+ :gate_value => false,
3
+ :json_value => {},
4
+ :rule_id => "",
5
+ :is_experiment_group => false,
6
+ :secondary_exposures => []
7
+ }
8
+
9
+ module ClientInitializeHelpers
10
+ class ResponseFormatter
11
+ def initialize(evaluator, user)
12
+ @evaluator = evaluator
13
+ @user = user
14
+ @specs = evaluator.spec_store.get_raw_specs
15
+ end
16
+
17
+ def get_responses(key)
18
+ @specs[key]
19
+ .map { |name, spec| to_response(name, spec) }
20
+ .delete_if { |v| v.nil? }.to_h
21
+ end
22
+
23
+ private
24
+
25
+ def to_response(config_name, config_spec)
26
+ eval_result = @evaluator.eval_spec(@user, config_spec)
27
+ if eval_result.nil?
28
+ return nil
29
+ end
30
+
31
+ safe_eval_result = eval_result == $fetch_from_server ? $empty_eval_result : {
32
+ :gate_value => eval_result.gate_value,
33
+ :json_value => eval_result.json_value,
34
+ :rule_id => eval_result.rule_id,
35
+ :config_delegate => eval_result.config_delegate,
36
+ :is_experiment_group => eval_result.is_experiment_group,
37
+ :secondary_exposures => eval_result.secondary_exposures,
38
+ :undelegated_sec_exps => eval_result.undelegated_sec_exps
39
+ }
40
+
41
+ category = config_spec['type']
42
+ entity_type = config_spec['entity']
43
+
44
+ result = {}
45
+
46
+ case category
47
+
48
+ when 'feature_gate'
49
+ if entity_type == 'segment' || entity_type == 'holdout'
50
+ return nil
51
+ end
52
+
53
+ result['value'] = safe_eval_result[:gate_value]
54
+ when 'dynamic_config'
55
+ id_type = config_spec['idType']
56
+ result['value'] = safe_eval_result[:json_value]
57
+ result["group"] = safe_eval_result[:rule_id]
58
+ result["is_device_based"] = id_type.is_a?(String) && id_type.downcase == 'stableid'
59
+ else
60
+ return nil
61
+ end
62
+
63
+ if entity_type == 'experiment'
64
+ populate_experiment_fields(config_name, config_spec, safe_eval_result, result)
65
+ end
66
+
67
+ if entity_type == 'layer'
68
+ populate_layer_fields(config_spec, safe_eval_result, result)
69
+ end
70
+
71
+ hashed_name = hash_name(config_name)
72
+ [hashed_name, result.merge(
73
+ {
74
+ "name" => hashed_name,
75
+ "rule_id" => safe_eval_result[:rule_id],
76
+ "secondary_exposures" => clean_exposures(safe_eval_result[:secondary_exposures])
77
+ })]
78
+ end
79
+
80
+ def clean_exposures(exposures)
81
+ seen = {}
82
+ exposures.reject do |exposure|
83
+ key = "#{exposure["gate"]}|#{exposure["gateValue"]}|#{exposure["ruleID"]}}"
84
+ should_reject = seen[key]
85
+ seen[key] = true
86
+ should_reject == true
87
+ end
88
+ end
89
+
90
+ def populate_experiment_fields(config_name, config_spec, eval_result, result)
91
+ result["is_user_in_experiment"] = eval_result[:is_experiment_group]
92
+ result["is_experiment_active"] = config_spec['isActive'] == true
93
+
94
+ if config_spec['hasSharedParams'] != true
95
+ return
96
+ end
97
+
98
+ result["is_in_layer"] = true
99
+ result["explicit_parameters"] = config_spec["explicitParameters"] || []
100
+
101
+ layer_name = @specs[:experiment_to_layer][config_name]
102
+ if layer_name.nil? || @specs[:layers][layer_name].nil?
103
+ return
104
+ end
105
+
106
+ layer = @specs[:layers][layer_name]
107
+ result["value"] = layer["defaultValue"].merge(result["value"])
108
+ end
109
+
110
+ def populate_layer_fields(config_spec, eval_result, result)
111
+ delegate = eval_result[:config_delegate]
112
+ result["explicit_parameters"] = config_spec["explicitParameters"] || []
113
+
114
+ if delegate.nil? == false && delegate.empty? == false
115
+ delegate_spec = @specs[:configs][delegate]
116
+ delegate_result = @evaluator.eval_spec(@user, delegate_spec)
117
+
118
+ result["allocated_experiment_name"] = hash_name(delegate)
119
+ result["is_user_in_experiment"] = delegate_result.is_experiment_group
120
+ result["is_experiment_active"] = delegate_spec['isActive'] == true
121
+ result["explicit_parameters"] = delegate_spec["explicitParameters"] || []
122
+ end
123
+
124
+ result["undelegated_secondary_exposures"] = clean_exposures(eval_result[:undelegated_sec_exps] || [])
125
+ end
126
+
127
+ def hash_name(name)
128
+ Digest::SHA256.base64digest(name)
129
+ end
130
+ end
131
+ end
data/lib/config_result.rb CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  module Statsig
3
2
  class ConfigResult
4
3
  attr_accessor :name
@@ -9,8 +8,20 @@ module Statsig
9
8
  attr_accessor :undelegated_sec_exps
10
9
  attr_accessor :config_delegate
11
10
  attr_accessor :explicit_parameters
11
+ attr_accessor :is_experiment_group
12
+ attr_accessor :evaluation_details
12
13
 
13
- def initialize(name, gate_value = false, json_value = {}, rule_id = '', secondary_exposures = [], undelegated_sec_exps = [], config_delegate = '', explicit_parameters = [])
14
+ def initialize(
15
+ name,
16
+ gate_value = false,
17
+ json_value = {},
18
+ rule_id = '',
19
+ secondary_exposures = [],
20
+ undelegated_sec_exps = [],
21
+ config_delegate = '',
22
+ explicit_parameters = [],
23
+ is_experiment_group: false,
24
+ evaluation_details: nil)
14
25
  @name = name
15
26
  @gate_value = gate_value
16
27
  @json_value = json_value
@@ -19,6 +30,8 @@ module Statsig
19
30
  @undelegated_sec_exps = undelegated_sec_exps.is_a?(Array) ? undelegated_sec_exps : []
20
31
  @config_delegate = config_delegate
21
32
  @explicit_parameters = explicit_parameters
33
+ @is_experiment_group = is_experiment_group
34
+ @evaluation_details = evaluation_details
22
35
  end
23
36
  end
24
37
  end
@@ -0,0 +1,40 @@
1
+ module Statsig
2
+
3
+ module EvaluationReason
4
+ NETWORK = "Network"
5
+ LOCAL_OVERRIDE = "LocalOverride"
6
+ UNRECOGNIZED = "Unrecognized"
7
+ UNINITIALIZED = "Uninitialized"
8
+ BOOTSTRAP = "Bootstrap"
9
+ end
10
+
11
+ class EvaluationDetails
12
+ attr_accessor :config_sync_time
13
+ attr_accessor :init_time
14
+ attr_accessor :reason
15
+ attr_accessor :server_time
16
+
17
+ def initialize(config_sync_time, init_time, reason)
18
+ @config_sync_time = config_sync_time
19
+ @init_time = init_time
20
+ @reason = reason
21
+ @server_time = (Time.now.to_i * 1000).to_s
22
+ end
23
+
24
+ def self.unrecognized(config_sync_time, init_time)
25
+ EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::UNRECOGNIZED)
26
+ end
27
+
28
+ def self.uninitialized
29
+ EvaluationDetails.new(0, 0, EvaluationReason::UNINITIALIZED)
30
+ end
31
+
32
+ def self.network(config_sync_time, init_time)
33
+ EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::NETWORK)
34
+ end
35
+
36
+ def self.local_override(config_sync_time, init_time)
37
+ EvaluationDetails.new(config_sync_time, init_time, EvaluationReason::LOCAL_OVERRIDE)
38
+ end
39
+ end
40
+ end
data/lib/evaluator.rb CHANGED
@@ -2,9 +2,11 @@ require 'config_result'
2
2
  require 'country_lookup'
3
3
  require 'digest'
4
4
  require 'evaluation_helpers'
5
+ require 'client_initialize_helpers'
5
6
  require 'spec_store'
6
7
  require 'time'
7
8
  require 'user_agent_parser'
9
+ require 'evaluation_details'
8
10
  require 'user_agent_parser/operating_system'
9
11
 
10
12
  $fetch_from_server = 'fetch_from_server'
@@ -12,43 +14,105 @@ $type_dynamic_config = 'dynamic_config'
12
14
 
13
15
  module Statsig
14
16
  class Evaluator
17
+ attr_accessor :spec_store
18
+
15
19
  def initialize(network, options, error_callback)
16
- @spec_store = Statsig::SpecStore.new(network, error_callback, options.rulesets_sync_interval, options.idlists_sync_interval)
20
+ @spec_store = Statsig::SpecStore.new(network, error_callback, options.rulesets_sync_interval, options.idlists_sync_interval, options.bootstrap_values, options.rules_updated_callback)
17
21
  @ua_parser = UserAgentParser::Parser.new
18
22
  CountryLookup.initialize
19
- @initialized = true
20
23
 
21
24
  @gate_overrides = {}
22
25
  @config_overrides = {}
23
26
  end
24
27
 
28
+ def maybe_restart_background_threads
29
+ @spec_store.maybe_restart_background_threads
30
+ end
31
+
25
32
  def check_gate(user, gate_name)
26
- return Statsig::ConfigResult.new(
27
- gate_name,
28
- @gate_overrides[gate_name],
29
- @gate_overrides[gate_name],
30
- 'override',
31
- []) unless !@gate_overrides.has_key?(gate_name)
32
- return nil unless @initialized && @spec_store.has_gate?(gate_name)
33
+ if @gate_overrides.has_key?(gate_name)
34
+ return Statsig::ConfigResult.new(
35
+ gate_name,
36
+ @gate_overrides[gate_name],
37
+ @gate_overrides[gate_name],
38
+ 'override',
39
+ [],
40
+ evaluation_details: EvaluationDetails.local_override(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
41
+ end
42
+
43
+ if @spec_store.init_reason == EvaluationReason::UNINITIALIZED
44
+ return Statsig::ConfigResult.new(gate_name, evaluation_details: EvaluationDetails.uninitialized)
45
+ end
46
+
47
+ unless @spec_store.has_gate?(gate_name)
48
+ return Statsig::ConfigResult.new(gate_name, evaluation_details: EvaluationDetails.unrecognized(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
49
+ end
50
+
33
51
  eval_spec(user, @spec_store.get_gate(gate_name))
34
52
  end
35
53
 
36
54
  def get_config(user, config_name)
37
- return Statsig::ConfigResult.new(
38
- config_name,
39
- false,
40
- @config_overrides[config_name],
41
- 'override',
42
- []) unless !@config_overrides.has_key?(config_name)
43
- return nil unless @initialized && @spec_store.has_config?(config_name)
55
+ if @config_overrides.has_key?(config_name)
56
+ return Statsig::ConfigResult.new(
57
+ config_name,
58
+ false,
59
+ @config_overrides[config_name],
60
+ 'override',
61
+ [],
62
+ evaluation_details: EvaluationDetails.local_override(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
63
+ end
64
+
65
+ if @spec_store.init_reason == EvaluationReason::UNINITIALIZED
66
+ return Statsig::ConfigResult.new(config_name, evaluation_details: EvaluationDetails.uninitialized)
67
+ end
68
+
69
+ unless @spec_store.has_config?(config_name)
70
+ return Statsig::ConfigResult.new(config_name, evaluation_details: EvaluationDetails.unrecognized(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
71
+ end
72
+
44
73
  eval_spec(user, @spec_store.get_config(config_name))
45
74
  end
46
75
 
47
76
  def get_layer(user, layer_name)
48
- return nil unless @initialized && @spec_store.has_layer?(layer_name)
77
+ if @spec_store.init_reason == EvaluationReason::UNINITIALIZED
78
+ return Statsig::ConfigResult.new(layer_name, evaluation_details: EvaluationDetails.uninitialized)
79
+ end
80
+
81
+ unless @spec_store.has_layer?(layer_name)
82
+ return Statsig::ConfigResult.new(layer_name, evaluation_details: EvaluationDetails.unrecognized(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time))
83
+ end
84
+
49
85
  eval_spec(user, @spec_store.get_layer(layer_name))
50
86
  end
51
87
 
88
+ def get_client_initialize_response(user)
89
+ if @spec_store.is_ready_for_checks == false
90
+ return nil
91
+ end
92
+
93
+ formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user)
94
+
95
+ {
96
+ "feature_gates" => formatter.get_responses(:gates),
97
+ "dynamic_configs" => formatter.get_responses(:configs),
98
+ "layer_configs" => formatter.get_responses(:layers),
99
+ "sdkParams" => {},
100
+ "has_updates" => true,
101
+ "generator" => "statsig-ruby-sdk",
102
+ "time" => 0,
103
+ }
104
+ end
105
+
106
+ def clean_exposures(exposures)
107
+ seen = {}
108
+ exposures.reject do |exposure|
109
+ key = "#{exposure["gate"]}|#{exposure["gateValue"]}|#{exposure["ruleID"]}}"
110
+ should_reject = seen[key]
111
+ seen[key] = true
112
+ should_reject == true
113
+ end
114
+ end
115
+
52
116
  def shutdown
53
117
  @spec_store.shutdown
54
118
  end
@@ -61,8 +125,6 @@ module Statsig
61
125
  @config_overrides[config] = value
62
126
  end
63
127
 
64
- private
65
-
66
128
  def eval_spec(user, config)
67
129
  default_rule_id = 'default'
68
130
  exposures = []
@@ -85,7 +147,9 @@ module Statsig
85
147
  pass,
86
148
  pass ? result.json_value : config['defaultValue'],
87
149
  result.rule_id,
88
- exposures
150
+ exposures,
151
+ evaluation_details: EvaluationDetails.new(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time, @spec_store.init_reason),
152
+ is_experiment_group: result.is_experiment_group,
89
153
  )
90
154
  end
91
155
 
@@ -95,9 +159,17 @@ module Statsig
95
159
  default_rule_id = 'disabled'
96
160
  end
97
161
 
98
- Statsig::ConfigResult.new(config['name'], false, config['defaultValue'], default_rule_id, exposures)
162
+ Statsig::ConfigResult.new(
163
+ config['name'],
164
+ false,
165
+ config['defaultValue'],
166
+ default_rule_id,
167
+ exposures,
168
+ evaluation_details: EvaluationDetails.new(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time, @spec_store.init_reason))
99
169
  end
100
170
 
171
+ private
172
+
101
173
  def eval_rule(user, rule)
102
174
  exposures = []
103
175
  pass = true
@@ -117,7 +189,14 @@ module Statsig
117
189
  i += 1
118
190
  end
119
191
 
120
- Statsig::ConfigResult.new('', pass, rule['returnValue'], rule['id'], exposures)
192
+ Statsig::ConfigResult.new(
193
+ '',
194
+ pass,
195
+ rule['returnValue'],
196
+ rule['id'],
197
+ exposures,
198
+ evaluation_details: EvaluationDetails.new(@spec_store.last_config_sync_time, @spec_store.initial_config_sync_time, @spec_store.init_reason),
199
+ is_experiment_group: rule["isExperimentGroup"] == true)
121
200
  end
122
201
 
123
202
  def eval_delegate(name, user, rule, exposures)
@@ -177,7 +256,7 @@ module Statsig
177
256
  when 'environment_field'
178
257
  value = get_value_from_environment(user, field)
179
258
  when 'current_time'
180
- value = Time.now.to_f # epoch time in seconds
259
+ value = Time.now.to_i # epoch time in seconds
181
260
  when 'user_bucket'
182
261
  begin
183
262
  salt = additional_values['salt']
data/lib/network.rb CHANGED
@@ -22,7 +22,7 @@ module Statsig
22
22
  return nil unless !@local_mode
23
23
  http = HTTP.headers(
24
24
  {"STATSIG-API-KEY" => @server_secret,
25
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
25
+ "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
26
26
  "STATSIG-SERVER-SESSION-ID" => @session_id,
27
27
  "Content-Type" => "application/json; charset=UTF-8"
28
28
  }).accept(:json)
data/lib/spec_store.rb CHANGED
@@ -1,63 +1,101 @@
1
1
  require 'net/http'
2
2
  require 'uri'
3
3
 
4
+ require 'evaluation_details'
4
5
  require 'id_list'
5
6
 
6
7
  module Statsig
7
8
  class SpecStore
8
- def initialize(network, error_callback = nil, rulesets_sync_interval = 10, id_lists_sync_interval = 60)
9
+ attr_accessor :last_config_sync_time
10
+ attr_accessor :initial_config_sync_time
11
+ attr_accessor :init_reason
12
+
13
+ def initialize(network, error_callback = nil, rulesets_sync_interval = 10, id_lists_sync_interval = 60, bootstrap_values = nil, rules_updated_callback = nil)
14
+ @init_reason = EvaluationReason::UNINITIALIZED
9
15
  @network = network
10
- @last_sync_time = 0
16
+ @last_config_sync_time = 0
17
+ @initial_config_sync_time = 0
11
18
  @rulesets_sync_interval = rulesets_sync_interval
12
19
  @id_lists_sync_interval = id_lists_sync_interval
13
- @store = {
20
+ @rules_updated_callback = rules_updated_callback
21
+ @specs = {
14
22
  :gates => {},
15
23
  :configs => {},
16
24
  :layers => {},
17
25
  :id_lists => {},
26
+ :experiment_to_layer => {}
18
27
  }
28
+
29
+ unless bootstrap_values.nil?
30
+ begin
31
+ if process(JSON.parse(bootstrap_values))
32
+ @init_reason = EvaluationReason::BOOTSTRAP
33
+ end
34
+ rescue
35
+ puts 'the provided bootstrapValues is not a valid JSON string'
36
+ end
37
+ end
38
+
19
39
  @error_callback = error_callback
20
40
  download_config_specs
41
+ @initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
21
42
  get_id_lists
22
43
 
23
44
  @config_sync_thread = sync_config_specs
24
45
  @id_lists_sync_thread = sync_id_lists
25
46
  end
26
47
 
48
+ def is_ready_for_checks
49
+ @last_config_sync_time != 0
50
+ end
51
+
27
52
  def shutdown
28
53
  @config_sync_thread&.exit
29
54
  @id_lists_sync_thread&.exit
30
55
  end
31
56
 
32
57
  def has_gate?(gate_name)
33
- @store[:gates].key?(gate_name)
58
+ @specs[:gates].key?(gate_name)
34
59
  end
35
60
 
36
61
  def has_config?(config_name)
37
- @store[:configs].key?(config_name)
62
+ @specs[:configs].key?(config_name)
38
63
  end
39
64
 
40
65
  def has_layer?(layer_name)
41
- @store[:layers].key?(layer_name)
66
+ @specs[:layers].key?(layer_name)
42
67
  end
43
68
 
44
69
  def get_gate(gate_name)
45
70
  return nil unless has_gate?(gate_name)
46
- @store[:gates][gate_name]
71
+ @specs[:gates][gate_name]
47
72
  end
48
73
 
49
74
  def get_config(config_name)
50
75
  return nil unless has_config?(config_name)
51
- @store[:configs][config_name]
76
+ @specs[:configs][config_name]
52
77
  end
53
78
 
54
79
  def get_layer(layer_name)
55
80
  return nil unless has_layer?(layer_name)
56
- @store[:layers][layer_name]
81
+ @specs[:layers][layer_name]
57
82
  end
58
83
 
59
84
  def get_id_list(list_name)
60
- @store[:id_lists][list_name]
85
+ @specs[:id_lists][list_name]
86
+ end
87
+
88
+ def get_raw_specs
89
+ @specs
90
+ end
91
+
92
+ def maybe_restart_background_threads
93
+ if @config_sync_thread.nil? or !@config_sync_thread.alive?
94
+ @config_sync_thread = sync_config_specs
95
+ end
96
+ if @id_lists_sync_thread.nil? or !@id_lists_sync_thread.alive?
97
+ @id_lists_sync_thread = sync_id_lists
98
+ end
61
99
  end
62
100
 
63
101
  private
@@ -87,9 +125,12 @@ module Statsig
87
125
 
88
126
  def get_config_specs_from_network
89
127
  begin
90
- response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @last_sync_time}))
128
+ response, e = @network.post_helper('download_config_specs', JSON.generate({ 'sinceTime' => @last_config_sync_time }))
91
129
  if e.nil?
92
- process(JSON.parse(response.body))
130
+ if process(JSON.parse(response.body))
131
+ @init_reason = EvaluationReason::NETWORK
132
+ @rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
133
+ end
93
134
  nil
94
135
  else
95
136
  e
@@ -101,36 +142,47 @@ module Statsig
101
142
 
102
143
  def process(specs_json)
103
144
  if specs_json.nil?
104
- return
145
+ return false
105
146
  end
106
147
 
107
- @last_sync_time = specs_json['time'] || @last_sync_time
108
- return unless specs_json['has_updates'] == true &&
148
+ @last_config_sync_time = specs_json['time'] || @last_config_sync_time
149
+
150
+ return false unless specs_json['has_updates'] == true &&
109
151
  !specs_json['feature_gates'].nil? &&
110
152
  !specs_json['dynamic_configs'].nil? &&
111
- !specs_json['layer_configs'].nil?
153
+ !specs_json['layer_configs'].nil?
112
154
 
113
155
  new_gates = {}
114
156
  new_configs = {}
115
157
  new_layers = {}
158
+ new_exp_to_layer = {}
159
+
160
+ specs_json['feature_gates'].each { |gate| new_gates[gate['name']] = gate }
161
+ specs_json['dynamic_configs'].each { |config| new_configs[config['name']] = config }
162
+ specs_json['layer_configs'].each { |layer| new_layers[layer['name']] = layer }
163
+
164
+ if specs_json['layers'].is_a?(Hash)
165
+ specs_json['layers'].each { |layer_name, experiments|
166
+ experiments.each { |experiment_name| new_exp_to_layer[experiment_name] = layer_name }
167
+ }
168
+ end
116
169
 
117
- specs_json['feature_gates'].map{|gate| new_gates[gate['name']] = gate }
118
- specs_json['dynamic_configs'].map{|config| new_configs[config['name']] = config }
119
- specs_json['layer_configs'].map{|layer| new_layers[layer['name']] = layer }
120
- @store[:gates] = new_gates
121
- @store[:configs] = new_configs
122
- @store[:layers] = new_layers
170
+ @specs[:gates] = new_gates
171
+ @specs[:configs] = new_configs
172
+ @specs[:layers] = new_layers
173
+ @specs[:experiment_to_layer] = new_exp_to_layer
174
+ true
123
175
  end
124
176
 
125
177
  def get_id_lists
126
- response, e = @network.post_helper('get_id_lists', JSON.generate({'statsigMetadata' => Statsig.get_statsig_metadata}))
178
+ response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
127
179
  if !e.nil? || response.nil?
128
180
  return
129
181
  end
130
182
 
131
183
  begin
132
184
  server_id_lists = JSON.parse(response)
133
- local_id_lists = @store[:id_lists]
185
+ local_id_lists = @specs[:id_lists]
134
186
  if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
135
187
  return
136
188
  end
@@ -184,7 +236,7 @@ module Statsig
184
236
 
185
237
  def download_single_id_list(list)
186
238
  nil unless list.is_a? IDList
187
- http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
239
+ http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
188
240
  begin
189
241
  res = http.get(list.url)
190
242
  nil unless res.status.success?
@@ -192,7 +244,7 @@ module Statsig
192
244
  nil if content_length.nil? || content_length <= 0
193
245
  content = res.body.to_s
194
246
  unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
195
- @store[:id_lists].delete(list.name)
247
+ @specs[:id_lists].delete(list.name)
196
248
  return
197
249
  end
198
250
  ids_clone = list.ids # clone the list, operate on the new list, and swap out the old list, so the operation is thread-safe
data/lib/statsig.rb CHANGED
@@ -4,6 +4,7 @@ module Statsig
4
4
  def self.initialize(secret_key, options = nil, error_callback = nil)
5
5
  unless @shared_instance.nil?
6
6
  puts 'Statsig already initialized.'
7
+ @shared_instance.maybe_restart_background_threads
7
8
  return @shared_instance
8
9
  end
9
10
 
@@ -52,10 +53,15 @@ module Statsig
52
53
  @shared_instance&.override_config(config_name, config_value)
53
54
  end
54
55
 
56
+ def self.get_client_initialize_response(user)
57
+ ensure_initialized
58
+ @shared_instance&.get_client_initialize_response(user)
59
+ end
60
+
55
61
  def self.get_statsig_metadata
56
62
  {
57
63
  'sdkType' => 'ruby-server',
58
- 'sdkVersion' => '1.12.0',
64
+ 'sdkVersion' => '1.13.0',
59
65
  }
60
66
  end
61
67
 
@@ -39,7 +39,7 @@ class StatsigDriver
39
39
  res = check_gate_fallback(user, gate_name)
40
40
  # exposure logged by the server
41
41
  else
42
- @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures)
42
+ @logger.log_gate_exposure(user, res.name, res.gate_value, res.rule_id, res.secondary_exposures, res.evaluation_details)
43
43
  end
44
44
 
45
45
  res.gate_value
@@ -106,6 +106,18 @@ class StatsigDriver
106
106
  @evaluator.override_config(config_name, config_value)
107
107
  end
108
108
 
109
+ # @param [StatsigUser] user
110
+ # @return [Hash]
111
+ def get_client_initialize_response(user)
112
+ normalize_user(user)
113
+ @evaluator.get_client_initialize_response(user)
114
+ end
115
+
116
+ def maybe_restart_background_threads
117
+ @evaluator.maybe_restart_background_threads
118
+ @logger.maybe_restart_background_threads
119
+ end
120
+
109
121
  private
110
122
 
111
123
  def verify_inputs(user, config_name, variable_name)
@@ -128,7 +140,7 @@ class StatsigDriver
128
140
  res = get_config_fallback(user, config_name)
129
141
  # exposure logged by the server
130
142
  else
131
- @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures)
143
+ @logger.log_config_exposure(user, res.name, res.rule_id, res.secondary_exposures, res.evaluation_details)
132
144
  end
133
145
 
134
146
  DynamicConfig.new(res.name, res.json_value, res.rule_id)
@@ -140,7 +152,7 @@ class StatsigDriver
140
152
  (
141
153
  # user_id is nil and custom_ids is not a hash with entries
142
154
  !user.user_id.is_a?(String) &&
143
- (!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
155
+ (!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
144
156
  )
145
157
  raise 'Must provide a valid StatsigUser with a user_id or at least a custom ID. See https://docs.statsig.com/messages/serverRequiredUserID/ for more details.'
146
158
  end
data/lib/statsig_event.rb CHANGED
@@ -7,7 +7,7 @@ class StatsigEvent
7
7
 
8
8
  def initialize(event_name)
9
9
  @event_name = event_name
10
- @time = Time.now.to_f * 1000
10
+ @time = (Time.now.to_f * 1000).to_i
11
11
  end
12
12
 
13
13
  def user=(value)
@@ -20,28 +20,32 @@ module Statsig
20
20
  end
21
21
  end
22
22
 
23
- def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures)
23
+ def log_gate_exposure(user, gate_name, value, rule_id, secondary_exposures, eval_details)
24
24
  event = StatsigEvent.new($gate_exposure_event)
25
25
  event.user = user
26
26
  event.metadata = {
27
27
  'gate' => gate_name,
28
28
  'gateValue' => value.to_s,
29
- 'ruleID' => rule_id
29
+ 'ruleID' => rule_id,
30
30
  }
31
31
  event.statsig_metadata = Statsig.get_statsig_metadata
32
32
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
33
+
34
+ safe_add_eval_details(eval_details, event)
33
35
  log_event(event)
34
36
  end
35
37
 
36
- def log_config_exposure(user, config_name, rule_id, secondary_exposures)
38
+ def log_config_exposure(user, config_name, rule_id, secondary_exposures, eval_details)
37
39
  event = StatsigEvent.new($config_exposure_event)
38
40
  event.user = user
39
41
  event.metadata = {
40
42
  'config' => config_name,
41
- 'ruleID' => rule_id
43
+ 'ruleID' => rule_id,
42
44
  }
43
45
  event.statsig_metadata = Statsig.get_statsig_metadata
44
46
  event.secondary_exposures = secondary_exposures.is_a?(Array) ? secondary_exposures : []
47
+
48
+ safe_add_eval_details(eval_details, event)
45
49
  log_event(event)
46
50
  end
47
51
 
@@ -61,10 +65,12 @@ module Statsig
61
65
  'ruleID' => layer.rule_id,
62
66
  'allocatedExperiment' => allocated_experiment,
63
67
  'parameterName' => parameter_name,
64
- 'isExplicitParameter' => String(is_explicit)
68
+ 'isExplicitParameter' => String(is_explicit),
65
69
  }
66
70
  event.statsig_metadata = Statsig.get_statsig_metadata
67
71
  event.secondary_exposures = exposures.is_a?(Array) ? exposures : []
72
+
73
+ safe_add_eval_details(config_evaluation.evaluation_details, event)
68
74
  log_event(event)
69
75
  end
70
76
 
@@ -90,5 +96,24 @@ module Statsig
90
96
 
91
97
  @network.post_logs(flush_events)
92
98
  end
99
+
100
+ def maybe_restart_background_threads
101
+ if @background_flush.nil? or !@background_flush.alive?
102
+ @background_flush = periodic_flush
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def safe_add_eval_details(eval_details, event)
109
+ if eval_details.nil?
110
+ return
111
+ end
112
+
113
+ event.metadata['reason'] = eval_details.reason
114
+ event.metadata['configSyncTime'] = eval_details.config_sync_time
115
+ event.metadata['initTime'] = eval_details.init_time
116
+ event.metadata['serverTime'] = eval_details.server_time
117
+ end
93
118
  end
94
119
  end
@@ -6,6 +6,8 @@ class StatsigOptions
6
6
  attr_accessor :logging_interval_seconds
7
7
  attr_accessor :logging_max_buffer_size
8
8
  attr_accessor :local_mode
9
+ attr_accessor :bootstrap_values
10
+ attr_accessor :rules_updated_callback
9
11
 
10
12
  def initialize(
11
13
  environment=nil,
@@ -14,7 +16,9 @@ class StatsigOptions
14
16
  idlists_sync_interval: 60,
15
17
  logging_interval_seconds: 60,
16
18
  logging_max_buffer_size: 1000,
17
- local_mode: false)
19
+ local_mode: false,
20
+ bootstrap_values: nil,
21
+ rules_updated_callback: nil)
18
22
  @environment = environment.is_a?(Hash) ? environment : nil
19
23
  @api_url_base = api_url_base
20
24
  @rulesets_sync_interval = rulesets_sync_interval
@@ -22,5 +26,7 @@ class StatsigOptions
22
26
  @logging_interval_seconds = logging_interval_seconds
23
27
  @logging_max_buffer_size = [logging_max_buffer_size, 1000].min
24
28
  @local_mode = local_mode
29
+ @bootstrap_values = bootstrap_values
30
+ @rules_updated_callback = rules_updated_callback
25
31
  end
26
32
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: statsig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Statsig, Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-08-24 00:00:00.000000000 Z
11
+ date: 2022-09-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -120,8 +120,10 @@ executables: []
120
120
  extensions: []
121
121
  extra_rdoc_files: []
122
122
  files:
123
+ - lib/client_initialize_helpers.rb
123
124
  - lib/config_result.rb
124
125
  - lib/dynamic_config.rb
126
+ - lib/evaluation_details.rb
125
127
  - lib/evaluation_helpers.rb
126
128
  - lib/evaluator.rb
127
129
  - lib/id_list.rb
@@ -153,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
153
155
  - !ruby/object:Gem::Version
154
156
  version: '0'
155
157
  requirements: []
156
- rubygems_version: 3.2.32
158
+ rubygems_version: 3.2.3
157
159
  signing_key:
158
160
  specification_version: 4
159
161
  summary: Statsig server SDK for Ruby