quonfig 0.0.2
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/.claude/rules/constitution.md +81 -0
- data/.claude/rules/git-safety.md +11 -0
- data/.claude/rules/issue-tracking.md +13 -0
- data/.claude/rules/testing-workflow.md +28 -0
- data/.envrc.sample +3 -0
- data/.github/CODEOWNERS +2 -0
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/push_gem.yml +49 -0
- data/.github/workflows/ruby.yml +60 -0
- data/.github/workflows/test.yaml +40 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +301 -0
- data/CLAUDE.md +29 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +177 -0
- data/LICENSE.txt +20 -0
- data/README.md +213 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/dev/allocation_stats +60 -0
- data/dev/benchmark +40 -0
- data/dev/console +12 -0
- data/dev/script_setup.rb +18 -0
- data/lib/quonfig/bound_client.rb +71 -0
- data/lib/quonfig/caching_http_connection.rb +95 -0
- data/lib/quonfig/client.rb +221 -0
- data/lib/quonfig/config_envelope.rb +5 -0
- data/lib/quonfig/config_loader.rb +103 -0
- data/lib/quonfig/config_store.rb +42 -0
- data/lib/quonfig/context.rb +101 -0
- data/lib/quonfig/datadir.rb +101 -0
- data/lib/quonfig/duration.rb +58 -0
- data/lib/quonfig/encryption.rb +74 -0
- data/lib/quonfig/error.rb +6 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
- data/lib/quonfig/errors/missing_default_error.rb +13 -0
- data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
- data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
- data/lib/quonfig/errors/uninitialized_error.rb +13 -0
- data/lib/quonfig/evaluation.rb +64 -0
- data/lib/quonfig/evaluator.rb +464 -0
- data/lib/quonfig/exponential_backoff.rb +21 -0
- data/lib/quonfig/fixed_size_hash.rb +14 -0
- data/lib/quonfig/http_connection.rb +46 -0
- data/lib/quonfig/internal_logger.rb +173 -0
- data/lib/quonfig/murmer3.rb +50 -0
- data/lib/quonfig/options.rb +194 -0
- data/lib/quonfig/periodic_sync.rb +74 -0
- data/lib/quonfig/quonfig.rb +58 -0
- data/lib/quonfig/rate_limit_cache.rb +41 -0
- data/lib/quonfig/reason.rb +39 -0
- data/lib/quonfig/resolver.rb +42 -0
- data/lib/quonfig/semantic_logger_filter.rb +90 -0
- data/lib/quonfig/semver.rb +132 -0
- data/lib/quonfig/sse_config_client.rb +135 -0
- data/lib/quonfig/time_helpers.rb +7 -0
- data/lib/quonfig/types.rb +56 -0
- data/lib/quonfig/weighted_value_resolver.rb +49 -0
- data/lib/quonfig.rb +57 -0
- data/quonfig.gemspec +149 -0
- data/scripts/generate_integration_tests.rb +362 -0
- data/test/fixtures/datafile.json +87 -0
- data/test/integration/test_context_precedence.rb +194 -0
- data/test/integration/test_datadir_environment.rb +76 -0
- data/test/integration/test_enabled.rb +784 -0
- data/test/integration/test_enabled_with_contexts.rb +94 -0
- data/test/integration/test_get.rb +224 -0
- data/test/integration/test_get_feature_flag.rb +34 -0
- data/test/integration/test_get_or_raise.rb +86 -0
- data/test/integration/test_get_weighted_values.rb +29 -0
- data/test/integration/test_helpers.rb +139 -0
- data/test/integration/test_helpers_test.rb +73 -0
- data/test/integration/test_post.rb +34 -0
- data/test/integration/test_telemetry.rb +114 -0
- data/test/support/common_helpers.rb +106 -0
- data/test/support/mock_base_client.rb +27 -0
- data/test/support/mock_config_loader.rb +1 -0
- data/test/test_bound_client.rb +109 -0
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_client.rb +255 -0
- data/test/test_config_loader.rb +70 -0
- data/test/test_context.rb +136 -0
- data/test/test_datadir.rb +199 -0
- data/test/test_duration.rb +37 -0
- data/test/test_encryption.rb +16 -0
- data/test/test_evaluator.rb +285 -0
- data/test/test_exponential_backoff.rb +44 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_helper.rb +17 -0
- data/test/test_http_connection.rb +79 -0
- data/test/test_internal_logger.rb +34 -0
- data/test/test_options.rb +167 -0
- data/test/test_rate_limit_cache.rb +44 -0
- data/test/test_reason.rb +79 -0
- data/test/test_rename.rb +65 -0
- data/test/test_resolver.rb +144 -0
- data/test/test_semantic_logger_filter.rb +123 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +297 -0
- data/test/test_typed_getters.rb +131 -0
- data/test/test_types.rb +141 -0
- data/test/test_weighted_value_resolver.rb +84 -0
- metadata +311 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Internal logger for the Quonfig SDK
|
|
5
|
+
# Uses SemanticLogger if available, falls back to stdlib Logger
|
|
6
|
+
class InternalLogger
|
|
7
|
+
def initialize(klass)
|
|
8
|
+
@klass = klass
|
|
9
|
+
@level_sym = nil # Track the symbol level for consistency
|
|
10
|
+
|
|
11
|
+
if defined?(SemanticLogger)
|
|
12
|
+
@logger = create_semantic_logger
|
|
13
|
+
@using_semantic = true
|
|
14
|
+
else
|
|
15
|
+
@logger = create_stdlib_logger
|
|
16
|
+
@using_semantic = false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Track all instances regardless of logger type
|
|
20
|
+
instances << self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Log methods
|
|
24
|
+
def trace(message = nil, &block)
|
|
25
|
+
log_message(:trace, message, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def debug(message = nil, &block)
|
|
29
|
+
log_message(:debug, message, &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def info(message = nil, &block)
|
|
33
|
+
log_message(:info, message, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def warn(message = nil, &block)
|
|
37
|
+
log_message(:warn, message, &block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def error(message = nil, &block)
|
|
41
|
+
log_message(:error, message, &block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fatal(message = nil, &block)
|
|
45
|
+
log_message(:fatal, message, &block)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def level
|
|
49
|
+
if @using_semantic
|
|
50
|
+
@logger.level
|
|
51
|
+
else
|
|
52
|
+
# Return the symbol level we tracked, or map from Logger constant
|
|
53
|
+
@level_sym || case @logger.level
|
|
54
|
+
when Logger::DEBUG then :debug
|
|
55
|
+
when Logger::INFO then :info
|
|
56
|
+
when Logger::WARN then :warn
|
|
57
|
+
when Logger::ERROR then :error
|
|
58
|
+
when Logger::FATAL then :fatal
|
|
59
|
+
else :warn
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def level=(new_level)
|
|
65
|
+
if @using_semantic
|
|
66
|
+
@logger.level = new_level
|
|
67
|
+
else
|
|
68
|
+
# Track the symbol level for consistency
|
|
69
|
+
@level_sym = new_level
|
|
70
|
+
|
|
71
|
+
# Map symbol to Logger constant
|
|
72
|
+
@logger.level = case new_level
|
|
73
|
+
when :trace, :debug then Logger::DEBUG
|
|
74
|
+
when :info then Logger::INFO
|
|
75
|
+
when :warn then Logger::WARN
|
|
76
|
+
when :error then Logger::ERROR
|
|
77
|
+
when :fatal then Logger::FATAL
|
|
78
|
+
else Logger::WARN
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Our client outputs debug logging,
|
|
84
|
+
# but if you aren't using Quonfig logging this could be too chatty.
|
|
85
|
+
# If you aren't using the quonfig log filter, only log warn level and above
|
|
86
|
+
def self.using_quonfig_log_filter!
|
|
87
|
+
@@instances&.each do |logger|
|
|
88
|
+
logger.level = :trace
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def create_semantic_logger
|
|
95
|
+
default_level = env_log_level || :warn
|
|
96
|
+
logger = SemanticLogger::Logger.new(@klass, default_level)
|
|
97
|
+
|
|
98
|
+
# Wrap to prevent recursion
|
|
99
|
+
class << logger
|
|
100
|
+
def log(log, message = nil, progname = nil, &block)
|
|
101
|
+
return if recurse_check[local_log_id]
|
|
102
|
+
recurse_check[local_log_id] = true
|
|
103
|
+
begin
|
|
104
|
+
super(log, message, progname, &block)
|
|
105
|
+
ensure
|
|
106
|
+
recurse_check[local_log_id] = false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def local_log_id
|
|
111
|
+
Thread.current.__id__
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def recurse_check
|
|
117
|
+
@recurse_check ||= Concurrent::Map.new(initial_capacity: 2)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
logger
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def create_stdlib_logger
|
|
125
|
+
require 'logger'
|
|
126
|
+
# When using stdlib Logger (no SemanticLogger), write to $stderr only
|
|
127
|
+
# Tests use $logs for SemanticLogger-filtered output, not stdlib Logger
|
|
128
|
+
logger = Logger.new($stderr)
|
|
129
|
+
|
|
130
|
+
# When SemanticLogger is not available, default to :warn to match SemanticLogger behavior
|
|
131
|
+
default_level_sym = :warn
|
|
132
|
+
@level_sym = env_log_level || default_level_sym
|
|
133
|
+
|
|
134
|
+
logger.level = case @level_sym
|
|
135
|
+
when :trace, :debug then Logger::DEBUG
|
|
136
|
+
when :info then Logger::INFO
|
|
137
|
+
when :warn then Logger::WARN
|
|
138
|
+
when :error then Logger::ERROR
|
|
139
|
+
when :fatal then Logger::FATAL
|
|
140
|
+
else Logger::WARN
|
|
141
|
+
end
|
|
142
|
+
logger.progname = @klass.to_s
|
|
143
|
+
|
|
144
|
+
# Use a custom formatter that mimics SemanticLogger format
|
|
145
|
+
# SemanticLogger format: "ClassName -- Message"
|
|
146
|
+
# This helps tests that expect SemanticLogger-style output
|
|
147
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
|
148
|
+
"#{progname} -- #{msg}\n"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
logger
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def env_log_level
|
|
155
|
+
level_str = ENV['QUONFIG_LOG_CLIENT_BOOTSTRAP_LOG_LEVEL']
|
|
156
|
+
level_str&.downcase&.to_sym
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def log_message(level, message, &block)
|
|
160
|
+
if @using_semantic
|
|
161
|
+
@logger.send(level, message, &block)
|
|
162
|
+
else
|
|
163
|
+
# stdlib Logger doesn't have trace
|
|
164
|
+
level = :debug if level == :trace
|
|
165
|
+
@logger.send(level, message || block&.call)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def instances
|
|
170
|
+
@@instances ||= []
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Murmur3
|
|
4
|
+
## MurmurHash3 was written by Austin Appleby, and is placed in the public
|
|
5
|
+
## domain. The author hereby disclaims copyright to this source code.
|
|
6
|
+
|
|
7
|
+
MASK32 = 0xffffffff
|
|
8
|
+
|
|
9
|
+
def self.murmur3_32_rotl(x, r)
|
|
10
|
+
((x << r) | (x >> (32 - r))) & MASK32
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.murmur3_32_fmix(h)
|
|
14
|
+
h &= MASK32
|
|
15
|
+
h ^= h >> 16
|
|
16
|
+
h = (h * 0x85ebca6b) & MASK32
|
|
17
|
+
h ^= h >> 13
|
|
18
|
+
h = (h * 0xc2b2ae35) & MASK32
|
|
19
|
+
h ^ (h >> 16)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.murmur3_32__mmix(k1)
|
|
23
|
+
k1 = (k1 * 0xcc9e2d51) & MASK32
|
|
24
|
+
k1 = murmur3_32_rotl(k1, 15)
|
|
25
|
+
(k1 * 0x1b873593) & MASK32
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.murmur3_32(str, seed = 0)
|
|
29
|
+
h1 = seed
|
|
30
|
+
numbers = str.unpack('V*C*')
|
|
31
|
+
tailn = str.length % 4
|
|
32
|
+
tail = numbers.slice!(numbers.size - tailn, tailn)
|
|
33
|
+
for k1 in numbers
|
|
34
|
+
h1 ^= murmur3_32__mmix(k1)
|
|
35
|
+
h1 = murmur3_32_rotl(h1, 13)
|
|
36
|
+
h1 = (h1 * 5 + 0xe6546b64) & MASK32
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
unless tail.empty?
|
|
40
|
+
k1 = 0
|
|
41
|
+
tail.reverse_each do |c1|
|
|
42
|
+
k1 = (k1 << 8) | c1
|
|
43
|
+
end
|
|
44
|
+
h1 ^= murmur3_32__mmix(k1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
h1 ^= str.length
|
|
48
|
+
murmur3_32_fmix(h1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
# Options passed to Quonfig::Client at construction time.
|
|
7
|
+
class Options
|
|
8
|
+
attr_reader :sdk_key
|
|
9
|
+
attr_reader :environment
|
|
10
|
+
attr_reader :api_urls
|
|
11
|
+
attr_reader :sse_api_urls
|
|
12
|
+
attr_reader :telemetry_destination
|
|
13
|
+
attr_reader :config_api_urls
|
|
14
|
+
attr_reader :on_no_default
|
|
15
|
+
attr_reader :initialization_timeout_sec
|
|
16
|
+
attr_reader :on_init_failure
|
|
17
|
+
attr_reader :collect_sync_interval
|
|
18
|
+
attr_reader :datadir
|
|
19
|
+
attr_reader :enable_sse
|
|
20
|
+
attr_reader :enable_polling
|
|
21
|
+
attr_reader :global_context
|
|
22
|
+
attr_accessor :is_fork
|
|
23
|
+
|
|
24
|
+
module ON_INITIALIZATION_FAILURE
|
|
25
|
+
RAISE = :raise
|
|
26
|
+
RETURN = :return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module ON_NO_DEFAULT
|
|
30
|
+
RAISE = :raise
|
|
31
|
+
RETURN_NIL = :return_nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
DEFAULT_MAX_PATHS = 1_000
|
|
35
|
+
DEFAULT_MAX_KEYS = 100_000
|
|
36
|
+
DEFAULT_MAX_EXAMPLE_CONTEXTS = 100_000
|
|
37
|
+
DEFAULT_MAX_EVAL_SUMMARIES = 100_000
|
|
38
|
+
|
|
39
|
+
DEFAULT_API_URLS = [
|
|
40
|
+
'https://primary.quonfig.com',
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Derive the SSE stream URL for a given API URL by prepending `stream.` to
|
|
44
|
+
# the hostname. Preserves scheme, port, and path.
|
|
45
|
+
#
|
|
46
|
+
# derive_stream_url('https://primary.quonfig.com')
|
|
47
|
+
# # => 'https://stream.primary.quonfig.com'
|
|
48
|
+
# derive_stream_url('http://localhost:6550')
|
|
49
|
+
# # => 'http://stream.localhost:6550'
|
|
50
|
+
def self.derive_stream_url(api_url)
|
|
51
|
+
uri = URI.parse(api_url)
|
|
52
|
+
uri.host = "stream.#{uri.host}" if uri.host
|
|
53
|
+
uri.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private def init(
|
|
57
|
+
api_urls: nil,
|
|
58
|
+
sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
|
|
59
|
+
environment: ENV['QUONFIG_ENVIRONMENT'],
|
|
60
|
+
datadir: ENV['QUONFIG_DIR'],
|
|
61
|
+
enable_sse: true,
|
|
62
|
+
enable_polling: true,
|
|
63
|
+
on_no_default: ON_NO_DEFAULT::RAISE,
|
|
64
|
+
initialization_timeout_sec: 10,
|
|
65
|
+
on_init_failure: ON_INITIALIZATION_FAILURE::RAISE,
|
|
66
|
+
collect_max_paths: DEFAULT_MAX_PATHS,
|
|
67
|
+
collect_sync_interval: nil,
|
|
68
|
+
context_upload_mode: :periodic_example, # :periodic_example, :shapes_only, :none
|
|
69
|
+
context_max_size: DEFAULT_MAX_EVAL_SUMMARIES,
|
|
70
|
+
collect_evaluation_summaries: true,
|
|
71
|
+
collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
|
|
72
|
+
allow_telemetry_in_local_mode: false,
|
|
73
|
+
global_context: {}
|
|
74
|
+
)
|
|
75
|
+
@sdk_key = sdk_key
|
|
76
|
+
@environment = environment
|
|
77
|
+
@datadir = datadir
|
|
78
|
+
@enable_sse = enable_sse
|
|
79
|
+
@enable_polling = enable_polling
|
|
80
|
+
@on_no_default = on_no_default
|
|
81
|
+
@initialization_timeout_sec = initialization_timeout_sec
|
|
82
|
+
@on_init_failure = on_init_failure
|
|
83
|
+
|
|
84
|
+
@collect_max_paths = collect_max_paths
|
|
85
|
+
@collect_sync_interval = collect_sync_interval
|
|
86
|
+
@collect_evaluation_summaries = collect_evaluation_summaries
|
|
87
|
+
@collect_max_evaluation_summaries = collect_max_evaluation_summaries
|
|
88
|
+
@allow_telemetry_in_local_mode = allow_telemetry_in_local_mode
|
|
89
|
+
@is_fork = false
|
|
90
|
+
@global_context = global_context
|
|
91
|
+
|
|
92
|
+
# defaults that may be overridden by context_upload_mode
|
|
93
|
+
@collect_shapes = false
|
|
94
|
+
@collect_max_shapes = 0
|
|
95
|
+
@collect_example_contexts = false
|
|
96
|
+
@collect_max_example_contexts = 0
|
|
97
|
+
|
|
98
|
+
if ENV['QUONFIG_API_URLS'] && ENV['QUONFIG_API_URLS'].length > 0
|
|
99
|
+
api_urls = ENV['QUONFIG_API_URLS']
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@api_urls = Array(api_urls || DEFAULT_API_URLS).map { |url| remove_trailing_slash(url) }
|
|
103
|
+
|
|
104
|
+
@sse_api_urls = @api_urls.map { |url| Quonfig::Options.derive_stream_url(url) }
|
|
105
|
+
@config_api_urls = @api_urls
|
|
106
|
+
|
|
107
|
+
@telemetry_destination = ENV['QUONFIG_TELEMETRY_URL'] || derive_telemetry_destination(@api_urls)
|
|
108
|
+
|
|
109
|
+
case context_upload_mode
|
|
110
|
+
when :none
|
|
111
|
+
# no context telemetry
|
|
112
|
+
when :periodic_example
|
|
113
|
+
@collect_example_contexts = true
|
|
114
|
+
@collect_max_example_contexts = context_max_size
|
|
115
|
+
@collect_shapes = true
|
|
116
|
+
@collect_max_shapes = context_max_size
|
|
117
|
+
when :shapes_only
|
|
118
|
+
@collect_shapes = true
|
|
119
|
+
@collect_max_shapes = context_max_size
|
|
120
|
+
else
|
|
121
|
+
raise "Unknown context_upload_mode #{context_upload_mode}. Please provide :periodic_example, :shapes_only, or :none."
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def initialize(options = {})
|
|
126
|
+
init(**options)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# In datadir mode the SDK evaluates config from a local workspace and does
|
|
130
|
+
# not connect to the delivery service.
|
|
131
|
+
def local_only?
|
|
132
|
+
!@datadir.nil?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def datadir?
|
|
136
|
+
!@datadir.nil?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def collect_max_paths
|
|
140
|
+
return 0 unless telemetry_allowed?(true)
|
|
141
|
+
|
|
142
|
+
@collect_max_paths
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def collect_max_shapes
|
|
146
|
+
return 0 unless telemetry_allowed?(@collect_shapes)
|
|
147
|
+
|
|
148
|
+
@collect_max_shapes
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def collect_max_example_contexts
|
|
152
|
+
return 0 unless telemetry_allowed?(@collect_example_contexts)
|
|
153
|
+
|
|
154
|
+
@collect_max_example_contexts
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def collect_max_evaluation_summaries
|
|
158
|
+
return 0 unless telemetry_allowed?(@collect_evaluation_summaries)
|
|
159
|
+
|
|
160
|
+
@collect_max_evaluation_summaries
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def sdk_key_id
|
|
164
|
+
@sdk_key&.split('-')&.first
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def for_fork
|
|
168
|
+
clone = self.clone
|
|
169
|
+
clone.is_fork = true
|
|
170
|
+
clone
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def telemetry_allowed?(option)
|
|
176
|
+
option && (!local_only? || @allow_telemetry_in_local_mode)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def remove_trailing_slash(url)
|
|
180
|
+
url.end_with?('/') ? url[0..-2] : url
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Derive a telemetry URL from the configured api_urls by swapping the
|
|
184
|
+
# primary/secondary host prefix for `telemetry` on a *.quonfig.com host.
|
|
185
|
+
# Falls back to https://telemetry.quonfig.com if no URL matches.
|
|
186
|
+
def derive_telemetry_destination(api_urls)
|
|
187
|
+
api_urls.each do |api_url|
|
|
188
|
+
match = api_url.match(%r{\Ahttps?://(?:primary|secondary)\.([^/]*quonfig\.com)}i)
|
|
189
|
+
return "https://telemetry.#{match[1]}" if match
|
|
190
|
+
end
|
|
191
|
+
'https://telemetry.quonfig.com'
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
module PeriodicSync
|
|
5
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
6
|
+
|
|
7
|
+
def sync
|
|
8
|
+
return if @data.size.zero?
|
|
9
|
+
|
|
10
|
+
LOG.debug "Syncing #{@data.size} items"
|
|
11
|
+
|
|
12
|
+
start_at_was = @start_at
|
|
13
|
+
@start_at = Quonfig::TimeHelpers.now_in_ms
|
|
14
|
+
|
|
15
|
+
flush(prepare_data, start_at_was)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def prepare_data
|
|
19
|
+
to_ship = @data.dup
|
|
20
|
+
@data.clear
|
|
21
|
+
|
|
22
|
+
on_prepare_data
|
|
23
|
+
|
|
24
|
+
to_ship
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def on_prepare_data
|
|
28
|
+
# noop -- override as you wish
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def post(url, data)
|
|
32
|
+
@client.post(url, data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def instance_hash
|
|
36
|
+
@client.instance_hash
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start_periodic_sync(sync_interval)
|
|
40
|
+
@start_at = Quonfig::TimeHelpers.now_in_ms
|
|
41
|
+
|
|
42
|
+
@sync_interval = calculate_sync_interval(sync_interval)
|
|
43
|
+
|
|
44
|
+
Thread.new do
|
|
45
|
+
LOG.debug "Initialized #{@name} instance_hash=#{@client.instance_hash}"
|
|
46
|
+
|
|
47
|
+
loop do
|
|
48
|
+
sleep @sync_interval.call
|
|
49
|
+
sync
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pool
|
|
55
|
+
@pool ||= Concurrent::ThreadPoolExecutor.new(
|
|
56
|
+
fallback_policy: :discard,
|
|
57
|
+
max_queue: 5,
|
|
58
|
+
max_threads: 4,
|
|
59
|
+
min_threads: 1,
|
|
60
|
+
name: @name
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def calculate_sync_interval(sync_interval)
|
|
67
|
+
if sync_interval.is_a?(Numeric)
|
|
68
|
+
proc { sync_interval }
|
|
69
|
+
else
|
|
70
|
+
sync_interval || ExponentialBackoff.new(initial_delay: 8, max_delay: 600, multiplier: 1.5)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
5
|
+
@@lock = Concurrent::ReadWriteLock.new
|
|
6
|
+
|
|
7
|
+
def self.init(options = Quonfig::Options.new)
|
|
8
|
+
unless @singleton.nil?
|
|
9
|
+
LOG.warn 'Quonfig already initialized.'
|
|
10
|
+
return @singleton
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@@lock.with_write_lock do
|
|
14
|
+
@singleton = Quonfig::Client.new(options)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.fork
|
|
19
|
+
ensure_initialized
|
|
20
|
+
@@lock.with_write_lock { @singleton = @singleton.fork }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.get(key, default = NO_DEFAULT_PROVIDED, jit_context = NO_DEFAULT_PROVIDED)
|
|
24
|
+
ensure_initialized(key)
|
|
25
|
+
@singleton.get(key, default, jit_context)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.enabled?(feature_name, jit_context = NO_DEFAULT_PROVIDED)
|
|
29
|
+
ensure_initialized(feature_name)
|
|
30
|
+
@singleton.enabled?(feature_name, jit_context)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.with_context(properties, &block)
|
|
34
|
+
ensure_initialized
|
|
35
|
+
@singleton.with_context(properties, &block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.instance
|
|
39
|
+
ensure_initialized
|
|
40
|
+
@singleton
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.semantic_logger_filter(config_key:)
|
|
44
|
+
ensure_initialized
|
|
45
|
+
@singleton.semantic_logger_filter(config_key: config_key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.defined?(key)
|
|
49
|
+
ensure_initialized(key)
|
|
50
|
+
@singleton.defined?(key)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.ensure_initialized(key = nil)
|
|
54
|
+
if !defined?(@singleton) || @singleton.nil?
|
|
55
|
+
raise Quonfig::Errors::UninitializedError.new(key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# A key-based rate limiter that considers a key to be fresh if it has been
|
|
5
|
+
# seen within the last `duration` seconds.
|
|
6
|
+
#
|
|
7
|
+
# This is used to rate limit the number of times we send a given context
|
|
8
|
+
# to the server.
|
|
9
|
+
#
|
|
10
|
+
# Because expected usage is to immediately `set` on a `fresh?` miss, we do
|
|
11
|
+
# not prune the data structure on `fresh?` calls. Instead, we manually invoke
|
|
12
|
+
# `prune` periodically from the cache consumer.
|
|
13
|
+
class RateLimitCache
|
|
14
|
+
attr_reader :data
|
|
15
|
+
|
|
16
|
+
def initialize(duration)
|
|
17
|
+
@data = Concurrent::Map.new
|
|
18
|
+
@duration = duration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fresh?(key)
|
|
22
|
+
timestamp = @data[key]
|
|
23
|
+
|
|
24
|
+
return false unless timestamp
|
|
25
|
+
return false if Time.now.utc.to_i - timestamp > @duration
|
|
26
|
+
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def set(key)
|
|
31
|
+
@data[key] = Time.now.utc.to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def prune
|
|
35
|
+
now = Time.now.utc.to_i
|
|
36
|
+
@data.each_pair do |key, (timestamp, _)|
|
|
37
|
+
@data.delete(key) if now - timestamp > @duration
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Computes the *why* of an evaluation — the symbol that explains which code path
|
|
5
|
+
# selected the returned value. Mirrors sdk-node/src/reason.ts.
|
|
6
|
+
#
|
|
7
|
+
# :DEFAULT — config has no targeting rules; matched value is the static default
|
|
8
|
+
# :RULE_MATCH — at least one targeting rule exists on the config (the matched
|
|
9
|
+
# conditional may itself be ALWAYS_TRUE, but the *config* is targeted)
|
|
10
|
+
# :SPLIT — matched value came from a non-default weighted variant
|
|
11
|
+
# :ERROR — evaluation failed
|
|
12
|
+
# :UNKNOWN — unable to determine
|
|
13
|
+
module Reason
|
|
14
|
+
UNKNOWN = :UNKNOWN
|
|
15
|
+
DEFAULT = :DEFAULT
|
|
16
|
+
RULE_MATCH = :RULE_MATCH
|
|
17
|
+
SPLIT = :SPLIT
|
|
18
|
+
ERROR = :ERROR
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def compute(config:, conditional_value:, weighted_value_index: nil)
|
|
23
|
+
return SPLIT if weighted_value_index && weighted_value_index.positive?
|
|
24
|
+
return RULE_MATCH if targeting_rules?(config)
|
|
25
|
+
return RULE_MATCH if non_always_true_criteria?(conditional_value)
|
|
26
|
+
DEFAULT
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def targeting_rules?(config)
|
|
30
|
+
config.rows.any? do |row|
|
|
31
|
+
row.values.any? { |cv| non_always_true_criteria?(cv) }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def non_always_true_criteria?(conditional_value)
|
|
36
|
+
conditional_value.criteria.any? { |c| c.operator != :ALWAYS_TRUE }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# Public-API resolver: looks up a config by key in a ConfigStore and runs
|
|
5
|
+
# it through an Evaluator against a Context.
|
|
6
|
+
#
|
|
7
|
+
# store = Quonfig::ConfigStore.new(configs_hash)
|
|
8
|
+
# evaluator = Quonfig::Evaluator.new(store)
|
|
9
|
+
# resolver = Quonfig::Resolver.new(store, evaluator)
|
|
10
|
+
# result = resolver.get('my.flag', context)
|
|
11
|
+
#
|
|
12
|
+
# Mirrors the sdk-node pattern so integration tests (qfg-dk6.22-24) can
|
|
13
|
+
# drive evaluation without constructing a full Client. For the full
|
|
14
|
+
# production read path (with config_loader, SSE updates, telemetry), see
|
|
15
|
+
# Quonfig::ConfigResolver — the two coexist during the JSON migration.
|
|
16
|
+
class Resolver
|
|
17
|
+
attr_reader :store, :evaluator
|
|
18
|
+
attr_accessor :project_env_id
|
|
19
|
+
|
|
20
|
+
def initialize(store, evaluator)
|
|
21
|
+
@store = store
|
|
22
|
+
@evaluator = evaluator
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def raw(key)
|
|
26
|
+
@store.get(key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(key, context = nil)
|
|
30
|
+
config = raw(key)
|
|
31
|
+
return nil unless config
|
|
32
|
+
|
|
33
|
+
@evaluator.evaluate_config(config, context, resolver: self)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Integration shims for code that expects a ConfigResolver. Keep these
|
|
37
|
+
# narrow; the real ConfigResolver still owns the production hot path.
|
|
38
|
+
def symbolize_json_names?
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|