quonfig 0.0.5 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +36 -0
  4. data/VERSION +1 -1
  5. data/lib/quonfig/client.rb +132 -2
  6. data/lib/quonfig/context.rb +10 -1
  7. data/lib/quonfig/datadir.rb +2 -4
  8. data/lib/quonfig/errors/decryption_error.rb +20 -0
  9. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  10. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  11. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  12. data/lib/quonfig/evaluator.rb +64 -2
  13. data/lib/quonfig/http_connection.rb +1 -1
  14. data/lib/quonfig/resolver.rb +187 -2
  15. data/lib/quonfig/stdlib_formatter.rb +95 -0
  16. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  17. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  18. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  19. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  20. data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
  21. data/lib/quonfig.rb +9 -0
  22. data/quonfig.gemspec +20 -4
  23. data/test/integration/test_context_precedence.rb +35 -117
  24. data/test/integration/test_datadir_environment.rb +15 -37
  25. data/test/integration/test_enabled.rb +157 -463
  26. data/test/integration/test_enabled_with_contexts.rb +19 -49
  27. data/test/integration/test_get.rb +43 -131
  28. data/test/integration/test_get_feature_flag.rb +7 -13
  29. data/test/integration/test_get_or_raise.rb +19 -45
  30. data/test/integration/test_get_weighted_values.rb +9 -4
  31. data/test/integration/test_helpers.rb +499 -4
  32. data/test/integration/test_post.rb +15 -5
  33. data/test/integration/test_telemetry.rb +63 -21
  34. data/test/test_client_telemetry.rb +132 -0
  35. data/test/test_context.rb +4 -1
  36. data/test/test_context_shape.rb +37 -0
  37. data/test/test_context_shape_aggregator.rb +126 -0
  38. data/test/test_datadir.rb +6 -2
  39. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  40. data/test/test_example_contexts_aggregator.rb +119 -0
  41. data/test/test_http_connection.rb +1 -1
  42. data/test/test_resolver.rb +149 -2
  43. data/test/test_should_log.rb +186 -0
  44. data/test/test_stdlib_formatter.rb +195 -0
  45. data/test/test_telemetry_reporter.rb +209 -0
  46. metadata +19 -3
  47. data/scripts/generate_integration_tests.rb +0 -362
@@ -14,6 +14,8 @@ module Quonfig
14
14
  # production read path (with config_loader, SSE updates, telemetry), see
15
15
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
16
16
  class Resolver
17
+ TRUE_VALUES = %w[true 1 t yes].freeze
18
+
17
19
  attr_reader :store, :evaluator
18
20
  attr_accessor :project_env_id
19
21
 
@@ -26,11 +28,55 @@ module Quonfig
26
28
  @store.get(key)
27
29
  end
28
30
 
31
+ # Look up +key+ and evaluate against +context+. Mirrors Quonfig.get_or_raise
32
+ # semantics: if the key is unknown to the store, raise
33
+ # Quonfig::Errors::MissingDefaultError so callers can distinguish "no
34
+ # such config" from "config matched a nil/false value". Tests that want
35
+ # the legacy "return nil if absent" shape can rescue and recover (see
36
+ # IntegrationTestHelpers.assert_resolved, which folds a missing-key
37
+ # raise into the test's expected default).
29
38
  def get(key, context = nil)
30
39
  config = raw(key)
31
- return nil unless config
40
+ raise Quonfig::Errors::MissingDefaultError.new(key) if config.nil?
41
+
42
+ eval_result = @evaluator.evaluate_config(config, context, resolver: self)
43
+ return nil if eval_result.nil?
44
+
45
+ weighted_index = nil
46
+ resolved_value = resolve_value(eval_result.value, config, context) do |idx|
47
+ weighted_index = idx
48
+ end
49
+ EvalResult.new(
50
+ value: resolved_value,
51
+ rule_index: eval_result.rule_index,
52
+ config: config,
53
+ weighted_value_index: weighted_index
54
+ )
55
+ end
56
+
57
+ # Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
58
+ # and sdk-go resolver.Resolve:
59
+ # - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
60
+ # - confidential + decryptWith → look up the key config, decrypt
61
+ # - everything else passes through unchanged
62
+ def resolve_value(value, config, context = nil, &on_weighted_index)
63
+ return nil if value.nil?
32
64
 
