eppo-server-sdk 0.3.0 → 3.1.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.
metadata CHANGED
@@ -1,191 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eppo-server-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eppo
8
8
  autorequire:
9
- bindir: bin
9
+ bindir: exe
10
10
  cert_chain: []
11
- date: 2024-07-01 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: concurrent-ruby
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '1.1'
20
- - - ">="
21
- - !ruby/object:Gem::Version
22
- version: 1.1.9
23
- type: :runtime
24
- prerelease: false
25
- version_requirements: !ruby/object:Gem::Requirement
26
- requirements:
27
- - - "~>"
28
- - !ruby/object:Gem::Version
29
- version: '1.1'
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: 1.1.9
33
- - !ruby/object:Gem::Dependency
34
- name: faraday
35
- requirement: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '2.7'
40
- - - ">="
41
- - !ruby/object:Gem::Version
42
- version: 2.7.1
43
- type: :runtime
44
- prerelease: false
45
- version_requirements: !ruby/object:Gem::Requirement
46
- requirements:
47
- - - "~>"
48
- - !ruby/object:Gem::Version
49
- version: '2.7'
50
- - - ">="
51
- - !ruby/object:Gem::Version
52
- version: 2.7.1
53
- - !ruby/object:Gem::Dependency
54
- name: faraday-retry
55
- requirement: !ruby/object:Gem::Requirement
56
- requirements:
57
- - - "~>"
58
- - !ruby/object:Gem::Version
59
- version: '2.0'
60
- - - ">="
61
- - !ruby/object:Gem::Version
62
- version: 2.0.0
63
- type: :runtime
64
- prerelease: false
65
- version_requirements: !ruby/object:Gem::Requirement
66
- requirements:
67
- - - "~>"
68
- - !ruby/object:Gem::Version
69
- version: '2.0'
70
- - - ">="
71
- - !ruby/object:Gem::Version
72
- version: 2.0.0
73
- - !ruby/object:Gem::Dependency
74
- name: semver2
75
- requirement: !ruby/object:Gem::Requirement
76
- requirements:
77
- - - "~>"
78
- - !ruby/object:Gem::Version
79
- version: '3.4'
80
- - - ">="
81
- - !ruby/object:Gem::Version
82
- version: 3.4.2
83
- type: :runtime
84
- prerelease: false
85
- version_requirements: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - "~>"
88
- - !ruby/object:Gem::Version
89
- version: '3.4'
90
- - - ">="
91
- - !ruby/object:Gem::Version
92
- version: 3.4.2
93
- - !ruby/object:Gem::Dependency
94
- name: rake
95
- requirement: !ruby/object:Gem::Requirement
96
- requirements:
97
- - - "~>"
98
- - !ruby/object:Gem::Version
99
- version: '13.0'
100
- - - ">="
101
- - !ruby/object:Gem::Version
102
- version: 13.0.6
103
- type: :development
104
- prerelease: false
105
- version_requirements: !ruby/object:Gem::Requirement
106
- requirements:
107
- - - "~>"
108
- - !ruby/object:Gem::Version
109
- version: '13.0'
110
- - - ">="
111
- - !ruby/object:Gem::Version
112
- version: 13.0.6
113
- - !ruby/object:Gem::Dependency
114
- name: rspec
115
- requirement: !ruby/object:Gem::Requirement
116
- requirements:
117
- - - "~>"
118
- - !ruby/object:Gem::Version
119
- version: '3.12'
120
- - - ">="
121
- - !ruby/object:Gem::Version
122
- version: 3.12.0
123
- type: :development
124
- prerelease: false
125
- version_requirements: !ruby/object:Gem::Requirement
126
- requirements:
127
- - - "~>"
128
- - !ruby/object:Gem::Version
129
- version: '3.12'
130
- - - ">="
131
- - !ruby/object:Gem::Version
132
- version: 3.12.0
133
- - !ruby/object:Gem::Dependency
134
- name: rubocop
135
- requirement: !ruby/object:Gem::Requirement
136
- requirements:
137
- - - "~>"
138
- - !ruby/object:Gem::Version
139
- version: 0.82.0
140
- type: :development
141
- prerelease: false
142
- version_requirements: !ruby/object:Gem::Requirement
143
- requirements:
144
- - - "~>"
145
- - !ruby/object:Gem::Version
146
- version: 0.82.0
147
- - !ruby/object:Gem::Dependency
148
- name: webmock
149
- requirement: !ruby/object:Gem::Requirement
150
- requirements:
151
- - - "~>"
152
- - !ruby/object:Gem::Version
153
- version: '3.18'
154
- - - ">="
155
- - !ruby/object:Gem::Version
156
- version: 3.18.1
157
- type: :development
158
- prerelease: false
159
- version_requirements: !ruby/object:Gem::Requirement
160
- requirements:
161
- - - "~>"
162
- - !ruby/object:Gem::Version
163
- version: '3.18'
164
- - - ">="
165
- - !ruby/object:Gem::Version
166
- version: 3.18.1
11
+ date: 2024-08-16 00:00:00.000000000 Z
12
+ dependencies: []
167
13
  description:
168
- email: eppo-team@geteppo.com
14
+ email:
15
+ - eppo-team@geteppo.com
169
16
  executables: []
170
- extensions: []
17
+ extensions:
18
+ - ext/eppo_client/Cargo.toml
171
19
  extra_rdoc_files: []
172
20
  files:
21
+ - ".gitignore"
22
+ - ".rspec"
23
+ - ".rubocop.yml"
24
+ - Cargo.lock
25
+ - Cargo.toml
26
+ - LICENSE.txt
27
+ - README.md
28
+ - Rakefile
29
+ - Steepfile
30
+ - ext/eppo_client/Cargo.toml
31
+ - ext/eppo_client/build.rs
32
+ - ext/eppo_client/extconf.rb
33
+ - ext/eppo_client/src/client.rs
34
+ - ext/eppo_client/src/lib.rs
173
35
  - lib/eppo_client.rb
174
36
  - lib/eppo_client/assignment_logger.rb
175
37
  - lib/eppo_client/client.rb
176
38
  - lib/eppo_client/config.rb
177
- - lib/eppo_client/configuration_requestor.rb
178
- - lib/eppo_client/configuration_store.rb
179
- - lib/eppo_client/constants.rb
180
39
  - lib/eppo_client/custom_errors.rb
181
- - lib/eppo_client/http_client.rb
182
- - lib/eppo_client/lru_cache.rb
183
- - lib/eppo_client/poller.rb
184
- - lib/eppo_client/rules.rb
185
- - lib/eppo_client/shard.rb
186
40
  - lib/eppo_client/validation.rb
187
- - lib/eppo_client/variation_type.rb
188
41
  - lib/eppo_client/version.rb
42
+ - sig/eppo_server_sdk.rbs
189
43
  homepage: https://github.com/Eppo-exp/ruby-sdk
190
44
  licenses:
191
45
  - MIT
@@ -203,14 +57,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
203
57
  requirements:
204
58
  - - ">="
205
59
  - !ruby/object:Gem::Version
206
- version: 3.0.6
60
+ version: 3.0.0
207
61
  required_rubygems_version: !ruby/object:Gem::Requirement
208
62
  requirements:
209
63
  - - ">="
210
64
  - !ruby/object:Gem::Version
211
- version: '0'
65
+ version: 3.3.11
212
66
  requirements: []
213
- rubygems_version: 3.4.6
67
+ rubygems_version: 3.5.11
214
68
  signing_key:
215
69
  specification_version: 4
