statsig 1.25.1 → 1.26.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: f1772b0fb47eaaf4b95950425eeba70ea7b792a8e5aad952b67e7aea8000d06b
4
- data.tar.gz: 2e75c8c1c9e68817440dd73d98020f99f20e80255424fb499dff8d3464eb39ba
3
+ metadata.gz: 42f5328314de8727a2baaf89da54999627a5505ad95cd5ab816877741e02dd55
4
+ data.tar.gz: f6af1d20f540ceae4e24c70bdc702fbd9db3acc38776b71ecc50ded6ef40f67d
5
5
  SHA512:
6
- metadata.gz: 5f291605da74348e702b8258ca52c6bb609d6359c961a862d76c50e7a53b348bb7705ed6ac2e3da2b68ea89c2db852feb50f5213d5c9543af665142e8cc2bc15
7
- data.tar.gz: 3f1923846883e667b5f5ae14c9efd90fd295a3c0336be262ec879227b3af85b513f9dc407687e8f6273e911644b40bbde7324ff86fc3bc7b26e4a3fd9345992b
6
+ metadata.gz: 8c4375a325bf7dd1afaa584951b31eca2fd19422301b72397729fdca89e1ef8ae9b7a979aff562889cf49fc98fcd7db3edfc022ca66eb063b2680645c90df2db
7
+ data.tar.gz: ab23702707825b1ed5ada0df1982d765b97b75e8d813aeeaa9f24ae31974bb0cafa58249e82cc041eaa845bea9ee7639189c8778f8e21e0908b7ec3174d41661
@@ -1,4 +1,7 @@
1
1
  # typed: true
