quonfig 1.1.1 → 1.3.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: df149576c34f45d691f7252a2f0e05b2c02b95681ab9986fac6a373071210c7e
4
- data.tar.gz: ab5dc409a48ed4f471b500d62451a8d1b3b1e7de67871172ce684327ba7a1777
3
+ metadata.gz: 8408e71b66de6bfe3e771b4091429d31f15f1dbdc0bb74d2e05bc3bcd83415db
4
+ data.tar.gz: e22b82909ffbb87dec181aa5c526ae12f7d4d7669c498027809ba8209ccd7c97
5
5
  SHA512:
6
- metadata.gz: dec2b311edf9f515e00803d7f2a60c9f10897807779b26a44f4aa842ef5ac46239a432a194e72378eb23c3e03a79002ff3332e6d72be265683b707adb88134ed
7
- data.tar.gz: 55c3a892dfa3af12e4db273e09faf5f0b96c8c2861cdfdb611c9ba75877a6e3a1c696568593b0cdd6283874b072f259738cc14c8d3a871a432cfbc17a194df35
6
+ metadata.gz: '0149ee240869602465f2ff10180f8f2c35b577bf12d6f73bf33afe02ac16e0742e93744c085cbd641c1640b542a137dbde8650e5dde580174b718d89d4b08ed6'
7
+ data.tar.gz: b223c20dc4cde206a30e803d43d507c1e4b5f204465c7e5e0c1f19db178842bc3a5013db7d734d09178a23cddca3df4e2b275f4f000b71bb3f2566e6395d337d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-08-18
4
+
5
+ > **Heads up — datadir users will start emitting telemetry.** Datadir mode with a valid SDK key now emits usage telemetry as documented; it previously sent nothing. If you run the SDK with a `datadir:` (or `QUONFIG_DIR`) **and** an SDK key, upgrading to 1.3.0 means evaluation summaries and context telemetry begin flowing to the telemetry service from that deployment — data you were not sending on 1.2.0. Disable with the standard telemetry options if unwanted (`collect_evaluation_summaries: false` and `context_upload_mode: :none`). A datadir client with **no** SDK key still sends nothing at all.
6
+
7
+ - **Fix (telemetry): the telemetry gate is SDK-key presence, not delivery mode (qfg-5x9x).** `Options#telemetry_allowed?` consulted `local_only?` (i.e. "is a datadir set?"), which forced every `collect_max_*` to `0` the moment a datadir was configured. With no collector limits the client built no aggregators, took `initialize_telemetry`'s early return, and never constructed a `TelemetryReporter` — so a datadir client holding a perfectly valid SDK key emitted **no** evaluation summaries, **no** context telemetry, nothing at all. Datadir is a config-**delivery** mode: it decides where config comes from, not whether usage is reported. The gate is now the SDK key alone, which is what identifies the workspace telemetry is attributed to. This aligns sdk-ruby with sdk-node, sdk-go, sdk-python, sdk-java, and sdk-net, and is the inverse of the defect fixed for sdk-go / sdk-python in 1.2.1 (those *over*-sent without a key). No wire-format change, no new dependencies, no public API removed.
8
+ - **Fix (telemetry): a keyless client now collects nothing at all.** Without an SDK key there is no workspace to attribute telemetry to, so the aggregators are no longer even allocated and the reporter never starts (previously the collectors filled up in delivery mode and were discarded at flush time). Behavior for the open-source / no-account path is unchanged from the outside — still zero telemetry HTTP — but nothing is accumulated in memory on the way there.
9
+ - **Deprecated: `allow_telemetry_in_local_mode:` is now a no-op.** It existed only to re-enable telemetry that datadir mode had suppressed; with mode out of the decision there is nothing left for it to unlock. The kwarg is still accepted so existing callers keep working, and it can neither enable telemetry without a key nor suppress it with one. Slated for removal in 2.0.0 — use `collect_evaluation_summaries: false` / `context_upload_mode: :none` to opt out.
10
+ - **Docs:** the README's datadir section previously claimed the mode "performs no network I/O" unconditionally. It now states the delivery-side guarantee precisely (no config fetch, no SSE, no polling) and documents the telemetry gate, including how to run datadir + key with telemetry off.
11
+
12
+ ## 1.2.0 - 2026-07-08
13
+
14
+ - **Feat (options): warn when an explicit `api_urls` disables automatic failover (qfg-41nh.26).** The default (and every `QUONFIG_DOMAIN`-derived) `api_urls` list carries a primary AND a secondary leg, and the HTTP config-fetch hedges/fails over between them (the secondary runs on separate infrastructure). An explicit `api_urls:` replaces that list wholesale, so a single-entry override silently dropped the secondary and disabled automatic failover. The client now logs one WARN at init when the caller explicitly set `api_urls` and the resolved list has fewer than two legs, pointing at the fix (pass both a primary and a secondary URL). Behavior is otherwise unchanged; no new dependencies. A new README "Failover & `QUONFIG_DOMAIN`" section documents the URL derivation and the failover model.
15
+ - **Feat (telemetry): emit failover-behavior signals on the existing telemetry wire (qfg-41nh.18).** The SDK now folds three additive failover counters into the periodic telemetry flush so the failover dashboards can be built — previously no SDK emitted any failover signal. Each flush window that saw failover activity ships one `failover` event alongside the existing `summaries` / `contextShapes` / `exampleContexts` events: `hedgeFired` (config-fetch cycles where the parallel primary+secondary hedge fired its secondary leg), `guardRejected` (installs dropped by the reject-older ordering guard, counted on BOTH the HTTP config-fetch path and the SSE message path), and `resolvedFromPrimary` / `resolvedFromSecondary` (which upstream leg served each successful HTTP install; SSE/datadir installs are not counted). `resolvedFromLkg` is reserved and always `0`. The event is emitted only when at least one counter is non-zero in the window, so a steady-state client with no config churn emits nothing. Recording rides the existing telemetry opt-out (nothing is emitted unless telemetry is enabled) and adds only a per-config-refresh mutex increment — no new dependencies, no per-evaluation overhead. Wire shape is additive and matches sdk-go exactly (an older api-telemetry strips the unknown field).
16
+
3
17
  ## 1.1.1 - 2026-07-03
