Module: Magick

Defined in:
lib/magick/rails/railtie.rb,
lib/magick.rb,
lib/magick/dsl.rb,
lib/magick/config.rb,
lib/magick/errors.rb,
lib/magick/feature.rb,
lib/magick/version.rb,
lib/magick/admin_ui.rb,
lib/magick/log_safe.rb,
lib/magick/audit_log.rb,
lib/magick/versioning.rb,
lib/magick/bulk_result.rb,
lib/magick/rails/events.rb,
lib/magick/adapters/base.rb,
lib/magick/documentation.rb,
lib/magick/export_import.rb,
lib/magick/adapters/redis.rb,
lib/magick/targeting/base.rb,
lib/magick/targeting/role.rb,
lib/magick/targeting/user.rb,
lib/magick/adapter_failure.rb,
lib/magick/adapters/memory.rb,
lib/magick/admin_ui/engine.rb,
lib/magick/circuit_breaker.rb,
lib/magick/feature_variant.rb,
lib/magick/targeting/group.rb,
lib/magick/testing_helpers.rb,
lib/magick/admin_ui/helpers.rb,
lib/magick/adapters/registry.rb,
lib/magick/targeting/complex.rb,
lib/magick/targeting_payload.rb,
lib/magick/performance_metrics.rb,
lib/magick/targeting/date_range.rb,
lib/magick/targeting/ip_address.rb,
lib/magick/targeting/percentage.rb,
lib/magick/adapters/async_writer.rb,
lib/magick/adapters/active_record.rb,
lib/magick/rails/event_subscriber.rb,
lib/magick/admin_ui/authentication.rb,
lib/magick/request_store_integration.rb,
lib/magick/targeting/custom_attribute.rb,
lib/magick/targeting/request_percentage.rb,
app/controllers/magick/adminui/stats_controller.rb,
lib/generators/magick/install/install_generator.rb,
app/controllers/magick/adminui/features_controller.rb,
lib/generators/magick/active_record/active_record_generator.rb

Overview

Admin UI engine is now loaded in magick.rb when Rails is detected

Defined Under Namespace

Modules: AdapterFailure, Adapters, AdminUI, ConfigDSL, DSL, Generators, LogSafe, Rails, RequestStoreIntegration, Targeting, TargetingPayload, TestingHelpers Classes: AdapterError, AuditLog, BulkResult, CircuitBreaker, CircuitOpenError, Config, ConfigurationError, Documentation, Error, ExportImport, Feature, FeatureNotFoundError, FeatureVariant, InvalidFeatureTypeError, InvalidFeatureValueError, InvalidTargetingError, PerformanceMetrics, Versioning

Constant Summary collapse

UNKNOWN_DEPENDENCY_POLICIES =

Accepted values for Magick.unknown_dependency_policy (see below).

%i[satisfied unsatisfied].freeze
VERSION =
'1.7.0'

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.adapter_registry ⇒ Object

Returns the value of attribute adapter_registry.



65
66
67
# File 'lib/magick.rb', line 65

def adapter_registry
  @adapter_registry
end

.audit_log ⇒ Object

Returns the value of attribute audit_log.



65
66
67
# File 'lib/magick.rb', line 65

def audit_log
  @audit_log
end

.default_adapter ⇒ Object

Returns the value of attribute default_adapter.



65
66
67
# File 'lib/magick.rb', line 65

def default_adapter
  @default_adapter
end

.versioning ⇒ Object

Returns the value of attribute versioning.



65
66
67
# File 'lib/magick.rb', line 65

def versioning
  @versioning
end

.versioning_enabled=(value) ⇒ Object (writeonly)

When false, Feature#record_change skips version snapshots (audit log entries are still written). Set via versioning enabled: false in the configuration DSL.



302
303
304
# File 'lib/magick.rb', line 302

def versioning_enabled=(value)
  @versioning_enabled = value
end

.warn_on_deprecated ⇒ Object

Returns the value of attribute warn_on_deprecated.



