quonfig 1.7.1 → 1.8.0

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
 
5
6
  module Quonfig
6
7
  # Fetches config envelopes from the Quonfig delivery API and installs them
@@ -18,6 +19,79 @@ module Quonfig
18
19
 
19
20
  CONFIGS_PATH = '/api/v2/configs'
20
21
 
22
+ # Rule R1 (qfg-goi1.10.26): the auth class. A fetch cycle with no 200/304
23
+ # on any leg and one of these on the primary leg is rejected; the client's
24
+ # poller then enters auth-failed (Client#start_polling).
25
+ AUTH_STATUSES = [401, 403, 404, 410].freeze
26
+
27
+ # Result of one fetch cycle across the configured legs. +statuses+ maps a
28
+ # config_api_urls index to the HTTP status that leg returned (0 = no
29
+ # response: timeout, connection failure). +primary_host+ is host:port of
30
+ # the primary leg, for the entry ERROR (never the key).
31
+ class FetchCycle
32
+ attr_reader :result, :statuses, :primary_host
33
+
34
+ def initialize(result, statuses, primary_host)
35
+ @result = result
36
+ @statuses = statuses
37
+ @primary_host = primary_host
38
+ end
39
+
40
+ def primary_status
41
+ @statuses[0] || 0
42
+ end
43
+
44
+ # Rule R2: no leg returned 200/304 and the primary returned an
45
+ # auth-class status. A 200 the guard dropped, or a 304, never qualifies.
46
+ def auth_rejected?
47
+ return false unless @result == :failed
48
+ return false if @statuses.values.any? { |s| [200, 304].include?(s) }
49
+
50
+ AUTH_STATUSES.include?(primary_status)
51
+ end
52
+ end
53
+
54
+ # Collects one cycle's per-leg log lines. +mode+:
55
+ # :normal - log at once, as always (init fetch, refresh, legacy callers)
56
+ # :poll - a poller cycle: hold the auth-class lines until the cycle is
57
+ # classified, so an entering cycle logs its one ERROR in their
58
+ # place (rule R5); otherwise they are logged unchanged
59
+ # :probe - an auth-failed probe: every line at DEBUG (rule R5)
60
+ class CycleLog
61
+ def initialize(logger, mode)
62
+ @logger = logger
63
+ @mode = mode
64
+ @held = []
65
+ @released = false
66
+ @mutex = Mutex.new
67
+ end
68
+
69
+ def log(level, msg, auth: false)
70
+ return @logger.debug(msg) if @mode == :probe
71
+
72
+ if @mode == :poll && auth
73
+ hold = @mutex.synchronize do
74
+ @held << [level, msg] unless @released
75
+ !@released
76
+ end
77
+ return if hold
78
+ end
79
+ @logger.public_send(level, msg)
80
+ end
81
+
82
+ # Log (or with +drop+, discard) the held lines. Lines from a leg that
83
+ # settles later are logged as they come.
84
+ def release(drop: false)
85
+ held = @mutex.synchronize do
86
+ @released = true
87
+ h = @held
88
+ @held = []
89
+ h
90
+ end
91
+ held.each { |level, msg| @logger.public_send(level, msg) } unless drop
92
+ end
93
+ end
94
+
21
95
  attr_reader :version, :environment_id
22
96
 
23
97
  # qfg-7h5d.1.9 (canonical ordering). Diagnostic surface read by the failover/
@@ -127,17 +201,32 @@ module Quonfig
127
201
  # :not_modified -- a leg answered 304 (no change) and nothing installed
128
202
  # :failed -- every fired leg failed
129
203
  def fetch!
204
+ fetch_cycle!.result
205
+ end
206
+
207
+ # One fetch cycle, as #fetch!, returning a FetchCycle that carries each
208
+ # leg's status (rule R2, qfg-goi1.10.26). +log_mode+ is one of CycleLog's
209
+ # modes; the poller passes :poll for an ordinary cycle and :probe while
210
+ # auth-failed.
211
+ def fetch_cycle!(log_mode: :normal)
130
212
  urls = Array(@options.config_api_urls)
131
- return :failed if urls.empty?
213
+ statuses = Concurrent::Map.new
214
+ return FetchCycle.new(:failed, statuses, '') if urls.empty?
132
215
 
