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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Quonfig
4
6
  # Public-API resolver: looks up a config by key in a ConfigStore and runs
5
7
  # it through an Evaluator against a Context.
@@ -14,6 +16,13 @@ module Quonfig
14
16
  # production read path (with config_loader, SSE updates, telemetry), see
15
17
  # Quonfig::ConfigResolver — the two coexist during the JSON migration.
16
18
  class Resolver
19
+ TRUE_VALUES = %w[true 1 t yes].freeze
20
+ # Prefix the eval-summary aggregator stamps onto redacted confidential
21
+ # values before the 5-char MD5 hash. Matches CONFIDENTIAL_PREFIX in
22
+ # ReforgeHQ/sdk-ruby/lib/reforge/config_value_unwrapper.rb so dashboards
23
+ # built against the predecessor wire format keep working.
24
+ CONFIDENTIAL_PREFIX = '*****'
25
+
17
26
  attr_reader :store, :evaluator
18
27
  attr_accessor :project_env_id
19
28
 
@@ -26,11 +35,56 @@ module Quonfig
26
35
  @store.get(key)
27
36
  end
28
37
 
38
+ # Look up +key+ and evaluate against +context+. Mirrors Quonfig.get_or_raise
39
+ # semantics: if the key is unknown to the store, raise
40
+ # Quonfig::Errors::MissingDefaultError so callers can distinguish "no
41
+ # such config" from "config matched a nil/false value". Tests that want
42
+ # the legacy "return nil if absent" shape can rescue and recover (see
43
+ # IntegrationTestHelpers.assert_resolved, which folds a missing-key
44
+ # raise into the test's expected default).
29
45
  def get(key, context = nil)
30
46
  config = raw(key)
31
- return nil unless config
47
+ raise Quonfig::Errors::MissingDefaultError.new(key) if config.nil?
48
+
49
+ eval_result = @evaluator.evaluate_config(config, context, resolver: self)
50
+ return nil if eval_result.nil?
32
51
 
33
- @evaluator.evaluate_config(config, context, resolver: self)
52
+ weighted_index = nil
53
+ resolved_value = resolve_value(eval_result.value, config, context) do |idx|
54
+ weighted_index = idx
55
+ end
56
+ EvalResult.new(
57
+ value: resolved_value,
58
+ rule_index: eval_result.rule_index,
59
+ config: config,
60
+ weighted_value_index: weighted_index,
61
+ reportable_value: redacted_reportable_value(eval_result.value)
62
+ )
63
+ end
64
+
65
+ # Post-evaluation value resolution. Mirrors sdk-node Resolver#resolveValue
66
+ # and sdk-go resolver.Resolve:
67
+ # - "provided" + ENV_VAR → read ENV[lookup], coerce to config's valueType
68
+ # - confidential + decryptWith → look up the key config, decrypt
69
+ # - everything else passes through unchanged
70
+ def resolve_value(value, config, context = nil, &on_weighted_index)
71
+ return nil if value.nil?
72
+
73
+ type = vget(value, :type, 'type')
74
+
75
+ if type == 'provided'
76
+ return resolve_provided(value, config)
77
+ end
78
+
79
+ if type == 'weighted_values'
80
+ return resolve_weighted(value, config, context, &on_weighted_index)
81
+ end
82
+
83
+ confidential = vget(value, :confidential, 'confidential')
84
+ decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
85
+ return resolve_decryption(value, config, context, decrypt_with) if confidential && decrypt_with && !decrypt_with.to_s.empty?
86
+
87
+ value
34
88
  end
35
89
 
36
90
  # Integration shims for code that expects a ConfigResolver. Keep these
@@ -38,5 +92,164 @@ module Quonfig
38
92
  def symbolize_json_names?
39
93
  false
40
94
  end