4
18
 
5
19
  - **Fix (SSE): the stream leg is pinned to the primary stream URL — SSE never fails over (qfg-41nh.6).** The reconnect loop previously rotated through `sse_api_urls` on every reconnect, so with the default two derived stream URLs a primary blip silently parked the live stream on `stream.secondary` and bound config freshness to the mirror. The stream now always dials `sse_api_urls[0]` and retries it forever with backoff (matching sdk-go); failover remains HTTP-poll only, and the HTTP config-fetch hedge still uses both `api_urls` legs. The `sse_failed_over_to_secondary?` diagnostic is now latched at the dial site, and the failover chaos harness hands the client both stream legs so the f05 probe actually exercises the pin.
data/README.md CHANGED
@@ -86,7 +86,8 @@ bound.get_int('rate-limit')
86
86
 
87
87
  For tests, CI, or air-gapped environments, point the client at a local workspace
88
88
  directory instead of the Quonfig API. In datadir mode the SDK loads JSON config
89
- files from disk and performs no network I/O.
89
+ files from disk config delivery does no network I/O at all: no config fetch,
90
+ no SSE stream, no polling.
90
91
 
91
92
  ```ruby
92
93
  client = Quonfig::Client.new(
@@ -111,6 +112,32 @@ export QUONFIG_ENVIRONMENT=production
111
112
  client = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT
112
113
  ```
113
114
 
