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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc65d262824128014a4e5e631001687be36d698efbc1adc5f222685d96b10613
4
- data.tar.gz: d2e15a3828b13071ee573a9c6356215e311012d16d55817a0fdb2aeee51a7172
3
+ metadata.gz: 046dc5815e415705073eff7b3619c90bfd09a4af59b74d4116a3b2c206d5572e
4
+ data.tar.gz: 97bd2be3b303a73fa464c87ebbc6456098003601d236072c10e9a483e52c50b2
5
5
  SHA512:
6
- metadata.gz: 6a932f9c887525e88dfe8e6392803a90dee47a9891fb6fa04c2c18561b9203518442d8b46590a20d85b32a2c1d5d75005686f652ed24495b90747aca44e4e4ee
7
- data.tar.gz: 40ac9092db1747cc50e79c94045ba80118274fcd198b57c70a6ad0c17e4b693578ae12f785d152d148684bf9b365e001a51b338174e47d2f1db2dcb5a74b830b
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
- #### Feature Variants (A/B Testing)
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
- feature = Magick[:button_color]
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: 'blue', value: '#0066cc', weight: 50 },
414
- { name: 'green', value: '#00cc66', weight: 30 },
415
- { name: 'red', value: '#cc0000', weight: 20 }
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
- variant = feature.get_variant
419
- # Returns 'blue', 'green', or 'red' based on weights
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);">&times;</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);">&times;</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.any? %>
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
@@ -10,6 +10,7 @@ Magick::AdminUI::Engine.routes.draw do
10
10
  put :enable_for_role
11
11
  put :disable_for_role
12
12
  put :update_targeting
13
+ put :update_variants
13
14
  end
14
15
  end
15
16
  resources :stats, only: [:show]
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)
@@ -442,34 +442,38 @@ module Magick
442
442
  return nil unless targeting[:variants]
443
443
 
444
444
  variants = targeting[:variants]
445
- selected_variant = if variants.length == 1
446
- variants.first[:name]
447
- else
448
- # Weighted random selection
449
- total_weight = variants.sum { |v| v[:weight] || 0 }
450
- if total_weight.zero?
451
- variants.first[:name]
452
- else
453
- random = rand(total_weight)
454
- current = 0
455
- selected = nil
456
- variants.each do |variant|
457
- current += variant[:weight] || 0
458
- if random < current
459
- selected = variant[:name]
460
- break
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
- # Rails 8+ event
468
- if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
469
- Magick::Rails::Events.variant_selected(name, variant_name: selected_variant, context: context)
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
- selected_variant
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Magick
4
- VERSION = '1.2.3'
4
+ VERSION = '1.3.0'
5
5
  end
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: magick-feature-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.3
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Lobanov