statsig 1.10.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/client_initialize_helpers.rb +132 -0
- data/lib/config_result.rb +16 -3
- data/lib/diagnostics.rb +44 -0
- data/lib/dynamic_config.rb +37 -0
- data/lib/error_boundary.rb +57 -0
- data/lib/evaluation_details.rb +42 -0
- data/lib/evaluation_helpers.rb +1 -0
- data/lib/evaluator.rb +127 -14
- data/lib/id_list.rb +1 -0
- data/lib/interfaces/data_store.rb +19 -0
- data/lib/layer.rb +39 -0
- data/lib/network.rb +39 -11
- data/lib/spec_store.rb +183 -43
- data/lib/statsig.rb +213 -4
- data/lib/statsig_driver.rb +192 -62
- data/lib/statsig_errors.rb +11 -0
- data/lib/statsig_event.rb +6 -1
- data/lib/statsig_logger.rb +81 -13
- data/lib/statsig_options.rb +114 -7
- data/lib/statsig_user.rb +79 -16
- metadata +70 -8
data/lib/layer.rb
CHANGED
@@ -1,7 +1,22 @@
|
|
1
|
+
# typed: false
|
2
|
+
|
3
|
+
##
|
4
|
+
# Contains the current values from Statsig.
|
5
|
+
# Will contain layer default values for all shared parameters in that layer.
|
6
|
+
# If a parameter is in an active experiment, and the current user is allocated to that experiment,
|
7
|
+
# those parameters will be updated to reflect the experiment values not the layer defaults.
|
8
|
+
#
|
9
|
+
# Layers Documentation: https://docs.statsig.com/layers
|
1
10
|
class Layer
|
11
|
+
extend T::Sig
|
12
|
+
|
13
|
+
sig { returns(String) }
|
2
14
|
attr_accessor :name
|
15
|
+
|
16
|
+
sig { returns(String) }
|
3
17
|
attr_accessor :rule_id
|
4
18
|
|
19
|
+
sig { params(name: String, value: T::Hash[String, T.untyped], rule_id: String, exposure_log_func: T.any(Method, Proc, NilClass)).void }
|
5
20
|
def initialize(name, value = {}, rule_id = '', exposure_log_func = nil)
|
6
21
|
@name = name
|
7
22
|
@value = value
|
@@ -9,6 +24,12 @@ class Layer
|
|
9
24
|
@exposure_log_func = exposure_log_func
|
10
25
|
end
|
11
26
|
|
27
|
+
sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
|
28
|
+
##
|
29
|
+
# Get the value for the given key (index), falling back to the default_value if it cannot be found.
|
30
|
+
#
|
31
|
+
# @param index The name of parameter being fetched
|
32
|
+
# @param default_value The fallback value if the name cannot be found
|
12
33
|
def get(index, default_value)
|
13
34
|
return default_value if @value.nil? || !@value.key?(index)
|
14
35
|
|
@@ -18,4 +39,22 @@ class Layer
|
|
18
39
|
|
19
40
|
@value[index]
|
20
41
|
end
|
42
|
+
|
43
|
+
sig { params(index: String, default_value: T.untyped).returns(T.untyped) }
|
44
|
+
##
|
45
|
+
# Get the value for the given key (index), falling back to the default_value if it cannot be found
|
46
|
+
# or is found to have a different type from the default_value.
|
47
|
+
#
|
48
|
+
# @param index The name of parameter being fetched
|
49
|
+
# @param default_value The fallback value if the name cannot be found
|
50
|
+
def get_typed(index, default_value)
|
51
|
+
return default_value if @value.nil? || !@value.key?(index)
|
52
|
+
return default_value if @value[index].class != default_value.class and default_value.class != TrueClass and default_value.class != FalseClass
|
53
|
+
|
54
|
+
if @exposure_log_func.is_a? Proc
|
55
|
+
@exposure_log_func.call(self, index)
|
56
|
+
end
|
57
|
+
|
58
|
+
@value[index]
|
59
|
+
end
|
21
60
|
end
|
data/lib/network.rb
CHANGED
@@ -1,28 +1,56 @@
|
|
1
|
+
# typed: true
|
2
|
+
|
1
3
|
require 'http'
|
2
4
|
require 'json'
|
3
5
|
require 'securerandom'
|
6
|
+
require 'sorbet-runtime'
|
4
7
|
|
5
8
|
$retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
|
6
9
|
|
7
10
|
module Statsig
|
11
|
+
class NetworkError < StandardError
|
12
|
+
attr_reader :http_code
|
13
|
+
|
14
|
+
def initialize(msg = nil, http_code = nil)
|
15
|
+
super(msg)
|
16
|
+
@http_code = http_code
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
8
20
|
class Network
|
9
|
-
|
21
|
+
extend T::Sig
|
22
|
+
|
23
|
+
sig { params(server_secret: String, api: String, local_mode: T::Boolean, backoff_mult: Integer).void }
|
24
|
+
|
25
|
+
def initialize(server_secret, api, local_mode, backoff_mult = 10)
|
10
26
|
super()
|
11
27
|
unless api.end_with?('/')
|
12
28
|
api += '/'
|
13
29
|
end
|
14
30
|
@server_secret = server_secret
|
15
31
|
@api = api
|
32
|
+
@local_mode = local_mode
|
16
33
|
@backoff_multiplier = backoff_mult
|
17
34
|
@session_id = SecureRandom.uuid
|
18
35
|
end
|
19
36
|
|
37
|
+
sig { params(endpoint: String, body: String, retries: Integer, backoff: Integer)
|
38
|
+
.returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
|
39
|
+
|
20
40
|
def post_helper(endpoint, body, retries = 0, backoff = 1)
|
41
|
+
if @local_mode
|
42
|
+
return nil, nil
|
43
|
+
end
|
44
|
+
|
45
|
+
meta = Statsig.get_statsig_metadata
|
21
46
|
http = HTTP.headers(
|
22
|
-
{
|
23
|
-
|
24
|
-
|
25
|
-
|
47
|
+
{
|
48
|
+
"STATSIG-API-KEY" => @server_secret,
|
49
|
+
"STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
|
50
|
+
"STATSIG-SERVER-SESSION-ID" => @session_id,
|
51
|
+
"Content-Type" => "application/json; charset=UTF-8",
|
52
|
+
"STATSIG-SDK-TYPE" => meta['sdkType'],
|
53
|
+
"STATSIG-SDK-VERSION" => meta['sdkVersion'],
|
26
54
|
}).accept(:json)
|
27
55
|
begin
|
28
56
|
res = http.post(@api + endpoint, body: body)
|
@@ -32,8 +60,8 @@ module Statsig
|
|
32
60
|
sleep backoff
|
33
61
|
return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
34
62
|
end
|
35
|
-
return res, nil
|
36
|
-
return nil,
|
63
|
+
return res, nil if res.status.success?
|
64
|
+
return nil, NetworkError.new("Got an exception when making request to #{@api + endpoint}: #{res.to_s}", res.status.to_i) unless retries > 0 && $retry_codes.include?(res.code)
|
37
65
|
## status code retry
|
38
66
|
sleep backoff
|
39
67
|
post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
|
@@ -41,7 +69,7 @@ module Statsig
|
|
41
69
|
|
42
70
|
def check_gate(user, gate_name)
|
43
71
|
begin
|
44
|
-
request_body = JSON.generate({'user' => user&.serialize(false), 'gateName' => gate_name})
|
72
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
|
45
73
|
response, _ = post_helper('check_gate', request_body)
|
46
74
|
return JSON.parse(response.body) unless response.nil?
|
47
75
|
false
|
@@ -52,7 +80,7 @@ module Statsig
|
|
52
80
|
|
53
81
|
def get_config(user, dynamic_config_name)
|
54
82
|
begin
|
55
|
-
request_body = JSON.generate({'user' => user&.serialize(false), 'configName' => dynamic_config_name})
|
83
|
+
request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
|
56
84
|
response, _ = post_helper('get_config', request_body)
|
57
85
|
return JSON.parse(response.body) unless response.nil?
|
58
86
|
nil
|
@@ -63,8 +91,8 @@ module Statsig
|
|
63
91
|
|
64
92
|
def post_logs(events)
|
65
93
|
begin
|
66
|
-
json_body = JSON.generate({'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata})
|
67
|
-
post_helper('log_event', json_body,
|
94
|
+
json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
|
95
|
+
post_helper('log_event', json_body, 5)
|
68
96
|
rescue
|
69
97
|
end
|
70
98
|
end
|
data/lib/spec_store.rb
CHANGED
@@ -1,71 +1,157 @@
|
|
1
|
+
# typed: false
|
1
2
|
require 'net/http'
|
2
3
|
require 'uri'
|
3
|
-
|
4
|
+
require 'evaluation_details'
|
4
5
|
require 'id_list'
|
6
|
+
require 'concurrent-ruby'
|
5
7
|
|
6
8
|
module Statsig
|
7
9
|
class SpecStore
|
8
|
-
|
10
|
+
|
11
|
+
CONFIG_SPECS_KEY = "statsig.cache"
|
12
|
+
|
13
|
+
attr_accessor :last_config_sync_time
|
14
|
+
attr_accessor :initial_config_sync_time
|
15
|
+
attr_accessor :init_reason
|
16
|
+
|
17
|
+
def initialize(network, options, error_callback, init_diagnostics = nil)
|
18
|
+
@init_reason = EvaluationReason::UNINITIALIZED
|
9
19
|
@network = network
|
10
|
-
@
|
11
|
-
@
|
12
|
-
@
|
13
|
-
@
|
20
|
+
@options = options
|
21
|
+
@error_callback = error_callback
|
22
|
+
@last_config_sync_time = 0
|
23
|
+
@initial_config_sync_time = 0
|
24
|
+
@rulesets_sync_interval = options.rulesets_sync_interval
|
25
|
+
@id_lists_sync_interval = options.idlists_sync_interval
|
26
|
+
@rules_updated_callback = options.rules_updated_callback
|
27
|
+
@specs = {
|
14
28
|
:gates => {},
|
15
29
|
:configs => {},
|
16
30
|
:layers => {},
|
17
31
|
:id_lists => {},
|
32
|
+
:experiment_to_layer => {}
|
18
33
|
}
|
19
|
-
|
20
|
-
|
21
|
-
|
34
|
+
|
35
|
+
@id_list_thread_pool = Concurrent::FixedThreadPool.new(
|
36
|
+
options.idlist_threadpool_size,
|
37
|
+
max_queue: 100,
|
38
|
+
fallback_policy: :discard,
|
39
|
+
)
|
40
|
+
|
41
|
+
unless @options.bootstrap_values.nil?
|
42
|
+
begin
|
43
|
+
if !@options.data_store.nil?
|
44
|
+
puts 'data_store gets priority over bootstrap_values. bootstrap_values will be ignored'
|
45
|
+
else
|
46
|
+
init_diagnostics&.mark("bootstrap", "start", "load")
|
47
|
+
if process(options.bootstrap_values)
|
48
|
+
@init_reason = EvaluationReason::BOOTSTRAP
|
49
|
+
end
|
50
|
+
init_diagnostics&.mark("bootstrap", "end", "load", @init_reason == EvaluationReason::BOOTSTRAP)
|
51
|
+
end
|
52
|
+
rescue
|
53
|
+
puts 'the provided bootstrapValues is not a valid JSON string'
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
unless @options.data_store.nil?
|
58
|
+
init_diagnostics&.mark("data_store", "start", "load")
|
59
|
+
@options.data_store.init
|
60
|
+
load_from_storage_adapter
|
61
|
+
init_diagnostics&.mark("data_store", "end", "load", @init_reason == EvaluationReason::DATA_ADAPTER)
|
62
|
+
end
|
63
|
+
|
64
|
+
if @init_reason == EvaluationReason::UNINITIALIZED
|
65
|
+
download_config_specs(init_diagnostics)
|
66
|
+
end
|
67
|
+
|
68
|
+
@initial_config_sync_time = @last_config_sync_time == 0 ? -1 : @last_config_sync_time
|
69
|
+
get_id_lists(init_diagnostics)
|
22
70
|
|
23
71
|
@config_sync_thread = sync_config_specs
|
24
72
|
@id_lists_sync_thread = sync_id_lists
|
25
73
|
end
|
26
74
|
|
75
|
+
def is_ready_for_checks
|
76
|
+
@last_config_sync_time != 0
|
77
|
+
end
|
78
|
+
|
27
79
|
def shutdown
|
28
80
|
@config_sync_thread&.exit
|
29
81
|
@id_lists_sync_thread&.exit
|
82
|
+
@id_list_thread_pool.shutdown
|
83
|
+
@id_list_thread_pool.wait_for_termination(timeout = 3)
|
84
|
+
unless @options.data_store.nil?
|
85
|
+
@options.data_store.shutdown
|
86
|
+
end
|
30
87
|
end
|
31
88
|
|
32
89
|
def has_gate?(gate_name)
|
33
|
-
@
|
90
|
+
@specs[:gates].key?(gate_name)
|
34
91
|
end
|
35
92
|
|
36
93
|
def has_config?(config_name)
|
37
|
-
@
|
94
|
+
@specs[:configs].key?(config_name)
|
38
95
|
end
|
39
96
|
|
40
97
|
def has_layer?(layer_name)
|
41
|
-
@
|
98
|
+
@specs[:layers].key?(layer_name)
|
42
99
|
end
|
43
100
|
|
44
101
|
def get_gate(gate_name)
|
45
102
|
return nil unless has_gate?(gate_name)
|
46
|
-
@
|
103
|
+
@specs[:gates][gate_name]
|
47
104
|
end
|
48
105
|
|
49
106
|
def get_config(config_name)
|
50
107
|
return nil unless has_config?(config_name)
|
51
|
-
@
|
108
|
+
@specs[:configs][config_name]
|
52
109
|
end
|
53
110
|
|
54
111
|
def get_layer(layer_name)
|
55
112
|
return nil unless has_layer?(layer_name)
|
56
|
-
@
|
113
|
+
@specs[:layers][layer_name]
|
57
114
|
end
|
58
115
|
|
59
116
|
def get_id_list(list_name)
|
60
|
-
@
|
117
|
+
@specs[:id_lists][list_name]
|
118
|
+
end
|
119
|
+
|
120
|
+
def get_raw_specs
|
121
|
+
@specs
|
122
|
+
end
|
123
|
+
|
124
|
+
def maybe_restart_background_threads
|
125
|
+
if @config_sync_thread.nil? or !@config_sync_thread.alive?
|
126
|
+
@config_sync_thread = sync_config_specs
|
127
|
+
end
|
128
|
+
if @id_lists_sync_thread.nil? or !@id_lists_sync_thread.alive?
|
129
|
+
@id_lists_sync_thread = sync_id_lists
|
130
|
+
end
|
61
131
|
end
|
62
132
|
|
63
133
|
private
|
64
134
|
|
135
|
+
def load_from_storage_adapter
|
136
|
+
cached_values = @options.data_store.get(CONFIG_SPECS_KEY)
|
137
|
+
if cached_values.nil?
|
138
|
+
return
|
139
|
+
end
|
140
|
+
process(cached_values, true)
|
141
|
+
@init_reason = EvaluationReason::DATA_ADAPTER
|
142
|
+
end
|
143
|
+
|
144
|
+
def save_to_storage_adapter(specs_string)
|
145
|
+
if @options.data_store.nil?
|
146
|
+
return
|
147
|
+
end
|
148
|
+
@options.data_store.set(CONFIG_SPECS_KEY, specs_string)
|
149
|
+
end
|
150
|
+
|
65
151
|
def sync_config_specs
|
66
152
|
Thread.new do
|
67
153
|
loop do
|
68
|
-
sleep @rulesets_sync_interval
|
154
|
+
sleep @options.rulesets_sync_interval
|
69
155
|
download_config_specs
|
70
156
|
end
|
71
157
|
end
|
@@ -80,55 +166,102 @@ module Statsig
|
|
80
166
|
end
|
81
167
|
end
|
82
168
|
|
83
|
-
def download_config_specs
|
169
|
+
def download_config_specs(init_diagnostics = nil)
|
170
|
+
init_diagnostics&.mark("download_config_specs", "start", "network_request")
|
171
|
+
|
172
|
+
error = nil
|
84
173
|
begin
|
85
|
-
response, e = @network.post_helper('download_config_specs', JSON.generate({'sinceTime' => @
|
174
|
+
response, e = @network.post_helper('download_config_specs', JSON.generate({ 'sinceTime' => @last_config_sync_time }))
|
175
|
+
code = response&.status.to_i
|
176
|
+
if e.is_a? NetworkError
|
177
|
+
code = e.http_code
|
178
|
+
end
|
179
|
+
init_diagnostics&.mark("download_config_specs", "end", "network_request", code)
|
180
|
+
|
86
181
|
if e.nil?
|
87
|
-
|
182
|
+
unless response.nil?
|
183
|
+
init_diagnostics&.mark("download_config_specs", "start", "process")
|
184
|
+
|
185
|
+
if process(response.body)
|
186
|
+
@init_reason = EvaluationReason::NETWORK
|
187
|
+
@rules_updated_callback.call(response.body.to_s, @last_config_sync_time) unless response.body.nil? or @rules_updated_callback.nil?
|
188
|
+
end
|
189
|
+
|
190
|
+
init_diagnostics&.mark("download_config_specs", "end", "process", @init_reason == EvaluationReason::NETWORK)
|
191
|
+
end
|
192
|
+
|
193
|
+
nil
|
88
194
|
else
|
89
|
-
e
|
195
|
+
error = e
|
90
196
|
end
|
91
197
|
rescue StandardError => e
|
92
|
-
e
|
198
|
+
error = e
|
93
199
|
end
|
200
|
+
|
201
|
+
@error_callback.call(error) unless error.nil? or @error_callback.nil?
|
94
202
|
end
|
95
203
|
|
96
|
-
def process(
|
97
|
-
if
|
98
|
-
return
|
204
|
+
def process(specs_string, from_adapter = false)
|
205
|
+
if specs_string.nil?
|
206
|
+
return false
|
99
207
|
end
|
100
208
|
|
101
|
-
|
102
|
-
return unless specs_json
|
209
|
+
specs_json = JSON.parse(specs_string)
|
210
|
+
return false unless specs_json.is_a? Hash
|
211
|
+
|
212
|
+
@last_config_sync_time = specs_json['time'] || @last_config_sync_time
|
213
|
+
return false unless specs_json['has_updates'] == true &&
|
103
214
|
!specs_json['feature_gates'].nil? &&
|
104
215
|
!specs_json['dynamic_configs'].nil? &&
|
105
|
-
!specs_json['layer_configs'].nil?
|
216
|
+
!specs_json['layer_configs'].nil?
|
106
217
|
|
107
218
|
new_gates = {}
|
108
219
|
new_configs = {}
|
109
220
|
new_layers = {}
|
221
|
+
new_exp_to_layer = {}
|
222
|
+
|
223
|
+
specs_json['feature_gates'].each { |gate| new_gates[gate['name']] = gate }
|
224
|
+
specs_json['dynamic_configs'].each { |config| new_configs[config['name']] = config }
|
225
|
+
specs_json['layer_configs'].each { |layer| new_layers[layer['name']] = layer }
|
226
|
+
|
227
|
+
if specs_json['layers'].is_a?(Hash)
|
228
|
+
specs_json['layers'].each { |layer_name, experiments|
|
229
|
+
experiments.each { |experiment_name| new_exp_to_layer[experiment_name] = layer_name }
|
230
|
+
}
|
231
|
+
end
|
110
232
|
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
@
|
115
|
-
|
116
|
-
|
233
|
+
@specs[:gates] = new_gates
|
234
|
+
@specs[:configs] = new_configs
|
235
|
+
@specs[:layers] = new_layers
|
236
|
+
@specs[:experiment_to_layer] = new_exp_to_layer
|
237
|
+
|
238
|
+
unless from_adapter
|
239
|
+
save_to_storage_adapter(specs_string)
|
240
|
+
end
|
241
|
+
true
|
117
242
|
end
|
118
243
|
|
119
|
-
def get_id_lists
|
120
|
-
|
244
|
+
def get_id_lists(init_diagnostics = nil)
|
245
|
+
init_diagnostics&.mark("get_id_lists", "start", "network_request")
|
246
|
+
response, e = @network.post_helper('get_id_lists', JSON.generate({ 'statsigMetadata' => Statsig.get_statsig_metadata }))
|
121
247
|
if !e.nil? || response.nil?
|
122
248
|
return
|
123
249
|
end
|
250
|
+
init_diagnostics&.mark("get_id_lists", "end", "network_request", response.status.to_i)
|
124
251
|
|
125
252
|
begin
|
126
253
|
server_id_lists = JSON.parse(response)
|
127
|
-
local_id_lists = @
|
254
|
+
local_id_lists = @specs[:id_lists]
|
128
255
|
if !server_id_lists.is_a?(Hash) || !local_id_lists.is_a?(Hash)
|
129
256
|
return
|
130
257
|
end
|
131
|
-
|
258
|
+
tasks = []
|
259
|
+
|
260
|
+
if server_id_lists.length == 0
|
261
|
+
return
|
262
|
+
end
|
263
|
+
|
264
|
+
init_diagnostics&.mark("get_id_lists", "start", "process", server_id_lists.length)
|
132
265
|
|
133
266
|
server_id_lists.each do |list_name, list|
|
134
267
|
server_list = IDList.new(list)
|
@@ -157,11 +290,17 @@ module Statsig
|
|
157
290
|
next
|
158
291
|
end
|
159
292
|
|
160
|
-
|
293
|
+
tasks << Concurrent::Promise.execute(:executor => @id_list_thread_pool) do
|
161
294
|
download_single_id_list(local_list)
|
162
295
|
end
|
163
296
|
end
|
164
|
-
|
297
|
+
|
298
|
+
result = Concurrent::Promise.all?(*tasks).execute.wait(@id_lists_sync_interval)
|
299
|
+
if result.state != :fulfilled
|
300
|
+
init_diagnostics&.mark("get_id_lists", "end", "process", false)
|
301
|
+
return # timed out
|
302
|
+
end
|
303
|
+
|
165
304
|
delete_lists = []
|
166
305
|
local_id_lists.each do |list_name, list|
|
167
306
|
unless server_id_lists.key? list_name
|
@@ -171,6 +310,7 @@ module Statsig
|
|
171
310
|
delete_lists.each do |list_name|
|
172
311
|
local_id_lists.delete list_name
|
173
312
|
end
|
313
|
+
init_diagnostics&.mark("get_id_lists", "end", "process", true)
|
174
314
|
rescue
|
175
315
|
# Ignored, will try again
|
176
316
|
end
|
@@ -178,7 +318,7 @@ module Statsig
|
|
178
318
|
|
179
319
|
def download_single_id_list(list)
|
180
320
|
nil unless list.is_a? IDList
|
181
|
-
http = HTTP.headers({'Range' => "bytes=#{list&.size || 0}-"}).accept(:json)
|
321
|
+
http = HTTP.headers({ 'Range' => "bytes=#{list&.size || 0}-" }).accept(:json)
|
182
322
|
begin
|
183
323
|
res = http.get(list.url)
|
184
324
|
nil unless res.status.success?
|
@@ -186,7 +326,7 @@ module Statsig
|
|
186
326
|
nil if content_length.nil? || content_length <= 0
|
187
327
|
content = res.body.to_s
|
188
328
|
unless content.is_a?(String) && (content[0] == '-' || content[0] == '+')
|
189
|
-
@
|
329
|
+
@specs[:id_lists].delete(list.name)
|
190
330
|
return
|
191
331
|
end
|
192
332
|
ids_clone = list.ids # clone the list, operate on the new list, and swap out the old list, so the operation is thread-safe
|
@@ -195,7 +335,7 @@ module Statsig
|
|
195
335
|
line = li.strip
|
196
336
|
next if line.length <= 1
|
197
337
|
op = line[0]
|
198
|
-
id = line[1..]
|
338
|
+
id = line[1..line.length]
|
199
339
|
if op == '+'
|
200
340
|
ids_clone.add(id)
|
201
341
|
elsif op == '-'
|