statsig 1.11.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: c8f1e2b0340c9bbeb49888f54d13505bc7b89c8a70d33e5adaed1e4b95568798
4
- data.tar.gz: f70a5060da61c965bbe7e93d0ee7a42cbb30fd0504228959aba7d5ca894278a2
3
+ metadata.gz: 898237740392ece6cde365fd111e6d49daacac47d39317f06435fbfe83df8821
4
+ data.tar.gz: 6075c3333c37aeb94fc24511cea4bb6f1a7689c23d05100c91f9a9afc8525393
5
5
  SHA512:
6
- metadata.gz: cadde7dcab3590b3d9a3d44bc3856f847b5cb50e9b8f365c1287ebad6bff59a5f82aeaef74e8ab958fb6e8e489ee811df9998cf86f1c18b588389f6194f2f2c0
7
- data.tar.gz: 879e6ee89157ee4e86032d0d28c6588f21236b3503955150f6e702d06feaac503a8572696c82fe40b086a1e6df17900952c7b08e35f46162df8c50938e0f1225
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,33 +14,116 @@ $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
23
+
24
+ @gate_overrides = {}
25
+ @config_overrides = {}
26
+ end
27
+
28
+ def maybe_restart_background_threads
29
+ @spec_store.maybe_restart_background_threads
20
30
  end
21
31
 
22
32
  def check_gate(user, gate_name)
23
- 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
+
24
51
  eval_spec(user, @spec_store.get_gate(gate_name))
25
52
  end
26
53
 
27
54
  def get_config(user, config_name)
28
- 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
+
29
73
  eval_spec(user, @spec_store.get_config(config_name))
30
74
  end
31
75
 
32
76
  def get_layer(user, layer_name)
33
- 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
+
34
85
  eval_spec(user, @spec_store.get_layer(layer_name))
35
86
  end
36
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
+
37
116
  def shutdown
38
117
  @spec_store.shutdown
39
118
  end
40
119
 
41
- private
120
+ def override_gate(gate, value)
121
+ @gate_overrides[gate] = value
122
+ end
123
+
124
+ def override_config(config, value)
125
+ @config_overrides[config] = value
126
+ end
42
127
 
43
128
  def eval_spec(user, config)
44
129
  default_rule_id = 'default'
@@ -62,7 +147,9 @@ module Statsig
62
147
  pass,
63
148
  pass ? result.json_value : config['defaultValue'],
64
149
  result.rule_id,
65
- 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,
66
153
  )
67
154
  end
68
155
 
@@ -72,9 +159,17 @@ module Statsig
72
159
  default_rule_id = 'disabled'
73
160
  end
74
161
 
75
- 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))
76
169
  end
77
170
 
171
+ private
172
+
78
173
  def eval_rule(user, rule)
79
174
  exposures = []
80
175
  pass = true
@@ -94,7 +189,14 @@ module Statsig
94
189
  i += 1
95
190
  end
96
191
 
97
- 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)
98
200
  end
99
201
 
100
202
  def eval_delegate(name, user, rule, exposures)
@@ -154,7 +256,7 @@ module Statsig
154
256
  when 'environment_field'
155
257
  value = get_value_from_environment(user, field)
156
258
  when 'current_time'
157
- value = Time.now.to_f # epoch time in seconds
259
+ value = Time.now.to_i # epoch time in seconds
158
260
  when 'user_bucket'
159
261
  begin
160
262
  salt = additional_values['salt']
data/lib/network.rb CHANGED
@@ -6,21 +6,23 @@ $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
6
6
 
7
7
  module Statsig
8
8
  class Network
9
- def initialize(server_secret, api, backoff_mult = 10)
9
+ def initialize(server_secret, api, local_mode, backoff_mult = 10)
10
10
  super()
11
11
  unless api.end_with?('/')
12
12
  api += '/'
13
13
  end
14
14
  @server_secret = server_secret
15
15
  @api = api
16
+ @local_mode = local_mode
16
17
  @backoff_multiplier = backoff_mult
