toggly 0.6.0 → 1.0.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 +33 -0
- data/README.md +22 -0
- data/lib/toggly/client/snapshot_support.rb +5 -39
- data/lib/toggly/client.rb +40 -84
- data/lib/toggly/config.rb +0 -34
- data/lib/toggly/definitions_provider.rb +0 -167
- data/lib/toggly/feature_definition.rb +20 -3
- data/lib/toggly/feature_variant.rb +188 -0
- data/lib/toggly/snapshot_providers/base.rb +0 -35
- data/lib/toggly/snapshot_providers/file.rb +0 -54
- data/lib/toggly/snapshot_providers/memory.rb +0 -29
- data/lib/toggly/variant_allocator.rb +161 -0
- data/lib/toggly/variant_result.rb +51 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +3 -1
- metadata +4 -2
- data/lib/toggly/evaluated_variant.rb +0 -88
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af67c2cd1ab3f935ea72b5f3959e0958f8e7225eb8d176a40fbdb9baf3b3f57d
|
|
4
|
+
data.tar.gz: 8911b6582910f651d545a1fd625314f7a5aab4c8972188bc31844156ee37882e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78ff528d676027bb0379688179b888fae05c567f0a1f57b78982890e07fc3c68cf68c763f0ddd63c8611e21c2aea4bcc0e25d31dde2f33e6ba11adbb52653cf4
|
|
7
|
+
data.tar.gz: dbe5aad2ad173a579bd7385a24b00a18980be769ea89a46e875ec6ab31d5e5af4246a9ac74789530cc7bf673dd237addee389261e2c5fc515d3989fe520b5653
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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
|
+
## [1.0.0] - 2026-09-23
|
|
9
|
+
|
|
10
|
+
### Changed (Breaking)
|
|
11
|
+
|
|
12
|
+
- **Catalog-local, MF-parity feature variants**, replacing the
|
|
13
|
+
`enable_variants` / `evaluated-variants-signed` dual-rail from 0.6.0.
|
|
14
|
+
`Client#get_variant` / `#get_variant_value` now assign variants **locally**
|
|
15
|
+
from the same `variants` / `allocation` payload on the `definitions` /
|
|
16
|
+
`definitions-signed` catalog that drives `enabled?` — there is no separate
|
|
17
|
+
network call. Assignment matches `Microsoft.FeatureManagement` 4.7.0's
|
|
18
|
+
`IVariantFeatureManager` bit-for-bit (user → group → percentile → default,
|
|
19
|
+
`StatusOverride`, percentile SHA-256 hashing) and is verified against the
|
|
20
|
+
shared `variant-allocator-corpus/cases.json` gold corpus (100% pass).
|
|
21
|
+
- `get_variant(feature_key, context: nil)` / `get_variant_value(feature_key,
|
|
22
|
+
context: nil)` now take the same `context:` (`userId` + `groups`) used by
|
|
23
|
+
`enabled?`, instead of a client-wide `variant_identity`.
|
|
24
|
+
- `VariantResult` gains `enabled` (effective enabled after the assigned
|
|
25
|
+
variant's `StatusOverride` — `enabled?` itself stays filter-based only)
|
|
26
|
+
and `reason` (`"User"` | `"Group"` | `"Percentile"` |
|
|
27
|
+
`"DefaultWhenEnabled"` | `"DefaultWhenDisabled"`).
|
|
28
|
+
- New `FeatureDefinition#variants` / `#allocation` (`FeatureVariant`,
|
|
29
|
+
`FeatureVariantAllocation`), parsed from the `definitions` wire.
|
|
30
|
+
- **Removed**: `Config#enable_variants` / `#variant_identity` /
|
|
31
|
+
`#variant_groups` / `#variant_claims` / `#variants_endpoint`,
|
|
32
|
+
`Client#variant_defs` / `#set_variant_identity`, `EvaluatedVariantDef`,
|
|
33
|
+
and the `evaluated-variants-signed` fetch/cache rail (including the
|
|
34
|
+
`SnapshotProviders` `save_variants` / `load_variants` hooks — variants are
|
|
35
|
+
now part of the ordinary definitions snapshot via `FeatureDefinition`).
|
|
36
|
+
|
|
37
|
+
**Migration**: drop `enable_variants` / `variant_identity` /
|
|
38
|
+
`set_variant_identity` from your config. Pass targeting via
|
|
39
|
+
`get_variant(key, context: Toggly::Context.new(identity: ..., groups: ...))`.
|
|
40
|
+
|
|
8
41
|
## [0.6.0] - 2026-09-22
|
|
9
42
|
|
|
10
43
|
### Added
|
data/README.md
CHANGED
|
@@ -38,6 +38,28 @@ if client.enabled?(:my_feature)
|
|
|
38
38
|
end
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
## Feature variants (catalog-local, MF-parity)
|
|
42
|
+
|
|
43
|
+
`get_variant` / `get_variant_value` assign a variant purely from the
|
|
44
|
+
feature's `variants` / `allocation` catalog data (no network round-trip),
|
|
45
|
+
matching `Microsoft.FeatureManagement`'s `IVariantFeatureManager`
|
|
46
|
+
bit-for-bit — verified against the shared
|
|
47
|
+
`variant-allocator-corpus/cases.json` gold corpus.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
variant = client.get_variant('checkout-flow', context: Toggly::Context.new(identity: 'user-1'))
|
|
51
|
+
variant&.name # assigned variant name, or nil
|
|
52
|
+
variant&.configuration_value # untyped configuration payload
|
|
53
|
+
variant&.enabled # effective enabled after StatusOverride
|
|
54
|
+
variant&.reason # "User" | "Group" | "Percentile" | "DefaultWhenEnabled" | "DefaultWhenDisabled"
|
|
55
|
+
|
|
56
|
+
client.get_variant_value('checkout-flow', context: context) # shortcut for configuration_value
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`enabled?` stays filter-based only. The `enable_variants` /
|
|
60
|
+
`evaluated-variants-signed` dual-rail (and `set_variant_identity`) was
|
|
61
|
+
removed in 1.0 — see [CHANGELOG.md](CHANGELOG.md).
|
|
62
|
+
|
|
41
63
|
## Usage & metrics telemetry
|
|
42
64
|
|
|
43
65
|
When `app_key` is set, usage tracking and metrics default **on** (disable with
|
|
@@ -2,31 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Toggly
|
|
4
4
|
class Client
|
|
5
|
-
# Durable snapshot load/save helpers for Client.
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# only when `config.enable_variants` is true — it never replaces the
|
|
9
|
-
# definitions snapshot.
|
|
5
|
+
# Durable snapshot load/save helpers for Client. Definitions (including
|
|
6
|
+
# `variants` / `allocation`) are the only persisted rail — there is no
|
|
7
|
+
# separate evaluated-variants snapshot (removed with the dual-rail).
|
|
10
8
|
module SnapshotSupport
|
|
11
9
|
private
|
|
12
10
|
|
|
13
|
-
# @return [Boolean] true when
|
|
14
|
-
# into memory (definitions and/or evaluated variants)
|
|
11
|
+
# @return [Boolean] true when the durable snapshot was applied into memory
|
|
15
12
|
def load_snapshot
|
|
16
13
|
return false unless @config.snapshot_provider
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
variants_loaded = @config.enable_variants && load_variants_snapshot
|
|
20
|
-
|
|
21
|
-
definitions_loaded || variants_loaded
|
|
15
|
+
load_definitions_snapshot
|
|
22
16
|
rescue StandardError => e
|
|
23
17
|
log_warn("Failed to load snapshot: #{e.message}")
|
|
24
18
|
false
|
|
25
19
|
end
|
|
26
20
|
|
|
27
|
-
# Persist the definitions rail. Always available regardless of
|
|
28
|
-
# `config.enable_variants` — definitions remain authoritative for
|
|
29
|
-
# `enabled?`.
|
|
30
21
|
def save_definitions_snapshot
|
|
31
22
|
return unless @config.snapshot_provider
|
|
32
23
|
|
|
@@ -36,17 +27,6 @@ module Toggly
|
|
|
36
27
|
log_warn("Failed to save snapshot: #{e.message}")
|
|
37
28
|
end
|
|
38
29
|
|
|
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
30
|
def load_definitions_snapshot
|
|
51
31
|
data = @config.snapshot_provider.load
|
|
52
32
|
return false unless data
|
|
@@ -59,20 +39,6 @@ module Toggly
|
|
|
59
39
|
log_debug("Loaded #{@definitions.size} features from snapshot")
|
|
60
40
|
true
|
|
61
41
|
end
|
|
62
|
-
|
|
63
|
-
def load_variants_snapshot
|
|
64
|
-
return false unless @config.snapshot_provider.respond_to?(:load_variants)
|
|
65
|
-
|
|
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
|
|
75
|
-
end
|
|
76
42
|
end
|
|
77
43
|
end
|
|
78
44
|
end
|
data/lib/toggly/client.rb
CHANGED
|
@@ -33,10 +33,6 @@ 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
|
-
|
|
40
36
|
# @return [Boolean] Whether the client is ready
|
|
41
37
|
attr_reader :ready
|
|
42
38
|
|
|
@@ -48,7 +44,6 @@ module Toggly
|
|
|
48
44
|
@config.validate!
|
|
49
45
|
|
|
50
46
|
@definitions = {}
|
|
51
|
-
@variant_defs = {}
|
|
52
47
|
# True once a revision (including empty) or durable snapshot was applied.
|
|
53
48
|
@definitions_loaded = false
|
|
54
49
|
@mutex = Mutex.new
|
|
@@ -85,10 +80,9 @@ module Toggly
|
|
|
85
80
|
|
|
86
81
|
definition = @mutex.synchronize { @definitions[key] }
|
|
87
82
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# only by get_variant / get_variant_value and never override this.
|
|
83
|
+
# `enabled?` stays filter-based only (definitions/definitions-signed).
|
|
84
|
+
# It never reflects a variant's StatusOverride — see `get_variant`
|
|
85
|
+
# (`VariantResult#enabled`) for MF-identical effective-enabled semantics.
|
|
92
86
|
result = if definition.nil?
|
|
93
87
|
if !default.nil?
|
|
94
88
|
default
|
|
@@ -117,52 +111,52 @@ module Toggly
|
|
|
117
111
|
!enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
|
|
118
112
|
end
|
|
119
113
|
|
|
120
|
-
# Get the assigned variant for a feature
|
|
121
|
-
#
|
|
114
|
+
# Get the assigned variant for a feature, computed locally from the
|
|
115
|
+
# feature's catalog `variants` / `allocation` (MF-parity, bit-for-bit
|
|
116
|
+
# with `Microsoft.FeatureManagement`'s `IVariantFeatureManager`). Returns
|
|
117
|
+
# nil when the feature is unknown, has no variants configured, or no
|
|
118
|
+
# variant resolves for this context (see {VariantAllocator}).
|
|
119
|
+
#
|
|
120
|
+
# There is no network round-trip here — this superseded the
|
|
121
|
+
# `enable_variants` / `evaluated-variants-signed` dual-rail removed in
|
|
122
|
+
# 1.0 (see CHANGELOG).
|
|
122
123
|
#
|
|
123
124
|
# NOTE: this is the actual A/B assignment. It is unrelated to the
|
|
124
125
|
# `variant:` telemetry label on `record_usage` / `record_view`, which is
|
|
125
|
-
# a free-form usage tag (defaults to "enabled"/"disabled")
|
|
126
|
-
# reflect `evaluated-variants-signed` results.
|
|
126
|
+
# a free-form usage tag (defaults to "enabled"/"disabled").
|
|
127
127
|
#
|
|
128
128
|
# @param feature_key [String, Symbol] The feature key
|
|
129
|
+
# @param context [Context, nil] Optional targeting context (userId + groups)
|
|
129
130
|
# @return [VariantResult, nil]
|
|
130
|
-
def get_variant(feature_key)
|
|
131
|
-
return nil unless @config.enable_variants
|
|
132
|
-
|
|
131
|
+
def get_variant(feature_key, context: nil)
|
|
133
132
|
key = feature_key.to_s
|
|
134
|
-
|
|
135
|
-
return nil if
|
|
133
|
+
definition = @mutex.synchronize { @definitions[key] }
|
|
134
|
+
return nil if definition.nil?
|
|
135
|
+
|
|
136
|
+
enabled = @engine.evaluate(definition, context)
|
|
137
|
+
assignment = VariantAllocator.assign(
|
|
138
|
+
definition,
|
|
139
|
+
enabled: enabled,
|
|
140
|
+
identity: context&.identity,
|
|
141
|
+
groups: context&.groups || []
|
|
142
|
+
)
|
|
143
|
+
return nil if assignment.variant_name.nil?
|
|
136
144
|
|
|
137
|
-
VariantResult.new(
|
|
145
|
+
VariantResult.new(
|
|
146
|
+
name: assignment.variant_name,
|
|
147
|
+
configuration_value: assignment.configuration_value,
|
|
148
|
+
enabled: assignment.enabled,
|
|
149
|
+
reason: assignment.reason
|
|
150
|
+
)
|
|
138
151
|
end
|
|
139
152
|
|
|
140
153
|
# Get the configuration value for the assigned variant, if any.
|
|
141
154
|
#
|
|
142
155
|
# @param feature_key [String, Symbol] The feature key
|
|
156
|
+
# @param context [Context, nil] Optional targeting context (userId + groups)
|
|
143
157
|
# @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
|
|
158
|
+
def get_variant_value(feature_key, context: nil)
|
|
159
|
+
get_variant(feature_key, context: context)&.configuration_value
|
|
166
160
|
end
|
|
167
161
|
|
|
168
162
|
# Get detailed evaluation result
|
|
@@ -217,15 +211,7 @@ module Toggly
|
|
|
217
211
|
end
|
|
218
212
|
|
|
219
213
|
begin
|
|
220
|
-
|
|
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
|
|
214
|
+
refresh_definitions_rail(force: force)
|
|
229
215
|
ensure
|
|
230
216
|
drain_pending = false
|
|
231
217
|
@mutex.synchronize do
|
|
@@ -334,9 +320,9 @@ module Toggly
|
|
|
334
320
|
|
|
335
321
|
private
|
|
336
322
|
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
323
|
+
# `definitions` / `definitions-signed` → local rule eval. The sole
|
|
324
|
+
# source of truth for `enabled?`; also carries `variants` / `allocation`
|
|
325
|
+
# for the catalog-local `get_variant` assignment.
|
|
340
326
|
def refresh_definitions_rail(force:)
|
|
341
327
|
refresh_definitions(force: force)
|
|
342
328
|
rescue StandardError => e
|
|
@@ -365,36 +351,6 @@ module Toggly
|
|
|
365
351
|
end
|
|
366
352
|
end
|
|
367
353
|
|
|
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
|
-
|
|
398
354
|
def initialize_definitions
|
|
399
355
|
# Startup served from durable snapshot before first network — cache hit.
|
|
400
356
|
# Distinct from the subsequent refresh() network outcome (no double-count
|
|
@@ -419,7 +375,7 @@ module Toggly
|
|
|
419
375
|
log_error("Failed to initialize definitions: #{e.message}")
|
|
420
376
|
|
|
421
377
|
# Use snapshot or defaults as fallback
|
|
422
|
-
@ready = true if @definitions.any? || @
|
|
378
|
+
@ready = true if @definitions.any? || @config.defaults.any?
|
|
423
379
|
end
|
|
424
380
|
|
|
425
381
|
def start_background_refresh
|
data/lib/toggly/config.rb
CHANGED
|
@@ -55,25 +55,6 @@ 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
|
-
|
|
77
58
|
# @return [Logger, nil] Logger instance
|
|
78
59
|
attr_accessor :logger
|
|
79
60
|
|
|
@@ -123,10 +104,6 @@ module Toggly
|
|
|
123
104
|
@snapshot_provider = options[:snapshot_provider]
|
|
124
105
|
@use_signed_definitions = options[:use_signed_definitions] || false
|
|
125
106
|
@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] || {}
|
|
130
107
|
@logger = options[:logger]
|
|
131
108
|
|
|
132
109
|
@usage_tracking_explicit = options.key?(:enable_usage_tracking)
|
|
@@ -150,16 +127,6 @@ module Toggly
|
|
|
150
127
|
"#{normalize_url(base)}#{endpoint}/#{@app_key}/#{@environment}"
|
|
151
128
|
end
|
|
152
129
|
|
|
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
|
-
|
|
163
130
|
# Validate the configuration
|
|
164
131
|
#
|
|
165
132
|
# @raise [ConfigError] if configuration is invalid
|
|
@@ -219,7 +186,6 @@ module Toggly
|
|
|
219
186
|
enable_undefined_in_dev: @enable_undefined_in_dev,
|
|
220
187
|
disable_background_refresh: @disable_background_refresh,
|
|
221
188
|
enable_live_updates: @enable_live_updates,
|
|
222
|
-
enable_variants: @enable_variants,
|
|
223
189
|
app_version: @app_version,
|
|
224
190
|
instance_name: @instance_name,
|
|
225
191
|
use_signed_definitions: @use_signed_definitions,
|
|
@@ -17,12 +17,6 @@ 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
|
-
|
|
26
20
|
# Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
|
|
27
21
|
FALLBACK_REFRESH_INTERVAL = 20 * 60
|
|
28
22
|
|
|
@@ -43,13 +37,6 @@ module Toggly
|
|
|
43
37
|
@last_modified = nil
|
|
44
38
|
@last_ts = 0
|
|
45
39
|
|
|
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
|
-
|
|
53
40
|
# WebSocket state
|
|
54
41
|
@ws = nil
|
|
55
42
|
@ws_connected = false
|
|
@@ -90,56 +77,6 @@ module Toggly
|
|
|
90
77
|
@last_ts = 0
|
|
91
78
|
end
|
|
92
79
|
|
|
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
|
-
|
|
143
80
|
# Check whether the periodic refresh should be skipped because the
|
|
144
81
|
# WebSocket connection is active and the fallback interval has not elapsed.
|
|
145
82
|
#
|
|
@@ -299,110 +236,6 @@ module Toggly
|
|
|
299
236
|
@last_modified = last_modified if last_modified && !last_modified.empty?
|
|
300
237
|
end
|
|
301
238
|
|
|
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
|
-
|
|
406
239
|
def extract_signed_timestamp(data)
|
|
407
240
|
return nil unless data.is_a?(Hash)
|
|
408
241
|
|
|
@@ -35,6 +35,12 @@ module Toggly
|
|
|
35
35
|
# @return [String, nil] Feature description
|
|
36
36
|
attr_reader :description
|
|
37
37
|
|
|
38
|
+
# @return [Array<FeatureVariant>] Named variants for MF-parity assignment
|
|
39
|
+
attr_reader :variants
|
|
40
|
+
|
|
41
|
+
# @return [FeatureVariantAllocation, nil] Allocation rules for variant assignment
|
|
42
|
+
attr_reader :allocation
|
|
43
|
+
|
|
38
44
|
# Feature types
|
|
39
45
|
TYPES = %w[Release Experiment Ops Permission].freeze
|
|
40
46
|
|
|
@@ -50,7 +56,9 @@ module Toggly
|
|
|
50
56
|
updated_at: nil,
|
|
51
57
|
requirement_type: "Any",
|
|
52
58
|
context_kind: nil,
|
|
53
|
-
context_requirement_type: nil
|
|
59
|
+
context_requirement_type: nil,
|
|
60
|
+
variants: [],
|
|
61
|
+
allocation: nil
|
|
54
62
|
)
|
|
55
63
|
@feature_key = feature_key.to_s
|
|
56
64
|
@feature_type = validate_type(feature_type)
|
|
@@ -63,6 +71,8 @@ module Toggly
|
|
|
63
71
|
@requirement_type = requirement_type || "Any"
|
|
64
72
|
@context_kind = context_kind
|
|
65
73
|
@context_requirement_type = context_requirement_type
|
|
74
|
+
@variants = Array(variants)
|
|
75
|
+
@allocation = allocation
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
# Create from a hash (e.g., from JSON)
|
|
@@ -83,6 +93,9 @@ module Toggly
|
|
|
83
93
|
!rules.empty?
|
|
84
94
|
end
|
|
85
95
|
|
|
96
|
+
variants = Array(hash[:variants]).map { |v| FeatureVariant.from_hash(v) }
|
|
97
|
+
allocation = FeatureVariantAllocation.from_hash(hash[:allocation])
|
|
98
|
+
|
|
86
99
|
new(
|
|
87
100
|
feature_key: hash[:featureKey] || hash[:feature_key],
|
|
88
101
|
feature_type: hash[:featureType] || hash[:feature_type] || "Release",
|
|
@@ -94,7 +107,9 @@ module Toggly
|
|
|
94
107
|
updated_at: hash[:updatedAt] || hash[:updated_at],
|
|
95
108
|
requirement_type: hash[:requirementType] || hash[:requirement_type] || "Any",
|
|
96
109
|
context_kind: hash[:contextKind] || hash[:context_kind],
|
|
97
|
-
context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type]
|
|
110
|
+
context_requirement_type: hash[:contextRequirementType] || hash[:context_requirement_type],
|
|
111
|
+
variants: variants,
|
|
112
|
+
allocation: allocation
|
|
98
113
|
)
|
|
99
114
|
end
|
|
100
115
|
|
|
@@ -110,7 +125,9 @@ module Toggly
|
|
|
110
125
|
metadata: @metadata,
|
|
111
126
|
description: @description,
|
|
112
127
|
created_at: @created_at&.iso8601,
|
|
113
|
-
updated_at: @updated_at&.iso8601
|
|
128
|
+
updated_at: @updated_at&.iso8601,
|
|
129
|
+
variants: @variants.map(&:to_h),
|
|
130
|
+
allocation: @allocation&.to_h
|
|
114
131
|
}
|
|
115
132
|
end
|
|
116
133
|
|