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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -0
  3. data/README.md +34 -2
  4. data/lib/toggly/client/cache_telemetry.rb +36 -0
  5. data/lib/toggly/client/snapshot_support.rb +38 -0
  6. data/lib/toggly/client.rb +173 -51
  7. data/lib/toggly/config.rb +39 -1
  8. data/lib/toggly/context.rb +135 -9
  9. data/lib/toggly/definition_cache.rb +78 -0
  10. data/lib/toggly/definitions_provider.rb +72 -18
  11. data/lib/toggly/evaluation_engine.rb +4 -3
  12. data/lib/toggly/evaluators/always_off.rb +5 -1
  13. data/lib/toggly/evaluators/always_on.rb +5 -1
  14. data/lib/toggly/evaluators/base.rb +21 -3
  15. data/lib/toggly/evaluators/browser_family.rb +27 -0
  16. data/lib/toggly/evaluators/browser_language.rb +26 -0
  17. data/lib/toggly/evaluators/context_property.rb +5 -1
  18. data/lib/toggly/evaluators/contextual_targeting.rb +4 -0
  19. data/lib/toggly/evaluators/country.rb +30 -0
  20. data/lib/toggly/evaluators/device_type.rb +27 -0
  21. data/lib/toggly/evaluators/operating_system.rb +31 -0
  22. data/lib/toggly/evaluators/percentage.rb +11 -49
  23. data/lib/toggly/evaluators/segment_helpers.rb +98 -0
  24. data/lib/toggly/evaluators/targeting.rb +61 -14
  25. data/lib/toggly/evaluators/time_window.rb +14 -9
  26. data/lib/toggly/evaluators/user_claims.rb +27 -0
  27. data/lib/toggly/feature_definition.rb +1 -2
  28. data/lib/toggly/http_request_mapper.rb +64 -0
  29. data/lib/toggly/registry.rb +25 -6
  30. data/lib/toggly/request_context.rb +77 -0
  31. data/lib/toggly/sticky_hash.rb +39 -0
  32. data/lib/toggly/telemetry/grpc_clients.rb +333 -0
  33. data/lib/toggly/telemetry/metrics_batcher.rb +140 -0
  34. data/lib/toggly/telemetry/pb/metrics_pb.rb +28 -0
  35. data/lib/toggly/telemetry/pb/metrics_services_pb.rb +28 -0
  36. data/lib/toggly/telemetry/pb/usage_pb.rb +27 -0
  37. data/lib/toggly/telemetry/pb/usage_services_pb.rb +28 -0
  38. data/lib/toggly/telemetry/runtime.rb +262 -0
  39. data/lib/toggly/telemetry/runtime_flush.rb +89 -0
  40. data/lib/toggly/telemetry/usage_batcher.rb +277 -0
  41. data/lib/toggly/telemetry.rb +37 -0
  42. data/lib/toggly/user_agent_parser.rb +69 -0
  43. data/lib/toggly/version.rb +1 -1
  44. data/lib/toggly.rb +16 -1
  45. data/proto/metrics.proto +60 -0
  46. data/proto/usage.proto +55 -0
  47. metadata +29 -3
@@ -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
- # traits: { country: "US", plan: "enterprise" }
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
- # identity, groups, traits, and optional entity for ContextProperty filters
15
- attr_reader :identity, :groups, :traits, :entity
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 [Hash, nil] Hash of definitions, or nil if not modified
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
- case response.code.to_i
170
- when 200
171
- parse_definitions(response)
172
- when 304
173
- # Not modified
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: #{response.code}"
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: #{response.code}",
183
- status_code: response.code.to_i,
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 parse_definitions(response)
190
- # Cache headers for conditional requests
191
- @etag = response["ETag"]
192
- @last_modified = response["Last-Modified"]
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
- data = JSON.parse(response.body)
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] || "always_on"
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] || "always_on"
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
- next
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
- "always_off"
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)
@@ -7,7 +7,11 @@ module Toggly
7
7
  # Used for features that should be enabled for everyone.
8
8
  class AlwaysOn < Base
9
9
  def self.type
10
- "always_on"
10
+ "AlwaysOn"
11
+ end
12
+
13
+ def self.aliases
14
+ %w[always_on]
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.to_s.downcase == self.class.type.to_s.downcase
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
- rule[key.to_s] || rule[key.to_sym] || default
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
- "contextproperty"
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)
@@ -20,6 +20,10 @@ module Toggly
20
20
  "contextual"
21
21
  end
22
22
 
23
+ def self.aliases
24
+ %w[ContextualTargeting]
25
+ end
26
+
23
27
  # Evaluate contextual targeting rule
24
28
  #
25
29
  # @param rule [Hash] Rule with "conditions" array
@@ -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