toggly 0.5.2 → 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 +58 -0
- data/README.md +22 -0
- data/lib/toggly/client/snapshot_support.rb +19 -13
- data/lib/toggly/client.rb +83 -22
- data/lib/toggly/definitions_provider.rb +3 -3
- data/lib/toggly/feature_definition.rb +20 -3
- data/lib/toggly/feature_variant.rb +188 -0
- 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 -0
- metadata +5 -2
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,64 @@ 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
|
+
|
|
41
|
+
## [0.6.0] - 2026-09-22
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Server-evaluated feature variants (dual-rail). Set `enable_variants: true`
|
|
46
|
+
on `Config` to additionally fetch `evaluated-variants-signed/{app_key}/
|
|
47
|
+
{environment}` on its own rail alongside the existing `definitions` /
|
|
48
|
+
`definitions-signed` pipeline. `definitions` / `definitions-signed` remain
|
|
49
|
+
the sole source of truth for `enabled?` regardless of `enable_variants` —
|
|
50
|
+
evaluated variants are additive and only feed `get_variant` /
|
|
51
|
+
`get_variant_value`; they never override `enabled?`. New `Config` options:
|
|
52
|
+
`enable_variants`, `variant_identity`, `variant_groups`, `variant_claims`.
|
|
53
|
+
New `Client#get_variant` / `Client#get_variant_value` return the assigned
|
|
54
|
+
variant name and `configuration_value`, or `nil` when unassigned or
|
|
55
|
+
disabled. New `Client#set_variant_identity` updates the `userId` sent to
|
|
56
|
+
`evaluated-variants-signed` and refreshes.
|
|
57
|
+
- `SnapshotProviders::Base#save_variants` / `#load_variants` (default no-op)
|
|
58
|
+
so `Memory` and `File` providers persist evaluated variants across
|
|
59
|
+
restarts, independent of the `definitions` snapshot.
|
|
60
|
+
|
|
61
|
+
**Note:** the new `get_variant` assignment is unrelated to the existing
|
|
62
|
+
`variant:` label on `record_usage` / `record_view`, which is a free-form
|
|
63
|
+
usage tag (defaults to `"enabled"`/`"disabled"`) and does not reflect
|
|
64
|
+
`evaluated-variants-signed` results.
|
|
65
|
+
|
|
8
66
|
## [0.5.2] - 2026-09-17
|
|
9
67
|
|
|
10
68
|
### Fixed
|
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,30 +2,23 @@
|
|
|
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. Definitions (including
|
|
6
|
+
# `variants` / `allocation`) are the only persisted rail — there is no
|
|
7
|
+
# separate evaluated-variants snapshot (removed with the dual-rail).
|
|
6
8
|
module SnapshotSupport
|
|
7
9
|
private
|
|
8
10
|
|
|
9
|
-
# @return [Boolean] true when
|
|
11
|
+
# @return [Boolean] true when the durable snapshot was applied into memory
|
|
10
12
|
def load_snapshot
|
|
11
13
|
return false unless @config.snapshot_provider
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
return false unless data
|
|
15
|
-
|
|
16
|
-
@mutex.synchronize do
|
|
17
|
-
@definitions = data[:definitions]
|
|
18
|
-
@definitions_loaded = true
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
log_debug("Loaded #{@definitions.size} features from snapshot")
|
|
22
|
-
true
|
|
15
|
+
load_definitions_snapshot
|
|
23
16
|
rescue StandardError => e
|
|
24
17
|
log_warn("Failed to load snapshot: #{e.message}")
|
|
25
18
|
false
|
|
26
19
|
end
|
|
27
20
|
|
|
28
|
-
def
|
|
21
|
+
def save_definitions_snapshot
|
|
29
22
|
return unless @config.snapshot_provider
|
|
30
23
|
|
|
31
24
|
@config.snapshot_provider.save(@definitions)
|
|
@@ -33,6 +26,19 @@ module Toggly
|
|
|
33
26
|
rescue StandardError => e
|
|
34
27
|
log_warn("Failed to save snapshot: #{e.message}")
|
|
35
28
|
end
|
|
29
|
+
|
|
30
|
+
def load_definitions_snapshot
|
|
31
|
+
data = @config.snapshot_provider.load
|
|
32
|
+
return false unless data
|
|
33
|
+
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
@definitions = data[:definitions]
|
|
36
|
+
@definitions_loaded = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
log_debug("Loaded #{@definitions.size} features from snapshot")
|
|
40
|
+
true
|
|
41
|
+
end
|
|
36
42
|
end
|
|
37
43
|
end
|
|
38
44
|
end
|
data/lib/toggly/client.rb
CHANGED
|
@@ -80,7 +80,9 @@ module Toggly
|
|
|
80
80
|
|
|
81
81
|
definition = @mutex.synchronize { @definitions[key] }
|
|
82
82
|
|
|
83
|
-
#
|
|
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.
|
|
84
86
|
result = if definition.nil?
|
|
85
87
|
if !default.nil?
|
|
86
88
|
default
|
|
@@ -109,6 +111,54 @@ module Toggly
|
|
|
109
111
|
!enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
|
|
110
112
|
end
|
|
111
113
|
|
|
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).
|
|
123
|
+
#
|
|
124
|
+
# NOTE: this is the actual A/B assignment. It is unrelated to the
|
|
125
|
+
# `variant:` telemetry label on `record_usage` / `record_view`, which is
|
|
126
|
+
# a free-form usage tag (defaults to "enabled"/"disabled").
|
|
127
|
+
#
|
|
128
|
+
# @param feature_key [String, Symbol] The feature key
|
|
129
|
+
# @param context [Context, nil] Optional targeting context (userId + groups)
|
|
130
|
+
# @return [VariantResult, nil]
|
|
131
|
+
def get_variant(feature_key, context: nil)
|
|
132
|
+
key = feature_key.to_s
|
|
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?
|
|
144
|
+
|
|
145
|
+
VariantResult.new(
|
|
146
|
+
name: assignment.variant_name,
|
|
147
|
+
configuration_value: assignment.configuration_value,
|
|
148
|
+
enabled: assignment.enabled,
|
|
149
|
+
reason: assignment.reason
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Get the configuration value for the assigned variant, if any.
|
|
154
|
+
#
|
|
155
|
+
# @param feature_key [String, Symbol] The feature key
|
|
156
|
+
# @param context [Context, nil] Optional targeting context (userId + groups)
|
|
157
|
+
# @return [Object, nil]
|
|
158
|
+
def get_variant_value(feature_key, context: nil)
|
|
159
|
+
get_variant(feature_key, context: context)&.configuration_value
|
|
160
|
+
end
|
|
161
|
+
|
|
112
162
|
# Get detailed evaluation result
|
|
113
163
|
#
|
|
114
164
|
# @param feature_key [String, Symbol] The feature key
|
|
@@ -161,27 +211,7 @@ module Toggly
|
|
|
161
211
|
end
|
|
162
212
|
|
|
163
213
|
begin
|
|
164
|
-
|
|
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
|
|
214
|
+
refresh_definitions_rail(force: force)
|
|
185
215
|
ensure
|
|
186
216
|
drain_pending = false
|
|
187
217
|
@mutex.synchronize do
|
|
@@ -290,6 +320,37 @@ module Toggly
|
|
|
290
320
|
|
|
291
321
|
private
|
|
292
322
|
|
|
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.
|
|
326
|
+
def refresh_definitions_rail(force:)
|
|
327
|
+
refresh_definitions(force: force)
|
|
328
|
+
rescue StandardError => e
|
|
329
|
+
log_error("Failed to refresh definitions: #{e.message}")
|
|
330
|
+
# Network error / timeout keeping last-good revision (incl. empty) — hit.
|
|
331
|
+
record_definition_cache_hit if definitions_cached?
|
|
332
|
+
false
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def refresh_definitions(force:)
|
|
336
|
+
result = @provider.fetch(force: force)
|
|
337
|
+
record_refresh_cache_outcome(result.cache_outcome)
|
|
338
|
+
|
|
339
|
+
if result.definitions
|
|
340
|
+
@mutex.synchronize do
|
|
341
|
+
@definitions = result.definitions
|
|
342
|
+
@definitions_loaded = true
|
|
343
|
+
@ready = true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
save_definitions_snapshot
|
|
347
|
+
log_info("Definitions refreshed (#{result.definitions.size} features)")
|
|
348
|
+
true
|
|
349
|
+
else
|
|
350
|
+
false
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
293
354
|
def initialize_definitions
|
|
294
355
|
# Startup served from durable snapshot before first network — cache hit.
|
|
295
356
|
# Distinct from the subsequent refresh() network outcome (no double-count
|
|
@@ -195,7 +195,7 @@ module Toggly
|
|
|
195
195
|
when :new_content
|
|
196
196
|
handle_new_content(response, response_etag, response_lm)
|
|
197
197
|
when :error_status
|
|
198
|
-
handle_error_status(status, response)
|
|
198
|
+
handle_error_status(status, response, resource: "definitions")
|
|
199
199
|
end
|
|
200
200
|
end
|
|
201
201
|
|
|
@@ -216,12 +216,12 @@ module Toggly
|
|
|
216
216
|
raise DefinitionsError, "Failed to parse definitions: #{e.message}"
|
|
217
217
|
end
|
|
218
218
|
|
|
219
|
-
def handle_error_status(status, response)
|
|
219
|
+
def handle_error_status(status, response, resource: "definitions")
|
|
220
220
|
case status
|
|
221
221
|
when 401, 403
|
|
222
222
|
raise DefinitionsError, "Authentication failed: #{status}"
|
|
223
223
|
when 404
|
|
224
|
-
raise DefinitionsError, "
|
|
224
|
+
raise DefinitionsError, "#{resource.capitalize} not found (check app_key and environment)"
|
|
225
225
|
else
|
|
226
226
|
raise NetworkError.new(
|
|
227
227
|
"API error: #{status}",
|
|
@@ -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
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# A single named variant of a feature flag, as sent on the catalog
|
|
5
|
+
# (`definitions` / `definitions-signed`) wire alongside `filters`.
|
|
6
|
+
#
|
|
7
|
+
# Matches the shape produced by `Microsoft.FeatureManagement`'s
|
|
8
|
+
# `VariantDefinition` (`Name`, `ConfigurationValue`, `StatusOverride`).
|
|
9
|
+
class FeatureVariant
|
|
10
|
+
# Valid `StatusOverride` values (MF `StatusOverride` enum as strings).
|
|
11
|
+
STATUS_OVERRIDES = %w[None Enabled Disabled].freeze
|
|
12
|
+
|
|
13
|
+
# @return [String] Variant name
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
# @return [Object, nil] Untyped configuration payload for this variant
|
|
17
|
+
attr_reader :configuration_value
|
|
18
|
+
|
|
19
|
+
# @return [String] "None" | "Enabled" | "Disabled"
|
|
20
|
+
attr_reader :status_override
|
|
21
|
+
|
|
22
|
+
def initialize(name:, configuration_value: nil, status_override: "None")
|
|
23
|
+
@name = name.to_s
|
|
24
|
+
@configuration_value = configuration_value
|
|
25
|
+
@status_override = STATUS_OVERRIDES.include?(status_override.to_s) ? status_override.to_s : "None"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param hash [Hash] Variant hash (camelCase wire or snake_case snapshot)
|
|
29
|
+
# @return [FeatureVariant]
|
|
30
|
+
def self.from_hash(hash)
|
|
31
|
+
hash = symbolize_keys(hash)
|
|
32
|
+
configuration_value = if hash.key?(:configurationValue)
|
|
33
|
+
hash[:configurationValue]
|
|
34
|
+
else
|
|
35
|
+
hash[:configuration_value]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
name: hash[:name],
|
|
40
|
+
configuration_value: configuration_value,
|
|
41
|
+
status_override: hash[:statusOverride] || hash[:status_override] || "None"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] True when this variant forces the feature to be
|
|
46
|
+
# reported enabled regardless of the base filter-evaluated state.
|
|
47
|
+
def enabled_override?
|
|
48
|
+
@status_override == "Enabled"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Boolean] True when this variant forces the feature to be
|
|
52
|
+
# reported disabled regardless of the base filter-evaluated state.
|
|
53
|
+
def disabled_override?
|
|
54
|
+
@status_override == "Disabled"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Hash]
|
|
58
|
+
def to_h
|
|
59
|
+
{
|
|
60
|
+
name: @name,
|
|
61
|
+
configuration_value: @configuration_value,
|
|
62
|
+
status_override: @status_override
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ==(other)
|
|
67
|
+
other.is_a?(FeatureVariant) &&
|
|
68
|
+
@name == other.name &&
|
|
69
|
+
@configuration_value == other.configuration_value &&
|
|
70
|
+
@status_override == other.status_override
|
|
71
|
+
end
|
|
72
|
+
alias eql? ==
|
|
73
|
+
|
|
74
|
+
def hash
|
|
75
|
+
[@name, @configuration_value, @status_override].hash
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.symbolize_keys(hash)
|
|
79
|
+
return {} unless hash.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
82
|
+
end
|
|
83
|
+
private_class_method :symbolize_keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Allocation rules for assigning variants to users/groups/percentile
|
|
87
|
+
# buckets, matching `Microsoft.FeatureManagement`'s `Allocation` schema.
|
|
88
|
+
class FeatureVariantAllocation
|
|
89
|
+
# @return [String, nil] Variant assigned when the feature is enabled and
|
|
90
|
+
# no user/group/percentile allocation matched.
|
|
91
|
+
attr_reader :default_when_enabled
|
|
92
|
+
|
|
93
|
+
# @return [String, nil] Variant assigned when the feature is disabled.
|
|
94
|
+
attr_reader :default_when_disabled
|
|
95
|
+
|
|
96
|
+
# @return [String, nil] Seed for the percentile hash; falls back to
|
|
97
|
+
# "allocation\n{featureName}" when nil.
|
|
98
|
+
attr_reader :seed
|
|
99
|
+
|
|
100
|
+
# @return [Array<Hash>] `[{ variant:, users: [...] }, ...]`
|
|
101
|
+
attr_reader :user
|
|
102
|
+
|
|
103
|
+
# @return [Array<Hash>] `[{ variant:, groups: [...] }, ...]`
|
|
104
|
+
attr_reader :group
|
|
105
|
+
|
|
106
|
+
# @return [Array<Hash>] `[{ variant:, from:, to: }, ...]`
|
|
107
|
+
attr_reader :percentile
|
|
108
|
+
|
|
109
|
+
def initialize(default_when_enabled: nil, default_when_disabled: nil, seed: nil, user: [], group: [], percentile: [])
|
|
110
|
+
@default_when_enabled = default_when_enabled
|
|
111
|
+
@default_when_disabled = default_when_disabled
|
|
112
|
+
@seed = seed
|
|
113
|
+
@user = Array(user)
|
|
114
|
+
@group = Array(group)
|
|
115
|
+
@percentile = Array(percentile)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @param hash [Hash, nil]
|
|
119
|
+
# @return [FeatureVariantAllocation, nil] nil when +hash+ is nil (no allocation configured)
|
|
120
|
+
def self.from_hash(hash)
|
|
121
|
+
return nil unless hash.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
hash = symbolize_keys(hash)
|
|
124
|
+
new(
|
|
125
|
+
default_when_enabled: hash[:defaultWhenEnabled] || hash[:default_when_enabled],
|
|
126
|
+
default_when_disabled: hash[:defaultWhenDisabled] || hash[:default_when_disabled],
|
|
127
|
+
seed: hash[:seed],
|
|
128
|
+
user: parse_entries(hash[:user], :users),
|
|
129
|
+
group: parse_entries(hash[:group], :groups),
|
|
130
|
+
percentile: parse_percentile_entries(hash[:percentile])
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [Hash]
|
|
135
|
+
def to_h
|
|
136
|
+
{
|
|
137
|
+
default_when_enabled: @default_when_enabled,
|
|
138
|
+
default_when_disabled: @default_when_disabled,
|
|
139
|
+
seed: @seed,
|
|
140
|
+
user: @user,
|
|
141
|
+
group: @group,
|
|
142
|
+
percentile: @percentile
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ==(other)
|
|
147
|
+
other.is_a?(FeatureVariantAllocation) &&
|
|
148
|
+
@default_when_enabled == other.default_when_enabled &&
|
|
149
|
+
@default_when_disabled == other.default_when_disabled &&
|
|
150
|
+
@seed == other.seed &&
|
|
151
|
+
@user == other.user &&
|
|
152
|
+
@group == other.group &&
|
|
153
|
+
@percentile == other.percentile
|
|
154
|
+
end
|
|
155
|
+
alias eql? ==
|
|
156
|
+
|
|
157
|
+
def hash
|
|
158
|
+
[@default_when_enabled, @default_when_disabled, @seed, @user, @group, @percentile].hash
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def self.parse_entries(raw, members_key)
|
|
162
|
+
Array(raw).filter_map do |entry|
|
|
163
|
+
next unless entry.is_a?(Hash)
|
|
164
|
+
|
|
165
|
+
entry = symbolize_keys(entry)
|
|
166
|
+
{ variant: entry[:variant], members_key => Array(entry[members_key]).map(&:to_s) }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :parse_entries
|
|
170
|
+
|
|
171
|
+
def self.parse_percentile_entries(raw)
|
|
172
|
+
Array(raw).filter_map do |entry|
|
|
173
|
+
next unless entry.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
entry = symbolize_keys(entry)
|
|
176
|
+
{ variant: entry[:variant], from: entry[:from].to_f, to: entry[:to].to_f }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
private_class_method :parse_percentile_entries
|
|
180
|
+
|
|
181
|
+
def self.symbolize_keys(hash)
|
|
182
|
+
return {} unless hash.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
185
|
+
end
|
|
186
|
+
private_class_method :symbolize_keys
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Toggly
|
|
6
|
+
# Catalog-local, MF-parity variant allocator.
|
|
7
|
+
#
|
|
8
|
+
# Assigns a feature variant purely from a {FeatureDefinition}'s
|
|
9
|
+
# `variants` / `allocation` and a targeting context (`identity` + `groups`),
|
|
10
|
+
# bit-for-bit matching `Microsoft.FeatureManagement` 4.7.0's
|
|
11
|
+
# `FeatureManager#GetVariantAsync`. Verified against the shared
|
|
12
|
+
# `variant-allocator-corpus/cases.json` gold corpus.
|
|
13
|
+
#
|
|
14
|
+
# This replaces the `evaluated-variants-signed` dual-rail: there is no
|
|
15
|
+
# network call here — the caller supplies the already filter-evaluated
|
|
16
|
+
# `enabled` boolean (the same value `Client#enabled?` would return for
|
|
17
|
+
# this feature/context) and this module does the rest locally.
|
|
18
|
+
module VariantAllocator
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Assignment reasons (mirrors MF `AssignmentReason`).
|
|
22
|
+
REASON_NONE = "None"
|
|
23
|
+
REASON_USER = "User"
|
|
24
|
+
REASON_GROUP = "Group"
|
|
25
|
+
REASON_PERCENTILE = "Percentile"
|
|
26
|
+
REASON_DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
|
|
27
|
+
REASON_DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled"
|
|
28
|
+
|
|
29
|
+
# Result of a variant assignment.
|
|
30
|
+
#
|
|
31
|
+
# @!attribute variant_name [String, nil] Assigned variant name, or nil if unassigned
|
|
32
|
+
# @!attribute configuration_value [Object, nil] The assigned variant's configuration payload
|
|
33
|
+
# @!attribute enabled [Boolean] Effective enabled flag after StatusOverride is applied
|
|
34
|
+
# @!attribute reason [String] One of the REASON_* constants above
|
|
35
|
+
Assignment = Struct.new(:variant_name, :configuration_value, :enabled, :reason, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
# Assign a variant for a feature + targeting context.
|
|
38
|
+
#
|
|
39
|
+
# @param definition [FeatureDefinition] Feature definition (variants + allocation)
|
|
40
|
+
# @param enabled [Boolean] The filter-evaluated enabled state for this context
|
|
41
|
+
# (i.e. what `EvaluationEngine#evaluate` returns for this definition/context)
|
|
42
|
+
# @param identity [String, nil] Targeting user id
|
|
43
|
+
# @param groups [Array<String>] Targeting groups
|
|
44
|
+
# @param ignore_case [Boolean] MF `TargetingEvaluationOptions.IgnoreCase` parity knob.
|
|
45
|
+
# Defaults to `false`, matching Microsoft.FeatureManagement's default.
|
|
46
|
+
# @return [Assignment]
|
|
47
|
+
def assign(definition, enabled:, identity: nil, groups: [], ignore_case: false)
|
|
48
|
+
variants = definition&.variants || []
|
|
49
|
+
allocation = definition&.allocation
|
|
50
|
+
|
|
51
|
+
return build_assignment(nil, variants, enabled, REASON_NONE) if variants.empty?
|
|
52
|
+
|
|
53
|
+
unless enabled
|
|
54
|
+
variant_name = allocation&.default_when_disabled
|
|
55
|
+
return build_assignment(variant_name, variants, enabled, REASON_DEFAULT_WHEN_DISABLED)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
variant_name, reason = resolve_enabled_allocation(
|
|
59
|
+
allocation, identity, groups, ignore_case, definition.feature_key
|
|
60
|
+
)
|
|
61
|
+
build_assignment(variant_name, variants, enabled, reason)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_enabled_allocation(allocation, identity, groups, ignore_case, feature_key)
|
|
65
|
+
return [nil, REASON_DEFAULT_WHEN_ENABLED] unless allocation
|
|
66
|
+
|
|
67
|
+
user_variant = match_user(allocation.user, identity, ignore_case)
|
|
68
|
+
return [user_variant, REASON_USER] if user_variant
|
|
69
|
+
|
|
70
|
+
group_variant = match_group(allocation.group, groups, ignore_case)
|
|
71
|
+
return [group_variant, REASON_GROUP] if group_variant
|
|
72
|
+
|
|
73
|
+
percentile_variant = match_percentile(allocation.percentile, allocation.seed, identity, feature_key, ignore_case)
|
|
74
|
+
return [percentile_variant, REASON_PERCENTILE] if percentile_variant
|
|
75
|
+
|
|
76
|
+
[allocation.default_when_enabled, REASON_DEFAULT_WHEN_ENABLED]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def match_user(entries, identity, ignore_case)
|
|
80
|
+
return nil if identity.nil? || identity.to_s.empty?
|
|
81
|
+
|
|
82
|
+
Array(entries).each do |entry|
|
|
83
|
+
users = Array(entry[:users])
|
|
84
|
+
matched = if ignore_case
|
|
85
|
+
users.any? { |u| u.to_s.casecmp?(identity.to_s) }
|
|
86
|
+
else
|
|
87
|
+
users.include?(identity.to_s)
|
|
88
|
+
end
|
|
89
|
+
return entry[:variant] if matched
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def match_group(entries, groups, ignore_case)
|
|
95
|
+
context_groups = Array(groups).map(&:to_s)
|
|
96
|
+
return nil if context_groups.empty?
|
|
97
|
+
|
|
98
|
+
Array(entries).each do |entry|
|
|
99
|
+
entry_groups = Array(entry[:groups]).map(&:to_s)
|
|
100
|
+
matched = if ignore_case
|
|
101
|
+
entry_groups.any? { |eg| context_groups.any? { |cg| cg.casecmp?(eg) } }
|
|
102
|
+
else
|
|
103
|
+
entry_groups.intersect?(context_groups)
|
|
104
|
+
end
|
|
105
|
+
return entry[:variant] if matched
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def match_percentile(entries, seed, identity, feature_key, ignore_case)
|
|
111
|
+
list = Array(entries)
|
|
112
|
+
return nil if list.empty?
|
|
113
|
+
|
|
114
|
+
pct = compute_percentile(identity, seed, feature_key, ignore_case)
|
|
115
|
+
list.each do |entry|
|
|
116
|
+
from = entry[:from].to_f
|
|
117
|
+
to = entry[:to].to_f
|
|
118
|
+
matched = to >= 100.0 ? pct >= from : (pct >= from && pct < to)
|
|
119
|
+
return entry[:variant] if matched
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# MF-parity percentile hash.
|
|
125
|
+
#
|
|
126
|
+
# `contextId = "{userId}\n{hint}"`, `hint = seed` (if non-nil) else
|
|
127
|
+
# `"allocation\n{featureName}"`. SHA-256 over the UTF-8 bytes; the first
|
|
128
|
+
# 4 bytes read as a little-endian uint32; `pct = marker / 0xFFFFFFFF * 100`.
|
|
129
|
+
#
|
|
130
|
+
# @return [Float] Percentile in [0, 100]
|
|
131
|
+
def compute_percentile(identity, seed, feature_key, ignore_case)
|
|
132
|
+
user_id = identity.to_s
|
|
133
|
+
user_id = user_id.downcase if ignore_case
|
|
134
|
+
hint = seed.nil? ? "allocation\n#{feature_key}" : seed.to_s
|
|
135
|
+
context_id = "#{user_id}\n#{hint}"
|
|
136
|
+
|
|
137
|
+
digest = Digest::SHA256.digest(context_id)
|
|
138
|
+
marker = digest.byteslice(0, 4).unpack1("V") # little-endian uint32; unpack1("V") is endian-explicit
|
|
139
|
+
(marker.to_f / 0xFFFFFFFF) * 100.0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_assignment(variant_name, variants, base_enabled, reason)
|
|
143
|
+
variant = Array(variants).find { |v| v.name == variant_name }
|
|
144
|
+
effective_enabled =
|
|
145
|
+
if variant&.enabled_override?
|
|
146
|
+
true
|
|
147
|
+
elsif variant&.disabled_override?
|
|
148
|
+
false
|
|
149
|
+
else
|
|
150
|
+
base_enabled
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
Assignment.new(
|
|
154
|
+
variant_name: variant_name,
|
|
155
|
+
configuration_value: variant&.configuration_value,
|
|
156
|
+
enabled: effective_enabled,
|
|
157
|
+
reason: reason
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Assigned variant for a feature, returned by `Client#get_variant`.
|
|
5
|
+
#
|
|
6
|
+
# Assignment is computed locally from the feature's catalog
|
|
7
|
+
# `variants` / `allocation` (see {VariantAllocator}), matching
|
|
8
|
+
# `Microsoft.FeatureManagement`'s `IVariantFeatureManager` bit-for-bit.
|
|
9
|
+
# There is no network round-trip: this is not the `enable_variants`
|
|
10
|
+
# dual-rail from earlier releases (removed; see CHANGELOG).
|
|
11
|
+
class VariantResult
|
|
12
|
+
# @return [String] Variant name
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
# @return [Object, nil] Untyped configuration payload for this variant
|
|
16
|
+
attr_reader :configuration_value
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] Effective enabled flag for this feature/context after
|
|
19
|
+
# the variant's `StatusOverride` is applied. This can differ from
|
|
20
|
+
# `Client#enabled?`, which stays filter-based only; use this field when
|
|
21
|
+
# you need MF-identical `GetVariantAsync`-style effective-enabled
|
|
22
|
+
# semantics (e.g. a variant with `StatusOverride: "Enabled"` on an
|
|
23
|
+
# otherwise-disabled feature).
|
|
24
|
+
attr_reader :enabled
|
|
25
|
+
|
|
26
|
+
# @return [String] Assignment reason: "User" | "Group" | "Percentile" |
|
|
27
|
+
# "DefaultWhenEnabled" | "DefaultWhenDisabled"
|
|
28
|
+
attr_reader :reason
|
|
29
|
+
|
|
30
|
+
def initialize(name:, configuration_value: nil, enabled: true, reason: nil)
|
|
31
|
+
@name = name
|
|
32
|
+
@configuration_value = configuration_value
|
|
33
|
+
@enabled = enabled
|
|
34
|
+
@reason = reason
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Hash]
|
|
38
|
+
def to_h
|
|
39
|
+
{ name: @name, configuration_value: @configuration_value, enabled: @enabled, reason: @reason }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ==(other)
|
|
43
|
+
other.is_a?(VariantResult) &&
|
|
44
|
+
@name == other.name &&
|
|
45
|
+
@configuration_value == other.configuration_value &&
|
|
46
|
+
@enabled == other.enabled &&
|
|
47
|
+
@reason == other.reason
|
|
48
|
+
end
|
|
49
|
+
alias eql? ==
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/toggly/version.rb
CHANGED
data/lib/toggly.rb
CHANGED
|
@@ -9,7 +9,10 @@ require_relative "toggly/sticky_hash"
|
|
|
9
9
|
require_relative "toggly/user_agent_parser"
|
|
10
10
|
require_relative "toggly/context"
|
|
11
11
|
require_relative "toggly/errors"
|
|
12
|
+
require_relative "toggly/feature_variant"
|
|
12
13
|
require_relative "toggly/feature_definition"
|
|
14
|
+
require_relative "toggly/variant_allocator"
|
|
15
|
+
require_relative "toggly/variant_result"
|
|
13
16
|
require_relative "toggly/evaluators/base"
|
|
14
17
|
require_relative "toggly/evaluators/segment_helpers"
|
|
15
18
|
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: 1.0.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.
|
|
@@ -48,6 +48,7 @@ files:
|
|
|
48
48
|
- lib/toggly/evaluators/time_window.rb
|
|
49
49
|
- lib/toggly/evaluators/user_claims.rb
|
|
50
50
|
- lib/toggly/feature_definition.rb
|
|
51
|
+
- lib/toggly/feature_variant.rb
|
|
51
52
|
- lib/toggly/http_request_mapper.rb
|
|
52
53
|
- lib/toggly/registry.rb
|
|
53
54
|
- lib/toggly/request_context.rb
|
|
@@ -66,6 +67,8 @@ files:
|
|
|
66
67
|
- lib/toggly/telemetry/runtime_flush.rb
|
|
67
68
|
- lib/toggly/telemetry/usage_batcher.rb
|
|
68
69
|
- lib/toggly/user_agent_parser.rb
|
|
70
|
+
- lib/toggly/variant_allocator.rb
|
|
71
|
+
- lib/toggly/variant_result.rb
|
|
69
72
|
- lib/toggly/version.rb
|
|
70
73
|
- proto/metrics.proto
|
|
71
74
|
- proto/usage.proto
|