TrailGuide

TrailGuide is a rails engine providing a framework for running user experiments and A/B tests in rails apps.

Getting Started

Requirements

Currently only rails 5.x is officially tested/supported, and trailguide requires redis to store experiment metadata and (optionally) participants.

docker-compose is a great way to run redis in development. Take a look at the docker-compose.yml in the root of this repo for an example.

In production I recommend configuring redis as a persistent datastore (rather than a cache), in order to avoid evicting experiment or participant keys unexpectedly.

Installation

Add this line to your Gemfile:

gem 'trailguide'

Then run bundle install.

Engine & Admin Routes

If you plan on using the included javascript client, or if you just want an API to interact with experiments in other ways, you can mount the engine in your route config:

# /config/routes.rb

Rails.application.routes.draw do
  # ...

  mount TrailGuide::Engine => 'api/experiments'

  # ...
end

You can also mount the admin engine to manage and analyze your experiments via the built-in admin UI. You'll probably want to wrap this in some sort of authentication, though the details will vary between applications. If you're already mounting other admin engines (i.e. something like sidekiq or flipper), you should be able to apply the same technique to trailguide.

# /config/routes.rb

Rails.application.routes.draw do
  # ...

  mount TrailGuide::Engine => 'api/experiments'

  # example auth route helper
  authenticate :user, lambda { |u| u.admin? } do
    mount TrailGuide::Admin::Engine => 'admin/trailguide'
  end

  # ...
end

Quick Start

Create and configure an experiment:

# config/experiments.rb

experiment :simple_ab do |config|
  config.summary = "This is a simple A/B test" # optional

  variant :a
  variant :b
end

Start your experiment either via the admin UI or from a rails console with TrailGuide.catalog.find(:simple_ab).start! to enable enrollment.

Then use it (in controller actions for this example):

def show
  # enroll in the experiment and do something based on the assigned variant group
  case trailguide.choose(:simple_ab)
    when :a
      # perform logic for group "a"
    when :b
      # perform logic for group "b"
  end

  # ...
end

def update
  # mark this participant as having converted when they take a certain action
  trailguide.convert(:simple_ab)

  # ...
end

If you've mounted the admin engine, you can view your experiment's participants and conversions there.

Configuration

The core engine and base experiment class have a number of configuration options available to customize behavior and hook into various pieces of functionality. The best way to configure trailguide is via a config initializer, and this gem configures it's own defaults the same way.

# config/initializers/trailguide.rb

TrailGuide.configure do |config|
  config.redis = Redis.new(url: ENV['REDIS_URL'])
  # ...
end

TrailGuide::Experiment.configure do |config|
  config.algorithm = :weighted
  # ...
end

Take a look at config/initializers/trailguide.rb in this repo for a full list of defaults and examples of the available configuration options.

Configuring Experiments

Before you can start running experiments in your app you'll need to define and configure them. There are a few options for defining experiments - YAML files, a ruby DSL, or custom classes - and they all inherit the base TrailGuide::Experiment.configuration for defaults, which can be overridden per-experiment.

YAML

YAML files are an easy way to configure simple experiments. They can be put in config/experiments.yml or config/experiments/**/*.yml:

# config/experiments.yml

simple_ab:
  variants:
    - 'option_a'
    - 'option_b'
# config/experiments/search/widget.yml

search_widget:
  start_manually: false
  algorithm: 'distributed'
  variants:
    - 'original'
    - 'simple'
    - 'advanced'

Ruby DSL

The ruby DSL provides a more dynamic and flexible way to configure your experiments, and allows you to define custom behavior via callbacks and options. You can put these experiments in config/experiments.rb or config/experiments/**/*.rb:

# config/experiments.rb

experiment :search_widget do |config|
  config.start_manually = false
  config.algorithm = :distributed

  # the first variant is your control by default, but you can declare any one as
  # the control like we do below
  variant :simple
  variant :original, control: true
  variant :advanced

  goal :interacted
  goal :searched

  on_choose do |experiment, variant, |
    # ... send a track to some third party service ...
  end

  on_convert do |experiment, variant, goal, |
    # ... send a track to some third party service ...
  end
end

Custom Classes

You can also take it a step further and define your own custom experiment classes, inheriting from TrailGuide::Experiment. This allows you to add or override all sorts of additional behavior on top of all the standard configuration provided by the DSL. In fact, the YAML and ruby DSL configs both use this to parse experiments into anonmymous classes extending TrailGuide::Experiment.

