featureflip 2.12.0 → 2.13.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9ade6e604108bf5c72e9b0d69dee554ef6c929004db80b56e4d184980de4ee1
4
- data.tar.gz: 32c424cf3af497320ad75dc8f23baf336b086f3e8bdf867da20e8e7ce4f96e70
3
+ metadata.gz: c4c086d9a3b50763d562f2a79ef07aa449ddbdb1b88446d8fee98948d7c3933c
4
+ data.tar.gz: bf96d5faeb953a5219f1de18d5ff85d404b5fbf3daa783550b3b64837b0c72bc
5
5
  SHA512:
6
- metadata.gz: 5e5d545c1410cac76b7603008f9708a40e6c257e82e8142b1d799fcfc2b5475500072e764a6828b43300fade2cbede2936b82bf1e3e043131606317600ea2985
7
- data.tar.gz: b8727a3ddf735c16c4d0094472721ba2ad53fb5bca323024a7d3291b993730efb583cc1683375aea9ba8d3235b523dac252ddac1f82fcc0799718d49337b5065
6
+ metadata.gz: 2a2a87d48a71704a9b2885885a55c74f664497be27703bb4dcb83463756347f771803cfa6608f2c2512b8cff19a982a21ab72d76c52ec53a45fd3d5187f02f45
7
+ data.tar.gz: be23805658d9ce4f0ba3c4a49f0fde25ea8e2e05e0da8497ab4ec94b557ee9d554b1ed870782acf6df2cbe8476bc5691ae8728eb4438a1259b304c09d91fc4fa
@@ -5,11 +5,14 @@ module Featureflip
5
5
  # BucketValueFormatter.CanonicalNumber, so every SDK hashes one number to one
6
6
  # bucket. Used only on the bucket path; condition evaluation is unaffected.
7
7
  module CanonicalNumber
8
- # A BigDecimal with no fractional part keeps its exact integer digits (like the
9
- # engine's raw integer text); anything else goes through Float (#4236).
8
+ # A BigDecimal below 1e21 with no fractional part keeps its exact integer digits (like
9
+ # the engine's raw integer text); anything else goes through Float (#4236). The 1e21
10
+ # cap is where ECMAScript spelling turns exponential, and stops BigDecimal("1E+999999999")
11
+ # expanding to a billion digits per evaluation (#4246). BigDecimal#exponent is n in
12
+ # 0.ddd * 10**n, so |d| < 1e21 is exponent <= 21.
10
13
  def self.format_big_decimal(d)
11
14
  return format(d.to_f) unless d.finite?
12
- return d.to_i.to_s if d.frac.zero?
15
+ return d.to_i.to_s if d.exponent <= 21 && d.frac.zero?
13
16
 
14
17
  format(d.to_f)
15
18
  end
@@ -1,3 +1,4 @@
1
+ require_relative "context_attribute"
1
2
  require "time"
2
3
 
3
4
  module Featureflip
@@ -10,7 +11,7 @@ module Featureflip
10
11
  REGEX_TIMEOUT_SECONDS = 0.1
11
12
 
12
13
  def evaluate_condition(condition, context)
13
- attr_value = context[condition.attribute]
14
+ attr_value = ContextAttribute.resolve(context, condition.attribute)
14
15
 
15
16
  return condition.negate if attr_value.nil?
16
17
 
@@ -0,0 +1,24 @@
1
+ module Featureflip
2
+ module Evaluation
3
+ # Looks an attribute up in an evaluation context hash.
4
+ #
5
+ # "user_id" is the canonical spelling of the user identifier and "userId" is
6
+ # accepted as an alias (#4518). A lookup of exactly one of the two returns
7
+ # "user_id" when it is non-nil, else "userId" when it is non-nil, else nil.
8
+ # An empty string is a value and does not fall through. Every other key,
9
+ # including other casings, is an ordinary attribute.
10
+ module ContextAttribute
11
+ module_function
12
+
13
+ def resolve(context, attribute)
14
+ if attribute == "user_id" || attribute == "userId"
15
+ value = context["user_id"]
16
+ value = context["userId"] if value.nil?
17
+ value
18
+ else
19
+ context[attribute]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,3 +1,4 @@
1
+ require_relative "context_attribute"
1
2
  require_relative "condition_evaluator"
2
3
  require_relative "bucketing"
3
4
  require_relative "canonical_number"
@@ -164,7 +165,7 @@ module Featureflip
164
165
  end
165
166
 
166
167
  def warn_unbucketable(flag, rule_id, serve)
167
- UnbucketableWarnings.warn(@logger, flag.key, rule_id, serve.bucket_by || "userId")
168
+ UnbucketableWarnings.warn(@logger, flag.key, rule_id, serve.bucket_by || "user_id")
168
169
  end
169
170
 
170
171
  # Returns the variation key to serve, or nil when a Rollout serve can't bucket
@@ -178,13 +179,13 @@ module Featureflip
178
179
  end
179
180
 
180
181
  def resolve_rollout(serve, context)
181
- bucket_by = serve.bucket_by || "userId"
182
- bucket_value = context[bucket_by]
183
- # Alias "userId" <-> "user_id" for the built-in user identifier
184
- bucket_value = context["user_id"] if bucket_value.nil? && bucket_by == "userId"
185
- bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
182
+ bucket_by = serve.bucket_by || "user_id"
183
+ # "user_id" and "userId" resolve the same identity (user_id first).
184
+ bucket_value = ContextAttribute.resolve(context, bucket_by)
186
185
  bucket_value_str =
