quonfig 0.0.6 → 0.0.8

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/client.rb +109 -2
  5. data/lib/quonfig/context.rb +10 -1
  6. data/lib/quonfig/datadir.rb +2 -4
  7. data/lib/quonfig/errors/decryption_error.rb +20 -0
  8. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  9. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  10. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  11. data/lib/quonfig/evaluator.rb +64 -2
  12. data/lib/quonfig/http_connection.rb +1 -1
  13. data/lib/quonfig/resolver.rb +187 -2
  14. data/lib/quonfig/stdlib_formatter.rb +95 -0
  15. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  16. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  17. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  18. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  19. data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
  20. data/lib/quonfig.rb +8 -0
  21. data/quonfig.gemspec +20 -4
  22. data/test/integration/test_context_precedence.rb +35 -117
  23. data/test/integration/test_datadir_environment.rb +15 -37
  24. data/test/integration/test_enabled.rb +157 -463
  25. data/test/integration/test_enabled_with_contexts.rb +19 -49
  26. data/test/integration/test_get.rb +43 -131
  27. data/test/integration/test_get_feature_flag.rb +7 -13
  28. data/test/integration/test_get_or_raise.rb +19 -45
  29. data/test/integration/test_get_weighted_values.rb +9 -4
  30. data/test/integration/test_helpers.rb +499 -4
  31. data/test/integration/test_post.rb +15 -5
  32. data/test/integration/test_telemetry.rb +63 -21
  33. data/test/test_client_telemetry.rb +132 -0
  34. data/test/test_context.rb +4 -1
  35. data/test/test_context_shape.rb +37 -0
  36. data/test/test_context_shape_aggregator.rb +126 -0
  37. data/test/test_datadir.rb +6 -2
  38. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  39. data/test/test_example_contexts_aggregator.rb +119 -0
  40. data/test/test_http_connection.rb +1 -1
  41. data/test/test_resolver.rb +149 -2
  42. data/test/test_should_log.rb +186 -0
  43. data/test/test_stdlib_formatter.rb +195 -0
  44. data/test/test_telemetry_reporter.rb +209 -0
  45. metadata +19 -3
  46. data/scripts/generate_integration_tests.rb +0 -362
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class TestEvaluationSummariesAggregator < Minitest::Test
6
+ def make_aggregator(max_keys: 100)
7
+ Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max_keys)
8
+ end
9
+
10
+ def record_eval(agg, overrides = {})
11
+ defaults = {
12
+ config_id: 'c1',
13
+ config_key: 'my-test-key',
14
+ config_type: 'config',
15
+ conditional_value_index: 0,
16
+ weighted_value_index: nil,
17
+ selected_value: 'hello',
18
+ reason: 1
19
+ }
20
+ agg.record(**defaults.merge(overrides))
21
+ end
22
+
23
+ def test_record_dedupes_identical_evaluations_into_one_counter
24
+ agg = make_aggregator
25
+
26
+ record_eval(agg)
27
+ record_eval(agg)
28
+ record_eval(agg)
29
+
30
+ event = agg.drain_event
31
+ refute_nil event
32
+ summaries = event['summaries']['summaries']
33
+ assert_equal 1, summaries.size
34
+
35
+ counters = summaries[0]['counters']
36
+ assert_equal 1, counters.size
37
+ assert_equal 3, counters[0]['count']
38
+ end
39
+
40
+ def test_record_creates_separate_counters_for_different_rule_indexes
41
+ agg = make_aggregator
42
+
43
+ record_eval(agg, conditional_value_index: 0)
44
+ record_eval(agg, conditional_value_index: 1)
45
+ record_eval(agg, conditional_value_index: 1)
46
+
47
+ summaries = agg.drain_event['summaries']['summaries']
48
+ assert_equal 1, summaries.size
49
+
50
+ counters = summaries[0]['counters']
51
+ assert_equal 2, counters.size
52
+
53
+ by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['count'] }
54
+ assert_equal 1, by_idx[0]
55
+ assert_equal 2, by_idx[1]
56
+ end
57
+
58
+ def test_record_groups_by_config_key_and_type
59
+ agg = make_aggregator
60
+
61
+ record_eval(agg, config_key: 'alpha', config_type: 'config')
62
+ record_eval(agg, config_key: 'alpha', config_type: 'config')
63
+ record_eval(agg, config_key: 'beta', config_type: 'feature_flag')
64
+
65
+ summaries = agg.drain_event['summaries']['summaries']
66
+ assert_equal 2, summaries.size
67
+
68
+ by_key = summaries.each_with_object({}) { |s, h| h[s['key']] = s }
69
+ assert_equal 'config', by_key['alpha']['type']
70
+ assert_equal 'feature_flag', by_key['beta']['type']
71
+ assert_equal 2, by_key['alpha']['counters'][0]['count']
72
+ assert_equal 1, by_key['beta']['counters'][0]['count']
73
+ end
74
+
75
+ def test_drain_event_nil_when_empty
76
+ agg = make_aggregator
77
+ assert_nil agg.drain_event
78
+ end
79
+
80
+ def test_drain_clears_state
81
+ agg = make_aggregator
82
+ record_eval(agg)
83
+
84
+ refute_nil agg.drain_event
85
+ assert_nil agg.drain_event, 'second drain with no new records should be nil'
86
+ end
87
+
88
+ def test_drain_event_wire_shape
89
+ agg = make_aggregator
90
+
91
+ record_eval(agg,
92
+ config_id: 'cid-42',
93
+ config_key: 'my-test-key',
94
+ config_type: 'config',
95
+ conditional_value_index: 1,
96
+ weighted_value_index: nil,
97
+ selected_value: 'my-test-value',
98
+ reason: 2)
99
+
100
+ event = agg.drain_event
101
+ refute_nil event
102
+ assert event.key?('summaries'), 'top-level event key is summaries'
103
+
104
+ inner = event['summaries']
105
+ assert_kind_of Integer, inner['start']
106
+ assert_kind_of Integer, inner['end']
107
+ assert inner['end'] >= inner['start']
108
+
109
+ summary = inner['summaries'][0]
110
+ assert_equal 'my-test-key', summary['key']
111
+ assert_equal 'config', summary['type']
112
+
113
+ counter = summary['counters'][0]
114
+ assert_equal 'cid-42', counter['configId']
115
+ assert_equal 1, counter['conditionalValueIndex']
116
+ assert_equal 0, counter['configRowIndex']
117
+ assert_equal 1, counter['count']
118
+ assert_equal 2, counter['reason']
119
+ assert_equal({ 'string' => 'my-test-value' }, counter['selectedValue'])
120
+ refute counter.key?('weightedValueIndex'),
121
+ 'weightedValueIndex omitted when nil'
122
+ end
123
+
124
+ def test_selected_value_wrapper_keys_match_prefab_shape
125
+ agg = make_aggregator
126
+
127
+ record_eval(agg, selected_value: true, conditional_value_index: 0)
128
+ record_eval(agg, selected_value: 3, conditional_value_index: 1)
129
+ record_eval(agg, selected_value: 1.5, conditional_value_index: 2)
130
+ record_eval(agg, selected_value: 'hi', conditional_value_index: 3)
131
+ record_eval(agg, selected_value: %w[a b], conditional_value_index: 4)
132
+
133
+ counters = agg.drain_event['summaries']['summaries'][0]['counters']
134
+ by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['selectedValue'] }
135
+
136
+ assert_equal({ 'bool' => true }, by_idx[0])
137
+ assert_equal({ 'int' => 3 }, by_idx[1])
138
+ assert_equal({ 'double' => 1.5 }, by_idx[2])
139
+ assert_equal({ 'string' => 'hi' }, by_idx[3])
140
+ assert_equal({ 'stringList' => %w[a b] }, by_idx[4])
141
+ end
142
+
143
+ def test_weighted_value_index_included_when_present
144
+ agg = make_aggregator
145
+
146
+ record_eval(agg, weighted_value_index: 2, reason: 3)
147
+
148
+ counter = agg.drain_event['summaries']['summaries'][0]['counters'][0]
149
+ assert_equal 2, counter['weightedValueIndex']
150
+ assert_equal 3, counter['reason']
151
+ end
152
+
153
+ def test_log_level_evaluations_are_excluded
154
+ agg = make_aggregator
155
+
156
+ record_eval(agg, config_type: 'log_level')
157
+
158
+ assert_nil agg.drain_event
159
+ end
160
+
161
+ def test_record_caps_at_max_keys
162
+ agg = make_aggregator(max_keys: 2)
163
+
164
+ record_eval(agg, config_key: 'a')
165
+ record_eval(agg, config_key: 'b')
166
+ record_eval(agg, config_key: 'c') # dropped — at cap
167
+
168
+ summaries = agg.drain_event['summaries']['summaries']
169
+ keys = summaries.map { |s| s['key'] }.sort
170
+ assert_equal %w[a b], keys
171
+ end
172
+
173
+ def test_noop_when_max_keys_zero
174
+ agg = make_aggregator(max_keys: 0)
175
+
176
+ record_eval(agg)
177
+
178
+ assert_nil agg.drain_event
179
+ end
180
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'timecop'
5
+
6
+ class TestExampleContextsAggregator < Minitest::Test
7
+ def test_record_dedupes_within_window
8
+ aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 2)
9
+
10
+ context = Quonfig::Context.new(
11
+ 'user' => { 'key' => 'abc' },
12
+ 'device' => { 'key' => 'def', 'mobile' => true }
13
+ )
14
+
15
+ aggregator.record(context)
16
+ assert_equal 1, aggregator.data.size
17
+
18
+ # Same grouped key → skipped.
19
+ aggregator.record(context)
20
+ assert_equal 1, aggregator.data.size
21
+
22
+ new_context = Quonfig::Context.new(
23
+ 'user' => { 'key' => 'ghi', 'admin' => true },
24
+ 'team' => { 'key' => '999' }
25
+ )
26
+
27
+ aggregator.record(new_context)
28
+ assert_equal 2, aggregator.data.size
29
+
30
+ # At max_contexts — next record is dropped.
31
+ aggregator.record(Quonfig::Context.new('user' => { 'key' => 'new' }))
32
+ assert_equal 2, aggregator.data.size
33
+ end
34
+
35
+ def test_record_drops_contexts_without_a_key_property
36
+ aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
37
+
38
+ # Anonymous contexts (no `key` / `trackingId`) produce an empty
39
+ # grouped_key under the sdk-node-aligned shape. The aggregator drops
40
+ # them so we don't ship rows the backend can't dedupe.
41
+ aggregator.record(Quonfig::Context.new('user' => { 'name' => 'no-key' }))
42
+ aggregator.record(Quonfig::Context.new('user' => { 'name' => 'still-no-key' }))
43
+ assert_equal 0, aggregator.data.size
44
+ end
45
+
46
+ def test_record_with_expiry_allows_re_recording_after_window
47
+ aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
48
+
49
+ context = Quonfig::Context.new(
50
+ 'user' => { 'key' => 'abc' },
51
+ 'device' => { 'key' => 'def', 'mobile' => true }
52
+ )
53
+
54
+ aggregator.record(context)
55
+ assert_equal 1, aggregator.data.size
56
+
57
+ Timecop.travel(Time.now + (60 * 60) - 1) do
58
+ aggregator.record(context)
59
+ assert_equal 1, aggregator.data.size
60
+ end
61
+
62
+ Timecop.travel(Time.now + (60 * 60) + 1) do
63
+ aggregator.record(context)
64
+ assert_equal 2, aggregator.data.size
65
+ end
66
+ end
67
+
68
+ def test_drain_event_emits_api_telemetry_wire_shape
69
+ aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
70
+
71
+ aggregator.record(
72
+ Quonfig::Context.new(
73
+ 'user' => { 'key' => 'abc' },
74
+ 'device' => { 'key' => 'def', 'mobile' => true }
75
+ )
76
+ )
77
+ aggregator.record(
78
+ Quonfig::Context.new('user' => { 'key' => 'kev', 'name' => 'kevin', 'age' => 48.5 })
79
+ )
80
+
81
+ event = aggregator.drain_event
82
+
83
+ refute_nil event
84
+ assert event.key?('exampleContexts')
85
+ examples = event['exampleContexts']['examples']
86
+ assert_equal 2, examples.size
87
+
88
+ first = examples[0]
89
+ assert_kind_of Integer, first['timestamp']
90
+ assert first['timestamp'] > 0
91
+
92
+ contexts_list = first['contextSet']['contexts']
93
+ user_ctx = contexts_list.find { |c| c['type'] == 'user' }
94
+ device_ctx = contexts_list.find { |c| c['type'] == 'device' }
95
+
96
+ refute_nil user_ctx
97
+ refute_nil device_ctx
98
+ assert_equal 'abc', user_ctx['values']['key']
99
+ assert_equal true, device_ctx['values']['mobile']
100
+
101
+ second = examples[1]
102
+ user_ctx = second['contextSet']['contexts'].find { |c| c['type'] == 'user' }
103
+ assert_equal 'kev', user_ctx['values']['key']
104
+ assert_equal 'kevin', user_ctx['values']['name']
105
+ assert_in_delta 48.5, user_ctx['values']['age']
106
+ end
107
+
108
+ def test_drain_event_nil_when_empty
109
+ aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
110
+ assert_nil aggregator.drain_event
111
+ end
112
+
113
+ def test_drain_clears_data
114
+ aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
115
+ aggregator.record(Quonfig::Context.new('user' => { 'key' => 'abc' }))
116
+ aggregator.drain_event
117
+ assert_equal 0, aggregator.data.size
118
+ end
119
+ end
@@ -23,7 +23,7 @@ module Quonfig
23
23
 
