subflag-rails 0.5.1 → 0.6.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: 56034c2de3a47fbbb96f97aa9f1149c87c58bc1558b50a7520db7b07a93c3e85
4
- data.tar.gz: dc818a9034753adfa51b2cd810a5affe7c3cb9e30ff9943c67e0df01d0c6cf66
3
+ metadata.gz: 67d1ea6917f303ab8a78a5ffa90a9d5cfc3d6c30b7c13cd039f89cdd20365402
4
+ data.tar.gz: f8e5269abd5ef6ec77d8a4288b8a5ce91d114dcf96905d56c7aab5db368a1833
5
5
  SHA512:
6
- metadata.gz: ca26926074b168811199ae8fe3e065843073dcbae20667a9951a641bfb0e02e0d30e11ed16ae7376a1cf75c5f8ffc46e8012929494ba7d142a80260732ee4e0c
7
- data.tar.gz: c8b0af027aa0cf5d01bdac26b2a7681c05fa818bfe2a92d1d178a3c8cd67cc0d94f555b495b132593009c2272ad3b3b60b61b6b93087109321c99bffdef446a3
6
+ metadata.gz: 7ba49356001ccb5676eccb9417e54bafc382bbc5ff0af19ad95a7567f4ac7a1fc1c2bd50505fad9d40ca4528e26e18c71900b423de149f91395c41686ce30b17
7
+ data.tar.gz: 18a47da01c9c44396faf29d4fe2234e4132f2479ff247aa5764f09fe0b27b361ceec05c65057f8cb8e5380ed2b9a319aa8a6fb7499882aa52ee38dcf7f0dcfab
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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
+ - Admin UI: percentage field in targeting rules editor
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
+ - Admin UI: targeting rules now saved via fetch API with proper redirect handling
20
+
5
21
  ## [0.5.1] - 2025-12-15
6
22
 
7
23
  ### 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
@@ -49,6 +49,8 @@
49
49
  .rule-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
