quonfig 1.7.2 → 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.
@@ -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'],
@@ -12,8 +14,13 @@ module Quonfig
12
14
  [config.key, config.value_type]
13
15
  end
14
16
  # +_env_var+ (the raw value) is deliberately left out of the message:
15
- # an env var or stored value can be a secret (qfg-goi1.2.11).
16
- super("Evaluating #{key} couldn't coerce #{env_var_name} to #{value_type}")
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}"))
17
24
  end
18
25
  end
19
26
  end
@@ -54,6 +54,16 @@ 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
 
59
69
  # PROP_MATCHES / PROP_DOES_NOT_MATCH match timeout (qfg-goi1.2.11). Ruby
@@ -282,8 +292,8 @@ module Quonfig
282
292
  when OP_PROP_SEMVER_LESS_THAN, OP_PROP_SEMVER_EQUAL, OP_PROP_SEMVER_GREATER_THAN
283
293
  mv = hget(match_value, :value)
284
294
  if context_exists && context_value.is_a?(String) && mv.is_a?(String)
285
- sv_ctx = SemanticVersion.parse_quietly(context_value)
286
- 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)
287
297
  if sv_ctx && sv_mv
288
298
  cmp = (sv_ctx <=> sv_mv)
289
299
  case operator
@@ -356,12 +366,27 @@ module Quonfig
356
366
 
357
367
  # --- Type coercion helpers ----------------------------------------
358
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").
359
374
  def to_s_nil(v)
360
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)
361
378
 
362
379
  v.to_s
363
380
  end
364
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
+
365
390
  def to_string_slice(v)
366
391
  return [] if v.nil?
367
392
  return v.map { |i| to_s_nil(i) } if v.is_a?(Array)
@@ -414,21 +439,43 @@ module Quonfig
414
439
  [start_v || min, end_v || max]
415
440
  end
416
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).
417
449
  def date_to_millis(val)
418
450
  case val
419
- when Integer, Float
420
- val.to_i
451
+ when Integer
452
+ val
453
+ when Float
454
+ val.finite? ? val.to_i : nil
421
455
  when String
422
- # Try ISO-8601 / RFC3339 first, fall back to integer-string.
423
- begin
424
- t = DateTime.parse(val)
425
- return (t.to_time.to_f * 1000).to_i
426
- rescue ArgumentError, TypeError
427
- # not a date; try integer
428
- end
429
- Integer(val, exception: false)
456
+ return Integer(val, 10) if INTEGER_STRING.match?(val)
457
+
458
+ rfc3339_to_millis(val)
459
+ end
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
430
474
 
475
+ offset_seconds = ((oh * 3600) + (om * 60)) * (m[:sign] == '-' ? -1 : 1)
431
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
432
479
  end
433
480
  end
434
481
 
@@ -540,8 +587,11 @@ module Quonfig
540
587
  when 'log_level' then raw.is_a?(Numeric) ? raw : raw.to_s
541
588
  when 'duration' then duration_to_millis(raw)
542
589
  when 'json'
543
- # JSON values must be native JS/Ruby types on the wire.
544
- 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)
545
595
  else
546
596
  raw
547
597
  end
@@ -555,6 +605,14 @@ module Quonfig
555
605
 
556
606
  private
557
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
+
558
616
  # Coerce an ISO 8601 duration value (e.g. "PT0.2S", "P1DT6H2M1.5S")
559
617
  # to integer milliseconds. The wire format may either be a bare ISO
560
618
  # string in `value` or the structured `{ seconds, nanos }` proto-style
@@ -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)
@@ -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
@@ -51,10 +51,17 @@ module Quonfig
51
51
 
52
52
  LOG = Quonfig::InternalLogger.new(self)
53
53
 