216
+ cycle_log = CycleLog.new(@logger, log_mode)
133
217
  # Single leg (or no secondary configured): no hedge, just fetch on the
134
218
  # calling thread under the SEQUENTIAL per-URL timeout (config_fetch_timeout_ms
135
219
  # is unchanged and still governs any non-hedged path). Preserves the
136
220
  # synchronous, single-request-per-call shape the legacy/mock callers depend
137
221
  # on.
138
- return fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms) if urls.length < 2
139
-
140
- fetch_hedged(urls)
222
+ result = if urls.length < 2
223
+ fetch_from(urls[0], 0, timeout_ms: config_fetch_timeout_ms, statuses: statuses, cycle_log: cycle_log)
224
+ else
225
+ fetch_hedged(urls, statuses: statuses, cycle_log: cycle_log)
226
+ end
227
+ cycle = FetchCycle.new(result, statuses, host_of(urls[0]))
228
+ cycle_log.release(drop: cycle.auth_rejected?)
229
+ cycle
141
230
  end
142
231
 
143
232
  # Apply a ConfigEnvelope (from SSE) to the store. Called by the SSE client
@@ -176,7 +265,7 @@ module Quonfig
176
265
  # the hedge abort inside fetch_from, and heals forward through the
177
266
  # reject-older guard. We never join the slow leg — a hung primary must not
178
267
  # block a successful secondary install.
179
- def fetch_hedged(urls)
268
+ def fetch_hedged(urls, statuses: nil, cycle_log: nil)
180
269
  hedge_delay_s = hedge_delay_ms / 1000.0
181
270
  abort_ms = hedge_abort_ms
182
271
 
@@ -204,7 +293,7 @@ module Quonfig
204
293
  Thread.new do
205
294
  result = :failed
206
295
  begin
207
- result = fetch_from(urls[index], index, timeout_ms: abort_ms)
296
+ result = fetch_from(urls[index], index, timeout_ms: abort_ms, statuses: statuses, cycle_log: cycle_log)
208
297
  rescue StandardError => e
209
298
  @logger.debug "Hedge leg #{index} failed: #{e.class}: #{e.message}"
210
299
  ensure
@@ -306,7 +395,15 @@ module Quonfig
306
395
  @options.respond_to?(:config_fetch_timeout_ms) ? @options.config_fetch_timeout_ms : nil
307
396
  end
308
397
 
309
- def fetch_from(source, index = nil, timeout_ms: nil)
398
+ def host_of(url)
399
+ uri = URI(url.to_s)
400
+ uri.host ? "#{uri.host}:#{uri.port}" : url.to_s
401
+ rescue URI::InvalidURIError
402
+ ''
403
+ end
404
+
405
+ def fetch_from(source, index = nil, timeout_ms: nil, statuses: nil, cycle_log: nil)
406
+ cycle_log ||= CycleLog.new(@logger, :normal)
310
407
  # qfg-7h5d.1.9 / .1.14: bound this single per-leg attempt so a hung upstream
311
408
  # aborts (Faraday::TimeoutError, caught below as :failed). On the hedged
312
409
  # path the caller passes config_fetch_hedge_abort_ms; on the sequential /
@@ -318,6 +415,7 @@ module Quonfig
318
415
  etag = etag_for(index)
319
416
  headers['If-None-Match'] = etag if etag
320
417
  response = conn.get(CONFIGS_PATH, headers)
418
+ statuses[index || 0] = response.status.to_i if statuses
321
419
 
322
420
  case response.status
323
421
  when 200
@@ -337,23 +435,24 @@ module Quonfig
337
435
  @logger.debug "Configs not modified (304) from #{source}"
338
436
  :not_modified
339
437
  when 401, 403
340
- @logger.warn "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}"
438
+ cycle_log.log(:warn, "Config fetch rejected (#{response.status}) from #{source}: #{short_body(response)}", auth: true)
341
439
  :failed
342
440
  else
343
- @logger.info "Config fetch failed: status #{response.status} from #{source}"
441
+ cycle_log.log(:info, "Config fetch failed: status #{response.status} from #{source}",
442
+ auth: AUTH_STATUSES.include?(response.status.to_i))
344
443
  :failed
345
444
  end
346
445
  rescue NonEnvelopeError => e
347
446
  # qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