115
+ ### Telemetry in datadir mode
116
+
117
+ Usage telemetry is gated on **SDK-key presence, not on mode**. A datadir client
118
+ with an `sdk_key:` configured still reports evaluation summaries and context
119
+ telemetry to the telemetry service, exactly as a delivery-mode client does —
120
+ that combination is what makes flag usage visible in the Quonfig UI for services
121
+ that read config from a checked-out workspace.
122
+
123
+ A datadir client with **no** SDK key has no workspace to attribute telemetry to,
124
+ so it collects and sends nothing: fully offline, zero network I/O.
125
+
126
+ To run with a key but without telemetry, use the standard opt-outs:
127
+
128
+ ```ruby
129
+ client = Quonfig::Client.new(
130
+ datadir: '/path/to/workspace',
131
+ environment: 'production',
132
+ sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
133
+ collect_evaluation_summaries: false,
134
+ context_upload_mode: :none
135
+ )
136
+ ```
137
+
138
+ > Changed in 1.3.0: before 1.3.0 a datadir sent nothing even with a valid SDK
139
+ > key. See the CHANGELOG.
140
+
114
141
  ## Datadir mode: auto-reload on file changes
115
142
 
116
143
  In datadir mode the SDK loads the workspace once at construction time and then
@@ -258,6 +285,42 @@ Quonfig::Client.new(
258
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`. |
259
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. |
260
287
 
288
+ ## Failover & `QUONFIG_DOMAIN`
289
+
290
+ By default the SDK derives every hostname from `QUONFIG_DOMAIN` (default
291
+ `quonfig.com`):
292
+
293
+ | Role | URL |
294
+ |--------------------------|-----------------------------------------|
295
+ | Config fetch (primary) | `https://primary.quonfig.com` |
296
+ | SSE stream (primary) | `https://stream.primary.quonfig.com` |
297
+ | Config fetch (secondary) | `https://secondary.quonfig.com` |
298
+ | SSE stream (secondary) | `https://stream.secondary.quonfig.com` |
299
+ | Telemetry | `https://telemetry.quonfig.com` |
300
+
301
+ Set `QUONFIG_DOMAIN` to move all of them together (e.g.
302
+ `QUONFIG_DOMAIN=quonfig-staging.com`). **Automatic failover and hedging between
303
+ the primary and the secondary are on by default** — the secondary runs on
304
+ separate infrastructure, and the HTTP config-fetch fails over to it if the
305
+ primary is unreachable and hedges to it if the primary is slow.
306
+
307
+ `api_urls:` replaces the derived list wholesale. To keep automatic failover
308
+ with custom URLs, **pass both a primary and a secondary URL**:
309
+
310
+ ```ruby
311
+ Quonfig::Client.new(
312
+ sdk_key: 'your-sdk-key',
313
+ api_urls: [
314
+ 'https://primary.your-proxy.example',
315
+ 'https://secondary.your-proxy.example'
316
+ ]
317
+ )
318
+ ```
319
+
320
+ A single URL disables failover, and the SDK logs a warning at init. See
321
+ https://docs.quonfig.com/docs/explanations/architecture/resiliency for the full
322
+ model.
323
+
261
324
  ## Typed getters
262
325
 
263
326
  Each typed getter takes a config key and an optional context hash. If the key
@@ -66,6 +66,16 @@ module Quonfig
66
66
  @poll_supervisor = nil
67
67
  @stopped = false
68
68
  @telemetry_reporter = nil
69
+ # Shared failover-telemetry aggregator (qfg-41nh.18). Created once and
70
+ # shared between the ConfigLoader (which records hedge-fired /
71
+ # guard-rejected / resolved-from at the failover call sites) and the
72
+ # TelemetryReporter (which drains it on each flush). Created before the
73
+ # ConfigLoader so the initial HTTP fetch — often the ONLY HTTP fetch when
74
+ # SSE is healthy — is captured. Recording is a per-config-refresh mutex +
75
+ # increment (negligible); nothing is EMITTED unless telemetry is enabled
76
+ # (the reporter, which owns the drain, only starts then). Survives fork:
77
+ # the pre-fork stop drains it, the child's rebuilt reporter drains onward.
78
+ @failover_aggregator = Quonfig::Telemetry::FailoverAggregator.new
69
79
  @state_mutex = Mutex.new
70
80
  @last_successful_refresh = nil
71
81
  @sse_state = :idle
@@ -689,6 +699,7 @@ module Quonfig
689
699
  context_shape_aggregator: shape_aggregator,
690
700
  example_contexts_aggregator: example_aggregator,
691
701
  evaluation_summaries_aggregator: summaries_aggregator,
702
+ failover_aggregator: @failover_aggregator,
692
703
  sync_interval: @options.collect_sync_interval
693
704
  )