54
- # qfg-i5xv: HTTP status codes the SDK classifies as terminal — these will
55
- # not heal by retrying (bad key, revoked permission, missing endpoint).
56
- # Anything else (5xx, 429, network errors) stays on the transient path.
57
- TERMINAL_HTTP_CODES = [401, 403, 404].freeze
54
+ # Auth-class statuses (rule R1, qfg-goi1.10.26). On the stream they keep the
55
+ # normal reconnect ladder: SSE alone never stops retrying and never enters
56
+ # auth-failed (R2); the client's fallback poller decides that from a whole
57
+ # fetch cycle. They only get their own log line: WARN once per status,
58
+ # re-armed by a 200, DEBUG otherwise (R5).
59
+ AUTH_HTTP_CODES = [401, 403, 404, 410].freeze
60
+
61
+ # @deprecated since 1.8.0. qfg-i5xv's "terminal" codes. Nothing is terminal
62
+ # any more (the latch is gone, qfg-goi1.10.26); kept as an alias of
63
+ # AUTH_HTTP_CODES for code that references the constant.
64
+ TERMINAL_HTTP_CODES = AUTH_HTTP_CODES
58
65
 
59
66
  # qfg-6y44: headroom added to +sse_read_timeout+ when configuring
60
67
  # Net::HTTP#read_timeout. The ReadDeadlineWatchdog already covers both the
@@ -88,6 +95,8 @@ module Quonfig
88
95
  @active_http = nil
89
96
 
90
97
  @last_event_id = nil
98
+ # Auth-class statuses already logged at WARN since the last 200 (R5).
99
+ @auth_warned = Set.new
91
100
  # qfg-7h5d.1.9 / qfg-41nh.6: latches true if a connection attempt ever
92
101
  # targets a non-primary sse_api_urls leg. The failover epic asserts SSE
93
102
  # does NOT fail over to the secondary (f05) — the stream is PINNED to
@@ -220,7 +229,7 @@ module Quonfig
220
229
  begin
221
230
  unless first_attempt
222
231
  increment_restart!
223
- interruptible_sleep(jittered(delay))
232
+ interruptible_sleep(jittered(Quonfig::Waits.duration(:sse, delay)))
224
233
  break if @stopped.value
225
234
  end
226
235
  first_attempt = false
@@ -314,26 +323,7 @@ module Quonfig
314
323
 
315
324
  begin
316
325
  http.request(req) do |resp|
317
- code = resp.code.to_i
318
- if TERMINAL_HTTP_CODES.include?(code)
319
- # qfg-i5xv: 401/403/404 will not heal by retrying — bad key,
320
- # revoked permission, or wrong endpoint. Mark stopped *before*
321
- # invoking on_error so the loop's terminal-error branch is
322
- # already locked in if the parent callback inspects state, and
323
- # so the inner rescue's `handle_error(e) unless @stopped.value`
324
- # guard suppresses a second on_error edge.
325
- err = SSEHTTPTerminalError.new(code)
326
- @logger.error "SSE Streaming Terminal Error: HTTP #{code} for url #{url}; will not retry"
327
- @stopped.make_true
328
- invoke_on_error(err)
329
- raise err
330
- end
331
- if code != 200
332
- err = SSEHTTPStatusError.new(code)
333
- @logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
334
- invoke_on_error(err)
335
- raise err
336
- end
326
+ check_response_status!(resp.code.to_i, url)
337
327
 
338
328
  parser = EventParser.new
339
329
  # qfg-tj18: run_loop wraps the body in +:on_blocking+ which
@@ -365,6 +355,35 @@ module Quonfig
365
355
  end
366
356
  end
367
357
 
358
+ # Classify the stream response. Raises for any non-200 so run_loop applies
359
+ # its backoff.
360
+ #
361
+ # Auth class (401/403/404/410, qfg-goi1.10.26): no latch. The normal
362
+ # ladder applies and run_loop's handle_error fires on_error once, so the
363
+ # client schedules the fallback poller exactly as for any drop; that
364
+ # poller's fetch cycle is what decides auth-failed (R2). Logged WARN once
365
+ # per status until a 200 re-arms it, DEBUG otherwise (R5), so a revoked
366
+ # key does not log two ERRORs per attempt for the ~2 min before entry.
367
+ def check_response_status!(code, url)
368
+ if AUTH_HTTP_CODES.include?(code)
369
+ if @auth_warned.add?(code)
370
+ @logger.warn "SSE Streaming Error: HTTP #{code} for url #{url}; " \
371
+ 'the SDK key may be invalid or revoked (check the SDK key); retrying'
372
+ else
373
+ @logger.debug "SSE Streaming Error: HTTP #{code} for url #{url}; retrying"
374
+ end
375
+ raise SSEHTTPAuthError, code
376
+ end
377
+ if code != 200
378
+ err = SSEHTTPStatusError.new(code)
379
+ @logger.error "SSE Streaming Error: HTTP #{code} for url #{url}"
380
+ invoke_on_error(err)
381
+ raise err
382
+ end
383
+
384
+ @auth_warned.clear
385
+ end
386
+
368
387
  # Track the active connection so close() can interrupt a blocked
