Recently, while working on a project at GoGrow we encountered a very common issue: ingesting data from various API’s. This is nothing out of the ordinary, and we were able to resolve it by mapping the data into models and storing them locally.
Everything was fine, until we started seeing errors in Bugsnag in the staging environment. This is because we did not implement a safeguard that makes the ingestion run only in the Production environment.
You may have seen something like this in your codebase:
class DataSynchronizer
def self.ingest_data
return unless Rails.env.production?
data = ExternalService::Api.fetch_resources
...
end
end
This solution works and is easy to implement: add a single line on top of your method body and you’re good to go. However, after taking a closer look at it, you may come to realize that this implementation, although fast, is not that great. Now, you are adding environment-dependent code to it and may run into hard-to-debug issues in the future.
Enter feature flags
Feature Flags
Feature flags are a way to enable or disable certain features in your application at runtime. This can be useful for a number of reasons:
- You can use feature flags to test new features with a small group of users before rolling them out to everyone.
- Feature flags can help you roll back changes quickly if something goes wrong.
- You can use feature flags to customize the experience of groups of users.
Although there are many different implementations out there already (see the great flipper gem), they don’t always adapt 100% to your situation. This is why we decided to go with our own approach.
The requirements were the following:
- Have an easy way for the dev to toggle features on/off without doing any commits to the code.
- Be able to have a default logic for the feature flags but be able to override them in different environments.
- Have an easy way to test the features, with separate contexts for the possible states.
- We are not taking users/audiences into consideration for now, but we may want to add this capability in the future.
The solution we came up with was to have a default logic for the feature flags, but be able to override them with environment variables.
After thinking about this for a while, this is the interface we came up with:
# Create a feature flag with a name and a block which
# is evaluated each time the flag is checked.
Feature.create_feature :feature_name { some_ruby_code }
# Check if a feature flag is enabled
Feature.feature_name?
# check if a feature flag is enabled
Feature.enabled?(:feature_name)
# override the feature logic so that it is always enabled
Feature.feature_name = true
# override the feature logic so that it is never enabled
Feature.feature_name = false
# unset the feature override
Feature.feature_name = nil
We knew that to keep it dry, we would have to use a bit of metaprogramming to define singleton methods with the same name as the feature flag. Having these methods would help make code clearer and easier to understand.
This is the implementation
# app/services/feature.rb
# frozen_string_literal: true
class Feature
class MissingFeatureError < StandardError; end
OVERRIDE_ENV_PREFIX = 'ENABLE_'
class << self
def enabled?(feature_name)
return send(feature_name) if feature_defined?(feature_name)
raise MissingFeatureError, "Feature #{feature_name} is not defined"
end
def enable(feature_name)
send("#{feature_name}=", true) if feature_defined?(feature_name)
end
def disable(feature_name)
send("#{feature_name}=", false) if feature_defined?(feature_name)
end
def unset(feature_name)
send("#{feature_name}=", nil) if feature_defined?(feature_name)
end
def override?(feature_name)
raise MissingFeatureError, "Feature #{feature_name} is not defined" unless feature_defined?(feature_name)
env_name = "#{OVERRIDE_ENV_PREFIX}#{feature_name}".upcase
res = ENV.fetch(env_name, nil)
if res.is_a?(String)
%w[true yes 1].include?(res.downcase)
else
res
end
end
def feature_defined?(feature_name)
return false if %i[enabled? override? create_feature? feature_defined?].include?(feature_name)
respond_to?(feature_name)
end
def create_feature(feature_name)
raise ArgumentError, "Feature #{feature_name} already defined" if feature_defined?(feature_name)
define_singleton_method(feature_name) do
override_value = override?(feature_name)
return override_value unless override_value.nil?
yield
end
define_singleton_method("#{feature_name}?") do
send(feature_name)
end
define_singleton_method("#{feature_name}=") do |value|
override_name = "#{OVERRIDE_ENV_PREFIX}#{feature_name}".upcase
if value
ENV[override_name] = 'true'
elsif value.is_a?(FalseClass)
ENV[override_name] = 'false'
else
ENV.delete(override_name)
end
end
define_singleton_method("override_#{feature_name}?") do
override?(feature_name)
end
feature_name.to_sym
end
end
create_feature :feature_name, -> { Rails.env.production? }
end
Now you can go ahead and create the feature flags you want at the bottom of the class and paste it into the app/services/feature.rb
. Overriding features in your environments should be as easy as adding an environment variable:
# makes feature flag return always true. Possible values are true, yes, 1
ENABLE_FEATURE_NAME = true
# set the value to anything else to disable. If the ENV variable is not present,
# it will execute the code block passed to the create_feature method
ENABLE_FEATURE_NAME = false
Use it in your code wherever with:
def call
return unless Feature.feature_name?
...
end
To override feature flags in your specs, simply add this to your specs:
before { Feature.feature_name = true|false|nil }
That’s it! With this you are now ready to remove that pesky return unless Rails.env.production?
guard from your methods.