featureflip 2.10.0 → 2.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33b15e24100b214989443fd146e3b5a4ef29c92ee28f457419c4ef0816e01efa
4
- data.tar.gz: f638de9e949664b629c35771bce31f2dff71eb018133eeecc3a1d952ba037992
3
+ metadata.gz: f9ade6e604108bf5c72e9b0d69dee554ef6c929004db80b56e4d184980de4ee1
4
+ data.tar.gz: 32c424cf3af497320ad75dc8f23baf336b086f3e8bdf867da20e8e7ce4f96e70
5
5
  SHA512:
6
- metadata.gz: 0c3da771ddf9cb78f4a73f890d03d178047f78dfdf0a1347a52ca82dd1e3721f79b3249856ea1413068aa924b1f5d9acf9a8c2449b79731134508b9f4701714f
7
- data.tar.gz: f73db84108ad650e6812868b217fc3b981ba0cdbc2985de312ef94d2fc88d8fdb2d24cc638a5c24837b3598271f7a49005ed5ba1b9f428778099909042b5457b
6
+ metadata.gz: 5e5d545c1410cac76b7603008f9708a40e6c257e82e8142b1d799fcfc2b5475500072e764a6828b43300fade2cbede2936b82bf1e3e043131606317600ea2985
7
+ data.tar.gz: b8727a3ddf735c16c4d0094472721ba2ad53fb5bca323024a7d3291b993730efb583cc1683375aea9ba8d3235b523dac252ddac1f82fcc0799718d49337b5065
@@ -4,6 +4,15 @@ module Featureflip
4
4
  :flush_batch_size, :init_timeout, :connect_timeout, :read_timeout,
5
5
  :max_stream_retries, :send_events, :logger
6
6
 
7
+ # Identifies a library that wraps this SDK, such as the OpenFeature provider. Its
8
+ # token is sent after the SDK's own in the User-Agent
9
+ # (`featureflip-ruby/<version> my-wrapper/1.0.0`) so usage through the wrapper can be
10
+ # counted. Both parts must be valid HTTP tokens; an invalid wrapper is ignored with a
11
+ # logged warning. wrapper_version may be nil. Leave unset when calling the SDK
12
+ # directly. Excluded from SharedCore._configs_equal: it identifies the caller rather
13
+ # than changing behavior.
14
+ attr_accessor :wrapper_name, :wrapper_version
15
+
7
16
  # Evaluation inspectors -- callables invoked once per variation call with a
8
17
  # Models::EvaluationEvent. Always an Array; non-callable entries are dropped
9
18
  # on assignment rather than blowing up on the evaluation hot path.
@@ -22,7 +31,9 @@ module Featureflip
22
31
  max_stream_retries: 5,
23
32
  send_events: true,
24
33
  logger: nil,
25
- inspectors: nil
34
+ inspectors: nil,
35
+ wrapper_name: nil,
36
+ wrapper_version: nil
26
37
  )
27
38
  @sdk_key = sdk_key
28
39
  @base_url = base_url
@@ -37,6 +48,8 @@ module Featureflip
37
48
  @send_events = send_events
38
49
  @logger = logger || default_logger
39
50
  self.inspectors = inspectors
51
+ @wrapper_name = wrapper_name
52
+ @wrapper_version = wrapper_version
40
53
 
41
54
  validate!
42
55
  end
@@ -53,6 +66,13 @@ module Featureflip
53
66
  @inspectors = list.select { |i| i.respond_to?(:call) }.freeze
54
67
  end
55
68
 
69
+ # The User-Agent for every request: the SDK token, then the wrapper's (#4154). Built
70
+ # on first use and memoized, so the HTTP client and the stream share one value and an
71
+ # invalid wrapper is warned about once.
72
+ def user_agent
73
+ @user_agent ||= SdkIdentity.user_agent(self)
74
+ end
75
+
56
76
  def validate!
57
77
  finalize!
58
78
  validate_positive_fields!
