statsig 1.25.1 → 1.26.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: 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