2
+
3
+ require_relative 'hash_utils'
4
+
2
5
  $empty_eval_result = {
3
6
  :gate_value => false,
4
7
  :json_value => {},
@@ -9,10 +12,12 @@ $empty_eval_result = {
9
12
 
10
13
  module ClientInitializeHelpers
11
14
  class ResponseFormatter
12
- def initialize(evaluator, user)
15
+ def initialize(evaluator, user, hash, client_sdk_key)
13
16
  @evaluator = evaluator
14
17
  @user = user
15
18
  @specs = evaluator.spec_store.get_raw_specs
19
+ @hash = hash
20
+ @client_sdk_key = client_sdk_key
16
21
  end
17
22
 
18
23
  def get_responses(key)
@@ -24,6 +29,13 @@ module ClientInitializeHelpers
24
29
  private
25
30
 
26
31
  def to_response(config_name, config_spec)
32
+ target_app_id = @evaluator.spec_store.get_app_id_for_sdk_key(@client_sdk_key)
33
+ config_target_apps = config_spec['targetAppIDs']
34
+
35
+ unless target_app_id.nil? || config_target_apps.nil? || config_target_apps.include?(target_app_id)
36
+ return nil
37
+ end
38
+
27
39
  eval_result = @evaluator.eval_spec(@user, config_spec)
28
40
  if eval_result.nil?
29
41
  return nil
@@ -33,6 +45,8 @@ module ClientInitializeHelpers
33
45
  :gate_value => eval_result.gate_value,
34
46
  :json_value => eval_result.json_value,
35
47
  :rule_id => eval_result.rule_id,
48
+ :group_name => eval_result.group_name,
49
+ :id_type => eval_result.id_type,
36
50
  :config_delegate => eval_result.config_delegate,
37
51
  :is_experiment_group => eval_result.is_experiment_group,
38
52
  :secondary_exposures => eval_result.secondary_exposures,
@@ -52,10 +66,14 @@ module ClientInitializeHelpers
52
66
  end
53
67
 
54
68
  result['value'] = safe_eval_result[:gate_value]
69
+ result["group_name"] = safe_eval_result[:group_name]
70
+ result["id_type"] = safe_eval_result[:id_type]
55
71
  when 'dynamic_config'
56
72
  id_type = config_spec['idType']
57
73
  result['value'] = safe_eval_result[:json_value]
58
74
  result["group"] = safe_eval_result[:rule_id]
75
+ result["group_name"] = safe_eval_result[:group_name]
76
+ result["id_type"] = safe_eval_result[:id_type]
59
77
  result["is_device_based"] = id_type.is_a?(String) && id_type.downcase == 'stableid'
60
78
  else
61
79
  return nil
@@ -67,6 +85,7 @@ module ClientInitializeHelpers
67
85
 
68
86
  if entity_type == 'layer'
69
87
  populate_layer_fields(config_spec, safe_eval_result, result)
88
+ result.delete('id_type') # not exposed for layer configs in /initialize
70
89
  end
71
90
 
72
91
  hashed_name = hash_name(config_name)
@@ -126,7 +145,14 @@ module ClientInitializeHelpers
126
145
  end
127
146
 
128
147
  def hash_name(name)
129
- Digest::SHA256.base64digest(name)
148
+ case @hash
149
+ when 'none'
150
+ return name
151
+ when 'sha256'
152
+ return Statsig::HashUtils.sha256(name)
153
+ when 'djb2'
154
+ return Statsig::HashUtils.djb2(name)
155
+ end
130
156
  end
131
157
  end
132
- end
158
+ end
data/lib/diagnostics.rb CHANGED
@@ -12,33 +12,100 @@ module Statsig
12
12
  sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
13
13
  attr_reader :markers
14
14
 
15
- sig { params(context: String).void }
16
-
17
15
  def initialize(context)
18
16
  @context = context
19
17
  @markers = []
20
18
  end
21
19
 
22
- sig { params(key: String, action: String, step: T.any(String, NilClass), value: T.any(String, Integer, T::Boolean, NilClass)).void }
20
+ sig do
21
+ params(
22
+ key: String,
23
+ action: String,
24
+ step: T.any(String, NilClass),
25
+ tags: T::Hash[Symbol, T.untyped]
26
+ ).void
27
+ end
28
+
29
+ def mark(key, action, step, tags)
30
+ marker = {
31
+ key: key,
32
+ action: action,
33
+ timestamp: (Time.now.to_f * 1000).to_i
34
+ }
35
+ if !step.nil?
36
+ marker[:step] = step
37
+ end
38
+ tags.each do |key, val|
39
+ unless val.nil?
40
+ marker[key] = val
41
+ end
42
+ end
43
+ @markers.push(marker)
44
+ end
23
45
 
24
- def mark(key, action, step = nil, value = nil)
25
- @markers.push({
26
- key: key,
27
- step: step,
28
- action: action,
29
- value: value,
30
- timestamp: (Time.now.to_f * 1000).to_i
31
- })
46
+ sig do
47
+ params(
48
+ key: String,
49
+ step: T.any(String, NilClass),
50
+ tags: T::Hash[Symbol, T.untyped]
51
+ ).returns(Tracker)
52
+ end
53
+ def track(key, step = nil, tags = {})
54
+ tracker = Tracker.new(self, key, step, tags)
55
+ tracker.start(**tags)
56
+ tracker
32
57
  end
33
58
 
34
59
  sig { returns(T::Hash[Symbol, T.untyped]) }
35
60
 
36
61
  def serialize
37
62
  {
38
- context: @context,
39
- markers: @markers
63
+ context: @context.clone,
64
+ markers: @markers.clone
40
65
  }
41
66
  end
42
- end
43
67
 
44
- end
68
+ def clear_markers
69
+ @markers.clear
70
+ end
71
+
72
+ def self.sample(rate)
73
+ rand(rate).zero?
74
+ end
75
+
76
+ class Context
77
+ INITIALIZE = 'initialize'.freeze
78
+ CONFIG_SYNC = 'config_sync'.freeze
79
+ API_CALL = 'api_call'.freeze
80
+ end
81
+
82
+ API_CALL_KEYS = %w[check_gate get_config get_experiment get_layer].freeze
83
+
84
+ class Tracker
85
+ extend T::Sig
86
+
87
+ sig do
88
+ params(
89
+ diagnostics: Diagnostics,
90
+ key: String,
91
+ step: T.any(String, NilClass),
92
+ tags: T::Hash[Symbol, T.untyped]
93
+ ).void
94
+ end
95
+ def initialize(diagnostics, key, step, tags = {})
96
+ @diagnostics = diagnostics
97
+ @key = key
98
+ @step = step
99
+ @tags = tags
100
+ end
101
+
102
+ def start(**tags)
103
+ @diagnostics.mark(@key, 'start', @step, tags.nil? ? {} : tags.merge(@tags))
104
+ end
105
+
106
+ def end(**tags)
107
+ @diagnostics.mark(@key, 'end', @step, tags.nil? ? {} : tags.merge(@tags))
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,57 +1,64 @@
1
- require "statsig_errors"
1
+ # typed: true
2
+
3
+ require 'statsig_errors'
4
+ require 'sorbet-runtime'
2
5
 
3
6
  $endpoint = 'https://statsigapi.net/v1/sdk_exception'
4
7
 
5
8
  module Statsig
6
9
  class ErrorBoundary
10
+ extend T::Sig
11
+
12
+ sig { params(sdk_key: String).void }
7
13
  def initialize(sdk_key)
8
14
  @sdk_key = sdk_key
9
15
  @seen = Set.new
10
16
  end
11
17
 
12
- def capture(task, recover = -> {})
18
+ def capture(task:, recover: -> {}, caller: nil)
13
19
  begin
14
- return task.call
20
+ res = task.call
15
21
  rescue StandardError => e
16
- if e.is_a?(Statsig::UninitializedError) or e.is_a?(Statsig::ValueError)
22
+ if e.is_a?(Statsig::UninitializedError) || e.is_a?(Statsig::ValueError)
17
23
  raise e
18
24
  end
19
- puts "[Statsig]: An unexpected exception occurred."
20
- log_exception(e)
21
- return recover.call
25
+
26
+ puts '[Statsig]: An unexpected exception occurred.'
27
+ log_exception(e, tag: caller)
28
+ res = recover.call
22
29
  end
30
+ return res
23
31
  end
24
32
 
25
33
  private
26
34
 
27
- def log_exception(exception)
28
- begin
29
- name = exception.class.name
30
- if @seen.include?(name)
31
- return
32
- end
33
-
34
- @seen << name
35
- meta = Statsig.get_statsig_metadata
36
- http = HTTP.headers(
37
- {
38
- "STATSIG-API-KEY" => @sdk_key,
39
- "STATSIG-SDK-TYPE" => meta['sdkType'],
40
- "STATSIG-SDK-VERSION" => meta['sdkVersion'],
41
- "Content-Type" => "application/json; charset=UTF-8"
42
- }).accept(:json)
43
- body = {
44
- "exception" => name,
45
- "info" => {
46
- "trace" => exception.backtrace.to_s,
47
- "message" => exception.message
48
- }.to_s,
49
- "statsigMetadata" => meta
50
- }
51
- http.post($endpoint, body: JSON.generate(body))
52
- rescue
35
+ def log_exception(exception, tag: nil)
36
+ name = exception.class.name
37
+ if @seen.include?(name)
53
38
  return
54
39
  end
40
+
41
+ @seen << name
42
+ meta = Statsig.get_statsig_metadata
43
+ http = HTTP.headers(
44
+ {
45
+ 'STATSIG-API-KEY' => @sdk_key,
46
+ 'STATSIG-SDK-TYPE' => meta['sdkType'],
47
+ 'STATSIG-SDK-VERSION' => meta['sdkVersion'],
48
+ 'Content-Type' => 'application/json; charset=UTF-8'
49
+ }).accept(:json)
50
+ body = {
51
+ 'exception' => name,
52
+ 'info' => {
53
+ 'trace' => exception.backtrace.to_s,
54
+ 'message' => exception.message
55
+ }.to_s,
56
+ 'statsigMetadata' => meta,
57
+ 'tag' => tag
58
+ }
59
+ http.post($endpoint, body: JSON.generate(body))
60
+ rescue StandardError
61
+ return
55
62
  end
56
63
  end
57
- end
64
+ end
data/lib/evaluator.rb CHANGED
@@ -17,8 +17,8 @@ module Statsig
17
17
  class Evaluator
18
18
  attr_accessor :spec_store
19
19
 
20
- def initialize(network, options, error_callback, init_diagnostics = nil)
21
- @spec_store = Statsig::SpecStore.new(network, options, error_callback, init_diagnostics)
20
+ def initialize(network, options, error_callback, diagnostics, error_boundary, logger)
21
+ @spec_store = Statsig::SpecStore.new(network, options, error_callback, diagnostics, error_boundary, logger)
22
22
  UAParser.initialize_async
23
23
  CountryLookup.initialize_async
24
24
 
@@ -98,12 +98,12 @@ module Statsig
98
98
  eval_spec(user, @spec_store.get_layer(layer_name))
99
99
  end
100
100
 
101
- def get_client_initialize_response(user)
101
+ def get_client_initialize_response(user, hash, client_sdk_key)
102
102
  if @spec_store.is_ready_for_checks == false
103
103
  return nil
104
104
  end
105
105
 
106
- formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user)
106
+ formatter = ClientInitializeHelpers::ResponseFormatter.new(self, user, hash, client_sdk_key)
107
107
 
108
108
  evaluated_keys = {}
109
109
  if user.user_id.nil? == false
@@ -123,6 +123,7 @@ module Statsig
123
123
  "generator" => "statsig-ruby-sdk",
124
124
  "evaluated_keys" => evaluated_keys,
125
125
  "time" => 0,
126
+ "hash_used" => hash
126
127
  }
