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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +12 -0
- data/app/views/subflag/rails/flags/edit.html.erb +58 -51
- data/lib/subflag/rails/models/flag.rb +15 -2
- data/lib/subflag/rails/targeting_engine.rb +43 -6
- data/lib/subflag/rails/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67d1ea6917f303ab8a78a5ffa90a9d5cfc3d6c30b7c13cd039f89cdd20365402
|
|
4
|
+
data.tar.gz: f8e5269abd5ef6ec77d8a4288b8a5ce91d114dcf96905d56c7aab5db368a1833
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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: [
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
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)
|
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.
|
|
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
|