You can put these classes anywhere rails will autoload them (or require them yourself), but I recommend app/experiments/**/*.rb:

# app/experiments/my_complex_experiment.rb

class MyComplexExperiment < TrailGuide::Experiment
  # if you want to actually use this class as an experiment (like we do in this
  # example), you must call `register!` in order to register it in the catalog.
  #
  # if you want to use your class as a base class, so other experiments can
  # inherit from it, you should _not_ call `register!`
  register!

  # all standard experiment config goes in the `configure` block
  configure do |config|
    config.reset_manually = true

    control :option_a
    variant :option_b
    variant :option_c
    variant :option_d

    on_start do |experiment|
      # ... do some custom stuff when the experiment is started ...
    end
  end

  # override the experiment `choose!` method, and maybe do some custom stuff
  # depending on custom options you pass in
  def choose!(**opts)
    if opts[:foo] == :bar
      return control
    else
      super(**opts)
    end
  end

  def foobar
    # ... you can define whatever other custom methods, mixins and behaviors ...
  end

end

You can also use inheritance to setup base experiments and inherit configuration:

class ApplicationExperiment < TrailGuide::Experiment
  configure do |config|
    # ... config, variants, etc.
  end
  # ... custom behavior, etc.
end

class MyAppExperiment < ApplicationExperiment
  # inherits config from ApplicationExperiment
end

class MyDefaultExperiment < TrailGuide::Experiment
  # inherits from configured trailguide defaults
end

You can even use these in your DSL-defined experiments by specifying a class: argument:

# config/experiments.rb

experiment :my_inheriting_experiment, class: ApplicationExperiment do |config|
  # ...
end

Participant Adapters

While all experiment configuration, metadata and metrics are stored in redis, there are various adapters available for participants to control where individual assignments are stored for each user. These adapters are configurable and extensible, so you can customize them or even create your own by following a simple interface.

The following participant adapters are included with trailguide:

  • :cookie (default) - stores participant assignments in a cookie in their browser
  • :session - stores participant assignments in a hash in their rails session
  • :redis - stores participant assignments in redis, under a configurable key identifier (usually current_user.id or a cookie storing some sort of tracking/visitor ID for logged out users)
  • :anonymous - temporary storage, in a local hash, that only exists for as long as you have a handle on the participant object (usually a last resort fallback)
  • :multi - attempts to use the "best" available adapter based on the current context
  • :unity - uses TrailGuide::Unity to attempt to unify visitor/user sessions based on your configuration

This is the default adapter, which stores participation details in a cookie in the user's browser. If you want to configure the cookie name, path or expiration, you can do so directly in your initializer:

TrailGuide.configure do |config|
  # config.adapter = :cookie
  config.adapter = TrailGuide::Adapters::Participants::Cookie.configure do |config|
    config.cookie = :trailguide
    config.path = '/'
    config.expiration = 1.year.to_i
  end
end

Session

The session adapter will store participation in a hash under a configurable key within the user's rails session.

TrailGuide.configure do |config|
  # use the symbol shortcut for defaults
  config.adapter = :session

  # or configure it
  config.adapter = TrailGuide::Adapters::Participants::Session.configure do |config|
    config.key = :trailguide
  end
end

Redis

The redis adapter stores participation details in a configurable redis key, which makes it great for ensuring consistency across visits and even devices. While the cookie and session adapters are restricted to a single browser or even a single browsing session, the redis adapter is more persistent and controllable, with the tradeoff being that you'll need to be able to identify your users in some way (i.e. current_user.id).

TrailGuide.configure do |config|
  # use the symbol shortcut for defaults
  config.adapter = :redis

  # or configure it
  config.adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
    config.namespace = :participants
    config.expiration = nil
    config.lookup = -> (context) { # context is wherever you're invoking trailguide, usually a controller or view
      context.try(:trailguide_user).try(:id) ||
        context.try(:current_user).try(:id)
    }
  end
end

Anonymous

The anonymous adapter is a simple, ephemeral ruby hash that only exists for as long as you have a reference to that local participant object. It's generally only used as a last resort when there's no way to identify a participant who is enrolling in an experiment, because there's no way to get a new reference later on (for example to track conversion).

Multi