127
128
  end
128
129
 
@@ -199,7 +200,7 @@ module Statsig
199
200
  @spec_store.initial_config_sync_time,
200
201
  @spec_store.init_reason
201
202
  ),
202
- group_name: nil,
203
+ group_name: 'default',
203
204
  id_type: config['idType']
204
205
  )
205
206
  end
data/lib/hash_utils.rb ADDED
@@ -0,0 +1,17 @@
1
+ module Statsig
2
+ class HashUtils
3
+ def self.djb2(input_str)
4
+ hash = 0
5
+ input_str.each_char.each do |c|
6
+ hash = (hash << 5) - hash + c.ord
7
+ hash &= hash
8
+ end
9
+ hash &= 0xFFFFFFFF # Convert to unsigned 32-bit integer
10
+ return hash.to_s
11
+ end
12
+
13
+ def self.sha256(input_str)
14
+ return Digest::SHA256.base64digest(input_str)
15
+ end
16
+ end
17
+ end
data/lib/network.rb CHANGED
@@ -4,8 +4,10 @@ require 'http'
4
4
  require 'json'
5
5
  require 'securerandom'
6
6
  require 'sorbet-runtime'
7
+ require 'uri_helper'
8
+ require 'connection_pool'
7
9
 
8
- $retry_codes = [408, 500, 502, 503, 504, 522, 524, 599]
10
+ RETRY_CODES = [408, 500, 502, 503, 504, 522, 524, 599].freeze
9
11
 
