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 +4 -4
- data/CHANGELOG.md +31 -0
- data/lib/toggly/client/snapshot_support.rb +51 -11
- data/lib/toggly/client.rb +128 -23
- data/lib/toggly/config.rb +35 -1
- data/lib/toggly/definitions_provider.rb +170 -3
- data/lib/toggly/evaluated_variant.rb +88 -0
- data/lib/toggly/snapshot_providers/base.rb +35 -0
- data/lib/toggly/snapshot_providers/file.rb +54 -0
- data/lib/toggly/snapshot_providers/memory.rb +29 -0
- data/lib/toggly/telemetry/grpc_clients.rb +1 -1
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.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: 2f6a78a0c74623574333be32320578cdd12ee7bf1415ed199c2584716b5e98aa
|
|
4
|
+
data.tar.gz: c38ea07fda43a2ce57af893804471bd45ac819d51019e47e082aeccf0411e157
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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://
|
|
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, "
|
|
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://
|
|
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``.
|
data/lib/toggly/version.rb
CHANGED
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.
|
|
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-
|
|
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
|