33
- @evaluator.evaluate_config(config, context, resolver: self)
65
+ type = vget(value, :type, 'type')
66
+
67
+ if type == 'provided'
68
+ return resolve_provided(value, config)
69
+ end
70
+
71
+ if type == 'weighted_values'
72
+ return resolve_weighted(value, config, context, &on_weighted_index)
73
+ end
74
+
75
+ confidential = vget(value, :confidential, 'confidential')
76
+ decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
77
+ return resolve_decryption(value, config, context, decrypt_with) if confidential && decrypt_with && !decrypt_with.to_s.empty?
78
+
79
+ value
34
80
  end
35
81
 
36
82
  # Integration shims for code that expects a ConfigResolver. Keep these
@@ -38,5 +84,144 @@ module Quonfig
38
84
  def symbolize_json_names?
39
85
  false
40
86
  end
87
+
88
+ private
89
+
90
+ def vget(hash, *keys)
91
+ return nil if hash.nil?
92
+
93
+ keys.each do |k|
94
+ return hash[k] if hash.is_a?(Hash) && hash.key?(k)
95
+ end
96
+ nil
97
+ end
98
+
99
+ def config_key(config)
100
+ return nil if config.nil?
101
+
102
+ vget(config, :key, 'key')
103
+ end
104
+
105
+ def config_value_type(config)
106
+ return nil if config.nil?
107
+
108
+ vget(config, :value_type, 'value_type', 'valueType', :valueType)
109
+ end
110
+
111
+ def resolve_provided(value, config)
112
+ provided = vget(value, :value, 'value')
113
+ return value if provided.nil?
114
+
115
+ source = vget(provided, :source, 'source')
116
+ lookup = vget(provided, :lookup, 'lookup')
117
+ return value if source != 'ENV_VAR' || lookup.nil? || lookup.to_s.empty?
118
+
119
+ env_value = ENV[lookup.to_s]
120
+ if env_value.nil?
121
+ raise Quonfig::Errors::MissingEnvVarError,
122
+ %(Environment variable "#{lookup}" not set for config "#{config_key(config)}")
123
+ end
124
+
125
+ value_type = config_value_type(config)
126
+ coerced = coerce_env_value(env_value, value_type, config, lookup)
127
+ {
128
+ 'type' => coerced_value_type(value_type),
129
+ 'value' => coerced
130
+ }
131
+ end
132
+
133
+ # Pick a weighted variant. Mirrors sdk-node Resolver#resolveWeightedValues
134
+ # and sdk-go resolveWeightedValues: hash the configured context property
135
+ # (or fall back to a per-call random) into [0,1), then walk the variant
136
+ # weights until cumulative weight >= bucket. Recurses through
137
+ # resolve_value so nested provided/encrypted variants work too.
138
+ def resolve_weighted(value, config, context, &on_weighted_index)
139
+ payload = vget(value, :value, 'value') || {}
140
+ weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
141
+ return value unless weighted.is_a?(Array) && !weighted.empty?
142
+
143
+ hash_property = vget(payload, :hashByPropertyName, 'hashByPropertyName',
144
+ :hash_by_property_name, 'hash_by_property_name')
145
+ hash_value = nil
146
+ if hash_property && context
147
+ ctx_value =
148
+ if context.respond_to?(:get)
149
+ context.get(hash_property.to_s)
150
+ elsif context.is_a?(Hash)
151
+ ctx_obj = Quonfig::Context.new(context)
152
+ ctx_obj.get(hash_property.to_s)
153
+ end
154
+ hash_value = ctx_value.to_s unless ctx_value.nil?
155
+ end
156
+
157
+ cfg_key = config_key(config)
158
+ picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
159
+ variant, index = picker.resolve
160
+ on_weighted_index&.call(index)
161
+ variant_value = vget(variant, :value, 'value')
162
+ resolve_value(variant_value, config, context, &on_weighted_index)
163
+ end
164
+
165
+ # Recursively resolve the decryption-key config (it may itself be a
166
+ # provided ENV_VAR), then AES-GCM decrypt the value with that key.
167
+ def resolve_decryption(value, config, context, decrypt_with)
168
+ key_cfg = @store.get(decrypt_with)
169
+ raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
170
+
171
+ key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
172
+ raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
173
+
174
+ resolved_key = resolve_value(key_match.value, key_cfg, context)
175
+ secret_key = vget(resolved_key, :value, 'value').to_s
176
+ raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
177
+
178
+ ciphertext = vget(value, :value, 'value').to_s
179
+ begin
180
+ plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
181
+ rescue StandardError => e
182
+ raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message)
183
+ end
184
+
185
+ {
186
+ 'type' => 'string',
187
+ 'value' => plaintext,
188
+ 'confidential' => true
189
+ }
190
+ end
191
+
192
+ # Coerce a raw env var string to the SDK type declared by the config.
193
+ # Matches sdk-node coerceValue (string/int/double/bool/string_list)
194
+ # and sdk-go coerceValue (string/int/double/bool). Anything else falls
195
+ # through as a string.
196
+ def coerce_env_value(env_value, value_type, config, lookup)
197
+ case value_type
198
+ when 'string', nil, ''
199
+ env_value
200
+ when 'int'
201
+ Integer(env_value, 10)
202
+ when 'double'
203
+ Float(env_value)
204
+ when 'bool'
205
+ TRUE_VALUES.include?(env_value.downcase)
206
+ when 'string_list'
207
+ env_value.split(/\s*,\s*/)
208
+ when 'duration'
209
+ env_value
210
+ else
211
+ env_value
212
+ end
213
+ rescue ArgumentError, TypeError
214
+ raise Quonfig::Errors::EnvVarParseError.new(env_value, config, lookup)
215
+ end
216
+
217
+ def coerced_value_type(value_type)
218
+ case value_type
219
+ when 'int' then 'int'
220
+ when 'double' then 'double'
221
+ when 'bool' then 'bool'
222
+ when 'string_list' then 'string_list'
223
+ else 'string'
224
+ end
225
+ end
41
226
  end
