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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/rules/constitution.md +81 -0
  3. data/.claude/rules/git-safety.md +11 -0
  4. data/.claude/rules/issue-tracking.md +13 -0
  5. data/.claude/rules/testing-workflow.md +28 -0
  6. data/.envrc.sample +3 -0
  7. data/.github/CODEOWNERS +2 -0
  8. data/.github/pull_request_template.md +8 -0
  9. data/.github/workflows/push_gem.yml +49 -0
  10. data/.github/workflows/ruby.yml +60 -0
  11. data/.github/workflows/test.yaml +40 -0
  12. data/.rubocop.yml +13 -0
  13. data/.tool-versions +1 -0
  14. data/CHANGELOG.md +301 -0
  15. data/CLAUDE.md +29 -0
  16. data/CODEOWNERS +1 -0
  17. data/Gemfile +26 -0
  18. data/Gemfile.lock +177 -0
  19. data/LICENSE.txt +20 -0
  20. data/README.md +213 -0
  21. data/Rakefile +64 -0
  22. data/VERSION +1 -0
  23. data/dev/allocation_stats +60 -0
  24. data/dev/benchmark +40 -0
  25. data/dev/console +12 -0
  26. data/dev/script_setup.rb +18 -0
  27. data/lib/quonfig/bound_client.rb +71 -0
  28. data/lib/quonfig/caching_http_connection.rb +95 -0
  29. data/lib/quonfig/client.rb +221 -0
  30. data/lib/quonfig/config_envelope.rb +5 -0
  31. data/lib/quonfig/config_loader.rb +103 -0
  32. data/lib/quonfig/config_store.rb +42 -0
  33. data/lib/quonfig/context.rb +101 -0
  34. data/lib/quonfig/datadir.rb +101 -0
  35. data/lib/quonfig/duration.rb +58 -0
  36. data/lib/quonfig/encryption.rb +74 -0
  37. data/lib/quonfig/error.rb +6 -0
  38. data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
  39. data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
  40. data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
  41. data/lib/quonfig/errors/missing_default_error.rb +13 -0
  42. data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
  43. data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
  44. data/lib/quonfig/errors/uninitialized_error.rb +13 -0
  45. data/lib/quonfig/evaluation.rb +64 -0
  46. data/lib/quonfig/evaluator.rb +464 -0
  47. data/lib/quonfig/exponential_backoff.rb +21 -0
  48. data/lib/quonfig/fixed_size_hash.rb +14 -0
  49. data/lib/quonfig/http_connection.rb +46 -0
  50. data/lib/quonfig/internal_logger.rb +173 -0
  51. data/lib/quonfig/murmer3.rb +50 -0
  52. data/lib/quonfig/options.rb +194 -0
  53. data/lib/quonfig/periodic_sync.rb +74 -0
  54. data/lib/quonfig/quonfig.rb +58 -0
  55. data/lib/quonfig/rate_limit_cache.rb +41 -0
  56. data/lib/quonfig/reason.rb +39 -0
  57. data/lib/quonfig/resolver.rb +42 -0
  58. data/lib/quonfig/semantic_logger_filter.rb +90 -0
  59. data/lib/quonfig/semver.rb +132 -0
  60. data/lib/quonfig/sse_config_client.rb +135 -0
  61. data/lib/quonfig/time_helpers.rb +7 -0
  62. data/lib/quonfig/types.rb +56 -0
  63. data/lib/quonfig/weighted_value_resolver.rb +49 -0
  64. data/lib/quonfig.rb +57 -0
  65. data/quonfig.gemspec +149 -0
  66. data/scripts/generate_integration_tests.rb +362 -0
  67. data/test/fixtures/datafile.json +87 -0
  68. data/test/integration/test_context_precedence.rb +194 -0
  69. data/test/integration/test_datadir_environment.rb +76 -0
  70. data/test/integration/test_enabled.rb +784 -0
  71. data/test/integration/test_enabled_with_contexts.rb +94 -0
  72. data/test/integration/test_get.rb +224 -0
  73. data/test/integration/test_get_feature_flag.rb +34 -0
  74. data/test/integration/test_get_or_raise.rb +86 -0
  75. data/test/integration/test_get_weighted_values.rb +29 -0
  76. data/test/integration/test_helpers.rb +139 -0
  77. data/test/integration/test_helpers_test.rb +73 -0
  78. data/test/integration/test_post.rb +34 -0
  79. data/test/integration/test_telemetry.rb +114 -0
  80. data/test/support/common_helpers.rb +106 -0
  81. data/test/support/mock_base_client.rb +27 -0
  82. data/test/support/mock_config_loader.rb +1 -0
  83. data/test/test_bound_client.rb +109 -0
  84. data/test/test_caching_http_connection.rb +218 -0
  85. data/test/test_client.rb +255 -0
  86. data/test/test_config_loader.rb +70 -0
  87. data/test/test_context.rb +136 -0
  88. data/test/test_datadir.rb +199 -0
  89. data/test/test_duration.rb +37 -0
  90. data/test/test_encryption.rb +16 -0
  91. data/test/test_evaluator.rb +285 -0
  92. data/test/test_exponential_backoff.rb +44 -0
  93. data/test/test_fixed_size_hash.rb +119 -0
  94. data/test/test_helper.rb +17 -0
  95. data/test/test_http_connection.rb +79 -0
  96. data/test/test_internal_logger.rb +34 -0
  97. data/test/test_options.rb +167 -0
  98. data/test/test_rate_limit_cache.rb +44 -0
  99. data/test/test_reason.rb +79 -0
  100. data/test/test_rename.rb +65 -0
  101. data/test/test_resolver.rb +144 -0
  102. data/test/test_semantic_logger_filter.rb +123 -0
  103. data/test/test_semver.rb +108 -0
  104. data/test/test_sse_config_client.rb +297 -0
  105. data/test/test_typed_getters.rb +131 -0
  106. data/test/test_types.rb +141 -0
  107. data/test/test_weighted_value_resolver.rb +84 -0
  108. 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