@@ -160,7 +160,9 @@ module Featureflip
160
160
  req = Net::HTTP::Get.new(uri.request_uri)
161
161
  req["Authorization"] = @sdk_key
162
162
  req["Accept"] = "text/event-stream"
163
- req["User-Agent"] = "featureflip-ruby/#{Featureflip::VERSION}"
163
+ # Same memoized value as the HTTP client, so an invalid wrapper is warned about
164
+ # once rather than on every reconnect.
165
+ req["User-Agent"] = @config.user_agent
164
166
 
165
167
  http.request(req) do |response|
166
168
  unless response.is_a?(Net::HTTPSuccess)
@@ -0,0 +1,53 @@
1
+ module Featureflip
2
+ module Evaluation
3
+ # Canonical spelling of a floating-point rollout bucket value (#4236): the same
4
+ # text as ECMAScript Number::toString and the Evaluation API's
5
+ # BucketValueFormatter.CanonicalNumber, so every SDK hashes one number to one
6
+ # bucket. Used only on the bucket path; condition evaluation is unaffected.
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).
10
+ def self.format_big_decimal(d)
11
+ return format(d.to_f) unless d.finite?
12
+ return d.to_i.to_s if d.frac.zero?
13
+
14
+ format(d.to_f)
15
+ end
16
+
17
+ def self.format(x)
18
+ return "NaN" if x.nan?
19
+ return (x.positive? ? "Infinity" : "-Infinity") if x.infinite?
20
+ return "0" if x.zero?
21
+ return "-" + format(-x) if x.negative?
22
+
23
+ # Float#to_s is the shortest round-trip form (e.g. "0.1", "100.25", "1.0e+21").
24
+ # Kernel.format("%.{p}e", ...) with the first round-tripping p is NOT always
25
+ # shortest: for 2**-44 the nearest 16-digit decimal does not round-trip but a
26
+ # neighbour does, so the loop yields 17 digits where ECMAScript yields 16.
27
+ mantissa, exp10 = x.to_s.split("e")
28
+ int_part, frac_part = mantissa.split(".")
29
+ frac_part ||= ""
30
+ all_digits = int_part + frac_part
31
+ lead = all_digits.length - all_digits.sub(/\A0+/, "").length
32
+ lead = all_digits.length - 1 if lead >= all_digits.length
33
+ exp = exp10.to_i + int_part.length - 1 - lead
34
+ digits = all_digits[lead..].sub(/0+\z/, "")
35
+ digits = "0" if digits.empty?
36
+ k = digits.length
37
+ n = exp + 1
38
+
39
+ if k <= n && n <= 21
40
+ digits + "0" * (n - k)
41
+ elsif n.positive? && n <= 21
42
+ "#{digits[0, n]}.#{digits[n..]}"
43
+ elsif n > -6 && n <= 0
44
+ "0." + "0" * -n + digits
45
+ else
46
+ e = n - 1
47
+ head = k > 1 ? "#{digits[0]}.#{digits[1..]}" : digits
48
+ "#{head}e#{e >= 0 ? '+' : '-'}#{e.abs}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,7 @@
1
1
  require_relative "condition_evaluator"
2
2
  require_relative "bucketing"
3
+ require_relative "canonical_number"
4
+ require_relative "unbucketable_warnings"
3
5
 
4
6
  module Featureflip
5
7
  module Evaluation
@@ -9,8 +11,9 @@ module Featureflip
9
11
  # the JS reference implementation; see prerequisite_spec.rb depth test.
10
12
  MAX_PREREQUISITE_DEPTH = 10
11
13
 
12
- def initialize
14
+ def initialize(logger: nil)
13
15
  @condition_evaluator = ConditionEvaluator.new
16
+ @logger = logger
14
17
  end
15
18
 
16
19
  def evaluate(flag, context, get_segment: nil, all_flags: {})
@@ -63,7 +66,21 @@ module Featureflip
63
66
 
