Determinator
A gem that works with Florence to deterministically calculate whether an actor should have a feature flag turned on or off, or which variant they should see in an experiment. Florence's UI is currently hosted within actor-tracking.
You can make changes to your feature flags and experiments within Florence. If you work at Deliveroo you can find Florence UI at: https://actor-tracking.deliveroo.net/florence
Useful documentation
Getting help
For Deliveroo Employees:
- Many people contribute to Determinator and Florence. We hang out in this Slack channel
- This JIRA board covers pieces of work that are planned or in-flight
- This Workplace group holds more general discussions about the Florence ecosystem
At the moment we can only promise support for Determinator within Deliveroo, but if you add issues to this github repo we'll try and help if we can!
Basic Use
Once set up, determinator can be used to determine whether a feature flag or experiment is on or off for the current actor (or user) and, for experiments, which variant they should see.
# Feature flags: the basics
Determinator.instance.feature_flag_on?(:my_feature_name, id: 'some user')
# => true
Determinator.instance.feature_flag_on?(:my_feature_name, id: 'another user')
# => false
# A handy short cut…
def determinator
# See the urther Usage section below for a handy shorthand which means ID
# and GUID don't need to be specified every time you need a determination.
end
# Which means you can also do:
if determinator.feature_flag_on?(:my_feature_name)
# Show the feature
end
# Experiments
case determinator.which_variant(:my_experiment_name)
when false
# This actor isn't in a target group for this experiment
when 'control'
# Do nothing different
when 'sloths'
# Show some sloth pictures
when 'velociraptors'
# RUN!
end
Please note that Determinator requires an identifier for your actor — either an ID (when they are logged in, eg. a user id), or a globally unique id (GUID) that identifies them across sessions (which would normally be storied in a cookie or in a long-lived session store).
Feature flags and experiments can be limited to actors with specific properties by specifying them when (which must match the constraints defined in the feature).
# Targeting specific actors
variant = determinator.which_variant(
:my_experiment_name,
properties: {
employee: current_user.employee?
}
)
Writing tests? Check out the Local development docs to see examples of RSpec::Determinator
to help you mock your Feature Flags and Experiments.
Installation
Determinator requires a initialiser block somewhere in your application's boot process, it might look something like this:
# config/initializers/determinator.rb
require 'determinator/retrieve/dynaconf'
require 'active_support/cache'
Determinator.configure(
retrieval: Determinator::Retrieve::Dynaconf.new(host: 'localhost:2345'),
feature_cache: Determinator::Cache::FetchWrapper.new(
ActiveSupport::Cache::MemoryStore.new(expires_in: 1.minute)
)
)
Determinator.on_error(NewRelic::Agent.method(:notice_error))
Determinator.on_missing_feature do |feature_name|
STATSD.increment 'determinator.missing_feature', tags: ["feature:#{feature_name}"]
end
Determinator.on_determination do |id, guid, feature, determination|
if feature.experiment? && determination != false
YourTrackingSolution.record_variant_viewing(
user_id: id,
experiment_name: feature.name,
variant: determination
)
end
end
This configures the Determinator.instance
with:
- What retrieval mechanism should be used to get feature details
- (recommended) How features should be cached as they're retrieved. This mechanism allows caching features and missing features, so when a cache is configured a determination request for a missing feature on busy machines won't result in a thundering herd.
- (optional) How errors should be reported
- (optional) How missing features should be monitored (as they indicate something's up with your code or your set up!)
You may also want to configure a determinator
helper method inside your web request scope, see below for more information.
Using over http
Using the HttpRetriever will cause a request to be sent to actor tracking every time a feature is checked. The impact of this can be mitigated somewhat by having a short lived memory cache, but we're limited in the length of time we can cache for without some way of notifying the cache that an item has changed.
faraday_connection = Faraday.new("http://actor-tracking.local") do |conn|
conn.headers['User-Agent'] = "Determinator - my service name"
conn.basic_auth('my-service-name', 'actor-tracking-token')
conn.adapter Faraday.default_adapter
end
Determinator.configure(
retrieval: Determinator::Retrieve::HttpRetriever.new(
connection: faraday_connection,
),
feature_cache: Determinator::Cache::FetchWrapper.new(
ActiveSupport::Cache::MemoryStore.new(expires_in: 1.minute),
ActiveSupport::Cache::RedisCacheStore.new
)
)
In this set up we've got two caches - some limited local cache and a larger redis cache that's shared between instances. The memory cache ensure that we're able to perform determination lookups in tight loops without excessive calls to redis.
We don't set a TTL on the redis cache (although we could) because we intend to expire the caches manually when we receive an update from our event bus:
feature_name = Determinator.retrieval.get_name("http://actor-tracking.local/features/some_feature")
Determinator.feature_cache.expire(feature_name)
or in instances where the event bus provides a full feature object with a name it's simply:
Determinator.feature_cache.expire(deserialized_kafka_feature.name)
This will expire both the limited local cache and the larger shared cache.
Using hooks for retriever
HttpRetriever
has before_retrieve
and after_retrieve
hooks.
Example of usage:
http_retriever = Determinator::Retrieve::HttpRetriever.new(faraday_connection)
http_retriever.before_retrieve do
do_something
end
http_retriever.after_retrieve do |status, err|
raise err if err
do_something(status)
end
Further Usage
Once this is done you can ask for a determination like this:
# Anywhere in your application:
variant = Determinator.instance.which_variant?(
:my_experiment_name,
id: 123,
guid: 'anonymous id',
properties: {
employee: true,
using_top_level_domain: 'uk'
}
)
Or, if you're within a web request, you might want to use a shorthand, and let determinator remember the ID, GUID and any properties which will be true. The following will have the same effect:
# Somewhere inside your request's scope:
def determinator
@determinator ||= Determinator.instance.for_actor(
id: 123,
guid: 'anonymous id',
default_properties: {
employee: true,
using_top_level_domain: 'uk'
}
)
end
# Anywhere in your requests' scope:
determinator.which_variant(:my_experiment_name)
Check the example Rails app in the examples
directory for more information on how to make use of this gem.
app_version constraint
Feature flags and experiments can also be limited to actors with a semantic versioning property using an app_version
property:
variant = determinator.which_variant(
:my_experiment_name,
properties: {
app_version: "1.2.3"
}
)
The app_version
constraint for that flag needs to follow ruby gem version constraints. We support the following operators: >, <, >=, <=, ~>
. For example:
app_version: ">=1.2.0"
Using Determinator in RSpec
- Include the
spec_helper.rb
.
require 'rspec/determinator'
Determinator.configure(retrieval: nil)
-
Tag your rspec test with
:determinator_support
, so theforced_determination
helper method will be available.Please note,
RSpec::Determinator
mocks a determination outcome, not the process of choosing one. Set theonly_for
argument to be the properties you require for Determinator to return the specified outcome. At the moment this mock does not allow for the testing of theid
orguid
arguments (only the properties).
RSpec.describe "something", :determinator_support do
context "something" do
forced_determination(:my_feature_flag, true)
forced_determination(:my_experiment, "variant_a")
forced_determination(:my_lazyexperiment, :some_lazy_variable)
let(:some_lazy_variable) { 'variant_b' }
forced_determination(:my_targeted_feature_flag, true, only_for: { employee: true })
forced_determination(:my_targeted_feature_flag, false, only_for: { id: 12345 })
it "uses forced_determination" do
determinator = Determinator.for_actor(id: 1)
expect(determinator.feature_flag_on?(:my_feature_flag)).to be true
expect(determinator.which_variant(:my_experiment)).to eq("variant_a")
expect(determinator.which_variant(:my_lazy_experiment)).to eq("variant_b")
expect(determinator.feature_flag_on?(:my_targeted_feature_flag, properties: { employee: false })).to be false
expect(determinator.feature_flag_on?(:my_targeted_feature_flag, properties: { employee: true })).to be true
# The last forced determination takes precedence
expect(Determinator.instance.feature_flag_on?(:my_targeted_feature_flag, id: 12345, properties: { employee: true })).to be false
end
end
end
- Check out the specs for
RSpec::Determinator
to find out what you can do!
Tracking
The library includes a middleware to track all determinations being made, allowing logging them at the end of the request (including some useful request metrics).
To enable it, e.g. in Rails:
# config/application.rb
require 'determinator/tracking/rack/middleware'
# possibly near the top of your stack, in case other middlewares make determinations
config.middleware.use Determinator::Tracking::Rack::Middleware
or for Sidekiq:
# config/initializers/sidekiq.rb
require 'determinator/tracking/sidekiq/middleware'
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add Determinator::Tracking::Sidekiq::Middleware
end
end
# config/initializers/determinator.rb
require 'determinator/tracking'
Determinator::Tracking.on_request do |r|
Rails.logger.info("tag=determinator_request endpoint=#{r.endpoint} type=#{r.type} request_time=#{r.time} error=#{r.error?} response_status=#{r.attributes[:status]} sidekiq_queue=#{r.attributes[:queue]}")
r.determinations.each do |d|
Rails.logger.info("tag=determination id=#{d.id} guid=#{d.guid} flag=#{d.feature_id} result=#{d.determination}")
end
end
# The library sets the "endpoint" with information about the request or sidekiq job. If you
# have environment variables that further identify the service, e.g. ENV['APP_NAME'],
# you can configure the tracker to prepend it to the endpoint:
Determinator::Tracking.endpoint_env_vars = ['APP_NAME']
# If using an APM, you can provide trace information on the request by providing a get_context hook: e.g.
Determinator::Tracking.get_context do
span = Datadog.tracer.active_root_span
return unless span
Determinator::Tracking::Context.new(
request_id: span.trace_id,
service: span.service,
resource: span.resource,
type: span.type,
meta: span.meta
)
end
NOTE: determinations will only be recorded on the threads where Determinator::Tracking is initialised via the middleware. If offloading work away from these thread (for example, by spinning up new threads within a Rack request or a Sidekiq worker), make the determinations before, and pass them through to the new threads; or, if it's not possible, collect them manually and track them in the request's thread with
Determinator::Tracking.track(id, guid, feature, determination)
Testing this library
This library makes use of the Determinator Standard Tests to ensure that it conforms to the same specification as determinator libraries in other languages. The standard tests can be updated to the latest ones available by updating the submodule:
git submodule foreach git pull origin master
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/deliveroo/determinator. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
Any PR should include a new section at the top of the CHANGELOG.md
(if it doesn't exist) called 'Unreleased' of a similar format to the lines below. Upon release, this will be used to detail what has been added.
License
The gem is available as open source under the terms of the MIT License.