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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
#
|
|
3
3
|
# AUTO-GENERATED from integration-test-data/tests/eval/telemetry.yaml.
|
|
4
|
-
# Regenerate with
|
|
4
|
+
# Regenerate with:
|
|
5
|
+
# cd integration-test-data/generators && npm run generate -- --target=ruby
|
|
6
|
+
# Source: integration-test-data/generators/src/targets/ruby.ts
|
|
5
7
|
# Do NOT edit by hand — changes will be overwritten.
|
|
6
8
|
|
|
7
9
|
require 'test_helper'
|
|
@@ -14,101 +16,141 @@ class TestTelemetry < Minitest::Test
|
|
|
14
16
|
|
|
15
17
|
# reason is STATIC for config with no targeting rules
|
|
16
18
|
def test_reason_is_static_for_config_with_no_targeting_rules
|
|
17
|
-
|
|
19
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
20
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.string"]}, contexts: {})
|
|
21
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "hello.world"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
# reason is STATIC for feature flag with only ALWAYS_TRUE rules
|
|
21
25
|
def test_reason_is_static_for_feature_flag_with_only_always_true_rules
|
|
22
|
-
|
|
26
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
27
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["always.true"]}, contexts: {})
|
|
28
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "always.true", "type" => "FEATURE_FLAG", "value" => true, "value_type" => "bool", "count" => 1, "reason" => 1, "selected_value" => {"bool" => true}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
# reason is TARGETING_MATCH when config has targeting rules but evaluation falls through
|
|
26
32
|
def test_reason_is_targeting_match_when_config_has_targeting_rules_but_evaluation_falls_through
|
|
27
|
-
|
|
33
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
34
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["my-test-key"]}, contexts: {})
|
|
35
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "my-test-key", "type" => "CONFIG", "value" => "my-test-value", "value_type" => "string", "count" => 1, "reason" => 2, "selected_value" => {"string" => "my-test-value"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 1}}], endpoint: "/api/v1/telemetry")
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
# reason is TARGETING_MATCH when a targeting rule matches
|
|
31
39
|
def test_reason_is_targeting_match_when_a_targeting_rule_matches
|
|
32
|
-
|
|
40
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
41
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["feature-flag.integer"]}, contexts: {"user" => {"key" => "michael"}})
|
|
42
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "feature-flag.integer", "type" => "FEATURE_FLAG", "value" => 5, "value_type" => "int", "count" => 1, "reason" => 2, "selected_value" => {"int" => 5}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
# reason is SPLIT for weighted value evaluation
|
|
36
46
|
def test_reason_is_split_for_weighted_value_evaluation
|
|
37
|
-
|
|
47
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
48
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["feature-flag.weighted"]}, contexts: {"user" => {"tracking_id" => "92a202f2"}})
|
|
49
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "feature-flag.weighted", "type" => "FEATURE_FLAG", "value" => 2, "value_type" => "int", "count" => 1, "reason" => 3, "selected_value" => {"int" => 2}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0, "weighted_value_index" => 2}}], endpoint: "/api/v1/telemetry")
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
# reason is TARGETING_MATCH for feature flag fallthrough with targeting rules
|
|
41
53
|
def test_reason_is_targeting_match_for_feature_flag_fallthrough_with_targeting_rules
|
|
42
|
-
|
|
54
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
55
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["feature-flag.integer"]}, contexts: {})
|
|
56
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "feature-flag.integer", "type" => "FEATURE_FLAG", "value" => 3, "value_type" => "int", "count" => 1, "reason" => 2, "selected_value" => {"int" => 3}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 1}}], endpoint: "/api/v1/telemetry")
|
|
43
57
|
end
|
|
44
58
|
|
|
45
59
|
# evaluation summary deduplicates identical evaluations
|
|
46
60
|
def test_evaluation_summary_deduplicates_identical_evaluations
|
|
47
|
-
|
|
61
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
62
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.string", "brand.new.string", "brand.new.string", "brand.new.string", "brand.new.string"]}, contexts: {})
|
|
63
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 5, "reason" => 1, "selected_value" => {"string" => "hello.world"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
48
64
|
end
|
|
49
65
|
|
|
50
66
|
# evaluation summary creates separate counters for different rules of same config
|
|
51
67
|
def test_evaluation_summary_creates_separate_counters_for_different_rules_of_same_config
|
|
52
|
-
|
|
68
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
69
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["feature-flag.integer"], "keys_without_context" => ["feature-flag.integer"]}, contexts: {"user" => {"key" => "michael"}})
|
|
70
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "feature-flag.integer", "type" => "FEATURE_FLAG", "value" => 5, "value_type" => "int", "count" => 1, "reason" => 2, "selected_value" => {"int" => 5}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}, {"key" => "feature-flag.integer", "type" => "FEATURE_FLAG", "value" => 3, "value_type" => "int", "count" => 1, "reason" => 2, "selected_value" => {"int" => 3}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 1}}], endpoint: "/api/v1/telemetry")
|
|
53
71
|
end
|
|
54
72
|
|
|
55
73
|
# evaluation summary groups by config key
|
|
56
74
|
def test_evaluation_summary_groups_by_config_key
|
|
57
|
-
|
|
75
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
76
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.string", "always.true"]}, contexts: {})
|
|
77
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "hello.world"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}, {"key" => "always.true", "type" => "FEATURE_FLAG", "value" => true, "value_type" => "bool", "count" => 1, "reason" => 1, "selected_value" => {"bool" => true}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
# selectedValue wraps string correctly
|
|
61
81
|
def test_selectedvalue_wraps_string_correctly
|
|
62
|
-
|
|
82
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
83
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.string"]}, contexts: {})
|
|
84
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "hello.world"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
63
85
|
end
|
|
64
86
|
|
|
65
87
|
# selectedValue wraps boolean correctly
|
|
66
88
|
def test_selectedvalue_wraps_boolean_correctly
|
|
67
|
-
|
|
89
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
90
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.boolean"]}, contexts: {})
|
|
91
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.boolean", "type" => "CONFIG", "value" => false, "value_type" => "bool", "count" => 1, "reason" => 1, "selected_value" => {"bool" => false}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
68
92
|
end
|
|
69
93
|
|
|
70
94
|
# selectedValue wraps int correctly
|
|
71
95
|
def test_selectedvalue_wraps_int_correctly
|
|
72
|
-
|
|
96
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
97
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.int"]}, contexts: {})
|
|
98
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.int", "type" => "CONFIG", "value" => 123, "value_type" => "int", "count" => 1, "reason" => 1, "selected_value" => {"int" => 123}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
73
99
|
end
|
|
74
100
|
|
|
75
101
|
# selectedValue wraps double correctly
|
|
76
102
|
def test_selectedvalue_wraps_double_correctly
|
|
77
|
-
|
|
103
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
104
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.double"]}, contexts: {})
|
|
105
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "brand.new.double", "type" => "CONFIG", "value" => 123.99, "value_type" => "double", "count" => 1, "reason" => 1, "selected_value" => {"double" => 123.99}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
78
106
|
end
|
|
79
107
|
|
|
80
108
|
# selectedValue wraps string list correctly
|
|
81
109
|
def test_selectedvalue_wraps_string_list_correctly
|
|
82
|
-
|
|
110
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
111
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["my-string-list-key"]}, contexts: {})
|
|
112
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "my-string-list-key", "type" => "CONFIG", "value" => ["a", "b", "c"], "value_type" => "string_list", "count" => 1, "reason" => 1, "selected_value" => {"stringList" => ["a", "b", "c"]}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
|
|
83
113
|
end
|
|
84
114
|
|
|
85
115
|
# context shape merges fields across multiple records
|
|
86
116
|
def test_context_shape_merges_fields_across_multiple_records
|
|
87
|
-
|
|
117
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:context_shape, {})
|
|
118
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, [{"user" => {"name" => "alice", "age" => 30}}, {"user" => {"name" => "bob", "score" => 9.5}, "team" => {"name" => "engineering"}}], contexts: {})
|
|
119
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, [{"name" => "user", "field_types" => {"name" => 2, "age" => 1, "score" => 4}}, {"name" => "team", "field_types" => {"name" => 2}}], endpoint: "/api/v1/context-shapes")
|
|
88
120
|
end
|
|
89
121
|
|
|
90
122
|
# example contexts deduplicates by key value
|
|
91
123
|
def test_example_contexts_deduplicates_by_key_value
|
|
92
|
-
|
|
124
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:example_contexts, {})
|
|
125
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :example_contexts, [{"user" => {"key" => "user-123", "name" => "alice"}}, {"user" => {"key" => "user-123", "name" => "bob"}}], contexts: {})
|
|
126
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :example_contexts, {"user" => {"key" => "user-123", "name" => "alice"}}, endpoint: "/api/v1/telemetry")
|
|
93
127
|
end
|
|
94
128
|
|
|
95
129
|
# telemetry disabled emits nothing
|
|
96
130
|
def test_telemetry_disabled_emits_nothing
|
|
97
|
-
|
|
131
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {"collect_evaluation_summaries" => false, "context_upload_mode" => ":none"})
|
|
132
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["brand.new.string"]}, contexts: {})
|
|
133
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, nil, endpoint: "/api/v1/telemetry")
|
|
98
134
|
end
|
|
99
135
|
|
|
100
136
|
# shapes only mode reports shapes but not examples
|
|
101
137
|
def test_shapes_only_mode_reports_shapes_but_not_examples
|
|
102
|
-
|
|
138
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:context_shape, {"context_upload_mode" => ":shape_only"})
|
|
139
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, {"user" => {"name" => "alice", "key" => "alice-123"}}, contexts: {})
|
|
140
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, [{"name" => "user", "field_types" => {"name" => 2, "key" => 2}}], endpoint: "/api/v1/context-shapes")
|
|
103
141
|
end
|
|
104
142
|
|
|
105
143
|
# log level evaluations are excluded from telemetry
|
|
106
144
|
def test_log_level_evaluations_are_excluded_from_telemetry
|
|
107
|
-
|
|
145
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
146
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["log-level.prefab.criteria_evaluator"]}, contexts: {})
|
|
147
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, nil, endpoint: "/api/v1/telemetry")
|
|
108
148
|
end
|
|
109
149
|
|
|
110
150
|
# empty context produces no context telemetry
|
|
111
151
|
def test_empty_context_produces_no_context_telemetry
|
|
112
|
-
|
|
152
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:context_shape, {})
|
|
153
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, {}, contexts: {})
|
|
154
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, nil, endpoint: "/api/v1/context-shapes")
|
|
113
155
|
end
|
|
114
156
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Verifies that Quonfig::Client#get feeds every evaluation into the
|
|
6
|
+
# telemetry reporter's evaluation_summaries aggregator.
|
|
7
|
+
class TestClientTelemetry < Minitest::Test
|
|
8
|
+
CONFIG_KEY = 'my.flag'
|
|
9
|
+
|
|
10
|
+
class FakeHttpConnection
|
|
11
|
+
FakeResponse = Struct.new(:status)
|
|
12
|
+
attr_reader :posts
|
|
13
|
+
def initialize; @posts = []; end
|
|
14
|
+
def post(path, body); @posts << [path, body]; FakeResponse.new(200); end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Plain ConfigResponse-shaped hash (matches Datadir.to_config_response).
|
|
18
|
+
def make_config(key:, value:, type: 'string', criteria: nil)
|
|
19
|
+
{
|
|
20
|
+
'id' => 'cid-abc',
|
|
21
|
+
'key' => key,
|
|
22
|
+
'type' => 'config',
|
|
23
|
+
'valueType' => type,
|
|
24
|
+
'sendToClientSdk' => false,
|
|
25
|
+
'default' => {
|
|
26
|
+
'rules' => [
|
|
27
|
+
{
|
|
28
|
+
'criteria' => criteria || [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
29
|
+
'value' => { 'type' => type, 'value' => value }
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
'environment' => nil
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def make_client_with_telemetry(store)
|
|
38
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: store)
|
|
39
|
+
|
|
40
|
+
# The store-injection path skips initialize_telemetry by design (it's
|
|
41
|
+
# for test/bootstrap mode), so we attach the reporter + aggregators
|
|
42
|
+
# here explicitly to exercise the record path.
|
|
43
|
+
summaries_agg = Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: 100)
|
|
44
|
+
shape_agg = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 100)
|
|
45
|
+
example_agg = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 100)
|
|
46
|
+
|
|
47
|
+
options = Quonfig::Options.new(
|
|
48
|
+
sdk_key: 'qf_sk_dev_abc_deadbeef',
|
|
49
|
+
environment: 'development',
|
|
50
|
+
api_urls: ['https://primary.example.com'],
|
|
51
|
+
enable_sse: false,
|
|
52
|
+
enable_polling: false,
|
|
53
|
+
on_init_failure: Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
|
|
54
|
+
).tap { |o| o.instance_variable_set(:@telemetry_destination, 'https://t.example.com') }
|
|
55
|
+
|
|
56
|
+
fake_conn = FakeHttpConnection.new
|
|
57
|
+
reporter = Quonfig::Telemetry::TelemetryReporter.new(
|
|
58
|
+
options: options,
|
|
59
|
+
instance_hash: client.instance_hash,
|
|
60
|
+
context_shape_aggregator: shape_agg,
|
|
61
|
+
example_contexts_aggregator: example_agg,
|
|
62
|
+
evaluation_summaries_aggregator: summaries_agg,
|
|
63
|
+
http_connection: fake_conn
|
|
64
|
+
)
|
|
65
|
+
client.instance_variable_set(:@telemetry_reporter, reporter)
|
|
66
|
+
|
|
67
|
+
[client, reporter, summaries_agg, fake_conn]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def test_get_pushes_evaluation_into_summaries_aggregator
|
|
71
|
+
store = Quonfig::ConfigStore.new
|
|
72
|
+
store.set(CONFIG_KEY, make_config(key: CONFIG_KEY, value: 'hello'))
|
|
73
|
+
|
|
74
|
+
client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
|
|
75
|
+
client.get(CONFIG_KEY, Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
|
|
76
|
+
|
|
77
|
+
event = summaries_agg.drain_event
|
|
78
|
+
refute_nil event, 'expected an evaluation_summaries event after Client#get'
|
|
79
|
+
|
|
80
|
+
summary = event['summaries']['summaries'][0]
|
|
81
|
+
assert_equal CONFIG_KEY, summary['key']
|
|
82
|
+
assert_equal 'config', summary['type']
|
|
83
|
+
|
|
84
|
+
counter = summary['counters'][0]
|
|
85
|
+
assert_equal 'cid-abc', counter['configId']
|
|
86
|
+
assert_equal 0, counter['conditionalValueIndex']
|
|
87
|
+
assert_equal 1, counter['count']
|
|
88
|
+
assert_equal({ 'string' => 'hello' }, counter['selectedValue'])
|
|
89
|
+
# ALWAYS_TRUE on the only rule → STATIC (1)
|
|
90
|
+
assert_equal 1, counter['reason']
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_get_with_targeting_rule_reports_targeting_match
|
|
94
|
+
store = Quonfig::ConfigStore.new
|
|
95
|
+
store.set(CONFIG_KEY, make_config(
|
|
96
|
+
key: CONFIG_KEY,
|
|
97
|
+
value: 'targeted',
|
|
98
|
+
criteria: [{
|
|
99
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
100
|
+
'propertyName' => 'user.tier',
|
|
101
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['pro'] }
|
|
102
|
+
}]
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
|
|
106
|
+
client.get(CONFIG_KEY, 'fallback', 'user' => { 'key' => 'u1', 'tier' => 'pro' })
|
|
107
|
+
|
|
108
|
+
counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
|
|
109
|
+
# Config has a non-ALWAYS_TRUE rule → TARGETING_MATCH (2)
|
|
110
|
+
assert_equal 2, counter['reason']
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_repeated_get_increments_count_not_new_counters
|
|
114
|
+
store = Quonfig::ConfigStore.new
|
|
115
|
+
store.set(CONFIG_KEY, make_config(key: CONFIG_KEY, value: 'hello'))
|
|
116
|
+
|
|
117
|
+
client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
|
|
118
|
+
3.times { client.get(CONFIG_KEY, Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' }) }
|
|
119
|
+
|
|
120
|
+
counters = summaries_agg.drain_event['summaries']['summaries'][0]['counters']
|
|
121
|
+
assert_equal 1, counters.size, 'same evaluation should dedupe into one counter'
|
|
122
|
+
assert_equal 3, counters[0]['count']
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_missing_key_does_not_record_evaluation
|
|
126
|
+
store = Quonfig::ConfigStore.new
|
|
127
|
+
client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
|
|
128
|
+
|
|
129
|
+
client.get('does.not.exist', 'fallback')
|
|
130
|
+
assert_nil summaries_agg.drain_event
|
|
131
|
+
end
|
|
132
|
+
end
|
data/test/test_context.rb
CHANGED
|
@@ -90,7 +90,10 @@ class TestContext < Minitest::Test
|
|
|
90
90
|
team: { key: 't1' }
|
|
91
91
|
)
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
# Mirrors sdk-node groupedKey: just the key/trackingId values, sorted
|
|
94
|
+
# and pipe-joined. Contexts without a key/trackingId contribute
|
|
95
|
+
# nothing — so the example aggregator drops anonymous contexts.
|
|
96
|
+
assert_equal 't1|u1', context.grouped_key
|
|
94
97
|
end
|
|
95
98
|
|
|
96
99
|
def test_named_context_lookup_returns_namedcontext
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TestContextShape < Minitest::Test
|
|
6
|
+
class Email; end
|
|
7
|
+
|
|
8
|
+
def test_field_type_number
|
|
9
|
+
[
|
|
10
|
+
[1, 1],
|
|
11
|
+
[99_999_999_999_999_999_999_999_999_999_999_999_999_999_999, 1],
|
|
12
|
+
[-99_999_999_999_999_999_999_999_999_999_999_999_999_999_999, 1],
|
|
13
|
+
|
|
14
|
+
['a', 2],
|
|
15
|
+
['99999999999999999999999999999999999999999999', 2],
|
|
16
|
+
|
|
17
|
+
[1.0, 4],
|
|
18
|
+
[99_999_999_999_999_999_999_999_999_999_999_999_999_999_999.0, 4],
|
|
19
|
+
[-99_999_999_999_999_999_999_999_999_999_999_999_999_999_999.0, 4],
|
|
20
|
+
|
|
21
|
+
[true, 5],
|
|
22
|
+
[false, 5],
|
|
23
|
+
|
|
24
|
+
[[], 10],
|
|
25
|
+
[[1, 2, 3], 10],
|
|
26
|
+
[%w[a b c], 10],
|
|
27
|
+
|
|
28
|
+
# Unknown / custom types fall back to "string" (2).
|
|
29
|
+
[Email.new, 2]
|
|
30
|
+
].each do |value, expected|
|
|
31
|
+
actual = Quonfig::Telemetry::ContextShape.field_type_number(value)
|
|
32
|
+
|
|
33
|
+
refute_nil actual, "Expected a value for input: #{value.inspect}"
|
|
34
|
+
assert_equal expected, actual, "Expected #{expected} for #{value.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TestContextShapeAggregator < Minitest::Test
|
|
6
|
+
CONTEXT_1 = Quonfig::Context.new(
|
|
7
|
+
'user' => {
|
|
8
|
+
'name' => 'user-name',
|
|
9
|
+
'email' => 'user.email',
|
|
10
|
+
'age' => 42.5
|
|
11
|
+
},
|
|
12
|
+
'subscription' => {
|
|
13
|
+
'plan' => 'advanced',
|
|
14
|
+
'free' => false
|
|
15
|
+
}
|
|
16
|
+
).freeze
|
|
17
|
+
|
|
18
|
+
CONTEXT_2 = Quonfig::Context.new(
|
|
19
|
+
'user' => {
|
|
20
|
+
'name' => 'other-user-name',
|
|
21
|
+
'dob' => '2020-01-01'
|
|
22
|
+
},
|
|
23
|
+
'device' => {
|
|
24
|
+
'name' => 'device-name',
|
|
25
|
+
'os' => 'os-name',
|
|
26
|
+
'version' => 3
|
|
27
|
+
}
|
|
28
|
+
).freeze
|
|
29
|
+
|
|
30
|
+
CONTEXT_3 = Quonfig::Context.new(
|
|
31
|
+
'subscription' => {
|
|
32
|
+
'plan' => 'pro',
|
|
33
|
+
'trial' => true
|
|
34
|
+
}
|
|
35
|
+
).freeze
|
|
36
|
+
|
|
37
|
+
def test_push_respects_max_shapes
|
|
38
|
+
aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 9)
|
|
39
|
+
|
|
40
|
+
aggregator.push(CONTEXT_1)
|
|
41
|
+
aggregator.push(CONTEXT_2)
|
|
42
|
+
assert_equal 9, aggregator.data.size
|
|
43
|
+
|
|
44
|
+
# At the limit — further shapes get dropped.
|
|
45
|
+
aggregator.push(CONTEXT_3)
|
|
46
|
+
assert_equal 9, aggregator.data.size
|
|
47
|
+
|
|
48
|
+
tuples = aggregator.data.to_a
|
|
49
|
+
assert_includes tuples, ['user', 'name', 2]
|
|
50
|
+
assert_includes tuples, ['user', 'email', 2]
|
|
51
|
+
assert_includes tuples, ['user', 'age', 4]
|
|
52
|
+
assert_includes tuples, ['subscription', 'plan', 2]
|
|
53
|
+
assert_includes tuples, ['subscription', 'free', 5]
|
|
54
|
+
assert_includes tuples, ['device', 'name', 2]
|
|
55
|
+
assert_includes tuples, ['device', 'os', 2]
|
|
56
|
+
assert_includes tuples, ['device', 'version', 1]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_prepare_data_folds_tuples_and_clears
|
|
60
|
+
aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
|
|
61
|
+
|
|
62
|
+
aggregator.push(CONTEXT_1)
|
|
63
|
+
aggregator.push(CONTEXT_2)
|
|
64
|
+
aggregator.push(CONTEXT_3)
|
|
65
|
+
|
|
66
|
+
data = aggregator.prepare_data
|
|
67
|
+
|
|
68
|
+
assert_equal %w[user subscription device].sort, data.keys.sort
|
|
69
|
+
|
|
70
|
+
assert_equal(
|
|
71
|
+
{ 'name' => 2, 'email' => 2, 'dob' => 2, 'age' => 4 },
|
|
72
|
+
data['user']
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
assert_equal(
|
|
76
|
+
{ 'plan' => 2, 'trial' => 5, 'free' => 5 },
|
|
77
|
+
data['subscription']
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
assert_equal(
|
|
81
|
+
{ 'name' => 2, 'os' => 2, 'version' => 1 },
|
|
82
|
+
data['device']
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
assert_equal [], aggregator.data.to_a
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_drain_event_emits_api_telemetry_wire_shape
|
|
89
|
+
aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
|
|
90
|
+
aggregator.push(Quonfig::Context.new('user' => { 'key' => 'abc', 'age' => 42 }))
|
|
91
|
+
|
|
92
|
+
event = aggregator.drain_event
|
|
93
|
+
|
|
94
|
+
refute_nil event
|
|
95
|
+
assert event.key?('contextShapes')
|
|
96
|
+
shapes = event['contextShapes']['shapes']
|
|
97
|
+
assert_equal 1, shapes.size
|
|
98
|
+
assert_equal 'user', shapes[0]['name']
|
|
99
|
+
assert_equal({ 'key' => 2, 'age' => 1 }, shapes[0]['fieldTypes'])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_drain_event_nil_when_empty
|
|
103
|
+
aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
|
|
104
|
+
assert_nil aggregator.drain_event
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_push_dedupes_identical_shapes
|
|
108
|
+
aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
|
|
109
|
+
|
|
110
|
+
aggregator.push(Quonfig::Context.new('user' => { 'key' => 'a', 'age' => 1 }))
|
|
111
|
+
aggregator.push(Quonfig::Context.new('user' => { 'key' => 'b', 'age' => 2 }))
|
|
112
|
+
|
|
113
|
+
# Same (name, key, type) tuples should have been deduped by the Set.
|
|
114
|
+
assert_equal 2, aggregator.data.size
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_accepts_plain_hash_context
|
|
118
|
+
aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: 1000)
|
|
119
|
+
|
|
120
|
+
aggregator.push('user' => { 'key' => 'abc', 'is_admin' => true })
|
|
121
|
+
|
|
122
|
+
event = aggregator.drain_event
|
|
123
|
+
refute_nil event
|
|
124
|
+
assert_equal({ 'key' => 2, 'is_admin' => 5 }, event['contextShapes']['shapes'][0]['fieldTypes'])
|
|
125
|
+
end
|
|
126
|
+
end
|
data/test/test_datadir.rb
CHANGED
|
@@ -147,7 +147,9 @@ class TestDatadir < Minitest::Test
|
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
def test_raises_when_no_environment
|
|
150
|
-
err = assert_raises(
|
|
150
|
+
err = assert_raises(Quonfig::Errors::MissingEnvironmentError) do
|
|
151
|
+
Quonfig::Datadir.load_envelope(@tmpdir, nil)
|
|
152
|
+
end
|
|
151
153
|
assert_match(/Environment required for datadir mode/, err.message)
|
|
152
154
|
end
|
|
153
155
|
|
|
@@ -159,7 +161,9 @@ class TestDatadir < Minitest::Test
|
|
|
159
161
|
end
|
|
160
162
|
|
|
161
163
|
def test_raises_when_environment_not_in_workspace
|
|
162
|
-
err = assert_raises(
|
|
164
|
+
err = assert_raises(Quonfig::Errors::InvalidEnvironmentError) do
|
|
165
|
+
Quonfig::Datadir.load_envelope(@tmpdir, 'NotAnEnv')
|
|
166
|
+
end
|
|
163
167
|
assert_match(/Environment "NotAnEnv" not found/, err.message)
|
|
164
168
|
assert_match(/Production/, err.message)
|
|
165
169
|
end
|