A performant and memory-efficient feature toggle gem for Ruby and Rails applications.
- Multiple Feature Types: Boolean, string, and number feature flags
- A/B Testing: Built-in experiment support with deterministic, weighted variant assignment
- Flexible Targeting: Enable features for specific users, groups, roles, tags, or percentages
- Exclusions: Exclude specific users, groups, roles, tags, or IPs — exclusions always take priority over inclusions
- Dual Backend: Memory adapter (fast) with Redis fallback (persistent)
- Rails Integration: Seamless integration with Rails, including request store caching
- DSL Support: Define features in a Ruby DSL file (
config/features.rb) - Thread-Safe: All operations are thread-safe for concurrent access
- Performance: Lightning-fast feature checks with async metrics recording and memory-first caching strategy
- Advanced Features: Circuit breaker, audit logging, performance metrics, versioning, and more
Add this line to your application's Gemfile:
gem 'magick'And then execute:
$ bundle installOr install it yourself as:
$ gem install magickAfter adding the gem to your Gemfile and running bundle install, generate the configuration file:
rails generate magick:installThis will create config/initializers/magick.rb with a basic configuration.
If you want to use ActiveRecord as a persistent storage backend, you must generate and run the migration:
rails generate magick:active_record
rails db:migrateThis will create a migration file that creates the magick_features table. The adapter will not auto-create the table - you must run migrations.
Note: The ActiveRecord adapter is optional and only needed if you want database-backed feature flags. The gem works perfectly fine with just the memory adapter or Redis adapter.
The generator creates config/initializers/magick.rb with sensible defaults. You can also create it manually:
Magick.configure do
# Configure Redis (optional)
# Use database 1 by default to avoid conflicts with Rails cache (which uses DB 0)
redis url: ENV['REDIS_URL'], db: 1
# Enable features
performance_metrics enabled: true
audit_log enabled: true
versioning enabled: true
warn_on_deprecated enabled: true
endMagick.configure do
# Environment
environment Rails.env
# Memory TTL
memory_ttl 7200 # 2 hours
# Redis configuration
# Use separate database (DB 1) to avoid conflicts with Rails cache (DB 0)
# This ensures feature toggles persist even when Rails cache is cleared
redis url: ENV['REDIS_URL'], namespace: 'magick:features', db: 1
# Or include database in URL: redis url: 'redis://localhost:6379/1'
# Circuit breaker settings
circuit_breaker threshold: 5, timeout: 60
# Async updates: Redis writes are handed to one serialized background
# writer instead of blocking the caller. See "Async Updates" below.
async_updates enabled: true, queue_limit: 1000, enqueue_timeout: 5
# Enable services
performance_metrics(
enabled: true,
redis_tracking: true, # Auto-enabled if Redis is configured
batch_size: 100, # Flush after 100 updates
flush_interval: 60 # Or flush every 60 seconds
)
audit_log enabled: true
versioning enabled: true
warn_on_deprecated enabled: true
end# Check if a feature is enabled
if Magick.enabled?(:new_dashboard)
# Show new dashboard
end
# With context (user, role, etc.)
if Magick.enabled?(:premium_features, user_id: current_user.id, role: current_user.role)
# Show premium features
end# Register a boolean feature
Magick.register_feature(:new_dashboard,
type: :boolean,
default_value: false,
description: "New dashboard UI",
group: "UI" # Optional: group features for organization
)
# Register a string feature
Magick.register_feature(:api_version,
type: :string,
default_value: "v1",
description: "API version to use"
)
# Register a number feature
Magick.register_feature(:max_results,
type: :number,
default_value: 10,
description: "Maximum number of results"
)feature = Magick[:new_dashboard]
# Enable globally (for everyone, no targeting)
feature.enable
# Disable globally (for everyone, no targeting)
feature.disable
# Enable for specific user
feature.enable_for_user(123)
# Enable for specific group
feature.enable_for_group("beta_testers")
# Enable for specific role
feature.enable_for_role("admin")
# Enable for specific tag
feature.enable_for_tag("premium")
feature.enable_for_tag("beta")
# Enable for percentage of users (consistent)
feature.enable_percentage_of_users(25) # 25% of users
# Enable for percentage of requests (random)
feature.enable_percentage_of_requests(50) # 50% of requests
# Enable for date range
feature.enable_for_date_range('2024-01-01', '2024-12-31')
# Enable for IP addresses
feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
# Enable for custom attributes
feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])enable and disable are the global switches: they set the feature's value
and clear its targeting, so a flag that was on for one specific user is off
for that user too once it has been disabled.
Both are all-or-nothing. Only a boolean feature has an "on", and calling
enable on a string or number feature raises without having changed or
persisted anything:
Magick[:api_version].enable_for_user(123)
Magick[:api_version].enable
# => Magick::InvalidFeatureValueError: Cannot enable string feature. Use set_value instead.
Magick[:api_version].targeting # => { user: ["123"] } — untouched, in memory and in the backendMagick.bulk_enable and Magick.bulk_disable apply exactly those semantics to
a list of features. They return a Magick::BulkResult, which iterates as the
array of features it was handed and names anything it could not act on:
result = Magick.bulk_disable(%i[checkout new_dashboard api_version])
result.complete? # => true — every feature type has an "off"
result.map(&:name) # => ["checkout", "new_dashboard", "api_version"]
result = Magick.bulk_enable(%i[checkout api_version])
result.complete? # => false
result.changed # => [#<Magick::Feature checkout>]
result.skipped # => [#<Magick::Feature api_version>]
result.skipped_reasons # => { "api_version" => "cannot enable a string feature; use set_value" }A bulk call still acts on every feature it can; skipped_reasons is how you
tell a partial run from a complete one.
For building flag-management endpoints on top of the gem (an internal panel, a sync job, any JSON API), two primitives implement the wire contract — the gem ships no routes, your app owns paths and auth:
# GET side — full flag payload, string keys. The "targeting" key is ALWAYS
# present ({} = no targeting); list rules are arrays of strings, percentages
# floats. Rails-idiomatic: works with render json: directly.
render json: Magick.features.values # [{... "targeting" => {"user" => ["3"], "percentage_users" => 50.0}}, ...]
# PATCH side — wholesale, declarative write: the payload IS the new targeting
# state. Keys absent from the payload are removed; {} clears everything.
feature.replace_targeting(payload['targeting'])replace_targeting is lenient about input spellings (string or symbol keys,
plural aliases like users:, scalars for lists, numeric strings) but strict
about content: unknown keys or invalid values (percentage outside (0, 100],
malformed date ranges, junk IPs) raise Magick::InvalidTargetingError
before anything is applied — map it to a 422:
rescue Magick::InvalidTargetingError => e
render json: { error: e.message }, status: :unprocessable_entityEach call records one audit entry and one version snapshot
(replace_targeting). A/B variants are not part of the targeting payload —
they never appear inside the wire targeting object and survive a replace
untouched (manage them via set_variants). enable/disable still clear
all targeting wholesale, so their wire representation is "targeting": {}.
Exclusions let you block specific users, groups, roles, tags, or IP addresses from a feature — even if they match an inclusion rule. Exclusions always take priority over inclusions.
feature = Magick[:new_dashboard]
# Exclude specific users
feature.exclude_user(456)
feature.exclude_user(789)
# Exclude specific tags
feature.exclude_tag('legacy_tier')
feature.exclude_tag('banned')
# Exclude specific groups
feature.exclude_group('suspended_users')
# Exclude specific roles
feature.exclude_role('guest')
# Exclude IP addresses (supports CIDR notation)
feature.exclude_ip_addresses(['10.0.0.0/8', '192.168.1.100'])
# Remove exclusions
feature.remove_user_exclusion(456)
feature.remove_tag_exclusion('legacy_tier')
feature.remove_group_exclusion('suspended_users')
feature.remove_role_exclusion('guest')
feature.remove_ip_exclusion # Removes all IP exclusionsExclusions win over inclusions:
feature = Magick[:premium_features]
feature.enable # Enabled globally for everyone
feature.exclude_user(123)
Magick.enabled?(:premium_features, user_id: 123) # => false (excluded)
Magick.enabled?(:premium_features, user_id: 456) # => true (not excluded)
# Even percentage targeting is overridden
feature.enable_percentage_of_users(100) # 100% of users
feature.exclude_user(123)
Magick.enabled?(:premium_features, user_id: 123) # => false (still excluded)Exclusions in DSL (config/features.rb):
boolean_feature :new_dashboard, default: true
# Exclude problematic users
exclude_user :new_dashboard, 'user_123'
exclude_user :new_dashboard, 'user_456'
# Exclude legacy tiers
exclude_tag :new_dashboard, 'legacy_tier'
# Exclude groups
exclude_group :new_dashboard, 'banned_users'
# Exclude roles
exclude_role :new_dashboard, 'suspended'
# Exclude IPs
exclude_ip_addresses :new_dashboard, '10.0.0.0/8'You can check if a feature is enabled for an object (like a User model) and its fields:
# Using enabled_for? with an object
user = User.find(123)
if Magick.enabled_for?(:premium_features, user)
# Feature is enabled for this user
end
# Or using the feature directly
feature = Magick[:premium_features]
if feature.enabled_for?(user)
# Feature is enabled for this user
end
# With additional context
if Magick.enabled_for?(:premium_features, user, ip_address: request.remote_ip)
# Feature is enabled for this user and IP
end
# Works with ActiveRecord objects, hashes, or simple IDs
Magick.enabled_for?(:feature, user) # ActiveRecord object
Magick.enabled_for?(:feature, { id: 123, role: 'admin' }) # Hash
Magick.enabled_for?(:feature, 123) # Simple ID
# Tag targeting - tags are automatically extracted from user objects
user = User.find(123) # User has tags association
feature.enable_for_tag('premium')
Magick.enabled_for?(:feature, user) # Checks user.tags automatically
# Or explicitly pass tags
Magick.enabled?(:feature, tags: user.tags.map(&:id))
Magick.enabled?(:feature, tags: ['premium', 'beta'])
# Tags are extracted from:
# - user.tags (ActiveRecord association)
# - user.tag_ids (array of IDs)
# - user.tag_names (array of names)
# - hash[:tags], hash[:tag_ids], hash[:tag_names]The enabled_for? method automatically extracts:
user_idfromidoruser_idattributegroupfromgroupattributerolefromroleattributetagsfromtagsassociation,tag_ids, ortag_namesmethods/attributesip_addressfromip_addressattribute- All other attributes for custom attribute matching
All enable/disable methods now return true to indicate success:
# All these methods return true on success
result = feature.enable # => true
result = feature.disable # => true
result = feature.enable_for_user(123) # => true
result = feature.enable_for_group('beta') # => true
result = feature.enable_for_role('admin') # => true
result = feature.enable_for_tag('premium') # => true
result = feature.enable_percentage_of_users(25) # => true
result = feature.set_value(true) # => true
# Exclusion methods also return true
result = feature.exclude_user(456) # => true
result = feature.exclude_tag('banned') # => true
result = feature.exclude_group('blocked') # => true
result = feature.exclude_role('guest') # => trueCreate config/features.rb:
# Boolean features
boolean_feature :new_dashboard,
default: false,
name: "New Dashboard",
description: "New dashboard UI"
boolean_feature :dark_mode,
default: false,
name: "Dark Mode",
description: "Dark mode theme"
# String features
string_feature :api_version, default: "v1", description: "API version"
# Number features
number_feature :max_results, default: 10, description: "Maximum results per page"
# A/B test experiment
experiment :checkout_button,
name: "Checkout Button",
description: "Button color experiment",
variants: [
{ name: 'control', value: '#0066cc', weight: 50 },
{ name: 'green', value: '#00cc66', weight: 30 },
{ name: 'red', value: '#cc0000', weight: 20 }
]
# With status
feature :experimental_feature,
type: :boolean,
default_value: false,
status: :deprecated,
description: "Experimental feature (deprecated)"
# With dependencies (feature will only be enabled if dependencies are enabled)
boolean_feature :advanced_feature,
default: false,
description: "Advanced feature requiring base_feature",
dependencies: [:base_feature]
# Multiple dependencies
boolean_feature :premium_feature,
default: false,
description: "Premium feature requiring multiple features",
dependencies: [:base_feature, :auth_feature]
# Add dependencies after feature definition
add_dependency(:another_feature, :required_feature)
# Exclusions - block specific users/groups/roles/tags
exclude_user :new_dashboard, 'user_123'
exclude_tag :new_dashboard, 'legacy_tier'
exclude_group :new_dashboard, 'banned_users'
exclude_role :new_dashboard, 'suspended'
exclude_ip_addresses :new_dashboard, '10.0.0.0/8'Loading a DSL file yourself
The Rails railtie loads config/features.rb for you. If you load one by hand,
use Magick::ConfigDSL.load_from_file:
Magick.definition_mode { Magick::ConfigDSL.load_from_file(Rails.root.join('config/features.rb')) }load_from_file evaluates the file as Ruby. Never hand it a path derived
from HTTP params, ENV, or any other untrusted source — that is remote code
execution. As a backstop, the path is resolved with File.realpath and must
live inside the project root: Rails.root under Rails, otherwise the directory
holding the Gemfile. The check is separator-aware (/srv/app-evil is not
inside /srv/app) and never consults the process working directory, so
starting the app from / or chdir-ing later cannot widen it. Outside Rails and
Bundler, set the root explicitly:
Magick::ConfigDSL.project_root = '/srv/app'MAGICK_ALLOW_CONFIG_EVAL=1 is dangerous. It disables the containment
check entirely, so any caller that can influence the path gets arbitrary code
execution in your process. Set it only for a trusted file you deliberately keep
outside the project tree, and never on a host where the path can come from a
request.
class DashboardController < ApplicationController
def show
if Magick.enabled?(:new_dashboard, user_id: current_user.id, role: current_user.role)
render :new_dashboard
else
render :old_dashboard
end
end
endAdd the optional request_store
gem and repeated checks of the same feature with the same context are evaluated
once per request and reused:
# Gemfile
gem 'request_store'# Evaluated once; the second and third calls reuse the answer.
Magick.enabled?(:new_dashboard, user_id: current_user.id)
Magick.enabled?(:new_dashboard, user_id: current_user.id)
Magick.disabled?(:new_dashboard, user_id: current_user.id)This matters most for enable_percentage_of_requests, which rolls a die on
every check. Cached, the whole request gets one answer, so a page cannot render
half of a rollout. The next request rolls again.
The cache is keyed by feature name and context, lives in RequestStore and is
cleared with it at the end of each request. It is only consulted inside a
request — in a console, a rake task or at boot, checks are evaluated live,
which is also what happens when request_store is not installed. If you change
a feature mid-request and later call sites in that same request must see the
new state, drop the memo:
Magick::RequestStoreIntegration.clear!Rails installs this for you. Elsewhere (a Sidekiq-only process, for example)
wire it up yourself — request_store ships middleware for both Rack and
Sidekiq:
require 'magick/request_store_integration'
Magick::RequestStoreIntegration.install!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.
Quick setup with DSL (config/features.rb):
experiment :checkout_button,
name: "Checkout Button Color",
description: "Test which button color converts better",
variants: [
{ name: 'control', value: '#0066cc', weight: 50 },
{ name: 'green', value: '#00cc66', weight: 30 },
{ name: 'red', value: '#cc0000', weight: 20 }
]Or set up programmatically:
feature = Magick[:checkout_button]
feature.set_variants([
{ name: 'control', value: '#0066cc', weight: 50 },
{ name: 'green', value: '#00cc66', weight: 30 },
{ name: 'red', value: '#cc0000', weight: 20 }
])Usage in your application:
# Get the variant name for a user (deterministic — same user always gets same variant)
variant = Magick.variant(:checkout_button, user_id: current_user.id)
# => "control", "green", or "red"
# Get the variant value directly
color = Magick.variant_value(:checkout_button, user_id: current_user.id)
# => "#0066cc", "#00cc66", or "#cc0000"
# Works with user objects too
variant = Magick.variant(:checkout_button, user: current_user)
# Use in views/controllers
class CheckoutController < ApplicationController
def show
@button_color = Magick.variant_value(:checkout_button, user: current_user)
# Same user always sees the same color
end
endExperiments without a user (anonymous visitors):
For flows where there's no authenticated user yet (e.g., registration, landing pages), use any stable identifier as user_id — a session ID or a tracking cookie:
class RegistrationController < ApplicationController
def new
cookies[:visitor_id] ||= SecureRandom.uuid
@variant = Magick.variant(:registration_flow, user_id: cookies[:visitor_id])
end
endThe hashing just needs a consistent string. As long as the same visitor sends the same identifier, they get the same variant every time.
Safe to call on non-existent experiments:
Magick.variant(:nonexistent, user_id: 123) # => nil
Magick.variant_value(:nonexistent, user_id: 123) # => nilImportant — changing weights may shift users:
You can change variant weights at any time via the Admin UI or code, and changes take effect immediately across all adapters. However, changing weights alters the bucket boundaries, which means some users may be reassigned to a different variant after the update. Magick does not persist individual user-to-variant assignments — assignment is computed on the fly from the hash. If your experiment requires that users never shift variants mid-experiment, you should persist the assignment externally (e.g., store user_id → variant in a database table on first exposure).
How it works:
- Variants are assigned using a deterministic MD5 hash of
feature_name + user_id - The same user always gets the same variant across sessions and requests
- Weights control the distribution (e.g., 50/30/20 means ~50% control, ~30% green, ~20% red)
- If no
user_idis provided, falls back to random assignment (useful for anonymous users) - Experiments are boolean features with variants — they work with all targeting and exclusion rules
- Manage variants through the Admin UI with visual weight distribution
A feature can require other features (its prerequisites) to be on. This is evaluation-only: a dependent feature evaluates as disabled while any prerequisite evaluates as disabled, and its own configured state is never touched — turn the prerequisite back on and the dependent feature evaluates as enabled again, with no re-toggling.
Magick[:advanced_feature].add_dependency(:base_feature)
Magick[:advanced_feature].enable
Magick[:base_feature].disable
Magick[:advanced_feature].enabled? # => false (prerequisite is off)
Magick[:advanced_feature].get_value # => true (configured state is preserved)
Magick[:base_feature].enable
Magick[:advanced_feature].enabled? # => true
Magick[:advanced_feature].remove_dependency(:base_feature)
Magick[:advanced_feature].replace_dependencies(%i[base_feature auth]) # wholesale write
Magick[:advanced_feature].dependencies # => ["base_feature", "auth"]Dependencies are persisted under the feature's dependencies key, alongside
its value and targeting. Adding or removing one writes to the backend, publishes
cache invalidation, and records one audit entry plus one version — so every
container sees the relationship, it survives a restart, and it travels through
export/import and rollback.
Declared in code vs. stored. dependencies: in the DSL is a declaration;
the stored set is what evaluation uses.
- The declaration seeds the stored set the first time it is seen.
- Stored state wins after that, so a dependency added at runtime (console, Admin UI, another container) is not erased by every process that boots with the older declaration. A process that declares nothing never writes.
- A declaration that changed since it was last recorded replaces the stored
set, so editing
dependencies:in code takes effect on the next boot. Deleting the declaration is not a change — remove the dependency explicitly withremove_dependency/replace_dependencies.
Prerequisites that this process never declared are resolved from the
backend, so a worker that only registers :advanced_feature still evaluates
:base_feature correctly.
Unknown prerequisites. A prerequisite that is registered nowhere and absent from the backend is, by default, treated as satisfied: the dependent feature falls back to its own value and targeting, so a prerequisite that has not shipped yet (or a typo) cannot switch off features that are otherwise correctly configured. The name is reported on stderr once per process, so the condition is never silent. To fail closed instead:
Magick.configure do
unknown_dependency_policy :unsatisfied # dependent features stay off until the prerequisite exists
end
# or directly
Magick.unknown_dependency_policy = :unsatisfiedA feature cannot depend on itself (ArgumentError), and a dependency cycle
(a -> b -> a) evaluates as unsatisfied and is reported, rather than
overflowing the stack.
# Export features
json_data = Magick.export(format: :json)
File.write('features.json', json_data)
# Import features
Magick.import(File.read('features.json'))The payload carries a feature's whole state — value, status, targeting, exclusions, A/B variants and dependencies — so an import reproduces the feature the export was taken from, variant assignment included. Because it is the whole state, importing a feature whose payload carries no variants also clears any the target store had for that name.
Every state-changing operation (value, status, group, targeting, exclusions,
variants, dependencies, delete) automatically records a version snapshot and
an audit entry — one per logical operation, under its real action name
(enable, exclude_user, set_status, …). Nested internals never
double-record.
# History accumulates automatically:
Magick[:my_feature].enable # => version 1 (action: "enable")
Magick[:my_feature].enable_for_user(42) # => version 2 (action: "enable_for_user")
# Inspect history (hot window: last 50 versions by default)
Magick.versioning.get_versions(:my_feature)
# Include the unlimited ActiveRecord archive (when AR adapter is configured)
Magick.versioning.get_versions(:my_feature, all: true)
# Rollback fully restores a snapshot: value (including false/empty), status,
# group, and the entire targeting hash — and records the rollback itself as a
# new version, so history only ever rolls forward.
Magick.versioning.rollback(:my_feature, 2)
# Manual snapshots still work (action: "manual")
Magick.versioning.save_version(:my_feature, created_by: current_user.id)Retention is tiered: memory/Redis keep the last max_versions snapshots
(default 50) for fast access; the ActiveRecord adapter keeps an unlimited
archive that also survives feature deletion.
Magick.configure do
versioning enabled: true, max_versions: 50
endVersion numbers come from the shared store, not from any one process. The
number is allocated by an atomic counter — a row-locked UPDATE on the
ActiveRecord row when that adapter is configured, otherwise Redis HSETNX +
HINCRBY. Every append re-reads the current history rather than trusting a
window cached at boot.
Snapshots are stored one per key, and in ActiveRecord one per row. The hot
window keeps each snapshot under its own version_<n> key in the reserved
__magick_versions:<name> namespace; the archive gives each snapshot a row of
its own, __magick_versions:<name>#v<n>, with the counter in
__magick_versions:<name>#seq. An ActiveRecord write rewrites the whole row it
touches, so keeping the history in one row would make appending version N cost a
rewrite of all N-1 before it. Nothing in that namespace is a feature: like the
audit namespace, it is skipped by all_features, by the cache preload, and by
the Admin UI's source refresh — and skipped in the store, not after loading, so
an unbounded archive is never read into a worker's memory cache.
That is what makes history correct across containers: two processes saving at
the same time interleave into one list, neither loses a snapshot, and
rollback(name, 2) restores the same state whichever process serves the
request. Only with no shared backend at all (memory-only, single process) is
the counter process-local.
Custom adapters: an adapter used with versioning should implement
#next_sequence(feature_name, key, floor:) and #delete_key(feature_name, key)
in addition to the usual read/write methods. One that can filter by name in its
store should also override #load_features_data_with_prefix(prefix) and
#load_features_data_without_prefixes(prefixes); the inherited defaults are
correct but read everything before discarding it.
Magick::Adapters::Base ships a
read-modify-write #next_sequence that is correct for a store only one process
can reach; an adapter backed by a store shared between processes must
override it with something genuinely atomic, or two processes will be handed the
same version number. Without #delete_key, the hot window is never pruned.
Attribution: wrap changes in Magick.with_actor to stamp audit entries
(user_id) and versions (created_by):
Magick.with_actor(current_user.id) do
Magick[:my_feature].enable_for_user(42)
endBoot replay is not recorded: the Rails railtie loads config/features.rb
inside Magick.definition_mode, so re-applying declarative definitions on
every process boot does not flood history. Non-Rails apps should wrap their
own definition file load the same way:
Magick.definition_mode { load 'config/features.rb' }# Get comprehensive stats for a feature
Magick.feature_stats(:my_feature)
# => {
# usage_count: 1250,
# average_duration: 0.032,
# average_duration_by_operation: {
# enabled: 0.032,
# value: 0.0,
# get_value: 0.0
# }
# }
# Get just the usage count
Magick.feature_usage_count(:my_feature)
# => 1250
# Get average duration (optionally filtered by operation)
Magick.feature_average_duration(:my_feature)
Magick.feature_average_duration(:my_feature, operation: 'enabled?')
# Get most used features
Magick.most_used_features(limit: 10)
# => {
# "my_feature" => 1250,
# "another_feature" => 890,
# ...
# }
# Direct access to performance metrics (for advanced usage)
Magick.performance_metrics.average_duration(feature_name: :my_feature)
Magick.performance_metrics.usage_count(:my_feature)
Magick.performance_metrics.most_used_features(limit: 10)Configuration:
Magick.configure do
performance_metrics(
enabled: true,
redis_tracking: true, # Auto-enabled if Redis is configured
batch_size: 100, # Flush after 100 updates
flush_interval: 60 # Or flush every 60 seconds
)
endPerformance: Metrics are recorded asynchronously in a background thread, ensuring zero overhead on feature checks. The enabled? method remains lightning-fast even with metrics enabled.
Note: When redis_tracking: true is set, usage counts are persisted to Redis and aggregated across all processes, giving you total usage statistics. Metrics are automatically flushed in batches to minimize Redis overhead.
Without Redis the counts stay in memory and are reported in full: a flush
only drains pending updates once the write has actually landed, so a
Redis-less deployment — or a Redis that is temporarily unreachable — never
loses counts. Durations are kept as a rolling window of the most recent 1,000
samples per process, so averages track recent behaviour rather than the first
samples after boot. Stats that need to enumerate the keyspace use SCAN, not
KEYS.
Every mutation is logged under its real action name (enable, disable,
set_value, enable_for_user, exclude_role, set_status, set_group,
delete, rollback, …). One logical operation produces exactly one entry:
enable no longer surfaces as a bare set_value.
# View audit log entries — newest last, merged across every process
entries = Magick.audit_log.entries(feature_name: :my_feature, limit: 100)
entries.each do |entry|
puts "#{entry.id} #{entry.timestamp}: #{entry.action} by #{entry.user_id}"
endDurability and retention. Entries are written to every configured adapter
that outlives the process — Redis and/or ActiveRecord — so history survives a
restart and every container can answer "who changed this flag" about a change
made anywhere else. entries merges that shared history with the entries this
process wrote itself.
Retention is tiered, like versioning:
| Tier | Where | Kept | Survives restart |
|---|---|---|---|
| Ring | This process's memory | last max_entries (default 10,000) across all features |
no |
| Durable store | Redis and/or ActiveRecord | last retention (default 200) per feature |
yes |
The durable store lives under a reserved __magick_audit:<feature> pseudo-feature
namespace, so audit history never shows up in the feature list, is not dragged
along by feature reads, and outlives the feature it describes — a deleted flag
keeps the record of who deleted it. Entries carry a unique, chronologically
sortable id, which is what de-duplicates them when the same entry is read back
from more than one adapter.
Writes are best-effort and happen outside the lock that guards the ring: a slow or unavailable backend never fails a feature mutation and never serializes mutations behind the audit log.
Unlike version numbers, an audit append is a read-merge-write of the feature's capped list rather than an atomic allocation, so two containers writing for the same feature in the same instant can drop one entry from the shared list. Each write merges this process's recent entries back in, so a dropped entry is restored by that process's next write; entry ids make the merge exact.
A memory-only deployment has nothing that outlives the process, so it keeps
only the ring — Magick.audit_log.durable? returns false there. Configure
Redis or the ActiveRecord adapter to get durable audit history.
Magick.configure do
# Defaults
audit_log enabled: true, retention: 200, max_entries: 10_000
# Ship entries to your own sink as well (called with each entry)
# audit_log adapter: MyAuditSink.new
# Your sink is the system of record: keep the ring, write nothing to
# Redis/ActiveRecord
# audit_log adapter: MyAuditSink.new, persist: false
# Opt out entirely — Magick.audit_log is nil and nothing is recorded
# audit_log enabled: false
endA host-supplied adapter only has to respond to append(entry); it is called
outside the ring lock, and an exception it raises is logged rather than
propagated into the feature mutation.
In the Admin UI, configure a current_actor hook so every change made
through the UI is attributed:
Magick::AdminUI.configure do |config|
config.current_actor = ->(controller) { controller.session[:admin_id] }
endMagick uses a dual-adapter strategy:
- Memory Adapter: Fast, in-memory storage with TTL support and JSON serialization
- Redis Adapter: Persistent storage for distributed systems (optional), uses SCAN instead of KEYS and pipelined bulk operations
The registry automatically falls back from memory to Redis if a feature isn't found in memory. When features are updated:
- Both adapters are updated simultaneously
- Cache invalidation messages are published via Redis Pub/Sub to notify other processes
- Each message carries the identity of the process that published it: a process ignores only its own messages and always acts on a peer's, so two processes writing the same flag moments apart still converge on the shared store's value
- Targeting updates trigger immediate cache invalidation to ensure consistency
If Redis is not configured, Magick works in memory-only mode:
- ✅ Fast, zero external dependencies
- ✅ Perfect for single-process applications or development
⚠️ No cross-process cache invalidation - each process has isolated cache⚠️ Changes in one process won't be reflected in other processes
With Redis configured:
- ✅ Cross-process cache invalidation via Redis Pub/Sub
- ✅ Persistent storage across restarts
- ✅ Zero Redis calls on feature checks (only memory lookups)
- ✅ Automatic cache invalidation when features change in any process
- ✅ Isolated from Rails cache - Use
db: 1to store feature toggles in a separate Redis database, ensuring they persist even when Rails cache is cleared
Important: By default, Magick uses Redis database 1 to avoid conflicts with Rails cache (which typically uses database 0). This ensures that clearing Rails cache (Rails.cache.clear) won't affect your feature toggle states.
With async_updates enabled: true, memory is still updated synchronously but
the Redis write (and its Pub/Sub invalidation) is handed to a background
serialized writer: one thread per registry, draining a bounded FIFO queue.
Magick.configure do
async_updates enabled: true,
queue_limit: 1000, # pending writes held before backpressure
enqueue_timeout: 5 # seconds a caller waits on a full queue
endTwo properties follow from the single writer, and both matter:
- Order is preserved. Writes to the same feature reach Redis in the order they were issued, so Redis cannot end up holding an older value than memory (and the trailing invalidation cannot tell other processes to load a stale one).
- Resources are bounded. A bulk toggle of 200 features costs one thread and one Redis connection, not 200 of each.
When the queue is full the caller blocks for up to enqueue_timeout
seconds — real backpressure, the queue drains while it waits. If it is still
full when that expires, the write is dropped. A dropped write never reaches
Redis, so it is reported exactly like a backend write that failed (see
When a Backend Write Fails): error-severity log
line plus a magick.feature_flag.adapter_write_failed event, carrying the
running drop total. The announcement is rate-limited to once a second; every
drop is still counted.
Magick: redis async_write failed for 'checkout_v2': async write queue full (limit 1000), write dropped; 3 dropped so far
Dropping is deliberate. Blocking forever would turn a wedged Redis into an
application-wide stall, and running the write inline would let it overtake the
writes already queued for that feature — the exact out-of-order divergence the
writer exists to prevent. A full queue means Redis is down or unreachably slow,
in which case the write would most likely have failed anyway; memory (the read
path) still holds the correct value, and the drop is loud rather than silent.
Raise queue_limit if you legitimately burst harder than that.
On shutdown (Magick.shutdown!) the writer stops accepting work and drains
its backlog within the shutdown timeout. Anything still queued when the timeout
expires is abandoned — reported on the same channel, not waited on — so
shutdown never hangs:
Magick: redis async_write failed: abandoned 12 pending write(s) after 5s drain timeout
Writes issued after shutdown are performed inline rather than lost.
Async updates are off by default; without them every Redis write is synchronous and ordering is the caller's own.
The ActiveRecord adapter provides database-backed persistent storage for feature flags. It's useful when you want to:
- Store feature flags in your application database
- Use ActiveRecord models for feature management
- Have a fallback storage layer
- Work with PostgreSQL, MySQL, SQLite, or any ActiveRecord-supported database
Setup:
-
Generate and run the migration (required):
rails generate magick:active_record rails db:migrate
With UUID primary keys:
rails generate magick:active_record --uuid rails db:migrate
Important: The adapter will not auto-create the table. You must run migrations before using the ActiveRecord adapter. If the table doesn't exist, the adapter will raise a clear error with instructions.
-
Configure in
config/initializers/magick.rb:Magick.configure do active_record # Uses default MagickFeature model # Or specify a custom model: # active_record model_class: YourCustomModel end
PostgreSQL Support:
The generator automatically detects PostgreSQL and uses jsonb for the data column, providing:
- Better performance with native JSON queries
- Native JSON indexing and querying capabilities
- Type-safe JSON storage
For other databases (MySQL, SQLite, etc.), it uses text with serialized JSON.
UUID Primary Keys:
When using the --uuid flag:
- Creates table with
id: :uuidinstead of integer primary key - Enables
pgcryptoextension for PostgreSQL (required for UUID generation) - Works with other databases using their native UUID support
Note: The ActiveRecord adapter works as a fallback in the adapter chain: Memory → Redis → ActiveRecord. It's automatically included if ActiveRecord is available and configured.
Adapter Chain:
The adapter registry uses a fallback strategy:
- Memory Adapter (first) - Fast, in-memory lookups
- Redis Adapter (second) - Persistent, distributed storage
- ActiveRecord Adapter (third) - Database-backed fallback
When a feature is requested:
- First checks memory cache (fastest)
- Falls back to Redis if not in memory
- Falls back to ActiveRecord if Redis is unavailable or returns nil
- Updates all adapters when features are modified
This ensures maximum performance while maintaining persistence and reliability.
Writes go to memory first and memory is never rolled back, so a Redis or ActiveRecord write that fails leaves that one process serving a value the rest of the fleet does not have. The write itself stays contained — a broken backend never raises into your code — but the divergence is always reported:
- Logged at
errorseverity in every environment (not just development), throughRails.loggerwhen there is one and$stderrotherwise. Feature names and driver messages are sanitized withMagick::LogSafe, so nothing coming off the wire can forge a log line. - Emitted as
magick.feature_flag.adapter_write_failedon the structured event channel, withbackend,operation,feature_nameand the error (orreason: "circuit breaker open"when the write was dropped without being attempted). Subscribe to it to alert on divergence — see RAILS8_EVENTS.md.
Magick: redis set failed for 'new_checkout': Magick::AdapterError: Failed to set in Redis: Connection refused
Redis going away degrades the gem; it never breaks the host app. Nothing on a
read path raises — Magick.enabled?, exists?, all_features,
get_all_data and preload! all fall through to the next adapter and, failing
that, answer "not found".
Every Redis call — reads, writes, deletes and Pub/Sub publishes alike — goes through the circuit breaker:
Magick.configure do
circuit_breaker threshold: 5, timeout: 60
end- After
thresholdfailures the circuit opens and Redis is not contacted at all fortimeoutseconds. Calls raiseMagick::CircuitOpenErrorinternally (aMagick::AdapterError), which the registry catches; callers just see the fallback answer. Dropped writes are still reported as described above, withreason: "circuit breaker open". - Once the window elapses the circuit goes half-open and admits exactly
one probe. If that probe fails the circuit re-opens immediately, so a
permanently dead Redis costs one failed call per window, not
thresholdof them. - An open circuit never publishes cache invalidation. This matters more than it sounds: the invalidation tells every peer to re-read the feature from Redis, and if this process could not write to Redis, what the peers would read is the pre-toggle value. Invalidation is published only after Redis has genuinely accepted the write. The same rule applies to the async write path.
Locally the toggle still takes effect: memory is updated synchronously, so the process that made the change keeps serving the new value while Redis is down. Peers will not see it until Redis recovers and the change is written again.
The default Redis client sets explicit connect_timeout, read_timeout and
write_timeout (1s each) rather than inheriting redis-rb's 5s defaults — a
Redis that black-holes packets never refuses the connection, so without them a
single lookup can pin a request thread for 5 seconds. Override any of them:
Magick.configure do
redis url: ENV['REDIS_URL'], db: 1, connect_timeout: 0.5, read_timeout: 2.0
endMagick includes a web-based Admin UI for managing feature flags. It's a Rails Engine that provides a user-friendly interface for viewing, enabling, disabling, and configuring features.
Setup:
- Configure roles and tags (optional) for targeting management in
config/initializers/magick.rb:
Rails.application.config.after_initialize do
Magick::AdminUI.configure do |config|
config.available_roles = ['admin', 'user', 'manager', 'guest']
# Tags can be configured as an array or lambda (for dynamic loading)
config.available_tags = -> { Tag.all } # Lambda loads tags dynamically
# Or as a static array:
# config.available_tags = ['premium', 'beta', 'vip']
end
end- Mount the engine in
config/routes.rb:
Rails.application.routes.draw do
# ... your other routes ...
# With authentication (recommended for production)
authenticate :admin_user do
mount Magick::AdminUI::Engine, at: '/magick'
end
# Or without authentication (development only)
# mount Magick::AdminUI::Engine, at: '/magick'
endAccess:
Once mounted, visit /magick in your browser to access the Admin UI.
Features:
- Feature List: View all registered features with their current status, type, and description
- Feature Details: View detailed information about each feature including:
- Current value/status
- Targeting rules (users, groups, roles, percentages, etc.)
- Performance statistics (usage count, average duration)
- Feature metadata (type, default value, dependencies)
- Enable/Disable: Quickly enable or disable features globally
- Targeting Management: Configure targeting rules through a user-friendly interface:
- Role Targeting: Select roles from a configured list (checkboxes)
- Tag Targeting: Select tags from a dynamically loaded list (checkboxes)
- User Targeting: Enter user IDs (comma-separated)
- Exclusions: Exclude users, roles, and tags from a feature (exclusions override inclusions)
- Visual Display: See all active targeting rules with badges
- Edit Features: Update feature values (boolean, string, number) directly from the UI
- A/B Test Management: Create and manage experiment variants with visual weight distribution
- Statistics: View performance metrics and usage statistics for each feature
- Feature Grouping: Organize features into groups for easier management and filtering
- Filtering: Filter features by group, name, or description
Feature Grouping:
Features can be organized into groups for easier management and filtering:
-
Setting Groups:
- Set a group when registering a feature in code:
Magick.register_feature(:new_payment_flow, type: :boolean, default_value: false, group: 'Payment', description: "New payment processing flow" )
- Or set/update groups via the Admin UI when editing a feature
- Set a group when registering a feature in code:
-
Filtering by Group:
- Use the group dropdown in the Admin UI to filter features by group
- Combine group filtering with search to find specific features quickly
-
Benefits:
- Organize features by functional area (e.g., "Authentication", "Payment", "UI")
- Quickly find related features
- Better organization for large feature flag sets
Targeting Management:
The Admin UI provides a comprehensive targeting interface:
-
Role Targeting:
- Configure available roles via
Magick::AdminUI.configure - Select multiple roles using checkboxes
- Roles are automatically added/removed when checkboxes are toggled
- Configure available roles via
-
Tag Targeting:
- Configure available tags via
Magick::AdminUI.configure(supports lambda for dynamic loading) - Tags are loaded dynamically each time the page loads (if using lambda)
- Select multiple tags using checkboxes
- Tags are automatically added/removed when checkboxes are toggled
- Tags can be ActiveRecord objects (IDs are stored) or simple strings
- Configure available tags via
-
User Targeting:
- Enter user IDs as comma-separated values (e.g.,
123, 456, 789) - Add or remove users dynamically
- Clear all user targeting by leaving the field empty
- Enter user IDs as comma-separated values (e.g.,
-
Exclusion Targeting:
- Exclude specific users (comma-separated IDs), roles, and tags
- Exclusions always take priority over inclusions
- Managed through the same targeting form
-
Visual Feedback:
- All targeting rules are displayed as badges in the feature details view
- Easy to see which roles/tags/users have access to each feature
Routes:
The Admin UI provides the following routes:
GET /magick- Feature list (index)GET /magick/features/:id- Feature detailsGET /magick/features/:id/edit- Edit featurePUT /magick/features/:id- Update feature valuePUT /magick/features/:id/enable- Enable feature globallyPUT /magick/features/:id/disable- Disable feature globallyPUT /magick/features/:id/enable_for_user- Enable feature for specific userPUT /magick/features/:id/enable_for_role- Enable feature for specific rolePUT /magick/features/:id/disable_for_role- Disable feature for specific rolePUT /magick/features/:id/update_targeting- Update targeting rules (roles and users)PUT /magick/features/:id/update_variants- Update A/B test variantsGET /magick/stats/:id- View feature statistics
Security:
Gating at the router is the more robust of the two options below, and the one to reach for in production: it covers everything the engine mounts — including routes added by a later version of this gem — and it stops unauthenticated requests before they reach the gem at all.
# config/routes.rb
Rails.application.routes.draw do
# Using Devise
authenticate :admin_user do
mount Magick::AdminUI::Engine, at: '/magick'
end
# Or using session-based authentication
constraints(->(request) { request.session[:user_id].present? && request.session[:admin] }) do
mount Magick::AdminUI::Engine, at: '/magick'
end
endIf your auth layer does not fit a router-level block, the Admin UI also
includes a built-in authentication hook via require_role. It runs on every
route the engine exposes — feature routes and the stats route alike:
# config/initializers/magick.rb
Rails.application.config.after_initialize do
Magick::AdminUI.configure do |config|
# Option 1: Lambda-based authentication
config.require_role = ->(controller) {
# Return true to allow, false to deny (returns 403 Forbidden)
controller.current_user&.admin?
}
# Option 2: Check for a specific role
config.require_role = ->(controller) {
controller.current_user&.role == 'admin'
}
end
endrequire_role must be a callable (a lambda or proc taking the controller) or
nil. A bare role name is rejected — config.require_role = :admin raises
Magick::ConfigurationError at configuration time rather than being accepted
and then ignored on every request:
config.require_role = :admin # => Magick::ConfigurationError
config.require_role = 'admin' # => Magick::ConfigurationErrorNote: The Admin UI is optional and only loaded when explicitly enabled in configuration. It requires Rails to be available.
:boolean- True/false flags:string- String values:number- Numeric values
:active- Feature is active and can be enabled:inactive- Feature is disabled for everyone:deprecated- Feature is deprecated (can be enabled withallow_deprecated: truein context)
Magick starts a background Redis Pub/Sub subscriber thread for cross-process
cache invalidation, an asynchronous metrics processor, and — when
async_updates is enabled — one serialized write draining thread. All must be
stopped before the host process exits or Puma's graceful-stop will block on
the still-running Redis#subscribe call.
The Railtie registers an at_exit hook that calls Magick.shutdown!
automatically, so most Rails apps don't need to do anything. In long-running
non-Rails processes (rake tasks, CLI tools) call it explicitly:
Magick.shutdown! # default 5 second join timeout
Magick.shutdown!(timeout: 1) # more aggressiveThe timeout is also the async writer's drain budget: pending Redis writes are flushed first (while the connection is still usable), and whatever does not fit in that budget is abandoned with a log line instead of blocking exit.
Fork-based deployments (Puma workers with preload_app!, Unicorn) are handled
automatically. A Rack middleware (Magick::Rails::SubscriberMiddleware) calls
ensure_subscriber! on each request — a pid-guarded no-op once the subscriber
is running — so a worker that inherited a dead parent thread starts its own
subscriber on its first request. This matters because in production
config.to_prepare runs once at boot (before workers fork), not per
request, so it cannot revive the subscriber inside forked workers on its own.
No action required from the host app.
The Admin UI is CSRF-protected out of the box (protect_from_forgery with: :exception) and 404s on unknown feature IDs instead of auto-creating
features from user-controlled params[:id].
Authentication is opt-in — if Magick::AdminUI.config.require_role is
left nil the UI is reachable by anyone who can hit its routes. Always put it
behind your app's auth, preferably at the router, which gates every route the
engine mounts, present and future:
# config/routes.rb — the more robust option
authenticate :admin_user do
mount Magick::AdminUI::Engine, at: '/magick'
endThe built-in hook is the alternative when a router-level block does not fit.
It gates every Admin UI route, and it is fail-closed: it must be a callable or
nil, and a value that is neither (a role name, say) raises
Magick::ConfigurationError when assigned rather than quietly leaving the
panel open.
Magick::AdminUI.configure do |c|
c.require_role = ->(controller) { controller.current_user&.admin? }
endUse the testing helpers in your RSpec tests:
RSpec.describe MyFeature do
it 'works with feature enabled' do
with_feature_enabled(:new_feature) do
# Test code here
end
end
it 'works with feature disabled' do
with_feature_disabled(:new_feature) do
# Test code here
end
end
endAfter checking out the repo, run:
bundle install
bundle exec rspec # full suite; needs no external services
bundle exec rubocop # linterThe specs that exercise the Redis adapter, Pub/Sub cache invalidation, and the
circuit breaker need a real Redis. They are opt-in, so a contributor without
Redis is never blocked — bundle exec rspec skips them and stays green.
redis-server & # or: docker run -p 6379:6379 redis:7
bundle exec rake spec:redis # defaults to REDIS_URL=redis://localhost:6379/1
REDIS_URL=redis://elsewhere:6379/1 bundle exec rake spec:redisThey run in their own RSpec process on purpose. Requiring the redis gem
defines ::Redis, and Magick's adapter auto-detection keys off
defined?(Redis) — sharing a process would silently point the rest of the suite
at a real Redis and leak state between examples.
rake spec:redis sets MAGICK_REDIS_SPECS=1, which makes the gate strict: an
unreachable Redis aborts the run rather than quietly skipping. CI relies on this,
plus a check that the executed example count is non-zero, so the build cannot go
green on specs that never ran. Note that rake spec:redis calls FLUSHDB on
the database in REDIS_URL — point it at a scratch database, not one holding
data you care about.
Bug reports and pull requests are welcome on GitHub.
The gem is available as open source under the terms of the MIT License.