quonfig 0.0.9 → 0.0.11

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +4 -4
  4. data/lib/quonfig/evaluation_details.rb +60 -0
  5. data/lib/quonfig/options.rb +37 -16
  6. data/lib/quonfig/sse_config_client.rb +1 -1
  7. data/lib/quonfig/version.rb +5 -0
  8. data/lib/quonfig.rb +2 -1
  9. data/quonfig.gemspec +30 -163
  10. metadata +29 -182
  11. data/.claude/rules/constitution.md +0 -81
  12. data/.claude/rules/git-safety.md +0 -11
  13. data/.claude/rules/issue-tracking.md +0 -13
  14. data/.claude/rules/testing-workflow.md +0 -28
  15. data/.envrc.sample +0 -3
  16. data/.github/CODEOWNERS +0 -2
  17. data/.github/pull_request_template.md +0 -8
  18. data/.github/workflows/release.yml +0 -49
  19. data/.github/workflows/ruby.yml +0 -60
  20. data/.github/workflows/test.yaml +0 -40
  21. data/.rubocop.yml +0 -13
  22. data/.tool-versions +0 -1
  23. data/CLAUDE.md +0 -29
  24. data/CODEOWNERS +0 -1
  25. data/Gemfile +0 -26
  26. data/Gemfile.lock +0 -177
  27. data/Rakefile +0 -64
  28. data/VERSION +0 -1
  29. data/dev/allocation_stats +0 -60
  30. data/dev/benchmark +0 -40
  31. data/dev/console +0 -12
  32. data/dev/script_setup.rb +0 -18
  33. data/test/fixtures/datafile.json +0 -87
  34. data/test/integration/test_context_precedence.rb +0 -112
  35. data/test/integration/test_datadir_environment.rb +0 -54
  36. data/test/integration/test_dev_overrides.rb +0 -40
  37. data/test/integration/test_enabled.rb +0 -478
  38. data/test/integration/test_enabled_with_contexts.rb +0 -64
  39. data/test/integration/test_get.rb +0 -136
  40. data/test/integration/test_get_feature_flag.rb +0 -28
  41. data/test/integration/test_get_or_raise.rb +0 -60
  42. data/test/integration/test_get_weighted_values.rb +0 -34
  43. data/test/integration/test_helpers.rb +0 -667
  44. data/test/integration/test_helpers_test.rb +0 -73
  45. data/test/integration/test_post.rb +0 -44
  46. data/test/integration/test_telemetry.rb +0 -170
  47. data/test/support/common_helpers.rb +0 -106
  48. data/test/support/mock_base_client.rb +0 -27
  49. data/test/support/mock_config_loader.rb +0 -1
  50. data/test/test_bound_client.rb +0 -109
  51. data/test/test_caching_http_connection.rb +0 -218
  52. data/test/test_client.rb +0 -255
  53. data/test/test_client_network_mode.rb +0 -136
  54. data/test/test_client_telemetry.rb +0 -175
  55. data/test/test_config_loader.rb +0 -70
  56. data/test/test_context.rb +0 -139
  57. data/test/test_context_shape.rb +0 -37
  58. data/test/test_context_shape_aggregator.rb +0 -126
  59. data/test/test_datadir.rb +0 -203
  60. data/test/test_dev_context.rb +0 -163
  61. data/test/test_duration.rb +0 -37
  62. data/test/test_encryption.rb +0 -16
  63. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  64. data/test/test_evaluator.rb +0 -285
  65. data/test/test_example_contexts_aggregator.rb +0 -119
  66. data/test/test_exponential_backoff.rb +0 -44
  67. data/test/test_fixed_size_hash.rb +0 -119
  68. data/test/test_helper.rb +0 -17
  69. data/test/test_http_connection.rb +0 -79
  70. data/test/test_internal_logger.rb +0 -34
  71. data/test/test_options.rb +0 -167
  72. data/test/test_rate_limit_cache.rb +0 -44
  73. data/test/test_reason.rb +0 -79
  74. data/test/test_rename.rb +0 -65
  75. data/test/test_resolver.rb +0 -291
  76. data/test/test_semantic_logger_filter.rb +0 -144
  77. data/test/test_semver.rb +0 -108
  78. data/test/test_should_log.rb +0 -186
  79. data/test/test_sse_config_client.rb +0 -297
  80. data/test/test_stdlib_formatter.rb +0 -195
  81. data/test/test_telemetry_reporter.rb +0 -209
  82. data/test/test_typed_getters.rb +0 -131
  83. data/test/test_types.rb +0 -141
  84. data/test/test_weighted_value_resolver.rb +0 -84