369
388
  # read_body from another thread. Guarded by @conn_mutex.
370
389
  def register_active(http)
@@ -376,7 +395,12 @@ module Quonfig
376
395
  end
377
396
 
378
397
  def handle_error(error)
379
- @logger.error "SSE Streaming Error: #{error.inspect}"
398
+ if error.is_a?(SSEHTTPAuthError)
399
+ # Already logged once by check_response_status! (R5).
400
+ @logger.debug "SSE Streaming Error: #{error.inspect}"
401
+ else
402
+ @logger.error "SSE Streaming Error: #{error.inspect}"
403
+ end
380
404
  invoke_on_error(error)
381
405
  end
382
406
 
@@ -445,13 +469,14 @@ module Quonfig
445
469
  end
446
470
  end
447
471
 
448
- # qfg-i5xv: terminal HTTP failures the SDK will not retry. 401 = bad key,
449
- # 403 = revoked workspace permission, 404 = wrong endpoint / missing
450
- # workspace. A subclass of SSEHTTPStatusError so existing on_error
451
- # callbacks that only check `is_a?(SSEHTTPStatusError)` keep working,
452
- # while customers that want to distinguish (alerting, OpenFeature
453
- # provider error events) can dispatch on the subclass.
454
- class SSEHTTPTerminalError < SSEHTTPStatusError; end
472
+ # An auth-class (401/403/404/410) stream response (qfg-goi1.10.26). A
473
+ # subclass of SSEHTTPStatusError so on_error callbacks that only check
474
+ # `is_a?(SSEHTTPStatusError)` keep working. The stream keeps retrying.
475
+ class SSEHTTPAuthError < SSEHTTPStatusError; end
476
+
477
+ # @deprecated since 1.8.0: qfg-i5xv's name for SSEHTTPAuthError. The stream
478
+ # no longer stops on these statuses.
479
+ SSEHTTPTerminalError = SSEHTTPAuthError # rubocop:disable Lint/UselessConstantScoping
455
480
 
456
481
  # Raised by the watchdog into the worker thread when the per-chunk
457
482
  # read deadline elapses. Caught by run_loop's rescue, indistinguishable
@@ -630,37 +655,59 @@ module Quonfig
630
655
  def initialize
631
656
  @buffer = +''.b
632
657
  @last_was_cr = false
658
+ # Bytes of @buffer already scanned without finding a terminator, so
659
+ # the next feed resumes there instead of rescanning from byte 0
660
+ # (qfg-goi1.10.2; O(n^2) on a long line in small chunks).
661
+ @scanned = 0
633
662
  end
634
663
 
664
+ # Lines are sliced out by position and the consumed prefix is dropped
665
+ # once per feed (in +ensure+, so a raising block does not see a line
666
+ # twice), not once per line.
635
667
  def feed(chunk)
636
668
  @buffer << chunk.b
637
- loop do
638
- idx = @buffer.index(/[\r\n]/)
639
- break if idx.nil?
669
+ pos = 0
670
+ begin
671
+ loop do
672
+ idx = @buffer.index(/[\r\n]/, [pos, @scanned].max)
673
+ if idx.nil?
674
+ @scanned = @buffer.bytesize
675
+ break
676
+ end
640
677
 
641
- ch = @buffer[idx]
642
- if idx.zero? && ch == "\n" && @last_was_cr
643
- # Dangling LF of a CRLF pair split across chunks — consume and skip.
644
- @last_was_cr = false
645
- @buffer.slice!(0, 1)
646
- next
647
- end
678
+ ch = @buffer.getbyte(idx)
679
+ if idx == pos && ch == LF && @last_was_cr
680
+ # Dangling LF of a CRLF pair split across chunks — consume and skip.
681
+ @last_was_cr = false
682
+ pos += 1
683
+ next
684
+ end
648
685
 
