toggly 0.5.1 → 0.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: 0cde892347959f5a15a5c42626562e9e9e5565fe3c1aa68bed4d0290ebfd08a1
4
- data.tar.gz: d6825a1cc1616c69a9f4738b333e9a07bfc0d5ae3bd70d70221b158d4ecf0891
3
+ metadata.gz: 2f6a78a0c74623574333be32320578cdd12ee7bf1415ed199c2584716b5e98aa
4
+ data.tar.gz: c38ea07fda43a2ce57af893804471bd45ac819d51019e47e082aeccf0411e157
5
5
  SHA512:
6
- metadata.gz: 5ff95be9cc331f8e8be111f25aafef777299ae3b2e38a0fdeeb22fdb3eeb2a54531266d3be5351283792e991d2aa00d427aa664a9ef6d2139bccb5baeb39b926
7
- data.tar.gz: b27946b9231816193fb1aaeac0ebab94cb6dd8242664a4081108cfb5bf7c4e0dc2d9b6c4261be745e0e0364312efa6d9a0fff3610ce5cc5449cbc7cca41a20fc
6
+ metadata.gz: 9e6ce90750098dd4d67642925c3239f1a39a43d5f894d6de60949614d5e6c1de662912073001b9e1c1db42cc6baf9fd39e99efbeeb629d71dd92c54420461539
7
+ data.tar.gz: 3dce06734ce96188493c9fef22d030f17e538f8ae8711a128c5918a7c9c9ecdedd3786e1d6881553b1c962185940523df22a576ec059e8649c670d70053becd2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-09-22
9
+
10
+ ### Added
11
+
12
+ - Server-evaluated feature variants (dual-rail). Set `enable_variants: true`
13
+ on `Config` to additionally fetch `evaluated-variants-signed/{app_key}/
14
+ {environment}` on its own rail alongside the existing `definitions` /
15
+ `definitions-signed` pipeline. `definitions` / `definitions-signed` remain
16
+ the sole source of truth for `enabled?` regardless of `enable_variants` —
17
+ evaluated variants are additive and only feed `get_variant` /
18
+ `get_variant_value`; they never override `enabled?`. New `Config` options:
19
+ `enable_variants`, `variant_identity`, `variant_groups`, `variant_claims`.
20
+ New `Client#get_variant` / `Client#get_variant_value` return the assigned
21
+ variant name and `configuration_value`, or `nil` when unassigned or
22
+ disabled. New `Client#set_variant_identity` updates the `userId` sent to
23
+ `evaluated-variants-signed` and refreshes.
24
+ - `SnapshotProviders::Base#save_variants` / `#load_variants` (default no-op)
25
+ so `Memory` and `File` providers persist evaluated variants across
26
+ restarts, independent of the `definitions` snapshot.
27
+
28
+ **Note:** the new `get_variant` assignment is unrelated to the existing
29
+ `variant:` label on `record_usage` / `record_view`, which is a free-form
30
+ usage tag (defaults to `"enabled"`/`"disabled"`) and does not reflect
31
+ `evaluated-variants-signed` results.
32
+
33
+ ## [0.5.2] - 2026-09-17
34
+
35
+ ### Fixed
36
+
37
+ - Default usage/metrics gRPC host is `https://metrics.toggly.io/`.
38
+
8
39
  ## [0.5.1] - 2026-09-16
9
40
 
10
41
  ### Fixed
@@ -2,14 +2,52 @@
2
2
 
3
3
  module Toggly
4
4
  class Client
5
- # Durable snapshot load/save helpers for Client.
5
+ # Durable snapshot load/save helpers for Client. Dual-rail: `definitions`
6
+ # is always persisted/restored (sole source of truth for `enabled?`).
7
+ # `variant_defs` is an additive rail, persisted/restored independently
8
+ # only when `config.enable_variants` is true — it never replaces the
9
+ # definitions snapshot.
6
10
  module SnapshotSupport
7
11
  private
8
12
 
9
- # @return [Boolean] true when a durable snapshot was applied into memory
13
+ # @return [Boolean] true when any durable snapshot rail was applied
14
+ # into memory (definitions and/or evaluated variants)
10
15
  def load_snapshot
11
16
  return false unless @config.snapshot_provider
12
17
 
18
+ definitions_loaded = load_definitions_snapshot
19
+ variants_loaded = @config.enable_variants && load_variants_snapshot
20
+
21
+ definitions_loaded || variants_loaded
22
+ rescue StandardError => e
23
+ log_warn("Failed to load snapshot: #{e.message}")
24
+ false
25
+ end
26
+
27
+ # Persist the definitions rail. Always available regardless of
28
+ # `config.enable_variants` — definitions remain authoritative for
29
+ # `enabled?`.
30
+ def save_definitions_snapshot
31
+ return unless @config.snapshot_provider
32
+
33
+ @config.snapshot_provider.save(@definitions)
34
+ log_debug("Saved snapshot with #{@definitions.size} features")
35
+ rescue StandardError => e
36
+ log_warn("Failed to save snapshot: #{e.message}")
37
+ end
38
+
39
+ # Persist the evaluated-variants rail. Additive only; called only when
40
+ # `config.enable_variants` is true.
41
+ def save_variants_snapshot
42
+ return unless @config.snapshot_provider
43
+
44
+ @config.snapshot_provider.save_variants(@variant_defs)
45
+ log_debug("Saved variants snapshot with #{@variant_defs.size} features")
46
+ rescue StandardError => e
47
+ log_warn("Failed to save variants snapshot: #{e.message}")
48
+ end
49
+
50
+ def load_definitions_snapshot
13
51
  data = @config.snapshot_provider.load
