subflag-rails 0.5.1 → 0.6.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: 56034c2de3a47fbbb96f97aa9f1149c87c58bc1558b50a7520db7b07a93c3e85
4
- data.tar.gz: dc818a9034753adfa51b2cd810a5affe7c3cb9e30ff9943c67e0df01d0c6cf66
3
+ metadata.gz: 474015cc849b73a4360fa11418161fbc78c6bbc163596b5b9add7e45929b6db4
4
+ data.tar.gz: 25f595010b6722d107b0604774ac089386e9d3ced39d7fef72411964a310140c
5
5
  SHA512:
6
- metadata.gz: ca26926074b168811199ae8fe3e065843073dcbae20667a9951a641bfb0e02e0d30e11ed16ae7376a1cf75c5f8ffc46e8012929494ba7d142a80260732ee4e0c
7
- data.tar.gz: c8b0af027aa0cf5d01bdac26b2a7681c05fa818bfe2a92d1d178a3c8cd67cc0d94f555b495b132593009c2272ad3b3b60b61b6b93087109321c99bffdef446a3
6
+ metadata.gz: afbe39727f8e70a3273b77e49bf8127debc459487b4d01bc14693c682082592b11fd3506388a8db2bd5021e9bbb914767e19f279a56a56a30c114fe79f10b0f2
7
+ data.tar.gz: f124c9b3103ff13453615be92dfe7639fabfeb042d514bc4232b5a73e8fa1dcbe812e4d0fb418c45a35c33ac0e71753958d37cff01c6742ff991a408e637946f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.6.0] - 2025-12-15
6
+
7
+ ### Added
8
+
9
+ - **Percentage rollouts for ActiveRecord backend**: Gradually roll out features to a percentage of users
10
+ - Deterministic assignment using MurmurHash3 (same user always gets the same result)
11
+ - Combine with segment conditions (e.g., "50% of pro users")
12
+ - Configure via Admin UI or targeting rules JSON
13
+ - New dependency: `murmurhash3` gem for consistent hashing
14
+
15
+ ### Changed
16
+
17
+ - `TargetingEngine.evaluate` now accepts optional `flag_key:` parameter (required for percentage rollouts)
18
+ - Targeting rules validation now accepts `percentage` as alternative to `conditions`
19
+
5
20
  ## [0.5.1] - 2025-12-15
6
21
 
7
22
  ### Fixed
data/README.md CHANGED
@@ -482,6 +482,18 @@ Subflag::Rails::Flag.create!(
482
482
  }