17
18
  @session_id = SecureRandom.uuid
18
19
  end
19
20
 
20
21
  def post_helper(endpoint, body, retries = 0, backoff = 1)
22
+ return nil unless !@local_mode
21
23
  http = HTTP.headers(
22
24
  {"STATSIG-API-KEY" => @server_secret,
23
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_s,
25
+ "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
24
26
  "STATSIG-SERVER-SESSION-ID" => @session_id,
25
27
  "Content-Type" => "application/json; charset=UTF-8"
26
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
  }
19
- e = download_config_specs
20
- error_callback.call(e) unless error_callback.nil?
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
+
39
+ @error_callback = error_callback
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
@@ -81,10 +119,19 @@ module Statsig
81
119
  end
82
120
 
83
121
  def download_config_specs
122
+ e = get_config_specs_from_network
123
+ @error_callback.call(e) unless e.nil? or @error_callback.nil?
124
+ end
125
+
126
+ def get_config_specs_from_network
84
127
  begin
85
- 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 }))
86
129
  if e.nil?
87
- 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
134
+ nil
88
135
  else
89
136
  e
90
137
  end
@@ -95,36 +142,47 @@ module Statsig
95
142
 
96
143
  def process(specs_json)
97
144
  if specs_json.nil?
98
- return
145
+ return false
99
146
  end
100
147
 
101
- @last_sync_time = specs_json['time'] || @last_sync_time
102
- 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 &&
103
151
  !specs_json['feature_gates'].nil? &&
104
152
  !specs_json['dynamic_configs'].nil? &&
105
- !specs_json['layer_configs'].nil?
153
+ !specs_json['layer_configs'].nil?
106
154
 
107
155
  new_gates = {}
108
156
  new_configs = {}
109
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
110
169
 
111
- specs_json['feature_gates'].map{|gate| new_gates[gate['name']] = gate }
112
- specs_json['dynamic_configs'].map{|config| new_configs[config['name']] = config }
113
- specs_json['layer_configs'].map{|layer| new_layers[layer['name']] = layer }
114
- @store[:gates] = new_gates
115
- @store[:configs] = new_configs
116
- @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
117
175
  end
118
176
 
119
177
  def get_id_lists
120
- 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 }))
121
179
  if !e.nil? || response.nil?
122
180
  return
123
181
  end
124
182
 
125
183
  begin
126
184
  server_id_lists = JSON.parse(response)
127
- local_id_lists = @store[:id_lists]
185
+ local_id_lists = @specs[:id_lists]
128
186
  if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
129
187
  return
130
188
  end
@@ -178,7 +236,7 @@ module Statsig
178
236
 
179
237
  def download_single_id_list(list)
180
238
  nil unless list.is_a? IDList
181
- http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
239
+ http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
182
240
  begin
183
241
  res = http.get(list.url)
184
242
  nil unless res.status.success?
@@ -186,7 +244,7 @@ module Statsig
186
244
  nil if content_length.nil? || content_length <= 0
187
245
  content = res.body.to_s
188
246
  unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
189
- @store[:id_lists].delete(list.name)
247
+ @specs[:id_lists].delete(list.name)
190
248
  return
191
249
  end
192
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
 
@@ -42,10 +43,25 @@ module Statsig
42
43
  @shared_instance = nil
43
44
  end
44
45
 
46
+ def self.override_gate(gate_name, gate_value)
47
+ ensure_initialized
48
+ @shared_instance&.override_gate(gate_name, gate_value)
49
+ end
50
+
51
+ def self.override_config(config_name, config_value)
52
+ ensure_initialized
53
+ @shared_instance&.override_config(config_name, config_value)
54
+ end
55
+
56
+ def self.get_client_initialize_response(user)
57
+ ensure_initialized
58
+ @shared_instance&.get_client_initialize_response(user)
59
+ end
60
+
45
61
  def self.get_statsig_metadata
