statsig 1.11.0 → 1.13.0

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