14
52
  return false unless data
15
53
 
@@ -20,18 +58,20 @@ module Toggly
20
58
 
21
59
  log_debug("Loaded #{@definitions.size} features from snapshot")
22
60
  true
23
- rescue StandardError => e
24
- log_warn("Failed to load snapshot: #{e.message}")
25
- false
26
61
  end
27
62
 
28
- def save_snapshot
29
- return unless @config.snapshot_provider
63
+ def load_variants_snapshot
64
+ return false unless @config.snapshot_provider.respond_to?(:load_variants)
30
65
 
31
- @config.snapshot_provider.save(@definitions)
32
- log_debug("Saved snapshot with #{@definitions.size} features")
33
- rescue StandardError => e
34
- log_warn("Failed to save snapshot: #{e.message}")
66
+ data = @config.snapshot_provider.load_variants
67
+ return false unless data
68
+
69
+ @mutex.synchronize do
70
+ @variant_defs = data[:variants] || {}
71
+ end
72
+
73
+ log_debug("Loaded #{@variant_defs.size} evaluated variants from snapshot")
74
+ true
35
75
  end
36
76
  end
37
77
  end
data/lib/toggly/client.rb CHANGED
@@ -33,6 +33,10 @@ module Toggly
33
33
  # @return [Hash<String, FeatureDefinition>] Current definitions
34
34
  attr_reader :definitions
35
35
 
36
+ # @return [Hash<String, EvaluatedVariantDef>] Current evaluated variants
37
+ # (populated only when `config.enable_variants` is true)
38
+ attr_reader :variant_defs
39
+
36
40
  # @return [Boolean] Whether the client is ready
37
41
  attr_reader :ready
38
42
 
@@ -44,6 +48,7 @@ module Toggly
44
48
  @config.validate!
45
49
 
46
50
  @definitions = {}
51
+ @variant_defs = {}
47
52
  # True once a revision (including empty) or durable snapshot was applied.
48
53
  @definitions_loaded = false
49
54
  @mutex = Mutex.new
@@ -80,7 +85,10 @@ module Toggly
80
85
 
81
86
  definition = @mutex.synchronize { @definitions[key] }
82
87
 
83
- # Check defaults if not found
88
+ # Dual-rail: definitions/definitions-signed are the sole source of
89
+ # truth for enabled? — this holds even when config.enable_variants is
90
+ # true. Evaluated variants (@variant_defs) are an additive rail read
91
+ # only by get_variant / get_variant_value and never override this.
84
92
  result = if definition.nil?
85
93
  if !default.nil?
86
94
  default
@@ -109,6 +117,54 @@ module Toggly
109
117
  !enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
110
118
  end
111
119
 
120
+ # Get the assigned variant for a feature. Requires `config.enable_variants`;
121
+ # returns nil when variants are disabled, unknown, or unassigned.
122
+ #
123
+ # NOTE: this is the actual A/B assignment. It is unrelated to the
124
+ # `variant:` telemetry label on `record_usage` / `record_view`, which is
125
+ # a free-form usage tag (defaults to "enabled"/"disabled") and does not
126
+ # reflect `evaluated-variants-signed` results.
127
+ #
128
+ # @param feature_key [String, Symbol] The feature key
129
+ # @return [VariantResult, nil]
130
+ def get_variant(feature_key)
131
+ return nil unless @config.enable_variants
132
+
133
+ key = feature_key.to_s
134
+ entry = @mutex.synchronize { @variant_defs[key] }
135
+ return nil if entry.nil? || entry.variant.nil? || entry.variant.to_s.empty?
136
+
137
+ VariantResult.new(name: entry.variant, configuration_value: entry.configuration_value)
138
+ end
139
+
140
+ # Get the configuration value for the assigned variant, if any.
141
+ #
142
+ # @param feature_key [String, Symbol] The feature key
143
+ # @return [Object, nil]
144
+ def get_variant_value(feature_key)
145
+ get_variant(feature_key)&.configuration_value
146
+ end
147
+
148
+ # Update the `userId` sent to `evaluated-variants-signed` and, when
149
+ # `config.enable_variants` is true, clear cached variants and refresh.
150
+ # No-op (besides updating provider state) when variants are disabled.
151
+ # Named as an action (not `variant_identity=`) because it also triggers
152
+ # a network refresh — it is not a passive attribute writer.
153
+ #
154
+ # @param identity [String, nil]
155
+ # @return [Boolean] true if the identity changed
156
+ # rubocop:disable-next Naming/AccessorMethodName
157
+ def set_variant_identity(identity)
158
+ changed = @provider.set_variant_identity(identity)
159
+
160
+ if changed && @config.enable_variants
161
+ @mutex.synchronize { @variant_defs = {} }
162
+ refresh(force: true) unless @config.offline_mode?
163
+ end
164
+
165
+ changed
166
+ end
167
+
112
168
  # Get detailed evaluation result
