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
@@ -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 FNV-1a hash for consistent bucketing based on
8
- # feature key and user identity.
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
- "percentage"
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 "percentage" or "value" key (0-100)
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 = rule_value(rule, "percentage") ||
26
- rule_value(rule, "value") ||
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
- # Calculate bucket using FNV-1a hash
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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Shared helpers for Definitions-style segment / claims filters.
6
+ module SegmentHelpers
7
+ module_function
8
+
9
+ # @param rule [Hash]
10
+ # @return [Hash]
11
+ def parameters(rule)
12
+ return {} unless rule.is_a?(Hash)
13
+
14
+ params = rule["parameters"] || rule[:parameters]
15
+ params.is_a?(Hash) ? params : rule
16
+ end
17
+
18
+ # @param rule [Hash]
19
+ # @param keys [Array<String>]
20
+ # @return [Float, nil]
21
+ def as_float(rule, *keys)
22
+ params = parameters(rule)
23
+ keys.each do |key|
24
+ value = lookup(params, key)
25
+ next if value.nil?
26
+ next if [true, false].include?(value)
27
+
28
+ begin
29
+ return Float(value)
30
+ rescue ArgumentError, TypeError
31
+ return nil
32
+ end
33
+ end
34
+ nil
35
+ end
36
+
37
+ # @param rule [Hash]
38
+ # @param key [String]
39
+ # @return [String, nil]
40
+ def as_string(rule, key)
41
+ value = lookup(parameters(rule), key)
42
+ return nil if value.nil?
43
+
44
+ text = value.to_s
45
+ text.empty? ? nil : text
46
+ end
47
+
48
+ # Collect indexed RavenDB / legacy colon-prefixed parameter values.
49
+ #
50
+ # @param rule [Hash]
51
+ # @param prefixes [Array<String>]
52
+ # @return [Array<String>]
53
+ def collect_indexed_values(rule, *prefixes)
54
+ params = parameters(rule)
55
+ out = []
56
+ params.each do |key, value|
57
+ next if value.nil?
58
+
59
+ key_s = key.to_s
60
+ prefixes.each do |prefix|
61
+ next unless key_s.start_with?("#{prefix}:")
62
+
63
+ text = value.to_s
64
+ out << text unless text.empty?
65
+ break
66
+ end
67
+ end
68
+ out
69
+ end
70
+
71
+ # @param haystack [String, nil]
72
+ # @param needle [String, nil]
73
+ # @return [Boolean]
74
+ def contains_ignore_case?(haystack, needle)
75
+ return false if haystack.nil? || needle.nil?
76
+
77
+ haystack.downcase.include?(needle.downcase)
78
+ end
79
+
80
+ # @param left [String, nil]
81
+ # @param right [String, nil]
82
+ # @return [Boolean]
83
+ def equals_ignore_case?(left, right)
84
+ return false if left.nil? || right.nil?
85
+
86
+ left.casecmp?(right)
87
+ end
88
+
89
+ def lookup(params, key)
90
+ return nil unless params.is_a?(Hash)
91
+
92
+ params[key] || params[key.to_s] || params[key.to_sym] ||
93
+ params.find { |k, _| k.to_s.casecmp?(key.to_s) }&.last
94
+ end
95
+ module_function :lookup
96
+ end
97
+ end
98
+ end
@@ -7,45 +7,92 @@ module Toggly
7
7
  # Supports targeting by:
8
8
  # - Specific user identities
9
9
  # - Group membership
10
+ # - Definitions Audience.* indexed params and default rollout
10
11
  class Targeting < Base
11
12
  def self.type
12
- "targeting"
13
+ "Targeting"
14
+ end
15
+
16
+ def self.aliases
17
+ %w[Microsoft.Targeting targeting]
13
18
  end
14
19
 
15
20
  # Evaluate targeting rule
16
21
  #
17
- # @param rule [Hash] Rule with "users" and/or "groups" arrays
22
+ # @param rule [Hash] Rule with users/groups or Audience.* params
18
23
  # @param context [Context] Evaluation context
19
24
  # @param feature_key [String] The feature key
20
25
  # @return [Boolean, nil] True if matched, false if excluded, nil to continue
21
26
  def evaluate(rule, context, feature_key: nil)
22
27
  return nil unless context
23
28
 
24
- # Check user targeting
25
- users = Array(rule_value(rule, "users"))
29
+ users = collect_users(rule)
26
30
  excluded_users = Array(rule_value(rule, "excludedUsers") || rule_value(rule, "excluded_users"))
27
31
 
28
32
  if context.identity?
29
- # Check exclusion first
30
33
  return false if excluded_users.any? { |u| u.to_s == context.identity }
