toggly 0.2.1 → 0.5.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 +57 -0
- data/README.md +34 -2
- data/lib/toggly/client/cache_telemetry.rb +36 -0
- data/lib/toggly/client/snapshot_support.rb +38 -0
- data/lib/toggly/client.rb +173 -51
- data/lib/toggly/config.rb +39 -1
- data/lib/toggly/context.rb +135 -9
- data/lib/toggly/definition_cache.rb +78 -0
- data/lib/toggly/definitions_provider.rb +72 -18
- 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/telemetry/grpc_clients.rb +333 -0
- data/lib/toggly/telemetry/metrics_batcher.rb +140 -0
- data/lib/toggly/telemetry/pb/metrics_pb.rb +28 -0
- data/lib/toggly/telemetry/pb/metrics_services_pb.rb +28 -0
- data/lib/toggly/telemetry/pb/usage_pb.rb +27 -0
- data/lib/toggly/telemetry/pb/usage_services_pb.rb +28 -0
- data/lib/toggly/telemetry/runtime.rb +262 -0
- data/lib/toggly/telemetry/runtime_flush.rb +89 -0
- data/lib/toggly/telemetry/usage_batcher.rb +277 -0
- data/lib/toggly/telemetry.rb +37 -0
- data/lib/toggly/user_agent_parser.rb +69 -0
- data/lib/toggly/version.rb +1 -1
- data/lib/toggly.rb +16 -1
- data/proto/metrics.proto +60 -0
- data/proto/usage.proto +55 -0
- metadata +29 -3
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
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Toggly
|
|
4
|
+
# Shared definition-refresh cache helpers (hit/miss classification).
|
|
5
|
+
module DefinitionCache
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Normalize an ETag/revision for comparison (strip weak prefix + quotes).
|
|
9
|
+
#
|
|
10
|
+
# @param etag [String, nil]
|
|
11
|
+
# @return [String, nil]
|
|
12
|
+
def normalize_etag(etag)
|
|
13
|
+
return nil if etag.nil?
|
|
14
|
+
|
|
15
|
+
trimmed = etag.to_s.strip
|
|
16
|
+
return nil if trimmed.empty?
|
|
17
|
+
|
|
18
|
+
trimmed = trimmed[2..].to_s.strip if trimmed.length >= 2 && %w[W w].include?(trimmed[0]) && trimmed[1] == "/"
|
|
19
|
+
return trimmed[1...-1] if trimmed.length >= 2 && trimmed.start_with?('"') && trimmed.end_with?('"')
|
|
20
|
+
|
|
21
|
+
trimmed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param left [String, nil]
|
|
25
|
+
# @param right [String, nil]
|
|
26
|
+
# @return [Boolean]
|
|
27
|
+
def etags_match?(left, right)
|
|
28
|
+
a = normalize_etag(left)
|
|
29
|
+
b = normalize_etag(right)
|
|
30
|
+
!a.nil? && !b.nil? && a == b
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param left [String, nil]
|
|
34
|
+
# @param right [String, nil]
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def last_modified_match?(left, right)
|
|
37
|
+
a = left&.to_s&.strip
|
|
38
|
+
b = right&.to_s&.strip
|
|
39
|
+
!a.nil? && !b.nil? && !a.empty? && a == b
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Match Go isCachedRevision: currentTS > 0 && incomingTS <= currentTS → hit.
|
|
43
|
+
#
|
|
44
|
+
# @param current_ts [Integer, nil]
|
|
45
|
+
# @param incoming_ts [Integer, nil]
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def cached_signed_timestamp?(current_ts, incoming_ts)
|
|
48
|
+
return false if incoming_ts.nil?
|
|
49
|
+
|
|
50
|
+
current = current_ts.to_i
|
|
51
|
+
incoming = incoming_ts.to_i
|
|
52
|
+
current.positive? && incoming <= current
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Classify an HTTP definitions response for cache telemetry.
|
|
56
|
+
#
|
|
57
|
+
# @param status_code [Integer]
|
|
58
|
+
# @param existing_etag [String, nil]
|
|
59
|
+
# @param response_etag [String, nil]
|
|
60
|
+
# @param existing_last_modified [String, nil]
|
|
61
|
+
# @param response_last_modified [String, nil]
|
|
62
|
+
# @return [Symbol] :not_modified, :same_revision, :new_content, or :error_status
|
|
63
|
+
def classify_http(
|
|
64
|
+
status_code,
|
|
65
|
+
existing_etag,
|
|
66
|
+
response_etag,
|
|
67
|
+
existing_last_modified: nil,
|
|
68
|
+
response_last_modified: nil
|
|
69
|
+
)
|
|
70
|
+
return :not_modified if status_code == 304
|
|
71
|
+
return :error_status if status_code != 200
|
|
72
|
+
return :same_revision if etags_match?(existing_etag, response_etag)
|
|
73
|
+
return :same_revision if last_modified_match?(existing_last_modified, response_last_modified)
|
|
74
|
+
|
|
75
|
+
:new_content
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -14,6 +14,9 @@ end
|
|
|
14
14
|
module Toggly
|
|
15
15
|
# Provider for fetching feature definitions from Toggly API.
|
|
16
16
|
class DefinitionsProvider
|
|
17
|
+
# Result of one HTTP definitions fetch with cache telemetry outcome.
|
|
18
|
+
FetchResult = Struct.new(:definitions, :cache_outcome, keyword_init: true)
|
|
19
|
+
|
|
17
20
|
# Fallback HTTP refresh interval when WebSocket is connected (20 minutes)
|
|
18
21
|
FALLBACK_REFRESH_INTERVAL = 20 * 60
|
|
19
22
|
|
|
@@ -32,6 +35,7 @@ module Toggly
|
|
|
32
35
|
@on_definitions_updated = on_definitions_updated
|
|
33
36
|
@etag = nil
|
|
34
37
|
@last_modified = nil
|
|
38
|
+
@last_ts = 0
|
|
35
39
|
|
|
36
40
|
# WebSocket state
|
|
37
41
|
@ws = nil
|
|
@@ -44,11 +48,11 @@ module Toggly
|
|
|
44
48
|
# Fetch definitions from the API
|
|
45
49
|
#
|
|
46
50
|
# @param force [Boolean] Force fetch even if cached
|
|
47
|
-
# @return [
|
|
51
|
+
# @return [FetchResult] definitions (Hash or nil) plus :hit / :miss outcome
|
|
48
52
|
# @raise [NetworkError] On network failures
|
|
49
53
|
# @raise [DefinitionsError] On API errors
|
|
50
54
|
def fetch(force: false)
|
|
51
|
-
return nil if @config.offline_mode?
|
|
55
|
+
return FetchResult.new(definitions: nil, cache_outcome: :hit) if @config.offline_mode?
|
|
52
56
|
|
|
53
57
|
uri = URI.parse(@config.definitions_endpoint)
|
|
54
58
|
http = build_http(uri)
|
|
@@ -60,6 +64,8 @@ module Toggly
|
|
|
60
64
|
raise NetworkError, "Request timeout: #{e.message}"
|
|
61
65
|
rescue SocketError, Errno::ECONNREFUSED => e
|
|
62
66
|
raise NetworkError, "Connection failed: #{e.message}"
|
|
67
|
+
rescue NetworkError, DefinitionsError
|
|
68
|
+
raise
|
|
63
69
|
rescue StandardError => e
|
|
64
70
|
raise NetworkError, "Request failed: #{e.message}"
|
|
65
71
|
end
|
|
@@ -68,6 +74,7 @@ module Toggly
|
|
|
68
74
|
def reset_cache
|
|
69
75
|
@etag = nil
|
|
70
76
|
@last_modified = nil
|
|
77
|
+
@last_ts = 0
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
# Check whether the periodic refresh should be skipped because the
|
|
@@ -166,32 +173,81 @@ module Toggly
|
|
|
166
173
|
end
|
|
167
174
|
|
|
168
175
|
def handle_response(response)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
status = response.code.to_i
|
|
177
|
+
response_etag = response["ETag"]
|
|
178
|
+
response_lm = response["Last-Modified"]
|
|
179
|
+
kind = DefinitionCache.classify_http(
|
|
180
|
+
status,
|
|
181
|
+
@etag,
|
|
182
|
+
response_etag,
|
|
183
|
+
existing_last_modified: @last_modified,
|
|
184
|
+
response_last_modified: response_lm
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
case kind
|
|
188
|
+
when :not_modified
|
|
174
189
|
log_debug("Definitions not modified")
|
|
175
|
-
nil
|
|
190
|
+
FetchResult.new(definitions: nil, cache_outcome: :hit)
|
|
191
|
+
when :same_revision
|
|
192
|
+
log_debug("Definitions revision matches existing (ETag or Last-Modified)")
|
|
193
|
+
store_revision_headers(response_etag, response_lm)
|
|
194
|
+
FetchResult.new(definitions: nil, cache_outcome: :hit)
|
|
195
|
+
when :new_content
|
|
196
|
+
handle_new_content(response, response_etag, response_lm)
|
|
197
|
+
when :error_status
|
|
198
|
+
handle_error_status(status, response)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def handle_new_content(response, response_etag, response_lm)
|
|
203
|
+
data = JSON.parse(response.body)
|
|
204
|
+
signed_ts = extract_signed_timestamp(data)
|
|
205
|
+
if DefinitionCache.cached_signed_timestamp?(@last_ts, signed_ts)
|
|
206
|
+
log_debug("Definitions signed timestamp is not newer than cached revision")
|
|
207
|
+
store_revision_headers(response_etag, response_lm)
|
|
208
|
+
return FetchResult.new(definitions: nil, cache_outcome: :hit)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
definitions = parse_features(data)
|
|
212
|
+
store_revision_headers(response_etag, response_lm)
|
|
213
|
+
@last_ts = signed_ts if signed_ts&.positive?
|
|
214
|
+
FetchResult.new(definitions: definitions, cache_outcome: :miss)
|
|
215
|
+
rescue JSON::ParserError => e
|
|
216
|
+
raise DefinitionsError, "Failed to parse definitions: #{e.message}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def handle_error_status(status, response)
|
|
220
|
+
case status
|
|
176
221
|
when 401, 403
|
|
177
|
-
raise DefinitionsError, "Authentication failed: #{
|
|
222
|
+
raise DefinitionsError, "Authentication failed: #{status}"
|
|
178
223
|
when 404
|
|
179
224
|
raise DefinitionsError, "Definitions not found (check app_key and environment)"
|
|
180
225
|
else
|
|
181
226
|
raise NetworkError.new(
|
|
182
|
-
"API error: #{
|
|
183
|
-
status_code:
|
|
227
|
+
"API error: #{status}",
|
|
228
|
+
status_code: status,
|
|
184
229
|
response_body: response.body
|
|
185
230
|
)
|
|
186
231
|
end
|
|
187
232
|
end
|
|
188
233
|
|
|
189
|
-
def
|
|
190
|
-
|
|
191
|
-
@
|
|
192
|
-
|
|
234
|
+
def store_revision_headers(etag, last_modified)
|
|
235
|
+
@etag = etag if etag && !etag.empty?
|
|
236
|
+
@last_modified = last_modified if last_modified && !last_modified.empty?
|
|
237
|
+
end
|
|
193
238
|
|
|
194
|
-
|
|
239
|
+
def extract_signed_timestamp(data)
|
|
240
|
+
return nil unless data.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
raw = data["timestamp"]
|
|
243
|
+
return nil if raw.nil?
|
|
244
|
+
|
|
245
|
+
Integer(raw)
|
|
246
|
+
rescue ArgumentError, TypeError
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def parse_features(data)
|
|
195
251
|
features = if data.is_a?(Hash)
|
|
196
252
|
data["defs"] || data["features"] || data
|
|
197
253
|
else
|
|
@@ -209,8 +265,6 @@ module Toggly
|
|
|
209
265
|
else
|
|
210
266
|
raise DefinitionsError, "Invalid definitions format"
|
|
211
267
|
end
|
|
212
|
-
rescue JSON::ParserError => e
|
|
213
|
-
raise DefinitionsError, "Failed to parse definitions: #{e.message}"
|
|
214
268
|
end
|
|
215
269
|
|
|
216
270
|
def build_websocket_url
|
|
@@ -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
|