quonfig 0.0.6 → 0.0.9

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/bound_client.rb +26 -0
  5. data/lib/quonfig/client.rb +212 -3
  6. data/lib/quonfig/context.rb +10 -1
  7. data/lib/quonfig/datadir.rb +2 -4
  8. data/lib/quonfig/dev_context.rb +41 -0
  9. data/lib/quonfig/errors/decryption_error.rb +20 -0
  10. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  11. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  12. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  13. data/lib/quonfig/evaluator.rb +84 -3
  14. data/lib/quonfig/http_connection.rb +1 -1
  15. data/lib/quonfig/options.rb +4 -1
  16. data/lib/quonfig/resolver.rb +215 -2
  17. data/lib/quonfig/stdlib_formatter.rb +95 -0
  18. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  19. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  20. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  21. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  22. data/lib/quonfig/telemetry/telemetry_reporter.rb +212 -0
  23. data/lib/quonfig.rb +10 -0
  24. data/quonfig.gemspec +23 -4
  25. data/test/integration/test_context_precedence.rb +35 -117
  26. data/test/integration/test_datadir_environment.rb +15 -37
  27. data/test/integration/test_dev_overrides.rb +40 -0
  28. data/test/integration/test_enabled.rb +157 -463
  29. data/test/integration/test_enabled_with_contexts.rb +19 -49
  30. data/test/integration/test_get.rb +43 -131
  31. data/test/integration/test_get_feature_flag.rb +7 -13
  32. data/test/integration/test_get_or_raise.rb +19 -45
  33. data/test/integration/test_get_weighted_values.rb +9 -4
  34. data/test/integration/test_helpers.rb +532 -4
  35. data/test/integration/test_post.rb +15 -5
  36. data/test/integration/test_telemetry.rb +77 -21
  37. data/test/test_client_telemetry.rb +175 -0
  38. data/test/test_context.rb +4 -1
  39. data/test/test_context_shape.rb +37 -0
  40. data/test/test_context_shape_aggregator.rb +126 -0
  41. data/test/test_datadir.rb +6 -2
  42. data/test/test_dev_context.rb +163 -0
  43. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  44. data/test/test_example_contexts_aggregator.rb +119 -0
  45. data/test/test_http_connection.rb +1 -1
  46. data/test/test_resolver.rb +149 -2
  47. data/test/test_should_log.rb +186 -0
  48. data/test/test_stdlib_formatter.rb +195 -0
  49. data/test/test_telemetry_reporter.rb +209 -0
  50. metadata +22 -3
  51. data/scripts/generate_integration_tests.rb +0 -362
@@ -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
@@ -0,0 +1,195 @@
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
@@ -0,0 +1,209 @@
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
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.6
4
+ version: 0.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-22 00:00:00.000000000 Z
11
+ date: 2026-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -213,14 +213,18 @@ files:
213
213
  - lib/quonfig/config_store.rb
214
214
  - lib/quonfig/context.rb
215
215
  - lib/quonfig/datadir.rb
216
+ - lib/quonfig/dev_context.rb
216
217
  - lib/quonfig/duration.rb
217
218
  - lib/quonfig/encryption.rb
218
219
  - lib/quonfig/error.rb
220
+ - lib/quonfig/errors/decryption_error.rb
219
221
  - lib/quonfig/errors/env_var_parse_error.rb
220
222
  - lib/quonfig/errors/initialization_timeout_error.rb
223
+ - lib/quonfig/errors/invalid_environment_error.rb
221
224
  - lib/quonfig/errors/invalid_sdk_key_error.rb
222
225
  - lib/quonfig/errors/missing_default_error.rb
223
226
  - lib/quonfig/errors/missing_env_var_error.rb
227
+ - lib/quonfig/errors/missing_environment_error.rb
224
228
  - lib/quonfig/errors/type_mismatch_error.rb
225
229
  - lib/quonfig/errors/uninitialized_error.rb
226
230
  - lib/quonfig/evaluation.rb
@@ -239,14 +243,20 @@ files:
239
243
  - lib/quonfig/semantic_logger_filter.rb
240
244
  - lib/quonfig/semver.rb
241
245
  - lib/quonfig/sse_config_client.rb
246
+ - lib/quonfig/stdlib_formatter.rb
247
+ - lib/quonfig/telemetry/context_shape.rb
248
+ - lib/quonfig/telemetry/context_shape_aggregator.rb
249
+ - lib/quonfig/telemetry/evaluation_summaries_aggregator.rb
250
+ - lib/quonfig/telemetry/example_contexts_aggregator.rb
251
+ - lib/quonfig/telemetry/telemetry_reporter.rb
242
252
  - lib/quonfig/time_helpers.rb
243
253
  - lib/quonfig/types.rb
244
254
  - lib/quonfig/weighted_value_resolver.rb
245
255
  - quonfig.gemspec
246
- - scripts/generate_integration_tests.rb
247
256
  - test/fixtures/datafile.json
248
257
  - test/integration/test_context_precedence.rb
249
258
  - test/integration/test_datadir_environment.rb
259
+ - test/integration/test_dev_overrides.rb
250
260
  - test/integration/test_enabled.rb
251
261
  - test/integration/test_enabled_with_contexts.rb
252
262
  - test/integration/test_get.rb
@@ -264,12 +274,18 @@ files:
264
274
  - test/test_caching_http_connection.rb
265
275
  - test/test_client.rb
266
276
  - test/test_client_network_mode.rb
277
+ - test/test_client_telemetry.rb
267
278
  - test/test_config_loader.rb
268
279
  - test/test_context.rb
280
+ - test/test_context_shape.rb
281
+ - test/test_context_shape_aggregator.rb
269
282
  - test/test_datadir.rb
283
+ - test/test_dev_context.rb
270
284
  - test/test_duration.rb
271
285
  - test/test_encryption.rb
286
+ - test/test_evaluation_summaries_aggregator.rb
272
287
  - test/test_evaluator.rb
288
+ - test/test_example_contexts_aggregator.rb
273
289
  - test/test_exponential_backoff.rb
274
290
  - test/test_fixed_size_hash.rb
275
291
  - test/test_helper.rb
@@ -282,7 +298,10 @@ files:
282
298
  - test/test_resolver.rb
283
299
  - test/test_semantic_logger_filter.rb
284
300
  - test/test_semver.rb
301
+ - test/test_should_log.rb
285
302
  - test/test_sse_config_client.rb
303
+ - test/test_stdlib_formatter.rb
304
+ - test/test_telemetry_reporter.rb
286
305
  - test/test_typed_getters.rb
287
306
  - test/test_types.rb
288
307
  - test/test_weighted_value_resolver.rb