31
-
32
- # Check inclusion
33
- return true if users.any? { |u| u.to_s == context.identity }
34
+ return true if users.include?(context.identity)
34
35
  end
35
36
 
36
- # Check group targeting
37
- groups = Array(rule_value(rule, "groups"))
37
+ groups = collect_groups(rule)
38
38
  excluded_groups = Array(rule_value(rule, "excludedGroups") || rule_value(rule, "excluded_groups"))
39
39
 
40
- # Check group exclusion
41
40
  return false if excluded_groups.any? { |g| context.in_group?(g) }
42
-
43
- # Check group inclusion
44
41
  return true if groups.any? { |g| context.in_group?(g) }
45
42
 
46
- # No match, continue evaluation
43
+ default_percentage = SegmentHelpers.as_float(
44
+ rule,
45
+ "Audience.DefaultRolloutPercentage",
46
+ "DefaultRolloutPercentage",
47
+ "defaultRolloutPercentage",
48
+ "default_percentage",
49
+ "Percentage"
50
+ )
51
+ return StickyHash.compute_percentile(context.identity, feature_key.to_s) < default_percentage if default_percentage&.positive? && context.identity?
52
+
53
+ # No match — continue evaluation for legacy sequential rules
47
54
  nil
48
55
  end
56
+
57
+ private
58
+
59
+ def collect_users(rule)
60
+ users = []
61
+ append_list_values(users, rule_value(rule, "users") || rule_value(rule, "Users"))
62
+ SegmentHelpers.parameters(rule).each do |key, value|
63
+ next unless key.to_s.start_with?("Audience.Users:") && value
64
+
65
+ users << value.to_s
66
+ end
67
+ users.uniq
68
+ end
69
+
70
+ def collect_groups(rule)
71
+ groups = []
72
+ append_list_values(groups, rule_value(rule, "groups") || rule_value(rule, "Groups"))
73
+ SegmentHelpers.parameters(rule).each do |key, value|
74
+ next unless key.to_s.start_with?("Audience.Groups:") && value
75
+
76
+ groups << value.to_s
77
+ end
78
+ groups.uniq
79
+ end
80
+
81
+ def append_list_values(memo, raw)
82
+ case raw
83
+ when Array
84
+ raw.each { |item| memo << item.to_s }
85
+ when String
86
+ raw.split(",").each do |item|
87
+ trimmed = item.strip
88
+ memo << trimmed unless trimmed.empty?
89
+ end
90
+ when nil
91
+ nil
92
+ else
93
+ memo << raw.to_s
94
+ end
95
+ end
49
96
  end
50
97
  end
51
98
  end
@@ -7,12 +7,16 @@ module Toggly
7
7
  # Enables features within a specific time range.
8
8
  class TimeWindow < Base
9
9
  def self.type
10
- "time_window"
10
+ "TimeWindow"
11
+ end
12
+
13
+ def self.aliases
14
+ %w[Microsoft.TimeWindow time_window]
11
15
  end
12
16
 
13
17
  # Evaluate time window rule
14
18
  #
15
- # @param rule [Hash] Rule with "startTime"/"start_time" and/or "endTime"/"end_time"
19
+ # @param rule [Hash] Rule with Start/End or startTime/endTime
16
20
  # @param context [Context] Evaluation context (ignored)
17
21
  # @param feature_key [String] The feature key
18
22
  # @return [Boolean] True if current time is within window
@@ -20,19 +24,20 @@ module Toggly
20
24
  now = Time.now.utc
21
25
 
22
26
  start_time = parse_time(
23
- rule_value(rule, "startTime") || rule_value(rule, "start_time")
27
+ rule_value(rule, "Start") ||
28
+ rule_value(rule, "start") ||
29
+ rule_value(rule, "startTime") ||
30
+ rule_value(rule, "start_time")
24
31
  )
25
32
  end_time = parse_time(
26
- rule_value(rule, "endTime") || rule_value(rule, "end_time")
33
+ rule_value(rule, "End") ||
34
+ rule_value(rule, "end") ||
35
+ rule_value(rule, "endTime") ||
36
+ rule_value(rule, "end_time")
27
37
  )
28
38
 
29
- # No time constraints means always valid
30
39
  return true if start_time.nil? && end_time.nil?
31
-
32
- # Check start time
33
40
  return false if start_time && now < start_time
34
-
35
- # Check end time
36
41
  return false if end_time && now > end_time
37
42
 
38
43
  true
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ module Evaluators
5
+ # Evaluator for UserClaims filters (Claim + Value params).
6
+ class UserClaims < Base
7
+ def self.type
8
+ "UserClaims"
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
+ claim_type = SegmentHelpers.as_string(rule, "Claim")
17
+ claim_value = SegmentHelpers.as_string(rule, "Value")
18
+ return false if claim_type.nil? || claim_value.nil? || context.nil?
19
+
20
+ claims = context.claims
21
+ return false if claims.nil? || !claims.key?(claim_type)
22
+
23
+ claim_value == claims[claim_type]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -38,7 +38,7 @@ module Toggly
38
38
  # Feature types