The multi adapter will attempt to use the "best" available adapter, depending on the context from which trailguide is being invoked (controller, view, background job, etc.). It comes with a default configuration that prefers to use redis if a trailguide_user or current_user is available, otherwise tries to use cookies if possible, then session if possible, falling back to anonymous as a last resort.

You can use the multi adapter to wrap any adapter selection logic you like, the only requirement is that you return one of the other adapters:

TrailGuide.configure do |config|
  # use the symbol shortcut for defaults
  config.adapter = :multi

  # or configure it
  config.adapter = TrailGuide::Adapters::Participants::Multi.configure do |config|
    # should be a proc that returns another adapter to be used
    config.adapter = -> (context) do
      if (context.respond_to?(:trailguide_user, true) && context.send(:trailguide_user).present?) ||
          (context.respond_to?(:current_user, true) && context.send(:current_user).present?)
        TrailGuide::Adapters::Participants::Redis
      elsif context.respond_to?(:cookies, true)
        TrailGuide::Adapters::Participants::Cookie
      elsif context.respond_to?(:session, true)
        TrailGuide::Adapters::Participants::Session
      else
        TrailGuide::Adapters::Participants::Anonymous
      end
    end
  end
end

Unity

The unity adapter is a wrapper around TrailGuide::Unity, which attempts to unify user/visitor sessions, then selects and configures the appropriate adapter. It looks for an available, configurable "user ID" (current_user) and "visitor ID" (from a cookie) and configures the redis adapter appropriately. If there is no identifying information available it falls back to the anonymous adapter.

You can configure the visitor cookie and the user id attribute, as well as the adapters to be used in each case:

TrailGuide.configure do |config|
  config.adapter = TrailGuide::Adapters::Participants::Unity.configure do |config|
    # setup the visitor ID cookie and user ID attribute
    config.visitor_cookie = :visitor_id # uses a cookie called visitor_id, must be set and managed by you separately
    config.user_id_key = :uuid # uses current_user.uuid, defaults to current_user.id

    # uses redis adapter for identified users
    config.user_adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
      config.namespace = 'unity:users'
      config.lookup = -> (user_id) { user_id }
      config.expiration = 1.year.seconds
    end

    # uses redis adapter for identified visitors
    config.visitor_adapter = TrailGuide::Adapters::Participants::Redis.configure do |config|
      config.namespace = 'unity:visitors'
      config.lookup = -> (visitor_id) { visitor_id }
      config.expiration = 1.year.seconds
    end

    # uses anonymous adapter for unidentified
    config.anonymous_adapter = TrailGuide::Adapters::Participants::Anonymous
  end
end

See the unity documentation for more info about unifying sessions.

Custom Adapters

TODO - In the meantime, checkout the cookie or session adapters for simple examples as a starting point.

TrailGuide.configure do |config|
  config.adapter = MyCustom::AdapterClass
end

Algorithms

There are a few common assignment algorithms included in trailguide, and it's easy to define your own and configure your experiments to use them. Algorithms can either be configured globally for all experiments in your initializer, or overridden individually per-experiment.

The following algorithms are available:

  • :weighted (default) - allows favoring variants by assigning them weights
  • :distributed - totally even distribution across variants
  • :random - truly random sampling of variants on assignment
  • :bandit - a "multi-armed bandit" approach to assignment

Weighted

This is the default algorithm, which allows weighted assignment to variants based on each variant's configuration. All things being equal (all variants having equal weights), it's essentially a random sampling that will provide mostly even distribution across a large enough sample size. The default weight for all variants is 1.

experiment :my_experiment do |config|
  config.algorithm = :weighted

  variant :a, weight: 2 # would be assigned roughly 40% of the time
  variant :b, weight: 2 # would be assigned roughly 40% of the time
  variant :c, weight: 1 # would be assigned roughly 20% of the time
end

Note that the weighted algorithm is the only one that takes variant weight into account, and the other algorithms will simply ignore it if it's defined.

Distributed

The distributed algorithm ensures completely even distribution across all variants by always selecting from the variant(s) with the lowest number of participants.

experiment :my_experiment do |config|
  config.algorithm = :distributed
end

Random

The random algorithm provides totally random distribution by sampling from all variants on assignment.

experiment :my_experiment do |config|
  config.algorithm = :random
end

Multi-Armed Bandit

The bandit algorithm in trailguide was heavily inspired by the split gem, and will automatically weight variants based on their performance over time. You can read more about this approach if you're interested.

