Skip to content

Determine which experiments and features a specific actor should see.

License

Notifications You must be signed in to change notification settings

deliveroo/determinator

Repository files navigation

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

Arnold Schwarzenegger might say "Come with me if you want to experiment" if he played The Determinator instead of The Terminator.


Useful documentation

Getting help

For Deliveroo Employees:

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 the forced_determination helper method will be available.

    Please note, RSpec::Determinator mocks a determination outcome, not the process of choosing one. Set the only_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 the id or guid 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

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.