toggly 0.6.0 → 1.1.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 +47 -0
- data/README.md +28 -0
- data/lib/toggly/client/snapshot_support.rb +5 -39
- data/lib/toggly/client.rb +80 -80
- data/lib/toggly/config.rb +30 -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 +26 -1
- metadata +5 -3
- 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: d728557cdb56574a22377e85047ec649a801532f9cea3574ae66ccba703e320e
|
|
4
|
+
data.tar.gz: 5054b15ad5ad1b48fde5c6fb174698c701bd8fa38b6054cc8afd6ff015e196ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8871e715209c606026d37367ec46522c3bbcc7925633f9f511bad51179f141924cb4b7e123978212e1a1697050072d0bfacff0463e53cf02510012cb28531db1
|
|
7
|
+
data.tar.gz: c3536488d775b49aeefa375f72f3c6b88a993fb4b6464a19e8c5ae73bde75222c310bc820e563f94039509bd6dbbc698ddb04c33cd89de6beba2e7c1ac29dd56
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ 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.1.0] - 2026-09-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Ambient targeting identity for catalog-local variants: `Config#identity`,
|
|
13
|
+
`Client#identity` / `Client#set_identity`. When `get_variant` /
|
|
14
|
+
`get_variant_value` are called with a nil context or a blank
|
|
15
|
+
`context.identity`, the client identity is used as the allocator
|
|
16
|
+
`userId`. Groups still come only from the per-call context (never Config).
|
|
17
|
+
Prefer config / `set_identity` (or Rails ambient context) over building
|
|
18
|
+
`Context.new(identity: …)` at every call site.
|
|
19
|
+
`Config#identity=` is configuration-time only (`Toggly.configure` /
|
|
20
|
+
`Config.new`); after `Client` is built, use `Client#set_identity`.
|
|
21
|
+
|
|
22
|
+
## [1.0.0] - 2026-09-23
|
|
23
|
+
|
|
24
|
+
### Changed (Breaking)
|
|
25
|
+
|
|
26
|
+
- **Catalog-local, MF-parity feature variants**, replacing the
|
|
27
|
+
`enable_variants` / `evaluated-variants-signed` dual-rail from 0.6.0.
|
|
28
|
+
`Client#get_variant` / `#get_variant_value` now assign variants **locally**
|
|
29
|
+
from the same `variants` / `allocation` payload on the `definitions` /
|
|
30
|
+
`definitions-signed` catalog that drives `enabled?` — there is no separate
|
|
31
|
+
network call. Assignment matches `Microsoft.FeatureManagement` 4.7.0's
|
|
32
|
+
`IVariantFeatureManager` bit-for-bit (user → group → percentile → default,
|
|
33
|
+
`StatusOverride`, percentile SHA-256 hashing) and is verified against the
|
|
34
|
+
shared `variant-allocator-corpus/cases.json` gold corpus (100% pass).
|
|
35
|
+
- `get_variant(feature_key, context: nil)` / `get_variant_value(feature_key,
|
|
36
|
+
context: nil)` now take the same `context:` (`userId` + `groups`) used by
|
|
37
|
+
`enabled?`, instead of a client-wide `variant_identity`.
|
|
38
|
+
- `VariantResult` gains `enabled` (effective enabled after the assigned
|
|
39
|
+
variant's `StatusOverride` — `enabled?` itself stays filter-based only)
|
|
40
|
+
and `reason` (`"User"` | `"Group"` | `"Percentile"` |
|
|
41
|
+
`"DefaultWhenEnabled"` | `"DefaultWhenDisabled"`).
|
|
42
|
+
- New `FeatureDefinition#variants` / `#allocation` (`FeatureVariant`,
|
|
43
|
+
`FeatureVariantAllocation`), parsed from the `definitions` wire.
|
|
44
|
+
- **Removed**: `Config#enable_variants` / `#variant_identity` /
|
|
45
|
+
`#variant_groups` / `#variant_claims` / `#variants_endpoint`,
|
|
46
|
+
`Client#variant_defs` / `#set_variant_identity`, `EvaluatedVariantDef`,
|
|
47
|
+
and the `evaluated-variants-signed` fetch/cache rail (including the
|
|
48
|
+
`SnapshotProviders` `save_variants` / `load_variants` hooks — variants are
|
|
49
|
+
now part of the ordinary definitions snapshot via `FeatureDefinition`).
|
|
50
|
+
|
|
51
|
+
**Migration**: drop `enable_variants` / `variant_identity` /
|
|
52
|
+
`set_variant_identity` from your config. Pass targeting via
|
|
53
|
+
`get_variant(key, context: Toggly::Context.new(identity: ..., groups: ...))`.
|
|
54
|
+
|
|
8
55
|
## [0.6.0] - 2026-09-22
|
|
9
56
|
|
|
10
57
|
### Added
|
data/README.md
CHANGED
|
@@ -38,6 +38,34 @@ 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
|
+
# Prefer ambient identity: Config#identity, Client#set_identity, or Rails
|
|
51
|
+
# feature_variant (uses toggly_context). Per-call context is an override.
|
|
52
|
+
client = Toggly::Client.new(app_key: '...', environment: 'Production', identity: 'user-1')
|
|
53
|
+
client.set_identity('user-2') # optional runtime update
|
|
54
|
+
|
|
55
|
+
variant = client.get_variant('checkout-flow') # uses client identity
|
|
56
|
+
variant = client.get_variant('checkout-flow', context: Toggly::Context.new(identity: 'override'))
|
|
57
|
+
variant&.name # assigned variant name, or nil
|
|
58
|
+
variant&.configuration_value # untyped configuration payload
|
|
59
|
+
variant&.enabled # effective enabled after StatusOverride
|
|
60
|
+
variant&.reason # "User" | "Group" | "Percentile" | "DefaultWhenEnabled" | "DefaultWhenDisabled"
|
|
61
|
+
|
|
62
|
+
client.get_variant_value('checkout-flow') # shortcut for configuration_value
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`enabled?` stays filter-based only. The `enable_variants` /
|
|
66
|
+
`evaluated-variants-signed` dual-rail (and `set_variant_identity`) was
|
|
67
|
+
removed in 1.0 — see [CHANGELOG.md](CHANGELOG.md).
|
|
68
|
+
|
|
41
69
|
## Usage & metrics telemetry
|
|
42
70
|
|
|
43
71
|
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,10 +44,10 @@ 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
|
|
50
|
+
@identity = normalize_client_identity(@config.identity)
|
|
55
51
|
@ready = false
|
|
56
52
|
@closed = false
|
|
57
53
|
|
|
@@ -85,10 +81,9 @@ module Toggly
|
|
|
85
81
|
|
|
86
82
|
definition = @mutex.synchronize { @definitions[key] }
|
|
87
83
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# only by get_variant / get_variant_value and never override this.
|
|
84
|
+
# `enabled?` stays filter-based only (definitions/definitions-signed).
|
|
85
|
+
# It never reflects a variant's StatusOverride — see `get_variant`
|
|
86
|
+
# (`VariantResult#enabled`) for MF-identical effective-enabled semantics.
|
|
92
87
|
result = if definition.nil?
|
|
93
88
|
if !default.nil?
|
|
94
89
|
default
|
|
@@ -117,52 +112,81 @@ module Toggly
|
|
|
117
112
|
!enabled?(feature_key, context: context, default: default.nil? ? nil : !default)
|
|
118
113
|
end
|
|
119
114
|
|
|
120
|
-
# Get the assigned variant for a feature
|
|
121
|
-
#
|
|
115
|
+
# Get the assigned variant for a feature, computed locally from the
|
|
116
|
+
# feature's catalog `variants` / `allocation` (MF-parity, bit-for-bit
|
|
117
|
+
# with `Microsoft.FeatureManagement`'s `IVariantFeatureManager`). Returns
|
|
118
|
+
# nil when the feature is unknown, has no variants configured, or no
|
|
119
|
+
# variant resolves for this context (see {VariantAllocator}).
|
|
120
|
+
#
|
|
121
|
+
# There is no network round-trip here — this superseded the
|
|
122
|
+
# `enable_variants` / `evaluated-variants-signed` dual-rail removed in
|
|
123
|
+
# 1.0 (see CHANGELOG).
|
|
124
|
+
#
|
|
125
|
+
# Targeting identity resolution (first non-empty wins):
|
|
126
|
+
# 1. per-call `context.identity`
|
|
127
|
+
# 2. {#identity} from Config / {#set_identity}
|
|
128
|
+
# 3. empty (MF anonymous)
|
|
129
|
+
#
|
|
130
|
+
# Groups come only from the per-call context (never Config). Prefer
|
|
131
|
+
# Rails `feature_variant` / ambient `toggly_context`, or
|
|
132
|
+
# `set_identity` / `Config#identity` for non-HTTP hosts — not a
|
|
133
|
+
# hand-built identity on every call.
|
|
122
134
|
#
|
|
123
135
|
# NOTE: this is the actual A/B assignment. It is unrelated to the
|
|
124
136
|
# `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.
|
|
137
|
+
# a free-form usage tag (defaults to "enabled"/"disabled").
|
|
127
138
|
#
|
|
128
139
|
# @param feature_key [String, Symbol] The feature key
|
|
140
|
+
# @param context [Context, nil] Optional targeting context (userId + groups)
|
|
129
141
|
# @return [VariantResult, nil]
|
|
130
|
-
def get_variant(feature_key)
|
|
131
|
-
return nil unless @config.enable_variants
|
|
132
|
-
|
|
142
|
+
def get_variant(feature_key, context: nil)
|
|
133
143
|
key = feature_key.to_s
|
|
134
|
-
|
|
135
|
-
return nil if
|
|
144
|
+
definition = @mutex.synchronize { @definitions[key] }
|
|
145
|
+
return nil if definition.nil?
|
|
146
|
+
|
|
147
|
+
enabled = @engine.evaluate(definition, context)
|
|
148
|
+
assignment = VariantAllocator.assign(
|
|
149
|
+
definition,
|
|
150
|
+
enabled: enabled,
|
|
151
|
+
identity: variant_user_id(context),
|
|
152
|
+
groups: context&.groups || []
|
|
153
|
+
)
|
|
154
|
+
return nil if assignment.variant_name.nil?
|
|
136
155
|
|
|
137
|
-
VariantResult.new(
|
|
156
|
+
VariantResult.new(
|
|
157
|
+
name: assignment.variant_name,
|
|
158
|
+
configuration_value: assignment.configuration_value,
|
|
159
|
+
enabled: assignment.enabled,
|
|
160
|
+
reason: assignment.reason
|
|
161
|
+
)
|
|
138
162
|
end
|
|
139
163
|
|
|
140
164
|
# Get the configuration value for the assigned variant, if any.
|
|
141
165
|
#
|
|
142
166
|
# @param feature_key [String, Symbol] The feature key
|
|
167
|
+
# @param context [Context, nil] Optional targeting context (userId + groups)
|
|
143
168
|
# @return [Object, nil]
|
|
144
|
-
def get_variant_value(feature_key)
|
|
145
|
-
get_variant(feature_key)&.configuration_value
|
|
169
|
+
def get_variant_value(feature_key, context: nil)
|
|
170
|
+
get_variant(feature_key, context: context)&.configuration_value
|
|
146
171
|
end
|
|
147
172
|
|
|
148
|
-
#
|
|
149
|
-
#
|
|
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.
|
|
173
|
+
# Default targeting userId used by {#get_variant} / {#get_variant_value}
|
|
174
|
+
# when the per-call context has no identity.
|
|
153
175
|
#
|
|
154
|
-
# @
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
176
|
+
# @return [String, nil]
|
|
177
|
+
def identity
|
|
178
|
+
@mutex.synchronize { @identity }
|
|
179
|
+
end
|
|
164
180
|
|
|
165
|
-
|
|
181
|
+
# Set the default targeting userId (overrides Config#identity at runtime).
|
|
182
|
+
# Pass +nil+ or an empty string to clear.
|
|
183
|
+
#
|
|
184
|
+
# Named +set_identity+ (not +identity=+) to match Python/Java/Go SDK APIs.
|
|
185
|
+
#
|
|
186
|
+
# @param value [String, nil]
|
|
187
|
+
# @return [String, nil]
|
|
188
|
+
def set_identity(value) # rubocop:disable Naming/AccessorMethodName
|
|
189
|
+
@mutex.synchronize { @identity = normalize_client_identity(value) }
|
|
166
190
|
end
|
|
167
191
|
|
|
168
192
|
# Get detailed evaluation result
|
|
@@ -217,15 +241,7 @@ module Toggly
|
|
|
217
241
|
end
|
|
218
242
|
|
|
219
243
|
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
|
|
244
|
+
refresh_definitions_rail(force: force)
|
|
229
245
|
ensure
|
|
230
246
|
drain_pending = false
|
|
231
247
|
@mutex.synchronize do
|
|
@@ -334,9 +350,9 @@ module Toggly
|
|
|
334
350
|
|
|
335
351
|
private
|
|
336
352
|
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
353
|
+
# `definitions` / `definitions-signed` → local rule eval. The sole
|
|
354
|
+
# source of truth for `enabled?`; also carries `variants` / `allocation`
|
|
355
|
+
# for the catalog-local `get_variant` assignment.
|
|
340
356
|
def refresh_definitions_rail(force:)
|
|
341
357
|
refresh_definitions(force: force)
|
|
342
358
|
rescue StandardError => e
|
|
@@ -365,36 +381,6 @@ module Toggly
|
|
|
365
381
|
end
|
|
366
382
|
end
|
|
367
383
|
|
|
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
384
|
def initialize_definitions
|
|
399
385
|
# Startup served from durable snapshot before first network — cache hit.
|
|
400
386
|
# Distinct from the subsequent refresh() network outcome (no double-count
|
|
@@ -419,7 +405,7 @@ module Toggly
|
|
|
419
405
|
log_error("Failed to initialize definitions: #{e.message}")
|
|
420
406
|
|
|
421
407
|
# Use snapshot or defaults as fallback
|
|
422
|
-
@ready = true if @definitions.any? || @
|
|
408
|
+
@ready = true if @definitions.any? || @config.defaults.any?
|
|
423
409
|
end
|
|
424
410
|
|
|
425
411
|
def start_background_refresh
|
|
@@ -496,6 +482,20 @@ module Toggly
|
|
|
496
482
|
@telemetry.record_check(feature_key, enabled, identity)
|
|
497
483
|
end
|
|
498
484
|
|
|
485
|
+
# Allocator userId: per-call context identity when present, else client identity.
|
|
486
|
+
def variant_user_id(context)
|
|
487
|
+
return context.identity if context&.identity?
|
|
488
|
+
|
|
489
|
+
identity
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def normalize_client_identity(value)
|
|
493
|
+
return nil if value.nil?
|
|
494
|
+
|
|
495
|
+
text = value.to_s
|
|
496
|
+
text.empty? ? nil : text
|
|
497
|
+
end
|
|
498
|
+
|
|
499
499
|
def log_info(message)
|
|
500
500
|
@config.logger&.info("[Toggly] #{message}")
|
|
501
501
|
end
|
data/lib/toggly/config.rb
CHANGED
|
@@ -12,6 +12,17 @@ module Toggly
|
|
|
12
12
|
# @return [String] Application key from Toggly dashboard
|
|
13
13
|
attr_reader :app_key
|
|
14
14
|
|
|
15
|
+
# Default targeting userId for catalog-local +get_variant+ /
|
|
16
|
+
# +get_variant_value+ when neither a per-call +Context#identity+ nor
|
|
17
|
+
# Rails ambient context supplies one. Groups are never taken from Config.
|
|
18
|
+
#
|
|
19
|
+
# Set at configuration time only ({Config.new}, +Toggly.configure+ block).
|
|
20
|
+
# After a {Client} is built, mutating this value has no effect — use
|
|
21
|
+
# {Client#set_identity} for runtime updates.
|
|
22
|
+
#
|
|
23
|
+
# @return [String, nil]
|
|
24
|
+
attr_reader :identity
|
|
25
|
+
|
|
15
26
|
# @return [String] Environment name (e.g., "Production", "Staging")
|
|
16
27
|
attr_accessor :environment
|
|
17
28
|
|
|
@@ -55,25 +66,6 @@ module Toggly
|
|
|
55
66
|
# @return [Array<String>] Allowed key IDs for signed definitions
|
|
56
67
|
attr_accessor :allowed_key_ids
|
|
57
68
|
|
|
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
69
|
# @return [Logger, nil] Logger instance
|
|
78
70
|
attr_accessor :logger
|
|
79
71
|
|
|
@@ -108,6 +100,7 @@ module Toggly
|
|
|
108
100
|
|
|
109
101
|
def initialize(**options)
|
|
110
102
|
@app_key = options[:app_key]
|
|
103
|
+
@identity = normalize_identity(options[:identity])
|
|
111
104
|
@environment = options[:environment] || DEFAULT_ENVIRONMENT
|
|
112
105
|
@base_url = normalize_url(options[:base_url] || DEFAULT_BASE_URL)
|
|
113
106
|
@definitions_url = options[:definitions_url]
|
|
@@ -123,10 +116,6 @@ module Toggly
|
|
|
123
116
|
@snapshot_provider = options[:snapshot_provider]
|
|
124
117
|
@use_signed_definitions = options[:use_signed_definitions] || false
|
|
125
118
|
@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
119
|
@logger = options[:logger]
|
|
131
120
|
|
|
132
121
|
@usage_tracking_explicit = options.key?(:enable_usage_tracking)
|
|
@@ -150,16 +139,6 @@ module Toggly
|
|
|
150
139
|
"#{normalize_url(base)}#{endpoint}/#{@app_key}/#{@environment}"
|
|
151
140
|
end
|
|
152
141
|
|
|
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
142
|
# Validate the configuration
|
|
164
143
|
#
|
|
165
144
|
# @raise [ConfigError] if configuration is invalid
|
|
@@ -205,12 +184,23 @@ module Toggly
|
|
|
205
184
|
(@app_key.nil? || @app_key.empty?) && !@defaults.empty?
|
|
206
185
|
end
|
|
207
186
|
|
|
187
|
+
# Configuration-time identity only (e.g. inside +Toggly.configure+ before
|
|
188
|
+
# {Client} is constructed). Does not update an existing client's targeting
|
|
189
|
+
# identity — call {Client#set_identity} after initialization.
|
|
190
|
+
#
|
|
191
|
+
# @param value [String, nil]
|
|
192
|
+
# @return [String, nil]
|
|
193
|
+
def identity=(value)
|
|
194
|
+
@identity = normalize_identity(value)
|
|
195
|
+
end
|
|
196
|
+
|
|
208
197
|
# Convert to hash
|
|
209
198
|
#
|
|
210
199
|
# @return [Hash]
|
|
211
200
|
def to_h
|
|
212
201
|
{
|
|
213
202
|
app_key: @app_key,
|
|
203
|
+
identity: @identity,
|
|
214
204
|
environment: @environment,
|
|
215
205
|
base_url: @base_url,
|
|
216
206
|
definitions_url: @definitions_url,
|
|
@@ -219,7 +209,6 @@ module Toggly
|
|
|
219
209
|
enable_undefined_in_dev: @enable_undefined_in_dev,
|
|
220
210
|
disable_background_refresh: @disable_background_refresh,
|
|
221
211
|
enable_live_updates: @enable_live_updates,
|
|
222
|
-
enable_variants: @enable_variants,
|
|
223
212
|
app_version: @app_version,
|
|
224
213
|
instance_name: @instance_name,
|
|
225
214
|
use_signed_definitions: @use_signed_definitions,
|
|
@@ -242,5 +231,12 @@ module Toggly
|
|
|
242
231
|
|
|
243
232
|
url.end_with?("/") ? url : "#{url}/"
|
|
244
233
|
end
|
|
234
|
+
|
|
235
|
+
def normalize_identity(value)
|
|
236
|
+
return nil if value.nil?
|
|
237
|
+
|
|
238
|
+
text = value.to_s
|
|
239
|
+
text.empty? ? nil : text
|
|
240
|
+
end
|
|
245
241
|
end
|
|
246
242
|
end
|