65
66
67
# File 'lib/magick.rb', line 65

def warn_on_deprecated
  @warn_on_deprecated
end

Class Method Details

.[](feature_name) ⇒ Object



168
169
170
171
# File 'lib/magick.rb', line 168

def [](feature_name)
  # Return registered feature if it exists, otherwise create new instance
  features[feature_name.to_s] || Feature.new(feature_name, adapter_registry || default_adapter_registry)
end

.bulk_disable(feature_names, _context = {}) ⇒ Object

Disable every named feature globally, with the same semantics as Feature#disable: the off value for the feature's type AND targeting cleared, so a flag that was enabled for one user is off for that user too. Every feature type has an "off", so nothing is skipped.



272
273
274
# File 'lib/magick.rb', line 272

def bulk_disable(feature_names, _context = {})
  bulk_toggle(feature_names, &:disable)
end

.bulk_enable(feature_names, _context = {}) ⇒ Object

Enable every named feature globally, with the same semantics as Feature#enable: value true AND targeting cleared. Only a boolean feature has an "on"; a string or number one is left untouched and named in the result rather than silently passed over. Returns a BulkResult, which still iterates as the array of features this used to return.



260
261
262
263
264
265
266
# File 'lib/magick.rb', line 260

def bulk_enable(feature_names, _context = {})
  bulk_toggle(feature_names) do |feature|
    next "cannot enable a #{feature.type} feature; use set_value" unless feature.type == :boolean

    feature.enable
  end
end

.change_recording_suppressed? ⇒ Boolean

Returns:

  • (Boolean)


353
354
355
# File 'lib/magick.rb', line 353

def change_recording_suppressed?
  Thread.current[:magick_change_recording] == true || definition_mode?
end

.configure(&block) ⇒ Object



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
164
165
166
# File 'lib/magick.rb', line 137

def configure(&block)
  @performance_metrics ||= PerformanceMetrics.new
  @audit_log ||= AuditLog.new
  @warn_on_deprecated ||= false
  # Ensure adapter_registry is set (fallback to default if not configured)
  @adapter_registry ||= default_adapter_registry

  # Support both old style and new DSL style
  return unless block_given?

  if block.arity.zero?
    # New DSL style - calls apply! automatically
    Magick::ConfigDSL.configure(&block)
  else
    # Old style - need to manually reapply Redis tracking after configuration
    yield self
    # Ensure adapter_registry is still set after configuration
    @adapter_registry ||= default_adapter_registry
    # Enable Redis tracking if adapter is available and performance_metrics exists
    # Only enable if not already enabled (to avoid overriding explicit false setting)
    if @performance_metrics && @adapter_registry.is_a?(Adapters::Registry) && @adapter_registry.redis_available?
      unless @performance_metrics.instance_variable_get(:@redis_enabled)
        @performance_metrics.enable_redis_tracking(enable: true)
      end
    end
  end

  # Final check: ensure adapter_registry is set
  @adapter_registry ||= default_adapter_registry
end

.current_actor ⇒ Object



321
322
323
# File 'lib/magick.rb', line 321

def current_actor
  Thread.current[:magick_actor]
end

.default_adapter_registry ⇒ Object

Get default adapter registry (public method for use by other classes)



438
439
440
441
442
443
444
445
446
447
448
# File 'lib/magick.rb', line 438

def default_adapter_registry
  @default_adapter_registry ||= begin
    memory_adapter = Adapters::Memory.new
    redis_adapter = begin
      Adapters::Redis.new if defined?(Redis)
    rescue AdapterError
      nil
    end
    Adapters::Registry.new(memory_adapter, redis_adapter)
  end
end

.definition_mode ⇒ Object

Suppress audit/version recording while declarative feature definitions are (re)applied. Process boot replays config/features.rb in every container; recording those replays would flood history with identical snapshots. The Rails railtie wraps the features file load in this; non-Rails apps should do the same around their definition file.



330
331
332
333
334
335
336
# File 'lib/magick.rb', line 330