24
24
  def test_x_quonfig_sdk_version_header
25
25
  conn = HttpConnection.new(URI, SDK_KEY).connection
26
- assert_equal 'ruby-0.1.0', conn.headers['X-Quonfig-SDK-Version']
26
+ assert_equal "ruby-#{Quonfig::VERSION}", conn.headers['X-Quonfig-SDK-Version']
27
27
  end
28
28
 
29
29
  def test_no_protobuf_content_type
@@ -134,11 +134,158 @@ class TestResolverTrio < Minitest::Test
134
134
  assert_equal DEFAULT_VALUE, result.unwrapped_value
135
135
  end
136
136
 
137
- def test_resolver_get_returns_nil_for_missing_key
137
+ def test_resolver_get_raises_missing_default_for_missing_key
138
138
  store = Quonfig::ConfigStore.new
139
139
  evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
140
140
  resolver = Quonfig::Resolver.new(store, evaluator)
141
141
 
142
- assert_nil resolver.get('nope', Quonfig::Context.new({}))
142
+ # Resolver.get raises Quonfig::Errors::MissingDefaultError when no
143
+ # config exists for the key (qfg-9x7 alignment with the shared YAML
144
+ # get_or_raise.yaml suite). Client.get catches this and folds it into
145
+ # the on_no_default policy / caller-supplied default.
146
+ assert_raises(Quonfig::Errors::MissingDefaultError) do
147
+ resolver.get('nope', Quonfig::Context.new({}))
148
+ end
149
+ end
150
+
151
+ # ---- ENV_VAR provided value resolution (qfg-08q) ---------------------
152
+
153
+ # Build a config whose value comes from a `provided` ENV_VAR lookup.
154
+ # value_type drives coercion of the env var string back to the SDK type
155
+ # (mirrors sdk-node/sdk-go behavior).
156
+ def make_provided_config(key:, value_type:, lookup:)
157
+ {
158
+ id: '1',
159
+ key: key,
160
+ type: 'config',
161
+ value_type: value_type,
162
+ send_to_client_sdk: false,
163
+ default: {
164
+ 'rules' => [
165
+ {
166
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
167
+ 'value' => {
168
+ 'type' => 'provided',
169
+ 'value' => { 'source' => 'ENV_VAR', 'lookup' => lookup }
170
+ }
171
+ }
172
+ ]
173
+ },
174
+ environment: nil
175
+ }
176
+ end
177
+
178
+ def with_env(name, value)
179
+ original = ENV[name]
180
+ ENV[name] = value
181
+ yield
182
+ ensure
183
+ if original.nil?
184
+ ENV.delete(name)
185
+ else
186
+ ENV[name] = original
187
+ end
188
+ end
189
+
190
+ def build_resolver(cfg)
191
+ store = Quonfig::ConfigStore.new({ cfg[:key] => cfg })
192
+ evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
193
+ Quonfig::Resolver.new(store, evaluator)
194
+ end
195
+
196
+ def test_resolver_get_resolves_provided_env_var_as_string
197
+ cfg = make_provided_config(key: 'a.string', value_type: 'string', lookup: 'QFG_TEST_STRING')
198
+ resolver = build_resolver(cfg)
199
+
200
+ with_env('QFG_TEST_STRING', 'hello') do
201
+ result = resolver.get('a.string', Quonfig::Context.new({}))
202
+ assert_equal 'hello', result.unwrapped_value
203
+ assert_equal 'string', result.value_type
204
+ end
205
+ end
206
+
207
+ def test_resolver_get_resolves_provided_env_var_as_int
208
+ cfg = make_provided_config(key: 'a.number', value_type: 'int', lookup: 'QFG_TEST_INT')
209
+ resolver = build_resolver(cfg)
210
+
211
+ with_env('QFG_TEST_INT', '1234') do
212
+ result = resolver.get('a.number', Quonfig::Context.new({}))
213
+ assert_equal 1234, result.unwrapped_value
214
+ assert_equal 'int', result.value_type
215
+ end
216
+ end
217
+
218
+ def test_resolver_get_resolves_provided_env_var_as_double
219
+ cfg = make_provided_config(key: 'a.double', value_type: 'double', lookup: 'QFG_TEST_DOUBLE')
220
+ resolver = build_resolver(cfg)
221
+
222
+ with_env('QFG_TEST_DOUBLE', '3.14') do
223
+ result = resolver.get('a.double', Quonfig::Context.new({}))
224
+ assert_in_delta 3.14, result.unwrapped_value, 0.0001
225
+ assert_equal 'double', result.value_type
226
+ end
227
+ end
228
+
229
+ def test_resolver_get_resolves_provided_env_var_as_bool
230
+ cfg = make_provided_config(key: 'a.bool', value_type: 'bool', lookup: 'QFG_TEST_BOOL')
231
+ resolver = build_resolver(cfg)
232
+
233
+ with_env('QFG_TEST_BOOL', 'true') do
234
+ result = resolver.get('a.bool', Quonfig::Context.new({}))
235
+ assert_equal true, result.unwrapped_value
236
+ assert_equal 'bool', result.value_type
237
+ end
238
+
239
+ with_env('QFG_TEST_BOOL', 'no') do
240
+ result = resolver.get('a.bool', Quonfig::Context.new({}))
241
+ assert_equal false, result.unwrapped_value
242
+ end
243
+ end
244
+
245
+ def test_resolver_get_resolves_provided_env_var_as_string_list
246
+ cfg = make_provided_config(key: 'a.list', value_type: 'string_list', lookup: 'QFG_TEST_LIST')
247
+ resolver = build_resolver(cfg)
248
+
249
+ with_env('QFG_TEST_LIST', 'a, b ,c') do
250
+ result = resolver.get('a.list', Quonfig::Context.new({}))
251
+ assert_equal %w[a b c], result.unwrapped_value
252
+ assert_equal 'string_list', result.value_type
253
+ end
254
+ end
255
+
256
+ def test_resolver_get_raises_missing_env_var_error_when_unset
257
+ cfg = make_provided_config(key: 'a.missing', value_type: 'string', lookup: 'QFG_DEFINITELY_UNSET')
258
+ resolver = build_resolver(cfg)
259
+ ENV.delete('QFG_DEFINITELY_UNSET')
260
+
261
+ err = assert_raises(Quonfig::Errors::MissingEnvVarError) do
262
+ resolver.get('a.missing', Quonfig::Context.new({}))
263
+ end
264
+ assert_match(/QFG_DEFINITELY_UNSET/, err.message)
265
+ assert_match(/a\.missing/, err.message)
266
+ end
267
+
268
+ def test_resolver_get_raises_env_var_parse_error_on_bad_int
269
+ cfg = make_provided_config(key: 'a.number', value_type: 'int', lookup: 'QFG_TEST_BAD_INT')
270
+ resolver = build_resolver(cfg)
271
+
272
+ with_env('QFG_TEST_BAD_INT', 'not_a_number') do
273
+ err = assert_raises(Quonfig::Errors::EnvVarParseError) do
274
+ resolver.get('a.number', Quonfig::Context.new({}))
275
+ end
276
+ assert_match(/a\.number/, err.message)
277
+ assert_match(/not_a_number/, err.message)
278
+ end
279
+ end
280
+
281
+ def test_resolver_get_raises_env_var_parse_error_on_bad_double
282
+ cfg = make_provided_config(key: 'a.double', value_type: 'double', lookup: 'QFG_TEST_BAD_DOUBLE')
283
+ resolver = build_resolver(cfg)
284
+
285
+ with_env('QFG_TEST_BAD_DOUBLE', 'not_a_number') do
286
+ assert_raises(Quonfig::Errors::EnvVarParseError) do
287
+ resolver.get('a.double', Quonfig::Context.new({}))
288
+ end
289
+ end
143
290
  end
144
291
  end
@@ -0,0 +1,186 @@
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