187
- if bucket_value.nil? then ""
186
+ # An Array or Hash (a JSON array or object, empty or not) has no spelling every
187
+ # SDK shares, so it can't be bucketed, like a missing value (#4572).
188
+ if bucket_value.nil? || bucket_value.is_a?(Array) || bucket_value.is_a?(Hash) then ""
188
189
  elsif bucket_value.is_a?(Float) then CanonicalNumber.format(bucket_value)
189
190
  elsif defined?(::BigDecimal) && bucket_value.is_a?(::BigDecimal) then CanonicalNumber.format_big_decimal(bucket_value)
190
191
  else bucket_value.to_s
@@ -196,7 +197,7 @@ module Featureflip
196
197
  # empty key. Mirrors the engine + C#/Java SDK evaluators.
197
198
  return serve.variation || "" if (serve.variations || []).empty?
198
199
 
199
- # A missing, nil or empty bucket value can't be bucketed: return nil so the caller
200
+ # A missing, nil, empty or non-scalar bucket value can't be bucketed: return nil so the caller
200
201
  # skips the rule (or serves the fallthrough's variation) instead of hashing the empty
201
202
  # string into an arbitrary bucket. Whitespace, "0", 0 and false bucket normally (#4236).
202
203
  return nil if bucket_value_str == ""
@@ -185,7 +185,7 @@ module Featureflip
185
185
 
186
186
  flag = @store.get_flag(key)
187
187
  unless flag
188
- record_evaluation(key, context, nil)
188
+ record_evaluation(key, context, nil, reason: "FlagNotFound")
189
189
  notify_inspectors(key, context, default_value, reason: "FlagNotFound")
190
190
  return Models::EvaluationDetail.new(value: default_value, reason: "FlagNotFound")
191
191
  end
@@ -224,7 +224,10 @@ module Featureflip
224
224
  reason = "Error"
225
225
  end
226
226
 
227
- record_evaluation(key, context, result.variation_key)
227
+ record_evaluation(
228
+ key, context, result.variation_key,
229
+ reason: reason, rule_id: result.rule_id, prerequisite_key: result.prerequisite_key
230
+ )
228
231
  notify_inspectors(
229
232
  key, context, value,
230
233
  reason: reason,
@@ -244,6 +247,7 @@ module Featureflip
244
247
  # Prerequisite-resolution failures return PrerequisiteFailed cleanly through
245
248
  # the evaluator; this rescue only fires on unexpected exceptions (malformed
246
249
  # config, programming errors), so prerequisite_key has no defined value.
250
+ record_evaluation(key, context, nil, reason: "Error")
247
251
  notify_inspectors(key, context, default_value, reason: "Error")
248
252
  Models::EvaluationDetail.new(value: default_value, reason: "Error", prerequisite_key: nil)
249
253
  end
@@ -521,16 +525,36 @@ module Featureflip
521
525
  event
522
526
  end
523
527
 
524
- def record_evaluation(key, context, variation_key)
528
+ # The kebab-case reason categories the Evaluation event carries (#4268), so
529
+ # the source of traffic reads the same whichever SDK reported it. The
530
+ # PascalCase names stay the public EvaluationDetail API; only the event is
531
+ # translated.
532
+ WIRE_REASONS = {
533
+ "RuleMatch" => "rule-match",
534
+ "Fallthrough" => "fallthrough",
535
+ "FlagDisabled" => "flag-disabled",
536
+ "FlagNotFound" => "flag-not-found",
537
+ "PrerequisiteFailed" => "prerequisite-failed",
538
+ "Error" => "error"
539
+ }.freeze
540
+
541
+ # +ruleId+ rides only on a rule match: an Error read can still carry the rule
542
+ # that selected a malformed variation, and that rule did not serve the
543
+ # traffic. Absent optionals are compacted away so they are omitted, never null.
544
+ def record_evaluation(key, context, variation_key, reason:, rule_id: nil, prerequisite_key: nil)
525
545
  return unless @event_processor
526
546
 
527
- @event_processor.queue_event(compact_event({
547
+ event = {
528
548
  type: "Evaluation",
529
549
  flagKey: key,
530
550
  userId: resolve_event_user_id(context),
531
551
  variation: variation_key,
532
- timestamp: Time.now.utc.iso8601
533
- }))
552
+ timestamp: Time.now.utc.iso8601,
553
+ prerequisiteKey: prerequisite_key,
554
+ reason: WIRE_REASONS[reason],
555
+ ruleId: reason == "RuleMatch" ? rule_id : nil
556
+ }
557
+ @event_processor.queue_event(event.compact)
534
558
  end
535
559
 
536
560
  # Fire the registered evaluation inspectors. Called once per variation call
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.12.0"
2
+ VERSION = "2.13.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureflip
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.12.0
4
+ version: 2.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Featureflip
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-22 00:00:00.000000000 Z
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -81,6 +81,7 @@ files:
81
81
  - lib/featureflip/evaluation/bucketing.rb
82
82
  - lib/featureflip/evaluation/canonical_number.rb
83
83
  - lib/featureflip/evaluation/condition_evaluator.rb
84
+ - lib/featureflip/evaluation/context_attribute.rb
84
85
  - lib/featureflip/evaluation/evaluator.rb
85
86
  - lib/featureflip/evaluation/unbucketable_warnings.rb
86
87
  - lib/featureflip/events/event.rb