def definition_mode
  previous = Thread.current[:magick_definition_mode]
  Thread.current[:magick_definition_mode] = true
  yield
ensure
  Thread.current[:magick_definition_mode] = previous
end

.definition_mode? ⇒ Boolean

Returns:

  • (Boolean)


338
339
340
# File 'lib/magick.rb', line 338

def definition_mode?
  Thread.current[:magick_definition_mode] == true
end

.disabled?(feature_name, context = {}) ⇒ Boolean

Returns:

  • (Boolean)


237
238
239
# File 'lib/magick.rb', line 237

def disabled?(feature_name, context = {})
  !enabled?(feature_name, context)
end

.disabled_for?(feature_name, object, **additional_context) ⇒ Boolean

Returns:

  • (Boolean)


204
205
206
# File 'lib/magick.rb', line 204

def disabled_for?(feature_name, object, **additional_context)
  !enabled_for?(feature_name, object, **additional_context)
end

.enable_redis_tracking(enable: true) ⇒ Object

Manually enable Redis tracking for performance metrics Useful if Redis adapter becomes available after initial configuration



359
360
361
362
363
# File 'lib/magick.rb', line 359

def enable_redis_tracking(enable: true)
  return unless performance_metrics

  performance_metrics.enable_redis_tracking(enable: enable)
end

.enabled?(feature_name, context = {}) ⇒ Boolean

Returns:

  • (Boolean)


192
193
194
195
196
197
# File 'lib/magick.rb', line 192

def enabled?(feature_name, context = {})
  # Fast path: use string key directly (avoid repeated to_s conversion)
  feature_name_str = feature_name.to_s
  feature = features[feature_name_str] || self[feature_name]
  feature.enabled?(context)
end

.enabled_for?(feature_name, object, **additional_context) ⇒ Boolean

Returns:

  • (Boolean)


199
200
201
202
# File 'lib/magick.rb', line 199

def enabled_for?(feature_name, object, **additional_context)
  feature = features[feature_name.to_s] || self[feature_name]
  feature.enabled_for?(object, **additional_context)
end

.exists?(feature_name) ⇒ Boolean

Returns:

  • (Boolean)


251
252
253
# File 'lib/magick.rb', line 251

def exists?(feature_name)
  features.key?(feature_name.to_s) || (adapter_registry || default_adapter_registry).exists?(feature_name)
end

.export(format: :json) ⇒ Object



276
277
278
279
280
281
282
283
284
285
# File 'lib/magick.rb', line 276

def export(format: :json)
  case format
  when :json
    ExportImport.export_json(features)
  when :hash
    ExportImport.export(features)
  else
    ExportImport.export(features)
  end
end

.feature_average_duration(feature_name, operation: nil) ⇒ Object

Get average duration for a feature (optionally filtered by operation)



386
387
388
389
390
# File 'lib/magick.rb', line 386

def feature_average_duration(feature_name, operation: nil)
  return 0.0 unless performance_metrics

  performance_metrics.average_duration(feature_name: feature_name, operation: operation)
end

.feature_stats(feature_name) ⇒ Object

Get total usage count for a feature (combines memory and Redis counts)



366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/magick.rb', line 366

def feature_stats(feature_name)
  return {} unless performance_metrics

  {
    usage_count: performance_metrics.usage_count(feature_name),
    average_duration: performance_metrics.average_duration(feature_name: feature_name),
    average_duration_by_operation: {
      enabled: performance_metrics.average_duration(feature_name: feature_name, operation: 'enabled?'),
      value: performance_metrics.average_duration(feature_name: feature_name, operation: 'value'),
      get_value: performance_metrics.average_duration(feature_name: feature_name, operation: 'get_value')
    }
  }
end

.feature_usage_count(feature_name) ⇒ Object

Get usage count for a feature



381
382
383
# File 'lib/magick.rb', line 381

def feature_usage_count(feature_name)
  performance_metrics&.usage_count(feature_name) || 0
end

.features ⇒ Object