216
70
  summary: Eppo SDK for Ruby
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'custom_errors'
4
- require_relative 'constants'
5
-
6
- module EppoClient
7
- # A class for the variation object
8
- class VariationDto
9
- attr_reader :name, :value, :typed_value, :shard_range
10
-
11
- def initialize(name, value, typed_value, shard_range)
12
- @name = name
13
- @value = value
14
- @typed_value = typed_value
15
- @shard_range = shard_range
16
- end
17
- end
18
-
19
- # A class for the allocation object
20
- class AllocationDto
21
- attr_reader :percent_exposure, :variations
22
-
23
- def initialize(percent_exposure, variations)
24
- @percent_exposure = percent_exposure
25
- @variations = variations
26
- end
27
- end
28
-
29
- # A class for the experiment configuration object
30
- class ExperimentConfigurationDto
31
- attr_reader :subject_shards, :enabled, :name, :overrides,
32
- :typed_overrides, :rules, :allocations
33
-
34
- def initialize(exp_config)
35
- @subject_shards = exp_config['subjectShards']
36
- @enabled = exp_config['enabled']
37
- @name = exp_config['name'] || nil
38
- @overrides = exp_config['overrides'] || {}
39
- @typed_overrides = exp_config['typedOverrides'] || {}
40
- @rules = exp_config['rules'] || []
41
- @allocations = exp_config['allocations']
42
- end
43
- end
44
-
45
- # A class for getting exp configs from the local cache or API
46
- class ExperimentConfigurationRequestor
47
- attr_reader :config_store
48
-
49
- def initialize(http_client, config_store)
50
- @http_client = http_client
51
- @config_store = config_store
52
- end
53
-
54
- def get_configuration(experiment_key)
55
- @http_client.is_unauthorized && raise(EppoClient::UnauthorizedError,
56
- 'please check your API key')
57
- @config_store.retrieve_configuration(experiment_key)
58
- end
59
-
60
- # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
61
- def fetch_and_store_configurations
62
- configs = {}
63
- begin
64
- exp_configs = @http_client.get(EppoClient::RAC_ENDPOINT).fetch(
65
- 'flags', {}
66
- )
67
- # rubocop: disable Metrics/BlockLength
68
- exp_configs.each do |exp_key, exp_config|
69
- exp_config['allocations'].each do |k, v|
70
- exp_config['allocations'][k] = EppoClient::AllocationDto.new(
71
- v['percentExposure'],
72
- v['variations'].map do |var|
73
- EppoClient::VariationDto.new(
74
- var['name'], var['value'], var['typedValue'],
75
- EppoClient::ShardRange.new(var['shardRange']['start'],
76
- var['shardRange']['end'])
77
- )
78
- end
79
- )
80
- end
81
- exp_config['rules'] = exp_config['rules'].map do |rule|
82
- EppoClient::Rule.new(
83
- conditions: rule['conditions'].map do |condition|
84
- EppoClient::Condition.new(
85
- value: condition['value'],
86
- operator: condition['operator'],
87
- attribute: condition['attribute']
88
- )
89
- end,
90
- allocation_key: rule['allocationKey']
91
- )
92
- end
93
- configs[exp_key] = EppoClient::ExperimentConfigurationDto.new(
94
- exp_config
95
- )
96
- end
97
- # rubocop: enable Metrics/BlockLength
98
- @config_store.assign_configurations(configs)
99
- rescue EppoClient::HttpRequestError => e
100
- Logger.new($stdout).error(
101
- "Error retrieving assignment configurations: #{e}"
102
- )
103
- end
104
- configs
105
- end
106
- # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
107
- end
108
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'concurrent/atomic/read_write_lock'
4
-
5
- require_relative 'lru_cache'
6
-
7
- module EppoClient
8
- # A thread safe store for the configurations to ensure that retrievals pull from a single source of truth
9
- class ConfigurationStore
10
- attr_reader :lock, :cache
11
-
12
- def initialize(max_size)
13
- @cache = EppoClient::LRUCache.new(max_size)
14
- @lock = Concurrent::ReadWriteLock.new
15
- end
16
-
17
- def retrieve_configuration(key)
18
- @lock.with_read_lock { @cache[key] }
19
- end
20
-
21
- def assign_configurations(configs)
22
- @lock.with_write_lock do
23
- # Create a temporary new cache and populate it.
24
- new_cache = EppoClient::LRUCache.new(@cache.size)
25
- configs.each do |key, config|
26
- new_cache[key] = config
27
- end
28
-
29
- # Replace the old cache with the new one.
30
- # Performs an atomic swap.
31
- @cache = new_cache
32
- end
33
- end
34
- end
35
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'logger'
4
-
5
- module EppoClient
6
- # default level for logging
7
- DEFAULT_LOGGER_LEVEL = Logger::INFO
8
-
9
- # configuration cache constants
10
- MAX_CACHE_ENTRIES = 1000 # arbitrary; the caching library requires a max limit
11
-
12
- # poller constants
13
- SECOND_MILLIS = 1000
14
- MINUTE_MILLIS = 60 * SECOND_MILLIS
15
- POLL_JITTER_MILLIS = 30 * SECOND_MILLIS
16
- POLL_INTERVAL_MILLIS = 5 * MINUTE_MILLIS
17
-
18
- # the configs endpoint
19
- RAC_ENDPOINT = 'randomized_assignment/v3/config'
20
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'faraday'
4
- require 'faraday/retry'
5
-
6
- require_relative 'custom_errors'
7
-
8
- REQUEST_TIMEOUT_SECONDS = 2
9
- # This applies only to failed DNS lookups and connection timeouts,
10
- # never to requests where data has made it to the server.
11
- MAX_RETRIES = 3
12
-
13
- module EppoClient
14
- # The SDK params object
15
- class SdkParams
16
- attr_reader :api_key, :sdk_name, :sdk_version
17
-
18
- def initialize(api_key, sdk_name, sdk_version)
19
- @api_key = api_key
20
- @sdk_name = sdk_name
21
- @sdk_version = sdk_version
22
- end
23
-
24
- # attributes are camelCase because that's what the backend endpoint expects
25
- def formatted
26
- {
27
- 'apiKey' => api_key,
28
- 'sdkName' => sdk_name,
29
- 'sdkVersion' => sdk_version
30
- }
31
- end
32
-
33
- # Hide instance variables (specifically api_key) from logs
34
- def inspect
35
- "#<EppoClient::SdkParams:#{object_id}>"
36
- end
37
- end
38
-
39
- # The http request client with retry/timeout behavior
40
- class HttpClient
41
- attr_reader :is_unauthorized
42
-
43
- @retry_options = {
44
- max: MAX_RETRIES,
45
- interval: 0.05,
46
- interval_randomness: 0.5,
47
- backoff_factor: 2,
48
- exceptions: ['Timeout::Error']
49
- }
50
-
51
- def initialize(base_url, sdk_params)
52
- @base_url = base_url
53
- @sdk_params = sdk_params
54
- @is_unauthorized = false
55
- end
56
-
57
- def get(resource)
58
- conn = Faraday::Connection.new(@base_url, params: @sdk_params) do |f|
59
- f.request :retry, @retry_options
60
- end
61
- conn.options.timeout = REQUEST_TIMEOUT_SECONDS
62
- response = conn.get(resource)
63
- @is_unauthorized = response.status == 401
64
- raise get_http_error(response.status, resource) if response.status != 200
65
-
66
- JSON.parse(response.body)
67
- end
68
-
69
- private
70
-
71
- def get_http_error(status_code, resource)
72
- EppoClient::HttpRequestError.new("HTTP #{status_code} error while requesting resource #{resource}", status_code)
73
- end
74
- end
75
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module EppoClient
4
- # The LRU cache relies on the fact that Ruby's Hash class maintains insertion order. So deleting
5
- # and re-inserting a key-value pair on access moves the key to the last position. When an
6
- # entry is added and the cache is full, the first entry is removed.
7
- class LRUCache
8
- attr_reader :cache, :size
9
-
10
- # Creates a new LRUCache that can hold +size+ entries.
11
- def initialize(size)
12
- @size = size
13
- @cache = {}
14
- end
15
-
16
- # Returns the stored value for +key+ or +nil+ if no value was stored under the key.
17
- def [](key)
18
- (val = @cache.delete(key)).nil? ? nil : @cache[key] = val
19
- end
20
-
21
- # Stores the +value+ under the +key+.
22
- def []=(key, value)
23
- @cache.delete(key)
24
- @cache[key] = value
25
- @cache.shift if @cache.length > @size
26
- end
27
- end
28
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'concurrent/atom'
4
-
5
- # The poller
6
- module EppoClient
7
- # The poller class invokes a callback and waits on repeat on a separate thread
8
- class Poller
9
- def initialize(interval_millis, jitter_millis, callback)
10
- @jitter_millis = jitter_millis
11
- @interval = interval_millis
12
- @stopped = Concurrent::Atom.new(false)
13
- @callback = callback
14
- @thread = nil
15
- end
16
-
17
- def start
18
- @stopped.reset(false)
19
- @thread = Thread.new { poll }
20
- end
21
-
22
- def stop
23
- @stopped.reset(true)
24
- Thread.kill(@thread)
25
- end
26
-
27
- def stopped?
28
- @stopped.value
29
- end
30
-
31
- def poll
32
- until stopped?
33
- begin
34
- @callback.call
35
- rescue StandardError => e
36
- Logger.new($stdout).error("Unexpected error running poll task: #{e}")
37
- break
38
- end
39
- _wait_for_interval
40
- end
41
- end
42
-
43
- def _wait_for_interval
44
- interval_with_jitter = @interval - rand(@jitter_millis)
45
- sleep interval_with_jitter / 1000
46
- end
47
- end
48
- end
@@ -1,119 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'semver'
4
-
5
- # The helper module for rules
6
- module EppoClient
7
- module OperatorType
8
- MATCHES = 'MATCHES'
9
- GTE = 'GTE'
10
- GT = 'GT'
11
- LTE = 'LTE'
12
- LT = 'LT'
13
- ONE_OF = 'ONE_OF'
14
- NOT_ONE_OF = 'NOT_ONE_OF'
15
- end
16
-
17
- # A class for the Condition object
18
- class Condition
19
- attr_accessor :operator, :attribute, :value
20
-
21
- def initialize(operator:, attribute:, value:)
22
- @operator = operator
23
- @attribute = attribute
24
- @value = value
25
- end
26
- end
27
-
28
- # A class for the Rule object
29
- class Rule
30
- attr_accessor :allocation_key, :conditions
31
-
32
- def initialize(allocation_key:, conditions:)
33
- @allocation_key = allocation_key
34
- @conditions = conditions
35
- end
36
- end
37
-
38
- def find_matching_rule(subject_attributes, rules)
39
- rules.each do |rule|
40
- return rule if matches_rule(subject_attributes, rule)
41
- end
42
- nil
43
- end
44
-
45
- def matches_rule(subject_attributes, rule)
46
- rule.conditions.each do |condition|
47
- return false unless evaluate_condition(subject_attributes, condition)
48
- end
49
- true
50
- end
51
-
52
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
53
- def evaluate_condition(subject_attributes, condition)
54
- subject_value = subject_attributes[condition.attribute]
55
- return false if subject_value.nil?
56
-
57
- case condition.operator
58
- when OperatorType::MATCHES
59
- !!(Regexp.new(condition.value) =~ subject_value.to_s)
60
- when OperatorType::ONE_OF
61
- condition.value.map(&:downcase).include?(subject_value.to_s.downcase)
62
- when OperatorType::NOT_ONE_OF
63
- !condition.value.map(&:downcase).include?(subject_value.to_s.downcase)
64
- else
65
- # Numeric operator: value could be numeric or semver.
66
- if subject_value.is_a?(Numeric)
67
- evaluate_numeric_condition(subject_value, condition)
68
- elsif valid_semver?(subject_value)
69
- compare_semver(subject_value, condition.value, condition.operator)
70
- end
71
- end
72
- end
73
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
74
-
75
- # rubocop:disable Metrics/MethodLength
76
- def evaluate_numeric_condition(subject_value, condition)
77
- case condition.operator
78
- when OperatorType::GT
79
- subject_value > condition.value
80
- when OperatorType::GTE
81
- subject_value >= condition.value
82
- when OperatorType::LT
83
- subject_value < condition.value
84
- when OperatorType::LTE
85
- subject_value <= condition.value
86
- else
87
- false
88
- end
89
- end
90
- # rubocop:enable Metrics/MethodLength
91
-
92
- # rubocop:disable Metrics/MethodLength
93
- def compare_semver(attribute_value, condition_value, operator)
94
- unless valid_semver?(attribute_value) && valid_semver?(condition_value)
95
- return false
96
- end
97
-
98
- case operator
99
- when OperatorType::GT
100
- SemVer.parse(attribute_value) > SemVer.parse(condition_value)
101
- when OperatorType::GTE
102
- SemVer.parse(attribute_value) >= SemVer.parse(condition_value)
103
- when OperatorType::LT
104
- SemVer.parse(attribute_value) < SemVer.parse(condition_value)
105
- when OperatorType::LTE
106
- SemVer.parse(attribute_value) <= SemVer.parse(condition_value)
107
- else
108
- false
109
- end
110
- end
111
- # rubocop:enable Metrics/MethodLength
112
-
113
- def valid_semver?(string)
114
- !SemVer.parse(string).nil?
115
- end
116
-
117
- module_function :find_matching_rule, :matches_rule, :evaluate_condition,
118
- :evaluate_numeric_condition, :valid_semver?, :compare_semver
119
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'digest'
4
-
5
- # The helper module for shard logic
6
- module EppoClient
7
- # A class for checking if a shard is in a range
8
- class ShardRange
9
- attr_reader :start, :end
10
-
11
- def initialize(range_start, range_end)
12
- @start = range_start
13
- @end = range_end
14
- end
15
-
16
- def shard_in_range?(shard)
17
- shard >= @start && shard < @end
18
- end
19
- end
20
-
21
- module_function
22
-
23
- def get_shard(input, subject_shards)
24
- hash_output = Digest::MD5.hexdigest(input)
25
- # get the first 4 bytes of the md5 hex string and parse it using base 16
26
- # (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer)
27
- int_from_hash = hash_output[0...8].to_i(16)
28
- int_from_hash % subject_shards
29
- end
30
- end