95
+
96
+ private
97
+
98
+ # If +value+ is confidential or has a decryptWith key, return the
99
+ # `*****<5-hex>` redacted string the eval-summary telemetry aggregator
100
+ # should ship in place of the resolved plaintext. The hash is computed
101
+ # over the raw `value[:value]` (ciphertext when decryptWith is set,
102
+ # plaintext-as-stored when only `confidential: true`) — matches
103
+ # ReforgeHQ/sdk-ruby ConfigValueUnwrapper#reportable_wrapped_value
104
+ # (CONFIDENTIAL_PREFIX + first 5 chars of MD5).
105
+ def redacted_reportable_value(value)
106
+ return nil if value.nil?
107
+
108
+ confidential = vget(value, :confidential, 'confidential')
109
+ decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
110
+ return nil unless confidential || (decrypt_with && !decrypt_with.to_s.empty?)
111
+
112
+ raw = vget(value, :value, 'value')
113
+ return nil if raw.nil?
114
+
115
+ "#{CONFIDENTIAL_PREFIX}#{Digest::MD5.hexdigest(raw.to_s)[0, 5]}"
116
+ end
117
+
118
+ def vget(hash, *keys)
119
+ return nil if hash.nil?
120
+
121
+ keys.each do |k|
122
+ return hash[k] if hash.is_a?(Hash) && hash.key?(k)
123
+ end
124
+ nil
125
+ end
126
+
127
+ def config_key(config)
128
+ return nil if config.nil?
129
+
130
+ vget(config, :key, 'key')
131
+ end
132
+
133
+ def config_value_type(config)
134
+ return nil if config.nil?
135
+
136
+ vget(config, :value_type, 'value_type', 'valueType', :valueType)
137
+ end
138
+
139
+ def resolve_provided(value, config)
140
+ provided = vget(value, :value, 'value')
141
+ return value if provided.nil?
142
+
143
+ source = vget(provided, :source, 'source')
144
+ lookup = vget(provided, :lookup, 'lookup')
145
+ return value if source != 'ENV_VAR' || lookup.nil? || lookup.to_s.empty?
146
+
147
+ env_value = ENV[lookup.to_s]
148
+ if env_value.nil?
149
+ raise Quonfig::Errors::MissingEnvVarError,
150
+ %(Environment variable "#{lookup}" not set for config "#{config_key(config)}")
151
+ end
152
+
153
+ value_type = config_value_type(config)
154
+ coerced = coerce_env_value(env_value, value_type, config, lookup)
155
+ {
156
+ 'type' => coerced_value_type(value_type),
157
+ 'value' => coerced
158
+ }
159
+ end
160
+
161
+ # Pick a weighted variant. Mirrors sdk-node Resolver#resolveWeightedValues
162
+ # and sdk-go resolveWeightedValues: hash the configured context property
163
+ # (or fall back to a per-call random) into [0,1), then walk the variant
164
+ # weights until cumulative weight >= bucket. Recurses through
165
+ # resolve_value so nested provided/encrypted variants work too.
166
+ def resolve_weighted(value, config, context, &on_weighted_index)
167
+ payload = vget(value, :value, 'value') || {}
168
+ weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
169
+ return value unless weighted.is_a?(Array) && !weighted.empty?
170
+
171
+ hash_property = vget(payload, :hashByPropertyName, 'hashByPropertyName',
172
+ :hash_by_property_name, 'hash_by_property_name')
173
+ hash_value = nil
174
+ if hash_property && context
175
+ ctx_value =
176
+ if context.respond_to?(:get)
177
+ context.get(hash_property.to_s)
178
+ elsif context.is_a?(Hash)
179
+ ctx_obj = Quonfig::Context.new(context)
180
+ ctx_obj.get(hash_property.to_s)
181
+ end
182
+ hash_value = ctx_value.to_s unless ctx_value.nil?
183
+ end
184
+
185
+ cfg_key = config_key(config)
186
+ picker = Quonfig::WeightedValueResolver.new(weighted, cfg_key, hash_value)
187
+ variant, index = picker.resolve
188
+ on_weighted_index&.call(index)
189
+ variant_value = vget(variant, :value, 'value')
190
+ resolve_value(variant_value, config, context, &on_weighted_index)
191
+ end
192
+
193
+ # Recursively resolve the decryption-key config (it may itself be a
194
+ # provided ENV_VAR), then AES-GCM decrypt the value with that key.
195
+ def resolve_decryption(value, config, context, decrypt_with)
196
+ key_cfg = @store.get(decrypt_with)
197
+ raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" not found) if key_cfg.nil?
198
+
199
+ key_match = @evaluator.evaluate_config(key_cfg, context, resolver: self)
200
+ raise Quonfig::Error, %(Decryption key config "#{decrypt_with}" did not match) if key_match.nil?
201
+
202
+ resolved_key = resolve_value(key_match.value, key_cfg, context)
203
+ secret_key = vget(resolved_key, :value, 'value').to_s
204
+ raise Quonfig::Error, %(Decryption key from "#{decrypt_with}" is empty) if secret_key.empty?
205
+
206
+ ciphertext = vget(value, :value, 'value').to_s
207
+ begin
208
+ plaintext = Quonfig::Encryption.new(secret_key).decrypt(ciphertext)
209
+ rescue StandardError => e
210
+ raise Quonfig::Errors::DecryptionError.new(config_key(config), e.message), cause: e
211
+ end
212
+
213
+ {
214
+ 'type' => 'string',
215
+ 'value' => plaintext,
216
+ 'confidential' => true
217
+ }
218
+ end
219
+
220
+ # Coerce a raw env var string to the SDK type declared by the config.
221
+ # Matches sdk-node coerceValue (string/int/double/bool/string_list)
222
+ # and sdk-go coerceValue (string/int/double/bool). Anything else falls
223
+ # through as a string.
224
+ def coerce_env_value(env_value, value_type, config, lookup)
225
+ case value_type
226
+ when 'string', nil, ''
227
+ env_value
228
+ when 'int'
229
+ Integer(env_value, 10)
230
+ when 'double'
231
+ Float(env_value)
232
+ when 'bool'
233
+ TRUE_VALUES.include?(env_value.downcase)
234
+ when 'string_list'
235
+ env_value.split(/\s*,\s*/)
236
+ when 'duration'
237
+ env_value
238
+ else
239
+ env_value
240
+ end
241
+ rescue ArgumentError, TypeError
242
+ raise Quonfig::Errors::EnvVarParseError.new(env_value, config, lookup)
243
+ end
244
+
245
+ def coerced_value_type(value_type)
246
+ case value_type
247
+ when 'int' then 'int'
248
+ when 'double' then 'double'
249
+ when 'bool' then 'bool'
250
+ when 'string_list' then 'string_list'
251
+ else 'string'
252
+ end
253
+ end
41
254
  end
42
255
  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