180
181
182
# File 'lib/magick.rb', line 180

def features
  @features ||= {}
end

.health ⇒ Object

What a host health check wants to know about this process's view of the flags: whether it is listening for cross-process invalidations, when it last confirmed its state against the shared backend, and whether async writes are queued. See Adapters::Registry#health for the keys.



230
231
232
233
234
235
# File 'lib/magick.rb', line 230

def health
  registry = adapter_registry || default_adapter_registry
  return {} unless registry.respond_to?(:health)

  registry.health
end

.import(data, format: :json) ⇒ Object



287
288
289
290
291
292
293
# File 'lib/magick.rb', line 287

def import(data, format: :json)
  imported = ExportImport.import(data, adapter_registry || default_adapter_registry)
  FEATURES_MUTEX.synchronize do
    @features = (@features || {}).merge(imported)
  end
  imported
end

.most_used_features(limit: 10) ⇒ Object

Get most used features



393
394
395
# File 'lib/magick.rb', line 393

def most_used_features(limit: 10)
  performance_metrics&.most_used_features(limit: limit) || {}
end

.performance_metrics ⇒ Object

Getter for performance_metrics



102
103
104
# File 'lib/magick.rb', line 102

def performance_metrics
  @performance_metrics
end

.performance_metrics=(value) ⇒ Object

Override performance_metrics setter to auto-enable Redis tracking



69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/magick.rb', line 69

def performance_metrics=(value)
  @performance_metrics = value
  # Auto-enable Redis tracking if Redis adapter is available
  if value && adapter_registry.is_a?(Adapters::Registry) && adapter_registry.redis_available?
    value.enable_redis_tracking(enable: true)
  end
  # Update all existing features to enable performance metrics tracking
  if value
    features.each_value do |feature|
      feature.instance_variable_set(:@_perf_metrics_enabled, true)
    end
  end
  value
end

.preload! ⇒ Object

Preload all features into memory cache in minimal queries. Call after configuration and feature registration to avoid per-feature cache misses.



399
400
401
402
403
404
405
406
407
408
409
410
411
412
# File 'lib/magick.rb', line 399

def preload!
  registry = adapter_registry || default_adapter_registry
  return unless registry

  # Bulk load all feature data into memory (1-2 queries total)
  all_data = registry.preload!

  # Also preload registered features' state from the cached data
  features.each_value do |feature|
    feature.reload
  end

  all_data
end

.refresh! ⇒ Object

Re-read every feature from the shared backend now, without waiting for the next scheduled refresh: memory is brought in line and registered features whose stored state changed are reloaded. For a console or a rake task that just changed flags behind the gem's back. Returns the names of the features that changed, or nil when no shared backend answered.



219
220
221
222
223
224
# File 'lib/magick.rb', line 219

def refresh!
  registry = adapter_registry || default_adapter_registry
  return nil unless registry.respond_to?(:refresh_from_source!)

  registry.refresh_from_source!
end

.register_feature(name, **options) ⇒ Object



184
185
186
187
188
189
190
# File 'lib/magick.rb', line 184

def register_feature(name, **options)
  feature = Feature.new(name, adapter_registry || default_adapter_registry, **options)
  FEATURES_MUTEX.synchronize do
    @features = (@features || {}).merge(name.to_s => feature)
  end
  feature
end

.reload_feature(feature_name) ⇒ Object

Reload a feature from the adapter (useful when feature is changed externally)



209
210
211
212
# File 'lib/magick.rb', line 209

def reload_feature(feature_name)
  feature = features[feature_name.to_s] || self[feature_name]
  feature.reload
end

.reset! ⇒ Object



414
415
416
417
418
419
420
421
422
423
424
425
426
# File 'lib/magick.rb', line 414

def reset!
  safely_shutdown(@adapter_registry) { |r| r.shutdown }
  safely_shutdown(@default_adapter_registry) { |r| r.shutdown }
  @features = {}
  @adapter_registry = nil
  @default_adapter = nil
  @default_adapter_registry = nil
  @versioning = nil
  @audit_log = nil
  @versioning_enabled = nil
  @unknown_dependency_policy = nil
  @performance_metrics&.clear!