@@ -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
@@ -1,209 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class TestTelemetryReporter < Minitest::Test
6
- # Minimal stand-in for Quonfig::HttpConnection that records POSTs.
7
- class FakeHttpConnection
8
- FakeResponse = Struct.new(:status)
9
-
10
- attr_reader :posts
11
-
12
- def initialize
13
- @posts = []
14
- end
15
-
16
- def post(path, body)
17
- @posts << [path, body]
18
- FakeResponse.new(200)
19
- end
20
- end
21
-
22
- def make_options(telemetry_destination: 'https://telemetry.example.com',
23
- sdk_key: 'qf_sk_development_abc_deadbeef')
24
- # Build minimal Options bypassing env-var lookups via explicit overrides.
25
- Quonfig::Options.new(
26
- sdk_key: sdk_key,
27
- environment: 'development',
28
- api_urls: ['https://primary.example.com'],
29
- enable_sse: false,
30
- enable_polling: false,
31
- on_init_failure: Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN,
32
- context_upload_mode: :periodic_example
33
- ).tap do |opts|
34
- opts.instance_variable_set(:@telemetry_destination, telemetry_destination)
35
- end
36
- end
37
-
38
- def test_sync_posts_combined_events_in_api_telemetry_wire_shape
39
- options = make_options
40
- fake = FakeHttpConnection.new
41
-
42
- shape_agg = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 100)
43
- example_agg = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 100)
44
-
45
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
46
- options: options,
47
- instance_hash: 'fake-instance-hash',
48
- context_shape_aggregator: shape_agg,
49
- example_contexts_aggregator: example_agg,
50
- http_connection: fake
51
- )
52
-
53
- ctx = Quonfig::Context.new('user' => { 'key' => 'abc', 'age' => 33 })
54
- shape_agg.push(ctx)
55
- example_agg.record(ctx)
56
-
57
- reporter.sync
58
-
59
- assert_equal 1, fake.posts.size
60
- path, body = fake.posts.first
61
- assert_equal '/api/v1/telemetry/', path
62
-
63
- # Wire shape: TelemetryEventsSchema
64
- assert_equal 'fake-instance-hash', body['instanceHash']
65
- assert_kind_of Array, body['events']
66
- assert_equal 2, body['events'].size
67
-
68
- shape_event = body['events'].find { |e| e.key?('contextShapes') }
69
- example_event = body['events'].find { |e| e.key?('exampleContexts') }
70
-
71
- refute_nil shape_event
72
- refute_nil example_event
73
-
74
- # ContextShapesSchema: { shapes: [{ name, fieldTypes }] }
75
- shapes = shape_event['contextShapes']['shapes']
76
- assert_equal 1, shapes.size
77
- assert_equal 'user', shapes[0]['name']
78
- assert_equal({ 'key' => 2, 'age' => 1 }, shapes[0]['fieldTypes'])
79
-
80
- # ExampleContextsSchema: { examples: [{ timestamp, contextSet: { contexts: [...] } }] }
81
- examples = example_event['exampleContexts']['examples']
82
- assert_equal 1, examples.size
83
- assert_kind_of Integer, examples[0]['timestamp']
84
- contexts_list = examples[0]['contextSet']['contexts']
85
- assert_equal 'user', contexts_list[0]['type']
86
- assert_equal 'abc', contexts_list[0]['values']['key']
87
- assert_equal 33, contexts_list[0]['values']['age']
88
- end
89
-
90
- def test_sync_noop_when_aggregators_empty
91
- fake = FakeHttpConnection.new
92
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
93
- options: make_options,
94
- instance_hash: 'h',
95
- context_shape_aggregator: Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 10),
96
- example_contexts_aggregator: Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10),
97
- http_connection: fake
98
- )
99
-
100
- reporter.sync
101
- assert_equal 0, fake.posts.size
102
- end
103
-
104
- def test_enabled_requires_sdk_key_and_destination
105
- options = make_options(sdk_key: '')
106
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
107
- options: options,
108
- instance_hash: 'h',
109
- context_shape_aggregator: Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 10)
110
- )
111
- refute reporter.enabled?
112
- end
113
-
114
- def test_record_feeds_both_aggregators
115
- fake = FakeHttpConnection.new
116
- shape_agg = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 100)
117
- example_agg = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 100)
118
-
119
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
120
- options: make_options,
121
- instance_hash: 'h',
122
- context_shape_aggregator: shape_agg,
123
- example_contexts_aggregator: example_agg,
124
- http_connection: fake
125
- )
126
-
127
- reporter.record(Quonfig::Context.new('user' => { 'key' => 'zzz' }))
128
-
129
- refute_nil shape_agg.drain_event
130
- refute_nil example_agg.drain_event
131
- end
132
-
133
- def test_sync_posts_evaluation_summaries_event
134
- fake = FakeHttpConnection.new
135
- summaries_agg = Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: 100)
136
-
137
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
138
- options: make_options,
139
- instance_hash: 'h',
140
- evaluation_summaries_aggregator: summaries_agg,
141
- http_connection: fake
142
- )
143
-
144
- summaries_agg.record(
145
- config_id: 'cid',
146
- config_key: 'my-key',
147
- config_type: 'config',
148
- conditional_value_index: 0,
149
- selected_value: 'v',
150
- reason: 1
151
- )
152
-
153
- reporter.sync
154
-
155
- assert_equal 1, fake.posts.size
156
- _path, body = fake.posts.first
157
-
158
- summaries_event = body['events'].find { |e| e.key?('summaries') }
159
- refute_nil summaries_event, 'expected a summaries event in the payload'
160
-
161
- inner = summaries_event['summaries']
162
- assert_kind_of Array, inner['summaries']
163
- counter = inner['summaries'][0]['counters'][0]
164
- assert_equal 1, counter['count']
165
- assert_equal 1, counter['reason']
166
- end
167
-
168
- def test_at_exit_final_drain_posts_pending_batch
169
- fake = FakeHttpConnection.new
170
- shape_agg = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 100)
171
-
172
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
173
- options: make_options,
174
- instance_hash: 'h',
175
- context_shape_aggregator: shape_agg,
176
- http_connection: fake
177
- )
178
-
179
- # Simulate evaluations accumulating between sync cycles.
180
- shape_agg.push(Quonfig::Context.new('user' => { 'key' => 'x' }))
181
-
182
- # Simulate a Rails SIGTERM: process exits without Client#stop being
183
- # called. The reporter's at_exit handler must flush the pending batch.
184
- reporter.send(:final_drain_on_exit)
185
-
186
- assert_equal 1, fake.posts.size
187
- _path, body = fake.posts.first
188
- refute_nil body['events'].find { |e| e.key?('contextShapes') }
189
- end
190
-
191
- def test_at_exit_handler_registered_on_start
192
- # Guards against regressing the Kernel.at_exit hookup that catches
193
- # Rails / Passenger worker SIGTERMs where Client#stop isn't called.
194
- fake = FakeHttpConnection.new
195
- reporter = Quonfig::Telemetry::TelemetryReporter.new(
196
- options: make_options,
197
- instance_hash: 'h',
198
- context_shape_aggregator: Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 10),
199
- http_connection: fake,
200
- sync_interval: 999 # avoid the background thread firing during the test
201
- )
202
-
203
- refute reporter.at_exit_registered?, 'not registered before start'
204
- reporter.start
205
- assert reporter.at_exit_registered?, 'registered after start'
206
- ensure
207
- reporter&.stop
208
- end
209
- end