113
169
  #
114
170
  # @param feature_key [String, Symbol] The feature key
@@ -161,27 +217,15 @@ module Toggly
161
217
  end
162
218
 
163
219
  begin
164
- result = @provider.fetch(force: force)
165
- record_refresh_cache_outcome(result.cache_outcome)
166
-
167
- if result.definitions
168
- @mutex.synchronize do
169
- @definitions = result.definitions
170
- @definitions_loaded = true
171
- @ready = true
172
- end
173
-
174
- save_snapshot
175
- log_info("Definitions refreshed (#{result.definitions.size} features)")
176
- true
177
- else
178
- false
179
- end
180
- rescue StandardError => e
181
- log_error("Failed to refresh definitions: #{e.message}")
182
- # Network error / timeout keeping last-good revision (incl. empty) — hit.
183
- record_definition_cache_hit if definitions_cached?
184
- false
220
+ # Dual-rail: definitions/definitions-signed are always refreshed —
221
+ # the sole source of truth for enabled?. When config.enable_variants
222
+ # is true, evaluated-variants-signed is ALSO fetched on its own rail,
223
+ # additive only for get_variant / get_variant_value. A failure on
224
+ # either rail must never affect the other.
225
+ definitions_updated = refresh_definitions_rail(force: force)
226
+ variants_updated = @config.enable_variants ? refresh_variants_rail(force: force) : false
227
+
228
+ definitions_updated || variants_updated
185
229
  ensure
186
230
  drain_pending = false
187
231
  @mutex.synchronize do
@@ -290,6 +334,67 @@ module Toggly
290
334
 
291
335
  private
292
336
 
337
+ # Definitions rail: `definitions` / `definitions-signed` → local rule eval.
338
+ # Always runs (dual-rail): the sole source of truth for `enabled?`,
339
+ # regardless of `config.enable_variants`.
340
+ def refresh_definitions_rail(force:)
341
+ refresh_definitions(force: force)
342
+ rescue StandardError => e
343
+ log_error("Failed to refresh definitions: #{e.message}")
344
+ # Network error / timeout keeping last-good revision (incl. empty) — hit.
345
+ record_definition_cache_hit if definitions_cached?
346
+ false
347
+ end
348
+
349
+ def refresh_definitions(force:)
350
+ result = @provider.fetch(force: force)
351
+ record_refresh_cache_outcome(result.cache_outcome)
352
+
353
+ if result.definitions
354
+ @mutex.synchronize do
355
+ @definitions = result.definitions
356
+ @definitions_loaded = true
357
+ @ready = true
358
+ end
359
+
360
+ save_definitions_snapshot
361
+ log_info("Definitions refreshed (#{result.definitions.size} features)")
362
+ true
363
+ else
364
+ false
365
+ end
366
+ end
367
+
368
+ # Variants rail: `evaluated-variants-signed` → server-evaluated assignment.
369
+ # Additive only (used by get_variant / get_variant_value); runs only when
370
+ # `config.enable_variants` is true and never influences `enabled?`. A
371
+ # failure here must never affect the definitions rail above, and is not
372
+ # counted in definition cache-hit/miss telemetry (that metric is scoped
373
+ # to the definitions rail).
374
+ def refresh_variants_rail(force:)
375
+ refresh_variants(force: force)
376
+ rescue StandardError => e
377
+ log_error("Failed to refresh evaluated variants: #{e.message}")
378
+ false
379
+ end
380
+
381
+ def refresh_variants(force:)
382
+ result = @provider.fetch_variants(force: force)
383
+
384
+ if result.variants
385
+ @mutex.synchronize do
386
+ @variant_defs = result.variants
387
+ @ready = true
388
+ end
389
+
390
+ save_variants_snapshot
391
+ log_info("Evaluated variants refreshed (#{result.variants.size} features)")
392
+ true
393
+ else
394
+ false
395
+ end
396
+ end
397
+
293
398
  def initialize_definitions
294
399
  # Startup served from durable snapshot before first network — cache hit.
295
400
  # Distinct from the subsequent refresh() network outcome (no double-count
@@ -314,7 +419,7 @@ module Toggly
314
419
  log_error("Failed to initialize definitions: #{e.message}")
315
420
 
316
421
  # Use snapshot or defaults as fallback
317
- @ready = true if @definitions.any? || @config.defaults.any?
422
+ @ready = true if @definitions.any? || @variant_defs.any? || @config.defaults.any?
318
423
  end