10
12
  module Statsig
11
13
  class NetworkError < StandardError
@@ -24,41 +26,44 @@ module Statsig
24
26
 
25
27
  def initialize(server_secret, options, backoff_mult = 10)
26
28
  super()
27
- api = options.api_url_base
28
- unless api.end_with?('/')
29
- api += '/'
30
- end
29
+ URIHelper.initialize(options)
31
30
  @server_secret = server_secret
32
- @api = api
33
31
  @local_mode = options.local_mode
34
32
  @timeout = options.network_timeout
35
33
  @backoff_multiplier = backoff_mult
36
34
  @post_logs_retry_backoff = options.post_logs_retry_backoff
37
35
  @post_logs_retry_limit = options.post_logs_retry_limit
38
36
  @session_id = SecureRandom.uuid
37
+ @connection_pool = ConnectionPool.new(size: 3) do
38
+ meta = Statsig.get_statsig_metadata
39
+ client = HTTP.headers(
40
+ {
41
+ 'STATSIG-API-KEY' => @server_secret,
42
+ 'STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s,
43
+ 'STATSIG-SERVER-SESSION-ID' => @session_id,
44
+ 'Content-Type' => 'application/json; charset=UTF-8',
45
+ 'STATSIG-SDK-TYPE' => meta['sdkType'],
46
+ 'STATSIG-SDK-VERSION' => meta['sdkVersion']
47
+ }
48
+ ).accept(:json)
49
+ if @timeout
50
+ client = client.timeout(@timeout)
51
+ end
52
+
53
+ client
54
+ end
39
55
  end
40
56
 
