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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
# SemanticLogger filter that gates log output by a single Quonfig config
|
|
5
|
+
# whose rules target the logger via the +quonfig.logger-name+ context
|
|
6
|
+
# property.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# filter = client.semantic_logger_filter(config_key: 'log-levels.my-app')
|
|
10
|
+
# SemanticLogger.add_appender(io: $stdout, filter: filter)
|
|
11
|
+
#
|
|
12
|
+
# The filter normalizes the SemanticLogger logger name to dotted snake_case
|
|
13
|
+
# (e.g. +MyApp::Foo::Bar+ → +my_app.foo.bar+) and exposes it to the
|
|
14
|
+
# evaluator under +quonfig.logger-name+ so the customer's Quonfig config can
|
|
15
|
+
# discriminate per-logger via PROP_STARTS_WITH_ONE_OF / PROP_IS_ONE_OF
|
|
16
|
+
# rules. Lookup is O(1): one +client.get+ call per log line.
|
|
17
|
+
class SemanticLoggerFilter
|
|
18
|
+
LEVELS = {
|
|
19
|
+
trace: 0,
|
|
20
|
+
debug: 1,
|
|
21
|
+
info: 2,
|
|
22
|
+
warn: 3,
|
|
23
|
+
error: 4,
|
|
24
|
+
fatal: 5
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
LOGGER_NAME_CONTEXT_KEY = 'quonfig.logger-name'
|
|
28
|
+
|
|
29
|
+
def self.semantic_logger_loaded?
|
|
30
|
+
defined?(SemanticLogger)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(client, config_key:)
|
|
34
|
+
unless self.class.semantic_logger_loaded?
|
|
35
|
+
raise LoadError, "semantic_logger gem is required for Quonfig::SemanticLoggerFilter. Add `gem 'semantic_logger'` to your Gemfile."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@client = client
|
|
39
|
+
@config_key = config_key
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# SemanticLogger filter contract: return true to emit, false to suppress.
|
|
43
|
+
# Missing config key → return true so SemanticLogger's static level decides.
|
|
44
|
+
def call(log)
|
|
45
|
+
configured = @client.get(@config_key, nil, context_for(log))
|
|
46
|
+
return true if configured.nil?
|
|
47
|
+
|
|
48
|
+
log_severity = LEVELS[log.level] || LEVELS[:debug]
|
|
49
|
+
min_severity = LEVELS[normalize_level(configured)] || LEVELS[:debug]
|
|
50
|
+
log_severity >= min_severity
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Normalize a SemanticLogger logger name to the dotted snake_case form
|
|
54
|
+
# the customer writes targeting rules against.
|
|
55
|
+
# MyApp::Foo::Bar → my_app.foo.bar
|
|
56
|
+
# HTMLParser → html_parser
|
|
57
|
+
def normalize(name)
|
|
58
|
+
name.to_s
|
|
59
|
+
.gsub('::', '.')
|
|
60
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
61
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
62
|
+
.downcase
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def context_for(log)
|
|
68
|
+
{ 'quonfig' => { 'logger-name' => normalize(log.name) } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_level(level)
|
|
72
|
+
case level
|
|
73
|
+
when Symbol then level.downcase
|
|
74
|
+
when String then level.downcase.to_sym
|
|
75
|
+
when Integer
|
|
76
|
+
# LogLevel ints from old proto: 1=trace … 9=fatal. Best-effort map.
|
|
77
|
+
case level
|
|
78
|
+
when 1 then :trace
|
|
79
|
+
when 2 then :debug
|
|
80
|
+
when 3 then :info
|
|
81
|
+
when 5 then :warn
|
|
82
|
+
when 6 then :error
|
|
83
|
+
when 9 then :fatal
|
|
84
|
+
else :debug
|
|
85
|
+
end
|
|
86
|
+
else :debug
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class SemanticVersion
|
|
4
|
+
include Comparable
|
|
5
|
+
|
|
6
|
+
SEMVER_PATTERN = /
|
|
7
|
+
^
|
|
8
|
+
(?<major>0|[1-9]\d*)
|
|
9
|
+
\.
|
|
10
|
+
(?<minor>0|[1-9]\d*)
|
|
11
|
+
\.
|
|
12
|
+
(?<patch>0|[1-9]\d*)
|
|
13
|
+
(?:-(?<prerelease>
|
|
14
|
+
(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)
|
|
15
|
+
(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*
|
|
16
|
+
))?
|
|
17
|
+
(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?
|
|
18
|
+
$
|
|
19
|
+
/x
|
|
20
|
+
|
|
21
|
+
attr_reader :major, :minor, :patch, :prerelease, :build_metadata
|
|
22
|
+
|
|
23
|
+
def self.parse(version_string)
|
|
24
|
+
raise ArgumentError, "version string cannot be nil" if version_string.nil?
|
|
25
|
+
raise ArgumentError, "version string cannot be empty" if version_string.empty?
|
|
26
|
+
|
|
27
|
+
match = SEMVER_PATTERN.match(version_string)
|
|
28
|
+
raise ArgumentError, "invalid semantic version format: #{version_string}" unless match
|
|
29
|
+
|
|
30
|
+
new(
|
|
31
|
+
major: match[:major].to_i,
|
|
32
|
+
minor: match[:minor].to_i,
|
|
33
|
+
patch: match[:patch].to_i,
|
|
34
|
+
prerelease: match[:prerelease],
|
|
35
|
+
build_metadata: match[:buildmetadata]
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.parse_quietly(version_string)
|
|
40
|
+
parse(version_string)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(major:, minor:, patch:, prerelease: nil, build_metadata: nil)
|
|
46
|
+
@major = major
|
|
47
|
+
@minor = minor
|
|
48
|
+
@patch = patch
|
|
49
|
+
@prerelease = prerelease
|
|
50
|
+
@build_metadata = build_metadata
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def <=>(other)
|
|
54
|
+
return nil unless other.is_a?(SemanticVersion)
|
|
55
|
+
|
|
56
|
+
# Compare major.minor.patch
|
|
57
|
+
return major <=> other.major if major != other.major
|
|
58
|
+
return minor <=> other.minor if minor != other.minor
|
|
59
|
+
return patch <=> other.patch if patch != other.patch
|
|
60
|
+
|
|
61
|
+
# Compare pre-release versions
|
|
62
|
+
compare_prerelease(prerelease, other.prerelease)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
return false unless other.is_a?(SemanticVersion)
|
|
67
|
+
|
|
68
|
+
major == other.major &&
|
|
69
|
+
minor == other.minor &&
|
|
70
|
+
patch == other.patch &&
|
|
71
|
+
prerelease == other.prerelease
|
|
72
|
+
# Build metadata is ignored in equality checks
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def eql?(other)
|
|
76
|
+
self == other
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def hash
|
|
80
|
+
[major, minor, patch, prerelease].hash
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_s
|
|
84
|
+
result = "#{major}.#{minor}.#{patch}"
|
|
85
|
+
result += "-#{prerelease}" if prerelease
|
|
86
|
+
result += "+#{build_metadata}" if build_metadata
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def self.numeric?(str)
|
|
93
|
+
str.to_i.to_s == str
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def compare_prerelease(pre1, pre2)
|
|
97
|
+
# If both are empty, they're equal
|
|
98
|
+
return 0 if pre1.nil? && pre2.nil?
|
|
99
|
+
|
|
100
|
+
# A version without prerelease has higher precedence
|
|
101
|
+
return 1 if pre1.nil?
|
|
102
|
+
return -1 if pre2.nil?
|
|
103
|
+
|
|
104
|
+
# Split into identifiers
|
|
105
|
+
ids1 = pre1.split('.')
|
|
106
|
+
ids2 = pre2.split('.')
|
|
107
|
+
|
|
108
|
+
# Compare each identifier until we find a difference
|
|
109
|
+
[ids1.length, ids2.length].min.times do |i|
|
|
110
|
+
cmp = compare_prerelease_identifiers(ids1[i], ids2[i])
|
|
111
|
+
return cmp if cmp != 0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# If all identifiers match up to the length of the shorter one,
|
|
115
|
+
# the longer one has higher precedence
|
|
116
|
+
ids1.length <=> ids2.length
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def compare_prerelease_identifiers(id1, id2)
|
|
120
|
+
# If both are numeric, compare numerically
|
|
121
|
+
if self.class.numeric?(id1) && self.class.numeric?(id2)
|
|
122
|
+
return id1.to_i <=> id2.to_i
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# If only one is numeric, numeric ones have lower precedence
|
|
126
|
+
return -1 if self.class.numeric?(id1)
|
|
127
|
+
return 1 if self.class.numeric?(id2)
|
|
128
|
+
|
|
129
|
+
# Neither is numeric, compare as strings
|
|
130
|
+
id1 <=> id2
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require 'base64'
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
class SSEConfigClient
|
|
7
|
+
class Options
|
|
8
|
+
attr_reader :sse_read_timeout, :seconds_between_new_connection,
|
|
9
|
+
:sse_default_reconnect_time, :sleep_delay_for_new_connection_check,
|
|
10
|
+
:errors_to_close_connection
|
|
11
|
+
|
|
12
|
+
def initialize(sse_read_timeout: 300,
|
|
13
|
+
seconds_between_new_connection: 5,
|
|
14
|
+
sleep_delay_for_new_connection_check: 1,
|
|
15
|
+
sse_default_reconnect_time: SSE::Client::DEFAULT_RECONNECT_TIME,
|
|
16
|
+
errors_to_close_connection: [HTTP::ConnectionError])
|
|
17
|
+
@sse_read_timeout = sse_read_timeout
|
|
18
|
+
@seconds_between_new_connection = seconds_between_new_connection
|
|
19
|
+
@sse_default_reconnect_time = sse_default_reconnect_time
|
|
20
|
+
@sleep_delay_for_new_connection_check = sleep_delay_for_new_connection_check
|
|
21
|
+
@errors_to_close_connection = errors_to_close_connection
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
LOG = Quonfig::InternalLogger.new(self)
|
|
26
|
+
|
|
27
|
+
def initialize(prefab_options, config_loader, options = nil, logger = nil)
|
|
28
|
+
@prefab_options = prefab_options
|
|
29
|
+
@options = options || Options.new
|
|
30
|
+
@config_loader = config_loader
|
|
31
|
+
@connected = false
|
|
32
|
+
@logger = logger || LOG
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close
|
|
36
|
+
@retry_thread&.kill
|
|
37
|
+
@client&.close
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start(&load_configs)
|
|
41
|
+
if @prefab_options.sse_api_urls.empty?
|
|
42
|
+
@logger.debug 'No SSE api_urls configured'
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@client = connect(&load_configs)
|
|
47
|
+
|
|
48
|
+
closed_count = 0
|
|
49
|
+
|
|
50
|
+
@retry_thread = Thread.new do
|
|
51
|
+
loop do
|
|
52
|
+
sleep @options.sleep_delay_for_new_connection_check
|
|
53
|
+
|
|
54
|
+
if @client.closed?
|
|
55
|
+
closed_count += @options.sleep_delay_for_new_connection_check
|
|
56
|
+
|
|
57
|
+
if closed_count > @options.seconds_between_new_connection
|
|
58
|
+
closed_count = 0
|
|
59
|
+
@logger.debug 'Reconnecting SSE client'
|
|
60
|
+
@client = connect(&load_configs)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def connect(&load_configs)
|
|
68
|
+
url = "#{source}/api/v2/sse/config"
|
|
69
|
+
@logger.debug "SSE Streaming Connect to #{url} start_at #{@config_loader.highwater_mark}"
|
|
70
|
+
|
|
71
|
+
SSE::Client.new(url,
|
|
72
|
+
headers: headers,
|
|
73
|
+
read_timeout: @options.sse_read_timeout,
|
|
74
|
+
reconnect_time: @options.sse_default_reconnect_time,
|
|
75
|
+
last_event_id: (@config_loader.highwater_mark&.positive? ? @config_loader.highwater_mark.to_s : nil),
|
|
76
|
+
logger: Quonfig::InternalLogger.new(SSE::Client)) do |client|
|
|
77
|
+
client.on_event do |event|
|
|
78
|
+
if event.data.nil? || event.data.empty?
|
|
79
|
+
@logger.error "SSE Streaming Error: Received empty data for url #{url}"
|
|
80
|
+
client.close
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
begin
|
|
85
|
+
parsed = JSON.parse(event.data)
|
|
86
|
+
rescue JSON::ParserError => e
|
|
87
|
+
@logger.error "SSE Streaming Error: Failed to parse JSON for url #{url}: #{e.message}"
|
|
88
|
+
client.close
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
envelope = Quonfig::ConfigEnvelope.new(
|
|
93
|
+
configs: parsed['configs'] || [],
|
|
94
|
+
meta: parsed['meta'] || {}
|
|
95
|
+
)
|
|
96
|
+
load_configs.call(envelope, event, :sse)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
client.on_error do |error|
|
|
100
|
+
# SSL "unexpected eof" is expected when SSE sessions timeout normally
|
|
101
|
+
if error.is_a?(OpenSSL::SSL::SSLError) && error.message.include?('unexpected eof')
|
|
102
|
+
@logger.debug "SSE Streaming: Connection closed (expected timeout) for url #{url}"
|
|
103
|
+
else
|
|
104
|
+
@logger.error "SSE Streaming Error: #{error.inspect} for url #{url}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if @options.errors_to_close_connection.any? { |klass| error.is_a?(klass) }
|
|
108
|
+
@logger.debug "Closing SSE connection for url #{url}"
|
|
109
|
+
client.close
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def headers
|
|
116
|
+
auth = "1:#{@prefab_options.sdk_key}"
|
|
117
|
+
auth_string = Base64.strict_encode64(auth)
|
|
118
|
+
return {
|
|
119
|
+
'Authorization' => "Basic #{auth_string}",
|
|
120
|
+
'Accept' => 'text/event-stream',
|
|
121
|
+
'X-Quonfig-SDK-Version' => "sdk-ruby-#{Quonfig::VERSION}"
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def source
|
|
126
|
+
@source_index = @source_index.nil? ? 0 : @source_index + 1
|
|
127
|
+
|
|
128
|
+
if @source_index >= @prefab_options.sse_api_urls.size
|
|
129
|
+
@source_index = 0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
@prefab_options.sse_api_urls[@source_index]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby types mirroring the JSON delivery protocol defined in
|
|
4
|
+
# sdk-node/src/types.ts. Field names are snake_case per Ruby convention;
|
|
5
|
+
# callers parsing JSON fixtures (camelCase) are responsible for the mapping.
|
|
6
|
+
# All Structs use keyword_init so omitted fields default to nil.
|
|
7
|
+
module Quonfig
|
|
8
|
+
Value = Struct.new(:type, :value, :confidential, :decrypt_with, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
Criterion = Struct.new(:property_name, :operator, :value_to_match, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
Rule = Struct.new(:criteria, :value, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
RuleSet = Struct.new(:rules, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
Environment = Struct.new(:id, :rules, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
Meta = Struct.new(:version, :environment, :workspace_id, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
ConfigResponse = Struct.new(
|
|
21
|
+
:id,
|
|
22
|
+
:key,
|
|
23
|
+
:type,
|
|
24
|
+
:value_type,
|
|
25
|
+
:send_to_client_sdk,
|
|
26
|
+
:default,
|
|
27
|
+
:environment,
|
|
28
|
+
keyword_init: true
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
WeightedValue = Struct.new(:weight, :value, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
WeightedValuesData = Struct.new(:weighted_values, :hash_by_property_name, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
SchemaData = Struct.new(:schema_type, :schema, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
ProvidedData = Struct.new(:source, :lookup, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
WorkspaceEnvironment = Struct.new(:id, :rules, keyword_init: true)
|
|
40
|
+
|
|
41
|
+
WorkspaceConfigDocument = Struct.new(
|
|
42
|
+
:id,
|
|
43
|
+
:key,
|
|
44
|
+
:type,
|
|
45
|
+
:value_type,
|
|
46
|
+
:send_to_client_sdk,
|
|
47
|
+
:default,
|
|
48
|
+
:environments,
|
|
49
|
+
keyword_init: true
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
QuonfigDatadirEnvironments = Struct.new(:environments, keyword_init: true)
|
|
53
|
+
|
|
54
|
+
# ConfigEnvelope is already defined in lib/quonfig/config_envelope.rb with
|
|
55
|
+
# the same members (:configs, :meta). It is required from lib/quonfig.rb.
|
|
56
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
class WeightedValueResolver
|
|
5
|
+
MAX_32_FLOAT = 4_294_967_294.0
|
|
6
|
+
|
|
7
|
+
def initialize(weights, config_key, context_hash_value)
|
|
8
|
+
@weights = weights
|
|
9
|
+
@config_key = config_key
|
|
10
|
+
@context_hash_value = context_hash_value
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def resolve
|
|
14
|
+
percent = @context_hash_value ? user_percent : rand
|
|
15
|
+
|
|
16
|
+
index = variant_index(percent)
|
|
17
|
+
|
|
18
|
+
[@weights[index], index]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def user_percent
|
|
22
|
+
to_hash = "#{@config_key}#{@context_hash_value}"
|
|
23
|
+
int_value = Murmur3.murmur3_32(to_hash)
|
|
24
|
+
int_value / MAX_32_FLOAT
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def variant_index(percent_through_distribution)
|
|
28
|
+
distribution_space = @weights.inject(0) { |sum, v| sum + weight_of(v) }
|
|
29
|
+
bucket = distribution_space * percent_through_distribution
|
|
30
|
+
|
|
31
|
+
sum = 0
|
|
32
|
+
@weights.each_with_index do |variant, index|
|
|
33
|
+
w = weight_of(variant)
|
|
34
|
+
return index if bucket < sum + w
|
|
35
|
+
|
|
36
|
+
sum += w
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# In the event that all weights are zero, return the last variant
|
|
40
|
+
@weights.size - 1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def weight_of(variant)
|
|
46
|
+
variant[:weight] || variant['weight']
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/quonfig.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quonfig
|
|
4
|
+
NO_DEFAULT_PROVIDED = :no_default_provided
|
|
5
|
+
VERSION = File.read(File.dirname(__FILE__) + '/../VERSION').strip
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
begin
|
|
9
|
+
require 'semantic_logger'
|
|
10
|
+
rescue LoadError
|
|
11
|
+
# semantic_logger is optional - only needed for dynamic log level filtering
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'securerandom'
|
|
15
|
+
require 'concurrent/atomics'
|
|
16
|
+
require 'concurrent'
|
|
17
|
+
require 'faraday'
|
|
18
|
+
require 'openssl'
|
|
19
|
+
require 'ld-eventsource'
|
|
20
|
+
|
|
21
|
+
require 'quonfig/internal_logger'
|
|
22
|
+
require 'quonfig/time_helpers'
|
|
23
|
+
require 'quonfig/types'
|
|
24
|
+
require 'quonfig/error'
|
|
25
|
+
require 'quonfig/duration'
|
|
26
|
+
require 'quonfig/reason'
|
|
27
|
+
require 'quonfig/evaluation'
|
|
28
|
+
require 'quonfig/encryption'
|
|
29
|
+
require 'quonfig/exponential_backoff'
|
|
30
|
+
require 'quonfig/periodic_sync'
|
|
31
|
+
require 'quonfig/errors/initialization_timeout_error'
|
|
32
|
+
require 'quonfig/errors/invalid_sdk_key_error'
|
|
33
|
+
require 'quonfig/errors/missing_default_error'
|
|
34
|
+
require 'quonfig/errors/env_var_parse_error'
|
|
35
|
+
require 'quonfig/errors/missing_env_var_error'
|
|
36
|
+
require 'quonfig/errors/type_mismatch_error'
|
|
37
|
+
require 'quonfig/errors/uninitialized_error'
|
|
38
|
+
require 'quonfig/options'
|
|
39
|
+
require 'quonfig/rate_limit_cache'
|
|
40
|
+
require 'quonfig/weighted_value_resolver'
|
|
41
|
+
require 'quonfig/config_store'
|
|
42
|
+
require 'quonfig/evaluator'
|
|
43
|
+
require 'quonfig/resolver'
|
|
44
|
+
require 'quonfig/config_envelope'
|
|
45
|
+
require 'quonfig/config_loader'
|
|
46
|
+
require 'quonfig/datadir'
|
|
47
|
+
require 'quonfig/sse_config_client'
|
|
48
|
+
require 'quonfig/http_connection'
|
|
49
|
+
require 'quonfig/caching_http_connection'
|
|
50
|
+
require 'quonfig/context'
|
|
51
|
+
require 'quonfig/client'
|
|
52
|
+
require 'quonfig/bound_client'
|
|
53
|
+
require 'quonfig/semantic_logger_filter'
|
|
54
|
+
require 'quonfig/quonfig'
|
|
55
|
+
require 'quonfig/murmer3'
|
|
56
|
+
require 'quonfig/semver'
|
|
57
|
+
require 'quonfig/fixed_size_hash'
|
data/quonfig.gemspec
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Generated by juwelier
|
|
2
|
+
# DO NOT EDIT THIS FILE DIRECTLY
|
|
3
|
+
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
|
|
4
|
+
# -*- encoding: utf-8 -*-
|
|
5
|
+
# stub: quonfig 0.0.2 ruby lib
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |s|
|
|
8
|
+
s.name = "quonfig".freeze
|
|
9
|
+
s.version = "0.0.2".freeze
|
|
10
|
+
|
|
11
|
+
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
12
|
+
s.require_paths = ["lib".freeze]
|
|
13
|
+
s.authors = ["Jeff Dwyer".freeze]
|
|
14
|
+
s.date = "2026-04-22"
|
|
15
|
+
s.description = "Quonfig \u2014 feature flags and live config, stored as files in git.".freeze
|
|
16
|
+
s.email = "jeff@quonfig.com".freeze
|
|
17
|
+
s.extra_rdoc_files = [
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"LICENSE.txt",
|
|
20
|
+
"README.md"
|
|
21
|
+
]
|
|
22
|
+
s.files = [
|
|
23
|
+
".claude/rules/constitution.md",
|
|
24
|
+
".claude/rules/git-safety.md",
|
|
25
|
+
".claude/rules/issue-tracking.md",
|
|
26
|
+
".claude/rules/testing-workflow.md",
|
|
27
|
+
".envrc.sample",
|
|
28
|
+
".github/CODEOWNERS",
|
|
29
|
+
".github/pull_request_template.md",
|
|
30
|
+
".github/workflows/push_gem.yml",
|
|
31
|
+
".github/workflows/ruby.yml",
|
|
32
|
+
".github/workflows/test.yaml",
|
|
33
|
+
".rubocop.yml",
|
|
34
|
+
".tool-versions",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"CLAUDE.md",
|
|
37
|
+
"CODEOWNERS",
|
|
38
|
+
"Gemfile",
|
|
39
|
+
"Gemfile.lock",
|
|
40
|
+
"LICENSE.txt",
|
|
41
|
+
"README.md",
|
|
42
|
+
"Rakefile",
|
|
43
|
+
"VERSION",
|
|
44
|
+
"dev/allocation_stats",
|
|
45
|
+
"dev/benchmark",
|
|
46
|
+
"dev/console",
|
|
47
|
+
"dev/script_setup.rb",
|
|
48
|
+
"lib/quonfig.rb",
|
|
49
|
+
"lib/quonfig/bound_client.rb",
|
|
50
|
+
"lib/quonfig/caching_http_connection.rb",
|
|
51
|
+
"lib/quonfig/client.rb",
|
|
52
|
+
"lib/quonfig/config_envelope.rb",
|
|
53
|
+
"lib/quonfig/config_loader.rb",
|
|
54
|
+
"lib/quonfig/config_store.rb",
|
|
55
|
+
"lib/quonfig/context.rb",
|
|
56
|
+
"lib/quonfig/datadir.rb",
|
|
57
|
+
"lib/quonfig/duration.rb",
|
|
58
|
+
"lib/quonfig/encryption.rb",
|
|
59
|
+
"lib/quonfig/error.rb",
|
|
60
|
+
"lib/quonfig/errors/env_var_parse_error.rb",
|
|
61
|
+
"lib/quonfig/errors/initialization_timeout_error.rb",
|
|
62
|
+
"lib/quonfig/errors/invalid_sdk_key_error.rb",
|
|
63
|
+
"lib/quonfig/errors/missing_default_error.rb",
|
|
64
|
+
"lib/quonfig/errors/missing_env_var_error.rb",
|
|
65
|
+
"lib/quonfig/errors/type_mismatch_error.rb",
|
|
66
|
+
"lib/quonfig/errors/uninitialized_error.rb",
|
|
67
|
+
"lib/quonfig/evaluation.rb",
|
|
68
|
+
"lib/quonfig/evaluator.rb",
|
|
69
|
+
"lib/quonfig/exponential_backoff.rb",
|
|
70
|
+
"lib/quonfig/fixed_size_hash.rb",
|
|
71
|
+
"lib/quonfig/http_connection.rb",
|
|
72
|
+
"lib/quonfig/internal_logger.rb",
|
|
73
|
+
"lib/quonfig/murmer3.rb",
|
|
74
|
+
"lib/quonfig/options.rb",
|
|
75
|
+
"lib/quonfig/periodic_sync.rb",
|
|
76
|
+
"lib/quonfig/quonfig.rb",
|
|
77
|
+
"lib/quonfig/rate_limit_cache.rb",
|
|
78
|
+
"lib/quonfig/reason.rb",
|
|
79
|
+
"lib/quonfig/resolver.rb",
|
|
80
|
+
"lib/quonfig/semantic_logger_filter.rb",
|
|
81
|
+
"lib/quonfig/semver.rb",
|
|
82
|
+
"lib/quonfig/sse_config_client.rb",
|
|
83
|
+
"lib/quonfig/time_helpers.rb",
|
|
84
|
+
"lib/quonfig/types.rb",
|
|
85
|
+
"lib/quonfig/weighted_value_resolver.rb",
|
|
86
|
+
"quonfig.gemspec",
|
|
87
|
+
"scripts/generate_integration_tests.rb",
|
|
88
|
+
"test/fixtures/datafile.json",
|
|
89
|
+
"test/integration/test_context_precedence.rb",
|
|
90
|
+
"test/integration/test_datadir_environment.rb",
|
|
91
|
+
"test/integration/test_enabled.rb",
|
|
92
|
+
"test/integration/test_enabled_with_contexts.rb",
|
|
93
|
+
"test/integration/test_get.rb",
|
|
94
|
+
"test/integration/test_get_feature_flag.rb",
|
|
95
|
+
"test/integration/test_get_or_raise.rb",
|
|
96
|
+
"test/integration/test_get_weighted_values.rb",
|
|
97
|
+
"test/integration/test_helpers.rb",
|
|
98
|
+
"test/integration/test_helpers_test.rb",
|
|
99
|
+
"test/integration/test_post.rb",
|
|
100
|
+
"test/integration/test_telemetry.rb",
|
|
101
|
+
"test/support/common_helpers.rb",
|
|
102
|
+
"test/support/mock_base_client.rb",
|
|
103
|
+
"test/support/mock_config_loader.rb",
|
|
104
|
+
"test/test_bound_client.rb",
|
|
105
|
+
"test/test_caching_http_connection.rb",
|
|
106
|
+
"test/test_client.rb",
|
|
107
|
+
"test/test_config_loader.rb",
|
|
108
|
+
"test/test_context.rb",
|
|
109
|
+
"test/test_datadir.rb",
|
|
110
|
+
"test/test_duration.rb",
|
|
111
|
+
"test/test_encryption.rb",
|
|
112
|
+
"test/test_evaluator.rb",
|
|
113
|
+
"test/test_exponential_backoff.rb",
|
|
114
|
+
"test/test_fixed_size_hash.rb",
|
|
115
|
+
"test/test_helper.rb",
|
|
116
|
+
"test/test_http_connection.rb",
|
|
117
|
+
"test/test_internal_logger.rb",
|
|
118
|
+
"test/test_options.rb",
|
|
119
|
+
"test/test_rate_limit_cache.rb",
|
|
120
|
+
"test/test_reason.rb",
|
|
121
|
+
"test/test_rename.rb",
|
|
122
|
+
"test/test_resolver.rb",
|
|
123
|
+
"test/test_semantic_logger_filter.rb",
|
|
124
|
+
"test/test_semver.rb",
|
|
125
|
+
"test/test_sse_config_client.rb",
|
|
126
|
+
"test/test_typed_getters.rb",
|
|
127
|
+
"test/test_types.rb",
|
|
128
|
+
"test/test_weighted_value_resolver.rb"
|
|
129
|
+
]
|
|
130
|
+
s.homepage = "https://github.com/quonfig/sdk-ruby".freeze
|
|
131
|
+
s.licenses = ["MIT".freeze]
|
|
132
|
+
s.rubygems_version = "3.5.22".freeze
|
|
133
|
+
s.summary = "Quonfig Ruby SDK".freeze
|
|
134
|
+
|
|
135
|
+
s.specification_version = 4
|
|
136
|
+
|
|
137
|
+
s.add_runtime_dependency(%q<concurrent-ruby>.freeze, ["~> 1.0".freeze, ">= 1.0.5".freeze])
|
|
138
|
+
s.add_runtime_dependency(%q<faraday>.freeze, [">= 0".freeze])
|
|
139
|
+
s.add_runtime_dependency(%q<ld-eventsource>.freeze, [">= 0".freeze])
|
|
140
|
+
s.add_runtime_dependency(%q<uuid>.freeze, [">= 0".freeze])
|
|
141
|
+
s.add_runtime_dependency(%q<activesupport>.freeze, [">= 4".freeze])
|
|
142
|
+
s.add_development_dependency(%q<allocation_stats>.freeze, [">= 0".freeze])
|
|
143
|
+
s.add_development_dependency(%q<benchmark-ips>.freeze, [">= 0".freeze])
|
|
144
|
+
s.add_development_dependency(%q<bundler>.freeze, [">= 0".freeze])
|
|
145
|
+
s.add_development_dependency(%q<juwelier>.freeze, ["~> 2.4.9".freeze])
|
|
146
|
+
s.add_development_dependency(%q<rdoc>.freeze, [">= 0".freeze])
|
|
147
|
+
s.add_development_dependency(%q<simplecov>.freeze, [">= 0".freeze])
|
|
148
|
+
end
|
|
149
|
+
|