694
705
 
@@ -813,8 +824,9 @@ module Quonfig
813
824
 
814
825
  warn_if_pin_ignored_in_delivery_mode
815
826
  warn_if_hedge_abort_exceeds_init_timeout
827
+ warn_if_explicit_api_urls_disables_failover
816
828
 
817
- @config_loader = Quonfig::ConfigLoader.new(@store, @options)
829
+ @config_loader = Quonfig::ConfigLoader.new(@store, @options, failover_aggregator: @failover_aggregator)
818
830
 
819
831
  perform_initial_fetch
820
832
 
@@ -914,6 +926,27 @@ module Quonfig
914
926
  )
915
927
  end
916
928
 
929
+ # qfg-41nh.26: the SDK's default (and every QUONFIG_DOMAIN-derived) api_urls
930
+ # list carries a primary AND a secondary leg, and the HTTP config-fetch
931
+ # hedges/fails over between them (secondary runs on separate infra). An
932
+ # explicit `api_urls:` replaces that list wholesale, so a single-entry
933
+ # override silently drops the secondary and disables automatic failover.
934
+ # Warn once at init in delivery mode so the customer isn't surprised.
935
+ # Mirrors sdk-go's construction-time warning (quonfig.go). Does not change
936
+ # behavior. Fired only when the caller explicitly set api_urls AND the
937
+ # resolved list has fewer than two legs — never on the default/derived
938
+ # two-URL list.
939
+ def warn_if_explicit_api_urls_disables_failover
940
+ return unless @options.respond_to?(:api_urls_explicit)
941
+ return unless @options.api_urls_explicit
942
+ return unless Array(@options.config_api_urls).length < 2
943
+
944
+ LOG.warn(
945
+ '[quonfig] explicit api_urls disables automatic failover to the ' \
946
+ 'secondary; pass both primary and secondary URLs to keep it'
947
+ )
948
+ end
949
+
917
950
  def handle_init_failure(err)
918
951
  if @options.on_init_failure == Quonfig::Options::ON_INITIALIZATION_FAILURE::RETURN
919
952
  LOG.warn "[quonfig] Initialization did not complete cleanly; continuing with empty store: #{err.message}"
@@ -37,7 +37,7 @@ module Quonfig
37
37
  #
38
38
  # Backward compat: callers that pass a single +base_client+ (mock client
39
39
  # used by tests that expects `.options`) are still supported.
40
- def initialize(store_or_base_client, options = nil, logger: nil)
40
+ def initialize(store_or_base_client, options = nil, logger: nil, failover_aggregator: nil)
41
41
  if options.nil? && store_or_base_client.respond_to?(:options)
42
42
  # Legacy shape: ConfigLoader.new(base_client)
43
43
  @options = store_or_base_client.options
@@ -47,6 +47,11 @@ module Quonfig
47
47
  @options = options
48
48
  end
49
49
 
50
+ # Optional failover-telemetry sink (qfg-41nh.18). When telemetry is
51
+ # enabled the Client passes the shared FailoverAggregator; nil otherwise,
52
+ # so every record call below is a no-op under a telemetry opt-out.
53
+ @failover_aggregator = failover_aggregator
54
+
50
55
  @api_config = Concurrent::Map.new
51
56
  # qfg-7h5d.1.14: per-leg ETag is load-bearing for the parallel hedge. The
52
57
  # hedge runs the primary and secondary legs concurrently; a SINGLE shared
@@ -179,6 +184,10 @@ module Quonfig
179
184
  # mutually exclusive — exactly one of suppress/fire wins.
180
185
  gate = Mutex.new
181
186
  secondary_fired = false
187
+ # Distinct from secondary_fired (which is ALSO set true by suppress on a
188
+ # fast primary win): this is set only when the secondary leg was ACTUALLY
189
+ # spawned, so it is the exact "the hedge fired" signal (qfg-41nh.18).
190
+ secondary_spawned = false
182
191
 
