quonfig 0.0.10 → 0.0.12
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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +94 -0
- data/lib/quonfig/caching_http_connection.rb +3 -3
- data/lib/quonfig/client.rb +22 -27
- data/lib/quonfig/config_loader.rb +5 -1
- data/lib/quonfig/config_store.rb +10 -6
- data/lib/quonfig/context.rb +5 -4
- data/lib/quonfig/datadir.rb +4 -10
- data/lib/quonfig/dev_context.rb +4 -2
- data/lib/quonfig/duration.rb +2 -2
- data/lib/quonfig/encryption.rb +12 -16
- data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
- data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
- data/lib/quonfig/errors/missing_environment_error.rb +1 -1
- data/lib/quonfig/errors/uninitialized_error.rb +1 -1
- data/lib/quonfig/evaluation.rb +11 -8
- data/lib/quonfig/evaluator.rb +34 -37
- data/lib/quonfig/fixed_size_hash.rb +1 -0
- data/lib/quonfig/http_connection.rb +2 -4
- data/lib/quonfig/internal_logger.rb +63 -27
- data/lib/quonfig/murmer3.rb +2 -2
- data/lib/quonfig/options.rb +62 -75
- data/lib/quonfig/periodic_sync.rb +1 -1
- data/lib/quonfig/quonfig.rb +3 -3
- data/lib/quonfig/reason.rb +2 -1
- data/lib/quonfig/resolver.rb +8 -9
- data/lib/quonfig/semantic_logger_filter.rb +4 -3
- data/lib/quonfig/semver.rb +6 -8
- data/lib/quonfig/sse_config_client.rb +14 -15
- data/lib/quonfig/stdlib_formatter.rb +3 -3
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
- data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
- data/lib/quonfig/time_helpers.rb +2 -0
- data/lib/quonfig/version.rb +5 -0
- data/lib/quonfig.rb +2 -1
- data/quonfig.gemspec +29 -165
- metadata +24 -193
- data/.claude/rules/constitution.md +0 -81
- data/.claude/rules/git-safety.md +0 -11
- data/.claude/rules/issue-tracking.md +0 -13
- data/.claude/rules/testing-workflow.md +0 -28
- data/.envrc.sample +0 -3
- data/.github/CODEOWNERS +0 -2
- data/.github/pull_request_template.md +0 -8
- data/.github/workflows/release.yml +0 -49
- data/.github/workflows/ruby.yml +0 -60
- data/.github/workflows/test.yaml +0 -40
- data/.rubocop.yml +0 -13
- data/.tool-versions +0 -1
- data/CLAUDE.md +0 -29
- data/CODEOWNERS +0 -1
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -177
- data/Rakefile +0 -64
- data/VERSION +0 -1
- data/dev/allocation_stats +0 -60
- data/dev/benchmark +0 -40
- data/dev/console +0 -12
- data/dev/script_setup.rb +0 -18
- data/test/fixtures/datafile.json +0 -87
- data/test/integration/test_context_precedence.rb +0 -112
- data/test/integration/test_datadir_environment.rb +0 -54
- data/test/integration/test_dev_overrides.rb +0 -40
- data/test/integration/test_enabled.rb +0 -478
- data/test/integration/test_enabled_with_contexts.rb +0 -64
- data/test/integration/test_get.rb +0 -136
- data/test/integration/test_get_feature_flag.rb +0 -28
- data/test/integration/test_get_or_raise.rb +0 -60
- data/test/integration/test_get_weighted_values.rb +0 -34
- data/test/integration/test_helpers.rb +0 -667
- data/test/integration/test_helpers_test.rb +0 -73
- data/test/integration/test_post.rb +0 -44
- data/test/integration/test_telemetry.rb +0 -170
- data/test/support/common_helpers.rb +0 -106
- data/test/support/mock_base_client.rb +0 -27
- data/test/support/mock_config_loader.rb +0 -1
- data/test/test_bound_client.rb +0 -109
- data/test/test_caching_http_connection.rb +0 -218
- data/test/test_client.rb +0 -255
- data/test/test_client_network_mode.rb +0 -136
- data/test/test_client_telemetry.rb +0 -175
- data/test/test_config_loader.rb +0 -70
- data/test/test_context.rb +0 -139
- data/test/test_context_shape.rb +0 -37
- data/test/test_context_shape_aggregator.rb +0 -126
- data/test/test_datadir.rb +0 -203
- data/test/test_details_getters.rb +0 -242
- data/test/test_dev_context.rb +0 -163
- data/test/test_duration.rb +0 -37
- data/test/test_encryption.rb +0 -16
- data/test/test_evaluation_summaries_aggregator.rb +0 -180
- data/test/test_evaluator.rb +0 -285
- data/test/test_example_contexts_aggregator.rb +0 -119
- data/test/test_exponential_backoff.rb +0 -44
- data/test/test_fixed_size_hash.rb +0 -119
- data/test/test_helper.rb +0 -17
- data/test/test_http_connection.rb +0 -81
- data/test/test_internal_logger.rb +0 -34
- data/test/test_options.rb +0 -198
- data/test/test_rate_limit_cache.rb +0 -44
- data/test/test_reason.rb +0 -79
- data/test/test_rename.rb +0 -65
- data/test/test_resolver.rb +0 -291
- data/test/test_semantic_logger_filter.rb +0 -144
- data/test/test_semver.rb +0 -108
- data/test/test_should_log.rb +0 -186
- data/test/test_sse_config_client.rb +0 -297
- data/test/test_stdlib_formatter.rb +0 -195
- data/test/test_telemetry_reporter.rb +0 -209
- data/test/test_typed_getters.rb +0 -131
- data/test/test_types.rb +0 -141
- data/test/test_weighted_value_resolver.rb +0 -84
data/test/test_should_log.rb
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
|
|
5
|
-
# Verifies the client-level should_log?(logger_path:, desired_level:, contexts:)
|
|
6
|
-
# API — a Reforge-style convenience built on top of the primitive get() that
|
|
7
|
-
# uses the client's `logger_key` option as the config key and injects the
|
|
8
|
-
# logger path under `quonfig-sdk-logging.key`. Parallels sdk-node's
|
|
9
|
-
# shouldLog({loggerPath}) and sdk-go's ShouldLogPath.
|
|
10
|
-
class TestShouldLog < Minitest::Test
|
|
11
|
-
LOG_LEVEL_KEY = 'log-level.my-app'
|
|
12
|
-
|
|
13
|
-
# Minimal config fixture mirroring what ConfigStore expects: a string
|
|
14
|
-
# config whose rule returns the configured log level.
|
|
15
|
-
def make_log_level_config(key:, level:)
|
|
16
|
-
{
|
|
17
|
-
'id' => '1',
|
|
18
|
-
'key' => key,
|
|
19
|
-
'type' => 'config',
|
|
20
|
-
'valueType' => 'string',
|
|
21
|
-
'sendToClientSdk' => false,
|
|
22
|
-
'default' => {
|
|
23
|
-
'rules' => [
|
|
24
|
-
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
25
|
-
'value' => { 'type' => 'string', 'value' => level } }
|
|
26
|
-
]
|
|
27
|
-
},
|
|
28
|
-
'environment' => nil
|
|
29
|
-
}
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def store_with(*configs)
|
|
33
|
-
store = Quonfig::ConfigStore.new
|
|
34
|
-
configs.each { |c| store.set(c['key'], c) }
|
|
35
|
-
store
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def client_with(store, **options)
|
|
39
|
-
Quonfig::Client.new(Quonfig::Options.new(**options), store: store)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# ---- logger_key option surface ---------------------------------------
|
|
43
|
-
|
|
44
|
-
def test_logger_key_option_defaults_to_nil
|
|
45
|
-
assert_nil Quonfig::Options.new.logger_key
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def test_logger_key_option_accepts_value
|
|
49
|
-
opts = Quonfig::Options.new(logger_key: LOG_LEVEL_KEY)
|
|
50
|
-
assert_equal LOG_LEVEL_KEY, opts.logger_key
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def test_client_exposes_logger_key_from_options
|
|
54
|
-
client = client_with(Quonfig::ConfigStore.new, logger_key: LOG_LEVEL_KEY)
|
|
55
|
-
assert_equal LOG_LEVEL_KEY, client.logger_key
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# ---- should_log? requires logger_key ---------------------------------
|
|
59
|
-
|
|
60
|
-
def test_should_log_raises_without_logger_key
|
|
61
|
-
client = client_with(Quonfig::ConfigStore.new)
|
|
62
|
-
err = assert_raises(Quonfig::Error) do
|
|
63
|
-
client.should_log?(logger_path: 'MyApp::Foo', desired_level: :info)
|
|
64
|
-
end
|
|
65
|
-
assert_match(/logger_key/, err.message)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# ---- should_log? gating ----------------------------------------------
|
|
69
|
-
|
|
70
|
-
def test_should_log_true_when_desired_at_or_above_configured
|
|
71
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'info'))
|
|
72
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
73
|
-
|
|
74
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :info)
|
|
75
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :warn)
|
|
76
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :error)
|
|
77
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :fatal)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def test_should_log_false_when_desired_below_configured
|
|
81
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
82
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
83
|
-
|
|
84
|
-
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :trace)
|
|
85
|
-
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :debug)
|
|
86
|
-
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :info)
|
|
87
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :warn)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def test_should_log_accepts_string_desired_level
|
|
91
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
92
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
93
|
-
|
|
94
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: 'warn')
|
|
95
|
-
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: 'info')
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def test_should_log_returns_true_when_no_config_found
|
|
99
|
-
# Missing config key → log everything (match go/node).
|
|
100
|
-
client = client_with(Quonfig::ConfigStore.new, logger_key: LOG_LEVEL_KEY)
|
|
101
|
-
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :trace)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# ---- context injection -----------------------------------------------
|
|
105
|
-
|
|
106
|
-
# Capture what context reaches get() by injecting a spy client that wraps
|
|
107
|
-
# a real store-backed client.
|
|
108
|
-
class ContextCapturingClient
|
|
109
|
-
attr_reader :captured_contexts
|
|
110
|
-
|
|
111
|
-
def initialize(delegate)
|
|
112
|
-
@delegate = delegate
|
|
113
|
-
@captured_contexts = []
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def logger_key
|
|
117
|
-
@delegate.logger_key
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def get(key, default = Quonfig::NO_DEFAULT_PROVIDED, jit_context = Quonfig::NO_DEFAULT_PROVIDED)
|
|
121
|
-
@captured_contexts << jit_context
|
|
122
|
-
@delegate.get(key, default, jit_context)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def test_should_log_injects_logger_path_under_quonfig_sdk_logging_key
|
|
127
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
128
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
129
|
-
|
|
130
|
-
# Reach into the context that get() sees. We do this by asserting on the
|
|
131
|
-
# resolver via a fake — simplest path: call should_log? with a sentinel
|
|
132
|
-
# path and verify the evaluator would see it. We assert via the public
|
|
133
|
-
# contract: context reaches get(), so we patch get() temporarily.
|
|
134
|
-
captured = []
|
|
135
|
-
client.define_singleton_method(:get) do |key, default = nil, jit_context = nil|
|
|
136
|
-
captured << { key: key, jit_context: jit_context }
|
|
137
|
-
'trace'
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
client.should_log?(logger_path: 'MyApp::Services::Auth', desired_level: :info)
|
|
141
|
-
|
|
142
|
-
assert_equal 1, captured.size
|
|
143
|
-
assert_equal LOG_LEVEL_KEY, captured.first[:key]
|
|
144
|
-
ctx = captured.first[:jit_context]
|
|
145
|
-
assert_equal({ 'quonfig-sdk-logging' => { 'key' => 'MyApp::Services::Auth' } }, ctx)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def test_should_log_merges_caller_contexts_with_logger_context
|
|
149
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
150
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
151
|
-
|
|
152
|
-
captured = []
|
|
153
|
-
client.define_singleton_method(:get) do |key, default = nil, jit_context = nil|
|
|
154
|
-
captured << jit_context
|
|
155
|
-
'trace'
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
client.should_log?(
|
|
159
|
-
logger_path: 'MyApp::Foo',
|
|
160
|
-
desired_level: :info,
|
|
161
|
-
contexts: { 'user' => { 'id' => 'u1' } }
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
assert_equal(
|
|
165
|
-
{
|
|
166
|
-
'user' => { 'id' => 'u1' },
|
|
167
|
-
'quonfig-sdk-logging' => { 'key' => 'MyApp::Foo' }
|
|
168
|
-
},
|
|
169
|
-
captured.first
|
|
170
|
-
)
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def test_should_log_logger_path_verbatim_no_normalization
|
|
174
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
175
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
176
|
-
|
|
177
|
-
captured = []
|
|
178
|
-
client.define_singleton_method(:get) do |key, default = nil, jit_context = nil|
|
|
179
|
-
captured << jit_context
|
|
180
|
-
'trace'
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
client.should_log?(logger_path: 'HTMLParser', desired_level: :info)
|
|
184
|
-
assert_equal 'HTMLParser', captured.first['quonfig-sdk-logging']['key']
|
|
185
|
-
end
|
|
186
|
-
end
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
require 'webrick'
|
|
5
|
-
require 'ostruct'
|
|
6
|
-
require 'json'
|
|
7
|
-
|
|
8
|
-
class TestSSEConfigClient < Minitest::Test
|
|
9
|
-
def test_connect_url_is_api_v2_sse_config
|
|
10
|
-
prefab_options = OpenStruct.new(sse_api_urls: ['https://stream.example.com'], sdk_key: 'test')
|
|
11
|
-
config_loader = OpenStruct.new(highwater_mark: 0)
|
|
12
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
|
|
13
|
-
|
|
14
|
-
captured_url = nil
|
|
15
|
-
fake = OpenStruct.new(closed?: false)
|
|
16
|
-
fake.define_singleton_method(:on_event) { |&_b| }
|
|
17
|
-
fake.define_singleton_method(:on_error) { |&_b| }
|
|
18
|
-
fake.define_singleton_method(:close) { }
|
|
19
|
-
|
|
20
|
-
SSE::Client.stub :new, ->(url, *_args, **_kwargs, &block) {
|
|
21
|
-
captured_url = url
|
|
22
|
-
block.call(fake) if block
|
|
23
|
-
fake
|
|
24
|
-
} do
|
|
25
|
-
client.connect { |_e, _ev, _s| }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
assert_equal 'https://stream.example.com/api/v2/sse/config', captured_url
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def test_on_event_parses_json_into_config_envelope
|
|
32
|
-
prefab_options = OpenStruct.new(sse_api_urls: ['https://stream.example.com'], sdk_key: 'test')
|
|
33
|
-
config_loader = OpenStruct.new(highwater_mark: 0)
|
|
34
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
|
|
35
|
-
|
|
36
|
-
captured = {}
|
|
37
|
-
event_handler = nil
|
|
38
|
-
fake = Object.new
|
|
39
|
-
fake.define_singleton_method(:on_event) { |&block| event_handler = block }
|
|
40
|
-
fake.define_singleton_method(:on_error) { |&_b| }
|
|
41
|
-
fake.define_singleton_method(:close) { }
|
|
42
|
-
fake.define_singleton_method(:closed?) { false }
|
|
43
|
-
|
|
44
|
-
SSE::Client.stub :new, ->(*_args, **_kwargs, &block) {
|
|
45
|
-
block.call(fake) if block
|
|
46
|
-
fake
|
|
47
|
-
} do
|
|
48
|
-
client.connect do |envelope, event, source|
|
|
49
|
-
captured[:envelope] = envelope
|
|
50
|
-
captured[:event] = event
|
|
51
|
-
captured[:source] = source
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
json_data = JSON.generate({
|
|
56
|
-
configs: [{ key: 'my.key', valueType: 'string', default: { rules: [] } }],
|
|
57
|
-
meta: { version: 'abc123', environment: 'prod' }
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
event_handler.call(OpenStruct.new(data: json_data))
|
|
61
|
-
|
|
62
|
-
assert_instance_of Quonfig::ConfigEnvelope, captured[:envelope]
|
|
63
|
-
assert_equal 1, captured[:envelope].configs.length
|
|
64
|
-
assert_equal 'my.key', captured[:envelope].configs[0]['key']
|
|
65
|
-
assert_equal 'abc123', captured[:envelope].meta['version']
|
|
66
|
-
assert_equal :sse, captured[:source]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def test_headers_basic_auth_uses_1_prefix
|
|
70
|
-
prefab_options = OpenStruct.new(sse_api_urls: ['https://stream.example.com'], sdk_key: 'mykey')
|
|
71
|
-
config_loader = OpenStruct.new(highwater_mark: 0)
|
|
72
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
|
|
73
|
-
|
|
74
|
-
h = client.headers
|
|
75
|
-
|
|
76
|
-
assert_equal "Basic #{Base64.strict_encode64('1:mykey')}", h['Authorization']
|
|
77
|
-
assert_match(/\Asdk-ruby-/, h['X-Quonfig-SDK-Version'])
|
|
78
|
-
refute h.key?('X-Reforge-SDK-Version')
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def test_recovering_from_disconnection
|
|
82
|
-
server, = start_webrick_server(4567, DisconnectingEndpoint)
|
|
83
|
-
|
|
84
|
-
config_loader = OpenStruct.new(highwater_mark: 4)
|
|
85
|
-
|
|
86
|
-
prefab_options = OpenStruct.new(sse_api_urls: ['http://localhost:4567'], sdk_key: 'test')
|
|
87
|
-
last_event_id = nil
|
|
88
|
-
client = nil
|
|
89
|
-
|
|
90
|
-
begin
|
|
91
|
-
Thread.new do
|
|
92
|
-
server.start
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
sse_options = Quonfig::SSEConfigClient::Options.new(
|
|
96
|
-
sse_default_reconnect_time: 0.1
|
|
97
|
-
)
|
|
98
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader, sse_options)
|
|
99
|
-
|
|
100
|
-
client.start do |_configs, event, _source|
|
|
101
|
-
last_event_id = event.id.to_i
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
wait_for -> { last_event_id && last_event_id > 1 }
|
|
105
|
-
ensure
|
|
106
|
-
client.close
|
|
107
|
-
server.stop
|
|
108
|
-
|
|
109
|
-
refute_nil last_event_id, 'Expected to have received an event'
|
|
110
|
-
assert last_event_id > 1, 'Expected to have received multiple events (indicating a retry)'
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def test_recovering_from_an_error
|
|
115
|
-
log_output = StringIO.new
|
|
116
|
-
logger = Logger.new(log_output)
|
|
117
|
-
|
|
118
|
-
server, = start_webrick_server(4568, ErroringEndpoint)
|
|
119
|
-
|
|
120
|
-
config_loader = OpenStruct.new(highwater_mark: 4)
|
|
121
|
-
|
|
122
|
-
prefab_options = OpenStruct.new(sse_api_urls: ['http://localhost:4568'], sdk_key: 'test')
|
|
123
|
-
last_event_id = nil
|
|
124
|
-
client = nil
|
|
125
|
-
|
|
126
|
-
begin
|
|
127
|
-
Thread.new do
|
|
128
|
-
server.start
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
sse_options = Quonfig::SSEConfigClient::Options.new(
|
|
132
|
-
sse_default_reconnect_time: 0.1,
|
|
133
|
-
seconds_between_new_connection: 0.1,
|
|
134
|
-
sleep_delay_for_new_connection_check: 0.1,
|
|
135
|
-
errors_to_close_connection: [SSE::Errors::HTTPStatusError]
|
|
136
|
-
)
|
|
137
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader, sse_options, logger)
|
|
138
|
-
|
|
139
|
-
client.start do |_configs, event, _source|
|
|
140
|
-
last_event_id = event.id.to_i
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
wait_for -> { last_event_id && last_event_id > 2 }
|
|
144
|
-
ensure
|
|
145
|
-
server.stop
|
|
146
|
-
client.close
|
|
147
|
-
|
|
148
|
-
refute_nil last_event_id, 'Expected to have received an event'
|
|
149
|
-
assert last_event_id > 2, 'Expected to have received multiple events (indicating a reconnect)'
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
log_lines = log_output.string.split("\n")
|
|
153
|
-
|
|
154
|
-
assert_match(/SSE Streaming Connect/, log_lines[0])
|
|
155
|
-
assert_match(/SSE Streaming Error/, log_lines[1], 'Expected to have logged an error. If this starts failing after an ld-eventsource upgrade, you might need to tweak NUMBER_OF_FAILURES below')
|
|
156
|
-
assert_match(/Closing SSE connection/, log_lines[2])
|
|
157
|
-
assert_match(/Reconnecting SSE client/, log_lines[3])
|
|
158
|
-
assert_match(/SSE Streaming Connect/, log_lines[4])
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def start_webrick_server(port, endpoint_class)
|
|
162
|
-
log_string = StringIO.new
|
|
163
|
-
logger = WEBrick::Log.new(log_string)
|
|
164
|
-
server = WEBrick::HTTPServer.new(Port: port, Logger: logger, AccessLog: [])
|
|
165
|
-
server.mount '/api/v2/sse', endpoint_class
|
|
166
|
-
|
|
167
|
-
[server, log_string]
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
module SharedEndpointLogic
|
|
171
|
-
def event_id
|
|
172
|
-
@@event_id ||= 0
|
|
173
|
-
@@event_id += 1
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def setup_response(response)
|
|
177
|
-
response.status = 200
|
|
178
|
-
response['Content-Type'] = 'text/event-stream'
|
|
179
|
-
response['Cache-Control'] = 'no-cache'
|
|
180
|
-
response['Connection'] = 'keep-alive'
|
|
181
|
-
|
|
182
|
-
response.chunked = false
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
SAMPLE_JSON_PAYLOAD = '{"configs":[],"meta":{"version":"1","environment":"test"}}'
|
|
187
|
-
|
|
188
|
-
class DisconnectingEndpoint < WEBrick::HTTPServlet::AbstractServlet
|
|
189
|
-
include SharedEndpointLogic
|
|
190
|
-
|
|
191
|
-
def do_GET(_request, response)
|
|
192
|
-
setup_response(response)
|
|
193
|
-
|
|
194
|
-
output = response.body
|
|
195
|
-
|
|
196
|
-
output << "id: #{event_id}\n"
|
|
197
|
-
output << "event: message\n"
|
|
198
|
-
output << "data: #{SAMPLE_JSON_PAYLOAD}\n\n"
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
class ErroringEndpoint < WEBrick::HTTPServlet::AbstractServlet
|
|
203
|
-
include SharedEndpointLogic
|
|
204
|
-
NUMBER_OF_FAILURES = 5
|
|
205
|
-
|
|
206
|
-
def do_GET(_request, response)
|
|
207
|
-
setup_response(response)
|
|
208
|
-
|
|
209
|
-
output = response.body
|
|
210
|
-
|
|
211
|
-
output << "id: #{event_id}\n"
|
|
212
|
-
|
|
213
|
-
if event_id < NUMBER_OF_FAILURES
|
|
214
|
-
raise 'ErroringEndpoint' # This manifests as an SSE::Errors::HTTPStatusError
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
output << "event: message\n"
|
|
218
|
-
output << "data: #{SAMPLE_JSON_PAYLOAD}\n\n"
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
|
|
222
|
-
def test_empty_data_validation
|
|
223
|
-
# Unit test to verify that empty data is properly detected and handled
|
|
224
|
-
log_output = StringIO.new
|
|
225
|
-
logger = Logger.new(log_output)
|
|
226
|
-
|
|
227
|
-
# Test that empty event.data is detected
|
|
228
|
-
mock_event = OpenStruct.new(data: '')
|
|
229
|
-
mock_client = Minitest::Mock.new
|
|
230
|
-
mock_client.expect(:close, nil)
|
|
231
|
-
|
|
232
|
-
# Simulate the on_event handler logic
|
|
233
|
-
if mock_event.data.nil? || mock_event.data.empty?
|
|
234
|
-
logger.error "SSE Streaming Error: Received empty data for url http://test"
|
|
235
|
-
mock_client.close
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
log_lines = log_output.string.split("\n")
|
|
239
|
-
assert log_lines.any? { |line| line.include?('SSE Streaming Error') && line.include?('empty data') },
|
|
240
|
-
'Expected to have logged an error about empty data'
|
|
241
|
-
mock_client.verify
|
|
242
|
-
|
|
243
|
-
# Test that nil event.data is detected
|
|
244
|
-
log_output = StringIO.new
|
|
245
|
-
logger = Logger.new(log_output)
|
|
246
|
-
mock_event = OpenStruct.new(data: nil)
|
|
247
|
-
mock_client = Minitest::Mock.new
|
|
248
|
-
mock_client.expect(:close, nil)
|
|
249
|
-
|
|
250
|
-
if mock_event.data.nil? || mock_event.data.empty?
|
|
251
|
-
logger.error "SSE Streaming Error: Received empty data for url http://test"
|
|
252
|
-
mock_client.close
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
log_lines = log_output.string.split("\n")
|
|
256
|
-
assert log_lines.any? { |line| line.include?('SSE Streaming Error') && line.include?('empty data') },
|
|
257
|
-
'Expected to have logged an error about empty data for nil'
|
|
258
|
-
mock_client.verify
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def test_last_event_id_initialization
|
|
262
|
-
# Test with positive highwater_mark
|
|
263
|
-
config_loader = OpenStruct.new(highwater_mark: 42)
|
|
264
|
-
prefab_options = OpenStruct.new(sse_api_urls: ['http://localhost:4567'], sdk_key: 'test')
|
|
265
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
|
|
266
|
-
|
|
267
|
-
# Mock SSE::Client.new to capture the last_event_id argument
|
|
268
|
-
SSE::Client.stub :new, ->(*args, **kwargs, &block) {
|
|
269
|
-
assert_equal '42', kwargs[:last_event_id], 'Expected last_event_id to be "42"'
|
|
270
|
-
OpenStruct.new(closed?: false, close: nil)
|
|
271
|
-
} do
|
|
272
|
-
client.connect { |_configs, _event, _source| }
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
# Test with nil highwater_mark
|
|
276
|
-
config_loader = OpenStruct.new(highwater_mark: nil)
|
|
277
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
|
|
278
|
-
|
|
279
|
-
SSE::Client.stub :new, ->(*args, **kwargs, &block) {
|
|
280
|
-
assert_nil kwargs[:last_event_id], 'Expected last_event_id to be nil when highwater_mark is nil'
|
|
281
|
-
OpenStruct.new(closed?: false, close: nil)
|
|
282
|
-
} do
|
|
283
|
-
client.connect { |_configs, _event, _source| }
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# Test with zero highwater_mark
|
|
287
|
-
config_loader = OpenStruct.new(highwater_mark: 0)
|
|
288
|
-
client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
|
|
289
|
-
|
|
290
|
-
SSE::Client.stub :new, ->(*args, **kwargs, &block) {
|
|
291
|
-
assert_nil kwargs[:last_event_id], 'Expected last_event_id to be nil when highwater_mark is 0'
|
|
292
|
-
OpenStruct.new(closed?: false, close: nil)
|
|
293
|
-
} do
|
|
294
|
-
client.connect { |_configs, _event, _source| }
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
end
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
require 'logger'
|
|
5
|
-
require 'stringio'
|
|
6
|
-
|
|
7
|
-
# Verifies Quonfig::StdlibFormatter — an adapter that plugs Quonfig's dynamic
|
|
8
|
-
# log-level evaluation into Ruby's built-in ::Logger via the
|
|
9
|
-
# `logger.formatter = <proc>` contract. The formatter is a callable with
|
|
10
|
-
# signature (severity, datetime, progname, msg) -> String. Returning an empty
|
|
11
|
-
# string suppresses the log line (Logger writes exactly what the formatter
|
|
12
|
-
# returns).
|
|
13
|
-
#
|
|
14
|
-
# The Ruby stdlib severity strings ("DEBUG", "INFO", "WARN", "ERROR", "FATAL",
|
|
15
|
-
# "ANY") are mapped to the quonfig level symbols used by the evaluator
|
|
16
|
-
# (:debug, :info, :warn, :error, :fatal). progname flows into the evaluator
|
|
17
|
-
# under `quonfig-sdk-logging.key` verbatim, no normalization — matching the
|
|
18
|
-
# SemanticLoggerFilter.
|
|
19
|
-
class TestStdlibFormatter < Minitest::Test
|
|
20
|
-
LOG_LEVEL_KEY = 'log-level.my-app'
|
|
21
|
-
|
|
22
|
-
# Build a minimal config fixture: a string config whose single rule always
|
|
23
|
-
# resolves to `level`. Mirrors the shape used in test_should_log.rb so we
|
|
24
|
-
# exercise the full get()/resolver/evaluator path rather than stubbing.
|
|
25
|
-
def make_log_level_config(key:, level:)
|
|
26
|
-
{
|
|
27
|
-
'id' => '1',
|
|
28
|
-
'key' => key,
|
|
29
|
-
'type' => 'config',
|
|
30
|
-
'valueType' => 'string',
|
|
31
|
-
'sendToClientSdk' => false,
|
|
32
|
-
'default' => {
|
|
33
|
-
'rules' => [
|
|
34
|
-
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
35
|
-
'value' => { 'type' => 'string', 'value' => level } }
|
|
36
|
-
]
|
|
37
|
-
},
|
|
38
|
-
'environment' => nil
|
|
39
|
-
}
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def store_with(*configs)
|
|
43
|
-
store = Quonfig::ConfigStore.new
|
|
44
|
-
configs.each { |c| store.set(c['key'], c) }
|
|
45
|
-
store
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def client_with(store, **options)
|
|
49
|
-
Quonfig::Client.new(Quonfig::Options.new(**options), store: store)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# ---- error behavior --------------------------------------------------
|
|
53
|
-
|
|
54
|
-
def test_stdlib_formatter_raises_without_logger_key
|
|
55
|
-
client = client_with(Quonfig::ConfigStore.new)
|
|
56
|
-
err = assert_raises(Quonfig::Error) { client.stdlib_formatter }
|
|
57
|
-
assert_match(/logger_key/, err.message)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# ---- proc contract ---------------------------------------------------
|
|
61
|
-
|
|
62
|
-
def test_stdlib_formatter_returns_a_callable_with_4_arity
|
|
63
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'info'))
|
|
64
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
65
|
-
|
|
66
|
-
formatter = client.stdlib_formatter
|
|
67
|
-
assert_respond_to formatter, :call
|
|
68
|
-
# Ruby Logger invokes formatter with exactly 4 args; a Proc takes any arity,
|
|
69
|
-
# but arity should be 4 so it matches the Logger contract faithfully.
|
|
70
|
-
assert_equal 4, formatter.arity
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# ---- gating ----------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
def test_formatter_drops_below_configured_level_returning_empty_string
|
|
76
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
77
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
78
|
-
|
|
79
|
-
formatter = client.stdlib_formatter
|
|
80
|
-
now = Time.utc(2026, 4, 22, 12, 0, 0)
|
|
81
|
-
|
|
82
|
-
# Below configured warn — suppressed (empty string, which Logger writes
|
|
83
|
-
# as zero bytes, effectively dropping the line).
|
|
84
|
-
assert_equal '', formatter.call('DEBUG', now, 'MyApp::Foo', 'hi')
|
|
85
|
-
assert_equal '', formatter.call('INFO', now, 'MyApp::Foo', 'hi')
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def test_formatter_emits_at_or_above_configured_level
|
|
89
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
90
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
91
|
-
|
|
92
|
-
formatter = client.stdlib_formatter
|
|
93
|
-
now = Time.utc(2026, 4, 22, 12, 0, 0)
|
|
94
|
-
|
|
95
|
-
refute_equal '', formatter.call('WARN', now, 'MyApp::Foo', 'hi')
|
|
96
|
-
refute_equal '', formatter.call('ERROR', now, 'MyApp::Foo', 'hi')
|
|
97
|
-
refute_equal '', formatter.call('FATAL', now, 'MyApp::Foo', 'hi')
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def test_formatter_default_format_includes_severity_time_progname_msg
|
|
101
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'debug'))
|
|
102
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
103
|
-
|
|
104
|
-
formatter = client.stdlib_formatter
|
|
105
|
-
now = Time.utc(2026, 4, 22, 12, 34, 56)
|
|
106
|
-
|
|
107
|
-
out = formatter.call('INFO', now, 'MyApp::Foo', 'hello world')
|
|
108
|
-
assert_includes out, 'INFO'
|
|
109
|
-
assert_includes out, 'MyApp::Foo'
|
|
110
|
-
assert_includes out, 'hello world'
|
|
111
|
-
assert out.end_with?("\n"), "formatter output should end with a newline"
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# ---- progname -> context ---------------------------------------------
|
|
115
|
-
|
|
116
|
-
def test_progname_flows_into_logger_context_verbatim
|
|
117
|
-
# We capture the context the formatter passes to should_log? by replacing
|
|
118
|
-
# the client's should_log? — avoids building a matcher fixture and lets
|
|
119
|
-
# us assert the exact context shape the adapter builds.
|
|
120
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
121
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
122
|
-
|
|
123
|
-
captured = []
|
|
124
|
-
client.define_singleton_method(:should_log?) do |logger_path:, desired_level:, contexts: {}|
|
|
125
|
-
captured << { logger_path: logger_path, desired_level: desired_level }
|
|
126
|
-
true
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
formatter = client.stdlib_formatter
|
|
130
|
-
formatter.call('INFO', Time.now, 'HTMLParser', 'x')
|
|
131
|
-
|
|
132
|
-
assert_equal 1, captured.size
|
|
133
|
-
assert_equal 'HTMLParser', captured.first[:logger_path]
|
|
134
|
-
assert_equal :info, captured.first[:desired_level]
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def test_explicit_logger_name_option_overrides_progname
|
|
138
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
139
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
140
|
-
|
|
141
|
-
captured = []
|
|
142
|
-
client.define_singleton_method(:should_log?) do |logger_path:, desired_level:, contexts: {}|
|
|
143
|
-
captured << logger_path
|
|
144
|
-
true
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
formatter = client.stdlib_formatter(logger_name: 'ExplicitName')
|
|
148
|
-
# Pass a different progname — the explicit logger_name should win.
|
|
149
|
-
formatter.call('INFO', Time.now, 'DifferentProgname', 'x')
|
|
150
|
-
|
|
151
|
-
assert_equal 'ExplicitName', captured.first
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def test_nil_progname_and_no_logger_name_falls_through_as_nil
|
|
155
|
-
# Ruby's Logger can invoke the formatter with a nil progname; the adapter
|
|
156
|
-
# should not crash. We pass through nil, and should_log? sees nil. The
|
|
157
|
-
# evaluator treats missing context values as absent.
|
|
158
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
159
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
160
|
-
|
|
161
|
-
captured = []
|
|
162
|
-
client.define_singleton_method(:should_log?) do |logger_path:, desired_level:, contexts: {}|
|
|
163
|
-
captured << logger_path
|
|
164
|
-
true
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
formatter = client.stdlib_formatter
|
|
168
|
-
formatter.call('INFO', Time.now, nil, 'x')
|
|
169
|
-
|
|
170
|
-
assert_nil captured.first
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# ---- end-to-end with a real ::Logger ---------------------------------
|
|
174
|
-
|
|
175
|
-
def test_end_to_end_real_logger_drops_below_and_emits_above
|
|
176
|
-
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
177
|
-
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
178
|
-
|
|
179
|
-
io = StringIO.new
|
|
180
|
-
logger = ::Logger.new(io)
|
|
181
|
-
# stdlib Logger has its own static level; set it permissive so our
|
|
182
|
-
# formatter is the thing actually gating output.
|
|
183
|
-
logger.level = ::Logger::DEBUG
|
|
184
|
-
logger.formatter = client.stdlib_formatter(logger_name: 'MyApp::Svc')
|
|
185
|
-
|
|
186
|
-
logger.info 'should be dropped'
|
|
187
|
-
logger.warn 'should be emitted'
|
|
188
|
-
logger.error 'also emitted'
|
|
189
|
-
|
|
190
|
-
out = io.string
|
|
191
|
-
refute_includes out, 'should be dropped'
|
|
192
|
-
assert_includes out, 'should be emitted'
|
|
193
|
-
assert_includes out, 'also emitted'
|
|
194
|
-
end
|
|
195
|
-
end
|