319
424
 
320
425
  def start_background_refresh
data/lib/toggly/config.rb CHANGED
@@ -55,6 +55,25 @@ module Toggly
55
55
  # @return [Array<String>] Allowed key IDs for signed definitions
56
56
  attr_accessor :allowed_key_ids
57
57
 
58
+ # @return [Boolean] When true, additionally fetch server-evaluated variants
59
+ # from `evaluated-variants-signed` on their own rail (dual-rail).
60
+ # `definitions` / `definitions-signed` remain the sole source of truth
61
+ # for `enabled?` regardless of this setting — evaluated variants are
62
+ # additive and only feed `Client#get_variant` / `#get_variant_value`.
63
+ attr_accessor :enable_variants
64
+
65
+ # @return [String, nil] `userId` sent to `evaluated-variants-signed` for targeting.
66
+ # Separate from the per-call `Context#identity` used for local rule evaluation.
67
+ attr_accessor :variant_identity
68
+
69
+ # @return [Array<String>] Application-wide group memberships sent to
70
+ # `evaluated-variants-signed` (not request-local booleans).
71
+ attr_accessor :variant_groups
72
+
73
+ # @return [Hash<String, String>] Application-wide string claims sent to
74
+ # `evaluated-variants-signed` (at most 20 on the wire).
75
+ attr_accessor :variant_claims
76
+
58
77
  # @return [Logger, nil] Logger instance
59
78
  attr_accessor :logger
60
79
 
@@ -84,7 +103,7 @@ module Toggly
84
103
  DEFAULT_REFRESH_INTERVAL = 300 # 5 minutes
85
104
  DEFAULT_HTTP_TIMEOUT = 10 # seconds
86
105
  DEFAULT_ENVIRONMENT = "Production"
87
- DEFAULT_METRICS_BASE_URL = "https://app.toggly.io/"
106
+ DEFAULT_METRICS_BASE_URL = "https://metrics.toggly.io/"
88
107
  DEFAULT_TELEMETRY_FLUSH_SECONDS = 60.0
89
108
 
90
109
  def initialize(**options)
@@ -104,6 +123,10 @@ module Toggly
104
123
  @snapshot_provider = options[:snapshot_provider]
105
124
  @use_signed_definitions = options[:use_signed_definitions] || false
106
125
  @allowed_key_ids = options[:allowed_key_ids] || []
126
+ @enable_variants = options[:enable_variants] || false
127
+ @variant_identity = options[:variant_identity]
128
+ @variant_groups = Array(options[:variant_groups])
129
+ @variant_claims = options[:variant_claims] || {}
107
130
  @logger = options[:logger]
108
131
 
109
132
  @usage_tracking_explicit = options.key?(:enable_usage_tracking)
@@ -127,6 +150,16 @@ module Toggly
127
150
  "#{normalize_url(base)}#{endpoint}/#{@app_key}/#{@environment}"
128
151
  end
129
152
 
153
+ # Get the evaluated-variants-signed endpoint URL. Always signed; query
154
+ # params (userId/groups/claims) are added by the provider since they can
155
+ # change at runtime (see `Client#set_variant_identity`).
156
+ #
157
+ # @return [String]
158
+ def variants_endpoint
159
+ base = @definitions_url || @base_url
160
+ "#{normalize_url(base)}evaluated-variants-signed/#{@app_key}/#{@environment}"
161
+ end
162
+
130
163
  # Validate the configuration
131
164
  #
132
165
  # @raise [ConfigError] if configuration is invalid
@@ -186,6 +219,7 @@ module Toggly
186
219
  enable_undefined_in_dev: @enable_undefined_in_dev,
187
220
  disable_background_refresh: @disable_background_refresh,
188
221
  enable_live_updates: @enable_live_updates,
222
+ enable_variants: @enable_variants,
189
223
  app_version: @app_version,
190
224
  instance_name: @instance_name,
191
225
  use_signed_definitions: @use_signed_definitions,
@@ -17,6 +17,12 @@ module Toggly
17
17
  # Result of one HTTP definitions fetch with cache telemetry outcome.
18
18
  FetchResult = Struct.new(:definitions, :cache_outcome, keyword_init: true)
19
19
 
20
+ # Result of one HTTP evaluated-variants fetch with cache telemetry outcome.
21
+ VariantFetchResult = Struct.new(:variants, :cache_outcome, keyword_init: true)
22
+
23
+ # Maximum number of string claims sent on the evaluated-variants-signed wire.
24
+ MAX_VARIANT_CLAIMS = 20
25
+
20
26
  # Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
21
27
  FALLBACK_REFRESH_INTERVAL = 20 * 60
22
28
 
@@ -37,6 +43,13 @@ module Toggly
37
43
  @last_modified = nil
38
44
  @last_ts = 0
39
45
 
46
+ # Evaluated-variants rail state — separate ETag/Last-Modified/timestamp
47
+ # namespace from definitions so the two rails never cross-invalidate.
48
+ @variant_etag = nil
49
+ @variant_last_modified = nil
50
+ @variant_last_ts = 0
51
+ @variant_identity = config.variant_identity
52
+
40
53
  # WebSocket state
