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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/bound_client.rb +26 -0
- data/lib/quonfig/client.rb +212 -3
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/dev_context.rb +41 -0
- 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 +84 -3
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/options.rb +4 -1
- data/lib/quonfig/resolver.rb +215 -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 +212 -0
- data/lib/quonfig.rb +10 -0
- data/quonfig.gemspec +23 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_dev_overrides.rb +40 -0
- 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 +532 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +77 -21
- data/test/test_client_telemetry.rb +175 -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_dev_context.rb +163 -0
- 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 +22 -3
- data/scripts/generate_integration_tests.rb +0 -362
data/lib/quonfig/resolver.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|