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
@@ -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 `bundle exec ruby scripts/generate_integration_tests.rb`.
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,155 @@ 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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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")
155
+ end
156
+
157
+ # confidential plain string is redacted in selectedValue
158
+ def test_confidential_plain_string_is_redacted_in_selectedvalue
159
+ aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
160
+ IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["confidential.new.string"]}, contexts: {})
161
+ IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "confidential.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****18aa7"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
162
+ end
163
+
164
+ # confidential encrypted string is redacted using ciphertext hash
165
+ def test_confidential_encrypted_string_is_redacted_using_ciphertext_hash
166
+ aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
167
+ IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["a.secret.config"]}, contexts: {})
168
+ IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "a.secret.config", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****936c9"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
113
169
  end
114
170
  end
@@ -0,0 +1,175 @@
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
+
133
+ # Regression: client used to hardcode weighted_value_index: nil, so split
134
+ # variants never reported as REASON_SPLIT in telemetry.
135
+ def test_weighted_value_records_split_reason_and_index
136
+ weighted_config = {
137
+ 'id' => 'cid-weighted',
138
+ 'key' => 'feature-flag.weighted',
139
+ 'type' => 'feature_flag',
140
+ 'valueType' => 'string',
141
+ 'sendToClientSdk' => false,
142
+ 'default' => {
143
+ 'rules' => [
144
+ {
145
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
146
+ 'value' => {
147
+ 'type' => 'weighted_values',
148
+ 'value' => {
149
+ 'hashByPropertyName' => 'user.key',
150
+ 'weightedValues' => [
151
+ { 'value' => { 'type' => 'string', 'value' => 'control' }, 'weight' => 1 },
152
+ { 'value' => { 'type' => 'string', 'value' => 'variant' }, 'weight' => 99 }
153
+ ]
154
+ }
155
+ }
156
+ }
157
+ ]
158
+ },
159
+ 'environment' => nil
160
+ }
161
+
162
+ store = Quonfig::ConfigStore.new
163
+ store.set('feature-flag.weighted', weighted_config)
164
+
165
+ client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
166
+ client.get('feature-flag.weighted', Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
167
+
168
+ counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
169
+ assert_equal Quonfig::EvalResult::REASON_SPLIT, counter['reason'],
170
+ 'weighted variant evaluation must report REASON_SPLIT (3)'
171
+ refute_nil counter['weightedValueIndex'],
172
+ 'weighted variant evaluation must report a weightedValueIndex'
173
+ assert_kind_of Integer, counter['weightedValueIndex']
174
+ end
175
+ 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
- assert_equal 'team:t1|user:u1', context.grouped_key
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(ArgumentError) { Quonfig::Datadir.load_envelope(@tmpdir, nil) }
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(ArgumentError) { Quonfig::Datadir.load_envelope(@tmpdir, 'NotAnEnv') }
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