41
- sig { params(endpoint: String, body: String, retries: Integer, backoff: Integer)
42
- .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)]) }
57
+ sig do
58
+ params(endpoint: String, body: String, retries: Integer, backoff: Integer)
59
+ .returns([T.any(HTTP::Response, NilClass), T.any(StandardError, NilClass)])
60
+ end
43
61
 
44
62
  def post_helper(endpoint, body, retries = 0, backoff = 1)
45
63
  if @local_mode
46
64
  return nil, nil
47
65
  end
48
66
 
49
- meta = Statsig.get_statsig_metadata
50
- http = HTTP.headers(
51
- {
52
- "STATSIG-API-KEY" => @server_secret,
53
- "STATSIG-CLIENT-TIME" => (Time.now.to_f * 1000).to_i.to_s,
54
- "STATSIG-SERVER-SESSION-ID" => @session_id,
55
- "Content-Type" => "application/json; charset=UTF-8",
56
- "STATSIG-SDK-TYPE" => meta['sdkType'],
57
- "STATSIG-SDK-VERSION" => meta['sdkVersion'],
58
- }).accept(:json)
59
- if @timeout
60
- http = http.timeout(@timeout)
61
- end
62
67
  backoff_adjusted = backoff > 10 ? backoff += Random.rand(10) : backoff # to deter overlap
63
68
  if @post_logs_retry_backoff
64
69
  if @post_logs_retry_backoff.is_a? Integer
@@ -67,49 +72,55 @@ module Statsig
67
72
  backoff_adjusted = @post_logs_retry_backoff.call(retries)
68
73
  end
69
74
  end
75
+ url = URIHelper.build_url(endpoint)
70
76
  begin
71
- res = http.post(@api + endpoint, body: body)
77
+ res = @connection_pool.with do |conn|
78
+ conn.headers('STATSIG-CLIENT-TIME' => (Time.now.to_f * 1000).to_i.to_s).post(url, body: body)
79
+ end
72
80
  rescue StandardError => e
73
81
  ## network error retry
74
- return nil, e unless retries > 0
82
+ return nil, e unless retries.positive?
83
+
75
84
  sleep backoff_adjusted
76
85
  return post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
77
86
  end
78
87
  return res, nil if res.status.success?
79
- 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)
88
+
89
+ unless retries.positive? && RETRY_CODES.include?(res.code)
90
+ return res, NetworkError.new("Got an exception when making request to #{url}: #{res.to_s}",
91
+ res.status.to_i)
92
+ end
93
+
80
94
  ## status code retry
81
95
  sleep backoff_adjusted
82
96
  post_helper(endpoint, body, retries - 1, backoff * @backoff_multiplier)
83
97
  end
84
98
 
85
99
  def check_gate(user, gate_name)
86
- begin
87
- request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
88
- response, _ = post_helper('check_gate', request_body)
89
- return JSON.parse(response.body) unless response.nil?
90
- false
91
- rescue
92
- return false
93
- end
100
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'gateName' => gate_name })
101
+ response, = post_helper('check_gate', request_body)
102
+ return JSON.parse(response.body) unless response.nil?
103
+
104
+ false
105
+ rescue StandardError
106
+ false
94
107
  end
95
108
 
96
109
  def get_config(user, dynamic_config_name)
97
- begin
98
- request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
99
- response, _ = post_helper('get_config', request_body)
100
- return JSON.parse(response.body) unless response.nil?
101
- nil
102
- rescue
103
- return nil
104
- end
110
+ request_body = JSON.generate({ 'user' => user&.serialize(false), 'configName' => dynamic_config_name })
111
+ response, = post_helper('get_config', request_body)
112
+ return JSON.parse(response.body) unless response.nil?
113
+
114
+ nil
115
+ rescue StandardError
116
+ nil
105
117
  end
106
118
 
107
119
  def post_logs(events)
108
- begin
109
- json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
110
- post_helper('log_event', json_body, @post_logs_retry_limit)
111
- rescue
112
- end
120
+ json_body = JSON.generate({ 'events' => events, 'statsigMetadata' => Statsig.get_statsig_metadata })
121
+ post_helper('log_event', json_body, @post_logs_retry_limit)
122
+ rescue StandardError
123
+
113
124
  end
114
125
  end
115
- end
126
+ end