183
192
  # Each leg is wall-clock bounded by abort_ms (HttpConnection enforces a
184
193
  # whole-request deadline, qfg-41nh.6), so a leg ALWAYS settles — a
@@ -203,6 +212,7 @@ module Quonfig
203
212
  next false if secondary_fired
204
213
 
205
214
  secondary_fired = true
215
+ secondary_spawned = true
206
216
  end
207
217
  run_leg.call(1) if spawn
208
218
  end
@@ -256,6 +266,14 @@ module Quonfig
256
266
  # Stop the timer if it is still sleeping (already-fired is harmless).
257
267
  hedge_timer.kill if hedge_timer.alive?
258
268
 
269
+ # Failover observability (qfg-41nh.18): if the secondary leg was actually
270
+ # spawned this cycle, the hedge fired (the primary was slow past the hedge
271
+ # delay or errored fast). Recorded once per cycle regardless of which leg's
272
+ # payload won the reject-older guard. Read under the gate — the flag is
273
+ # written under it by fire_secondary, which may run on the timer thread.
274
+ hedged = gate.synchronize { secondary_spawned }
275
+ @failover_aggregator&.record_hedge_fired if hedged
276
+
259
277
  return :updated if installed
260
278
  return :not_modified if saw_not_modified
261
279
 
@@ -369,6 +387,10 @@ module Quonfig
369
387
  # on stale config would be worse (mirrors sdk-node).
370
388
  unless @held_generation.nil? || incoming_gen <= 0 || incoming_gen > @held_generation
371
389
  @logger.debug "Reject-older guard: dropping incoming generation #{incoming_gen} <= held #{@held_generation} (source=#{source})"
390
+ # Failover observability (qfg-41nh.18): count the guard rejection. This
391
+ # single guard covers BOTH the HTTP config-fetch path and the SSE
392
+ # message path (apply_envelope) — every network install funnels here.
393
+ @failover_aggregator&.record_guard_rejected
372
394
  return :not_modified
373
395
  end
374
396
 
@@ -388,6 +410,11 @@ module Quonfig
388
410
  @held_generation = incoming_gen
389
411
  @install_count += 1
390
412
  @resolved_from_index = source_index unless source_index.nil?
413
+ # Failover observability (qfg-41nh.18): record which leg served this
414
+ # successful HTTP install (source_index 0 = primary, > 0 = secondary).
415
+ # SSE / datadir installs pass source_index nil and are ignored by the
416
+ # aggregator, so they are never counted.
417
+ @failover_aggregator&.record_resolved_from(source_index)
391
418
 
392
419
  # Replace the live store atomically.
393
420
  return if @store.nil?
@@ -8,7 +8,7 @@ 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
11
+ :config_fetch_hedge_delay_ms, :config_fetch_hedge_abort_ms, :api_urls_explicit
12
12
  attr_accessor :is_fork
13
13
 
14
14
  # Default fallback poll interval, in milliseconds. The SDK polls api-delivery
@@ -140,10 +140,21 @@ module Quonfig
140
140
 
141
141
  # In datadir mode the SDK evaluates config from a local workspace and does
142
142
  # not connect to the delivery service.
143
+ #
144
+ # NOTE: this says nothing about telemetry. Datadir mode is a config-DELIVERY
145
+ # mode; usage telemetry still flows whenever an SDK key is configured. See
146
+ # #telemetry_allowed?.
143
147
  def local_only?
144
148
  !@datadir.nil?
145
149
  end
146
150
 
151
+ # True when an SDK key is configured. The SDK key is what identifies the
152
+ # workspace telemetry is attributed to, so it is the single gate on whether
153
+ # any collection happens at all (qfg-j001 / qfg-5x9x).
154
+ def sdk_key?
155
+ !(@sdk_key.nil? || @sdk_key.to_s.empty?)
156
+ end
157
+
147
158
  def datadir?
148
159
  !@datadir.nil?
149
160
  end