39
39
  TYPES = %w[Release Experiment Ops Permission].freeze
40
40
 
41
- # rubocop:disable Metrics/ParameterLists
41
+ # rubocop:disable-next Metrics/ParameterLists
42
42
  def initialize(
43
43
  feature_key:,
44
44
  feature_type: "Release",
@@ -64,7 +64,6 @@ module Toggly
64
64
  @context_kind = context_kind
65
65
  @context_requirement_type = context_requirement_type
66
66
  end
67
- # rubocop:enable Metrics/ParameterLists
68
67
 
69
68
  # Create from a hash (e.g., from JSON)
70
69
  #
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # Maps common HTTP headers into RequestContext fields.
5
+ #
6
+ # Does not invent identity, groups, or claims — merge those separately.
7
+ class HttpRequestMapper
8
+ COUNTRY_HEADERS = %w[
9
+ cf-ipcountry
10
+ x-vercel-ip-country
11
+ cloudfront-viewer-country
12
+ ].freeze
13
+
14
+ class << self
15
+ # Build RequestContext from a header bag (case-insensitive keys).
16
+ #
17
+ # @param headers [Hash, nil]
18
+ # @return [RequestContext]
19
+ def from_http_headers(headers)
20
+ return RequestContext.new if headers.nil? || headers.empty?
21
+
22
+ RequestContext.new(
23
+ user_agent: header(headers, "user-agent"),
24
+ accept_language: header(headers, "accept-language"),
25
+ country: first_present(headers, *COUNTRY_HEADERS)
26
+ )
27
+ end
28
+
29
+ # Merge HTTP-mapped request fields over an existing evaluation context.
30
+ #
31
+ # @param headers [Hash, nil]
32
+ # @param base [Context, nil]
33
+ # @return [Context]
34
+ def merge_into(headers, base)
35
+ request = from_http_headers(headers)
36
+ return Context.new(request: request) if base.nil?
37
+
38
+ base.with_request(request)
39
+ end
40
+
41
+ private
42
+
43
+ def first_present(headers, *names)
44
+ names.each do |name|
45
+ value = header(headers, name)
46
+ return value if value
47
+ end
48
+ nil
49
+ end
50
+
51
+ def header(headers, name)
52
+ lower = name.downcase
53
+ headers.each do |key, value|
54
+ next unless key && key.to_s.downcase == lower
55
+ next if value.nil?
56
+
57
+ text = value.to_s
58
+ return text unless text.empty?
59
+ end
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -12,13 +12,26 @@ module Toggly
12
12
  register_defaults
13
13
  end
14
14
 
15
- # Register an evaluator
15
+ # Normalize filter / evaluator names for lookup.
16
+ # Strips non-alphanumeric characters so AlwaysOn and always_on match.
17
+ #
18
+ # @param type [String, Symbol]
19
+ # @return [String]
20
+ def self.normalize_key(type)
21
+ type.to_s.downcase.gsub(/[^a-z0-9]/, "")
22
+ end
23
+
24
+ # Register an evaluator (and optional extra aliases).
16
25
  #
17
26
  # @param evaluator [Evaluators::Base] The evaluator instance
27
+ # @param extra_aliases [Array<String>] Additional names
18
28
  # @return [self]
19
- def register(evaluator)
29
+ def register(evaluator, *extra_aliases)
30
+ names = [evaluator.class.type, *Array(evaluator.class.aliases), *extra_aliases]
20
31
  @mutex.synchronize do
21
- @evaluators[evaluator.class.type.to_s.downcase] = evaluator
32
+ names.compact.each do |name|
33
+ @evaluators[self.class.normalize_key(name)] = evaluator
34
+ end
22
35
  end
23
36
  self
24
37
  end
@@ -29,7 +42,7 @@ module Toggly
29
42
  # @return [Evaluators::Base, nil]
30
43
  def get(type)
31
44
  @mutex.synchronize do
32
- @evaluators[type.to_s.downcase]
45
+ @evaluators[self.class.normalize_key(type)]
33
46
  end
34
47
  end
35
48
 
@@ -39,7 +52,7 @@ module Toggly
39
52
  # @return [Boolean]
40
53
  def registered?(type)
41
54
  @mutex.synchronize do
42
- @evaluators.key?(type.to_s.downcase)
55
+ @evaluators.key?(self.class.normalize_key(type))
43
56
  end
44
57
  end
45
58
 
@@ -58,7 +71,7 @@ module Toggly
58
71
  # @return [Evaluators::Base, nil] The removed evaluator
59
72
  def unregister(type)
60
73
  @mutex.synchronize do
61
- @evaluators.delete(type.to_s.downcase)
74
+ @evaluators.delete(self.class.normalize_key(type))
62
75
  end
63
76
  end
64
77
 
@@ -82,6 +95,12 @@ module Toggly
82
95
  register(Evaluators::TimeWindow.new)
83
96
  register(Evaluators::ContextualTargeting.new)
84
97
  register(Evaluators::ContextProperty.new)
98
+ register(Evaluators::BrowserFamily.new)
99
+ register(Evaluators::BrowserLanguage.new)
100
+ register(Evaluators::Country.new)
101
+ register(Evaluators::DeviceType.new)
102
+ register(Evaluators::OperatingSystem.new)
103
+ register(Evaluators::UserClaims.new)
85
104
  end
86
105
  end
87
106
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toggly
4
+ # HTTP request fields used by segment identity filters.
5
+ class RequestContext
6
+ # @return [String, nil]
7
+ attr_reader :user_agent, :accept_language, :country
8
+
9
+ def initialize(user_agent: nil, accept_language: nil, country: nil)
10
+ @user_agent = blank_to_nil(user_agent)
11
+ @accept_language = blank_to_nil(accept_language)
12
+ @country = blank_to_nil(country)
13
+ end
14
+
15
+ # @return [Hash]
16
+ def to_h
17
+ {
18
+ userAgent: @user_agent,
19
+ acceptLanguage: @accept_language,
20
+ country: @country
21
+ }
22
+ end
23
+
24
+ # Create from a hash accepting camelCase or snake_case keys.
25
+ #
26
+ # @param data [Hash, nil]
27
+ # @return [RequestContext, nil]
28
+ def self.from_hash(data)
29
+ return nil unless data.is_a?(Hash)
30
+
31
+ new(
32
+ user_agent: first_value(data, "userAgent", "user_agent"),
33
+ accept_language: first_value(data, "acceptLanguage", "accept_language"),
34
+ country: first_value(data, "country")
35
+ )
36
+ end
37
+
38
+ # @param other [Object]
39
+ # @return [Boolean]
40
+ def ==(other)
41
+ return false unless other.is_a?(RequestContext)
42
+
43
+ @user_agent == other.user_agent &&
44
+ @accept_language == other.accept_language &&
45
+ @country == other.country
46
+ end
47
+ alias eql? ==
48
+
49
+ # @return [Integer]
50
+ def hash
51
+ [@user_agent, @accept_language, @country].hash
52
+ end
53
+
54
+ def self.first_value(data, *keys)
55
+ keys.each do |key|
56
+ next unless data.key?(key) || data.key?(key.to_sym)
57
+
58
+ value = data[key] || data[key.to_sym]
59
+ next if value.nil?
60
+
61
+ text = value.to_s
62
+ return text unless text.empty?
63
+ end
64
+ nil
65
+ end
66
+ private_class_method :first_value
67
+
68
+ private
69
+
70
+ def blank_to_nil(value)
71
+ return nil if value.nil?
72
+
73
+ text = value.to_s
74
+ text.empty? ? nil : text
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+
6
+ module Toggly
7
+ # Sticky percentage bucketing aligned with Definitions / toggly-eval.
8
+ module StickyHash
9
+ module_function
10
+
11
+ # Sticky bucket in [0, 100) matching Definitions / toggly-eval SHA-256.
12
+ #
13
+ # Hash input is +feature_key+ + "\n" + +user_id+; little-endian uint32 from
14
+ # the first 4 digest bytes, then +(value / 0xFFFFFFFF) * 100+.
15
+ #
16
+ # @param user_id [String]
17
+ # @param feature_key [String]
18
+ # @return [Float]
19
+ def compute_percentile(user_id, feature_key)
20
+ digest = Digest::SHA256.digest("#{feature_key}\n#{user_id}")
21
+ value = digest.byteslice(0, 4).unpack1("V")
22
+ (value.to_f / 0xFFFFFFFF) * 100.0
23
+ end
24
+
25
+ # Percentage gate for segment filters; missing or ≤0 fails closed.
26
+ #
27
+ # @param percentage [Numeric, nil]
28
+ # @param feature_key [String]
29
+ # @param identity [String, nil]
30
+ # @return [Boolean]
31
+ def segment_percentage_passes?(percentage, feature_key, identity)
32
+ return false if percentage.nil? || percentage <= 0
33
+ return true if percentage >= 100
34
+ return compute_percentile(identity, feature_key) < percentage if identity && !identity.empty?
35
+
36
+ SecureRandom.random_number * 100 < percentage
37
+ end
38
+ end
39
+ end