experiment :my_experiment do |config|
  config.algorithm = :bandit
end

Custom

TODO - In the meantime, take a look at the included algorithms as a starting point. Essentially as long as you accept an experiment and return a variant, the rest is up to you.

experiment :my_experiment do |config|
  config.algorithm = MyCustom::AlgorithmClass
end

Usage

Helpers

The TrailGuide::Helper module is available to be mixed into just about any context, and provides a helper proxy with an easy API to interact with trailguide. These helpers are mixed into controllers and views as helper methods by default. You can disable this behavior by setting the config.include_helpers option to false if you'd rather explicitly include it where you want to use it.

When mixed in, the trailguide method provides a reference to the helper proxy, which in turn provides a few methods to perform your experiments.

# enroll in an experiment or reuse previous assignment
trailguide.choose(:experiment_name)
trailguide.choose!(:experiment_name)

# choose, then automatically calls a method within the current context based on
# the selected variant
trailguide.run(:experiment_name)
trailguide.run!(:experiment_name)

# choose, then render a template or partial within the current context based on
# the selected variant
trailguide.render(:experiment_name)
trailguide.render!(:experiment_name)

# tracks a conversion for the participant's currently assigned variant
trailguide.convert(:experiment_name)
trailguide.convert!(:experiment_name)

As a general rule of thumb, the bang (!) methods will loudly raise exceptions on any failures, while the non-bang methods will log errors and do their best to gracefully continue.

Enrollment

The choose method will either enroll a participant into an experiment for the first time or return their previously assigned variant if they've already been enrolled. It can accept a block to execute and returns a TrailGuide::Variant object, but can be compared directly to strings or symbols.

class MyController < ApplicationController
  def index
    # choose inline
    variant = trailguide.choose(:experiment_name) # TrailGuide::Variant instance
    if variant == 'variant_one'
      # ...
    elsif variant == 'variant_two'
      # ...
    end

    # use directly in a case or other comparison
    case trailguide.choose(:experiment_name)
      when :variant_one
        # ...
      when :variant_two
        # ...
    end


    # pass in a block
    trailguide.choose(:experiment_name) do |variant, |
      # ... do something based on the assigned variant
    end


    # also accepts additional metadata which can be used in custom algorithms and
    # passed to blocks along with any configured variant metadata
    trailguide.choose(:experiment_name, metadata: {foo: :bar}) do |variant, |
      # ...
    end
  end
end

You can also call trailguide.choose from your view templates, though you probably want to keep any complex logic in your controllers (or maybe helpers). This would print out the variant name into an h1:

<% variant = trailguide.choose(:experiment_name) %>
<h1><%= variant.name %></h1>

Running Methods

If you prefer, you can encapsulate your logic into methods for each variant and ask trailguide to execute the appropriate one for you automatically.

class MyController < ApplicationController
  def index
    # this would call one of the methods below depending on assignment
    trailguide.run(:experiment_name)
  end

  private

  def variant_one(**)
    # ... do whatever, maybe use these almost like a `before_filter` to setup instance vars
  end

  def variant_two(**)
    # ...
  end
end

By default the above will attempt to call methods with a name matching your variant name, but you can configure custom methods via the methods: keyword argument.

class MyController < ApplicationController
  def index
    # this would call one of the methods below depending on assignment
    trailguide.run(:experiment_name, methods: {
      variant_one: :my_first_method,
      variant_two: :my_second_method
    },
    metadata: {
      # you can also optionally pass custom metadata through to choose
    })
  end

  private

  def my_first_method(**)
    # ... do whatever, maybe use these almost like a `before_filter` to setup instance vars
  end

  def my_second_method(**)
    # ...
  end
end

You can use trailguide.run in your views, but the methods you're calling must be available in that context. This usually means defining them as helper methods, either in your controller via helper_method or in a helpers module.

Rendering

Many experiments include some sort of UI component, and trailguide provides a handy shortcut to render different paths when that pattern suits your needs. The trailguide.render method can be used in controllers to render templates or in views to render partials, and uses rails' underlying render logic for each context.

# config/experiments/homepage_ab.rb
experiment :homepage_ab do |config|
  variant :old
  variant :new
end

# app/controllers/homepage_controller.rb
class HomepageController < ApplicationController
  def index
    trailguide.render(:homepage_experiment)
  end