46
62
  {
47
63
  'sdkType' => 'ruby-server',
48
- 'sdkVersion' => '1.11.0',
64
+ 'sdkVersion' => '1.13.0',
49
65
  }
50
66
  end
51
67
 
@@ -22,7 +22,7 @@ class StatsigDriver
22
22
  @options = options || StatsigOptions.new
23
23
  @shutdown = false
24
24
  @secret_key = secret_key
25
- @net = Statsig::Network.new(secret_key, @options.api_url_base)
25
+ @net = Statsig::Network.new(secret_key, @options.api_url_base, @options.local_mode)
26
26
  @logger = Statsig::StatsigLogger.new(@net, @options)
27
27
  @evaluator = Statsig::Evaluator.new(@net, @options, error_callback)
28
28
  end
@@ -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
@@ -98,6 +98,26 @@ class StatsigDriver
98
98
  @evaluator.shutdown
99
99
  end
100
100
 
101
+ def override_gate(gate_name, gate_value)
102
+ @evaluator.override_gate(gate_name, gate_value)
103
+ end
104
+
105
+ def override_config(config_name, config_value)
106
+ @evaluator.override_config(config_name, config_value)
107
+ end
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
+
101
121
  private
102
122
 
103
123
  def verify_inputs(user, config_name, variable_name)
@@ -120,7 +140,7 @@ class StatsigDriver
120
140
  res = get_config_fallback(user, config_name)
121
141
  # exposure logged by the server
122
142
  else
123
- @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)
124
144
  end
125
145
 
126
146
  DynamicConfig.new(res.name, res.json_value, res.rule_id)
@@ -132,7 +152,7 @@ class StatsigDriver
132
152
  (
133
153
  # user_id is nil and custom_ids is not a hash with entries
134
154
  !user.user_id.is_a?(String) &&
135
- (!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
155
+ (!user.custom_ids.is_a?(Hash) || user.custom_ids.size == 0)
136
156
  )
137
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.'
138
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
@@ -1,10 +1,13 @@
1
1
  class StatsigOptions
2
- attr_reader :environment
3
- attr_reader :api_url_base
4
- attr_reader :rulesets_sync_interval
5
- attr_reader :idlists_sync_interval
6
- attr_reader :logging_interval_seconds
7
- attr_reader :logging_max_buffer_size
2
+ attr_accessor :environment
3
+ attr_accessor :api_url_base
4
+ attr_accessor :rulesets_sync_interval
5
+ attr_accessor :idlists_sync_interval
6
+ attr_accessor :logging_interval_seconds
7
+ attr_accessor :logging_max_buffer_size
8
+ attr_accessor :local_mode
9
+ attr_accessor :bootstrap_values
10
+ attr_accessor :rules_updated_callback
8
11
 
9
12
  def initialize(
10
13
  environment=nil,
@@ -12,12 +15,18 @@ class StatsigOptions
12
15
  rulesets_sync_interval: 10,
13
16
  idlists_sync_interval: 60,
14
17
  logging_interval_seconds: 60,
15
- logging_max_buffer_size: 1000)
18
+ logging_max_buffer_size: 1000,
19
+ local_mode: false,
20
+ bootstrap_values: nil,
21
+ rules_updated_callback: nil)
16
22
  @environment = environment.is_a?(Hash) ? environment : nil
17
23
  @api_url_base = api_url_base
18
24
  @rulesets_sync_interval = rulesets_sync_interval
19
25
  @idlists_sync_interval = idlists_sync_interval
20
26
  @logging_interval_seconds = logging_interval_seconds
21
27
  @logging_max_buffer_size = [logging_max_buffer_size, 1000].min
28
+ @local_mode = local_mode
29
+ @bootstrap_values = bootstrap_values
30
+ @rules_updated_callback = rules_updated_callback
22
31
  end
23
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.11.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-23 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.3.11
158
+ rubygems_version: 3.2.3
157
159
  signing_key:
158
160
  specification_version: 4
159
161
  summary: Statsig server SDK for Ruby