@@ -202,6 +213,13 @@ module Quonfig
202
213
  # Debounce window in milliseconds. Filesystem events arriving
203
214
  # inside the window are coalesced into a single re-read. Ignored
204
215
  # when +:data_dir_auto_reload+ is +false+.
216
+ # @option options [Boolean] :allow_telemetry_in_local_mode (false)
217
+ # @deprecated No-op since 1.3.0 (qfg-5x9x). Telemetry is gated on SDK-key
218
+ # presence alone, so datadir mode no longer suppresses it and this flag
219
+ # has nothing left to unlock. Still accepted so existing callers keep
220
+ # working; slated for removal in 2.0.0. To turn telemetry off, use the
221
+ # standard opt-outs (+:collect_evaluation_summaries+ +false+,
222
+ # +:context_upload_mode+ +:none+).
205
223
  def init(
206
224
  api_urls: nil,
207
225
  telemetry_url: nil,
@@ -226,6 +244,7 @@ module Quonfig
226
244
  context_max_size: DEFAULT_MAX_EVAL_SUMMARIES,
227
245
  collect_evaluation_summaries: true,
228
246
  collect_max_evaluation_summaries: DEFAULT_MAX_EVAL_SUMMARIES,
247
+ # Deprecated no-op since 1.3.0 (qfg-5x9x) — see the @option doc above.
229
248
  allow_telemetry_in_local_mode: false,
230
249
  global_context: {},
231
250
  logger_key: nil,
@@ -287,6 +306,7 @@ module Quonfig
287
306
  @collect_sync_interval = collect_sync_interval
288
307
  @collect_evaluation_summaries = collect_evaluation_summaries
289
308
  @collect_max_evaluation_summaries = collect_max_evaluation_summaries
309
+ # Retained for back-compat only; nothing reads it (qfg-5x9x).
290
310
  @allow_telemetry_in_local_mode = allow_telemetry_in_local_mode
291
311
  @is_fork = false
292
312
  @global_context = global_context
@@ -308,6 +328,16 @@ module Quonfig
308
328
  # 3. Hardcoded DEFAULT_DOMAIN ('quonfig.com')
309
329
  domain = Quonfig::Options.domain
310
330
 
331
+ # Whether the caller explicitly supplied api_urls (vs. the SDK deriving
332
+ # both legs from QUONFIG_DOMAIN / DEFAULT_DOMAIN). The default and every
333
+ # domain-derived list carries a primary AND a secondary leg, so the SDK
334
+ # hedges/fails over automatically; an explicit single-entry override
335
+ # silently drops the secondary. Client warns once at init when this is
336
+ # true and the resolved list has < 2 legs (see
337
+ # Client#warn_if_explicit_api_urls_disables_failover). Mirrors sdk-go's
338
+ # apiURLsExplicit flag (qfg-41nh.26).
339
+ @api_urls_explicit = !api_urls.nil?
340
+
311
341
  @api_urls = Array(api_urls || Quonfig::Options.derive_api_urls(domain))
312
342
  .map { |url| remove_trailing_slash(url) }
313
343
 
@@ -332,8 +362,21 @@ module Quonfig
332
362
  end
333
363
  end
334
364
 
365
+ # The telemetry gate: an explicit per-collector opt-out (+option+) AND the
366
+ # presence of an SDK key. Mode is deliberately NOT part of this decision.
367
+ #
368
+ # qfg-5x9x: this used to read
369
+ # `option && (!local_only? || @allow_telemetry_in_local_mode)`
370
+ # which zeroed every collect_max_* the moment a datadir was set, so a
371
+ # datadir client holding a perfectly valid SDK key built no aggregators and
372
+ # therefore never constructed a reporter — it emitted nothing at all.
373
+ # Datadir + SDK key is a supported combination and telemetry has to flow
374
+ # there like it does in delivery mode. Conversely a keyless client (the
375
+ # open-source / no-account path) has no workspace to attribute data to, so
376
+ # nothing is collected and the reporter never starts. Mirrors sdk-node's
377
+ # `isTelemetryEnabled` and sdk-go's `Options.TelemetryEnabled()`.
335
378
  def telemetry_allowed?(option)
336
- option && (!local_only? || @allow_telemetry_in_local_mode)
379
+ option && sdk_key?
337
380
  end
338
381
 
339
382
  def remove_trailing_slash(url)
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ module Telemetry
5
+ # Accumulates failover-behavior counters over a flush window: how many times
6
+ # the config-fetch hedge fired its secondary leg, how many installs the
7
+ # reject-older ordering guard dropped, and which upstream leg served each
8
+ # successful HTTP install. Every counter is additive and carries NO user
9
+ # data.
10
+ #
11
+ # The aggregator is independently thread-safe (its own mutex) and is written
12
+ # directly from the failover call sites in ConfigLoader rather than through
13
+ # any queue — the call rate is per-config-refresh, not per-evaluation, so a
14
+ # plain mutex has negligible overhead.
15
+ #
16
+ # Wire shape matches sdk-go's FailoverEvent (the canonical reference) and
17
+ # api-telemetry's failover schema EXACTLY — camelCase keys, unix-millis
18
+ # window. #drain_event returns +nil+ when every counter is zero, so a healthy
19
+ # steady-state client emits no failover event at all.
20
+ #
21
+ # { "failover": {
22
+ # "start": <int ms>, "end": <int ms>,
23
+ # "hedgeFired": <int>, "guardRejected": <int>,
24
+ # "resolvedFromPrimary": <int>, "resolvedFromSecondary": <int>,
25
+ # "resolvedFromLkg": <int>
26
+ # } }
27
+ class FailoverAggregator
28
+ def initialize
29
+ @mutex = Mutex.new
30
+ @start_at_ms = nil
31
+ @hedge_fired = 0
32
+ @guard_rejected = 0
33
+ @resolved_from_primary = 0
34
+ @resolved_from_secondary = 0
35
+ # Reserved for a future last-known-good delivery path; backends emit 0.
36
+ @resolved_from_lkg = 0
37
+ end
38
+
39
+ # Count one config-fetch cycle whose hedge fired the secondary leg (the
40
+ # primary was slow past the hedge delay or errored fast).
41
+ def record_hedge_fired
42
+ @mutex.synchronize do
43
+ @start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
44
+ @hedge_fired += 1
45
+ end
46
+ end
47
+
48
+ # Count one install dropped by the reject-older ordering guard (an
49
+ # equal-or-older snapshot on any install path — HTTP config-fetch or SSE).
50
+ def record_guard_rejected
51
+ @mutex.synchronize do
52
+ @start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
53
+ @guard_rejected += 1
54
+ end
55
+ end
56
+
57
+ # Count one successful HTTP install by the leg that served it: source_index
58
+ # 0 is the primary, any index > 0 is a failover/secondary leg. A nil or
59
+ # negative index (SSE / datadir install with no HTTP leg) is ignored.
60
+ def record_resolved_from(source_index)
61
+ return if source_index.nil? || source_index.negative?
62
+
63
+ @mutex.synchronize do
64
+ @start_at_ms ||= Quonfig::TimeHelpers.now_in_ms
65
+ if source_index.zero?
66
+ @resolved_from_primary += 1
67
+ else
68
+ @resolved_from_secondary += 1
69
+ end
70
+ end
71
+ end
72
+
73
+ # Return the window's counters as a telemetry event hash and reset state.
74
+ # Returns +nil+ when no failover activity occurred (every counter zero).
75
+ def drain_event
76
+ @mutex.synchronize do
77
+ if @hedge_fired.zero? && @guard_rejected.zero? &&
78
+ @resolved_from_primary.zero? && @resolved_from_secondary.zero? &&
79
+ @resolved_from_lkg.zero?
80
+ return nil
81
+ end
82
+
83
+ event = {
84
+ 'failover' => {
85
+ 'start' => @start_at_ms || Quonfig::TimeHelpers.now_in_ms,
86
+ 'end' => Quonfig::TimeHelpers.now_in_ms,
87
+ 'hedgeFired' => @hedge_fired,
88
+ 'guardRejected' => @guard_rejected,
89
+ 'resolvedFromPrimary' => @resolved_from_primary,
90
+ 'resolvedFromSecondary' => @resolved_from_secondary,
91
+ 'resolvedFromLkg' => @resolved_from_lkg
92
+ }
93
+ }
94
+
95
+ reset
96
+ event
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # Caller holds @mutex.
103
+ def reset
104
+ @start_at_ms = nil
105
+ @hedge_fired = 0
106
+ @guard_rejected = 0
107
+ @resolved_from_primary = 0
108
+ @resolved_from_secondary = 0
109
+ @resolved_from_lkg = 0
110
+ end
111
+ end
112
+ end
113
+ end
@@ -13,7 +13,8 @@ module Quonfig
13
13
  # "events": [
14
14
  # { "summaries": { "start": ..., "end": ..., "summaries": [...] } },
15
15
  # { "contextShapes": { "shapes": [...] } },
16
- # { "exampleContexts": { "examples": [...] } }
16
+ # { "exampleContexts": { "examples": [...] } },
17
+ # { "failover": { "start": ..., "end": ..., "hedgeFired": ..., ... } }
17
18
  # ]
18
19
  # }
19
20
  #
@@ -30,6 +31,7 @@ module Quonfig
30
31
  context_shape_aggregator: nil,
31
32
  example_contexts_aggregator: nil,
32
33
  evaluation_summaries_aggregator: nil,
34
+ failover_aggregator: nil,
33
35
  sync_interval: nil,
34
36
  http_connection: nil)