348
447
  # hedge/failover proceeds and this leg's ETag is never stored (so a junk
349
448
  # 200 cannot pin itself through later 304s).
350
- @logger.warn "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg"
449
+ cycle_log.log(:warn, "Config fetch from #{source} returned a non-envelope 200 (#{e.message}); treating as a failed leg")
351
450
  :failed
352
451
  rescue Faraday::ConnectionFailed => e
353
452
  @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
354
453
  :failed
355
454
  rescue StandardError => e
356
- @logger.warn "Unexpected error fetching configs from #{source}: #{e.message}"
455
+ cycle_log.log(:warn, "Unexpected error fetching configs from #{source}: #{e.message}")
357
456
  :failed
358
457
  end
359
458
 
@@ -461,6 +560,25 @@ module Quonfig
461
560
  @version = meta['version'] || meta[:version] || @version
462
561
  @environment_id = meta['environment'] || meta[:environment] || @environment_id
463
562
 
563
+ # Replace the live store first, then advance the generation
564
+ # (qfg-goi1.10.2). An interrupt part-way through the store write
565
+ # (Thread#raise, Timeout) then leaves the held generation where it
566
+ # was, so the same-generation redelivery that follows installs again
567
+ # and repairs the store instead of being ignored as a duplicate.
568
+ unless @store.nil?
569
+ new_keys = next_map.keys.to_set
570
+ old_keys = @store.keys.to_set
571
+ # Drop keys that disappeared server-side.
572
+ (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
573
+
574
+ envelope.configs.each do |cfg|
575
+ key = config_key(cfg)
576
+ next if key.nil?
577
+
578
+ @store.set(key, cfg)
579
+ end
580
+ end
581
+
464
582
  # qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
465
583
  # ordering info and must never LOWER a positive held generation. Under
466
584
  # the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
@@ -473,21 +591,6 @@ module Quonfig
473
591
  # SSE / datadir installs pass source_index nil and are ignored by the
474
592
  # aggregator, so they are never counted.
475
593
  @failover_aggregator&.record_resolved_from(source_index)
476
-
477
- # Replace the live store atomically.
478
- return if @store.nil?
479
-
480
- new_keys = next_map.keys.to_set
481
- old_keys = @store.keys.to_set
482
- # Drop keys that disappeared server-side.
483
- (old_keys - new_keys).each { |k| @store.delete(k) } if @store.respond_to?(:delete)
484
-
485
- envelope.configs.each do |cfg|
486
- key = config_key(cfg)
487
- next if key.nil?
488
-
489
- @store.set(key, cfg)
490
- end
491
594
  end
492
595
  end
493
596
 
@@ -39,7 +39,10 @@ module Quonfig
39
39
  .sort
40
40
  .each do |filename|
41
41
  path = File.join(dir, filename)
42
- raw = JSON.parse(File.read(path))
42
+ # Always read as UTF-8: under LANG unset / LANG=C the process
43
+ # default is US-ASCII and JSON.parse would raise on any non-ASCII
44
+ # byte (qfg-goi1.2.11 / qfg-xy92).
45
+ raw = JSON.parse(File.read(path, encoding: 'UTF-8'))
43
46
  raise ArgumentError, "[quonfig] config has empty key — file is not a Quonfig Config: #{path}" if raw['key'].nil? || raw['key'].to_s.empty?
44
47
 
45
48
  coerce_numeric_values(raw)
@@ -68,7 +71,7 @@ module Quonfig
68
71
 
69
72
  raise ArgumentError, "[quonfig] Datadir is missing quonfig.json: #{quonfig_path}" unless File.exist?(quonfig_path)
70
73
 
71
- environments = JSON.parse(File.read(quonfig_path)).fetch('environments', [])
74
+ environments = JSON.parse(File.read(quonfig_path, encoding: 'UTF-8')).fetch('environments', [])
72
75
 
73
76
  raise Quonfig::Errors::InvalidEnvironmentError.new(environment, environments) if !environments.empty? && !environments.include?(environment)
74
77
 
@@ -21,7 +21,8 @@ module Quonfig
21
21
  return nil unless File.exist?(path)
22
22
 
23
23
  raw = begin
24
- File.read(path)
24
+ # UTF-8 regardless of the process locale (qfg-goi1.2.11 / qfg-xy92).
25
+ File.read(path, encoding: 'UTF-8')
25
26
  rescue StandardError => e
26
27
  LOG.warn "dev-context: could not read #{path} (#{e.class}: #{e.message}); skipping injection"
27
28
  return nil
@@ -3,7 +3,9 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class EnvVarParseError < Quonfig::Error
6
- def initialize(env_var, config, env_var_name)
6
+ # +stored: true+ marks a malformed stored value (no env var involved);
7
+ # the message then names the stored value rather than a variable.
8
+ def initialize(_env_var, config, env_var_name, stored: false)
7
9
  key, value_type =
8
10
  if config.is_a?(Hash)
9
11
  [config[:key] || config['key'],
@@ -11,7 +13,14 @@ module Quonfig
11
13
  else
12
14
  [config.key, config.value_type]
13
15
  end
14
- super("Evaluating #{key} couldn't coerce #{env_var_name} of #{env_var} to #{value_type}")
16
+ # +_env_var+ (the raw value) is deliberately left out of the message:
17
+ # an env var or stored value can be a secret (qfg-goi1.2.11). The
18
+ # wording is the cross-SDK template pinned by integration-test-data
19
+ # (v2026.10.12): it names the variable, the lowercase type and the
20
+ # config key, never the value or a parser message quoting it.
21
+ type_name = value_type.to_s.downcase
22
+ source = stored ? 'stored value' : %(environment variable "#{env_var_name}")
23
+ super(%(#{source} cannot be converted to #{type_name} for config "#{key}"))
15
24
  end
16
25
  end
17
26
  end
@@ -3,8 +3,12 @@
3
3
  module Quonfig
4
4
  module Errors
5
5
  class TypeMismatchError < Quonfig::Error
6
+ # The message names the key, the expected type and the actual value's
7
+ # class only. The value itself is never interpolated: it can be a
8
+ # decrypted secret, and exception messages end up in logs and error
9
+ # trackers (qfg-goi1.2.11).
6
10
  def initialize(key, expected, actual_value)
7
- super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}: #{actual_value.inspect}")
11
+ super("Quonfig value for key '#{key}' expected #{expected}, got #{actual_value.class}")
8
12
  end
9
13
  end
10
14
  end
@@ -54,8 +54,26 @@ module Quonfig
54
54
  OP_IS_PRESENT = 'IS_PRESENT'
55
55
  OP_IS_NOT_PRESENT = 'IS_NOT_PRESENT'
56
56
 
57
+ MAX_SAFE_INTEGER = (2**53) - 1
58
+
59
+ # Timestamp grammar for PROP_BEFORE / PROP_AFTER; see #date_to_millis.
60
+ INTEGER_STRING = /\A-?[0-9]+\z/
61
+ RFC3339_TIMESTAMP = /
62
+ \A(?<y>[0-9]{4})-(?<mo>[0-9]{2})-(?<d>[0-9]{2})
63
+ T(?<h>[0-9]{2}):(?<mi>[0-9]{2}):(?<s>[0-9]{2})(?:\.(?<frac>[0-9]+))?
64
+ (?:Z|(?<sign>[+-])(?<oh>[0-9]{2}):(?<om>[0-9]{2}))\z
65
+ /x
66
+
57
67
  MAGIC_CURRENT_TIME_PROPS = %w[quonfig.current-time prefab.current-time reforge.current-time].freeze
58
68
 
69
+ # PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
70
+ # 3.2+ supports Regexp.new(src, timeout:); Regexp::TimeoutError is a
71
+ # RegexpError, so a pattern that runs away (backreferences and lookarounds
72
+ # defeat Onigmo's memoization) does not match instead of pinning a
73
+ # thread. Ruby 3.0/3.1 have no timeout and are unchanged.
74
+ REGEX_MATCH_TIMEOUT_SECONDS = 0.1
75
+ REGEX_TIMEOUT_SUPPORTED = Regexp.respond_to?(:timeout)
76
+
59
77
  attr_reader :store
60
78
  attr_accessor :project_env_id, :env_id
61
79
 
@@ -218,7 +236,7 @@ module Quonfig
218
236
  mv = hget(match_value, :value)
219
237
  if context_exists && context_value.is_a?(String) && mv.is_a?(String)
220
238
  begin
221
- re = Regexp.new(mv)
239
+ re = REGEX_TIMEOUT_SUPPORTED ? Regexp.new(mv, timeout: REGEX_MATCH_TIMEOUT_SECONDS) : Regexp.new(mv)
222
240
  matched = re.match?(context_value)
223
241
  return matched == (operator == OP_PROP_MATCHES)
224
242
  rescue RegexpError
@@ -274,8 +292,8 @@ module Quonfig
274
292
  when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
275
293
  mv = hget(match_value, :value)
276
294
  if context_exists && context_value.is_a?(String) && mv.is_a?(String)
277
- sv_ctx = SemanticVersion.parse_quietly(context_value)
278
- sv_mv = SemanticVersion.parse_quietly(mv)
295
+ sv_ctx = SemanticVersion.parse_quietly(context_value, allow_abbreviated: true)
296
+ sv_mv = SemanticVersion.parse_quietly(mv, allow_abbreviated: true)
279
297
  if sv_ctx && sv_mv
280
298
  cmp = (sv_ctx <=> sv_mv)
281
299
  case operator
@@ -348,12 +366,27 @@ module Quonfig
348
366
 
349
367
  # --- Type coercion helpers ----------------------------------------
350
368
 
369
+ # Canonical string form (qfg-goi1.5 R1) for *_ONE_OF, the string
370
+ # operators and HIERARCHICAL_MATCH: strings as-is, booleans as
371
+ # "true"/"false", and an integral number below 2^53 as plain digits
372
+ # whatever its runtime type, so 1.0 is "1" and 5e9 is "5000000000"
373
+ # (Float#to_s would give "1.0" and "5.0e9").
351
374
  def to_s_nil(v)
352
375
  return '' if v.nil?
376
+ return v if v.is_a?(String)
377
+ return canonical_number(v) if v.is_a?(Numeric) && !v.is_a?(Integer)
353
378
 
354
379
  v.to_s
355
380
  end
356
381
 
382
+ def canonical_number(v)
383
+ return v.to_s unless v.real?
384
+ return v.to_s if v.respond_to?(:finite?) && !v.finite?
385
+ return v.to_s unless v == v.truncate && v.abs <= MAX_SAFE_INTEGER
386
+
387
+ v.to_i.to_s
388
+ end
389
+
357
390
  def to_string_slice(v)
358
391
  return [] if v.nil?
359
392
  return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
@@ -406,22 +439,44 @@ module Quonfig
406
439
  [start_v || min, end_v || max]
407
440
  end
408
441
 
442
+ # The ONE timestamp grammar for PROP_BEFORE / PROP_AFTER (qfg-goi1.5 R5),
443
+ # applied to both the context value and the criterion: a number (epoch
444
+ # ms, truncated), an all-digit string (epoch ms), or RFC3339 with an
445
+ # uppercase T and a Z or numeric offset (fractional seconds allowed,
446
+ # truncated to ms). Date-only, offset-less and free-form strings are not
447
+ # timestamps and never match. This replaces DateTime.parse, which read
448
+ # any of those (offset-less as UTC).
409
449
  def date_to_millis(val)
410
450
  case val
411
- when Integer, Float
412
- val.to_i
451
+ when Integer
452
+ val
453
+ when Float
454
+ val.finite? ? val.to_i : nil
413
455
  when String
414
- # Try ISO-8601 / RFC3339 first, fall back to integer-string.
415
- begin
416
- t = DateTime.parse(val)
417
- return (t.to_time.to_f * 1000).to_i
418
- rescue ArgumentError, TypeError
419
- # not a date; try integer
420
- end
421
- Integer(val, exception: false)
456
+ return Integer(val, 10) if INTEGER_STRING.match?(val)
422
457
 
458
+ rfc3339_to_millis(val)
423
459
  end
424
460
  end
461
+
462
+ def rfc3339_to_millis(str)
463
+ m = RFC3339_TIMESTAMP.match(str)
464
+ return nil if m.nil?
465
+
466
+ y, mo, d, h, mi, s = %i[y mo d h mi s].map { |k| m[k].to_i }
467
+ return nil unless Date.valid_date?(y, mo, d) && h <= 23 && mi <= 59 && s <= 59
468
+
469
+ offset_seconds = 0
470
+ if m[:sign]
471
+ oh = m[:oh].to_i
472
+ om = m[:om].to_i
473
+ return nil if oh > 23 || om > 59
474
+
475
+ offset_seconds = ((oh * 3600) + (om * 60)) * (m[:sign] == '-' ? -1 : 1)
476
+ end
477
+ epoch_seconds = Time.utc(y, mo, d, h, mi, s).to_i - offset_seconds
478
+ (epoch_seconds * 1000) + (m[:frac] || '').ljust(3, '0')[0, 3].to_i
479
+ end
425
480
  end
426
481
 
427
482
  # Result of a matched config evaluation. Provides the caller with both the
@@ -532,8 +587,11 @@ module Quonfig
532
587
  when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
533
588
  when 'duration' then duration_to_millis(raw)
534
589
  when 'json'
535
- # JSON values must be native JS/Ruby types on the wire.
536
- raw
590
+ # JSON values must be native JS/Ruby types on the wire. Hashes and
591
+ # Arrays are copied so a caller mutating the result cannot rewrite
592
+ # the cached config for every later read (qfg-goi1.10.3, sdk-go
593
+ # qfg-9dxb.6). Leaf values (strings included) are not copied.
594
+ copy_json_containers(raw)
537
595
  else
538
596
  raw
539
597
  end
@@ -547,6 +605,14 @@ module Quonfig
547
605
 
548
606
  private
549
607
 
608
+ def copy_json_containers(value)
609
+ case value
610
+ when Hash then value.to_h { |k, v| [k, copy_json_containers(v)] }
611
+ when Array then value.map { |v| copy_json_containers(v) }
612
+ else value
613
+ end
614
+ end
615
+
550
616
  # Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
551
617
  # to integer milliseconds. The wire format may either be a bare ISO
552
618
  # string in `value` or the structured `{ seconds, nanos }` proto-style
@@ -11,6 +11,14 @@ module Quonfig
11
11
  end
12
12
 
13
13
  @@lock.with_write_lock do
14
+ # Re-check under the lock: two threads can both pass the check above,
15
+ # and the loser must not build (and orphan) a second Client with its
16
+ # own SSE stream and telemetry thread (qfg-goi1.2.11).
17
+ unless @singleton.nil?
18
+ LOG.warn 'Quonfig already initialized.'
19
+ next @singleton
20
+ end
21
+
14
22
  @singleton = Quonfig::Client.new(options)
15
23
  end
16
24
  end
@@ -56,15 +56,21 @@ module Quonfig
56
56
 
57
57
  weighted_index = nil
58
58
  hash_missing = []
59
- resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing) do |idx|
59
+ selected_variants = []
60
+ resolved_value = resolve_value_on_path(eval_result.value, config, context, [], hash_missing,
61
+ selected_variants) do |idx|
60
62
  weighted_index = idx
61
63
  end
64
+ # Redact from the selected rollout variant, not only the outer
65
+ # weighted_values wrapper (which is never flagged), so a confidential or
66
+ # decryptWith variant never reaches telemetry in plaintext (qfg-goi1.2.39).
67
+ reportable = redacted_reportable_value(eval_result.value) || redacted_reportable_value(selected_variants.last)
62
68
  EvalResult.new(
63
69
  value: resolved_value,
64
70
  rule_index: eval_result.rule_index,
65
71
  config: config,
66
72
  weighted_value_index: weighted_index,
67
- reportable_value: redacted_reportable_value(eval_result.value),
73
+ reportable_value: reportable,
68
74
  hash_property_missing: !hash_missing.empty?
69
75
  )
70
76
  end
@@ -91,15 +97,21 @@ module Quonfig
91
97
  # DecryptionError instead of recursing until SystemStackError
92
98
  # (qfg-9dxb.7, matching sdk-go qfg-9dxb.4). +hash_missing+, when given,
93
99
  # gets an entry for each weighted rollout whose hash property was missing
94
- # from the context (qfg-9dxb.8).
95
- def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
100
+ # from the context (qfg-9dxb.8). +selected_variants+, when given, gets the
101
+ # stored (pre-resolution) Value of each weighted variant picked, so get()
102
+ # can redact a confidential/decryptWith variant (qfg-goi1.2.39).
103
+ def resolve_value_on_path(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
104
+ &on_weighted_index)
96
105
  return nil if value.nil?
97
106
 
98
107
  type = vget(value, :type, 'type')
99
108
 
100
109
  return resolve_provided(value, config) if type == 'provided'
101
110
 
102
- return resolve_weighted(value, config, context, key_path, hash_missing, &on_weighted_index) if type == 'weighted_values'
111
+ if type == 'weighted_values'
112
+ return resolve_weighted(value, config, context, key_path, hash_missing, selected_variants,
113
+ &on_weighted_index)
114
+ end
103
115
 
104
116
  confidential = vget(value, :confidential, 'confidential')
105
117
  decrypt_with = vget(value, :decryptWith, 'decryptWith', :decrypt_with, 'decrypt_with')
@@ -184,7 +196,8 @@ module Quonfig
184
196
  # nil) hashes an empty value, the same bucket as a present "", and warns
185
197
  # once per config key (qfg-9dxb.8). With no hash property configured the
186
198
  # pick stays random per call.
187
- def resolve_weighted(value, config, context, key_path, hash_missing = nil, &on_weighted_index)
199
+ def resolve_weighted(value, config, context, key_path, hash_missing = nil, selected_variants = nil,
200
+ &on_weighted_index)
188
201
  payload = vget(value, :value, 'value') || {}
189
202
  weighted = vget(payload, :weightedValues, 'weightedValues', :weighted_values, 'weighted_values')
190
203
  return value unless weighted.is_a?(Array) && !weighted.empty?
@@ -213,7 +226,9 @@ module Quonfig
213
226
  variant, index = picker.resolve
214
227
  on_weighted_index&.call(index)
215
228
  variant_value = vget(variant, :value, 'value')
216
- resolve_value_on_path(variant_value, config, context, key_path, hash_missing, &on_weighted_index)
229
+ selected_variants&.push(variant_value)
230
+ resolve_value_on_path(variant_value, config, context, key_path, hash_missing, selected_variants,
231
+ &on_weighted_index)
217
232
  end
218
233
 
219
234
  def warn_hash_property_missing(cfg_key, hash_property)
@@ -52,6 +52,10 @@ module Quonfig
52
52
 
53
53
  # SemanticLogger filter contract: return true to emit, false to suppress.
54
54
  # Missing config key → return true so SemanticLogger's static level decides.
55
+ # A resolution error (unset ENV_VAR, decryption failure, ...) is treated
56
+ # the same way: the filter runs inside the host app's log calls and must
57
+ # never raise out of them (qfg-goi1.2.11). Nothing is logged here, since
58
+ # logging from inside a log filter can recurse.
55
59
  def call(log)
56
60
  configured = @client.get(@config_key, nil, context_for(log))
57
61
  return true if configured.nil?
@@ -59,6 +63,8 @@ module Quonfig
59
63
  log_severity = LEVELS[log.level] || LEVELS[:debug]
60
64
  min_severity = LEVELS[normalize_level(configured)] || LEVELS[:debug]
61
65
  log_severity >= min_severity
66
+ rescue StandardError
67
+ true
62
68
  end
63
69
 
64
70
  private
@@ -18,6 +18,11 @@ class SemanticVersion
18
18
  $
19
19
  /x
20
20
 
21
+ # Abbreviated "x" and "x.y" (qfg-goi1.5 Q5), read as x.0.0 and x.y.0 by
22
+ # the PROP_SEMVER_* operators, matching LaunchDarkly. A prerelease or build
23
+ # suffix needs the full x.y.z.
24
+ ABBREVIATED_PATTERN = /\A(?<major>0|[1-9]\d*)(?:\.(?<minor>0|[1-9]\d*))?\z/
25
+
21
26
  attr_reader :major, :minor, :patch, :prerelease, :build_metadata
22
27
 
23
28
  def self.parse(version_string)
@@ -36,7 +41,12 @@ class SemanticVersion
36
41
  )
37
42
  end
38
43
 
39
- def self.parse_quietly(version_string)
44
+ def self.parse_quietly(version_string, allow_abbreviated: false)
45
+ if allow_abbreviated && version_string.is_a?(String)
46
+ match = ABBREVIATED_PATTERN.match(version_string)
47
+ return new(major: match[:major].to_i, minor: match[:minor].to_i, patch: 0) if match
48
+ end
49
+
40
50
  parse(version_string)
41
51
  rescue ArgumentError
42
52
  nil