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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +63 -1
- data/lib/quonfig/client.rb +8 -34
- data/lib/quonfig/config_envelope.rb +16 -1
- data/lib/quonfig/config_loader.rb +61 -19
- data/lib/quonfig/evaluator.rb +25 -10
- data/lib/quonfig/http_connection.rb +12 -2
- data/lib/quonfig/options.rb +59 -5
- data/lib/quonfig/resolver.rb +57 -16
- data/lib/quonfig/sse_config_client.rb +8 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +14 -1
- data/lib/quonfig/telemetry/telemetry_reporter.rb +231 -115
- data/lib/quonfig/telemetry/transport_queue.rb +311 -0
- data/lib/quonfig/version.rb +1 -1
- data/lib/quonfig.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96d17a74751571b9c6099b8239b2ccaafb25a5f7e55d006cecae5cb1f8651979
|
|
4
|
+
data.tar.gz: 6bbe54bd07a5fb1e20b987ef8a8aaef417533ca8ef26000873db8a671142d4bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
data/lib/quonfig/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
# generation
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
|
|
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
|
-
|
|
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).
|
data/lib/quonfig/evaluator.rb
CHANGED
|
@@ -131,10 +131,15 @@ module Quonfig
|
|
|
131
131
|
|
|
132
132
|
# --- Rule evaluation ------------------------------------------------
|
|
133
133
|
|
|
134
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/quonfig/options.rb
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|