end

.shutdown!(timeout: 5) ⇒ Object

Gracefully terminate background threads (Redis Pub/Sub subscriber, async metrics processor) so the host process can exit promptly. Intended for use in Rails shutdown hooks, at_exit, or tests.



431
432
433
434
435
# File 'lib/magick.rb', line 431

def shutdown!(timeout: 5)
  safely_shutdown(@adapter_registry) { |r| r.shutdown(timeout: timeout) }
  safely_shutdown(@performance_metrics, &:stop_async_processor)
  true
end

.suppress_change_recording ⇒ Object

Reentrancy guard for Feature#record_change: the outermost public mutator records once; nested mutator calls (enable -> set_value) run silently so one logical operation never produces multiple entries.



345
346
347
348
349
350
351
# File 'lib/magick.rb', line 345

def suppress_change_recording
  previous = Thread.current[:magick_change_recording]
  Thread.current[:magick_change_recording] = true
  yield
ensure
  Thread.current[:magick_change_recording] = previous
end

.unknown_dependency_policy ⇒ Object

What to do when a feature depends on a prerequisite that is registered nowhere in this process AND absent from the shared backend.

:satisfied   (default) the unknown prerequisite is ignored; the
           dependent feature falls back to its own value/targeting.
           A prerequisite that has not been deployed yet, or a typo,
           cannot switch off correctly configured features.
:unsatisfied the dependent feature evaluates false until the
           prerequisite exists. Choose this when a missing
           prerequisite means the dependent feature is unsafe to run.

Either way the unknown name is reported (once per process, per feature) on stderr, so the condition is never silent.



119
120
121
# File 'lib/magick.rb', line 119

def unknown_dependency_policy
  @unknown_dependency_policy || :satisfied
end

.unknown_dependency_policy=(policy) ⇒ Object



123
124
125
126
127
128
129
130
131
# File 'lib/magick.rb', line 123

def unknown_dependency_policy=(policy)
  normalized = policy.to_sym
  unless UNKNOWN_DEPENDENCY_POLICIES.include?(normalized)
    raise ArgumentError,
          "unknown_dependency_policy must be one of #{UNKNOWN_DEPENDENCY_POLICIES.inspect}, got #{policy.inspect}"
  end

  @unknown_dependency_policy = normalized
end

.unknown_dependency_satisfied? ⇒ Boolean

Returns:

  • (Boolean)


133
134
135
# File 'lib/magick.rb', line 133

def unknown_dependency_satisfied?
  unknown_dependency_policy == :satisfied
end

.variant(feature_name, context = {}) ⇒ Object



241
242
243
244
# File 'lib/magick.rb', line 241

def variant(feature_name, context = {})
  feature = features[feature_name.to_s] || self[feature_name]
  feature.get_variant(context)
end

.variant_value(feature_name, context = {}) ⇒ Object



246
247
248
249
# File 'lib/magick.rb', line 246

def variant_value(feature_name, context = {})
  feature = features[feature_name.to_s] || self[feature_name]
  feature.get_variant_value(context)
end

.versioning_enabled? ⇒ Boolean

Returns:

  • (Boolean)


304
305
306
# File 'lib/magick.rb', line 304

def versioning_enabled?
  @versioning_enabled.nil? || @versioning_enabled != false
end

.with_actor(actor) ⇒ Object

Attribute all changes made inside the block to the given actor. Audit entries pick it up as user_id and versions as created_by, unless the call site passes an explicit user_id:.

Magick.with_actor(current_user.id) { Magick[:checkout].enable }


313
314
315
316
317
318
319
# File 'lib/magick.rb', line 313

def with_actor(actor)
  previous = Thread.current[:magick_actor]
  Thread.current[:magick_actor] = actor
  yield
ensure
  Thread.current[:magick_actor] = previous
end