42
227
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Adapter that plugs Quonfig's dynamic log-level evaluation into Ruby's
5
+ # built-in +::Logger+. The formatter is a proc with the stdlib Logger
6
+ # contract — +(severity, datetime, progname, msg) -> String+ — that calls
7
+ # +client.should_log?+ before each line and returns either a formatted
8
+ # record (emit) or an empty string (suppress).
9
+ #
10
+ # Returning an empty string is how you "drop" a log line through the
11
+ # formatter hook: +::Logger+ writes exactly what the formatter returns,
12
+ # so an empty string produces zero visible output. We deliberately do NOT
13
+ # return +nil+ — stdlib Logger would still call +.to_s+ (→ "") on some
14
+ # Ruby versions but would emit "\n" from +::Logger::Formatter+ subclasses
15
+ # that pre-wrap the message. Empty string is the portable zero-output
16
+ # sentinel.
17
+ #
18
+ # Usage:
19
+ # client = Quonfig::Client.new(logger_key: 'log-level.my-app')
20
+ # logger = ::Logger.new($stdout)
21
+ # logger.formatter = client.stdlib_formatter # progname wins
22
+ # # or
23
+ # logger.formatter = client.stdlib_formatter(logger_name: 'MyApp::Svc')
24
+ #
25
+ # +logger_name+ (optional) is the fallback when +progname+ is nil / the
26
+ # caller doesn't pass one to the individual log call. If both are set,
27
+ # +logger_name+ wins — matching ReforgeHQ's stdlib_formatter semantics.
28
+ #
29
+ # Level mapping (stdlib severity string → quonfig level symbol):
30
+ #
31
+ # "DEBUG" -> :debug
32
+ # "INFO" -> :info
33
+ # "WARN" -> :warn
34
+ # "ERROR" -> :error
35
+ # "FATAL" -> :fatal
36
+ # "ANY" -> :fatal (Logger::UNKNOWN — treat as top severity)
37
+ # other -> :info (defensive — unknown labels don't silently drop)
38
+ #
39
+ # No normalization is applied to +progname+ / +logger_name+; they are
40
+ # passed verbatim into +quonfig-sdk-logging.key+ so customer matching
41
+ # rules can target exact class names (e.g. +PROP_STARTS_WITH_ONE_OF
42
+ # "MyApp::Services::"+). Parallels the SemanticLoggerFilter.
43
+ module StdlibFormatter
44
+ # Ruby stdlib Logger severity strings → quonfig level symbols. Covers
45
+ # every label the stdlib actually emits.
46
+ SEVERITY_TO_LEVEL = {
47
+ 'DEBUG' => :debug,
48
+ 'INFO' => :info,
49
+ 'WARN' => :warn,
50
+ 'ERROR' => :error,
51
+ 'FATAL' => :fatal,
52
+ 'ANY' => :fatal # Logger::UNKNOWN formats as "ANY"
53
+ }.freeze
54
+
55
+ # Build a formatter Proc. Exposed on +Quonfig::Client#stdlib_formatter+;
56
+ # callers should prefer the client helper.
57
+ #
58
+ # @param client [Quonfig::Client] the client whose +should_log?+ gates output.
59
+ # @param logger_name [String, nil] fallback logger identifier when the
60
+ # Logger call-site doesn't supply a progname.
61
+ # @return [Proc] a (severity, datetime, progname, msg) → String proc.
62
+ def self.build(client, logger_name: nil)
63
+ unless client.logger_key
64
+ raise Quonfig::Error,
65
+ 'logger_key must be set at init to use stdlib_formatter. ' \
66
+ 'Pass `logger_key:` to Quonfig::Options.new, or call ' \
67
+ 'semantic_logger_filter(config_key:) / get(config_key) directly.'
68
+ end
69
+
70
+ # Arity MUST be 4 — ::Logger invokes the formatter with exactly that
71
+ # signature. Declared explicitly (not *args) so arity is 4, matching
72
+ # ::Logger::Formatter#call.
73
+ proc do |severity, datetime, progname, msg|
74
+ path = logger_name || progname
75
+ level = SEVERITY_TO_LEVEL[severity.to_s.upcase] || :info
76
+
77
+ if client.should_log?(logger_path: path, desired_level: level)
78
+ format_record(severity, datetime, progname, msg)
79
+ else
80
+ ''
81
+ end
82
+ end
83
+ end
84
+
85
+ # Default record formatter. Matches ::Logger::Formatter's general shape
86
+ # ("I, [timestamp pid] SEVERITY -- progname: message") but without the
87
+ # process-id first-letter noise so output is readable in tests and
88
+ # modern dev logs. Callers who want a different format can wrap the
89
+ # gated proc with their own renderer.
90
+ def self.format_record(severity, datetime, progname, msg)
91
+ ts = datetime.respond_to?(:strftime) ? datetime.strftime('%Y-%m-%dT%H:%M:%S.%6N') : datetime.to_s
92
+ "[#{ts}] #{severity} -- #{progname}: #{msg}\n"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Telemetry
5
+ # Maps a context property value to the numeric field-type code used by
6
+ # api-telemetry. The numbers match the codes used by sdk-node and sdk-go
7
+ # (and historically the Prefab proto ConfigValue oneof):
8
+ #
9
+ # 1 = integer
10
+ # 2 = string
11
+ # 4 = double (float)
12
+ # 5 = boolean
13
+ # 10 = string list (array)
14
+ class ContextShape
15
+ MAPPING = {
16
+ Integer => 1,
17
+ String => 2,
18
+ Float => 4,
19
+ TrueClass => 5,
20
+ FalseClass => 5,
21
+ Array => 10
22
+ }.freeze
23
+
24
+ # We default to 2 (String) for unknown types — criteria evaluation
25
+ # treats them as strings via #to_s.
26
+ DEFAULT = MAPPING[String]
27
+
28
+ def self.field_type_number(value)
29
+ MAPPING.fetch(value.class, DEFAULT)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Telemetry
5
+ # Aggregates the set of context shapes observed during config
6
+ # evaluation. Each unique (context-name, property, type) tuple is
7
+ # stored once. On sync, the set is folded into a hash grouped by
8
+ # context name and emitted as api-telemetry's `contextShapes` event.
9
+ #
10
+ # Matches the sdk-node/sdk-go JSON wire format — NOT the old
11
+ # Prefab protobuf serialization.
12
+ class ContextShapeAggregator
13
+ attr_reader :data
14
+
15
+ def initialize(max_shapes:)
16
+ @max_shapes = max_shapes
17
+ @data = Concurrent::Set.new
18
+ end
19
+
20
+ # Record every property of every named context in +context+.
21
+ # +context+ may be a Quonfig::Context or a bare Hash
22
+ # ({ 'user' => { 'key' => ..., 'email' => ... }, ... }).
23
+ def push(context)
24
+ return if @max_shapes <= 0
25
+ return if context.nil?
26
+ return if @data.size >= @max_shapes
27
+
28
+ each_named_context(context) do |name, hash|
29
+ next unless hash.is_a?(Hash)
30
+
31
+ hash.each_pair do |key, value|
32
+ next if @data.size >= @max_shapes
33
+
34
+ @data.add [name.to_s, key.to_s, Quonfig::Telemetry::ContextShape.field_type_number(value)]
35
+ end
36
+ end
37
+ end
38
+
39
+ # Fold the raw tuples into { name => { key => type, ... }, ... }.
40
+ # Clears the underlying set.
41
+ def prepare_data
42
+ duped = @data.dup
43
+ @data.clear
44
+
45
+ duped.inject({}) do |acc, (name, key, type)|
46
+ acc[name] ||= {}
47
+ acc[name][key] = type
48
+ acc
49
+ end
50
+ end
51
+
52
+ # Drain accumulated shapes into a single telemetry event payload,
53
+ # matching api-telemetry's ContextShapesSchema. Returns +nil+ when
54
+ # there is nothing to ship — the reporter should skip empty events.
55
+ def drain_event
56
+ return nil if @data.size.zero?
57
+
58
+ shapes = prepare_data.map do |name, field_types|
59
+ { 'name' => name, 'fieldTypes' => field_types }
60
+ end
61
+
62
+ { 'contextShapes' => { 'shapes' => shapes } }
63
+ end
64
+
65
+ private
66
+
67
+ def each_named_context(context)
68
+ if context.respond_to?(:contexts)
69
+ # Quonfig::Context — each_pair yields (name, NamedContext)
70
+ context.contexts.each_pair do |name, named|
71
+ yield name, (named.respond_to?(:to_h) ? named.to_h : named)
72
+ end
73
+ elsif context.is_a?(Hash)
74
+ context.each_pair do |name, values|
75
+ values = { name.to_s => values } unless values.is_a?(Hash)
76
+ yield name, values
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Telemetry
5
+ # Accumulates per-evaluation counts grouped by (config_key, config_type),
6
+ # with one counter per unique (config_id, conditional_value_index,
7
+ # weighted_value_index, selected_value). Emits api-telemetry's
8
+ # `summaries` event — JSON wire format matching sdk-node and sdk-go.
9
+ #
10
+ # Ported from ReforgeHQ/sdk-ruby evaluation_summary_aggregator.rb and
11
+ # adapted to the JSON wire format (EvaluationSummariesSchema in
12
+ # api-telemetry/src/telemetry-schemas.ts).
13
+ class EvaluationSummariesAggregator
14
+ attr_reader :data
15
+
16
+ def initialize(max_keys:)
17
+ @max_keys = max_keys
18
+ @data = Concurrent::Hash.new
19
+ @start_at_ms = nil
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Record a single evaluation.
24
+ #
25
+ # @param config_id [String]
26
+ # @param config_key [String]
27
+ # @param config_type [String, nil] "config", "feature_flag", etc.
28
+ # "log_level" evaluations are intentionally dropped (they're high
29
+ # volume and not useful for usage analytics).
30
+ # @param conditional_value_index [Integer] rule index
31
+ # @param weighted_value_index [Integer, nil]
32
+ # @param selected_value [Object] the unwrapped evaluated value
33
+ # @param reason [Integer] wire reason code (see Quonfig::Reason::WIRE_*)
34
+ def record(config_id:, config_key:, config_type:,
35
+ conditional_value_index:, weighted_value_index: nil,
36
+ selected_value: nil, reason: 0)
37
+ return if @max_keys <= 0
38
+ return if config_type == 'log_level'
39
+
40
+ group_key = [config_key, config_type]
41
+ counter_key = [config_id, conditional_value_index, weighted_value_index, selected_value]
42
+
43
+ @mutex.synchronize do
44
+ unless @data.key?(group_key)
45
+ return if @data.size >= @max_keys
46
+
47
+ @data[group_key] = {}
48
+ end
49
+ @start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
50
+
51
+ bucket = @data[group_key]
52
+ bucket[counter_key] ||= { count: 0, reason: reason }
53
+ bucket[counter_key][:count] += 1
54
+ end
55
+ end
56
+
57
+ # Drain accumulated summaries into a single telemetry event payload.
58
+ # Returns +nil+ when there is nothing to ship.
59
+ def drain_event
60
+ snapshot = nil
61
+ start_at = nil
62
+
63
+ @mutex.synchronize do
64
+ return nil if @data.empty?
65
+
66
+ snapshot = @data
67
+ start_at = @start_at_ms
68
+ @data = Concurrent::Hash.new
69
+ @start_at_ms = nil
70
+ end
71
+
72
+ summaries = snapshot.map do |(config_key, config_type), counters|
73
+ counter_list = counters.map do |(config_id, cvi, wvi, sval), meta|
74
+ counter = {
75
+ 'configId' => config_id,
76
+ 'conditionalValueIndex' => cvi,
77
+ 'configRowIndex' => 0,
78
+ 'selectedValue' => wrap_selected_value(sval),
79
+ 'count' => meta[:count],
80
+ 'reason' => meta[:reason]
81
+ }
82
+ counter['weightedValueIndex'] = wvi unless wvi.nil?
83
+ counter
84
+ end
85
+
86
+ entry = { 'key' => config_key, 'counters' => counter_list }
87
+ entry['type'] = config_type unless config_type.nil?
88
+ entry
89
+ end
90
+
91
+ {
92
+ 'summaries' => {
93
+ 'start' => start_at || Quonfig::TimeHelpers.now_in_ms,
94
+ 'end' => Quonfig::TimeHelpers.now_in_ms,
95
+ 'summaries' => summaries
96
+ }
97
+ }
98
+ end
99
+
100
+ private
101
+
102
+ # Wrap the evaluated value in the Prefab-proto-style tagged hash that
103
+ # api-telemetry ClickHouse ingestion expects. Keys match sdk-go's
104
+ # marshalSelectedValue (proto field names): bool / int / double /
105
+ # string / stringList.
106
+ def wrap_selected_value(value)
107
+ case value
108
+ when true, false then { 'bool' => value }
109
+ when Integer then { 'int' => value }
110
+ when Float then { 'double' => value }
111
+ when String then { 'string' => value }
112
+ when Array then { 'stringList' => value.map(&:to_s) }
113
+ when nil then { 'string' => '' }
114
+ else { 'string' => value.to_s }
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Telemetry
5
+ # Samples *example* contexts seen during evaluation. Dedupes by the
6
+ # concatenation of each named context's "key" property and rate-limits
7
+ # each grouped key to once per hour.
8
+ #
9
+ # Emits api-telemetry's `exampleContexts` event — JSON wire format,
10
+ # matching sdk-node and sdk-go. This is NOT the old Prefab protobuf.
11
+ class ExampleContextsAggregator
12
+ ONE_HOUR_SECONDS = 60 * 60
13
+
14
+ attr_reader :data, :cache
15
+
16
+ def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS)
17
+ @max_contexts = max_contexts
18
+ @data = Concurrent::Array.new
19
+ @cache = Quonfig::RateLimitCache.new(rate_limit_seconds)
20
+ end
21
+
22
+ # Record a context for possible emission. Expects a Quonfig::Context.
23
+ # Contexts with no grouped_key (nothing to dedupe on) are dropped to
24
+ # avoid shipping empty/anonymous samples.
25
+ def record(context)
26
+ return if @max_contexts <= 0
27
+ return if context.nil?
28
+
29
+ key = grouped_key_for(context)
30
+ return if key.nil? || key.empty?
31
+
32
+ return unless @data.size < @max_contexts && !@cache.fresh?(key)
33
+
34
+ @cache.set(key)
35
+ @data.push([Quonfig::TimeHelpers.now_in_ms, context])
36
+ end
37
+
38
+ def prepare_data
39
+ to_ship = @data.dup
40
+ @data.clear
41
+ @cache.prune
42
+ to_ship
43
+ end
44
+
45
+ # Drain accumulated examples into a single telemetry event payload
46
+ # matching api-telemetry's ExampleContextsSchema, or +nil+ if empty.
47
+ def drain_event
48
+ return nil if @data.size.zero?
49
+
50
+ to_ship = prepare_data
51
+
52
+ examples = to_ship.map do |timestamp_ms, context|
53
+ contexts_list = contexts_to_list(context)
54
+ { 'timestamp' => timestamp_ms, 'contextSet' => { 'contexts' => contexts_list } }
55
+ end
56
+
57
+ { 'exampleContexts' => { 'examples' => examples } }
58
+ end
59
+
60
+ private
61
+
62
+ def grouped_key_for(context)
63
+ return context.grouped_key if context.respond_to?(:grouped_key)
64
+
65
+ # Fallback for plain-Hash contexts: concatenate each named context's
66
+ # "key" (or "trackingId") value.
67
+ return nil unless context.is_a?(Hash)
68
+
69
+ context.values.map do |ctx|
70
+ next nil unless ctx.is_a?(Hash)
71
+
72
+ ctx['key'] || ctx[:key] || ctx['trackingId'] || ctx[:trackingId]
73
+ end.compact.map(&:to_s).reject(&:empty?).sort.join('|')
74
+ end
75
+
76
+ def contexts_to_list(context)
77
+ if context.respond_to?(:contexts)
78
+ context.contexts.map do |name, named|
79
+ values = named.respond_to?(:to_h) ? named.to_h : named
80
+ { 'type' => name.to_s, 'values' => stringify_values(values) }
81
+ end
82
+ elsif context.is_a?(Hash)
83
+ context.map do |name, values|
84
+ values = { name.to_s => values } unless values.is_a?(Hash)
85
+ { 'type' => name.to_s, 'values' => stringify_values(values) }
86
+ end
87
+ else
88
+ []
89
+ end
90
+ end
91
+
92
+ def stringify_values(hash)
93
+ return {} unless hash.is_a?(Hash)
94
+
95
+ hash.each_with_object({}) do |(k, v), acc|
96
+ acc[k.to_s] = v
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end