quonfig 1.4.1 → 1.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ecc1b6f2a6c327c36d7da4cf091623b8c44b81d6949b887356301961229a684
4
- data.tar.gz: bf357130f83dc29baf032c8dd0db652c8910797aac2fc6a499ec3461e5b56ff6
3
+ metadata.gz: 96d17a74751571b9c6099b8239b2ccaafb25a5f7e55d006cecae5cb1f8651979
4
+ data.tar.gz: 6bbe54bd07a5fb1e20b987ef8a8aaef417533ca8ef26000873db8a671142d4bb
5
5
  SHA512:
6
- metadata.gz: '08951e8e2261dce91f0308de7920ea46eef5077979efdd0bd8df55b403719d8fb1f2e7f6ed863a12d38108b30cdef8b92d08832059a68c45b96425a5ced249f6'
7
- data.tar.gz: 81738455b310e66693801fe2b2aa3ff027e676b28b032398271d1de17c9421212d5e0c9d3b20c37164a7299c74f2347a52f371abfabc36d8ebca3c75492d17d0
6
+ metadata.gz: dcb86b3f98c5f986d61f0b0db084b992fe4c3bfefc86ef2f7d0f65487fa95dc6d66fdd4b5150dd2c0f8b6ddd819f6c0bc77980e4ae89d618d31daa3649bc48c6
7
+ data.tar.gz: 9faba58f93536256b216dd70dc95849adbb1c4e18b033feb9419d87d26dd8b04ec132738f4bfcbee3b0e7dca0b9051bad43971644b3312d0320ee496e8550efc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 - 2026-09-28
4
+
5
+ - **Change: a weighted rollout that hashes on a property missing from the context now hashes an empty value, so every such caller gets the same variant for that flag (qfg-9dxb.8).** Before, such evaluations got a random variant on every call. It is the same variant a context with the property set to `""` gets, and a variant with weight 0 is not served. The reason is still `SPLIT`. The `flag_metadata` of `get_*_details` results includes `'hashPropertyMissing' => true` when the property is missing, and the SDK logs one warning per flag. A weighted rollout with no hash property configured still picks a random variant on every evaluation, unchanged. Users whose context has the property keep the same variant as in 1.5.0.
6
+ - **Change: once the client holds a numbered config generation, a payload with generation 0 is ignored and the client keeps its current config (qfg-9dxb.9).** Only a delivery server with a damaged git store sends one. In 1.5.0 such a payload installed and could move the client back to older config. `held_generation` never goes backward. A client that has never received a real generation (for example one talking to `qfg serve`) still installs every payload.
7
+ - **Fix: a segment or `decryptWith` key that references itself (directly or through a chain) no longer crashes with `SystemStackError` (qfg-9dxb.7).** The looping segment reference evaluates as a missing segment. A `decryptWith` loop raises `DecryptionError`.
8
+ - **Fix: an HTTP 200 or SSE event that is not a config payload (for example `{}` from a proxy) is ignored instead of deleting all configs (qfg-9dxb.3).** Over HTTP the SDK tries the next server.
9
+
10
+ ## 1.5.0 - 2026-09-25
11
+
12
+ - **Telemetry transport policy (qfg-y8je.8).** The telemetry POST had no timeout of its own (Faraday's defaults, 60s connect + 60s read); it now has a 15s overall deadline (`telemetry_timeout_ms`) and a 5s connect + TLS deadline (`telemetry_connect_timeout_ms`). A failed batch is kept byte-for-byte and resent (never merged with newer data, so the server dedups a resend of a batch that did land). Resends happen no sooner than 30s after a failure and honor `Retry-After` up to 10 min. The retained queue is capped at 5 batches / 2MB / 5 min (oldest dropped). At most one POST is in flight. 401/403/404 disable telemetry for the process with one ERROR; any other 4xx drops that batch with one ERROR. Before this, a failed batch was simply lost.
13
+ - **Oversize batches are dropped on failure.** A single batch larger than the 2MB byte cap (`telemetry_max_retained_bytes`) is POSTed once and, if that POST fails, dropped rather than kept; the drop counts toward the warning below. This fires for Ruby in practice: in a 24h production sample, sdk-ruby was the only SDK sending large batches (p95 874KB, p99 1.73MB, max 5.2MB, all example-context data) and 0.07% of its POSTs were over 2MB. Most of that size came from the old interval (next item), which let a window grow for up to 10 minutes; at 60s batches should be much smaller. To keep batches small regardless, use `context_upload_mode: :shapes_only` or a lower `context_max_size`.
14
+ - **Flush interval is a fixed 60s (`collect_sync_interval`, now explicitly defaulted).** The old default started at 8s and grew by 1.5x on every tick, including successful ones (a bug), reaching the 600s ceiling about 23 minutes after start; the exponential backoff is gone.
15
+ - **Logging (was a WARN on every failed POST):** a failed POST logs at debug; one WARN when data is actually dropped (then a summary at most every 10 min while drops continue); one INFO on recovery.
16
+ - **Shutdown:** `Client#stop` and the reporter's `at_exit` hook send the live window once with a 5s deadline and do not resend kept batches; an in-flight POST is abandoned. Exit is never blocked longer than that.
17
+ - **Memory caps:** the evaluation-summary, context-shape and example-context caps drop from 100,000 to 10,000 per window (`collect_max_evaluation_summaries`, `context_max_size`), the uniform server-SDK cap. The example-context once-per-hour rate-limit map is bounded at 100,000 keys. A summary key already seen keeps counting at the cap.
18
+ - **New options:** `telemetry_timeout_ms`, `telemetry_connect_timeout_ms`, `telemetry_max_retained_batches`, `telemetry_max_retained_bytes`, `telemetry_max_retained_age_ms`. Invalid values (non-numeric or <= 0) fall back to the default.
19
+ - Internal classes: `Quonfig::HttpConnection` gains `open_timeout_ms:` and sends a String body verbatim. `Quonfig::Telemetry::TelemetryReporter` gains `.build`, `tick`, `flush`, `close` and `debug_state`; `sync` and `stop` remain as aliases of `flush` and `close`. New `Quonfig::Telemetry::TransportQueue`.
20
+ - `context_upload_mode` default is unchanged (`:periodic_example`). No wire change, no removed public API, no new dependencies. Fork safety is unchanged: a forked child still builds its own reporter (and its own retained queue) on first use and never sends the parent's data.
21
+
3
22
  ## 1.4.1 - 2026-09-11
4
23
 
5
24
  - **Fix (fork/telemetry): a forked child now reports under its OWN `instanceHash` (qfg-xcym).** `@instance_hash` was minted once in `Client#initialize` and survived `fork(2)`, so every forked child POSTed its telemetry under the **parent's** hash. The Quonfig Debugger groups SDK last-seen by that hash, so an 8-worker Puma cluster (or a `parallel`-gem batch) collapsed into a single instance row with the parent's and the children's windows interleaved on top of each other. A child now mints a fresh hash as part of the same post-fork rebuild that gives it fresh aggregators and a fresh reporter — before the reporter is constructed, so the hash the child POSTs under is its own. This is parity with Reforge, where a forked process simply builds a whole new client. The parent's hash is never touched, and a child's row count is unchanged (it already reported its own window; it just did so under the wrong identity). Present since 0.0.16.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Ruby SDK for [Quonfig](https://quonfig.com) — Feature Flags, Live Config, and Dynamic Log Levels.
4
4
 
5
- > **Note:** This SDK is pre-1.0 and the API is not yet stable.
5
+ > **Note:** This SDK is stable (v1) and follows [Semantic Versioning](https://semver.org): breaking changes to the public API land only in a major (`x.0.0`) release.
6
6
 
7
7
  ## Installation
8
8
 
@@ -284,6 +284,16 @@ Quonfig::Client.new(
284
284
  | `data_dir_auto_reload` | `Boolean` | `false` | Datadir mode only. When `true`, the SDK watches the datadir and re-reads the envelope when files change. See [Datadir mode: auto-reload on file changes](#datadir-mode-auto-reload-on-file-changes). |
285
285
  | `data_dir_auto_reload_debounce_ms` | `Integer` (ms) | `200` | Debounce window for the auto-reload watcher — events arriving inside the window are coalesced into a single re-read. Ignored when `data_dir_auto_reload` is `false`. |
286
286
  | `logger` | Logger-like object | `nil` | Optional host-app logger (e.g. `Rails.logger`). Must respond to `debug`/`info`/`warn`/`error`. When set, all SDK warnings/errors flow through this logger instead of the default stderr / SemanticLogger backend. |
287
+ | `collect_evaluation_summaries` | `Boolean` | `true` | Send per-flag evaluation counts. See [Telemetry](#telemetry). |
288
+ | `collect_max_evaluation_summaries` | `Integer` | `10_000` | Distinct flags/configs counted per telemetry window; a key already seen keeps counting at the cap. |
289
+ | `context_upload_mode` | `Symbol` | `:periodic_example` | `:periodic_example` (context shapes + example contexts), `:shapes_only`, or `:none`. |
290
+ | `context_max_size` | `Integer` | `10_000` | Context-shape fields, and separately example contexts, kept per telemetry window. |
291
+ | `collect_sync_interval` | `Numeric` (s) | `60` | Seconds between telemetry POSTs. |
292
+ | `telemetry_timeout_ms` | `Integer` (ms) | `15_000` | Overall deadline for one telemetry POST. |
293
+ | `telemetry_connect_timeout_ms` | `Integer` (ms) | `5_000` | TCP connect + TLS deadline for one telemetry POST. |
294
+ | `telemetry_max_retained_batches` | `Integer` | `5` | Failed telemetry batches kept for resend. |
295
+ | `telemetry_max_retained_bytes` | `Integer` | `2_097_152` | Byte cap (2MB) on kept batches; a single batch larger than this is sent once and never kept. |
296
+ | `telemetry_max_retained_age_ms` | `Integer` (ms) | `300_000` | A kept batch older than this is discarded. |
287
297
 
288
298
  ## Failover & `QUONFIG_DOMAIN`
289
299
 
@@ -634,6 +644,58 @@ process.
634
644
 
635
645
  There is intentionally no `client.healthy?` primitive.
636
646
 
647
+ ## Telemetry
648
+
649
+ With an SDK key the client sends usage telemetry to `telemetry_url` so the
650
+ Quonfig dashboard can show which flags and configs are evaluated and with what
651
+ contexts. Telemetry never affects flag evaluation: every failure below is
652
+ contained in the background reporter thread.
653
+
654
+ **What is sent.** Evaluation summaries (per flag/config: counts per rule and
655
+ value), context shapes (context field names and types), example contexts (up to
656
+ one per context key per hour) and failover counters. Opt out with
657
+ `collect_evaluation_summaries: false` and `context_upload_mode: :shapes_only`
658
+ (no example contexts) or `:none` (no context data). With both off, no reporter
659
+ runs.
660
+
661
+ **How it is sent.**
662
+
663
+ - One POST every `collect_sync_interval` seconds (60), with at most one POST in
664
+ flight. A tick that fires while a POST is still out is skipped and its data
665
+ rolls into the next window.
666
+ - Each POST has an overall deadline of `telemetry_timeout_ms` (15s) and a
667
+ connect + TLS deadline of `telemetry_connect_timeout_ms` (5s).
668
+ - When a POST fails (timeout, network error, 408, 429 or 5xx), the serialized
669
+ batch is kept byte-for-byte and resent unchanged, never merged with newer
670
+ data, so the server can recognize a resend of a batch that did land. Up to 5
671
+ batches / 2MB are kept for up to 5 minutes; beyond that the oldest is
672
+ dropped. A single batch larger than 2MB is sent once and never kept: if that
673
+ one POST fails, the batch is dropped. Large batches come from example
674
+ contexts; `context_upload_mode: :shapes_only` or a lower `context_max_size`
675
+ keeps batches small.
676
+ - Resends happen no sooner than 30s after a failure and after any
677
+ `Retry-After` (honored up to 10 minutes), oldest first, then the current
678
+ window.
679
+ - A 401, 403 or 404 means the SDK key or `telemetry_url` is wrong: the SDK logs
680
+ one error and disables telemetry for the rest of the process. Any other 4xx
681
+ drops that one batch with an error (the server rejected the payload) and
682
+ telemetry continues.
683
+
684
+ **Logging.** A failed POST logs at debug only. The first batch actually dropped
685
+ logs one warning with the last POST result and queue depth; further drops log
686
+ at debug with a summary warning at most every 10 minutes; the first success
687
+ after failures logs one info line. The SDK's default logger prints warnings and
688
+ errors only; pass `logger:` to receive the debug and info lines.
689
+
690
+ **Shutdown.** `stop` (and the `at_exit` hook the reporter registers) sends the
691
+ current window once with a 5s deadline, does not resend kept batches, and never
692
+ blocks process exit longer than that.
693
+
694
+ **Memory.** Everything is bounded: at most 10,000 evaluation-summary keys,
695
+ 10,000 context-shape fields and 10,000 example contexts per window (keys
696
+ already seen keep counting at the cap), a 100,000-entry example-context
697
+ rate-limit map, and the 2MB retained queue.
698
+
637
699
  ## Documentation
638
700
 
639
701
  Full documentation, including SPEC, SDK reference, and operational guides, is
@@ -1123,40 +1123,12 @@ module Quonfig
1123
1123
  # The reporter runs on a background thread and periodically POSTs
1124
1124
  # context-shape and example-context batches to +telemetry_destination+.
1125
1125
  def initialize_telemetry(start: true)
1126
- shape_aggregator = nil
1127
- example_aggregator = nil
1128
- summaries_aggregator = nil
1129
-
1130
- if @options.collect_max_shapes.to_i.positive?
1131
- shape_aggregator = Quonfig::Telemetry::ContextShapeAggregator.new(
1132
- max_shapes: @options.collect_max_shapes
1133
- )
1134
- end
1135
-
1136
- if @options.collect_max_example_contexts.to_i.positive?
1137
- example_aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(
1138
- max_contexts: @options.collect_max_example_contexts
1139
- )
1140
- end
1141
-
1142
- if @options.collect_max_evaluation_summaries.to_i.positive?
1143
- summaries_aggregator = Quonfig::Telemetry::EvaluationSummariesAggregator.new(
1144
- max_keys: @options.collect_max_evaluation_summaries
1145
- )
1146
- end
1147
-
1148
- return if shape_aggregator.nil? && example_aggregator.nil? && summaries_aggregator.nil?
1149
-
1150
- @telemetry_reporter = Quonfig::Telemetry::TelemetryReporter.new(
1126
+ @telemetry_reporter = Quonfig::Telemetry::TelemetryReporter.build(
1151
1127
  options: @options,
1152
1128
  instance_hash: @instance_hash,
1153
- context_shape_aggregator: shape_aggregator,
1154
- example_contexts_aggregator: example_aggregator,
1155
- evaluation_summaries_aggregator: summaries_aggregator,
1156
- failover_aggregator: @failover_aggregator,
1157
- sync_interval: @options.collect_sync_interval
1129
+ failover_aggregator: @failover_aggregator
1158
1130
  )
1159
-
1131
+ return if @telemetry_reporter.nil?
1160
1132
  return unless @telemetry_reporter.enabled?
1161
1133
  return unless start
1162
1134
 
@@ -1664,13 +1636,15 @@ module Quonfig
1664
1636
  end
1665
1637
 
1666
1638
  reason = result.of_reason
1639
+ flag_metadata = build_flag_metadata(
1640
+ config_id, config_type, result.rule_index, result.weighted_value_index, reason
1641
+ )
1642
+ flag_metadata['hashPropertyMissing'] = true if result.hash_property_missing
1667
1643
  Quonfig::EvaluationDetails.new(
1668
1644
  value: coerced,
1669
1645
  reason: reason,
1670
1646
  variant: build_variant(reason, result.rule_index, result.weighted_value_index),
1671
- flag_metadata: build_flag_metadata(
1672
- config_id, config_type, result.rule_index, result.weighted_value_index, reason
1673
- )
1647
+ flag_metadata: flag_metadata
1674
1648
  )
1675
1649
  rescue StandardError => e
1676
1650
  Quonfig::EvaluationDetails.new(
@@ -1,5 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true)
4
+ ConfigEnvelope = Struct.new(:configs, :meta, keyword_init: true) do
5
+ # qfg-9dxb.3: true when a decoded wire payload is a config envelope — a
6
+ # Hash carrying a +meta+ object with a non-empty +version+. api-delivery and
7
+ # `qfg serve` always send one; a `{}` or `{"error":"x"}` 200 from a
8
+ # misbehaving proxy/WAF does not, and must never be installed (it would
9
+ # read as "zero configs" and wipe every key on an established client).
10
+ def self.wire_envelope?(data)
11
+ return false unless data.is_a?(Hash)
12
+
13
+ meta = data['meta']
14
+ return false unless meta.is_a?(Hash)
15
+
16
+ version = meta['version']
17
+ !version.nil? && !version.to_s.empty?
18
+ end
19
+ end
5
20
  end
@@ -325,7 +325,11 @@ module Quonfig
325
325
  envelope = parse_envelope(response.body)
326
326
  result = install_envelope(envelope, source: source, source_index: index)
327
327
  # Write this leg's ETag back AFTER the response (per-leg, race-free).
328
- set_etag_for(index, new_etag)
328
+ # Exception (qfg-9dxb.9): an ignored gen<=0 payload keeps the previous
329
+ # ETag. Its ETag is the git sha, and the server may later repair the
330
+ # generation for the SAME sha; remembering it would 304 that repair.
331
+ ignored_unversioned = result == :not_modified && extract_generation(envelope.meta || {}) <= 0
332
+ set_etag_for(index, new_etag) unless ignored_unversioned
329
333
  # install_envelope returns :not_modified when the reject-older guard drops
330
334
  # an equal/older payload — surface that so the caller doesn't double-count.
331
335
  result == :not_modified ? :not_modified : :updated
@@ -339,6 +343,12 @@ module Quonfig
339
343
  @logger.info "Config fetch failed: status #{response.status} from #{source}"
340
344
  :failed
341
345
  end
346
+ rescue NonEnvelopeError => e
347
+ # qfg-9dxb.3: a non-envelope 200 is a leg error, NOT an install — the
348
+ # hedge/failover proceeds and this leg's ETag is never stored (so a junk
349
+ # 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"
351
+ :failed
342
352
  rescue Faraday::ConnectionFailed => e
343
353
  @logger.debug "Connection failure fetching configs from #{source}: #{e.message}"
344
354
  :failed
@@ -355,8 +365,14 @@ module Quonfig
355
365
  @etag_mutex.synchronize { @etags[index || 0] = value }
356
366
  end
357
367
 
368
+ # Raised by #parse_envelope for a 200 whose body is not a config envelope
369
+ # (qfg-9dxb.3). Caught in #fetch_from as a leg error.
370
+ class NonEnvelopeError < StandardError; end
371
+
358
372
  def parse_envelope(body)
359
373
  data = body.is_a?(String) ? JSON.parse(body) : body
374
+ raise NonEnvelopeError, 'missing meta.version' unless Quonfig::ConfigEnvelope.wire_envelope?(data)
375
+
360
376
  Quonfig::ConfigEnvelope.new(
361
377
  configs: data['configs'] || [],
362
378
  meta: data['meta'] || {}
@@ -375,23 +391,37 @@ module Quonfig
375
391
  incoming_gen = extract_generation(meta)
376
392
 
377
393
  @install_mutex.synchronize do
378
- # Reject-older install guard (canonical ordering, §5f). A fresh client
379
- # (no held generation) seeds off whatever arrives first — even an older
380
- # or gen-0 snapshot. An established client installs ONLY when the incoming
381
- # generation strictly advances the held one: a same-generation snapshot is
382
- # a no-op (no store churn, no install-count bump, no resolved-from change)
383
- # so a duplicate leg never flaps an established client, and an OLDER
384
- # snapshot (a stale secondary reached on failover) is dropped so the client
385
- # never regresses. Reject-older is the whole rule — no source ranking; a
386
- # newer primary landing late heals forward automatically. Applies on every
387
- # network install path (initial fetch, failover/poll fetch, SSE snapshot,
388
- # SSE update, fallback poller); datadir install bypasses this (it is the
389
- # local source of truth and goes through Client#apply_datadir_envelope).
390
- # Carve-out: an UNVERSIONED snapshot (generation <= 0 — a server that
391
- # predates the watermark, or one whose rev-count failed) carries no
392
- # ordering info, so it is never rejected as "older"; freezing the client
393
- # on stale config would be worse (mirrors sdk-node).
394
- unless @held_generation.nil? || incoming_gen <= 0 || incoming_gen > @held_generation
394
+ # Reject-older install guard (canonical ordering, §5f; mirrors sdk-go
395
+ # shouldInstall). The rule:
396
+ # - fresh client (nothing installed yet) -> install, whatever arrives
397
+ # - incoming generation <= 0 (unversioned) -> install ONLY if the held
398
+ # generation is 0 (the client has never held a real generation)
399
+ # - otherwise -> install iff incoming strictly exceeds held
400
+ # A same-generation snapshot is a no-op (no store churn, no install-count
401
+ # bump, no resolved-from change) so a duplicate leg never flaps an
402
+ # established client, and an OLDER snapshot (a stale secondary reached on
403
+ # failover) is dropped so the client never regresses. Reject-older is the
404
+ # whole rule — no source ranking; a newer primary landing late heals
405
+ # forward automatically. Applies on every network install path (initial
406
+ # fetch, failover/poll fetch, SSE snapshot, SSE update, fallback poller);
407
+ # datadir install bypasses this (it is the local source of truth and goes
408
+ # through Client#apply_datadir_envelope).
409
+ #
410
+ # Unversioned payloads (qfg-9dxb.9): the pre-watermark servers that sent
411
+ # gen 0 on every payload are long gone. Today gen 0 comes only from a
412
+ # server whose git store is damaged (rev-count failed) — the least
413
+ # trustworthy source — so it must not override a held real generation.
414
+ # A client that has only ever seen gen 0 (e.g. `qfg serve`) keeps
415
+ # installing each gen 0 payload.
416
+ unless should_install?(incoming_gen)
417
+ if incoming_gen <= 0
418
+ # Unversioned payload while a real generation is held: not provably
419
+ # older (it carries no ordering info), so a silent no-op — NOT
420
+ # counted as guardRejected.
421
+ @logger.debug "Unversioned payload ignored: held generation #{@held_generation} (source=#{source})"
422
+ return :not_modified
423
+ end
424
+
395
425
  if incoming_gen < @held_generation
396
426
  @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} < held #{@held_generation} (source=#{source})"
397
427
  # Failover observability (qfg-41nh.18): count the guard rejection.
@@ -431,7 +461,11 @@ module Quonfig
431
461
  @version = meta['version'] || meta[:version] || @version
432
462
  @environment_id = meta['environment'] || meta[:environment] || @environment_id
433
463
 
434
- @held_generation = incoming_gen
464
+ # qfg-9dxb.3 Fix A: an unversioned install (generation <= 0) carries no
465
+ # ordering info and must never LOWER a positive held generation. Under
466
+ # the qfg-9dxb.9 rule it only installs when held is nil/0 anyway, so
467
+ # this keeps held at 0 there; a fresh client seeds off it as usual.
468
+ @held_generation = incoming_gen if @held_generation.nil? || incoming_gen.positive?
435
469
  @install_count += 1
436
470
  @resolved_from_index = source_index unless source_index.nil?
437
471
  # Failover observability (qfg-41nh.18): record which leg served this
@@ -457,6 +491,14 @@ module Quonfig
457
491
  end
458
492
  end
459
493
 
494
+ # The install decision for a network payload (see #install_envelope).
495
+ def should_install?(incoming_gen)
496
+ return true if @held_generation.nil?
497
+ return @held_generation.zero? if incoming_gen <= 0
498
+
499
+ incoming_gen > @held_generation
500
+ end
501
+
460
502
  # Read Meta.generation (qfg-7h5d.1.1) — the monotonic per-branch commit
461
503
  # counter the backend stamps on every envelope. Absent/garbage → 0 (an old
462
504
  # backend that doesn't emit it, or fixture mode with no FIXTURE_GENERATION).
@@ -131,10 +131,15 @@ module Quonfig
131
131
 
132
132
  # --- Rule evaluation ------------------------------------------------
133
133
 
134
- def evaluate_rules(rules, context, config)
134
+ # +seg_path+ holds the keys of the configs being evaluated above this one
135
+ # through IN_SEG / NOT_IN_SEG, so segment resolution can detect a
136
+ # reference cycle instead of recursing until SystemStackError
137
+ # (qfg-9dxb.7). It is a path, not a global visited set, so a diamond (two
138
+ # segments that both reference a third) still resolves.
139
+ def evaluate_rules(rules, context, config, seg_path = [])
135
140
  rules.each_with_index do |rule, index|
136
141
  criteria = Array(hget(rule, :criteria) || [])
137
- next unless all_criteria_match?(criteria, context, config)
142
+ next unless all_criteria_match?(criteria, context, config, seg_path)
138
143
 
139
144
  value_hash = hget(rule, :value)
140
145
  return EvalResult.new(value: value_hash, rule_index: index, config: config)
@@ -142,8 +147,8 @@ module Quonfig
142
147
  nil
143
148
  end
144
149
 
145
- def all_criteria_match?(criteria, context, config)
146
- criteria.all? { |c| evaluate_criterion(c, context, config) }
150
+ def all_criteria_match?(criteria, context, config, seg_path)
151
+ criteria.all? { |c| evaluate_criterion(c, context, config, seg_path) }
147
152
  end
148
153
 
149
154
  # --- Per-operator evaluation ---------------------------------------
@@ -151,7 +156,7 @@ module Quonfig
151
156
  # Faithful port of sdk-node/src/operators.ts evaluateCriterion. Matches
152
157
  # context-exists / missing-context semantics (e.g. PROP_IS_NOT_ONE_OF is
153
158
  # true when context is missing).
154
- def evaluate_criterion(criterion, context, _config)
159
+ def evaluate_criterion(criterion, context, config, seg_path)
155
160
  property_name = hget(criterion, :propertyName) || ''
156
161
  operator = hget(criterion, :operator)
157
162
  match_value = hget(criterion, :valueToMatch)
@@ -296,7 +301,7 @@ module Quonfig
296
301
  when OP_IN_SEG, OP_NOT_IN_SEG
297
302
  if match_value
298
303
  segment_key = to_s_nil(hget(match_value, :value))
299
- found, result = resolve_segment(segment_key, context)
304
+ found, result = resolve_segment(segment_key, context, config, seg_path)
300
305
  return operator == OP_NOT_IN_SEG unless found
301
306
 
302
307
  return result == (operator == OP_IN_SEG)
@@ -319,16 +324,22 @@ module Quonfig
319
324
 
320
325
  # --- Segment resolution -------------------------------------------
321
326
 
322
- def resolve_segment(segment_key, context)
327
+ def resolve_segment(segment_key, context, config, seg_path)
323
328
  return [false, false] if segment_key.nil? || segment_key.empty?
324
329
 
330
+ # A reference back onto the current evaluation path is a cycle. Treat
331
+ # it like a missing segment (IN_SEG false, NOT_IN_SEG true), matching
332
+ # sdk-go (qfg-9dxb.4).
333
+ current_key = hget(config, :key).to_s
334
+ return [false, false] if segment_key == current_key || seg_path.include?(segment_key)
335
+
325
336
  seg_config = @store.get(segment_key)
326
337
  return [false, false] if seg_config.nil?
327
338
 
328
339
  # Segments have no environment-specific rules in the JSON shape; we
329
340
  # evaluate against default rules only (mirrors sdk-node behaviour —
330
341
  # evaluate_config with env_id='' falls through to default).
331
- match = evaluate_rules(default_rules_of(seg_config), context, seg_config)
342
+ match = evaluate_rules(default_rules_of(seg_config), context, seg_config, seg_path + [current_key])
332
343
  return [false, false] if match.nil?
333
344
 
334
345
  raw = match.raw_value
@@ -424,14 +435,18 @@ module Quonfig
424
435
  REASON_TARGETING_MATCH = 2
425
436
  REASON_SPLIT = 3
426
437
 
427
- attr_reader :value, :rule_index, :config, :reportable_value
438
+ attr_reader :value, :rule_index, :config, :reportable_value, :hash_property_missing
428
439
  attr_accessor :weighted_value_index
429
440
 
430
- def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil)
441
+ def initialize(value:, rule_index:, config:, weighted_value_index: nil, reportable_value: nil,
442
+ hash_property_missing: false)
431
443
  @value = value
432
444
  @rule_index = rule_index
433
445
  @config = config
434
446
  @weighted_value_index = weighted_value_index
447
+ # True when a weighted rollout's hash property was missing from the
448
+ # context and an empty value was hashed instead (qfg-9dxb.8).
449
+ @hash_property_missing = hash_property_missing
435
450
  # Telemetry-safe substitute for #unwrapped_value. Set by Resolver when
436
451
  # the underlying Value was confidential / decryptWith, so callers
437
452
  # (the eval-summary aggregator) never see the plaintext. Mirrors
@@ -34,10 +34,16 @@ module Quonfig
34
34
  # Options#config_fetch_timeout_ms (sequential) or the hedge abort (hedged
35
35
  # legs) so a hung OR drip-feeding upstream aborts fast instead of blocking
36
36
  # the caller's whole init budget.
37
- def initialize(uri, sdk_key, timeout_ms: nil)
37
+ #
38
+ # +open_timeout_ms+ (qfg-y8je.8): a separate, usually shorter, bound on the
39
+ # connect (open) phase — TCP connect plus TLS handshake. nil uses
40
+ # +timeout_ms+ for it, as before. The telemetry reporter passes 5s here and
41
+ # 15s as +timeout_ms+.
42
+ def initialize(uri, sdk_key, timeout_ms: nil, open_timeout_ms: nil)
38
43
  @uri = uri
39
44
  @sdk_key = sdk_key
40
45
  @timeout_ms = timeout_ms
46
+ @open_timeout_ms = open_timeout_ms
41
47
  end
42
48
 
43
49
  attr_reader :uri
@@ -46,8 +52,11 @@ module Quonfig
46
52
  with_wall_clock_deadline { connection(headers).get(path) }
47
53
  end
48
54
 
55
+ # A String +body+ is sent verbatim (the telemetry reporter resends a
56
+ # retained batch byte-for-byte); anything else is serialized as JSON.
49
57
  def post(path, body)
50
- with_wall_clock_deadline { connection.post(path, body.to_json) }
58
+ payload = body.is_a?(String) ? body : body.to_json
59
+ with_wall_clock_deadline { connection.post(path, payload) }
51
60
  end
52
61
 
53
62
  def connection(headers = {})
@@ -63,6 +72,7 @@ module Quonfig
63
72
  conn.options.open_timeout = seconds
64
73
  conn.options.timeout = seconds
65
74
  end
75
+ conn.options.open_timeout = @open_timeout_ms / 1000.0 if @open_timeout_ms
66
76
  end
67
77
  end
68
78
 
@@ -8,7 +8,9 @@ module Quonfig
8
8
  attr_reader :sdk_key, :environment, :api_urls, :sse_api_urls, :telemetry_destination, :config_api_urls,
9
9
  :on_no_default, :init_timeout_ms, :on_init_failure, :collect_sync_interval, :datadir, :enable_sse, :fallback_poll_enabled, :fallback_poll_interval_ms, :global_context, :logger_key, :logger, :enable_quonfig_user_context,
10
10
  :data_dir_auto_reload, :data_dir_auto_reload_debounce_ms, :config_fetch_timeout_ms,
11
- :config_fetch_hedge_delay_ms, :config_fetch_hedge_abort_ms, :api_urls_explicit
11
+ :config_fetch_hedge_delay_ms, :config_fetch_hedge_abort_ms, :api_urls_explicit,
12
+ :telemetry_timeout_ms, :telemetry_connect_timeout_ms, :telemetry_max_retained_batches,
13
+ :telemetry_max_retained_bytes, :telemetry_max_retained_age_ms
12
14
  attr_accessor :is_fork
13
15
 
14
16
  # Default fallback poll interval, in milliseconds. The SDK polls api-delivery
@@ -85,9 +87,28 @@ module Quonfig
85
87
  end
86
88
 
87
89
  DEFAULT_MAX_PATHS = 1_000
88
- DEFAULT_MAX_KEYS = 100_000
89
- DEFAULT_MAX_EXAMPLE_CONTEXTS = 100_000
90
- DEFAULT_MAX_EVAL_SUMMARIES = 100_000
90
+ # Telemetry aggregator caps per flush window (P6 of the telemetry transport
91
+ # policy, qfg-y8je.8): the uniform server-SDK cap. Were 100,000 before 1.5.0.
92
+ DEFAULT_MAX_KEYS = 10_000
93
+ DEFAULT_MAX_EXAMPLE_CONTEXTS = 10_000
94
+ DEFAULT_MAX_EVAL_SUMMARIES = 10_000
95
+
96
+ # Telemetry transport defaults (qfg-y8je.8; policy P1-P5 in
97
+ # project/plans/2026-09-24-sdk-telemetry-transport-policy.md).
98
+ # Seconds between telemetry POSTs (a fixed cadence; was an 8s interval
99
+ # that grew to 600s).
100
+ DEFAULT_COLLECT_SYNC_INTERVAL = 60
101
+ # Overall deadline for one telemetry POST.
102
+ DEFAULT_TELEMETRY_TIMEOUT_MS = 15_000
103
+ # TCP connect + TLS deadline for one telemetry POST.
104
+ DEFAULT_TELEMETRY_CONNECT_TIMEOUT_MS = 5_000
105
+ # Failed batches kept for resend: at most this many...
106
+ DEFAULT_TELEMETRY_MAX_RETAINED_BATCHES = 5
107
+ # ...and at most this many serialized bytes (2MB); a single batch larger
108
+ # than this is POSTed once and never kept.
109
+ DEFAULT_TELEMETRY_MAX_RETAINED_BYTES = 2 * 1024 * 1024
110
+ # A kept batch older than this is discarded.
111
+ DEFAULT_TELEMETRY_MAX_RETAINED_AGE_MS = 300_000
91
112
 
92
113
  # Hardcoded fallback domain. Overridden by ENV['QUONFIG_DOMAIN'].
93
114
  DEFAULT_DOMAIN = 'quonfig.com'
@@ -213,6 +234,22 @@ module Quonfig
213
234
  # Debounce window in milliseconds. Filesystem events arriving
214
235
  # inside the window are coalesced into a single re-read. Ignored
215
236
  # when +:data_dir_auto_reload+ is +false+.
237
+ # @option options [Numeric] :collect_sync_interval (60)
238
+ # Seconds between telemetry POSTs, on a fixed cadence. At most one POST
239
+ # is in flight; a tick that fires while one is out is skipped and its
240
+ # data rolls into the next window.
241
+ # @option options [Integer] :telemetry_timeout_ms (15000)
242
+ # Overall deadline for one telemetry POST.
243
+ # @option options [Integer] :telemetry_connect_timeout_ms (5000)
244
+ # TCP connect + TLS deadline for one telemetry POST.
245
+ # @option options [Integer] :telemetry_max_retained_batches (5)
246
+ # Failed batches kept (byte-for-byte) for resend; the oldest is dropped
247
+ # beyond this.
248
+ # @option options [Integer] :telemetry_max_retained_bytes (2097152)
249
+ # Byte cap on kept batches. A single batch larger than this is POSTed
250
+ # once and dropped if that POST fails.
251
+ # @option options [Integer] :telemetry_max_retained_age_ms (300000)
252
+ # A kept batch older than this is discarded.
216
253
  # @option options [Boolean] :allow_telemetry_in_local_mode (false)
217
254
  # @deprecated No-op since 1.3.0 (qfg-5x9x). Telemetry is gated on SDK-key
218
255
  # presence alone, so datadir mode no longer suppresses it and this flag
@@ -240,6 +277,11 @@ module Quonfig
240
277
  config_fetch_hedge_abort_ms: nil,
241
278
  collect_max_paths: DEFAULT_MAX_PATHS,
242
279
  collect_sync_interval: nil,
280
+ telemetry_timeout_ms: nil,
281
+ telemetry_connect_timeout_ms: nil,
282
+ telemetry_max_retained_batches: nil,
283
+ telemetry_max_retained_bytes: nil,
284
+ telemetry_max_retained_age_ms: nil,
243
285
  context_upload_mode: :periodic_example, # :periodic_example, :shapes_only, :none
244
286
  context_max_size: DEFAULT_MAX_EVAL_SUMMARIES,
245
287
  collect_evaluation_summaries: true,
@@ -303,7 +345,15 @@ module Quonfig
303
345
  @config_fetch_hedge_abort_ms = config_fetch_hedge_abort_ms || DEFAULT_CONFIG_FETCH_HEDGE_ABORT_MS
304
346
 
305
347
  @collect_max_paths = collect_max_paths
306
- @collect_sync_interval = collect_sync_interval
348
+ @collect_sync_interval = collect_sync_interval.nil? ? DEFAULT_COLLECT_SYNC_INTERVAL : collect_sync_interval
349
+ # Telemetry transport (qfg-y8je.8). nil, non-numeric or <= 0 -> default.
350
+ @telemetry_timeout_ms = positive_or(telemetry_timeout_ms, DEFAULT_TELEMETRY_TIMEOUT_MS)
351
+ @telemetry_connect_timeout_ms = positive_or(telemetry_connect_timeout_ms, DEFAULT_TELEMETRY_CONNECT_TIMEOUT_MS)
352
+ @telemetry_max_retained_batches = positive_or(telemetry_max_retained_batches,
353
+ DEFAULT_TELEMETRY_MAX_RETAINED_BATCHES)
354
+ @telemetry_max_retained_bytes = positive_or(telemetry_max_retained_bytes, DEFAULT_TELEMETRY_MAX_RETAINED_BYTES)
355
+ @telemetry_max_retained_age_ms = positive_or(telemetry_max_retained_age_ms,
356
+ DEFAULT_TELEMETRY_MAX_RETAINED_AGE_MS)
307
357
  @collect_evaluation_summaries = collect_evaluation_summaries
308
358
  @collect_max_evaluation_summaries = collect_max_evaluation_summaries
309
359
  # Retained for back-compat only; nothing reads it (qfg-5x9x).
@@ -379,6 +429,10 @@ module Quonfig
379
429
  option && sdk_key?
380
430
  end
381
431
 
432
+ def positive_or(value, default)
433
+ value.is_a?(Numeric) && value.positive? && value.finite? ? value : default
434
+ end
435
+
382
436
  def remove_trailing_slash(url)
383
437
  url.end_with?('/') ? url[0..-2] : url
384
438
  end