64
67
  if conditions_match
65
68
  variation_key = resolve_serve(rule.serve, context)
69
+ # A rollout that can't bucket the context doesn't match; fall to the next rule (#4236).
70
+ if variation_key.nil?
71
+ warn_unbucketable(flag, rule.id, rule.serve)
72
+ next
73
+ end
74
+
75
+ # A served key that names no variation is malformed config: report Error (no
76
+ # rule id) with the served key, like the engine's ServeVariation (#4236).
66
77
  variation = flag.get_variation(variation_key)
78
+ if variation.nil?
79
+ result = Models::EvaluationDetail.new(value: nil, reason: "Error", variation_key: variation_key)
80
+ memo[flag.key] = result
81
+ return result
82
+ end
83
+
67
84
  result = Models::EvaluationDetail.new(
68
85
  value: variation&.value,
69
86
  reason: "RuleMatch",
@@ -76,9 +93,18 @@ module Featureflip
76
93
  end
77
94
 
78
95
  variation_key = resolve_serve(flag.fallthrough, context)
96
+ if variation_key.nil?
97
+ warn_unbucketable(flag, nil, flag.fallthrough)
98
+ variation_key = flag.fallthrough.variation || ""
99
+ end
79
100
  variation = flag.get_variation(variation_key)
101
+ if variation.nil?
102
+ result = Models::EvaluationDetail.new(value: nil, reason: "Error", variation_key: variation_key)
103
+ memo[flag.key] = result
104
+ return result
105
+ end
80
106
  result = Models::EvaluationDetail.new(
81
- value: variation&.value,
107
+ value: variation.value,
82
108
  reason: "Fallthrough",
83
109
  variation_key: variation_key
84
110
  )
@@ -123,14 +149,26 @@ module Featureflip
123
149
 
124
150
  def off_result(flag, reason:, prerequisite_key: nil)
125
151
  variation = flag.get_variation(flag.off_variation)
152
+ # An off variation the flag doesn't define is malformed config: report Error
153
+ # (value nil, the key kept), like the engine's ServeVariation (#4236).
154
+ if variation.nil?
155
+ return Models::EvaluationDetail.new(value: nil, reason: "Error", variation_key: flag.off_variation)
156
+ end
157
+
126
158
  Models::EvaluationDetail.new(
127
- value: variation&.value,
159
+ value: variation.value,
128
160
  reason: reason,
129
161
  variation_key: flag.off_variation,
130
162
  prerequisite_key: prerequisite_key
131
163
  )
132
164
  end
133
165
 
166
+ def warn_unbucketable(flag, rule_id, serve)
167
+ UnbucketableWarnings.warn(@logger, flag.key, rule_id, serve.bucket_by || "userId")
168
+ end
169
+
170
+ # Returns the variation key to serve, or nil when a Rollout serve can't bucket
171
+ # the context (see resolve_rollout). A Fixed serve never returns nil.
134
172
  def resolve_serve(serve, context)
135
173
  if serve.type == "Fixed"
136
174
  serve.variation || ""
@@ -145,7 +183,12 @@ module Featureflip
145
183
  # Alias "userId" <-> "user_id" for the built-in user identifier
146
184
  bucket_value = context["user_id"] if bucket_value.nil? && bucket_by == "userId"
147
185
  bucket_value = context["userId"] if bucket_value.nil? && bucket_by == "user_id"
148
- bucket_value_str = bucket_value.nil? ? "" : bucket_value.to_s
186
+ bucket_value_str =
187
+ if bucket_value.nil? then ""
188
+ elsif bucket_value.is_a?(Float) then CanonicalNumber.format(bucket_value)
189
+ elsif defined?(::BigDecimal) && bucket_value.is_a?(::BigDecimal) then CanonicalNumber.format_big_decimal(bucket_value)
190
+ else bucket_value.to_s
191
+ end
149
192
 
150
193
  # A Rollout serve can arrive with no weighted variations -- env-level PercentageRollout
151
194
  # has nowhere to store per-variation weights, so the mapper emits type=Rollout with no
@@ -153,14 +196,10 @@ module Featureflip
153
196
  # empty key. Mirrors the engine + C#/Java SDK evaluators.
154
197
  return serve.variation || "" if (serve.variations || []).empty?
155
198
 
156
- # Keyless user contexts can't be bucketed. Rather than hashing the empty value
157
- # into an arbitrary salt-dependent bucket, serve the control (first) variation
158
- # deterministically. The engine assigns a random GUID per eval (spreading
159
- # anonymous users over HTTP); local SDK eval is deterministic, so parity is
160
- # guaranteed only for keyed contexts (#1457).
161
- if bucket_value_str == "" && %w[userId user_id].include?(bucket_by) && !(serve.variations || []).empty?
162
- return serve.variations[0].key
163
- end
199
+ # A missing, nil or empty bucket value can't be bucketed: return nil so the caller
200
+ # skips the rule (or serves the fallthrough's variation) instead of hashing the empty
201
+ # string into an arbitrary bucket. Whitespace, "0", 0 and false bucket normally (#4236).
202
+ return nil if bucket_value_str == ""
164
203
 
165
204
  bucket = Bucketing.compute_bucket(serve.salt || "", bucket_value_str)
166
205
 
@@ -0,0 +1,48 @@
1
+ require "set"
2
+
3
+ module Featureflip
4
+ module Evaluation
5
+ # Process-wide, deduplicated warning for a percentage rollout that was skipped
6
+ # because the context has no value for its bucket-by attribute (#4236).
7
+ #
8
+ # Logs once per (flag key, rule id or "fallthrough", bucket_by). The set is
9
+ # capped at MAX_KEYS; once full, unseen keys are dropped silently. Only the
10
+ # flag key, rule id and bucket-by attribute NAME are logged, never a context
11
+ # value.
12
+ module UnbucketableWarnings
13
+ MAX_KEYS = 1000
14
+
15
+ @mutex = Mutex.new
16
+ @seen = Set.new
17
+
18
+ class << self
19
+ def warn(logger, flag_key, rule_id, bucket_by)
20
+ where = rule_id.nil? ? "fallthrough" : "rule #{rule_id}"
21
+ key = [flag_key, where, bucket_by]
22
+
23
+ @mutex.synchronize do
24
+ return if @seen.include?(key) || @seen.size >= MAX_KEYS
25
+
26
+ @seen.add(key)
27
+ end
28
+
29
+ logger&.warn(
30
+ "[featureflip] Skipped the percentage rollout in flag \"#{flag_key}\" (#{where}): " \
31
+ "the context has no value for \"#{bucket_by}\". " \
32
+ "Pass a stable id in that attribute to include this context in the rollout."
33
+ )
34
+ end
35
+
36
+ # Number of distinct keys recorded so far.
37
+ def count
38
+ @mutex.synchronize { @seen.size }
39
+ end
40
+
41
+ # Clears the dedup set. For tests.
42
+ def reset
43
+ @mutex.synchronize { @seen.clear }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -83,7 +83,7 @@ module Featureflip
83
83
 
84
84
  req["Authorization"] = @sdk_key
85
85
  req["Content-Type"] = "application/json"
86
- req["User-Agent"] = "featureflip-ruby/#{Featureflip::VERSION}"
86
+ req["User-Agent"] = @config.user_agent
87
87
 
88
88
  response = http.request(req)
89
89
 
@@ -0,0 +1,27 @@
1
+ module Featureflip
2
+ # The User-Agent every request carries so the Evaluation API can count SDK usage by
3
+ # name and version (#4154, contract in #4138): `featureflip-ruby/<version>`, then the
4
+ # configured wrapper's token. VERSION is the constant the publish workflow rewrites,
5
+ # never a second literal (#2141).
6
+ module SdkIdentity
7
+ # RFC 9110 product token: tchars.
8
+ TOKEN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/
9
+
10
+ def self.user_agent(config)
11
+ sdk = "featureflip-ruby/#{Featureflip::VERSION}"
12
+ name = config.wrapper_name
13
+ return sdk if name.nil? || name.to_s.empty?
14
+
15
+ version = config.wrapper_version
16
+ unless TOKEN.match?(name.to_s) && (version.nil? || TOKEN.match?(version.to_s))
17
+ # Dropped rather than raised: identity metadata must never stop the client starting.
18
+ config.logger.warn(
19
+ "Featureflip: ignoring wrapper '#{name}/#{version}': not a valid User-Agent product token"
20
+ )
21
+ return sdk
22
+ end
23
+
24
+ version.nil? ? "#{sdk} #{name}" : "#{sdk} #{name}/#{version}"
25
+ end
26
+ end
27
+ end
@@ -85,7 +85,7 @@ module Featureflip
85
85
  # the "different config" warning.
86
86
  @inspectors = config.inspectors || []
87
87
  @store = Store::FlagStore.new
88
- @evaluator = Evaluation::Evaluator.new
88
+ @evaluator = Evaluation::Evaluator.new(logger: @config.logger)
89
89
  @initialized = false
90
90
  @closed = false
91
91
  @test_mode = false
@@ -579,7 +579,7 @@ module Featureflip
579
579
  @config = Config.new
580
580
  @inspectors = []
581
581
  @store = Store::FlagStore.new
582
- @evaluator = Evaluation::Evaluator.new
582
+ @evaluator = Evaluation::Evaluator.new(logger: @config.logger)
583
583
  @initialized = true
584
584
  @closed = false
585
585
  @test_mode = true
@@ -595,6 +595,8 @@ module Featureflip
595
595
  @update_listeners_mutex = Mutex.new
596
596
  end
597
597
 
598
+ # wrapper_name/wrapper_version are deliberately excluded: they identify the caller
599
+ # rather than change behavior (see Config).
598
600
  def self._configs_equal(a, b)
599
601
  a.base_url == b.base_url &&
600
602
  a.streaming == b.streaming &&
@@ -1,3 +1,3 @@
1
1
  module Featureflip
2
- VERSION = "2.10.0"
2
+ VERSION = "2.12.0"
3
3
  end
data/lib/featureflip.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  require_relative "featureflip/version"
2
2
  require_relative "featureflip/errors"
3
3
  require_relative "featureflip/config"
4
+ require_relative "featureflip/sdk_identity"
4
5
  require_relative "featureflip/models/flag"
5
6
  require_relative "featureflip/models/segment"
6
7
  require_relative "featureflip/models/evaluation_detail"
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.10.0
4
+ version: 2.12.0
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-10 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -79,8 +79,10 @@ files:
79
79
  - lib/featureflip/data_source/streaming.rb
80
80
  - lib/featureflip/errors.rb
81
81
  - lib/featureflip/evaluation/bucketing.rb
82
+ - lib/featureflip/evaluation/canonical_number.rb
82
83
  - lib/featureflip/evaluation/condition_evaluator.rb
83
84
  - lib/featureflip/evaluation/evaluator.rb
85
+ - lib/featureflip/evaluation/unbucketable_warnings.rb
84
86
  - lib/featureflip/events/event.rb
85
87
  - lib/featureflip/events/event_processor.rb
86
88
  - lib/featureflip/http/client.rb
@@ -88,6 +90,7 @@ files:
88
90
  - lib/featureflip/models/evaluation_event.rb
89
91
  - lib/featureflip/models/flag.rb
90
92
  - lib/featureflip/models/segment.rb
93
+ - lib/featureflip/sdk_identity.rb
91
94
  - lib/featureflip/shared_core.rb
92
95
  - lib/featureflip/store/flag_store.rb
93
96
  - lib/featureflip/store/update_diff.rb