Class: Magick::Feature
- Inherits:
-
Object
- Object
- Magick::Feature
- Defined in:
- lib/magick/feature.rb
Constant Summary collapse
- VALID_TYPES =
%i[boolean string number].freeze
- VALID_STATUSES =
%i[active inactive deprecated].freeze
Instance Attribute Summary collapse
-
#adapter_registry ⇒ Object
readonly
Returns the value of attribute adapter_registry.
-
#default_value ⇒ Object
readonly
Returns the value of attribute default_value.
-
#description ⇒ Object
readonly
Returns the value of attribute description.
-
#display_name ⇒ Object
readonly
Returns the value of attribute display_name.
-
#group ⇒ Object
readonly
Returns the value of attribute group.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#status ⇒ Object
readonly
Returns the value of attribute status.
-
#targeting ⇒ Object
readonly
Returns the value of attribute targeting.
-
#type ⇒ Object
readonly
Returns the value of attribute type.
Instance Method Summary collapse
- #add_dependency(dependency_name) ⇒ Object
-
#as_json(_options = nil) ⇒ Object
Wire-format serializer for control-plane APIs (e.g. the platform's /internal/panel/flags endpoints).
- #check_enabled(context = {}) ⇒ Object
- #delete ⇒ Object
- #dependencies ⇒ Object
- #disable(user_id: nil) ⇒ Object
- #disable_custom_attribute(attribute_name) ⇒ Object
- #disable_date_range ⇒ Object
- #disable_for_group(group_name) ⇒ Object
- #disable_for_role(role_name) ⇒ Object
- #disable_for_tag(tag_name) ⇒ Object
- #disable_for_user(user_id) ⇒ Object
- #disable_ip_addresses ⇒ Object
- #disable_percentage_of_requests ⇒ Object
- #disable_percentage_of_users ⇒ Object
- #disabled?(context = {}) ⇒ Boolean
- #disabled_for?(object, **additional_context) ⇒ Boolean
- #enable(user_id: nil) ⇒ Object
- #enable_for_custom_attribute(attribute_name, values, operator: :equals) ⇒ Object
- #enable_for_date_range(start_date, end_date) ⇒ Object
- #enable_for_group(group_name) ⇒ Object
- #enable_for_ip_addresses(ip_addresses) ⇒ Object
- #enable_for_role(role_name) ⇒ Object
- #enable_for_tag(tag_name) ⇒ Object
- #enable_for_user(user_id) ⇒ Object
- #enable_percentage_of_requests(percentage) ⇒ Object
- #enable_percentage_of_users(percentage) ⇒ Object
- #enabled?(context = {}) ⇒ Boolean
- #enabled_for?(object, **additional_context) ⇒ Boolean
- #exclude_group(group_name) ⇒ Object
- #exclude_ip_addresses(ip_addresses) ⇒ Object
- #exclude_role(role_name) ⇒ Object
- #exclude_tag(tag_name) ⇒ Object
-
#exclude_user(user_id) ⇒ Object
--- Exclusion methods ---.
- #get_value(context = {}) ⇒ Object
- #get_variant(context = {}) ⇒ Object
- #get_variant_value(context = {}) ⇒ Object
-
#initialize(name, adapter_registry, **options) ⇒ Feature
constructor
A new instance of Feature.
-
#reload ⇒ Object
Reload feature state from adapter (useful when feature is changed externally).
-
#reload_from_source! ⇒ Object
Reload feature state from the shared, authoritative backend (ActiveRecord/ Redis), bypassing this process's local in-process memory cache.
- #remove_dependency(dependency_name) ⇒ Object
- #remove_group_exclusion(group_name) ⇒ Object
- #remove_ip_exclusion ⇒ Object
- #remove_role_exclusion(role_name) ⇒ Object
- #remove_tag_exclusion(tag_name) ⇒ Object
- #remove_user_exclusion(user_id) ⇒ Object
-
#replace_targeting(payload = nil, user_id: nil, **inline_rules) ⇒ Object
Wholesale, declarative targeting write: the payload IS the new targeting state.
-
#restore_snapshot!(data) ⇒ Object
Restore full feature state from a version snapshot (used by Versioning#rollback).
- #save_targeting ⇒ Object
- #set_group(group_name) ⇒ Object
- #set_status(new_status) ⇒ Object
- #set_value(value, user_id: nil) ⇒ Object
- #set_variants(variants) ⇒ Object
- #to_h ⇒ Object
- #value(context = {}) ⇒ Object
- #variants_for_export ⇒ Object
Constructor Details
#initialize(name, adapter_registry, **options) ⇒ Feature
Returns a new instance of Feature.
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/magick/feature.rb', line 15 def initialize(name, adapter_registry, **) @name = name.to_s @adapter_registry = adapter_registry @type = ([:type] || :boolean).to_sym @status = ([:status] || :active).to_sym @default_value = .fetch(:default_value, default_for_type) @description = [:description] @display_name = [:name] || [:display_name] @group = [:group] @targeting = {} @dependencies = [:dependencies] ? Array([:dependencies]) : [] @stored_value_initialized = false # Track if @stored_value has been explicitly set # Performance optimizations: cache expensive checks @_targeting_empty = true # Will be updated after load_from_adapter @_rails_events_enabled = false # Cache Rails events availability (only enable in dev) @_perf_metrics_enabled = false # Cache performance metrics (disabled by default for speed) validate_type! validate_default_value! load_from_adapter # Update targeting empty cache after loading @_targeting_empty = @targeting.empty? # Cache performance metrics availability (check once, not on every call) # Only enable if performance_metrics exists AND is actually being used @_perf_metrics_enabled = !Magick.performance_metrics.nil? # Save description and display_name to adapter if they were provided and not already in adapter end |
Instance Attribute Details
#adapter_registry ⇒ Object (readonly)
Returns the value of attribute adapter_registry.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def adapter_registry @adapter_registry end |
#default_value ⇒ Object (readonly)
Returns the value of attribute default_value.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def default_value @default_value end |
#description ⇒ Object (readonly)
Returns the value of attribute description.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def description @description end |
#display_name ⇒ Object (readonly)
Returns the value of attribute display_name.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def display_name @display_name end |
#group ⇒ Object (readonly)
Returns the value of attribute group.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def group @group end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def name @name end |
#status ⇒ Object (readonly)
Returns the value of attribute status.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def status @status end |
#targeting ⇒ Object (readonly)
Returns the value of attribute targeting.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def targeting @targeting end |
#type ⇒ Object (readonly)
Returns the value of attribute type.
12 13 14 |
# File 'lib/magick/feature.rb', line 12 def type @type end |
Instance Method Details
#add_dependency(dependency_name) ⇒ Object
499 500 501 502 503 504 505 506 507 508 509 510 511 |
# File 'lib/magick/feature.rb', line 499 def add_dependency(dependency_name) record_change('add_dependency', { dependency: { added: dependency_name.to_s } }) do @dependencies ||= [] @dependencies << dependency_name.to_s unless @dependencies.include?(dependency_name.to_s) # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.dependency_added(name, dependency_name) end end true end |
#as_json(_options = nil) ⇒ Object
Wire-format serializer for control-plane APIs (e.g. the platform's
/internal/panel/flags endpoints). The "targeting" key is ALWAYS present
({} = no targeting), array rules are arrays of strings, percentages are
floats, and the internal :variants entry never appears inside targeting.
Rails-idiomatic: render json: feature (or a collection) emits this.
758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 |
# File 'lib/magick/feature.rb', line 758 def as_json( = nil) { 'name' => name, 'display_name' => display_name, 'group' => group, 'type' => type.to_s, 'status' => status.to_s, 'value' => stored_value, 'default_value' => default_value, 'description' => description, 'targeting' => TargetingPayload.serialize(targeting), 'dependencies' => (@dependencies || []).map(&:to_s), # Variants live inside @targeting under the internal :variants key # (variants_for_export reads a never-assigned ivar and is always # empty), so the wire payload reads the authoritative source. 'variants' => TargetingPayload.deep_stringify(targeting[:variants] || []) } end |
#check_enabled(context = {}) ⇒ Object
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/magick/feature.rb', line 93 def check_enabled(context = {}) # Dup context to avoid mutating the caller's hash context = context.dup # Extract context from user object if provided # This allows Magick.enabled?(:feature, user: player) to work if context[:user] extracted = extract_context_from_object(context.delete(:user)) # Merge extracted context, but don't override explicit values already in context extracted.each do |key, value| context[key] = value unless context.key?(key) end end # Fast path: check status first return false if status == :inactive return false if status == :deprecated && !context[:allow_deprecated] # Dependency check: a feature with unmet prerequisites evaluates as disabled, # regardless of its own configured state. Evaluation-only — prerequisite state # is never written into this feature. return false unless dependencies_satisfied?(context) # Fast path: skip targeting checks if targeting is empty (most common case) unless @_targeting_empty # Check exclusions FIRST — exclusions always take priority over inclusions return false if excluded?(context) # Check date/time range targeting return false if targeting[:date_range] && !date_range_active?(targeting[:date_range]) # Check IP address targeting return false if targeting[:ip_address] && context[:ip_address] && !ip_address_matches?(context[:ip_address]) # Check custom attributes return false if targeting[:custom_attributes] && !custom_attributes_match?(context, targeting[:custom_attributes]) # Check complex conditions return false if targeting[:complex_conditions] && !complex_conditions_match?(context, targeting[:complex_conditions]) # Check user/group/role/percentage targeting targeting_result = check_targeting(context) return false if targeting_result.nil? # Targeting doesn't match - return false # Targeting matches - for boolean features, return true directly # For string/number features, still check the value return true if type == :boolean # For string/number, continue to check value below end # Get value and check based on type value = get_value(context) case type when :boolean value == true when :string !value.nil? && value != '' when :number value.to_f.positive? else false end rescue StandardError => e # Return false on any error (fail-safe) warn "Magick: Error in check_enabled for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.)}" if defined?(Rails) && Rails.env.development? false end |
#delete ⇒ Object
689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 |
# File 'lib/magick/feature.rb', line 689 def delete # Snapshot before the wipe so the recorded version captures the state # this feature had when it was deleted, not the emptied one. snapshot = to_h record_change('delete', { deleted: true }, snapshot: snapshot) do adapter_registry.delete(name) @stored_value = nil @stored_value_initialized = false # Reset initialization flag so get_value returns default_value @targeting = {} # Also remove from Magick.features if registered Magick.features.delete(name.to_s) end true end |
#dependencies ⇒ Object
526 527 528 |
# File 'lib/magick/feature.rb', line 526 def dependencies @dependencies || [] end |
#disable(user_id: nil) ⇒ Object
629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 |
# File 'lib/magick/feature.rb', line 629 def disable(user_id: nil) disabled_value = { boolean: false, string: '', number: 0 }[type] changes = { value: { from: @stored_value, to: disabled_value }, targeting: { cleared: true } } record_change('disable', changes, user_id: user_id) do # Clear all targeting to disable globally @targeting = {} save_targeting case type when :boolean set_value(false, user_id: user_id) when :string set_value('', user_id: user_id) when :number set_value(0, user_id: user_id) else raise InvalidFeatureValueError, "Cannot disable feature of type #{type}" end # Ensure registered feature instance also has targeting cleared if Magick.features.key?(name) registered = Magick.features[name] registered.instance_variable_set(:@targeting, {}) end # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id) end end true end |
#disable_custom_attribute(attribute_name) ⇒ Object
468 469 470 471 472 473 474 475 476 477 478 479 |
# File 'lib/magick/feature.rb', line 468 def disable_custom_attribute(attribute_name) record_change('disable_custom_attribute', targeting_change(:custom_attributes, removed: attribute_name)) do custom_attrs = targeting[:custom_attributes] || {} custom_attrs.delete(attribute_name.to_sym) if custom_attrs.empty? disable_targeting(:custom_attributes) else enable_targeting(:custom_attributes, custom_attrs) end end true end |
#disable_date_range ⇒ Object
428 429 430 431 432 433 |
# File 'lib/magick/feature.rb', line 428 def disable_date_range record_change('disable_date_range', targeting_change(:date_range)) do disable_targeting(:date_range) end true end |
#disable_for_group(group_name) ⇒ Object
257 258 259 260 261 262 |
# File 'lib/magick/feature.rb', line 257 def disable_for_group(group_name) record_change('disable_for_group', targeting_change(:group, removed: group_name)) do disable_targeting(:group, group_name) end true end |
#disable_for_role(role_name) ⇒ Object
271 272 273 274 275 276 |
# File 'lib/magick/feature.rb', line 271 def disable_for_role(role_name) record_change('disable_for_role', targeting_change(:role, removed: role_name)) do disable_targeting(:role, role_name) end true end |
#disable_for_tag(tag_name) ⇒ Object
285 286 287 288 289 290 |
# File 'lib/magick/feature.rb', line 285 def disable_for_tag(tag_name) record_change('disable_for_tag', targeting_change(:tag, removed: tag_name)) do disable_targeting(:tag, tag_name) end true end |
#disable_for_user(user_id) ⇒ Object
243 244 245 246 247 248 |
# File 'lib/magick/feature.rb', line 243 def disable_for_user(user_id) record_change('disable_for_user', targeting_change(:user, removed: user_id)) do disable_targeting(:user, user_id) end true end |
#disable_ip_addresses ⇒ Object
450 451 452 453 454 455 |
# File 'lib/magick/feature.rb', line 450 def disable_ip_addresses record_change('disable_ip_addresses', targeting_change(:ip_address)) do disable_targeting(:ip_address) end true end |
#disable_percentage_of_requests ⇒ Object
413 414 415 416 417 418 |
# File 'lib/magick/feature.rb', line 413 def disable_percentage_of_requests record_change('disable_percentage_of_requests', targeting_change(:percentage_requests)) do disable_targeting(:percentage_requests) end true end |
#disable_percentage_of_users ⇒ Object
389 390 391 392 393 394 |
# File 'lib/magick/feature.rb', line 389 def disable_percentage_of_users record_change('disable_percentage_of_users', targeting_change(:percentage_users)) do disable_targeting(:percentage_users) end true end |
#disabled?(context = {}) ⇒ Boolean
165 166 167 |
# File 'lib/magick/feature.rb', line 165 def disabled?(context = {}) !enabled?(context) end |
#disabled_for?(object, **additional_context) ⇒ Boolean
177 178 179 |
# File 'lib/magick/feature.rb', line 177 def disabled_for?(object, **additional_context) !enabled_for?(object, **additional_context) end |
#enable(user_id: nil) ⇒ Object
601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 |
# File 'lib/magick/feature.rb', line 601 def enable(user_id: nil) changes = { value: { from: @stored_value, to: true }, targeting: { cleared: true } } record_change('enable', changes, user_id: user_id) do # Clear all targeting to enable globally @targeting = {} save_targeting case type when :boolean set_value(true, user_id: user_id) when :string raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.' when :number raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.' else raise InvalidFeatureValueError, "Cannot enable feature of type #{type}" end # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id) end end true end |
#enable_for_custom_attribute(attribute_name, values, operator: :equals) ⇒ Object
457 458 459 460 461 462 463 464 465 466 |
# File 'lib/magick/feature.rb', line 457 def enable_for_custom_attribute(attribute_name, values, operator: :equals) change = targeting_change(:custom_attributes, added: { attribute: attribute_name, values: Array(values), operator: operator }) record_change('enable_for_custom_attribute', change) do custom_attrs = targeting[:custom_attributes] || {} custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator } enable_targeting(:custom_attributes, custom_attrs) end true end |
#enable_for_date_range(start_date, end_date) ⇒ Object
420 421 422 423 424 425 426 |
# File 'lib/magick/feature.rb', line 420 def enable_for_date_range(start_date, end_date) record_change('enable_for_date_range', targeting_change(:date_range, added: { start: start_date, end: end_date })) do enable_targeting(:date_range, { start: start_date, end: end_date }) end true end |
#enable_for_group(group_name) ⇒ Object
250 251 252 253 254 255 |
# File 'lib/magick/feature.rb', line 250 def enable_for_group(group_name) record_change('enable_for_group', targeting_change(:group, added: group_name)) do enable_targeting(:group, group_name) end true end |
#enable_for_ip_addresses(ip_addresses) ⇒ Object
435 436 437 438 439 440 441 442 443 444 445 446 447 448 |
# File 'lib/magick/feature.rb', line 435 def enable_for_ip_addresses(ip_addresses) ips = Array(ip_addresses).map(&:to_s) record_change('enable_for_ip_addresses', targeting_change(:ip_address, added: ips)) do # ip_address is stored as a flat Array of strings; bypass the generic # enable_targeting path whose "array type" branch stringifies the # incoming array into a single '["x.y.z"]' entry. @targeting[:ip_address] ||= [] ips.each do |str| @targeting[:ip_address] << str unless @targeting[:ip_address].include?(str) end save_targeting end true end |
#enable_for_role(role_name) ⇒ Object
264 265 266 267 268 269 |
# File 'lib/magick/feature.rb', line 264 def enable_for_role(role_name) record_change('enable_for_role', targeting_change(:role, added: role_name)) do enable_targeting(:role, role_name) end true end |
#enable_for_tag(tag_name) ⇒ Object
278 279 280 281 282 283 |
# File 'lib/magick/feature.rb', line 278 def enable_for_tag(tag_name) record_change('enable_for_tag', targeting_change(:tag, added: tag_name)) do enable_targeting(:tag, tag_name) end true end |
#enable_for_user(user_id) ⇒ Object
236 237 238 239 240 241 |
# File 'lib/magick/feature.rb', line 236 def enable_for_user(user_id) record_change('enable_for_user', targeting_change(:user, added: user_id)) do enable_targeting(:user, user_id) end true end |
#enable_percentage_of_requests(percentage) ⇒ Object
396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 |
# File 'lib/magick/feature.rb', line 396 def enable_percentage_of_requests(percentage) record_change('enable_percentage_of_requests', targeting_change(:percentage_requests, added: percentage.to_f)) do @targeting[:percentage_requests] = percentage.to_f save_targeting # Update registered feature instance if it exists Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name) # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage) end end true end |
#enable_percentage_of_users(percentage) ⇒ Object
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/magick/feature.rb', line 372 def enable_percentage_of_users(percentage) record_change('enable_percentage_of_users', targeting_change(:percentage_users, added: percentage.to_f)) do @targeting[:percentage_users] = percentage.to_f save_targeting # Update registered feature instance if it exists Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name) # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage) end end true end |
#enabled?(context = {}) ⇒ Boolean
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/magick/feature.rb', line 45 def enabled?(context = {}) # Check performance metrics dynamically (in case enabled after feature creation) # But cache the check result for performance perf_metrics = Magick.performance_metrics perf_metrics_enabled = !perf_metrics.nil? # Update cached flag if it changed @_perf_metrics_enabled = perf_metrics_enabled if @_perf_metrics_enabled != perf_metrics_enabled # Fast path: if performance metrics disabled, skip all overhead return check_enabled(context) unless perf_metrics_enabled # Performance metrics enabled: measure and record # Use inline timing to avoid function call overhead start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) result = check_enabled(context) duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000 # milliseconds # Record metrics (fast path - minimal overhead) perf_metrics.record(name, 'enabled?', duration, success: true) # Rails 8+ events (only in development or when explicitly enabled) if @_rails_events_enabled if result Magick::Rails::Events.feature_enabled(name, context: context) else Magick::Rails::Events.feature_disabled(name, context: context) end end # Warn if deprecated (only if enabled) if status == :deprecated && result && !context[:allow_deprecated] && Magick.warn_on_deprecated warn "DEPRECATED: Feature '#{name}' is deprecated and will be removed." Magick::Rails::Events.deprecated_warning(name) if @_rails_events_enabled end result rescue StandardError => e # Record error metrics if enabled if perf_metrics_enabled && perf_metrics duration = defined?(start_time) && start_time ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000 : 0.0 perf_metrics.record(name, 'enabled?', duration, success: false) end # Return false on any error (fail-safe) warn "Magick: Error checking feature '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.)}" if defined?(Rails) && Rails.env.development? false end |
#enabled_for?(object, **additional_context) ⇒ Boolean
169 170 171 172 173 174 175 |
# File 'lib/magick/feature.rb', line 169 def enabled_for?(object, **additional_context) # Extract context from object context = extract_context_from_object(object) # Merge with any additional context provided context.merge!(additional_context) enabled?(context) end |
#exclude_group(group_name) ⇒ Object
322 323 324 325 326 327 |
# File 'lib/magick/feature.rb', line 322 def exclude_group(group_name) record_change('exclude_group', targeting_change(:excluded_groups, added: group_name)) do enable_targeting(:excluded_groups, group_name) end true end |
#exclude_ip_addresses(ip_addresses) ⇒ Object
350 351 352 353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/magick/feature.rb', line 350 def exclude_ip_addresses(ip_addresses) ips = Array(ip_addresses).map(&:to_s) record_change('exclude_ip_addresses', targeting_change(:excluded_ip_addresses, added: ips)) do # excluded_ip_addresses is stored as a flat Array of strings; bypass the # generic enable_targeting path whose "array type" branch stringifies the # incoming array into a single element. @targeting[:excluded_ip_addresses] ||= [] ips.each do |str| @targeting[:excluded_ip_addresses] << str unless @targeting[:excluded_ip_addresses].include?(str) end save_targeting end true end |
#exclude_role(role_name) ⇒ Object
336 337 338 339 340 341 |
# File 'lib/magick/feature.rb', line 336 def exclude_role(role_name) record_change('exclude_role', targeting_change(:excluded_roles, added: role_name)) do enable_targeting(:excluded_roles, role_name) end true end |
#exclude_tag(tag_name) ⇒ Object
308 309 310 311 312 313 |
# File 'lib/magick/feature.rb', line 308 def exclude_tag(tag_name) record_change('exclude_tag', targeting_change(:excluded_tags, added: tag_name)) do enable_targeting(:excluded_tags, tag_name) end true end |
#exclude_user(user_id) ⇒ Object
--- Exclusion methods ---
294 295 296 297 298 299 |
# File 'lib/magick/feature.rb', line 294 def exclude_user(user_id) record_change('exclude_user', targeting_change(:excluded_users, added: user_id)) do enable_targeting(:excluded_users, user_id) end true end |
#get_value(context = {}) ⇒ Object
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 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/magick/feature.rb', line 185 def get_value(context = {}) # Fast path: check targeting rules first (only if targeting exists) unless @_targeting_empty targeting_result = check_targeting(context) # If targeting matches (returns truthy), return the stored value # If targeting doesn't match (returns nil), continue to return default value unless targeting_result.nil? # Targeting matches - return stored value (or load it if not initialized) return @stored_value if @stored_value_initialized # Load from adapter loaded_value = load_value_from_adapter if loaded_value.nil? # Value not found in adapter, use default and cache it @stored_value = default_value @stored_value_initialized = true return default_value else # Value found in adapter, use it and mark as initialized @stored_value = loaded_value @stored_value_initialized = true return loaded_value end end # Targeting doesn't match - return default value return default_value end # Fast path: use cached value if initialized (avoid adapter calls) return @stored_value if @stored_value_initialized # Load from adapter if instance variable hasn't been initialized loaded_value = load_value_from_adapter if loaded_value.nil? # Value not found in adapter, use default and cache it @stored_value = default_value @stored_value_initialized = true default_value else # Value found in adapter, use it and mark as initialized @stored_value = loaded_value @stored_value_initialized = true loaded_value end rescue StandardError => e # Return default value on error (fail-safe) warn "Magick: Error in get_value for '#{Magick::LogSafe.sanitize(name)}': #{Magick::LogSafe.sanitize(e.)}" if defined?(Rails) && Rails.env.development? default_value end |
#get_variant(context = {}) ⇒ Object
530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 |
# File 'lib/magick/feature.rb', line 530 def get_variant(context = {}) return nil unless targeting[:variants] variants = targeting[:variants] return nil if variants.empty? return variants.first[:name] if variants.length == 1 total_weight = variants.sum { |v| v[:weight] || 0 } return variants.first[:name] if total_weight.zero? # Deterministic assignment: use MD5 hash of feature_name + user_id # This ensures the same user always gets the same variant user_id = context[:user_id] || context[:user]&.respond_to?(:id) && context[:user].id if user_id hash = Digest::MD5.hexdigest("#{name}:variant:#{user_id}") bucket = hash[0..7].to_i(16) % total_weight else # No user context — fall back to random (e.g., anonymous requests) bucket = rand(total_weight) end current = 0 variants.each do |variant| current += (variant[:weight] || 0) return variant[:name] if bucket < current end variants.last[:name] end |
#get_variant_value(context = {}) ⇒ Object
560 561 562 563 564 565 566 |
# File 'lib/magick/feature.rb', line 560 def get_variant_value(context = {}) variant_name = get_variant(context) return nil unless variant_name variant = targeting[:variants]&.find { |v| v[:name] == variant_name } variant&.dig(:value) end |
#reload ⇒ Object
Reload feature state from adapter (useful when feature is changed externally)
715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 |
# File 'lib/magick/feature.rb', line 715 def reload load_from_adapter # Update targeting empty cache @_targeting_empty = @targeting.empty? # Update performance metrics flag (in case it was enabled after feature creation) @_perf_metrics_enabled = !Magick.performance_metrics.nil? # Update registered feature instance if it exists if Magick.features.key?(name) registered = Magick.features[name] registered.instance_variable_set(:@stored_value, @stored_value) registered.instance_variable_set(:@stored_value_initialized, @stored_value_initialized) registered.instance_variable_set(:@status, @status) registered.instance_variable_set(:@description, @description) registered.instance_variable_set(:@display_name, @display_name) registered.instance_variable_set(:@group, @group) registered.instance_variable_set(:@targeting, @targeting.dup) registered.instance_variable_set(:@_targeting_empty, @_targeting_empty) registered.instance_variable_set(:@_perf_metrics_enabled, @_perf_metrics_enabled) end true end |
#reload_from_source! ⇒ Object
Reload feature state from the shared, authoritative backend (ActiveRecord/ Redis), bypassing this process's local in-process memory cache. Used by the Admin UI so a toggle performed on another process/container is reflected immediately, without waiting for Pub/Sub cache invalidation to arrive.
709 710 711 712 |
# File 'lib/magick/feature.rb', line 709 def reload_from_source! adapter_registry.(name) if adapter_registry.respond_to?(:authoritative_get_all_data) reload end |
#remove_dependency(dependency_name) ⇒ Object
513 514 515 516 517 518 519 520 521 522 523 524 |
# File 'lib/magick/feature.rb', line 513 def remove_dependency(dependency_name) record_change('remove_dependency', { dependency: { removed: dependency_name.to_s } }) do @dependencies&.delete(dependency_name.to_s) # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.dependency_removed(name, dependency_name) end end true end |
#remove_group_exclusion(group_name) ⇒ Object
329 330 331 332 333 334 |
# File 'lib/magick/feature.rb', line 329 def remove_group_exclusion(group_name) record_change('remove_group_exclusion', targeting_change(:excluded_groups, removed: group_name)) do disable_targeting(:excluded_groups, group_name) end true end |
#remove_ip_exclusion ⇒ Object
365 366 367 368 369 370 |
# File 'lib/magick/feature.rb', line 365 def remove_ip_exclusion record_change('remove_ip_exclusion', targeting_change(:excluded_ip_addresses)) do disable_targeting(:excluded_ip_addresses) end true end |
#remove_role_exclusion(role_name) ⇒ Object
343 344 345 346 347 348 |
# File 'lib/magick/feature.rb', line 343 def remove_role_exclusion(role_name) record_change('remove_role_exclusion', targeting_change(:excluded_roles, removed: role_name)) do disable_targeting(:excluded_roles, role_name) end true end |
#remove_tag_exclusion(tag_name) ⇒ Object
315 316 317 318 319 320 |
# File 'lib/magick/feature.rb', line 315 def remove_tag_exclusion(tag_name) record_change('remove_tag_exclusion', targeting_change(:excluded_tags, removed: tag_name)) do disable_targeting(:excluded_tags, tag_name) end true end |
#remove_user_exclusion(user_id) ⇒ Object
301 302 303 304 305 306 |
# File 'lib/magick/feature.rb', line 301 def remove_user_exclusion(user_id) record_change('remove_user_exclusion', targeting_change(:excluded_users, removed: user_id)) do disable_targeting(:excluded_users, user_id) end true end |
#replace_targeting(payload = nil, user_id: nil, **inline_rules) ⇒ Object
Wholesale, declarative targeting write: the payload IS the new targeting state. Keys absent from it are removed; {} clears all targeting. Accepts wire input leniently (string/symbol keys, plural aliases, scalars for lists, numeric strings) but validates strictly — unknown keys or invalid values raise InvalidTargetingError before any state is touched. The internal :variants entry is not part of the wire payload and survives the replace untouched. Accepts the payload as a positional hash or inline keywords (replace_targeting(user: [3])) — Ruby routes a braceless hash to keywords, so both spellings must land in the same place. Passing nothing raises (via normalize): clearing requires an explicit {}.
788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 |
# File 'lib/magick/feature.rb', line 788 def replace_targeting(payload = nil, user_id: nil, **inline_rules) raise ArgumentError, 'pass targeting either as a hash or inline, not both' if payload && inline_rules.any? normalized = TargetingPayload.normalize(payload || (inline_rules unless inline_rules.empty?)) normalized[:variants] = targeting[:variants] if targeting[:variants] changes = { targeting: { from: TargetingPayload.serialize(targeting), to: TargetingPayload.serialize(normalized) } } record_change('replace_targeting', changes, user_id: user_id) do @targeting = normalized persist_targeting if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id) end end true end |
#restore_snapshot!(data) ⇒ Object
Restore full feature state from a version snapshot (used by Versioning#rollback). Replaces value (including false/empty), status, group, the entire targeting hash and dependencies wholesale.
836 837 838 839 840 841 842 843 844 845 846 847 848 |
# File 'lib/magick/feature.rb', line 836 def restore_snapshot!(data) set_status(data[:status]) if data[:status] set_group(data[:group]) if data.key?(:group) value = data[:value] set_value(cast_value(value)) unless value.nil? @targeting = normalize_targeting(data[:targeting]) save_targeting @dependencies = Array(data[:dependencies]).map(&:to_s) true end |
#save_targeting ⇒ Object
824 825 826 827 828 829 830 831 |
# File 'lib/magick/feature.rb', line 824 def save_targeting # Records only when called directly (e.g. Admin UI clearing variants); # when reached through a wrapping mutator the guard is active and this # just persists. record_change('update_targeting', { targeting: targeting.dup }) do persist_targeting end end |
#set_group(group_name) ⇒ Object
674 675 676 677 678 679 680 681 682 683 684 685 686 687 |
# File 'lib/magick/feature.rb', line 674 def set_group(group_name) new_group = group_name.nil? || group_name.to_s.strip.empty? ? nil : group_name.to_s.strip record_change('set_group', { group: { from: @group, to: new_group } }) do @group = new_group # Clear group from adapter by setting to nil (adapters handle this) adapter_registry.set(name, 'group', @group) # Update registered feature instance if it exists Magick.features[name].instance_variable_set(:@group, @group) if Magick.features.key?(name) end true end |
#set_status(new_status) ⇒ Object
664 665 666 667 668 669 670 671 672 |
# File 'lib/magick/feature.rb', line 664 def set_status(new_status) raise InvalidFeatureValueError, "Invalid status: #{new_status}" unless VALID_STATUSES.include?(new_status.to_sym) record_change('set_status', { status: { from: @status, to: new_status.to_sym } }) do @status = new_status.to_sym adapter_registry.set(name, 'status', status) end true end |
#set_value(value, user_id: nil) ⇒ Object
568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 |
# File 'lib/magick/feature.rb', line 568 def set_value(value, user_id: nil) old_value = @stored_value validate_value!(value) changes = { value: { from: old_value, to: value } } record_change('set_value', changes, user_id: user_id) do # Bulk write all metadata in a single adapter call instead of 7 separate calls data = { 'value' => value, 'type' => type, 'status' => status, 'default_value' => default_value } data['description'] = description if description data['display_name'] = display_name if display_name data['group'] = group if group adapter_registry.set_all_data(name, data) @stored_value = value @stored_value_initialized = true # Update registered feature instance if it exists if Magick.features.key?(name) registered = Magick.features[name] registered.instance_variable_set(:@stored_value, value) registered.instance_variable_set(:@stored_value_initialized, true) registered.instance_variable_set(:@targeting, @targeting.dup) if @targeting end if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id) end end true end |
#set_variants(variants) ⇒ Object
481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 |
# File 'lib/magick/feature.rb', line 481 def set_variants(variants) variants_array = Array(variants).map do |v| v.is_a?(FeatureVariant) ? v : FeatureVariant.new(v[:name], v[:value], weight: v[:weight] || 0) end payload = variants_array.map(&:to_h) record_change('set_variants', { variants: payload }) do enable_targeting(:variants, payload) # Rails 8+ event if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8? Magick::Rails::Events.variant_set(name, variants: variants_array) end end true end |
#to_h ⇒ Object
737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 |
# File 'lib/magick/feature.rb', line 737 def to_h { name: name, display_name: display_name, group: group, type: type, status: status, value: stored_value, default_value: default_value, description: description, targeting: targeting, dependencies: (@dependencies || []).dup, variants: variants_for_export } end |
#value(context = {}) ⇒ Object
181 182 183 |
# File 'lib/magick/feature.rb', line 181 def value(context = {}) get_value(context) end |
#variants_for_export ⇒ Object
811 812 813 814 815 816 817 818 819 820 821 822 |
# File 'lib/magick/feature.rb', line 811 def variants_for_export return [] unless defined?(Magick::FeatureVariant) raw = @variants || [] raw.map do |v| if v.is_a?(Magick::FeatureVariant) { name: v.name, weight: v.weight, value: v.value } else v end end end |