41
54
  @ws = nil
42
55
  @ws_connected = false
@@ -77,6 +90,56 @@ module Toggly
77
90
  @last_ts = 0
78
91
  end
79
92
 
93
+ # Fetch server-evaluated variants from `evaluated-variants-signed`.
94
+ # Independent ETag/timestamp cache from {#fetch} (separate rail).
95
+ #
96
+ # @param force [Boolean] Force fetch even if cached
97
+ # @return [VariantFetchResult] variants (Hash or nil) plus :hit / :miss outcome
98
+ # @raise [NetworkError] On network failures
99
+ # @raise [DefinitionsError] On API errors
100
+ def fetch_variants(force: false)
101
+ return VariantFetchResult.new(variants: nil, cache_outcome: :hit) if @config.offline_mode?
102
+
103
+ uri = URI.parse(build_variant_request_url)
104
+ http = build_http(uri)
105
+ request = build_variant_request(uri, force)
106
+
107
+ response = http.request(request)
108
+ handle_variant_response(response)
109
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
110
+ raise NetworkError, "Request timeout: #{e.message}"
111
+ rescue SocketError, Errno::ECONNREFUSED => e
112
+ raise NetworkError, "Connection failed: #{e.message}"
113
+ rescue NetworkError, DefinitionsError
114
+ raise
115
+ rescue StandardError => e
116
+ raise NetworkError, "Request failed: #{e.message}"
117
+ end
118
+
119
+ # Update the `userId` sent to `evaluated-variants-signed`. Distinct from
120
+ # any per-call `Context#identity` used for local rule evaluation. Named
121
+ # as an action (not `variant_identity=`) because it also invalidates the
122
+ # variants cache — it is not a passive attribute writer.
123
+ #
124
+ # @param identity [String, nil]
125
+ # @return [Boolean] true if the identity changed (cache invalidated)
126
+ # rubocop:disable-next Naming/AccessorMethodName
127
+ def set_variant_identity(identity)
128
+ normalized = identity&.to_s
129
+ return false if normalized == @variant_identity
130
+
131
+ @variant_identity = normalized
132
+ reset_variant_cache
133
+ true
134
+ end
135
+
136
+ # Reset cached variant headers/timestamp (force full fetch next time)
137
+ def reset_variant_cache
138
+ @variant_etag = nil
139
+ @variant_last_modified = nil
140
+ @variant_last_ts = 0
141
+ end
142
+
80
143
  # Check whether the periodic refresh should be skipped because the
81
144
  # WebSocket connection is active and the fallback interval has not elapsed.
82
145
  #
@@ -195,7 +258,7 @@ module Toggly
195
258
  when :new_content
196
259
  handle_new_content(response, response_etag, response_lm)
197
260
  when :error_status
198
- handle_error_status(status, response)
261
+ handle_error_status(status, response, resource: "definitions")
199
262
  end
200
263
  end
201
264
 
@@ -216,12 +279,12 @@ module Toggly
216
279
  raise DefinitionsError, "Failed to parse definitions: #{e.message}"
217
280
  end
218
281
 
219
- def handle_error_status(status, response)
282
+ def handle_error_status(status, response, resource: "definitions")
220
283
  case status
221
284
  when 401, 403
222
285
  raise DefinitionsError, "Authentication failed: #{status}"
223
286
  when 404
224
- raise DefinitionsError, "Definitions not found (check app_key and environment)"
287
+ raise DefinitionsError, "#{resource.capitalize} not found (check app_key and environment)"
225
288
  else