483
483
  ```
484
484
 
485
+ **Percentage rollouts:**
486
+
487
+ Gradually roll out features to a percentage of users using the Admin UI:
488
+
489
+ 1. Create or edit a flag at `/subflag`
490
+ 2. Add a targeting rule with a **percentage** (0-100)
491
+ 3. Optionally combine with conditions (e.g., "50% of pro users")
492
+
493
+ Assignment is deterministic — the same user always gets the same result for the same flag.
494
+
495
+ > **Note:** Percentage rollouts require `targeting_key` in your user context (typically the user ID).
496
+
485
497
  **How evaluation works:**
486
498
 
487
499
  1. Flag disabled? → return code default
@@ -70,7 +70,7 @@ module Subflag
70
70
  def evaluate(context: nil, expected_type: nil)
71
71
  rules = parsed_targeting_rules
72
72
  raw_value = if rules.present? && context.present?
73
- matched = TargetingEngine.evaluate(rules, context)
73
+ matched = TargetingEngine.evaluate(rules, context, flag_key: key)
74
74
  matched || value
75
75
  else
76
76
  value
@@ -146,7 +146,20 @@ module Subflag
146
146
 
147
147
  rule = rule.transform_keys(&:to_s)
148
148
  errors.add(:targeting_rules, "rule #{index} must have a 'value' key") unless rule.key?("value")
149
- errors.add(:targeting_rules, "rule #{index} must have a 'conditions' key") unless rule.key?("conditions")
149
+
150
+ has_conditions = rule.key?("conditions") && rule["conditions"].present?
151
+ has_percentage = rule.key?("percentage")
152
+
153
+ unless has_conditions || has_percentage
154
+ errors.add(:targeting_rules, "rule #{index} must have 'conditions' and/or 'percentage'")
155
+ end
156
+
157
+ if has_percentage
158
+ pct = rule["percentage"]
159
+ unless pct.is_a?(Numeric) && pct >= 0 && pct <= 100
160
+ errors.add(:targeting_rules, "rule #{index} percentage must be a number between 0 and 100")
161
+ end
162
+ end
150
163
  end
151
164
  end
152
165
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "murmurhash3"
4
+
3
5
  module Subflag
4
6
  module Rails
5
7
  # Evaluates targeting rules against evaluation contexts.
@@ -34,25 +36,56 @@ module Subflag
34
36
  #
35
37
  # @param rules [Array<Hash>, nil] Array of targeting rules with values
36
38
  # @param context [Hash, nil] Evaluation context with attributes
39
+ # @param flag_key [String, nil] The flag key (required for percentage rollouts)
37
40
  # @return [String, nil] The matched rule's value, or nil if no match
38
- def evaluate(rules, context)
41
+ def evaluate(rules, context, flag_key: nil)
39
42
  return nil if rules.nil? || rules.empty?
40
43
  return nil if context.nil? || context.empty?
41
44
 
42
- # Normalize context keys to strings for comparison
43
45
  normalized_context = normalize_context(context)
46
+ targeting_key = extract_targeting_key(normalized_context)
44
47
 
45
48
  rules.each do |rule|
46
49
  rule = rule.transform_keys(&:to_s)
47
50
  conditions = rule["conditions"]
48
- next unless conditions
51
+ percentage = rule["percentage"]
52
+
53
+ segment_matches = if conditions.nil? || conditions.empty?
54
+ true
55
+ else
56
+ evaluate_rule(conditions, normalized_context)
57
+ end
58
+
59
+ next unless segment_matches
49
60
 
50
- if evaluate_rule(conditions, normalized_context)
51
- return rule["value"]
61
+ if percentage
62
+ next unless targeting_key && flag_key
63
+ next unless evaluate_percentage(targeting_key, flag_key, percentage)
52
64
  end
65
+
66
+ return rule["value"]
53
67
  end
54
68
 
55
- nil # No rules matched
69
+ nil
70
+ end
71
+
72
+ # Evaluate percentage rollout using MurmurHash3
73
+ #
74
+ # @param targeting_key [String] Unique identifier for the context
75
+ # @param flag_key [String] The flag being evaluated
76
+ # @param percentage [Integer] Target percentage (0-100)
77
+ # @return [Boolean] true if context falls within percentage
78
+ def evaluate_percentage(targeting_key, flag_key, percentage)
79
+ percentage = percentage.to_i
80
+ return false if percentage <= 0
81
+ return true if percentage >= 100
82
+
83
+ hash_input = "#{targeting_key}:#{flag_key}"
84
+ hash_bytes = MurmurHash3::V128.str_hash(hash_input)
85
+ hash_code = [hash_bytes[0]].pack("L").unpack1("l")
86
+ bucket = hash_code.abs % 100
87
+
88
+ bucket < percentage
56
89
  end
57
90
 
58
91
  private
@@ -63,6 +96,10 @@ module Subflag
63
96
  end
64
97
  end
65
98
 
99
+ def extract_targeting_key(context)
100
+ context["targeting_key"] || context["targetingKey"]
101
+ end
102
+
66
103
  # Evaluate an AND/OR rule block
67
104
  def evaluate_rule(rule, context)
68
105
  rule = rule.transform_keys(&:to_s)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Subflag
4
4
  module Rails
5
- VERSION = "0.5.1"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: subflag-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Subflag
@@ -58,6 +58,20 @@ dependencies:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
60
  version: '6.1'
61
+ - !ruby/object:Gem::Dependency
62
+ name: murmurhash3
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.1.6
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.1.6
61
75
  - !ruby/object:Gem::Dependency
62
76
  name: bundler
63
77
  requirement: !ruby/object:Gem::Requirement