649
- line = @buffer[0, idx].force_encoding('UTF-8')
650
- consume = idx + 1
651
- @last_was_cr = false
652
- if ch == "\r"
653
- if consume == @buffer.bytesize
654
- # CR at end of buffer — could be CRLF split across feeds.
655
- @last_was_cr = true
656
- elsif @buffer[consume] == "\n"
657
- consume += 1
686
+ line = @buffer.byteslice(pos, idx - pos).force_encoding('UTF-8')
687
+ consume_to = idx + 1
688
+ @last_was_cr = false
689
+ if ch == CR
690
+ if consume_to == @buffer.bytesize
691
+ # CR at end of buffer — could be CRLF split across feeds.
692
+ @last_was_cr = true
693
+ elsif @buffer.getbyte(consume_to) == LF
694
+ consume_to += 1
695
+ end
658
696
  end
697
+ pos = consume_to
698
+ yield line
699
+ end
700
+ ensure
701
+ if pos.positive?
702
+ @buffer = @buffer.byteslice(pos, @buffer.bytesize - pos)
703
+ @scanned = [@scanned - pos, 0].max
659
704
  end
660
- @buffer.slice!(0, consume)
661
- yield line
662
705
  end
663
706
  end
707
+
708
+ CR = 13
709
+ LF = 10
710
+ private_constant :CR, :LF
664
711
  end
665
712
  end
666
713
  end
@@ -74,7 +74,7 @@ module Quonfig
74
74
  path = logger_name || progname
75
75
  level = SEVERITY_TO_LEVEL[severity.to_s.upcase] || :info
76
76
 
77
- if client.should_log?(logger_path: path, desired_level: level)
77
+ if emit?(client, path, level)
78
78
  format_record(severity, datetime, progname, msg)
79
79
  else
80
80
  ''
@@ -82,6 +82,17 @@ module Quonfig
82
82
  end
83
83
  end
84
84
 
85
+ # The formatter runs inside the host app's Logger calls and must never
86
+ # raise out of them. A resolution error (unset ENV_VAR, decryption
87
+ # failure, ...) emits the line, matching SemanticLoggerFilter
88
+ # (qfg-goi1.2.11, qfg-goi1.2.28). Nothing is logged here, since logging
89
+ # from inside a formatter can recurse.
90
+ def self.emit?(client, path, level)
91
+ client.should_log?(logger_path: path, desired_level: level)
92
+ rescue StandardError
93
+ true
94
+ end
95
+
85
96
  # Default record formatter. Matches ::Logger::Formatter's general shape
86
97
  # ("I, [timestamp pid] SEVERITY -- progname: message") but without the
87
98
  # process-id first-letter noise so output is readable in tests and
@@ -15,6 +15,10 @@ module Quonfig
15
15
  # prunes expired entries, and is not recorded if it is still full.
16
16
  SEEN_CAP = 100_000
17
17
 
18
+ # Max nesting depth copied from a context value; deeper levels are left
19
+ # out. Guards the recursive copy against stack exhaustion.
20
+ MAX_SNAPSHOT_DEPTH = 32
21
+
18
22
  attr_reader :data, :cache
19
23
 
20
24
  def initialize(max_contexts:, rate_limit_seconds: ONE_HOUR_SECONDS, seen_cap: SEEN_CAP)
@@ -38,7 +42,7 @@ module Quonfig
38
42
  return unless room_in_cache?
39
43
 
40
44
  @cache.set(key)
41
- @data.push([Quonfig::TimeHelpers.now_in_ms, context])
45
+ @data.push([Quonfig::TimeHelpers.now_in_ms, snapshot(context)])
42
46
  end
43
47
 
44
48
  def prepare_data
@@ -65,6 +69,19 @@ module Quonfig
65
69
 
66
70
  private
67
71
 
72
+ # Copy +context+ into plain JSON-safe data at record time (qfg-goi1.2.35).
73
+ # The caller owns its nested Hashes/Arrays and may mutate them after
74
+ # get() returns, while the window is serialized later on the reporter
75
+ # thread. NaN/Infinity become nil, and an edge that closes a cycle is
76
+ # left out, so one bad value no longer makes JSON.generate raise and drop
77
+ # the whole window. Any other object is rendered with #to_s, which is
78
+ # what JSON.generate already sent for it (a UUID-like key keeps the
79
+ # example reportable); one whose #to_s raises is left out.
80
+ def snapshot(context)
81
+ named = contexts_to_list(context)
82
+ named.to_h { |entry| [entry['type'], snapshot_value(entry['values'], 0, {})] }
83
+ end
84
+
68
85
  def room_in_cache?