end

# this would render one of these templates within the layout (instead of homepage/index.html.erb)
# app/views/homepage/homepage_ab/old.html.erb
# app/views/homepage/homepage_ab/new.html.erb

You can also use render in a view to render partials instead of templates.

# config/experiments/homepage_hero.rb
experiment :homepage_hero do |config|
  variant :old
  variant :new
end

# app/controllers/homepage_controller.rb
class HomepageController < ApplicationController
  def index
  end
end
<!-- app/views/homepage/index.html.erb -->
<%= trailguide.render(:homepage_hero) %>

<!-- this would render one of these partials -->
<!-- app/views/homepage/homepage_hero/_old.html.erb -->
<!-- app/views/homepage/homepage_hero/_new.html.erb -->

By default the render method looks for templates or partials matching the assigned experiment and variant within the current render context path. For templates (in controllers) this means something like app/views/your_controller/experiment_name/variant_name.*, and for partials (in views) something like app/views/your_controller/experiment_name/_variant_name.* (note the underscore for partials, following rails' conventions).

You can override the prefix or the full paths to the individual templates via the prefix: and templates: keyword args respectively.

# looks for variant templates in app/views/foo/bar/experiment_name/*
trailguide.render(:experiment_name, prefix: 'foo/bar')

# specify the path for each variant's template (relative to rails view path)
trailguide.render(:experiment_name, templates: {
  variant_one: 'foo/bar/custom',
  variant_two: 'other/custom/template'
})

# renders one of these
# app/views/foo/bar/custom.html.erb
# app/views/other/custom/template.html.erb

Conversion

In order to analyze performance and potentially select a winning variant, you'll want to track a conversion metric relevant to your experiment. This might mean clicking a button, creating an account, adding something to a shopping cart, completing an order, or some other interaction performed by the user. You can convert a participant from pretty much any context with trailguide.convert.

# converts the participant in their assigned variant, or does nothing if they haven't been enrolled in the experiment
trailguide.convert(:experiment_name)

# requires a goal for experiments configured with multiple goals
trailguide.convert(:experiment_name, :goal_name)

Service Objects & Background Jobs

The way you use trailguide outside of a request context will mostly depend on the participant adapter being used. To get started, you'll need to include the TrailGuide::Helper module into whatever class or context you're working with.

The :cookie and :session adapters will not work in a background context, but the default :redis, :multi and :unity adapters will work if provided with a trailguide_user. This assumes that the trailguide_user matches whatever user you're assigning within your request contexts (which is commonly current_user) if you want assignments to match up and be consistent, and the default configurations for these supported adapters all look for either a trailguide_user or a current_user so they should work in most contexts.

A simple example might be sending a welcome email in a background job with a variable discount amount depending on what variant the user was enrolled into during signup.

# config/experiments.rb
experiment :welcome_discount do |config|
  variant :10
  variant :15
end

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    # ... signup the user
    amount = trailguide.choose(:welcome_discount)
    flash[:info] = "Check your email for a $#{amount} discount!"
    SendWelcomeEmailJob.perform_later(current_user)
  end
end

# app/jobs/send_welcome_email_job.rb
class SendWelcomeEmailJob < ApplicationJob
  include TrailGuide::Helper

  def perform(user)
    # set this to an instance var before choosing so it's available in the supported trailguide_user method
    @user = user

    amount = trailguide.choose(:welcome_discount)
    UserMailer.welcome_email(@user, amount)
  end

  # using one of the supported adapters will automatically call this method if it exists
  def trailguide_user
    @user
  end
end

If you're using a custom adapter, you'll need to make sure that your adapter is able to infer the participant from your context.

JavaScript Client

There is a simple javascript client available that mimics the ruby usage as closely as possible, and is ready to be used with the rails asset pipeline. This client uses axios to hit the API, and requires that you mount it in your routes.

// require the trailguide client in your application.js or wherever makes sense
//= require trailguide

// create a client instance
// make sure to pass in the route path where you've mounted the trailguide engine
var client = TrailGuide.client('/api/experiments');

// enroll in an experiment
client.choose('experiment_name');

// convert for an experiment with an optional goal
client.convert('experiment_name', 'optional_goal');

// return the participant's active experiments and their assigned variant group
client.active();

Experiment Lifecycle

TODO

Goals

You can configure experiment goals if a single experiment requires multiple conversion goals, or if you just want to define a single named goal to be more explicit.

experiment :button_color do |config|
  variant :red
  variant :green
  variant :blue

  goal :signed_up
  goal :checked_out

  # if this is false (default), once a participant converts to one of the defined goals, they will not be able to convert to any of the others unless the experiment is reset
  # if this is true, a single participant may convert to more than one goal, but only once each
  config.allow_multiple_goals = false
end

When you define one or more named goals for an experiment, you must pass one of the defined goals when converting.

trailguide.convert(:button_color, :signed_up)

Groups

If you have multiple experiments that share a relevant conversion point, you can configure them with a shared group. This allows you to reference and convert multiple experiments at once using that shared group, and only experiments in which participants have been enrolled will be converted.

Shared groups can only be used for conversion, not for enrollment, since experiments don't share assignments.

For example if you have multiple experiments where performing a search is considered to be a successful conversion, you can configure them all with the same shared group then use that group in your calls to trailguide.convert.

experiment :first_search_experiment do |config|
  config.group = :perform_search

  variant :a
  variant :b
end

experiment :second_search_experiment do |config|
  config.groups = [:perform_search, :other_group]

  variant :one
  variant :two
  variant :three
end

experiment :third_search_experiment do |config|
  group :other_group
  group :perform_search
  groups :yet_another_group, :one_more

  variant :red
  variant :blue
end

class SearchController < ApplicationController
  def search
    trailguide.convert(:perform_search)
    # ...
  end
end

Orphaned Groups

Sometimes in the real world, you might accidentally remove all the experiments that were sharing a given group, but miss one of the conversion calls that used one of it's groups. Maybe you forgot to search through your code for references to the group, or maybe you just didn't know you were removing the last experiment in that group. Ideally you'd be testing your code thoroughly, and you'd catch the problem before hitting production, but trailguide has a built-in safe guard just in case.

Instead of raising a TrailGuide::NoExperimentsError when no experiments match your arguments like trailguide.choose and related methods do, the trailguide.convert method will log a warning and return false as if no conversion happened.

After a failed conversion for an orphaned group, the next time you visit the trailguide admin dashboard you'll see an alert with the details of any logged orphaned groups. If you wish to ignore orphaned groups entirely, perhaps so you can leave conversion calls in your application while you regularly rotate experiments into and out of those groups, you can set the TrailGuide.configuration.ignore_orphaned_groups = true config option in your initializer.

Groups with Goals

Since grouping is only useful when converting, and experiments with defined goals require a goal to be passed in when converting, any experiments that are sharing a group must define the same goals in order to be converted together. Not all goals need to overlap, but you will only be able to convert goals that are shared when referencing a group.

If you're grouping your experiments, that probably means you have multiple experiments that are all being used in the same area of your app and therefore are likely sharing the same (or similar) conversion goals. You can assign your groups and goals the same names to make converting easier by referencing a single key:

experiment :first_search_experiment do |config|
  variant :alpha
  variant :bravo

  config.groups = [:click_search, :click_banner, :search_experiments]
  config.goals  = [:click_search, :click_banner, :custom_goal]
end

experiment :second_search_experiment do |config|
  variant :one
  variant :two
  variant :three

  config.groups = [:click_search, :click_banner, :search_experiments]
  config.goals  = [:click_search, :click_banner, :some_other_goal]
end

experiment :third_search_experiment do |config|
  variant :red
  variant :blue

  config.groups = [:click_search, :click_banner, :search_experiments]
  config.goals  = [:click_search, :click_banner]
end

# then to convert all three experiments for the click_search group, against the
# click_search goal
trailguide.convert(:click_search)

# the above is the equivalent of calling their group name (in this case not
# matching the goal name) and the goal name
trailguide.convert(:search_experiments, :click_search)

# or the equivalent of converting each of the three experiments individually
# for that goal
trailguide.convert(:first_search_experiment, :click_search)
trailguide.convert(:second_search_experiment, :click_search)
trailguide.convert(:third_search_experiment, :click_search)

# and you can still convert the individual experiments with goals that are not
# shared by their group
trailguide.convert(:first_search_experiment, :custom_goal)

Combined Experiments

TODO

Filtering Requests

TODO

Admin UI

TODO

API

TODO

RSpec Helpers

TODO

Contributing

TODO

License

The gem is available as open source under the terms of the MIT License.