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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +94 -0
  4. data/lib/quonfig/caching_http_connection.rb +3 -3
  5. data/lib/quonfig/client.rb +22 -27
  6. data/lib/quonfig/config_loader.rb +5 -1
  7. data/lib/quonfig/config_store.rb +10 -6
  8. data/lib/quonfig/context.rb +5 -4
  9. data/lib/quonfig/datadir.rb +4 -10
  10. data/lib/quonfig/dev_context.rb +4 -2
  11. data/lib/quonfig/duration.rb +2 -2
  12. data/lib/quonfig/encryption.rb +12 -16
  13. data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
  14. data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
  15. data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
  16. data/lib/quonfig/errors/missing_environment_error.rb +1 -1
  17. data/lib/quonfig/errors/uninitialized_error.rb +1 -1
  18. data/lib/quonfig/evaluation.rb +11 -8
  19. data/lib/quonfig/evaluator.rb +34 -37
  20. data/lib/quonfig/fixed_size_hash.rb +1 -0
  21. data/lib/quonfig/http_connection.rb +2 -4
  22. data/lib/quonfig/internal_logger.rb +63 -27
  23. data/lib/quonfig/murmer3.rb +2 -2
  24. data/lib/quonfig/options.rb +62 -75
  25. data/lib/quonfig/periodic_sync.rb +1 -1
  26. data/lib/quonfig/quonfig.rb +3 -3
  27. data/lib/quonfig/reason.rb +2 -1
  28. data/lib/quonfig/resolver.rb +8 -9
  29. data/lib/quonfig/semantic_logger_filter.rb +4 -3
  30. data/lib/quonfig/semver.rb +6 -8
  31. data/lib/quonfig/sse_config_client.rb +14 -15
  32. data/lib/quonfig/stdlib_formatter.rb +3 -3
  33. data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
  34. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
  35. data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
  36. data/lib/quonfig/time_helpers.rb +2 -0
  37. data/lib/quonfig/version.rb +5 -0
  38. data/lib/quonfig.rb +2 -1
  39. data/quonfig.gemspec +29 -165
  40. metadata +24 -193
  41. data/.claude/rules/constitution.md +0 -81
  42. data/.claude/rules/git-safety.md +0 -11
  43. data/.claude/rules/issue-tracking.md +0 -13
  44. data/.claude/rules/testing-workflow.md +0 -28
  45. data/.envrc.sample +0 -3
  46. data/.github/CODEOWNERS +0 -2
  47. data/.github/pull_request_template.md +0 -8
  48. data/.github/workflows/release.yml +0 -49
  49. data/.github/workflows/ruby.yml +0 -60
  50. data/.github/workflows/test.yaml +0 -40
  51. data/.rubocop.yml +0 -13
  52. data/.tool-versions +0 -1
  53. data/CLAUDE.md +0 -29
  54. data/CODEOWNERS +0 -1
  55. data/Gemfile +0 -26
  56. data/Gemfile.lock +0 -177
  57. data/Rakefile +0 -64
  58. data/VERSION +0 -1
  59. data/dev/allocation_stats +0 -60
  60. data/dev/benchmark +0 -40
  61. data/dev/console +0 -12
  62. data/dev/script_setup.rb +0 -18
  63. data/test/fixtures/datafile.json +0 -87
  64. data/test/integration/test_context_precedence.rb +0 -112
  65. data/test/integration/test_datadir_environment.rb +0 -54
  66. data/test/integration/test_dev_overrides.rb +0 -40
  67. data/test/integration/test_enabled.rb +0 -478
  68. data/test/integration/test_enabled_with_contexts.rb +0 -64
  69. data/test/integration/test_get.rb +0 -136
  70. data/test/integration/test_get_feature_flag.rb +0 -28
  71. data/test/integration/test_get_or_raise.rb +0 -60
  72. data/test/integration/test_get_weighted_values.rb +0 -34
  73. data/test/integration/test_helpers.rb +0 -667
  74. data/test/integration/test_helpers_test.rb +0 -73
  75. data/test/integration/test_post.rb +0 -44
  76. data/test/integration/test_telemetry.rb +0 -170
  77. data/test/support/common_helpers.rb +0 -106
  78. data/test/support/mock_base_client.rb +0 -27
  79. data/test/support/mock_config_loader.rb +0 -1
  80. data/test/test_bound_client.rb +0 -109
  81. data/test/test_caching_http_connection.rb +0 -218
  82. data/test/test_client.rb +0 -255
  83. data/test/test_client_network_mode.rb +0 -136
  84. data/test/test_client_telemetry.rb +0 -175
  85. data/test/test_config_loader.rb +0 -70
  86. data/test/test_context.rb +0 -139
  87. data/test/test_context_shape.rb +0 -37
  88. data/test/test_context_shape_aggregator.rb +0 -126
  89. data/test/test_datadir.rb +0 -203
  90. data/test/test_details_getters.rb +0 -242
  91. data/test/test_dev_context.rb +0 -163
  92. data/test/test_duration.rb +0 -37
  93. data/test/test_encryption.rb +0 -16
  94. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  95. data/test/test_evaluator.rb +0 -285
  96. data/test/test_example_contexts_aggregator.rb +0 -119
  97. data/test/test_exponential_backoff.rb +0 -44
  98. data/test/test_fixed_size_hash.rb +0 -119
  99. data/test/test_helper.rb +0 -17
  100. data/test/test_http_connection.rb +0 -81
  101. data/test/test_internal_logger.rb +0 -34
  102. data/test/test_options.rb +0 -198
  103. data/test/test_rate_limit_cache.rb +0 -44
  104. data/test/test_reason.rb +0 -79
  105. data/test/test_rename.rb +0 -65
  106. data/test/test_resolver.rb +0 -291
  107. data/test/test_semantic_logger_filter.rb +0 -144
  108. data/test/test_semver.rb +0 -108
  109. data/test/test_should_log.rb +0 -186
  110. data/test/test_sse_config_client.rb +0 -297
  111. data/test/test_stdlib_formatter.rb +0 -195
  112. data/test/test_telemetry_reporter.rb +0 -209
  113. data/test/test_typed_getters.rb +0 -131
  114. data/test/test_types.rb +0 -141
  115. data/test/test_weighted_value_resolver.rb +0 -84
@@ -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