226
289
  raise NetworkError.new(
227
290
  "API error: #{status}",
@@ -236,6 +299,110 @@ module Toggly
236
299
  @last_modified = last_modified if last_modified && !last_modified.empty?
237
300
  end
238
301
 
302
+ def build_variant_request_url
303
+ pairs = variant_query_pairs
304
+ base = @config.variants_endpoint
305
+ pairs.empty? ? base : "#{base}?#{URI.encode_www_form(pairs)}"
306
+ end
307
+
308
+ def variant_query_pairs
309
+ pairs = []
310
+ pairs << ["userId", @variant_identity] if @variant_identity && !@variant_identity.empty?
311
+
312
+ Array(@config.variant_groups).each do |group|
313
+ next unless group.is_a?(String)
314
+
315
+ trimmed = group.strip
316
+ pairs << ["g", trimmed] unless trimmed.empty?
317
+ end
318
+
319
+ normalized_claims = {}
320
+ (@config.variant_claims || {}).each do |key, value|
321
+ next unless key.is_a?(String) && value.is_a?(String)
322
+ next if key.empty? || value.empty?
323
+
324
+ normalized_claims[key] = value
325
+ end
326
+ normalized_claims.keys.sort.first(MAX_VARIANT_CLAIMS).each do |key|
327
+ pairs << ["claim.#{key}", normalized_claims[key]]
328
+ end
329
+
330
+ pairs
331
+ end
332
+
333
+ def build_variant_request(uri, force)
334
+ request = Net::HTTP::Get.new(uri)
335
+ request["Accept"] = "application/json"
336
+ request["User-Agent"] = "toggly-ruby/#{Toggly::VERSION}"
337
+ request["X-App-Version"] = @config.app_version if @config.app_version
338
+ request["X-Instance-Name"] = @config.instance_name if @config.instance_name
339
+
340
+ unless force
341
+ request["If-None-Match"] = @variant_etag if @variant_etag
342
+ request["If-Modified-Since"] = @variant_last_modified if @variant_last_modified
343
+ end
344
+
345
+ request
346
+ end
347
+
348
+ def handle_variant_response(response)
349
+ status = response.code.to_i
350
+ response_etag = response["ETag"]
351
+ response_lm = response["Last-Modified"]
352
+ kind = DefinitionCache.classify_http(
353
+ status,
354
+ @variant_etag,
355
+ response_etag,
356
+ existing_last_modified: @variant_last_modified,
357
+ response_last_modified: response_lm
358
+ )
359
+
360
+ case kind
361
+ when :not_modified
362
+ log_debug("Evaluated variants not modified")
363
+ VariantFetchResult.new(variants: nil, cache_outcome: :hit)
364
+ when :same_revision
365
+ log_debug("Evaluated variants revision matches existing (ETag or Last-Modified)")
366
+ store_variant_revision_headers(response_etag, response_lm)
367
+ VariantFetchResult.new(variants: nil, cache_outcome: :hit)
368
+ when :new_content
369
+ handle_new_variant_content(response, response_etag, response_lm)
370
+ when :error_status
371
+ handle_error_status(status, response, resource: "evaluated variants")
372
+ end
373
+ end
374
+
375
+ def handle_new_variant_content(response, response_etag, response_lm)
376
+ data = JSON.parse(response.body)
377
+ signed_ts = extract_signed_timestamp(data)
378
+ if DefinitionCache.cached_signed_timestamp?(@variant_last_ts, signed_ts)
379
+ log_debug("Evaluated variants signed timestamp is not newer than cached revision")
380
+ store_variant_revision_headers(response_etag, response_lm)
381
+ return VariantFetchResult.new(variants: nil, cache_outcome: :hit)
382
+ end
383
+
384
+ variants = parse_variant_defs(data)
385
+ store_variant_revision_headers(response_etag, response_lm)
386
+ @variant_last_ts = signed_ts if signed_ts&.positive?
387
+ VariantFetchResult.new(variants: variants, cache_outcome: :miss)
388
+ rescue JSON::ParserError => e
389
+ raise DefinitionsError, "Failed to parse evaluated variants: #{e.message}"
390
+ end
391
+
392
+ def store_variant_revision_headers(etag, last_modified)
393
+ @variant_etag = etag if etag && !etag.empty?
394
+ @variant_last_modified = last_modified if last_modified && !last_modified.empty?
395
+ end
396
+
397
+ def parse_variant_defs(data)
398
+ raw = data.is_a?(Hash) ? data["defs"] : nil
399
+ return {} unless raw.is_a?(Hash)
400
+
401
+ raw.each_with_object({}) do |(key, value), hash|
402
+ hash[key] = EvaluatedVariantDef.from_hash(value) if value.is_a?(Hash)
403
+ end
404
+ end
405
+
239
406
  def extract_signed_timestamp(data)
240
407
  return nil unless data.is_a?(Hash)
241
408
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Server-evaluated variant entry for a single feature flag, as returned by
5
+ # `evaluated-variants-signed`. This is distinct from `FeatureDefinition`
6
+ # (client-side rules): the server has already picked the winning variant.
7
+ class EvaluatedVariantDef
8
+ # @return [Boolean] Whether the feature is enabled for this assignment
9
+ attr_reader :enabled
10
+
11
+ # @return [String, nil] Assigned variant name, if any
12
+ attr_reader :variant
13
+
14
+ # @return [Object, nil] Configuration payload for the variant (shape depends on the feature)
15
+ attr_reader :configuration_value
16
+
17
+ def initialize(enabled:, variant: nil, configuration_value: nil)
18
+ @enabled = enabled ? true : false
19
+ @variant = variant
20
+ @configuration_value = configuration_value
21
+ end
22
+
23
+ # Create from a hash (API response or snapshot; camelCase or snake_case).
24
+ #
25
+ # @param hash [Hash]
26
+ # @return [EvaluatedVariantDef]
27
+ def self.from_hash(hash)
28
+ hash = symbolize_keys(hash)
29
+ configuration_value = if hash.key?(:configurationValue)
30
+ hash[:configurationValue]
31
+ else
32
+ hash[:configuration_value]
33
+ end
34
+
35
+ new(
36
+ enabled: hash[:enabled],
37
+ variant: hash[:variant],
38
+ configuration_value: configuration_value
39
+ )
40
+ end
41
+
42
+ # Convert to a hash for serialization (snapshot persistence).
43
+ #
44
+ # @return [Hash]
45
+ def to_h
46
+ {
47
+ enabled: @enabled,
48
+ variant: @variant,
49
+ configuration_value: @configuration_value
50
+ }
51
+ end
52
+
53
+ def ==(other)
54
+ other.is_a?(EvaluatedVariantDef) &&
55
+ @enabled == other.enabled &&
56
+ @variant == other.variant &&
57
+ @configuration_value == other.configuration_value
58
+ end
59
+ alias eql? ==
60
+
61
+ def self.symbolize_keys(hash)
62
+ return hash unless hash.is_a?(Hash)
63
+
64
+ hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
65
+ end
66
+ private_class_method :symbolize_keys
67
+ end
68
+
69
+ # Assigned variant name and configuration value for a feature, returned by
70
+ # `Client#get_variant` when `Config#enable_variants` is true.
71
+ class VariantResult
72
+ # @return [String] Variant name assigned by the server
73
+ attr_reader :name
74
+
75
+ # @return [Object, nil] Optional configuration payload for the variant
76
+ attr_reader :configuration_value
77
+
78
+ def initialize(name:, configuration_value: nil)
79
+ @name = name
80
+ @configuration_value = configuration_value
81
+ end
82
+
83
+ # @return [Hash]
84
+ def to_h
85
+ { name: @name, configuration_value: @configuration_value }
86
+ end
87
+ end
88
+ end
@@ -40,6 +40,21 @@ module Toggly
40
40
  false
41
41
  end
42
42
 
43
+ # Save evaluated variants snapshot (dual-rail, used when
44
+ # `config.enable_variants` is true). Default: no-op. Override in
45
+ # subclasses to persist variants across restarts.
46
+ #
47
+ # @param variants [Hash<String, EvaluatedVariantDef>] Variants to save
48
+ # @param metadata [Hash] Optional metadata
49
+ def save_variants(_variants, _metadata = {}); end
50
+
51
+ # Load evaluated variants snapshot (dual-rail).
52
+ #
53
+ # @return [Hash, nil] Hash with :variants and :metadata, or nil if not available
54
+ def load_variants
55
+ nil
56
+ end
57
+
43
58
  protected
44
59
 
45
60
  # Serialize definitions to a storable format
@@ -62,6 +77,26 @@ module Toggly
62
77
  hash[definition.feature_key] = definition
63
78
  end
64
79
  end
80
+
81
+ # Serialize evaluated variants to a storable format
82
+ #
83
+ # @param variants [Hash<String, EvaluatedVariantDef>] Variants
84
+ # @return [Hash<String, Hash>]
85
+ def serialize_variants(variants)
86
+ variants.transform_values(&:to_h)
87
+ end
88
+
89
+ # Deserialize evaluated variants from stored format
90
+ #
91
+ # @param data [Hash<String, Hash>] Serialized variants
92
+ # @return [Hash<String, EvaluatedVariantDef>]
93
+ def deserialize_variants(data)
94
+ return {} unless data.is_a?(Hash)
95
+
96
+ data.each_with_object({}) do |(key, value), hash|
97
+ hash[key.to_s] = EvaluatedVariantDef.from_hash(value)
98
+ end
99
+ end
65
100
  end
66
101
  end
67
102
  end
@@ -12,10 +12,14 @@ module Toggly
12
12
  # @return [String] Path to the snapshot file
13
13
  attr_reader :path
14
14
 
15
+ # @return [String] Path to the evaluated-variants snapshot file (dual-rail)
16
+ attr_reader :variants_path
17
+
15
18
  # @param path [String] Path to the snapshot file
16
19
  def initialize(path:)
17
20
  super()
18
21
  @path = path
22
+ @variants_path = derive_variants_path(path)
19
23
  @mutex = Mutex.new
20
24
  end
21
25
 
@@ -66,6 +70,7 @@ module Toggly
66
70
  def clear
67
71
  @mutex.synchronize do
68
72
  FileUtils.rm_f(@path)
73
+ FileUtils.rm_f(@variants_path)
69
74
  end
70
75
  rescue StandardError => e
71
76
  raise SnapshotError, "Failed to clear snapshot: #{e.message}"
@@ -78,6 +83,48 @@ module Toggly
78
83
  ::File.exist?(@path)
79
84
  end
80
85
 
86
+ # Save evaluated variants to file using atomic write (dual-rail)
87
+ #
88
+ # @param variants [Hash<String, EvaluatedVariantDef>] Variants
89
+ # @param metadata [Hash] Optional metadata
90
+ def save_variants(variants, metadata = {})
91
+ @mutex.synchronize do
92
+ ensure_directory_exists
93
+
94
+ data = {
95
+ "variants" => serialize_variants(variants),
96
+ "metadata" => metadata.merge("saved_at" => Time.now.utc.iso8601)
97
+ }
98
+
99
+ temp_path = "#{@variants_path}.tmp"
100
+ ::File.write(temp_path, JSON.pretty_generate(data))
101
+ ::File.rename(temp_path, @variants_path)
102
+ end
103
+ rescue StandardError => e
104
+ raise SnapshotError, "Failed to save variants snapshot: #{e.message}"
105
+ end
106
+
107
+ # Load evaluated variants from file (dual-rail)
108
+ #
109
+ # @return [Hash, nil] Hash with :variants and :metadata
110
+ def load_variants
111
+ @mutex.synchronize do
112
+ return nil unless ::File.exist?(@variants_path)
113
+
114
+ content = ::File.read(@variants_path)
115
+ data = JSON.parse(content)
116
+
117
+ {
118
+ variants: deserialize_variants(data["variants"]),
119
+ metadata: symbolize_keys(data["metadata"] || {})
120
+ }
121
+ end
122
+ rescue JSON::ParserError => e
123
+ raise SnapshotError, "Failed to parse variants snapshot: #{e.message}"
124
+ rescue StandardError => e
125
+ raise SnapshotError, "Failed to load variants snapshot: #{e.message}"
126
+ end
127
+
81
128
  private
82
129
 
83
130
  def ensure_directory_exists
@@ -85,6 +132,13 @@ module Toggly
85
132
  FileUtils.mkdir_p(dir) unless ::File.directory?(dir)
86
133
  end
87
134
 
135
+ def derive_variants_path(path)
136
+ dir = ::File.dirname(path)
137
+ ext = ::File.extname(path)
138
+ base = ::File.basename(path, ext)
139
+ ::File.join(dir, "#{base}_variants#{ext}")
140
+ end
141
+
88
142
  def symbolize_keys(hash)
89
143
  return {} unless hash.is_a?(Hash)
90
144
 
@@ -9,6 +9,7 @@ module Toggly
9
9
  def initialize
10
10
  super
11
11
  @data = nil
12
+ @variants_data = nil
12
13
  @mutex = Mutex.new
13
14
  end
14
15
 
@@ -43,6 +44,7 @@ module Toggly
43
44
  def clear
44
45
  @mutex.synchronize do
45
46
  @data = nil
47
+ @variants_data = nil
46
48
  end
47
49
  end
48
50
 
@@ -54,6 +56,33 @@ module Toggly
54
56
  !@data.nil?
55
57
  end
56
58
  end
59
+
60
+ # Save evaluated variants to memory (dual-rail)
61
+ #
62
+ # @param variants [Hash<String, EvaluatedVariantDef>] Variants
63
+ # @param metadata [Hash] Optional metadata
64
+ def save_variants(variants, metadata = {})
65
+ @mutex.synchronize do
66
+ @variants_data = {
67
+ variants: serialize_variants(variants),
68
+ metadata: metadata.merge(saved_at: Time.now.utc.iso8601)
69
+ }
70
+ end
71
+ end
72
+
73
+ # Load evaluated variants from memory (dual-rail)
74
+ #
75
+ # @return [Hash, nil] Hash with :variants and :metadata
76
+ def load_variants
77
+ @mutex.synchronize do
78
+ return nil unless @variants_data
79
+
80
+ {
81
+ variants: deserialize_variants(@variants_data[:variants]),
82
+ metadata: @variants_data[:metadata]
83
+ }
84
+ end
85
+ end
57
86
  end
58
87
  end
59
88
  end
@@ -6,7 +6,7 @@ module Toggly
6
6
  module Telemetry
7
7
  # Optional gRPC transport helpers for usage and metrics telemetry.
8
8
  module GrpcClients
9
- DEFAULT_METRICS_BASE_URL = "https://app.toggly.io/"
9
+ DEFAULT_METRICS_BASE_URL = "https://metrics.toggly.io/"
10
10
  DEFAULT_TELEMETRY_FLUSH_SECONDS = 60.0
11
11
 
12
12
  # HTTP/2 metadata is case-insensitive; .NET/Go/Node send ``UA``.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Toggly
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/toggly.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "toggly/user_agent_parser"
10
10
  require_relative "toggly/context"
11
11
  require_relative "toggly/errors"
12
12
  require_relative "toggly/feature_definition"
13
+ require_relative "toggly/evaluated_variant"
13
14
  require_relative "toggly/evaluators/base"
14
15
  require_relative "toggly/evaluators/segment_helpers"
15
16
  require_relative "toggly/evaluators/always_on"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toggly
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ops.ai
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: High-performance Ruby SDK for Toggly feature flag management. Works with
14
14
  or without Toggly.io.
@@ -31,6 +31,7 @@ files:
31
31
  - lib/toggly/definitions_provider.rb
32
32
  - lib/toggly/entity_context.rb
33
33
  - lib/toggly/errors.rb
34
+ - lib/toggly/evaluated_variant.rb
34
35
  - lib/toggly/evaluation_engine.rb
35
36
  - lib/toggly/evaluators/always_off.rb
36
37
  - lib/toggly/evaluators/always_on.rb