69
86
  return true if @cache.data.size < @seen_cap
70
87
 
@@ -102,6 +119,43 @@ module Quonfig
102
119
  end
103
120
  end
104
121
 
122
+ OMIT = Object.new.freeze
123
+ private_constant :OMIT
124
+
125
+ def snapshot_value(value, depth, ancestors)
126
+ case value
127
+ when nil, true, false, Integer then value
128
+ when Float then value.finite? ? value : nil
129
+ when String then value.frozen? ? value : value.dup
130
+ when Symbol then value.to_s
131
+ when Hash, Array then snapshot_container(value, depth, ancestors)
132
+ else value.to_s
133
+ end
134
+ rescue StandardError
135
+ OMIT
136
+ end
137
+
138
+ # +ancestors+ holds the containers on the current path only, so the same
139
+ # value reached twice without a cycle is still copied both times.
140
+ def snapshot_container(value, depth, ancestors)
141
+ id = value.object_id
142
+ return OMIT if depth >= MAX_SNAPSHOT_DEPTH || ancestors.key?(id)
143
+
144
+ ancestors[id] = true
145
+ begin
146
+ if value.is_a?(Hash)
147
+ value.each_with_object({}) do |(k, v), acc|
148
+ copied = snapshot_value(v, depth + 1, ancestors)
149
+ acc[k.to_s] = copied unless copied.equal?(OMIT)
150
+ end
151
+ else
152
+ value.map { |v| snapshot_value(v, depth + 1, ancestors) }.reject { |v| v.equal?(OMIT) }
153
+ end
154
+ ensure
155
+ ancestors.delete(id)
156
+ end
157
+ end
158
+
105
159
  def stringify_values(hash)
106
160
  return {} unless hash.is_a?(Hash)
107
161
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.7.2'
4
+ VERSION = '1.8.0'
5
5
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Internal test seam for the SDK's background waits (qfg-goi1.10.26).
5
+ #
6
+ # Every long background wait goes through here with a kind:
7
+ # :sse - SSE reconnect backoff (the unjittered base delay)
8
+ # :engage - fallback-poller grace after a mid-life SSE drop
9
+ # :poll - fallback / polling-mode poll interval
10
+ # :probe - auth-failed probe period (rule R3)
11
+ #
12
+ # A hook, when set, sees the REQUESTED seconds and returns the seconds to
13
+ # actually wait. Tests use it to record the requested values and compress
14
+ # time (supervisor-test-contract.md Test 8). It is not a customer option and
15
+ # is nil in production.
16
+ module Waits
17
+ class << self
18
+ attr_accessor :hook
19
+
20
+ def duration(kind, seconds)
21
+ h = hook
22
+ h ? h.call(kind, seconds) : seconds
23
+ end
24
+
25
+ def sleep(kind, seconds)
26
+ Kernel.sleep(duration(kind, seconds))
27
+ end
28
+ end
29
+ end
30
+ end
@@ -95,6 +95,11 @@ module Quonfig
95
95
  t
96
96
  end
97
97
  return if thread.nil?
98
+ # Called from the worker itself (the poller leaving auth-failed hands
99
+ # back to SSE, qfg-goi1.10.26): the flag is enough. The worker returns,
100
+ # run_loop sees stop? and exits without a restart. Raising into or
101
+ # joining the current thread would kill it mid-handoff.
102
+ return if thread == Thread.current
98
103
 
99
104
  raise_shutdown(thread)
100
105
  thread.join(SHUTDOWN_TIMEOUT_SEC)
data/lib/quonfig.rb CHANGED
@@ -29,6 +29,7 @@ require 'quonfig/evaluation'
29
29
  require 'quonfig/evaluation_details'
30
30
  require 'quonfig/encryption'
31
31
  require 'quonfig/exponential_backoff'
32
+ require 'quonfig/waits'
32
33
  require 'quonfig/worker_supervisor'
33
34
  require 'quonfig/periodic_sync'
34
35
  require 'quonfig/errors/initialization_timeout_error'