magick-feature-flags 1.2.3 → 1.3.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 +4 -4
- data/README.md +65 -7
- data/app/controllers/magick/adminui/features_controller.rb +30 -1
- data/app/views/layouts/application.html.erb +44 -0
- data/app/views/magick/adminui/features/edit.html.erb +52 -0
- data/app/views/magick/adminui/features/index.html.erb +3 -1
- data/app/views/magick/adminui/features/show.html.erb +46 -0
- data/config/routes.rb +1 -0
- data/lib/magick/dsl.rb +9 -0
- data/lib/magick/feature.rb +29 -25
- data/lib/magick/version.rb +1 -1
- data/lib/magick.rb +10 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 046dc5815e415705073eff7b3619c90bfd09a4af59b74d4116a3b2c206d5572e
|
|
4
|
+
data.tar.gz: 97bd2be3b303a73fa464c87ebbc6456098003601d236072c10e9a483e52c50b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85d499e307729dfd4742ec0535f5edde63fb5dc2706ee8b1189dd63f8ed7f9c8c6d7a784d9a7f59e988f394fb92f41acc84c176a9544fb417c892586dd6444f2
|
|
7
|
+
data.tar.gz: 589515a83297e0d3ce02332afa309741533f1cf91fa556044b9a1f4f63a2d327248eba80c1727baa08ed5ed476db401530ddd21645eab3d7028624f97f6ee204
|
data/README.md
CHANGED
|
@@ -5,6 +5,7 @@ A performant and memory-efficient feature toggle gem for Ruby and Rails applicat
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Multiple Feature Types**: Boolean, string, and number feature flags
|
|
8
|
+
- **A/B Testing**: Built-in experiment support with deterministic, weighted variant assignment
|
|
8
9
|
- **Flexible Targeting**: Enable features for specific users, groups, roles, tags, or percentages
|
|
9
10
|
- **Exclusions**: Exclude specific users, groups, roles, tags, or IPs — exclusions always take priority over inclusions
|
|
10
11
|
- **Dual Backend**: Memory adapter (fast) with Redis fallback (persistent)
|
|
@@ -359,6 +360,16 @@ string_feature :api_version, default: "v1", description: "API version"
|
|
|
359
360
|
# Number features
|
|
360
361
|
number_feature :max_results, default: 10, description: "Maximum results per page"
|
|
361
362
|
|
|
363
|
+
# A/B test experiment
|
|
364
|
+
experiment :checkout_button,
|
|
365
|
+
name: "Checkout Button",
|
|
366
|
+
description: "Button color experiment",
|
|
367
|
+
variants: [
|
|
368
|
+
{ name: 'control', value: '#0066cc', weight: 50 },
|
|
369
|
+
{ name: 'green', value: '#00cc66', weight: 30 },
|
|
370
|
+
{ name: 'red', value: '#cc0000', weight: 20 }
|
|
371
|
+
]
|
|
372
|
+
|
|
362
373
|
# With status
|
|
363
374
|
feature :experimental_feature,
|
|
364
375
|
type: :boolean,
|
|
@@ -405,20 +416,65 @@ end
|
|
|
405
416
|
|
|
406
417
|
### Advanced Features
|
|
407
418
|
|
|
408
|
-
####
|
|
419
|
+
#### A/B Testing (Experiments)
|
|
420
|
+
|
|
421
|
+
Magick has built-in support for A/B testing with deterministic variant assignment. The same user always gets the same variant (based on MD5 hashing), ensuring consistent experiences.
|
|
422
|
+
|
|
423
|
+
**Quick setup with DSL (`config/features.rb`):**
|
|
409
424
|
|
|
410
425
|
```ruby
|
|
411
|
-
|
|
426
|
+
experiment :checkout_button,
|
|
427
|
+
name: "Checkout Button Color",
|
|
428
|
+
description: "Test which button color converts better",
|
|
429
|
+
variants: [
|
|
430
|
+
{ name: 'control', value: '#0066cc', weight: 50 },
|
|
431
|
+
{ name: 'green', value: '#00cc66', weight: 30 },
|
|
432
|
+
{ name: 'red', value: '#cc0000', weight: 20 }
|
|
433
|
+
]
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Or set up programmatically:**
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
feature = Magick[:checkout_button]
|
|
412
440
|
feature.set_variants([
|
|
413
|
-
{ name: '
|
|
414
|
-
{ name: 'green',
|
|
415
|
-
{ name: 'red',
|
|
441
|
+
{ name: 'control', value: '#0066cc', weight: 50 },
|
|
442
|
+
{ name: 'green', value: '#00cc66', weight: 30 },
|
|
443
|
+
{ name: 'red', value: '#cc0000', weight: 20 }
|
|
416
444
|
])
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**Usage in your application:**
|
|
417
448
|
|
|
418
|
-
|
|
419
|
-
#
|
|
449
|
+
```ruby
|
|
450
|
+
# Get the variant name for a user (deterministic — same user always gets same variant)
|
|
451
|
+
variant = Magick.variant(:checkout_button, user_id: current_user.id)
|
|
452
|
+
# => "control", "green", or "red"
|
|
453
|
+
|
|
454
|
+
# Get the variant value directly
|
|
455
|
+
color = Magick.variant_value(:checkout_button, user_id: current_user.id)
|
|
456
|
+
# => "#0066cc", "#00cc66", or "#cc0000"
|
|
457
|
+
|
|
458
|
+
# Works with user objects too
|
|
459
|
+
variant = Magick.variant(:checkout_button, user: current_user)
|
|
460
|
+
|
|
461
|
+
# Use in views/controllers
|
|
462
|
+
class CheckoutController < ApplicationController
|
|
463
|
+
def show
|
|
464
|
+
@button_color = Magick.variant_value(:checkout_button, user: current_user)
|
|
465
|
+
# Same user always sees the same color
|
|
466
|
+
end
|
|
467
|
+
end
|
|
420
468
|
```
|
|
421
469
|
|
|
470
|
+
**How it works:**
|
|
471
|
+
- Variants are assigned using a deterministic MD5 hash of `feature_name + user_id`
|
|
472
|
+
- The same user always gets the same variant across sessions and requests
|
|
473
|
+
- Weights control the distribution (e.g., 50/30/20 means ~50% control, ~30% green, ~20% red)
|
|
474
|
+
- If no `user_id` is provided, falls back to random assignment (useful for anonymous users)
|
|
475
|
+
- Experiments are boolean features with variants — they work with all targeting and exclusion rules
|
|
476
|
+
- Manage variants through the Admin UI with visual weight distribution
|
|
477
|
+
|
|
422
478
|
#### Feature Dependencies
|
|
423
479
|
|
|
424
480
|
```ruby
|
|
@@ -676,6 +732,7 @@ Once mounted, visit `/magick` in your browser to access the Admin UI.
|
|
|
676
732
|
- **Exclusions**: Exclude users, roles, and tags from a feature (exclusions override inclusions)
|
|
677
733
|
- **Visual Display**: See all active targeting rules with badges
|
|
678
734
|
- **Edit Features**: Update feature values (boolean, string, number) directly from the UI
|
|
735
|
+
- **A/B Test Management**: Create and manage experiment variants with visual weight distribution
|
|
679
736
|
- **Statistics**: View performance metrics and usage statistics for each feature
|
|
680
737
|
- **Feature Grouping**: Organize features into groups for easier management and filtering
|
|
681
738
|
- **Filtering**: Filter features by group, name, or description
|
|
@@ -749,6 +806,7 @@ The Admin UI provides the following routes:
|
|
|
749
806
|
- `PUT /magick/features/:id/enable_for_role` - Enable feature for specific role
|
|
750
807
|
- `PUT /magick/features/:id/disable_for_role` - Disable feature for specific role
|
|
751
808
|
- `PUT /magick/features/:id/update_targeting` - Update targeting rules (roles and users)
|
|
809
|
+
- `PUT /magick/features/:id/update_variants` - Update A/B test variants
|
|
752
810
|
- `GET /magick/stats/:id` - View feature statistics
|
|
753
811
|
|
|
754
812
|
**Security:**
|
|
@@ -7,7 +7,7 @@ module Magick
|
|
|
7
7
|
include Magick::AdminUI::Engine.routes.url_helpers
|
|
8
8
|
layout 'application'
|
|
9
9
|
before_action :authenticate_admin!
|
|
10
|
-
before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting]
|
|
10
|
+
before_action :set_feature, only: %i[show edit update enable disable enable_for_user enable_for_role disable_for_role update_targeting update_variants]
|
|
11
11
|
|
|
12
12
|
# Make route helpers available in views via magick_admin_ui helper
|
|
13
13
|
helper_method :magick_admin_ui, :available_roles, :available_tags, :partially_enabled?
|
|
@@ -267,6 +267,35 @@ module Magick
|
|
|
267
267
|
redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating targeting: #{e.message}"
|
|
268
268
|
end
|
|
269
269
|
|
|
270
|
+
def update_variants
|
|
271
|
+
variants_data = []
|
|
272
|
+
|
|
273
|
+
if params[:variants].present?
|
|
274
|
+
params[:variants].each do |_index, variant_params|
|
|
275
|
+
next if variant_params[:name].blank?
|
|
276
|
+
|
|
277
|
+
variants_data << {
|
|
278
|
+
name: variant_params[:name].strip,
|
|
279
|
+
value: variant_params[:value].to_s.strip,
|
|
280
|
+
weight: (variant_params[:weight].presence || 0).to_f
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if variants_data.any?
|
|
286
|
+
@feature.set_variants(variants_data)
|
|
287
|
+
else
|
|
288
|
+
# Clear variants if all removed
|
|
289
|
+
@feature.instance_variable_get(:@targeting)&.delete(:variants)
|
|
290
|
+
@feature.save_targeting
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), notice: 'Variants updated successfully'
|
|
294
|
+
rescue StandardError => e
|
|
295
|
+
Rails.logger.error "Magick: Error updating variants: #{e.message}" if defined?(Rails)
|
|
296
|
+
redirect_to magick_admin_ui.feature_path(@feature.name), alert: "Error updating variants: #{e.message}"
|
|
297
|
+
end
|
|
298
|
+
|
|
270
299
|
private
|
|
271
300
|
|
|
272
301
|
def authenticate_admin!
|
|
@@ -1291,6 +1291,50 @@
|
|
|
1291
1291
|
searchInput.addEventListener('input', filterFeatures);
|
|
1292
1292
|
if (groupSelect) groupSelect.addEventListener('change', filterFeatures);
|
|
1293
1293
|
})();
|
|
1294
|
+
|
|
1295
|
+
// Variants management
|
|
1296
|
+
(function() {
|
|
1297
|
+
const addBtn = document.getElementById('add_variant_btn');
|
|
1298
|
+
const container = document.getElementById('variants_container');
|
|
1299
|
+
if (!addBtn || !container) return;
|
|
1300
|
+
|
|
1301
|
+
let variantIndex = container.querySelectorAll('.variant-row').length;
|
|
1302
|
+
|
|
1303
|
+
addBtn.addEventListener('click', function() {
|
|
1304
|
+
const noMsg = document.getElementById('no_variants_message');
|
|
1305
|
+
if (noMsg) noMsg.remove();
|
|
1306
|
+
|
|
1307
|
+
const row = document.createElement('div');
|
|
1308
|
+
row.className = 'variant-row';
|
|
1309
|
+
row.setAttribute('data-variant-index', variantIndex);
|
|
1310
|
+
row.innerHTML = '<div style="display: grid; grid-template-columns: 1fr 1fr auto auto; gap: 12px; align-items: end; margin-bottom: 12px; padding: 12px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm);">' +
|
|
1311
|
+
'<div><label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Name</label>' +
|
|
1312
|
+
'<input type="text" name="variants[' + variantIndex + '][name]" placeholder="e.g., control" required></div>' +
|
|
1313
|
+
'<div><label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Value</label>' +
|
|
1314
|
+
'<input type="text" name="variants[' + variantIndex + '][value]" placeholder="e.g., #0066cc"></div>' +
|
|
1315
|
+
'<div><label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Weight</label>' +
|
|
1316
|
+
'<input type="number" name="variants[' + variantIndex + '][weight]" value="0" min="0" max="1000" style="width: 80px;"></div>' +
|
|
1317
|
+
'<div style="padding-bottom: 2px;"><button type="button" class="btn btn-ghost btn-sm remove-variant-btn" onclick="this.closest(\'.variant-row\').remove(); updateVariantIndices();" title="Remove variant" style="color: var(--color-danger);">×</button></div>' +
|
|
1318
|
+
'</div>';
|
|
1319
|
+
container.appendChild(row);
|
|
1320
|
+
variantIndex++;
|
|
1321
|
+
});
|
|
1322
|
+
})();
|
|
1323
|
+
|
|
1324
|
+
function updateVariantIndices() {
|
|
1325
|
+
const container = document.getElementById('variants_container');
|
|
1326
|
+
if (!container) return;
|
|
1327
|
+
const rows = container.querySelectorAll('.variant-row');
|
|
1328
|
+
rows.forEach(function(row, i) {
|
|
1329
|
+
row.setAttribute('data-variant-index', i);
|
|
1330
|
+
row.querySelectorAll('input').forEach(function(input) {
|
|
1331
|
+
const name = input.getAttribute('name');
|
|
1332
|
+
if (name) {
|
|
1333
|
+
input.setAttribute('name', name.replace(/variants\[\d+\]/, 'variants[' + i + ']'));
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1294
1338
|
</script>
|
|
1295
1339
|
</body>
|
|
1296
1340
|
</html>
|
|
@@ -278,3 +278,55 @@
|
|
|
278
278
|
<% end %>
|
|
279
279
|
</div>
|
|
280
280
|
</div>
|
|
281
|
+
|
|
282
|
+
<div class="card" id="variants-section">
|
|
283
|
+
<div class="card-header">
|
|
284
|
+
<h2>A/B Test Variants</h2>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="card-body">
|
|
287
|
+
<%= form_with url: magick_admin_ui.update_variants_feature_path(@feature.name), method: :put, local: true do |f| %>
|
|
288
|
+
<p style="font-size: 13px; color: var(--color-text-secondary); margin-bottom: 16px;">Define experiment variants with weights. Users are deterministically assigned to variants based on their user ID.</p>
|
|
289
|
+
|
|
290
|
+
<% current_variants = (@feature.instance_variable_get(:@targeting) || {})[:variants] || [] %>
|
|
291
|
+
|
|
292
|
+
<div id="variants_container">
|
|
293
|
+
<% if current_variants.any? %>
|
|
294
|
+
<% current_variants.each_with_index do |variant, index| %>
|
|
295
|
+
<div class="variant-row" data-variant-index="<%= index %>">
|
|
296
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr auto auto; gap: 12px; align-items: end; margin-bottom: 12px; padding: 12px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm);">
|
|
297
|
+
<div>
|
|
298
|
+
<label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Name</label>
|
|
299
|
+
<input type="text" name="variants[<%= index %>][name]" value="<%= variant[:name] || variant['name'] %>" placeholder="e.g., control" required>
|
|
300
|
+
</div>
|
|
301
|
+
<div>
|
|
302
|
+
<label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Value</label>
|
|
303
|
+
<input type="text" name="variants[<%= index %>][value]" value="<%= variant[:value] || variant['value'] %>" placeholder="e.g., #0066cc">
|
|
304
|
+
</div>
|
|
305
|
+
<div>
|
|
306
|
+
<label class="form-label" style="margin-bottom: 4px; font-size: 12px;">Weight</label>
|
|
307
|
+
<input type="number" name="variants[<%= index %>][weight]" value="<%= (variant[:weight] || variant['weight'] || 0).to_i %>" min="0" max="1000" style="width: 80px;">
|
|
308
|
+
</div>
|
|
309
|
+
<div style="padding-bottom: 2px;">
|
|
310
|
+
<button type="button" class="btn btn-ghost btn-sm remove-variant-btn" onclick="this.closest('.variant-row').remove(); updateVariantIndices();" title="Remove variant" style="color: var(--color-danger);">×</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
<% end %>
|
|
315
|
+
<% else %>
|
|
316
|
+
<p id="no_variants_message" style="color: var(--color-text-muted); font-size: 13px; margin-bottom: 16px;">No variants configured. Add variants to create an A/B test.</p>
|
|
317
|
+
<% end %>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<div style="margin-bottom: 20px;">
|
|
321
|
+
<button type="button" class="btn btn-secondary btn-sm" id="add_variant_btn">+ Add Variant</button>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<div style="padding-top: 20px; border-top: 1px solid var(--color-border);">
|
|
325
|
+
<div class="btn-group">
|
|
326
|
+
<%= submit_tag 'Save Variants', class: 'btn btn-primary' %>
|
|
327
|
+
<a href="<%= magick_admin_ui.feature_path(@feature.name) %>" class="btn btn-secondary">Cancel</a>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
<% end %>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
@@ -76,7 +76,9 @@
|
|
|
76
76
|
<td data-label="Value">
|
|
77
77
|
<% if feature.type == :boolean %>
|
|
78
78
|
<% targeting = feature.instance_variable_get(:@targeting) || {} %>
|
|
79
|
-
<% if targeting.
|
|
79
|
+
<% if targeting[:variants].present? %>
|
|
80
|
+
<span class="toggle-indicator" style="background: var(--color-primary); color: #fff;">A/B</span>
|
|
81
|
+
<% elsif targeting.any? %>
|
|
80
82
|
<span class="toggle-indicator toggle-partial">PARTIAL</span>
|
|
81
83
|
<% elsif feature.enabled? %>
|
|
82
84
|
<span class="toggle-indicator toggle-on">ON</span>
|
|
@@ -138,6 +138,52 @@
|
|
|
138
138
|
</div>
|
|
139
139
|
</div>
|
|
140
140
|
|
|
141
|
+
<% variants = (@feature.instance_variable_get(:@targeting) || {})[:variants] %>
|
|
142
|
+
<% if variants.present? && variants.any? %>
|
|
143
|
+
<div class="card">
|
|
144
|
+
<div class="card-header">
|
|
145
|
+
<h2>A/B Test Variants</h2>
|
|
146
|
+
<a href="<%= magick_admin_ui.edit_feature_path(@feature.name) %>#variants-section" class="link-subtle">Manage</a>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="card-body" style="padding: 0;">
|
|
149
|
+
<table>
|
|
150
|
+
<thead>
|
|
151
|
+
<tr>
|
|
152
|
+
<th>Variant</th>
|
|
153
|
+
<th>Value</th>
|
|
154
|
+
<th style="text-align: right;">Weight</th>
|
|
155
|
+
<th style="text-align: right;">Distribution</th>
|
|
156
|
+
</tr>
|
|
157
|
+
</thead>
|
|
158
|
+
<tbody>
|
|
159
|
+
<% total_weight = variants.sum { |v| (v[:weight] || v['weight'] || 0).to_f } %>
|
|
160
|
+
<% variants.each do |variant| %>
|
|
161
|
+
<% v_name = variant[:name] || variant['name'] %>
|
|
162
|
+
<% v_value = variant[:value] || variant['value'] %>
|
|
163
|
+
<% v_weight = (variant[:weight] || variant['weight'] || 0).to_f %>
|
|
164
|
+
<% percentage = total_weight > 0 ? (v_weight / total_weight * 100).round(1) : 0 %>
|
|
165
|
+
<tr>
|
|
166
|
+
<td>
|
|
167
|
+
<span style="font-weight: 600;"><%= v_name %></span>
|
|
168
|
+
</td>
|
|
169
|
+
<td><code><%= v_value %></code></td>
|
|
170
|
+
<td style="text-align: right; font-variant-numeric: tabular-nums;"><%= v_weight.to_i %></td>
|
|
171
|
+
<td style="text-align: right;">
|
|
172
|
+
<div style="display: flex; align-items: center; justify-content: flex-end; gap: 8px;">
|
|
173
|
+
<div style="width: 80px; height: 6px; background: var(--color-border); border-radius: 3px; overflow: hidden;">
|
|
174
|
+
<div style="width: <%= percentage %>%; height: 100%; background: var(--color-primary); border-radius: 3px;"></div>
|
|
175
|
+
</div>
|
|
176
|
+
<span style="font-size: 12px; font-weight: 500; color: var(--color-text-secondary); min-width: 40px; text-align: right;"><%= percentage %>%</span>
|
|
177
|
+
</div>
|
|
178
|
+
</td>
|
|
179
|
+
</tr>
|
|
180
|
+
<% end %>
|
|
181
|
+
</tbody>
|
|
182
|
+
</table>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<% end %>
|
|
186
|
+
|
|
141
187
|
<% dependencies = @feature.dependencies %>
|
|
142
188
|
<% if dependencies.any? %>
|
|
143
189
|
<div class="card">
|
data/config/routes.rb
CHANGED
data/lib/magick/dsl.rb
CHANGED
|
@@ -19,6 +19,15 @@ module Magick
|
|
|
19
19
|
Magick.register_feature(name, type: :number, default_value: default, **options)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def experiment(name, variants:, **options)
|
|
23
|
+
feature = Magick.register_feature(name, type: :boolean, default_value: false, **options)
|
|
24
|
+
variant_data = variants.map do |v|
|
|
25
|
+
{ name: v[:name].to_s, value: v[:value], weight: (v[:weight] || 0).to_f }
|
|
26
|
+
end
|
|
27
|
+
feature.set_variants(variant_data)
|
|
28
|
+
feature
|
|
29
|
+
end
|
|
30
|
+
|
|
22
31
|
# Targeting DSL methods
|
|
23
32
|
def enable_for_user(feature_name, user_id)
|
|
24
33
|
Magick[feature_name].enable_for_user(user_id)
|
data/lib/magick/feature.rb
CHANGED
|
@@ -442,34 +442,38 @@ module Magick
|
|
|
442
442
|
return nil unless targeting[:variants]
|
|
443
443
|
|
|
444
444
|
variants = targeting[:variants]
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
end
|
|
462
|
-
end
|
|
463
|
-
selected || variants.first[:name]
|
|
464
|
-
end
|
|
465
|
-
end
|
|
445
|
+
return nil if variants.empty?
|
|
446
|
+
return variants.first[:name] if variants.length == 1
|
|
447
|
+
|
|
448
|
+
total_weight = variants.sum { |v| v[:weight] || 0 }
|
|
449
|
+
return variants.first[:name] if total_weight.zero?
|
|
450
|
+
|
|
451
|
+
# Deterministic assignment: use MD5 hash of feature_name + user_id
|
|
452
|
+
# This ensures the same user always gets the same variant
|
|
453
|
+
user_id = context[:user_id] || context[:user]&.respond_to?(:id) && context[:user].id
|
|
454
|
+
if user_id
|
|
455
|
+
hash = Digest::MD5.hexdigest("#{name}:variant:#{user_id}")
|
|
456
|
+
bucket = hash[0..7].to_i(16) % total_weight
|
|
457
|
+
else
|
|
458
|
+
# No user context — fall back to random (e.g., anonymous requests)
|
|
459
|
+
bucket = rand(total_weight)
|
|
460
|
+
end
|
|
466
461
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
462
|
+
current = 0
|
|
463
|
+
variants.each do |variant|
|
|
464
|
+
current += (variant[:weight] || 0)
|
|
465
|
+
return variant[:name] if bucket < current
|
|
470
466
|
end
|
|
471
467
|
|
|
472
|
-
|
|
468
|
+
variants.last[:name]
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def get_variant_value(context = {})
|
|
472
|
+
variant_name = get_variant(context)
|
|
473
|
+
return nil unless variant_name
|
|
474
|
+
|
|
475
|
+
variant = targeting[:variants]&.find { |v| v[:name] == variant_name }
|
|
476
|
+
variant&.dig(:value)
|
|
473
477
|
end
|
|
474
478
|
|
|
475
479
|
def set_value(value, user_id: nil)
|
data/lib/magick/version.rb
CHANGED
data/lib/magick.rb
CHANGED
|
@@ -150,6 +150,16 @@ module Magick
|
|
|
150
150
|
!enabled?(feature_name, context)
|
|
151
151
|
end
|
|
152
152
|
|
|
153
|
+
def variant(feature_name, context = {})
|
|
154
|
+
feature = features[feature_name.to_s] || self[feature_name]
|
|
155
|
+
feature.get_variant(context)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def variant_value(feature_name, context = {})
|
|
159
|
+
feature = features[feature_name.to_s] || self[feature_name]
|
|
160
|
+
feature.get_variant_value(context)
|
|
161
|
+
end
|
|
162
|
+
|
|
153
163
|
def exists?(feature_name)
|
|
154
164
|
features.key?(feature_name.to_s) || (adapter_registry || default_adapter_registry).exists?(feature_name)
|
|
155
165
|
end
|