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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +109 -2
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/errors/decryption_error.rb +20 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
- data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
- data/lib/quonfig/errors/missing_environment_error.rb +18 -0
- data/lib/quonfig/evaluator.rb +64 -2
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/resolver.rb +187 -2
- data/lib/quonfig/stdlib_formatter.rb +95 -0
- data/lib/quonfig/telemetry/context_shape.rb +33 -0
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
- data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
- data/lib/quonfig.rb +8 -0
- data/quonfig.gemspec +20 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_enabled.rb +157 -463
- data/test/integration/test_enabled_with_contexts.rb +19 -49
- data/test/integration/test_get.rb +43 -131
- data/test/integration/test_get_feature_flag.rb +7 -13
- data/test/integration/test_get_or_raise.rb +19 -45
- data/test/integration/test_get_weighted_values.rb +9 -4
- data/test/integration/test_helpers.rb +499 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +63 -21
- data/test/test_client_telemetry.rb +132 -0
- data/test/test_context.rb +4 -1
- data/test/test_context_shape.rb +37 -0
- data/test/test_context_shape_aggregator.rb +126 -0
- data/test/test_datadir.rb +6 -2
- data/test/test_evaluation_summaries_aggregator.rb +180 -0
- data/test/test_example_contexts_aggregator.rb +119 -0
- data/test/test_http_connection.rb +1 -1
- data/test/test_resolver.rb +149 -2
- data/test/test_should_log.rb +186 -0
- data/test/test_stdlib_formatter.rb +195 -0
- data/test/test_telemetry_reporter.rb +209 -0
- metadata +19 -3
- data/scripts/generate_integration_tests.rb +0 -362
|
@@ -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.
|
|
4
|
+
version: 0.0.8
|
|
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-
|
|
11
|
+
date: 2026-04-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -216,11 +216,14 @@ files:
|
|
|
216
216
|
- lib/quonfig/duration.rb
|
|
217
217
|
- lib/quonfig/encryption.rb
|
|
218
218
|
- lib/quonfig/error.rb
|
|
219
|
+
- lib/quonfig/errors/decryption_error.rb
|
|
219
220
|
- lib/quonfig/errors/env_var_parse_error.rb
|
|
220
221
|
- lib/quonfig/errors/initialization_timeout_error.rb
|
|
222
|
+
- lib/quonfig/errors/invalid_environment_error.rb
|
|
221
223
|
- lib/quonfig/errors/invalid_sdk_key_error.rb
|
|
222
224
|
- lib/quonfig/errors/missing_default_error.rb
|
|
223
225
|
- lib/quonfig/errors/missing_env_var_error.rb
|
|
226
|
+
- lib/quonfig/errors/missing_environment_error.rb
|
|
224
227
|
- lib/quonfig/errors/type_mismatch_error.rb
|
|
225
228
|
- lib/quonfig/errors/uninitialized_error.rb
|
|
226
229
|
- lib/quonfig/evaluation.rb
|
|
@@ -239,11 +242,16 @@ files:
|
|
|
239
242
|
- lib/quonfig/semantic_logger_filter.rb
|
|
240
243
|
- lib/quonfig/semver.rb
|
|
241
244
|
- lib/quonfig/sse_config_client.rb
|
|
245
|
+
- lib/quonfig/stdlib_formatter.rb
|
|
246
|
+
- lib/quonfig/telemetry/context_shape.rb
|
|
247
|
+
- lib/quonfig/telemetry/context_shape_aggregator.rb
|
|
248
|
+
- lib/quonfig/telemetry/evaluation_summaries_aggregator.rb
|
|
249
|
+
- lib/quonfig/telemetry/example_contexts_aggregator.rb
|
|
250
|
+
- lib/quonfig/telemetry/telemetry_reporter.rb
|
|
242
251
|
- lib/quonfig/time_helpers.rb
|
|
243
252
|
- lib/quonfig/types.rb
|
|
244
253
|
- lib/quonfig/weighted_value_resolver.rb
|
|
245
254
|
- quonfig.gemspec
|
|
246
|
-
- scripts/generate_integration_tests.rb
|
|
247
255
|
- test/fixtures/datafile.json
|
|
248
256
|
- test/integration/test_context_precedence.rb
|
|
249
257
|
- test/integration/test_datadir_environment.rb
|
|
@@ -264,12 +272,17 @@ files:
|
|
|
264
272
|
- test/test_caching_http_connection.rb
|
|
265
273
|
- test/test_client.rb
|
|
266
274
|
- test/test_client_network_mode.rb
|
|
275
|
+
- test/test_client_telemetry.rb
|
|
267
276
|
- test/test_config_loader.rb
|
|
268
277
|
- test/test_context.rb
|
|
278
|
+
- test/test_context_shape.rb
|
|
279
|
+
- test/test_context_shape_aggregator.rb
|
|
269
280
|
- test/test_datadir.rb
|
|
270
281
|
- test/test_duration.rb
|
|
271
282
|
- test/test_encryption.rb
|
|
283
|
+
- test/test_evaluation_summaries_aggregator.rb
|
|
272
284
|
- test/test_evaluator.rb
|
|
285
|
+
- test/test_example_contexts_aggregator.rb
|
|
273
286
|
- test/test_exponential_backoff.rb
|
|
274
287
|
- test/test_fixed_size_hash.rb
|
|
275
288
|
- test/test_helper.rb
|
|
@@ -282,7 +295,10 @@ files:
|
|
|
282
295
|
- test/test_resolver.rb
|
|
283
296
|
- test/test_semantic_logger_filter.rb
|
|
284
297
|
- test/test_semver.rb
|
|
298
|
+
- test/test_should_log.rb
|
|
285
299
|
- test/test_sse_config_client.rb
|
|
300
|
+
- test/test_stdlib_formatter.rb
|
|
301
|
+
- test/test_telemetry_reporter.rb
|
|
286
302
|
- test/test_typed_getters.rb
|
|
287
303
|
- test/test_types.rb
|
|
288
304
|
- test/test_weighted_value_resolver.rb
|