50
50
  .rule-value { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
51
51
  .rule-value input { width: 200px; }
52
+ .rule-percentage { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
53
+ .rule-percentage input { width: 80px; }
52
54
  .rule-logic { margin-bottom: 10px; }
53
55
  .rule-logic label { margin-right: 15px; }
54
56
  .condition { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
@@ -94,6 +96,8 @@
94
96
  function renderRule(rule, ruleIndex) {
95
97
  const conditions = rule.conditions?.conditions || [];
96
98
  const logicType = rule.conditions?.type || "AND";
99
+ const percentage = rule.percentage;
100
+ const hasConditions = conditions.length > 0;
97
101
 
98
102
  return `
99
103
  <div class="rule" data-rule-index="${ruleIndex}">
@@ -105,7 +109,13 @@
105
109
  <label>Return value:</label>
106
110
  <input type="text" value="${escapeHtml(rule.value || '')}" onchange="updateRuleValue(${ruleIndex}, this.value)" placeholder="value when matched">
107
111
  </div>
108
- <div class="rule-logic">
112
+ <div class="rule-percentage">
113
+ <label>Percentage rollout:</label>
114
+ <input type="number" min="0" max="100" value="${percentage ?? ''}" onchange="updateRulePercentage(${ruleIndex}, this.value)" placeholder="">
115
+ <span>% of users</span>
116
+ <small style="color: #666;">(leave empty to match all)</small>
117
+ </div>
118
+ <div class="rule-logic" ${!hasConditions && percentage ? 'style="opacity: 0.5;"' : ''}>
109
119
  <label><input type="radio" name="logic-${ruleIndex}" value="AND" ${logicType === 'AND' ? 'checked' : ''} onchange="updateLogic(${ruleIndex}, 'AND')"> ALL match</label>
110
120
  <label><input type="radio" name="logic-${ruleIndex}" value="OR" ${logicType === 'OR' ? 'checked' : ''} onchange="updateLogic(${ruleIndex}, 'OR')"> ANY match</label>
111
121
  </div>
@@ -113,6 +123,7 @@
113
123
  ${conditions.map((c, cIndex) => renderCondition(c, ruleIndex, cIndex)).join("")}
114
124
  </div>
115
125
  <button type="button" class="btn btn-sm" onclick="addCondition(${ruleIndex})">+ Add Condition</button>
126
+ <small style="display: block; margin-top: 5px; color: #666;">${hasConditions && percentage ? 'User must match conditions AND be in percentage' : ''}</small>
116
127
  </div>
117
128
  `;
118
129
  }
@@ -139,7 +150,7 @@
139
150
  function addRule() {
140
151
  rules.push({
141
152
  value: "",
142
- conditions: { type: "AND", conditions: [{ attribute: "", operator: "EQUALS", value: "" }] }
153
+ conditions: { type: "AND", conditions: [] }
143
154
  });
144
155
  renderRules();
145
156
  }
@@ -153,6 +164,15 @@
153
164
  rules[ruleIndex].value = value;
154
165
  }
155
166
 
167
+ function updateRulePercentage(ruleIndex, value) {
168
+ if (value === '' || value === null) {
169
+ delete rules[ruleIndex].percentage;
170
+ } else {
171
+ rules[ruleIndex].percentage = parseInt(value, 10);
172
+ }
173
+ renderRules();
174
+ }
175
+
156
176
  function updateLogic(ruleIndex, type) {
157
177
  rules[ruleIndex].conditions.type = type;
158
178
  }
@@ -180,55 +200,42 @@
180
200
  }
181
201
 
182
202
  function saveRules() {
183
- const form = document.createElement("form");
184
- form.method = "POST";
185
- form.action = "<%= flag_path(@flag) %>";
186
-
187
- const methodInput = document.createElement("input");
188
- methodInput.type = "hidden";
189
- methodInput.name = "_method";
190
- methodInput.value = "PATCH";
191
- form.appendChild(methodInput);
192
-
193
- const csrfInput = document.createElement("input");
194
- csrfInput.type = "hidden";
195
- csrfInput.name = "<%= request_forgery_protection_token %>";
196
- csrfInput.value = "<%= form_authenticity_token %>";
197
- form.appendChild(csrfInput);
198
-
199
- // Send all flag params to preserve other fields
200
- const keyInput = document.createElement("input");
201
- keyInput.type = "hidden";
202
- keyInput.name = "flag[key]";
203
- keyInput.value = "<%= @flag.key %>";
204
- form.appendChild(keyInput);
205
-
206
- const valueInput = document.createElement("input");
207
- valueInput.type = "hidden";
208
- valueInput.name = "flag[value]";
209
- valueInput.value = "<%= @flag.value %>";
210
- form.appendChild(valueInput);
211
-
212
- const typeInput = document.createElement("input");
213
- typeInput.type = "hidden";
214
- typeInput.name = "flag[value_type]";
215
- typeInput.value = "<%= @flag.value_type %>";
216
- form.appendChild(typeInput);
217
-
218
- const enabledInput = document.createElement("input");
219
- enabledInput.type = "hidden";
220
- enabledInput.name = "flag[enabled]";
221
- enabledInput.value = "<%= @flag.enabled? %>";
222
- form.appendChild(enabledInput);
223
-
224
- const rulesInput = document.createElement("input");
225
- rulesInput.type = "hidden";
226
- rulesInput.name = "flag[targeting_rules]";
227
- rulesInput.value = JSON.stringify(rules);
228
- form.appendChild(rulesInput);
229
-
230
- document.body.appendChild(form);
231
- form.submit();
203
+ // Clean up rules before saving - filter out invalid rules
204
+ const cleanedRules = rules.map(rule => {
205
+ const cleaned = { value: rule.value };
206
+ if (rule.percentage !== undefined && rule.percentage !== null && rule.percentage !== '') {
207
+ cleaned.percentage = rule.percentage;
208
+ }
209
+ const conditions = rule.conditions?.conditions?.filter(c => c.attribute && c.operator);
210
+ if (conditions && conditions.length > 0) {
211
+ cleaned.conditions = { type: rule.conditions?.type || "AND", conditions };
212
+ }
213
+ return cleaned;
214
+ }).filter(rule => rule.conditions || rule.percentage !== undefined);
215
+
216
+ const formData = new FormData();
217
+ formData.append("flag[key]", "<%= @flag.key %>");
218
+ formData.append("flag[value]", "<%= @flag.value %>");
219
+ formData.append("flag[value_type]", "<%= @flag.value_type %>");
220
+ formData.append("flag[enabled]", "<%= @flag.enabled? %>");
221
+ formData.append("flag[targeting_rules]", JSON.stringify(cleanedRules));
222
+
223
+ fetch("<%= flag_path(@flag) %>", {
224
+ method: "PATCH",
225
+ headers: {
226
+ "X-CSRF-Token": "<%= form_authenticity_token %>",
227
+ },
228
+ body: formData,
229
+ redirect: "manual"
230
+ }).then(response => {
231
+ if (response.ok || response.type === "opaqueredirect" || response.status === 302) {
232
+ window.location.reload();
233
+ } else {
234
+ alert("Failed to save rules: " + response.status);
235
+ }
236
+ }).catch(err => {
237
+ alert("Error saving rules: " + err.message);
238
+ });
232
239
  }
233
240
 
234
241
  function escapeHtml(str) {
@@ -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.1"
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.1
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