eppo-server-sdk 0.0.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 +7 -0
- data/lib/assignment_logger.rb +12 -0
- data/lib/client.rb +95 -0
- data/lib/config.rb +26 -0
- data/lib/configuration_requestor.rb +98 -0
- data/lib/configuration_store.rb +29 -0
- data/lib/constants.rb +15 -0
- data/lib/custom_errors.rb +34 -0
- data/lib/eppo_client.rb +46 -0
- data/lib/http_client.rb +75 -0
- data/lib/lru_cache.rb +28 -0
- data/lib/poller.rb +49 -0
- data/lib/rules.rb +86 -0
- data/lib/sdk_logger.rb +20 -0
- data/lib/shard.rb +30 -0
- data/lib/validation.rb +12 -0
- metadata +191 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: ce6db52c824656217a079b75231b2749b10bbbd656bda249e2f103666b173acb
|
4
|
+
data.tar.gz: 4629ce299c56cbcbfe342e7572803c935266172b376ef1c8aa1eac8f0bb8074b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 29de3064e5d780257e0e883295a6101b86e321ba6203dcbcc21e46192cc853156faab52031782b323be2cf37fe6e045345a5099acdb3ead7f6383fbcf5396f2c
|
7
|
+
data.tar.gz: c3a669f35584ecac1b4f12e4796b61de972bb2f117cfd95e5cd3c693fb9f53af3bf232d9800a3a519245408576978037371394ff4b9931655850f38b80e8f0bb
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'custom_errors'
|
4
|
+
|
5
|
+
module EppoClient
|
6
|
+
# The base assignment logger class to override
|
7
|
+
class AssignmentLogger
|
8
|
+
def log_assignment(_assignment)
|
9
|
+
raise(EppoClient::AssignmentLoggerError, 'log_assignment has not been set up')
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
data/lib/client.rb
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'singleton'
|
4
|
+
require 'time'
|
5
|
+
|
6
|
+
require 'validation'
|
7
|
+
require 'rules'
|
8
|
+
require 'shard'
|
9
|
+
require 'sdk_logger'
|
10
|
+
require 'custom_errors'
|
11
|
+
|
12
|
+
module EppoClient
|
13
|
+
# The main client singleton
|
14
|
+
class Client
|
15
|
+
include Singleton
|
16
|
+
attr_accessor :config_requestor, :assignment_logger, :poller
|
17
|
+
|
18
|
+
def instance
|
19
|
+
Client.instance
|
20
|
+
end
|
21
|
+
|
22
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
23
|
+
def get_assignment(subject_key, flag_or_experiment_key, subject_attributes = {})
|
24
|
+
EppoClient.validate_not_blank('subject_key', subject_key)
|
25
|
+
EppoClient.validate_not_blank('flag_or_experiment_key', flag_or_experiment_key)
|
26
|
+
experiment_config = @config_requestor.get_configuration(flag_or_experiment_key)
|
27
|
+
override = get_subject_variation_override(experiment_config, subject_key)
|
28
|
+
return override unless override.nil?
|
29
|
+
|
30
|
+
if experiment_config.nil? || experiment_config.enabled == false
|
31
|
+
EppoClient.logger('out').info(
|
32
|
+
"[Eppo SDK] No assigned variation. No active experiment or flag for key: #{flag_or_experiment_key}"
|
33
|
+
)
|
34
|
+
return nil
|
35
|
+
end
|
36
|
+
|
37
|
+
matched_rule = EppoClient.find_matching_rule(subject_attributes, experiment_config.rules)
|
38
|
+
if matched_rule.nil?
|
39
|
+
EppoClient.logger('out').info(
|
40
|
+
"[Eppo SDK] No assigned variation. Subject attributes do not match targeting rules: #{subject_attributes}"
|
41
|
+
)
|
42
|
+
return nil
|
43
|
+
end
|
44
|
+
|
45
|
+
allocation = experiment_config.allocations[matched_rule.allocation_key]
|
46
|
+
unless in_experiment_sample?(
|
47
|
+
subject_key,
|
48
|
+
flag_or_experiment_key,
|
49
|
+
experiment_config.subject_shards,
|
50
|
+
allocation.percent_exposure
|
51
|
+
)
|
52
|
+
EppoClient.logger('out').info(
|
53
|
+
'[Eppo SDK] No assigned variation. Subject is not part of experiment sample population'
|
54
|
+
)
|
55
|
+
return nil
|
56
|
+
end
|
57
|
+
|
58
|
+
shard = EppoClient.get_shard("assignment-#{subject_key}-#{flag_or_experiment_key}", experiment_config.subject_shards)
|
59
|
+
assigned_variation = allocation.variations.find { |var| var.shard_range.shard_in_range?(shard) }.value
|
60
|
+
|
61
|
+
assignment_event = {
|
62
|
+
"experiment": flag_or_experiment_key,
|
63
|
+
"variation": assigned_variation,
|
64
|
+
"subject": subject_key,
|
65
|
+
"timestamp": Time.now.utc.iso8601,
|
66
|
+
"subjectAttributes": subject_attributes
|
67
|
+
}
|
68
|
+
|
69
|
+
begin
|
70
|
+
@assignment_logger.log_assignment(assignment_event)
|
71
|
+
rescue EppoClient::AssignmentLoggerError => e
|
72
|
+
# This error means that log_assignment was not set up. This is okay to ignore.
|
73
|
+
rescue StandardError => e
|
74
|
+
EppoClient.logger('err').info("[Eppo SDK] Error logging assignment event: #{e}")
|
75
|
+
end
|
76
|
+
|
77
|
+
assigned_variation
|
78
|
+
end
|
79
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
80
|
+
|
81
|
+
def shutdown
|
82
|
+
@poller.stop
|
83
|
+
end
|
84
|
+
|
85
|
+
def get_subject_variation_override(experiment_config, subject)
|
86
|
+
subject_hash = Digest::MD5.hexdigest(subject.to_s)
|
87
|
+
experiment_config&.overrides && experiment_config.overrides[subject_hash]
|
88
|
+
end
|
89
|
+
|
90
|
+
def in_experiment_sample?(subject, experiment_key, subject_shards, percent_exposure)
|
91
|
+
shard = EppoClient.get_shard("exposure-#{subject}-#{experiment_key}", subject_shards)
|
92
|
+
shard <= percent_exposure * subject_shards
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
data/lib/config.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'validation'
|
4
|
+
require 'assignment_logger'
|
5
|
+
|
6
|
+
module EppoClient
|
7
|
+
# The class for configuring the Eppo client singleton
|
8
|
+
class Config
|
9
|
+
attr_reader :api_key, :assignment_logger, :base_url
|
10
|
+
|
11
|
+
def initialize(api_key, assignment_logger: AssignmentLogger.new, base_url: 'https://eppo.cloud/api')
|
12
|
+
@api_key = api_key
|
13
|
+
@assignment_logger = assignment_logger
|
14
|
+
@base_url = base_url
|
15
|
+
end
|
16
|
+
|
17
|
+
def validate
|
18
|
+
EppoClient.validate_not_blank('api_key', @api_key)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Hide instance variables (specifically api_key) from logs
|
22
|
+
def inspect
|
23
|
+
"#<EppoClient::Config:#{object_id}>"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sdk_logger'
|
4
|
+
require 'custom_errors'
|
5
|
+
require 'constants'
|
6
|
+
|
7
|
+
module EppoClient
|
8
|
+
# A class for the variation object
|
9
|
+
class VariationDto
|
10
|
+
attr_reader :name, :value, :shard_range
|
11
|
+
|
12
|
+
def initialize(name, value, shard_range)
|
13
|
+
@name = name
|
14
|
+
@value = 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, :rules, :allocations
|
32
|
+
|
33
|
+
def initialize(exp_config)
|
34
|
+
@subject_shards = exp_config['subjectShards']
|
35
|
+
@enabled = exp_config['enabled']
|
36
|
+
@name = exp_config['name'] || nil
|
37
|
+
@overrides = exp_config['overrides'] || {}
|
38
|
+
@rules = exp_config['rules'] || []
|
39
|
+
@allocations = exp_config['allocations']
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# A class for getting exp configs from the local cache or API
|
44
|
+
class ExperimentConfigurationRequestor
|
45
|
+
|
46
|
+
attr_reader :config_store
|
47
|
+
|
48
|
+
def initialize(http_client, config_store)
|
49
|
+
@http_client = http_client
|
50
|
+
@config_store = config_store
|
51
|
+
end
|
52
|
+
|
53
|
+
def get_configuration(experiment_key)
|
54
|
+
@http_client.is_unauthorized && raise(EppoClient::UnauthorizedError, 'please check your API key')
|
55
|
+
@config_store.retrieve_configuration(experiment_key)
|
56
|
+
end
|
57
|
+
|
58
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
59
|
+
def fetch_and_store_configurations
|
60
|
+
configs = {}
|
61
|
+
begin
|
62
|
+
exp_configs = @http_client.get(EppoClient::RAC_ENDPOINT).fetch('flags', {})
|
63
|
+
exp_configs.each do |exp_key, exp_config|
|
64
|
+
exp_config['allocations'].each do |k, v|
|
65
|
+
exp_config['allocations'][k] = EppoClient::AllocationDto.new(
|
66
|
+
v['percentExposure'],
|
67
|
+
v['variations'].map do |var|
|
68
|
+
EppoClient::VariationDto.new(
|
69
|
+
var['name'],
|
70
|
+
var['value'],
|
71
|
+
EppoClient::ShardRange.new(var['shardRange']['start'], var['shardRange']['end'])
|
72
|
+
)
|
73
|
+
end
|
74
|
+
)
|
75
|
+
end
|
76
|
+
exp_config['rules'] = exp_config['rules'].map do |rule|
|
77
|
+
EppoClient::Rule.new(
|
78
|
+
conditions: rule['conditions'].map do |condition|
|
79
|
+
EppoClient::Condition.new(
|
80
|
+
value: condition['value'],
|
81
|
+
operator: condition['operator'],
|
82
|
+
attribute: condition['attribute']
|
83
|
+
)
|
84
|
+
end,
|
85
|
+
allocation_key: rule['allocationKey']
|
86
|
+
)
|
87
|
+
end
|
88
|
+
configs[exp_key] = EppoClient::ExperimentConfigurationDto.new(exp_config)
|
89
|
+
end
|
90
|
+
@config_store.assign_configurations(configs)
|
91
|
+
rescue EppoClient::HttpRequestError => e
|
92
|
+
EppoClient.logger('err').error("Error retrieving assignment configurations: #{e}")
|
93
|
+
end
|
94
|
+
configs
|
95
|
+
end
|
96
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
97
|
+
end
|
98
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent/atomic/read_write_lock'
|
4
|
+
|
5
|
+
require '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
|
+
configs.each do |key, config|
|
24
|
+
@cache[key] = config
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/constants.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EppoClient
|
4
|
+
# configuration cache constants
|
5
|
+
MAX_CACHE_ENTRIES = 1000 # arbitrary; the caching library requires a max limit
|
6
|
+
|
7
|
+
# poller constants
|
8
|
+
SECOND_MILLIS = 1000
|
9
|
+
MINUTE_MILLIS = 60 * SECOND_MILLIS
|
10
|
+
POLL_JITTER_MILLIS = 30 * SECOND_MILLIS
|
11
|
+
POLL_INTERVAL_MILLIS = 5 * MINUTE_MILLIS
|
12
|
+
|
13
|
+
# the configs endpoint
|
14
|
+
RAC_ENDPOINT = 'randomized_assignment/v2/config'
|
15
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EppoClient
|
4
|
+
# A custom error class for AssignmentLogger
|
5
|
+
class AssignmentLoggerError < StandardError
|
6
|
+
def initialize(message)
|
7
|
+
super("AssignmentLoggerError: #{message}")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
# A custom error class for unauthorized requests
|
12
|
+
class UnauthorizedError < StandardError
|
13
|
+
def initialize(message)
|
14
|
+
super("Unauthorized: #{message}")
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# A custom error class for HTTP requests
|
19
|
+
class HttpRequestError < StandardError
|
20
|
+
attr_reader :status_code
|
21
|
+
|
22
|
+
def initialize(message, status_code)
|
23
|
+
@status_code = status_code
|
24
|
+
super("HttpRequestError: #{message}")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# A custom error class for invalid values
|
29
|
+
class InvalidValueError < StandardError
|
30
|
+
def initialize(message)
|
31
|
+
super("InvalidValueError: #{message}")
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/eppo_client.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'assignment_logger'
|
4
|
+
require 'http_client'
|
5
|
+
require 'poller'
|
6
|
+
require 'config'
|
7
|
+
require 'client'
|
8
|
+
require 'constants'
|
9
|
+
require 'configuration_requestor'
|
10
|
+
require 'configuration_store'
|
11
|
+
|
12
|
+
# This module scopes all the client SDK's classes and functions
|
13
|
+
module EppoClient
|
14
|
+
@sdk_version = '1.1.1'
|
15
|
+
|
16
|
+
# rubocop:disable Metrics/MethodLength
|
17
|
+
def initialize_client(config_requestor, assignment_logger)
|
18
|
+
client = EppoClient::Client.instance
|
19
|
+
!client.poller.nil? && client.shutdown
|
20
|
+
client.config_requestor = config_requestor
|
21
|
+
client.assignment_logger = assignment_logger
|
22
|
+
client.poller = EppoClient::Poller.new(
|
23
|
+
EppoClient::POLL_INTERVAL_MILLIS,
|
24
|
+
EppoClient::POLL_JITTER_MILLIS,
|
25
|
+
proc { client.config_requestor.fetch_and_store_configurations }
|
26
|
+
)
|
27
|
+
client.poller.start
|
28
|
+
client
|
29
|
+
end
|
30
|
+
# rubocop:enable Metrics/MethodLength
|
31
|
+
|
32
|
+
def init(config)
|
33
|
+
config.validate
|
34
|
+
sdk_params = EppoClient::SdkParams.new(config.api_key, 'ruby', @sdk_version)
|
35
|
+
http_client = EppoClient::HttpClient.new(config.base_url, sdk_params.formatted)
|
36
|
+
config_store = EppoClient::ConfigurationStore.new(EppoClient::MAX_CACHE_ENTRIES)
|
37
|
+
config_store.lock.with_write_lock do
|
38
|
+
EppoClient.initialize_client(
|
39
|
+
EppoClient::ExperimentConfigurationRequestor.new(http_client, config_store),
|
40
|
+
config.assignment_logger
|
41
|
+
)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
module_function :init, :initialize_client
|
46
|
+
end
|
data/lib/http_client.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday/retry'
|
5
|
+
|
6
|
+
require '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
|
data/lib/lru_cache.rb
ADDED
@@ -0,0 +1,28 @@
|
|
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
|
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
|
data/lib/poller.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'concurrent/atom'
|
4
|
+
require 'sdk_logger'
|
5
|
+
|
6
|
+
# The poller
|
7
|
+
module EppoClient
|
8
|
+
# The poller class invokes a callback and waits on repeat on a separate thread
|
9
|
+
class Poller
|
10
|
+
def initialize(interval_millis, jitter_millis, callback)
|
11
|
+
@jitter_millis = jitter_millis
|
12
|
+
@interval = interval_millis
|
13
|
+
@stopped = Concurrent::Atom.new(false)
|
14
|
+
@callback = callback
|
15
|
+
@thread = nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def start
|
19
|
+
@stopped.reset(false)
|
20
|
+
@thread = Thread.new { poll }
|
21
|
+
end
|
22
|
+
|
23
|
+
def stop
|
24
|
+
@stopped.reset(true)
|
25
|
+
Thread.kill(@thread)
|
26
|
+
end
|
27
|
+
|
28
|
+
def stopped?
|
29
|
+
@stopped.value
|
30
|
+
end
|
31
|
+
|
32
|
+
def poll
|
33
|
+
until stopped?
|
34
|
+
begin
|
35
|
+
@callback.call
|
36
|
+
rescue StandardError => e
|
37
|
+
EppoClient.logger('err').error("Unexpected error running poll task: #{e}")
|
38
|
+
break
|
39
|
+
end
|
40
|
+
_wait_for_interval
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def _wait_for_interval
|
45
|
+
interval_with_jitter = @interval - rand(@jitter_millis)
|
46
|
+
sleep interval_with_jitter / 1000
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
data/lib/rules.rb
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# The helper module for rules
|
4
|
+
module EppoClient
|
5
|
+
module OperatorType
|
6
|
+
MATCHES = 'MATCHES'
|
7
|
+
GTE = 'GTE'
|
8
|
+
GT = 'GT'
|
9
|
+
LTE = 'LTE'
|
10
|
+
LT = 'LT'
|
11
|
+
ONE_OF = 'ONE_OF'
|
12
|
+
NOT_ONE_OF = 'NOT_ONE_OF'
|
13
|
+
end
|
14
|
+
|
15
|
+
# A class for the Condition object
|
16
|
+
class Condition
|
17
|
+
attr_accessor :operator, :attribute, :value
|
18
|
+
|
19
|
+
def initialize(operator:, attribute:, value:)
|
20
|
+
@operator = operator
|
21
|
+
@attribute = attribute
|
22
|
+
@value = value
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# A class for the Rule object
|
27
|
+
class Rule
|
28
|
+
attr_accessor :allocation_key, :conditions
|
29
|
+
|
30
|
+
def initialize(allocation_key:, conditions:)
|
31
|
+
@allocation_key = allocation_key
|
32
|
+
@conditions = conditions
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def find_matching_rule(subject_attributes, rules)
|
37
|
+
rules.each do |rule|
|
38
|
+
return rule if matches_rule(subject_attributes, rule)
|
39
|
+
end
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
|
43
|
+
def matches_rule(subject_attributes, rule)
|
44
|
+
rule.conditions.each do |condition|
|
45
|
+
return false unless evaluate_condition(subject_attributes, condition)
|
46
|
+
end
|
47
|
+
true
|
48
|
+
end
|
49
|
+
|
50
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
51
|
+
def evaluate_condition(subject_attributes, condition)
|
52
|
+
subject_value = subject_attributes[condition.attribute]
|
53
|
+
return false if subject_value.nil?
|
54
|
+
|
55
|
+
case condition.operator
|
56
|
+
when OperatorType::MATCHES
|
57
|
+
!!(Regexp.new(condition.value) =~ subject_value.to_s)
|
58
|
+
when OperatorType::ONE_OF
|
59
|
+
condition.value.map(&:downcase).include?(subject_value.to_s.downcase)
|
60
|
+
when OperatorType::NOT_ONE_OF
|
61
|
+
!condition.value.map(&:downcase).include?(subject_value.to_s.downcase)
|
62
|
+
else
|
63
|
+
subject_value.is_a?(Numeric) && evaluate_numeric_condition(subject_value, condition)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
67
|
+
|
68
|
+
# rubocop:disable Metrics/MethodLength
|
69
|
+
def evaluate_numeric_condition(subject_value, condition)
|
70
|
+
case condition.operator
|
71
|
+
when OperatorType::GT
|
72
|
+
subject_value > condition.value
|
73
|
+
when OperatorType::GTE
|
74
|
+
subject_value >= condition.value
|
75
|
+
when OperatorType::LT
|
76
|
+
subject_value < condition.value
|
77
|
+
when OperatorType::LTE
|
78
|
+
subject_value <= condition.value
|
79
|
+
else
|
80
|
+
false
|
81
|
+
end
|
82
|
+
end
|
83
|
+
# rubocop:enable Metrics/MethodLength
|
84
|
+
|
85
|
+
module_function :find_matching_rule, :matches_rule, :evaluate_condition, :evaluate_numeric_condition
|
86
|
+
end
|
data/lib/sdk_logger.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'logger'
|
4
|
+
|
5
|
+
# The helper module for logging
|
6
|
+
module EppoClient
|
7
|
+
@stdout_logger = Logger.new($stdout)
|
8
|
+
@stderr_logger = Logger.new($stderr)
|
9
|
+
|
10
|
+
def self.logger(type)
|
11
|
+
case type
|
12
|
+
when 'out'
|
13
|
+
@stdout_logger
|
14
|
+
when 'err'
|
15
|
+
@stderr_logger
|
16
|
+
else
|
17
|
+
@stderr_logger.error("[Eppo SDK] Invalid logger type: #{type}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
data/lib/shard.rb
ADDED
@@ -0,0 +1,30 @@
|
|
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
|
+
def get_shard(input, subject_shards)
|
22
|
+
hash_output = Digest::MD5.hexdigest(input)
|
23
|
+
# get the first 4 bytes of the md5 hex string and parse it using base 16
|
24
|
+
# (8 hex characters represent 4 bytes, e.g. 0xffffffff represents the max 4-byte integer)
|
25
|
+
int_from_hash = hash_output[0...8].to_i(16)
|
26
|
+
int_from_hash % subject_shards
|
27
|
+
end
|
28
|
+
|
29
|
+
module_function :get_shard
|
30
|
+
end
|
data/lib/validation.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'custom_errors'
|
4
|
+
|
5
|
+
# The helper module to validate keys
|
6
|
+
module EppoClient
|
7
|
+
def validate_not_blank(field_name, field_value)
|
8
|
+
(field_value.nil? || field_value == '') && raise(EppoClient::InvalidValueError, "#{field_name} cannot be blank")
|
9
|
+
end
|
10
|
+
|
11
|
+
module_function :validate_not_blank
|
12
|
+
end
|
metadata
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: eppo-server-sdk
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Eppo
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2022-12-23 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: rake
|
75
|
+
requirement: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - "~>"
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '13.0'
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: 13.0.6
|
83
|
+
type: :development
|
84
|
+
prerelease: false
|
85
|
+
version_requirements: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '13.0'
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: 13.0.6
|
93
|
+
- !ruby/object:Gem::Dependency
|
94
|
+
name: rspec
|
95
|
+
requirement: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - "~>"
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '3.12'
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: 3.12.0
|
103
|
+
type: :development
|
104
|
+
prerelease: false
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
106
|
+
requirements:
|
107
|
+
- - "~>"
|
108
|
+
- !ruby/object:Gem::Version
|
109
|
+
version: '3.12'
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: 3.12.0
|
113
|
+
- !ruby/object:Gem::Dependency
|
114
|
+
name: rubocop
|
115
|
+
requirement: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - "~>"
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '1.41'
|
120
|
+
type: :development
|
121
|
+
prerelease: false
|
122
|
+
version_requirements: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - "~>"
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '1.41'
|
127
|
+
- !ruby/object:Gem::Dependency
|
128
|
+
name: webmock
|
129
|
+
requirement: !ruby/object:Gem::Requirement
|
130
|
+
requirements:
|
131
|
+
- - "~>"
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
version: '3.18'
|
134
|
+
- - ">="
|
135
|
+
- !ruby/object:Gem::Version
|
136
|
+
version: 3.18.1
|
137
|
+
type: :development
|
138
|
+
prerelease: false
|
139
|
+
version_requirements: !ruby/object:Gem::Requirement
|
140
|
+
requirements:
|
141
|
+
- - "~>"
|
142
|
+
- !ruby/object:Gem::Version
|
143
|
+
version: '3.18'
|
144
|
+
- - ">="
|
145
|
+
- !ruby/object:Gem::Version
|
146
|
+
version: 3.18.1
|
147
|
+
description:
|
148
|
+
email: eppo-team@geteppo.com
|
149
|
+
executables: []
|
150
|
+
extensions: []
|
151
|
+
extra_rdoc_files: []
|
152
|
+
files:
|
153
|
+
- lib/assignment_logger.rb
|
154
|
+
- lib/client.rb
|
155
|
+
- lib/config.rb
|
156
|
+
- lib/configuration_requestor.rb
|
157
|
+
- lib/configuration_store.rb
|
158
|
+
- lib/constants.rb
|
159
|
+
- lib/custom_errors.rb
|
160
|
+
- lib/eppo_client.rb
|
161
|
+
- lib/http_client.rb
|
162
|
+
- lib/lru_cache.rb
|
163
|
+
- lib/poller.rb
|
164
|
+
- lib/rules.rb
|
165
|
+
- lib/sdk_logger.rb
|
166
|
+
- lib/shard.rb
|
167
|
+
- lib/validation.rb
|
168
|
+
homepage: https://github.com/Eppo-exp/ruby-sdk
|
169
|
+
licenses:
|
170
|
+
- MIT
|
171
|
+
metadata: {}
|
172
|
+
post_install_message:
|
173
|
+
rdoc_options: []
|
174
|
+
require_paths:
|
175
|
+
- lib
|
176
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
177
|
+
requirements:
|
178
|
+
- - ">="
|
179
|
+
- !ruby/object:Gem::Version
|
180
|
+
version: 3.1.2
|
181
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
182
|
+
requirements:
|
183
|
+
- - ">="
|
184
|
+
- !ruby/object:Gem::Version
|
185
|
+
version: '0'
|
186
|
+
requirements: []
|
187
|
+
rubygems_version: 3.3.7
|
188
|
+
signing_key:
|
189
|
+
specification_version: 4
|
190
|
+
summary: Eppo SDK for Ruby
|
191
|
+
test_files: []
|