35
37
  @options = options
@@ -39,6 +41,11 @@ module Quonfig
39
41
  @context_shape_aggregator = context_shape_aggregator
40
42
  @example_contexts_aggregator = example_contexts_aggregator
41
43
  @evaluation_summaries_aggregator = evaluation_summaries_aggregator
44
+ # Failover counters carry no user data and are the operational signal for
45
+ # the secondary-delivery hardening (qfg-41nh.18), so they ride the
46
+ # existing telemetry stream. The ConfigLoader records directly into this
47
+ # aggregator at the failover call sites; the reporter only drains it.
48
+ @failover_aggregator = failover_aggregator
42
49
  @http_connection = http_connection
43
50
  @sync_interval = calculate_sync_interval(sync_interval)
44
51
  @stopped = Concurrent::AtomicBoolean.new(false)
@@ -125,6 +132,9 @@ module Quonfig
125
132
  if (example_event = @example_contexts_aggregator&.drain_event)
126
133
  events << example_event
127
134
  end
135
+ if (failover_event = @failover_aggregator&.drain_event)
136
+ events << failover_event
137
+ end
128
138
 
129
139
  return if events.empty?
130
140
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quonfig
4
- VERSION = '1.1.1'
4
+ VERSION = '1.3.0'
5
5
  end
data/lib/quonfig.rb CHANGED
@@ -61,6 +61,7 @@ require 'quonfig/telemetry/context_shape'
61
61
  require 'quonfig/telemetry/context_shape_aggregator'
62
62
  require 'quonfig/telemetry/example_contexts_aggregator'
63
63
  require 'quonfig/telemetry/evaluation_summaries_aggregator'
64
+ require 'quonfig/telemetry/failover_aggregator'
64
65
  require 'quonfig/telemetry/telemetry_reporter'
65
66
  require 'quonfig/client'
66
67
  require 'quonfig/bound_client'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-06 00:00:00.000000000 Z
11
+ date: 2026-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -130,6 +130,7 @@ files:
130
130
  - lib/quonfig/telemetry/context_shape_aggregator.rb
131
131
  - lib/quonfig/telemetry/evaluation_summaries_aggregator.rb
132
132
  - lib/quonfig/telemetry/example_contexts_aggregator.rb
133
+ - lib/quonfig/telemetry/failover_aggregator.rb
133
134
  - lib/quonfig/telemetry/telemetry_reporter.rb
134
135
  - lib/quonfig/time_helpers.rb
135
136
  - lib/quonfig/types.rb