toggly 0.2.0 → 0.3.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 +29 -0
- data/lib/toggly/context.rb +135 -9
- data/lib/toggly/evaluation_engine.rb +4 -3
- data/lib/toggly/evaluators/always_off.rb +5 -1
- data/lib/toggly/evaluators/always_on.rb +5 -1
- data/lib/toggly/evaluators/base.rb +21 -3
- data/lib/toggly/evaluators/browser_family.rb +27 -0
- data/lib/toggly/evaluators/browser_language.rb +26 -0
- data/lib/toggly/evaluators/context_property.rb +5 -1
- data/lib/toggly/evaluators/contextual_targeting.rb +4 -0
- data/lib/toggly/evaluators/country.rb +30 -0
- data/lib/toggly/evaluators/device_type.rb +27 -0
- data/lib/toggly/evaluators/operating_system.rb +31 -0
- data/lib/toggly/evaluators/percentage.rb +11 -49
- data/lib/toggly/evaluators/segment_helpers.rb +98 -0
- data/lib/toggly/evaluators/targeting.rb +61 -14
- data/lib/toggly/evaluators/time_window.rb +14 -9
- data/lib/toggly/evaluators/user_claims.rb +27 -0
- data/lib/toggly/feature_definition.rb +1 -2
- data/lib/toggly/http_request_mapper.rb +64 -0
- data/lib/toggly/registry.rb +25 -6
- data/lib/toggly/request_context.rb +77 -0
- data/lib/toggly/sticky_hash.rb +39 -0
- data/lib/toggly/user_agent_parser.rb +69 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +14 -1
- metadata +14 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b199467e0cb56245072bf1b0bda7b00ddf7931be0c87e691dac159287f1304e
|
|
4
|
+
data.tar.gz: 184d195d8caf6321358d2bda8894f8134d1bd2d5ddc3eca6dfd92c7c7c19595b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddfa9d5bcbf04311a427752c3dba07edb52b841e61f8630d6b9beca6d34d34d1c53b3107f7ca1c9a07f73a8d796278ef2296cd0b723d1ed83be2504277e4449b
|
|
7
|
+
data.tar.gz: 76bf82b4e3e23cdfea1beb0edd735a2be3ae8ce38982c7bfc75dcc00093160666b7372815ea462480590edf7c72dd0ec0b71a2bc11661b0510b8d85c39425c88
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-09-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- EvalContext fields `claims` and `request` (`userAgent`, `acceptLanguage`,
|
|
13
|
+
`country`) plus `Toggly::RequestContext` and `Toggly::HttpRequestMapper`
|
|
14
|
+
(headers → request; country order `cf-ipcountry` → `x-vercel-ip-country` →
|
|
15
|
+
`cloudfront-viewer-country`).
|
|
16
|
+
- Segment filters: `BrowserFamily`, `BrowserLanguage`, `Country` /
|
|
17
|
+
`CountryFamily`, `DeviceType`, `OS` / `OperatingSystem` with indexed params
|
|
18
|
+
and Percentage fail-closed gating.
|
|
19
|
+
- `UserClaims` filter (`Claim` + `Value`).
|
|
20
|
+
- `Microsoft.Percentage`, `Microsoft.TimeWindow`, and `Microsoft.Targeting`
|
|
21
|
+
aliases; golden fixtures under `docs/filter-parity/fixtures/`.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Sticky percentage hashing now uses Definitions / toggly-eval SHA-256
|
|
26
|
+
(`featureKey + "\n" + userId`, little-endian uint32 / `0xFFFFFFFF * 100`)
|
|
27
|
+
instead of FNV-1a. Existing sticky cohorts shift when upgrading from 0.2.x.
|
|
28
|
+
- Unknown filter names fail closed.
|
|
29
|
+
- Percentage missing or `≤0` fails closed (aligned with filter-parity contract).
|
|
30
|
+
|
|
31
|
+
## [0.2.1] - 2026-09-03
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Raise required Ruby version to 3.2+ (matches Gemfile.lock Bundler and CI).
|
|
36
|
+
|
|
8
37
|
## [0.2.0] - 2026-08-21
|
|
9
38
|
|
|
10
39
|
### Added
|
data/lib/toggly/context.rb
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Toggly
|
|
4
|
-
# Evaluation context containing user identity, groups, and traits.
|
|
4
|
+
# Evaluation context containing user identity, groups, claims, request, and traits.
|
|
5
5
|
#
|
|
6
6
|
# @example
|
|
7
7
|
# context = Toggly::Context.new(
|
|
8
8
|
# identity: "user-123",
|
|
9
9
|
# groups: ["beta-testers", "premium"],
|
|
10
|
-
#
|
|
10
|
+
# claims: { "role" => "admin" },
|
|
11
|
+
# request: Toggly::RequestContext.new(country: "US"),
|
|
12
|
+
# traits: { plan: "enterprise" }
|
|
11
13
|
# )
|
|
12
14
|
class Context
|
|
13
15
|
# @return [String, nil] User identity for percentage rollouts and targeting
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
+
# @return [Array<String>] Audience groups
|
|
17
|
+
# @return [Hash{String => Object}] Legacy/custom traits
|
|
18
|
+
# @return [Hash{String => String}] Principal claims for UserClaims filters
|
|
19
|
+
# @return [RequestContext, nil] HTTP request fields for segment filters
|
|
20
|
+
# @return [EntityContext, nil] Optional entity for ContextProperty filters
|
|
21
|
+
attr_reader :identity, :groups, :traits, :claims, :request, :entity
|
|
16
22
|
|
|
17
|
-
def initialize(identity: nil, groups: [], traits: {}, entity: nil)
|
|
23
|
+
def initialize(identity: nil, groups: [], traits: {}, claims: {}, request: nil, entity: nil)
|
|
18
24
|
@identity = identity&.to_s
|
|
19
25
|
@groups = Array(groups).map(&:to_s)
|
|
20
26
|
@traits = normalize_traits(traits)
|
|
27
|
+
@claims = normalize_claims(claims)
|
|
28
|
+
@request = coerce_request(request)
|
|
21
29
|
@entity = entity
|
|
22
30
|
end
|
|
23
31
|
|
|
@@ -36,6 +44,33 @@ module Toggly
|
|
|
36
44
|
new
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
# Create from a hash (EvalContext / fixture style).
|
|
48
|
+
#
|
|
49
|
+
# @param data [Hash]
|
|
50
|
+
# @return [Context]
|
|
51
|
+
def self.from_hash(data)
|
|
52
|
+
return anonymous unless data.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
entity_data = data["entity"] || data[:entity]
|
|
55
|
+
entity = nil
|
|
56
|
+
if entity_data.is_a?(Hash)
|
|
57
|
+
entity = EntityContext.new(
|
|
58
|
+
kind: entity_data["kind"] || entity_data[:kind],
|
|
59
|
+
key: entity_data["key"] || entity_data[:key],
|
|
60
|
+
attributes: entity_data["attributes"] || entity_data[:attributes] || {}
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
new(
|
|
65
|
+
identity: data["identity"] || data[:identity],
|
|
66
|
+
groups: data["groups"] || data[:groups] || [],
|
|
67
|
+
traits: data["traits"] || data[:traits] || {},
|
|
68
|
+
claims: data["claims"] || data[:claims] || {},
|
|
69
|
+
request: RequestContext.from_hash(data["request"] || data[:request]),
|
|
70
|
+
entity: entity
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
39
74
|
# Check if context has an identity
|
|
40
75
|
#
|
|
41
76
|
# @return [Boolean]
|
|
@@ -77,6 +112,8 @@ module Toggly
|
|
|
77
112
|
identity: @identity,
|
|
78
113
|
groups: @groups,
|
|
79
114
|
traits: @traits.merge(normalize_traits(new_traits)),
|
|
115
|
+
claims: @claims,
|
|
116
|
+
request: @request,
|
|
80
117
|
entity: @entity
|
|
81
118
|
)
|
|
82
119
|
end
|
|
@@ -90,18 +127,79 @@ module Toggly
|
|
|
90
127
|
identity: @identity,
|
|
91
128
|
groups: @groups + new_groups.flatten.map(&:to_s),
|
|
92
129
|
traits: @traits,
|
|
130
|
+
claims: @claims,
|
|
131
|
+
request: @request,
|
|
93
132
|
entity: @entity
|
|
94
133
|
)
|
|
95
134
|
end
|
|
96
135
|
|
|
136
|
+
# Create a new context with the specified claims map.
|
|
137
|
+
#
|
|
138
|
+
# @param new_claims [Hash]
|
|
139
|
+
# @return [Context]
|
|
140
|
+
def with_claims(new_claims)
|
|
141
|
+
Context.new(
|
|
142
|
+
identity: @identity,
|
|
143
|
+
groups: @groups,
|
|
144
|
+
traits: @traits,
|
|
145
|
+
claims: new_claims,
|
|
146
|
+
request: @request,
|
|
147
|
+
entity: @entity
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Create a new context with the specified request fields.
|
|
152
|
+
#
|
|
153
|
+
# @param new_request [RequestContext, Hash, nil]
|
|
154
|
+
# @return [Context]
|
|
155
|
+
def with_request(new_request)
|
|
156
|
+
Context.new(
|
|
157
|
+
identity: @identity,
|
|
158
|
+
groups: @groups,
|
|
159
|
+
traits: @traits,
|
|
160
|
+
claims: @claims,
|
|
161
|
+
request: new_request,
|
|
162
|
+
entity: @entity
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Create a new context with the specified entity.
|
|
167
|
+
#
|
|
168
|
+
# @param new_entity [EntityContext, nil]
|
|
169
|
+
# @return [Context]
|
|
170
|
+
def with_entity(new_entity)
|
|
171
|
+
Context.new(
|
|
172
|
+
identity: @identity,
|
|
173
|
+
groups: @groups,
|
|
174
|
+
traits: @traits,
|
|
175
|
+
claims: @claims,
|
|
176
|
+
request: @request,
|
|
177
|
+
entity: new_entity
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
97
181
|
# Convert to hash for serialization
|
|
98
182
|
#
|
|
99
183
|
# @return [Hash]
|
|
100
184
|
def to_h
|
|
185
|
+
entity_hash =
|
|
186
|
+
if @entity.nil?
|
|
187
|
+
nil
|
|
188
|
+
else
|
|
189
|
+
{
|
|
190
|
+
kind: @entity.kind,
|
|
191
|
+
key: @entity.key,
|
|
192
|
+
attributes: @entity.attributes
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
|
|
101
196
|
{
|
|
102
197
|
identity: @identity,
|
|
103
198
|
groups: @groups,
|
|
104
|
-
traits: @traits
|
|
199
|
+
traits: @traits,
|
|
200
|
+
claims: @claims,
|
|
201
|
+
request: @request&.to_h,
|
|
202
|
+
entity: entity_hash
|
|
105
203
|
}
|
|
106
204
|
end
|
|
107
205
|
|
|
@@ -114,7 +212,10 @@ module Toggly
|
|
|
114
212
|
|
|
115
213
|
@identity == other.identity &&
|
|
116
214
|
@groups.sort == other.groups.sort &&
|
|
117
|
-
@traits == other.traits
|
|
215
|
+
@traits == other.traits &&
|
|
216
|
+
@claims == other.claims &&
|
|
217
|
+
@request == other.request &&
|
|
218
|
+
@entity == other.entity
|
|
118
219
|
end
|
|
119
220
|
alias eql? ==
|
|
120
221
|
|
|
@@ -122,14 +223,14 @@ module Toggly
|
|
|
122
223
|
#
|
|
123
224
|
# @return [Integer]
|
|
124
225
|
def hash
|
|
125
|
-
[@identity, @groups.sort, @traits].hash
|
|
226
|
+
[@identity, @groups.sort, @traits, @claims, @request, @entity].hash
|
|
126
227
|
end
|
|
127
228
|
|
|
128
229
|
# Generate cache key
|
|
129
230
|
#
|
|
130
231
|
# @return [String]
|
|
131
232
|
def cache_key
|
|
132
|
-
"#{@identity}:#{@groups.sort.join(",")}:#{traits_cache_key}"
|
|
233
|
+
"#{@identity}:#{@groups.sort.join(",")}:#{traits_cache_key}:#{claims_cache_key}:#{request_cache_key}"
|
|
133
234
|
end
|
|
134
235
|
|
|
135
236
|
private
|
|
@@ -140,8 +241,33 @@ module Toggly
|
|
|
140
241
|
traits.transform_keys(&:to_s)
|
|
141
242
|
end
|
|
142
243
|
|
|
244
|
+
def normalize_claims(claims)
|
|
245
|
+
return {} unless claims.is_a?(Hash)
|
|
246
|
+
|
|
247
|
+
claims.each_with_object({}) do |(key, value), memo|
|
|
248
|
+
memo[key.to_s] = value.to_s
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def coerce_request(request)
|
|
253
|
+
return nil if request.nil?
|
|
254
|
+
return request if request.is_a?(RequestContext)
|
|
255
|
+
|
|
256
|
+
RequestContext.from_hash(request)
|
|
257
|
+
end
|
|
258
|
+
|
|
143
259
|
def traits_cache_key
|
|
144
260
|
@traits.sort.map { |k, v| "#{k}=#{v}" }.join(",")
|
|
145
261
|
end
|
|
262
|
+
|
|
263
|
+
def claims_cache_key
|
|
264
|
+
@claims.sort.map { |k, v| "#{k}=#{v}" }.join(",")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def request_cache_key
|
|
268
|
+
return "" unless @request
|
|
269
|
+
|
|
270
|
+
"#{@request.user_agent}|#{@request.accept_language}|#{@request.country}"
|
|
271
|
+
end
|
|
146
272
|
end
|
|
147
273
|
end
|
|
@@ -97,7 +97,7 @@ module Toggly
|
|
|
97
97
|
def evaluate_filter_group(rules, requirement_type, context, feature_key, result)
|
|
98
98
|
req = (requirement_type || "Any").to_s
|
|
99
99
|
evaluations = rules.map do |rule|
|
|
100
|
-
rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "
|
|
100
|
+
rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
|
|
101
101
|
evaluator = @registry.get(rule_type)
|
|
102
102
|
next false unless evaluator
|
|
103
103
|
|
|
@@ -115,12 +115,13 @@ module Toggly
|
|
|
115
115
|
|
|
116
116
|
def sequential_rules(rules, definition, context, result)
|
|
117
117
|
rules.each_with_index do |rule, index|
|
|
118
|
-
rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "
|
|
118
|
+
rule_type = rule["type"] || rule[:type] || rule["name"] || rule[:name] || "AlwaysOn"
|
|
119
119
|
evaluator = @registry.get(rule_type)
|
|
120
120
|
|
|
121
121
|
unless evaluator
|
|
122
122
|
log_warn("Unknown evaluator type: #{rule_type} for feature #{definition.feature_key}")
|
|
123
|
-
|
|
123
|
+
result&.reason = "unknown_filter"
|
|
124
|
+
return false
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
begin
|
|
@@ -7,7 +7,11 @@ module Toggly
|
|
|
7
7
|
# Used for features that should be disabled for everyone.
|
|
8
8
|
class AlwaysOff < Base
|
|
9
9
|
def self.type
|
|
10
|
-
"
|
|
10
|
+
"AlwaysOff"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.aliases
|
|
14
|
+
%w[always_off]
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
# @param rule [Hash] The rule configuration (ignored)
|
|
@@ -12,6 +12,13 @@ module Toggly
|
|
|
12
12
|
raise NotImplementedError, "Subclass must implement .type"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Optional alternate registration names (e.g. Microsoft.* aliases).
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<String>]
|
|
18
|
+
def self.aliases
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
# Evaluate a rule against a context
|
|
16
23
|
#
|
|
17
24
|
# @param rule [Hash] The rule configuration
|
|
@@ -27,19 +34,30 @@ module Toggly
|
|
|
27
34
|
# @param rule_type [String] The rule type
|
|
28
35
|
# @return [Boolean]
|
|
29
36
|
def handles?(rule_type)
|
|
30
|
-
rule_type
|
|
37
|
+
Registry.normalize_key(rule_type) == Registry.normalize_key(self.class.type)
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
protected
|
|
34
41
|
|
|
35
|
-
# Get a value from rule with fallback
|
|
42
|
+
# Get a value from rule with fallback (supports nested +parameters+).
|
|
36
43
|
#
|
|
37
44
|
# @param rule [Hash] The rule
|
|
38
45
|
# @param key [String, Symbol] The key to lookup
|
|
39
46
|
# @param default [Object] Default value
|
|
40
47
|
# @return [Object]
|
|
41
48
|
def rule_value(rule, key, default = nil)
|
|
42
|
-
|
|
49
|
+
return default unless rule.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
params = rule["parameters"] || rule[:parameters]
|
|
52
|
+
candidates = [rule]
|
|
53
|
+
candidates << params if params.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
candidates.each do |source|
|
|
56
|
+
value = source[key.to_s] || source[key.to_sym] ||
|
|
57
|
+
source.find { |k, _| k.to_s.casecmp?(key.to_s) }&.last
|
|
58
|
+
return value unless value.nil?
|
|
59
|
+
end
|
|
60
|
+
default
|
|
43
61
|
end
|
|
44
62
|
|
|
45
63
|
# Log evaluation result (if logger available)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for BrowserFamily segment filters.
|
|
6
|
+
class BrowserFamily < Base
|
|
7
|
+
def self.type
|
|
8
|
+
"BrowserFamily"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def evaluate(rule, context, feature_key: nil)
|
|
12
|
+
percentage = SegmentHelpers.as_float(rule, "Percentage")
|
|
13
|
+
identity = context&.identity
|
|
14
|
+
return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
|
|
15
|
+
|
|
16
|
+
values = SegmentHelpers.collect_indexed_values(rule, "BrowserFamily")
|
|
17
|
+
return false if values.empty?
|
|
18
|
+
|
|
19
|
+
ua = context&.request&.user_agent
|
|
20
|
+
parsed = UserAgentParser.parse(ua)
|
|
21
|
+
return false if parsed.nil? || parsed.browser_family == "Other"
|
|
22
|
+
|
|
23
|
+
values.any? { |value| SegmentHelpers.contains_ignore_case?(parsed.browser_family, value) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for BrowserLanguage segment filters.
|
|
6
|
+
class BrowserLanguage < Base
|
|
7
|
+
def self.type
|
|
8
|
+
"BrowserLanguage"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def evaluate(rule, context, feature_key: nil)
|
|
12
|
+
percentage = SegmentHelpers.as_float(rule, "Percentage")
|
|
13
|
+
identity = context&.identity
|
|
14
|
+
return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
|
|
15
|
+
|
|
16
|
+
values = SegmentHelpers.collect_indexed_values(rule, "BrowserLanguage")
|
|
17
|
+
return false if values.empty?
|
|
18
|
+
|
|
19
|
+
accept = context&.request&.accept_language
|
|
20
|
+
return false if accept.nil? || accept.empty?
|
|
21
|
+
|
|
22
|
+
values.any? { |value| SegmentHelpers.contains_ignore_case?(accept, value) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -5,7 +5,11 @@ module Toggly
|
|
|
5
5
|
# Evaluator for ContextProperty entity filters. Fail closed.
|
|
6
6
|
class ContextProperty < Base
|
|
7
7
|
def self.type
|
|
8
|
-
"
|
|
8
|
+
"ContextProperty"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.aliases
|
|
12
|
+
%w[contextproperty]
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def evaluate(rule, context, feature_key: nil)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for Country / CountryFamily segment filters.
|
|
6
|
+
class Country < Base
|
|
7
|
+
def self.type
|
|
8
|
+
"Country"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.aliases
|
|
12
|
+
%w[CountryFamily]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def evaluate(rule, context, feature_key: nil)
|
|
16
|
+
percentage = SegmentHelpers.as_float(rule, "Percentage")
|
|
17
|
+
identity = context&.identity
|
|
18
|
+
return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
|
|
19
|
+
|
|
20
|
+
values = SegmentHelpers.collect_indexed_values(rule, "Country")
|
|
21
|
+
return false if values.empty?
|
|
22
|
+
|
|
23
|
+
country = context&.request&.country
|
|
24
|
+
return false if country.nil? || country.empty?
|
|
25
|
+
|
|
26
|
+
values.any? { |value| SegmentHelpers.equals_ignore_case?(value, country) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for DeviceType segment filters.
|
|
6
|
+
class DeviceType < Base
|
|
7
|
+
def self.type
|
|
8
|
+
"DeviceType"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def evaluate(rule, context, feature_key: nil)
|
|
12
|
+
percentage = SegmentHelpers.as_float(rule, "Percentage")
|
|
13
|
+
identity = context&.identity
|
|
14
|
+
return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
|
|
15
|
+
|
|
16
|
+
values = SegmentHelpers.collect_indexed_values(rule, "DeviceType")
|
|
17
|
+
return false if values.empty?
|
|
18
|
+
|
|
19
|
+
ua = context&.request&.user_agent
|
|
20
|
+
parsed = UserAgentParser.parse(ua)
|
|
21
|
+
return false if parsed.nil? || parsed.device_family == "Other"
|
|
22
|
+
|
|
23
|
+
values.any? { |value| SegmentHelpers.contains_ignore_case?(parsed.device_family, value) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
module Evaluators
|
|
5
|
+
# Evaluator for OS / OperatingSystem segment filters.
|
|
6
|
+
class OperatingSystem < Base
|
|
7
|
+
def self.type
|
|
8
|
+
"OperatingSystem"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.aliases
|
|
12
|
+
%w[OS]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def evaluate(rule, context, feature_key: nil)
|
|
16
|
+
percentage = SegmentHelpers.as_float(rule, "Percentage")
|
|
17
|
+
identity = context&.identity
|
|
18
|
+
return false unless StickyHash.segment_percentage_passes?(percentage, feature_key.to_s, identity)
|
|
19
|
+
|
|
20
|
+
values = SegmentHelpers.collect_indexed_values(rule, "OperatingSystem")
|
|
21
|
+
return false if values.empty?
|
|
22
|
+
|
|
23
|
+
ua = context&.request&.user_agent
|
|
24
|
+
parsed = UserAgentParser.parse(ua)
|
|
25
|
+
return false if parsed.nil? || parsed.os_family == "Other"
|
|
26
|
+
|
|
27
|
+
values.any? { |value| SegmentHelpers.contains_ignore_case?(parsed.os_family, value) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -4,68 +4,30 @@ module Toggly
|
|
|
4
4
|
module Evaluators
|
|
5
5
|
# Evaluator for percentage-based rollouts.
|
|
6
6
|
#
|
|
7
|
-
# Uses
|
|
8
|
-
#
|
|
7
|
+
# Uses Definitions-aligned sticky SHA-256 hashing
|
|
8
|
+
# (+feature_key+ + "\n" + +identity+) for consistent buckets.
|
|
9
9
|
class Percentage < Base
|
|
10
|
-
# FNV-1a hash constants (32-bit)
|
|
11
|
-
FNV_PRIME = 0x01000193
|
|
12
|
-
FNV_OFFSET_BASIS = 0x811c9dc5
|
|
13
|
-
|
|
14
10
|
def self.type
|
|
15
|
-
"
|
|
11
|
+
"Percentage"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.aliases
|
|
15
|
+
%w[Microsoft.Percentage percentage]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Evaluate percentage rollout
|
|
19
19
|
#
|
|
20
|
-
# @param rule [Hash] Rule with
|
|
20
|
+
# @param rule [Hash] Rule with Percentage / Value params (0-100)
|
|
21
21
|
# @param context [Context] Evaluation context with identity
|
|
22
22
|
# @param feature_key [String] The feature key
|
|
23
23
|
# @return [Boolean] True if user falls within percentage
|
|
24
24
|
def evaluate(rule, context, feature_key: nil)
|
|
25
|
-
percentage =
|
|
26
|
-
|
|
27
|
-
0
|
|
28
|
-
|
|
29
|
-
percentage = percentage.to_f
|
|
30
|
-
|
|
31
|
-
# 0% always off, 100% always on
|
|
32
|
-
return false if percentage <= 0
|
|
25
|
+
percentage = SegmentHelpers.as_float(rule, "Value", "Percentage", "percentage", "value")
|
|
26
|
+
return false if percentage.nil? || percentage <= 0
|
|
33
27
|
return true if percentage >= 100
|
|
34
|
-
|
|
35
|
-
# Need identity for percentage rollouts
|
|
36
28
|
return false unless context&.identity?
|
|
37
29
|
|
|
38
|
-
|
|
39
|
-
bucket_key = "#{feature_key}:#{context.identity}"
|
|
40
|
-
bucket = calculate_bucket(bucket_key)
|
|
41
|
-
|
|
42
|
-
bucket < percentage
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
# Calculate bucket (0-100) using FNV-1a hash
|
|
48
|
-
#
|
|
49
|
-
# @param key [String] The key to hash
|
|
50
|
-
# @return [Float] Bucket value 0-100
|
|
51
|
-
def calculate_bucket(key)
|
|
52
|
-
hash = fnv1a_hash(key)
|
|
53
|
-
(hash % 10_000) / 100.0
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# FNV-1a hash implementation
|
|
57
|
-
#
|
|
58
|
-
# @param data [String] Data to hash
|
|
59
|
-
# @return [Integer] 32-bit hash value
|
|
60
|
-
def fnv1a_hash(data)
|
|
61
|
-
hash = FNV_OFFSET_BASIS
|
|
62
|
-
|
|
63
|
-
data.each_byte do |byte|
|
|
64
|
-
hash ^= byte
|
|
65
|
-
hash = (hash * FNV_PRIME) & 0xFFFFFFFF
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
hash
|
|
30
|
+
StickyHash.compute_percentile(context.identity, feature_key.to_s) < percentage
|
|
69
31
|
end
|
|
70
32
|
end
|
|
71
33
|
end
|