Repository: airbnb/trebuchet
Branch: master
Commit: d5cbd014fa6a
Files: 81
Total size: 112.8 KB
Directory structure:
gitextract_xbvggvpr/
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── Gemfile
├── README.md
├── Rakefile
├── app/
│ ├── controllers/
│ │ └── trebuchet_rails/
│ │ └── features_controller.rb
│ ├── helpers/
│ │ └── trebuchet_helper.rb
│ └── views/
│ ├── layouts/
│ │ └── trebuchet.html.erb
│ └── trebuchet_rails/
│ ├── features/
│ │ ├── index.html.erb
│ │ └── timeline.html.erb
│ └── trebuchet.css
├── config/
│ └── routes.rb
├── init.rb
├── lib/
│ ├── trebuchet/
│ │ ├── action_controller.rb
│ │ ├── action_controller_filter.rb
│ │ ├── backend/
│ │ │ ├── disabled.rb
│ │ │ ├── memcached.rb
│ │ │ ├── memory.rb
│ │ │ ├── redis.rb
│ │ │ ├── redis_cached.rb
│ │ │ └── redis_hammerspaced.rb
│ │ ├── backend.rb
│ │ ├── error.rb
│ │ ├── feature/
│ │ │ └── stubbing.rb
│ │ ├── feature.rb
│ │ ├── state.rb
│ │ ├── strategy/
│ │ │ ├── base.rb
│ │ │ ├── custom.rb
│ │ │ ├── custom_request_aware.rb
│ │ │ ├── default.rb
│ │ │ ├── everyone.rb
│ │ │ ├── experiment.rb
│ │ │ ├── hostname.rb
│ │ │ ├── invalid.rb
│ │ │ ├── logic_and.rb
│ │ │ ├── logic_base.rb
│ │ │ ├── logic_not.rb
│ │ │ ├── logic_or.rb
│ │ │ ├── multiple.rb
│ │ │ ├── nobody.rb
│ │ │ ├── per_denomination.rb
│ │ │ ├── percent.rb
│ │ │ ├── percent_deprecated.rb
│ │ │ ├── stub.rb
│ │ │ ├── user_id.rb
│ │ │ ├── visitor_experiment.rb
│ │ │ ├── visitor_percent.rb
│ │ │ └── visitor_percent_deprecated.rb
│ │ ├── strategy.rb
│ │ └── version.rb
│ ├── trebuchet.rb
│ ├── trebuchet_rails/
│ │ └── engine.rb
│ └── trebuchet_rails.rb
├── spec/
│ ├── custom_request_aware_strategy_spec.rb
│ ├── custom_strategy_spec.rb
│ ├── default_strategy_spec.rb
│ ├── disabled_backend_spec.rb
│ ├── everyone_strategy_spec.rb
│ ├── experiment_strategy_spec.rb
│ ├── feature_spec.rb
│ ├── logic_and_strategy_spec.rb
│ ├── logic_base_strategy_spec.rb
│ ├── logic_not_strategy_spec.rb
│ ├── logic_or_strategy_spec.rb
│ ├── multiple_strategy_spec.rb
│ ├── nobody_strategy_spec.rb
│ ├── per_denomination_strategy_spec.rb
│ ├── percent_deprecated_strategy_spec.rb
│ ├── percent_strategy_spec.rb
│ ├── redis_backend_spec.rb
│ ├── redis_hammerspaced_spec.rb
│ ├── spec_helper.rb
│ ├── stubbing_spec.rb
│ ├── trebuchet_spec.rb
│ ├── user.rb
│ ├── user_id_strategy_spec.rb
│ ├── visitor_experiment_strategy_spec.rb
│ ├── visitor_percent_deprecated_strategy_spec.rb
│ └── visitor_percent_strategy_spec.rb
└── trebuchet.gemspec
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
*.gem
.bundle
Gemfile.lock
pkg/*
.idea
.rspec
================================================
FILE: .travis.yml
================================================
language: ruby
cache: bundler
sudo: false
rvm:
- 1.9.3
- 2.1
- 2.2
- 2.3
- 2.4
- 2.5
- 2.6
before_install:
- gem install bundler -v 1.17.3
================================================
FILE: CHANGELOG.md
================================================
## 0.12.0 (Nov 26, 2019)
- Contain fiber-unsafe state of the main Trebuchet class into a state object and optionally store it as a thread/fiber local.
## 0.11.0 (Aug 15, 2019)
- add Rails 5 compatibility
## 0.10.1 (Jul 13, 2018)
- add PerDenomination strategy
## 0.10.0 (Nov 17, 2017)
- Cache Trebuchet#launch?
## 0.9.18 (Sep 18, 2017)
- Do not expose internal format
## 0.9.17 (Aug 15, 2017)
- adds CustomRequestAware strategy
## 0.9.16 (Aug 9, 2017)
- Don't raise an error if expiration date has not been previously set
## 0.9.15 (Aug 9, 2017)
- Return payload for expiration date accessor
## 0.9.14 (Aug 9, 2017)
- Expose expiration date accessor
## 0.9.13 (Aug 8, 2017)
- Fix a typo
## 0.9.12 (Aug 3, 2017)
- Add setter for expiration date
## 0.9.11 (Nov 16, 2016)
- minor change: reduce number of times backend is hit in the launch methods
## 0.9.10 (Nov 14, 2016)
- fix logical operators for logged out visitors
## 0.9.9 (Sep 15, 2016)
- minor change: keep raw option hash in logic strategies for editing purpose
## 0.9.8 (July 27, 2016)
- convert deserialized strategy names to symbols
## 0.9.7 (July 20, 2016)
- logic operation strategies, e.g. AND, OR, and NOT
## 0.9.6 (April 5, 2016)
- add :add_comment method to the Feature class
## 0.9.2 (October 11, 2015)
- add :nobody and :everybody strategies
- cache features
- make :default strategy a singleton
## 0.9.1 (Jul 22, 2015)
- fix last_update type in redis_hammerspaced
## 0.9.0 (Jul 22, 2015)
- adds update_hammerspace method so we can sync redis into local hammerspace
## 0.8.1 (Jul 21, 2015)
- update redis backend to update a sentinel to indicate modifications
## 0.8.0 (Jul 20, 2015)
- adds a new backened redis_hammerspaced
## 0.6.3 (Sep 7, 2013)
- reimplements the percent strategy
- adds per-feature stubbing interface
## 0.0.4 (Dec 6, 2011)
- add experiment strategy
- fix percentage offset
- fix Redis/Memcache dependency issue
================================================
FILE: Gemfile
================================================
source "http://rubygems.org"
# Specify your gem's dependencies in trebuchet.gemspec
gemspec
current_ruby = Gem::Version.new(RUBY_VERSION)
if current_ruby < Gem::Version.new("2.2")
gem "rake", "< 12.3"
end
================================================
FILE: README.md
================================================
Trebuchet
=========
Trebuchet launches features at people. Wisely choose a strategy, aim, and launch!
Installation
------------
Trebuchet can be used with Rails or standalone.
To use with Rails:
gem 'trebuchet', :require => 'trebuchet_rails'
Setup
-----
Trebuchet defaults to storing data in memory, or can be used with Redis or Memcache as a data store:
Trebuchet.set_backend :memcached
Trebuchet.set_backend :redis, :client => Redis.new(:host => 'example.com')
Trebuchet.set_backend :redis_cached, :client => Redis.new(:host => 'example.com')
A Rails initializer is a great spot for this. You may want to use a few other settings, either hardcoded values or procs (eval'd in the context of the controller):
Trebuchet.admin_view = proc { current_user.try(:admin?) } # /trebuchet admin interface access
Trebuchet.time_zone = proc { current_user.time_zone } # or just "Mountain Time (US & Canada)"
Aim
---
Trebuchet can be aimed while your application is running. The syntax is:
Trebuchet.aim('awesome_feature', :percent, 1)
Which will launch 'awesome_feature' to 1% of users.
Another builtin strategy allows launching to particular user IDs:
Trebuchet.aim('awesome_feature', :users, [23, 42])
You can also combine multiple strategies, in which case the feature is launched if any of them is true:
Trebuchet.feature('awesome_feature').aim(:percent, 1).aim(:users, [23, 42])
If you don't aim Trebuchet for a feature, the default action is not to launch it to anyone.
Launch
------
In a view, do this:
<% trebuchet.launch('time_machine') do %>
Welcome to the future!
<% end %>
The code between do .. end will only run if the strategy for 'time_machine' allows launching to current_user.
You can also use it in a controller:
def index
trebuchet.launch('time_machine') do
@time_machine = TimeMachine.new
end
end
Custom Strategies
-----------------
Trebuchet ships with a number of default strategies but you can also define your own custom strategies like so:
Trebuchet.define_strategy(:admins) do |user|
!!(user && user.has_role?(:admin))
end
controller.current_user is yielded to the block and it should return true for users you want to launch to.
You can use parameters with custom strategies too:
Trebuchet.define_strategy(:markets) do |user, markets|
markets.include?(user.market)
end
Like parameters for builtin strategies, these can be changed while the application is running. For example:
Trebuchet.aim('time_machine', :markets, ['San Francisco', 'New York City'])
When using Trebuchet together with Rails, a good place to define custom strategies is in an initializer.
Visitor Strategy
----------------
Trebuchet can be used to launch to visitors (no user object present).
First, set the visitor id either directly (in a before filter) or as a proc:
Trebuchet.visitor_id = 123
Trebuchet.visitor_id = proc { |request| request && request.cookies[:visitor] && request.cookies[:visitor].hash }
If you're using a proc, Trebuchet passes in the request object. It expects that the proc returns an integer.
If it returns anything else, Trebuchet will not launch.
Fiber and Thread Safety
-------------
Trebuchet stores global state such as `Trebuchet.current` which is thread and fiber unsafe behavior. In order to use these
features in a fiber or threaded environment, `Trebuchet.threadsafe_state = true` will cause Trebuchet to store these values
in a thread-local state object instead. This is not the default for backward compatability reasons.
================================================
FILE: Rakefile
================================================
require 'bundler'
Bundler::GemHelper.install_tasks
task :spec do
system 'rspec ./spec'
end
task :default => :spec
================================================
FILE: app/controllers/trebuchet_rails/features_controller.rb
================================================
module TrebuchetRails
class FeaturesController < ApplicationController
if Rails::VERSION::MAJOR >= 5
before_action :control_access, :get_time_zone
else
before_filter :control_access, :get_time_zone
end
layout 'trebuchet'
helper :trebuchet
def index
@features = Trebuchet::Feature.all
@features.sort! {|x,y| x.name.downcase <=> y.name.downcase }
@dismantled_features = Trebuchet::Feature.dismantled
@dismantled_features.sort! {|x,y| x.name.downcase <=> y.name.downcase }
respond_to do |wants|
wants.html # index.html.erb
wants.json { render :json => @features.map(&:export) }
end
end
def timeline
@history = []
Trebuchet::Feature.all.each do |f|
f.history.each do |timestamp, strategy|
@history << {
:feature_name => f.name,
:timestamp => timestamp,
:strategy => strategy
}
end
end
@history = @history.sort_by { |h| h[:timestamp] }
@history.reverse!
respond_to do |wants|
wants.html # index.html.erb
wants.json do
json_history = @history.map do |history|
history.tap { |h| h[:strategy] = h[:strategy].export }
end
render :json => json_history
end
end
end
private
def control_access
allowed = if Trebuchet.admin_view.is_a?(Proc)
begin
instance_eval &(Trebuchet.admin_view)
rescue
false
end
else
!!Trebuchet.admin_view
end
raise ActionController::RoutingError.new('Not Found') unless allowed
end
def get_time_zone
@zone = if Trebuchet.time_zone
if Trebuchet.time_zone.is_a?(Proc)
Trebuchet.time_zone.call
elsif Trebuchet.time_zone.is_a?(String)
Trebuchet.time_zone
else
nil
end
end
@zone = ActiveSupport::TimeZone.new(@zone || 'UTC')
end
end
end
================================================
FILE: app/helpers/trebuchet_helper.rb
================================================
module TrebuchetHelper
def feature(feature)
"#{feature.name}
#{strategy feature.strategy}
"
end
def strategy(strategy)
html = case strategy.name
when :multiple
strategy.strategies.map { |s| strategy s }.join().html_safe
else
strategy.to_s
end
strategy.name == :multiple ? html : content_tag(:li, html)
end
def trebuchet_css
filename = File.expand_path(File.dirname(__FILE__) + "/../views/trebuchet_rails/trebuchet.css")
return IO.read(filename)
end
end
================================================
FILE: app/views/layouts/trebuchet.html.erb
================================================
Trebuchet
<%= yield %>
================================================
FILE: app/views/trebuchet_rails/features/index.html.erb
================================================
Trebuchet Admin
| Feature Name |
Launch Strategy |
Strategy History |
<% @features.each do |feature| %>
<%- has_history = feature.history.length > 0 %>
| <%= feature.name %> |
<%= strategy feature.strategy %> |
<% if has_history %>
History
|
<% end %>
<% if has_history %>
<% feature.history.each do |time, strategy| %>
<%= @zone.at(time.to_i).strftime("%x %X %Z") %> <%= strategy strategy %> |
<% end %>
|
<% end %>
<% end %>
| Dismantled Features |
Strategy History |
<% @dismantled_features.each do |feature| %>
<%- has_history = feature.history.length > 0 %>
| <%= feature.name %> |
<% if has_history %>
History
|
<% end %>
<% if has_history %>
<% feature.history.each do |time, strategy| %>
<%= @zone.at(time.to_i).strftime("%x %X %Z") %> <%= strategy strategy %> |
<% end %>
|
<% end %>
<% end %>
================================================
FILE: app/views/trebuchet_rails/features/timeline.html.erb
================================================
Trebuchet Timeline
| Timestamp |
Feature Name |
Launch Strategy |
<% @history.each do |h| %>
| <%= @zone.at(h[:timestamp].to_i).strftime("%x %X %Z") %> |
<%= h[:feature_name] %> |
<%= strategy h[:strategy] %> |
<% end %>
================================================
FILE: app/views/trebuchet_rails/trebuchet.css
================================================
#trebuchet {
font-family: HelveticaNeue, 'Helvetica Neue', HelveticaNeueRoman, HelveticaNeue-Roman, 'Helvetica Neue Roman', TeXGyreHerosRegular, Helvetica, Tahoma, Geneva, Arial, sans-serif;
}
.well {
-webkit-box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.2), 0 0 0 black;
box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.2), 0 0 0 black;
}
.collapsed {
display: none;
}
.treb-features {
border-collapse: collapse;
border: 1px solid #c9c9c9;
border-top-color: #000000;
}
.treb-features-history {
border: none;
font-size: 0.8em;
width: 100%;
}
.treb-features thead {
background: -webkit-gradient(linear, center top, center bottom, from(#575a5b), to(#393c3d));
background: -moz-linear-gradient(top, #575a5b, #393c3d);
color: #ffffff;
text-shadow: -1px -1px 1px black;
}
.treb-features td,
.treb-features th {
border: 1px solid #ccc;
padding: 1em 1.5em;
text-align: left;
}
.treb-features-history tr:first-child td {
border-top: none;
}
.treb-features-history tr:last-child td {
border-bottom: none;
}
.treb-features td {
background-color: #f5f5f5;
}
.treb-features-history td {
background: transparent;
border-left: none;
border-right: none;
}
.treb-features th {
border-left-color: #525657;
border-right-color: #74787a;
font-weight: normal;
}
.treb-features .unstyled {
padding: 0;
}
================================================
FILE: config/routes.rb
================================================
# TrebuchetRails::Engine.routes do
routes_block = lambda do
scope "trebuchet", :module => "trebuchet_rails" do
get '/' => "features#index"
get 'timeline' => "features#timeline"
end
end
if Rails::VERSION::MAJOR == 3
case Rails::VERSION::MINOR
when 0
Rails.application.routes.draw &routes_block
when 1
Rails.application.routes.prepend &routes_block
when 2
Rails.application.routes &routes_block
end
end
================================================
FILE: init.rb
================================================
require 'trebuchet'
# FIXME: happens too early
# def set_trebuchet_namespace(app_name)
# if Trebuchet.backend.respond_to?(:namespace=)
# Trebuchet.backend.namespace = "trebuchet-#{app_name}/"
# end
# end
if defined? Rails
Trebuchet.use_with_rails!
if Rails.respond_to?(:version) && Rails.version =~ /^3/
# Rails 3.x
# no other setup needed
else
# Rails 2.x
load_paths.each do |path|
ActiveSupport::Dependencies.load_once_paths.delete(path)
end if config.environment == 'development'
# set_trebuchet_namespace "#{Rails.root.basename}-#{Rails.env}"
end
end
================================================
FILE: lib/trebuchet/action_controller.rb
================================================
require 'trebuchet/action_controller_filter'
module Trebuchet::ActionController
def self.included(base)
base.helper_method :trebuchet
base.class_eval do
around_filter Trebuchet::ActionControllerFilter
end
end
def trebuchet
Trebuchet.current
end
end
================================================
FILE: lib/trebuchet/action_controller_filter.rb
================================================
class Trebuchet::ActionControllerFilter
def self.before(controller)
Trebuchet.initialize_logs
if Trebuchet.backend.respond_to?(:refresh)
Trebuchet.backend.refresh
end
Trebuchet.current_block = Proc.new {
Trebuchet.new(controller.send(:current_user), controller.request)
}
end
def self.after(controller)
Trebuchet.current_block = nil
Trebuchet.reset_current! # very important
end
end
================================================
FILE: lib/trebuchet/backend/disabled.rb
================================================
# This backend stores nothing and returns empty/false data (launch? will always be false)
# It can be used to disable all Trebuchet features (especially if Trebuchet fails to connect to it's normal data store)
class Trebuchet::Backend::Disabled
def initialize(*args)
end
def get_strategy(feature_name)
[:default]
end
def set_strategy(feature, strategy, options = nil)
false
end
def append_strategy(feature, strategy, options = nil)
false
end
def get_feature_names
[]
end
end
================================================
FILE: lib/trebuchet/backend/memcached.rb
================================================
require 'memcache'
class Trebuchet::Backend::Memcached
attr_accessor :namespace
def initialize(*args)
@memcache = MemCache.new(*args)
@namespace = 'trebuchet/'
end
def get_strategy(feature_name)
@memcache.get(key(feature_name))
end
def set_strategy(feature, strategy, options = nil)
@memcache.set(key(feature), [strategy, options])
end
def append_strategy(feature, strategy, options = nil)
@memcache.set(key(feature), get_strategy(feature) + [strategy, options])
end
def get_feature_names
[] # TODO: store all key names
end
private
def key(feature_name)
"#{namespace}#{feature_name}"
end
end
================================================
FILE: lib/trebuchet/backend/memory.rb
================================================
class Trebuchet::Backend::Memory
def initialize(*args)
@hash = {}
@archived = []
end
def get_strategy(feature_name)
@hash.fetch(feature_name, nil) || []
end
def set_strategy(feature, strategy, options = nil)
@archived.delete(feature)
@hash.store(feature, [strategy, options])
end
def append_strategy(feature, strategy, options = nil)
@archived.delete(feature)
strategies = get_strategy(feature) || []
if i = strategies.index(strategy)
strategies.delete_at(i) # remove strategy_name
strategies.delete_at(i) # remove options
end
strategies += [strategy, options]
@hash.store(feature, strategies)
end
def remove_strategy(feature)
@hash.delete(feature)
end
def get_feature_names
@hash.keys
end
def remove_feature(feature)
@hash.delete(feature)
@archived << feature
@archived.uniq!
end
def get_archived_feature_names
@archived
end
end
================================================
FILE: lib/trebuchet/backend/redis.rb
================================================
require 'redis' unless defined?(Redis)
require 'json'
class Trebuchet::Backend::Redis
attr_accessor :namespace
def initialize(*args)
@namespace = 'trebuchet/'
begin
if args.first.is_a?(Hash) && (client = args.first[:client]) && (client.is_a?(Redis) || client.is_a?(MockRedis))
# ignore other args and use provided Redis connection
@options = args.first
@redis = args.first[:client]
else
@redis = Redis.new(*args)
end
unless @options && @options[:skip_check]
# raise error if not connectedUncaught ReferenceError: google is not defined
@redis.exists(feature_names_key) # @redis.info is slow and @redis.client.connected? is NOT reliable
end
rescue Exception => e
raise Trebuchet::BackendInitializationError, e.message
end
end
def get_strategy(feature_name)
return nil unless h = @redis.hgetall(feature_key(feature_name))
unpack_strategy(h)
end
def unpack_strategy(options)
return nil unless options.is_a?(Hash)
[].tap do |a|
options.each do |k, v|
begin
key = k.to_sym
value = JSON.load(v).first # unpack from array
a << key
a << value
rescue
# if it can't parse the JSON, skip it
end
end
end
end
def set_strategy(feature_name, strategy, options = nil)
remove_strategy(feature_name)
append_strategy(feature_name, strategy, options)
update_sentinel
end
def append_strategy(feature_name, strategy, options = nil)
@redis.srem(archived_feature_names_key, feature_name)
@redis.hset(feature_key(feature_name), strategy, [options].to_json) # have to put options in container for json
@redis.sadd(feature_names_key, feature_name)
store_history(feature_name)
update_sentinel
end
def remove_strategy(feature_name)
@redis.del(feature_key(feature_name))
update_sentinel
end
def get_feature_names
@redis.smembers(feature_names_key)
end
def get_archived_feature_names
@redis.smembers(archived_feature_names_key)
end
def remove_feature(feature_name)
@redis.del(feature_key(feature_name))
@redis.srem(feature_names_key, feature_name)
@redis.sadd(archived_feature_names_key, feature_name)
update_sentinel
end
def store_history(feature_name)
timestamp = Time.now.to_i
h = @redis.hgetall(feature_key(feature_name))
@redis.hmset(feature_history_key(feature_name, timestamp), *h.to_a.flatten)
@redis.sadd(feature_history_key(feature_name), timestamp) # subtle
end
def get_history(feature_name)
[].tap do |history|
@redis.smembers(feature_history_key(feature_name)).sort.each do |timestamp|
h = @redis.hgetall(feature_history_key(feature_name, timestamp))
history << [timestamp.to_i, unpack_strategy(h)]
end
end
end
def get_all_history(include_archived = false)
history = []
features = @redis.smembers(feature_names_key)
features += @redis.smembers(archived_feature_names_key) if include_archived
result = @redis.pipelined do
features.each do |feature_name|
@redis.smembers(feature_history_key(feature_name))
end
end
features.zip(result).each do |feature_name, timestamps|
timestamps.each do |timestamp|
history << [timestamp.to_i, feature_name]
end
end
# sort in reverse timestamp order
history.sort! { |x,y| y.first <=> x.first }
end
def update_sentinel
@redis.set(sentinel_key, Time.now.to_i)
end
def get_sentinel
@redis.get(sentinel_key) || Time.now.to_i
end
private
def archived_feature_names_key
"#{namespace}archived-feature-names"
end
def feature_names_key
"#{namespace}feature-names"
end
def feature_key(feature_name)
"#{namespace}features/#{feature_name}"
end
def feature_history_key(feature_name, timestamp = nil)
key = "#{namespace}feature-history/#{feature_name}"
key = "#{key}/#{timestamp}" if timestamp
key
end
def sentinel_key
"#{namespace}last_updated"
end
end
================================================
FILE: lib/trebuchet/backend/redis_cached.rb
================================================
require 'trebuchet/backend/redis'
class Trebuchet::Backend::RedisCached < Trebuchet::Backend::Redis
# cache strategies in memory until clear_cached_strategies is called
def get_strategy(feature_name)
if cached_strategies.has_key?(feature_name)
# use cached if available (even if value is nil)
cached_strategies[feature_name]
else
# or call Trebuchet::Backend::Redis#get_strategy
# which will fetch from Redis and unpack json
# and then cache it for next time
cache_strategy feature_name, super(feature_name)
end
end
def append_strategy(feature_name, strategy, options = nil)
# though we can't clear the strategy for all active instances
# this will clear the cache in the console environment to show current settings
clear_cached_strategies
super(feature_name, strategy, options)
end
def cache_strategy(feature_name, strategy)
cached_strategies[feature_name] = strategy
end
def cached_strategies
@cached_strategies ||= Hash.new
end
def cache_cleared_at
@cache_cleared_at ||= Time.now
end
def clear_cached_strategies
@cache_cleared_at = Time.now
@cached_strategies = nil
end
def refresh
clear_cached_strategies if Time.now > cache_cleared_at + 60.seconds
end
end
================================================
FILE: lib/trebuchet/backend/redis_hammerspaced.rb
================================================
require 'trebuchet/backend/redis'
require 'json'
class Trebuchet::Backend::RedisHammerspaced < Trebuchet::Backend::Redis
# This class will rely on a cron job to sync all trebuchet features
# to local hammerspace thus this class never directly updates hammerspace
# We also cache in memory the features and rely on before_filter
# to lazily invalidate local cache
attr_accessor :namespace
def initialize(*args)
# args.first must be a hash
super(*args)
@hammerspace = args.first[:hammerspace]
end
def get_strategy(feature_name)
if cached_strategies.has_key?(feature_name)
# use cached if available (even if value is nil)
cached_strategies[feature_name]
else
# call to hammerspace
cache_strategy feature_name, get_strategy_hammerspace(feature_name)
end
end
def get_strategy_hammerspace(feature_name)
# Read from hammerspace
h = @hammerspace[feature_key(feature_name)]
return nil unless h
# h will be a string, we need to convert it back to Hash
begin
h = JSON.load(h)
rescue
return nil
end
unpack_strategy_hammerspace(h)
end
def unpack_strategy_hammerspace(options)
# We don't need to further convert values
# because it's already taken care of
# by the refresh cron job
# assumption here is that v will be an array and we
# are using the first element for now
# This makes the format compatible with redis backend
return nil unless options.is_a?(Hash)
[].tap do |a|
options.each do |k, v|
key = k.to_sym
a << key
a << v.first
end
end
end
def get_feature_names
# Read from hammerspace
return [] unless @hammerspace.has_key?(feature_names_key)
JSON.load(@hammerspace[feature_names_key])
end
def append_strategy(feature_name, strategy, options = nil)
# though we can't clear the strategy for all active instances
# this will clear the cache in the console environment to show current settings
clear_cached_strategies
super(feature_name, strategy, options)
end
def cache_strategy(feature_name, strategy)
cached_strategies[feature_name] = strategy
end
def cached_strategies
@cached_strategies ||= Hash.new
end
def clear_cached_strategies
@cached_strategies = nil
end
def refresh
# We close and reopen hammerspace to see if we need to invalidate local cache
uid = @hammerspace.uid
@hammerspace.close
if @hammerspace.uid != uid
clear_cached_strategies
end
end
def update_hammerspace(forced = false)
last_updated = get_sentinel
return if !forced && last_updated == @hammerspace[sentinel_key]
feature_names = @redis.smembers(feature_names_key)
features = @redis.pipelined do
feature_names.each do |feature_name|
@redis.hgetall(feature_key(feature_name))
end
end
hash = generate_hammerspace_hash(feature_names, features, last_updated)
@hammerspace.replace(hash)
@hammerspace.close
clear_cached_strategies
end
# feature_names is an array of strings
# features is an array of strategies
# Each strategy is of form ["", "", "", ""...]
# We need to decode the values because they are in string form (not actual hash)
def generate_hammerspace_hash(feature_names, features, last_updated)
hash = {
sentinel_key => last_updated.to_s,
feature_names_key => feature_names.to_json,
}
feature_names.zip(features) do |feature_name, feature|
h = {}
feature.each_slice(2) {|k,v| h[k]=JSON.load(v)}
hash[feature_key(feature_name)] = h.to_json
end
hash
end
end
================================================
FILE: lib/trebuchet/backend.rb
================================================
module Trebuchet::Backend
def self.lookup(name)
# From ActiveSupport::Inflector.camelize
const_name = name.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
if const_defined?(const_name)
const_get(const_name)
else
raise ArgumentError.new("Unknown backend type #{name}")
end
end
end
================================================
FILE: lib/trebuchet/error.rb
================================================
class Trebuchet::Error < StandardError ; end
class Trebuchet::BackendInitializationError < Trebuchet::Error ; end
class Trebuchet::BackendError < Trebuchet::Error ; end
================================================
FILE: lib/trebuchet/feature/stubbing.rb
================================================
class Trebuchet
class Feature
module Stubbing
def stub(state)
self.class.stubbed_features[name] = state
end
def stubbed?
!!self.class.stubbed_features[name]
end
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def dismantle_stubs
@stubbed_features = nil
end
def stubbed_features
@stubbed_features ||= {}
end
end
end
end
end
================================================
FILE: lib/trebuchet/feature.rb
================================================
require 'trebuchet/feature/stubbing'
class Trebuchet::Feature
include Stubbing
@@deprecated_strategies_enabled = true
@@features = {}
attr_accessor :name
def initialize(name)
@name = name
end
def self.find(name)
feature = @@features[name]
if !feature
feature = new(name)
@@features[name] = feature
end
feature.reset
feature
end
def reset
@chained = false
end
def self.all
Trebuchet.backend.get_feature_names.map{|name| new(name)}
end
def self.dismantled
Trebuchet.backend.get_archived_feature_names.map{|name| new(name)}
end
def self.exist?(name)
!!all.detect{|feature| feature.name == name }
end
# Runs the block with deprecated features enabled so that various methods
# do not raise exceptions. This was added to allow specs to test
# deprecated features. Not thread safe.
def self.with_deprecated_strategies_enabled(value=true, &block)
original_value = @@deprecated_strategies_enabled
begin
@@deprecated_strategies_enabled = value
block.call()
ensure
@@deprecated_strategies_enabled = original_value
end
end
def strategy
Trebuchet::Strategy.for_feature(self)
end
def valid?
strategy.name != :invalid
end
def launch_at?(user, request = nil)
# Store strategy so that only one call to the backend is needed.
s = strategy
(!s.needs_user? || !user.nil?) && s.launch_at?(user, request)
end
def aim(strategy_name, options = nil)
if !@@deprecated_strategies_enabled &&
Trebuchet::Strategy.deprecated_strategy_names.include?(strategy_name)
raise "The #{strategy_name} strategy is deprecated."
end
if chained?
Trebuchet.backend.append_strategy(self.name, strategy_name, options)
else
Trebuchet.backend.set_strategy(self.name, strategy_name, options)
end
@chained = true
self
end
# add/edit just one strategy without affecting other chained strategies
def adjust(strategy_name, options = nil)
Trebuchet.backend.append_strategy(self.name, strategy_name, options)
self
end
# add to the options of a strategy (if it is an integer, hash or array)
def augment(strategy_name, new_options)
# get old options if any
strategy_array = Trebuchet.backend.get_strategy(self.name)
i = strategy_array.index(strategy_name)
old_options = i ? strategy_array[i+1] : nil
# augment them carefully
options = if old_options == nil
new_options
elsif old_options.is_a?(Array) && new_options.is_a?(Array)
old_options + new_options
elsif old_options.is_a?(Hash) && new_options.is_a?(Hash)
old_options.merge(new_options)
elsif old_options.is_a?(Numeric) && new_options.is_a?(Numeric)
old_options + new_options
else # otherwise, change nothing
old_options
end
# adjust that strategy
self.adjust(strategy_name, options)
end
def dismantle
Trebuchet.backend.remove_feature(self.name)
end
# add comments for a feature, as a place to hold change logs for example, to supported backends
def add_comment(comment)
if Trebuchet.backend.respond_to?(:add_comment)
Trebuchet.backend.add_comment(self.name, comment)
end
end
# Retrieve the expiration date of the feature.
# Return nil if the feature does not have an expiration date.
def expiration_date
return unless Trebuchet.backend.respond_to?(:expiration_date)
Trebuchet.backend.expiration_date(self.name)
end
# Set the expiration date of the feature.
def set_expiration_date(expiration_date)
return unless Trebuchet.backend.respond_to?(:set_expiration_date)
Trebuchet.backend.set_expiration_date(self.name, expiration_date)
end
def history
return [] unless Trebuchet.backend.respond_to?(:get_history)
Trebuchet.backend.get_history(self.name).map do |row|
[Time.at(row.first), Trebuchet::Strategy.find(*row.last)]
end
end
def feature_id
begin
@feature_id ||= Trebuchet::SHA1.hexdigest(@name).to_i(16)
rescue
return 0
end
end
def as_json(options = {})
{:name => @name, :strategy => strategy.export}
end
def to_s
str = "name: \"#{@name}\", "
str << "#{strategy.name == :multiple ? 'strategies' : 'strategy'}: #{strategy}"
end
def inspect
"#<#{self.class.name} #{self}>"
end
def export
{:feature_name => name, :strategy => strategy.export}
end
private
def chained?
@chained
end
end
================================================
FILE: lib/trebuchet/state.rb
================================================
# Represents the internal, global and thread-unsafe state of Trebuchet
Trebuchet::State = Struct.new(
:visitor_id, :current, :current_block,
:logs, :admin_view, :admin_edit, :time_zone,
:author
)
================================================
FILE: lib/trebuchet/strategy/base.rb
================================================
require 'digest/sha1'
class Trebuchet::Strategy::Base
attr_accessor :feature
def name
self.class.strategy_name
end
def feature_id
feature.feature_id
end
def needs_user?
true
end
def self.strategy_name
Trebuchet::Strategy.name_for_class(self)
end
def as_json(options = {})
excluded = [:feature, :block]
{:name => name}.tap do |h|
instance_variables.map do |v|
key = v.to_s.gsub('@','').to_sym
h[key] = instance_variable_get(v) unless excluded.include?(key)
end
end
end
def inspect
self.to_s
end
def export(options = nil)
{:name => self.name, :options => options}
end
end
================================================
FILE: lib/trebuchet/strategy/custom.rb
================================================
class Trebuchet::Strategy::Custom < Trebuchet::Strategy::Base
attr_reader :options, :custom_name
@@custom_strategies = {}
def initialize(name, options = nil)
@custom_name = name
@options = options
@block = @@custom_strategies[name]
end
def launch_at?(user, request = nil)
!!(options ? @block.call(user, options) : @block.call(user))
end
def self.define(name, block)
@@custom_strategies[name] = block
end
def self.exists?(name)
@@custom_strategies.has_key?(name)
end
def needs_user?
false
# re-enable after adding { |options, user, request| }
# if block = @@custom_strategies[custom_name]
# block.arity > 0
# else
# false
# end
end
def as_json(options = {})
{:custom_name => @custom_name, :options => @options}
end
def to_s
"#{custom_name} (custom) #{options.inspect if options}"
end
def export
super as_json
end
end
================================================
FILE: lib/trebuchet/strategy/custom_request_aware.rb
================================================
class Trebuchet::Strategy::CustomRequestAware < Trebuchet::Strategy::Custom
@@custom_request_aware_strategies = {}
def initialize(name, options = nil)
@custom_name = name
@options = options
@block = @@custom_request_aware_strategies[name]
end
def self.define(name, block)
@@custom_request_aware_strategies[name] = block
end
def self.exists?(name)
@@custom_request_aware_strategies.has_key?(name)
end
def launch_at?(user, request = nil)
request ||= {}
!!(options ? @block.call(user, request, options) : @block.call(user, request))
end
def to_s
"#{custom_name} (custom_request_aware) #{options.inspect if options}"
end
end
================================================
FILE: lib/trebuchet/strategy/default.rb
================================================
require 'singleton'
# Default is to not launch the feature to anyone
class Trebuchet::Strategy::Default < Trebuchet::Strategy::Base
include Singleton
def initialize(options = nil)
# ignore options
end
def name
:default
end
def launch_at?(user, request = nil)
false
end
def needs_user?
false
end
def to_s
"not launched (default)"
end
end
================================================
FILE: lib/trebuchet/strategy/everyone.rb
================================================
require 'singleton'
# Everyone is to launch the feature to everyone
class Trebuchet::Strategy::Everyone < Trebuchet::Strategy::Base
include Singleton
def initialize(options = nil)
# ignore options
end
def name
:everyone
end
def launch_at?(user, request = nil)
true
end
def needs_user?
false
end
def to_s
"launched to everyone"
end
end
================================================
FILE: lib/trebuchet/strategy/experiment.rb
================================================
# require 'digest/sha1'
class Trebuchet::Strategy::Experiment < Trebuchet::Strategy::Base
include Trebuchet::Strategy::Experimentable
def initialize(options = {})
initialize_experiment(options)
end
def launch_at?(user, request = nil)
return false unless user && user.id
value_in_bucket?(user.id)
end
# def to_s from experimentable
# def export from experimentable
end
================================================
FILE: lib/trebuchet/strategy/hostname.rb
================================================
class Trebuchet::Strategy::Hostname < Trebuchet::Strategy::Base
attr_reader :hostnames
def initialize(hostnames)
@hostnames = if hostnames.is_a?(Array)
hostnames
else
[hostnames]
end
end
def launch_at?(user, request = nil)
return false if request.nil?
self.hostnames.include?(request.host)
end
def needs_user?
false
end
def to_s
"hostnames (#{hostnames.empty? ? 'none' : hostnames.join(', ')})"
end
def export
super @hostnames
end
end
================================================
FILE: lib/trebuchet/strategy/invalid.rb
================================================
# Default is to not launch the feature to anyone
class Trebuchet::Strategy::Invalid < Trebuchet::Strategy::Base
attr_reader :invalid_name, :options
def initialize(name, options = nil)
@invalid_name = name
@options = options
end
def name
:invalid
end
def launch_at?(user, request = nil)
false
end
def needs_user?
false
end
def to_s
"#{invalid_name} (invalid) #{options.inspect if options}"
end
end
================================================
FILE: lib/trebuchet/strategy/logic_and.rb
================================================
require 'trebuchet/strategy/logic_base'
class Trebuchet::Strategy::LogicAnd < Trebuchet::Strategy::LogicBase
def launch_at?(user, request = nil)
@strategies
.all? { |s| (!s.needs_user? || !user.nil?) && s.launch_at?(user, request) }
end
end
================================================
FILE: lib/trebuchet/strategy/logic_base.rb
================================================
class Trebuchet::Strategy::LogicBase < Trebuchet::Strategy::Base
attr_reader :strategies
attr_reader :options
def initialize(options = {})
@options = options
@strategies = []
options.each do |strategy_name, strategy_options|
@strategies << Trebuchet::Strategy.find(strategy_name.to_sym, strategy_options)
end
end
# Override feature setter so that @feature gets set on @strategies as well
def feature=(f)
@feature = f
@strategies.each { |s| s.feature = f }
end
def launch_at?(user, request = nil)
false # To be overriden in implementation classes.
end
def needs_user?
false
end
end
================================================
FILE: lib/trebuchet/strategy/logic_not.rb
================================================
require 'trebuchet/strategy/logic_base'
class Trebuchet::Strategy::LogicNot < Trebuchet::Strategy::LogicBase
def launch_at?(user, request = nil)
@strategies
.none? { |s| (!s.needs_user? || !user.nil?) && s.launch_at?(user, request) }
end
end
================================================
FILE: lib/trebuchet/strategy/logic_or.rb
================================================
require 'trebuchet/strategy/logic_base'
class Trebuchet::Strategy::LogicOr < Trebuchet::Strategy::LogicBase
def launch_at?(user, request = nil)
@strategies
.any? { |s| (!s.needs_user? || !user.nil?) && s.launch_at?(user, request) }
end
end
================================================
FILE: lib/trebuchet/strategy/multiple.rb
================================================
class Trebuchet::Strategy::Multiple < Trebuchet::Strategy::Base
attr_reader :strategies
def initialize(args)
@strategies = []
args.each_slice(2) do |pair|
@strategies << Trebuchet::Strategy.find(*pair)
end
end
# override setter so that @feature gets set on @strategies as well
def feature=(f)
@feature = f
@strategies.each {|s| s.feature = f}
end
def launch_at?(user, request = nil)
!!(strategies.select{|s| !user.nil? || !s.needs_user?}.find { |s| s.launch_at?(user, request) })
# !!(strategies.find { |s| s.launch_at?(user, request) })
end
def as_json(options = {})
@strategies
end
def needs_user?
false # assume some of the strategies may not need user
# could change this so it calls only the strategies that don't need a user when none is present
# strategies.any? { |s| s.needs_user? }
end
def export
super :strategies => strategies.map(&:export)
end
end
================================================
FILE: lib/trebuchet/strategy/nobody.rb
================================================
require 'singleton'
# Nobody is to launch the feature to nobody
class Trebuchet::Strategy::Nobody < Trebuchet::Strategy::Base
include Singleton
def initialize(options = nil)
# ignore options
end
def name
:nobody
end
def launch_at?(user, request = nil)
false
end
def needs_user?
false
end
def to_s
"launched to nobody"
end
end
================================================
FILE: lib/trebuchet/strategy/per_denomination.rb
================================================
class Trebuchet::Strategy::PerDenomination < Trebuchet::Strategy::Base
include Trebuchet::Strategy::PerDenominationable
def set_range_from_options(options = {})
numerator = options['numerator'] || options[:numerator] || 0
denominator = options['denominator'] || options[:denominator] || 0
super(numerator: numerator, denominator: denominator)
end
def launch_at?(user, request = nil)
return false unless user && user.id
value_in_range?(user.id.to_i)
end
# def to_s from PerDenominationable
# def export from PerDenominationable
end
================================================
FILE: lib/trebuchet/strategy/percent.rb
================================================
class Trebuchet::Strategy::Percent < Trebuchet::Strategy::Base
include Trebuchet::Strategy::Percentable
def initialize(options)
set_range_from_options(options)
end
def launch_at?(user, request = nil)
return false unless user && user.id
value_in_range?(user.id.to_i)
end
# def to_s from percentable
# def export from percentable
end
================================================
FILE: lib/trebuchet/strategy/percent_deprecated.rb
================================================
class Trebuchet::Strategy::PercentDeprecated < Trebuchet::Strategy::Base
include Trebuchet::Strategy::PercentableDeprecated
def initialize(options)
set_range_from_options(options)
end
def launch_at?(user, request = nil)
return false unless user && user.id
value_in_range?(user.id.to_i)
end
# def to_s from percentable
# def export from percentable
end
================================================
FILE: lib/trebuchet/strategy/stub.rb
================================================
class Trebuchet
module Strategy
class Stub < Trebuchet::Strategy::Base
attr_reader :state
def initialize(state)
@state = state
end
def launch_at?(user, request = nil)
state == :launched
end
def needs_user?
false
end
def to_s
"stub (#{state}}"
end
def export
super state
end
end
end
end
================================================
FILE: lib/trebuchet/strategy/user_id.rb
================================================
class Trebuchet::Strategy::UserId < Trebuchet::Strategy::Base
attr_reader :user_ids
def initialize(user_ids)
user_ids = Array(user_ids).flatten
@user_ids = Set.new(user_ids)
end
def launch_at?(user, request = nil)
@user_ids.include?(user.id)
end
def to_s
"user ids (#{user_ids.empty? ? 'none' : user_ids.to_a.join(', ')})"
end
def export
super @user_ids.to_a
end
end
================================================
FILE: lib/trebuchet/strategy/visitor_experiment.rb
================================================
class Trebuchet::Strategy::VisitorExperiment < Trebuchet::Strategy::Base
include Trebuchet::Strategy::Experimentable
def initialize(options = {})
initialize_experiment(options)
end
def launch_at?(user, request = nil)
if Trebuchet.visitor_id.respond_to?(:call)
visitor_id = Trebuchet.visitor_id.call(request)
else
visitor_id = nil
end
return false if visitor_id.nil?
value_in_bucket?(visitor_id)
end
def needs_user?
false
end
# def to_s from experimentable
# def export from experimentable
end
================================================
FILE: lib/trebuchet/strategy/visitor_percent.rb
================================================
class Trebuchet::Strategy::VisitorPercent < Trebuchet::Strategy::Base
include Trebuchet::Strategy::Percentable
def initialize(options)
set_range_from_options(options)
end
def launch_at?(user, request = nil)
return false if request.nil?
if Trebuchet.visitor_id.respond_to?(:call)
visitor_id = Trebuchet.visitor_id.call(request)
else
visitor_id = nil
end
return false if visitor_id.nil?
value_in_range?(visitor_id.to_i)
end
def needs_user?
false
end
# def to_s from percentable
end
================================================
FILE: lib/trebuchet/strategy/visitor_percent_deprecated.rb
================================================
class Trebuchet::Strategy::VisitorPercentDeprecated < Trebuchet::Strategy::Base
include Trebuchet::Strategy::PercentableDeprecated
def initialize(options)
set_range_from_options(options)
end
def launch_at?(user, request = nil)
return false if request.nil?
if Trebuchet.visitor_id.respond_to?(:call)
visitor_id = Trebuchet.visitor_id.call(request)
else
visitor_id = nil
end
return false if visitor_id.nil?
value_in_range?(visitor_id.to_i)
end
def needs_user?
false
end
# def to_s from percentable
end
================================================
FILE: lib/trebuchet/strategy.rb
================================================
require 'digest/sha1'
module Trebuchet::Strategy
def self.for_feature(feature)
stub_state = Trebuchet::Feature.stubbed_features[feature.name]
if stub_state
Stub.new(stub_state)
else
strategy_args = Trebuchet.backend.get_strategy(feature.name)
find(*strategy_args).tap {|s| s.feature = feature }
end
end
def self.find(*args)
strategy_name, options = args
if args.size > 2
Multiple.new(args)
elsif strategy_name.nil? || strategy_name == :default
# Strategy hasn't been defined yet
Default.instance
elsif strategy_name == :everyone
Everyone.instance
elsif strategy_name == :nobody
Nobody.instance
elsif CustomRequestAware.exists?(strategy_name)
CustomRequestAware.new(strategy_name, options)
elsif Custom.exists?(strategy_name)
Custom.new(strategy_name, options)
elsif klass = class_for_name(strategy_name)
# percent, users
klass.new(options)
else
Invalid.new(strategy_name, options)
end
end
# The stub strategy purposely left out of this list as it should be
# accessible via the testing interface only and not externally.
def self.name_class_map
[
[:visitor_percent_deprecated, VisitorPercentDeprecated],
[:percent_deprecated, PercentDeprecated],
[:percent, Percent],
[:per_denomination, PerDenomination],
[:users, UserId],
[:default, Default],
[:everyone, Everyone],
[:nobody, Nobody],
[:custom, Custom],
[:multiple, Multiple],
[:experiment, Experiment],
[:visitor_percent, VisitorPercent],
[:hostname, Hostname],
[:visitor_experiment, VisitorExperiment],
[:logic_and, LogicAnd],
[:logic_or, LogicOr],
[:logic_not, LogicNot],
]
end
def self.deprecated_strategy_names
[
:percent_deprecated,
:visitor_percent_deprecated
]
end
def self.class_for_name(name)
classes = Hash[name_class_map]
classes[name]
end
def self.name_for_class(klass)
names = Hash[name_class_map.map(&:reverse)]
names[klass]
end
module PerDenominationable
attr_reader :numerator, :denominator
def initialize(options)
set_range_from_options(options)
end
# must be called from initialize
def set_range_from_options(options)
raise ArgumentError, "Missing required input numerator" unless options[:numerator]
raise ArgumentError, "Missing required input denominator" unless options[:denominator]
@numerator = options[:numerator].to_i
@denominator = options[:denominator].to_i
raise ArgumentError, "Invalid denominator #{@denominator}" if @denominator <= 0
if @numerator > @denominator
raise ArgumentError, "Invalid numerator #{@numerator} > denominator #{@denominator}"
end
end
def value_in_range?(value)
bucket = Trebuchet::SHA1.hexdigest("#{@feature.name}|#{value}").to_i(16) % denominator
bucket < numerator
end
def to_s
"#{numerator} / #{denominator} of users"
end
def export
super({ numerator: numerator, denominator: denominator })
end
end
### Percentable module standardizes logic for percentage-based strategies
module Percentable
include PerDenominationable
alias_method :percentage, :numerator
# must be called from initialize
def set_range_from_options(options)
numerator =
if options == nil || options.is_a?(Numeric)
options.to_i
else
0
end
super(numerator: numerator, denominator: 100)
end
def to_s
kind = self.name == :visitor_percent ? "visitors" : "users"
percentage_str = "#{percentage}% of #{kind}"
"#{percentage_str}"
end
def export
super(percentage)
end
end
# This module is deprecated because the implementation is such that it's
# not possible to trust per-feature analysis if multiple features are
# using the PercentableDeprecated based strategies because the same
# visitors will tend to get the same features (even with the offset).
module PercentableDeprecated
def initialize(options)
set_range_from_options(options)
end
# must be called from initialize
def set_range_from_options(options)
if options == nil || options.is_a?(Numeric)
@from = 0
@to = options.to_i - 1
@style = :percentage
elsif options.is_a?(Hash) && (p = options['percentage'] || options[:percentage])
@from = 0
@to = p.to_i - 1
@style = :percentage
elsif options.is_a?(Hash)
@from = options['from'] || options[:from]
@to = options['to'] || options[:to]
@style = :range
else
@from = 0
@to = -1
end
end
def offset
if @style == :percentage
feature_id % 100
else
0
end
end
def percentage
return 0 unless @to.is_a?(Integer) && @from.is_a?(Integer)
return 0 if @to < 0
((@to - @from) + 100) % 100 + 1
end
# call from launch_at? and pass in user id or another integer
def value_in_range?(value)
return false unless @from && @to
return false if @from.to_i < 0 || @to.to_i < 0
return false if value == nil || !value.is_a?(Numeric)
cutoff = percentage
value = ((value - @from) + 200 - offset) % 100
!!(value < cutoff)
end
def offset_from
(@from + offset) % 100
end
def offset_to
(@to + offset) % 100
end
def to_s
kind = self.name == :visitor_percent ? "visitors" : "users"
percentage_str = "#{percentage}% of #{kind}"
range_str = if @to < 0
"nobody"
else
str = ''
str << "user id ending with " if kind != "visitors"
str << "#{offset_from.to_s.rjust(2, '0')}"
str << " to #{offset_to.to_s.rjust(2, '0')}" if @to != @from
str
end
@style == :range ? "#{range_str} (#{percentage_str})" : "#{percentage_str} (#{range_str})"
end
def export
if @style == :percentage
super :percentage => @to
else
super :from => @from, :to => @to
end
end
end
module Experimentable
attr_reader :bucket, :total_buckets, :experiment_name
def initialize_experiment(options)
options.keys.each {|k| options[k.to_sym] = options.delete(k)} # cheap symbolize_keys
@experiment_name = options[:name]
@bucket = [ options[:bucket] ].flatten # always treat as an array
@total_buckets = options[:total_buckets] || 5
end
def value_in_bucket?(value)
return false if value == nil || !value.is_a?(Numeric)
return false unless self.valid?
# must hash feature name and value together to ensure uniform distribution
b = Trebuchet::SHA1.hexdigest("experiment: #{@experiment_name.downcase} user: #{value}").to_i(16) % total_buckets
!!@bucket.include?(b + 1) # is user in this bucket?
end
def valid?
experiment_name && total_buckets > 0 && bucket.max <= total_buckets && (1..total_buckets).include?(bucket.min)
rescue
false
end
def type
"#{name == :experiment ? "user" : "visitor"} experiment"
end
def as_json(options = {})
{
:name => experiment_name,
:bucket => bucket,
:total_buckets => total_buckets,
:type => self.type
}
end
def to_s
str = "buckets (#{bucket.join(', ')}) of total: #{total_buckets}"
str << " for #{self.type} experiment: #{experiment_name}"
end
def export
super :name => experiment_name, :bucket => bucket, :total_buckets => total_buckets
end
def inspect
"#<#{self.class.name} #{self}>"
end
end
end
================================================
FILE: lib/trebuchet/version.rb
================================================
class Trebuchet
VERSION = "0.12.1".freeze
end
================================================
FILE: lib/trebuchet.rb
================================================
require 'digest/sha1'
require 'forwardable'
class Trebuchet
# initialize a single one to save object allocations
# Todo perhaps choose a better hash instead of sha1
SHA1 = Digest::SHA1.new
class << self
extend Forwardable
def_delegators :state, :current=, :current_block=, :current_block,
:logs, :admin_view, :admin_view=, :admin_edit, :admin_edit=,
:time_zone, :time_zone=, :author=, :author, :visitor_id
attr_accessor :exception_handler
attr_accessor :threadsafe_state
def backend
self.backend = :memory unless @backend
@backend
end
def set_backend(backend_type, *args)
if backend_type.is_a?(Symbol)
require "trebuchet/backend/#{backend_type}"
@backend = Backend.lookup(backend_type).new(*args)
elsif backend_type.class.name =~ /Trebuchet::Backend/
@backend = backend_type
end
end
# this only works with additional args, e.g.: Trebuchet.backend = :memory
alias_method :backend=, :set_backend
alias_method :threadsafe_state?, :threadsafe_state
# Logging done at class level
# TODO: split by user identifier so instance can return scoped to one user
# (in case multiple users have user.trebuchet called)
def initialize_logs
state.logs = {}
end
def log(feature_name, result)
initialize_logs if state.logs.nil?
logs[feature_name] = result
end
def logs
state.logs
end
def current=(other)
state.current = other
end
def current
state.current ||= current_block.call if current_block.respond_to?(:call)
state.current || new(nil) # return an blank Trebuchet instance if @current is not set
end
def reset_current!
self.current = nil
end
def thread_local_key
:trebuchet_state
end
# state is a representation of the current context of Trebuchet such as current and
# current_proc, which are expected to be different between threads or fibers.
# exception_handler and backend are not included in this state object as they are
# not expected to change from fiber to fiber or request to request, therefore they
# must be thread/fibersafe on their own accord.
def state
if threadsafe_state?
Thread.current[thread_local_key] ||= State.new
else
@state ||= State.new
end
end
def state=(new_state)
if threadsafe_state?
Thread.current[thread_local_key] = new_state
else
@state = new_state
end
end
def threadsafe_state?
threadsafe_state
end
end
def self.aim(feature_name, *args)
Feature.find(feature_name).aim(*args)
end
def self.dismantle(feature_name)
Feature.find(feature_name).dismantle
end
def self.dismantle_stubs
Feature.dismantle_stubs
end
def self.define_strategy(name, &block)
Strategy::Custom.define(name, block)
end
def self.define_request_aware_strategy(name, &block)
Strategy::CustomRequestAware.define(name, block)
end
def self.visitor_id=(id_or_proc)
if id_or_proc.is_a?(Proc)
state.visitor_id = id_or_proc
elsif id_or_proc.is_a?(Integer)
state.visitor_id = proc { |request| id_or_proc }
else
state.visitor_id = nil
end
end
def self.use_with_rails!
if defined?(ActionController::Base)
ActionController::Base.send(:include, Trebuchet::ActionController)
end
end
def self.feature(name)
Feature.find(name)
end
def initialize(current_user, request = nil)
@current_user = current_user
@request = request
@result_cache = {}
end
def launch(feature, &block)
if launch?(feature)
yield if block_given?
end
end
def launch?(feature)
result = @result_cache[feature]
if result.nil?
result = @result_cache[feature] =
!!Feature.find(feature).launch_at?(@current_user, @request)
Trebuchet.log(feature, result)
end
result
rescue => e
handle_exception(e, feature)
return false
end
def handle_exception(exception, feature = nil)
if self.class.exception_handler.is_a?(Proc)
argc = self.class.exception_handler.arity
argc = 3 if argc < 0
self.class.exception_handler.call *[exception, feature, self][0,argc]
end
end
def self.export
{}.tap do |features|
Trebuchet.backend.get_feature_names.map do |fn|
features[fn] = self.feature(fn).strategy.export
end
end
end
def self.history(include_archived = false)
return [] unless Trebuchet.backend.respond_to?(:get_all_history)
Trebuchet.backend.get_all_history(include_archived).map do |row|
[Time.at(row.first), Feature.find(row.last)]
end
end
end
require 'set'
require 'trebuchet/version'
require 'trebuchet/error'
require 'trebuchet/backend'
require 'trebuchet/backend/disabled'
# load other backends on demand so their dependencies can load first
require 'trebuchet/state'
require 'trebuchet/feature'
require 'trebuchet/strategy'
require 'trebuchet/strategy/base'
require 'trebuchet/strategy/custom'
require 'trebuchet/strategy/custom_request_aware'
require 'trebuchet/strategy/default'
require 'trebuchet/strategy/everyone'
require 'trebuchet/strategy/experiment'
require 'trebuchet/strategy/hostname'
require 'trebuchet/strategy/invalid'
require 'trebuchet/strategy/logic_and'
require 'trebuchet/strategy/logic_not'
require 'trebuchet/strategy/logic_or'
require 'trebuchet/strategy/multiple'
require 'trebuchet/strategy/nobody'
require 'trebuchet/strategy/percent'
require 'trebuchet/strategy/percent_deprecated'
require 'trebuchet/strategy/per_denomination'
require 'trebuchet/strategy/user_id'
require 'trebuchet/strategy/visitor_experiment'
require 'trebuchet/strategy/visitor_percent'
require 'trebuchet/strategy/visitor_percent_deprecated'
require 'trebuchet/strategy/stub'
require 'trebuchet/action_controller'
================================================
FILE: lib/trebuchet_rails/engine.rb
================================================
require 'rails'
require File.expand_path(File.dirname(__FILE__) + "/../trebuchet")
module TrebuchetRails
class Engine < Rails::Engine
isolate_namespace TrebuchetRails if respond_to?(:isolate_namespace)
end
end
Trebuchet.use_with_rails!
================================================
FILE: lib/trebuchet_rails.rb
================================================
require 'trebuchet_rails/engine'
require 'trebuchet'
================================================
FILE: spec/custom_request_aware_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::CustomRequestAware do
it "should launch according to the custom strategy with options" do
Trebuchet.define_request_aware_strategy(:ip_address_strategy) do |current_user, request, ip_address|
request[:ip_address] == ip_address
end
Trebuchet.aim('ip_limited_feature', :ip_address_strategy, '1.1.1.1')
Trebuchet.new(User.new, { :ip_address => '1.1.1.1' }).launch?('ip_limited_feature')
.should be_true
Trebuchet.new(User.new, { :ip_address => '2.2.2.2' }).launch?('ip_limited_feature')
.should be_false
end
it "should launch according to the custom strategy without options" do
Trebuchet.define_request_aware_strategy(:ip_address_strategy) do |current_user, request|
request[:ip_address] == '1.1.1.1'
end
Trebuchet.aim('ip_limited_feature', :ip_address_strategy)
Trebuchet.new(User.new, { :ip_address => '1.1.1.1' }).launch?('ip_limited_feature')
.should be_true
Trebuchet.new(User.new, { :ip_address => '2.2.2.2' }).launch?('ip_limited_feature')
.should be_false
end
it "should not explode when request is nil" do
Trebuchet.define_request_aware_strategy(:ip_address_strategy) do |current_user, request|
request[:ip_address] == '1.1.1.1'
end
Trebuchet.aim('ip_limited_feature', :ip_address_strategy)
Trebuchet.new(User.new).launch?('ip_limited_feature').should be_false
end
end
================================================
FILE: spec/custom_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Custom do
it "should launch according to the custom strategy" do
Trebuchet.define_strategy(:admins) do |current_user|
current_user.has_role?(:admin)
end
Trebuchet.aim('admin_feature', :admins)
Trebuchet.new(User.new(1, :admin)).launch?('admin_feature').should be_true
Trebuchet.new(User.new(1, :user)).launch?('admin_feature').should be_false
end
it "should pass arguments to the custom strategy" do
Trebuchet.define_strategy(:role) do |current_user, role|
current_user.has_role?(role.to_sym)
end
Trebuchet.aim('power_feature', :role, :power_user)
Trebuchet.new(User.new(1, :power_user)).launch?('power_feature').should be_true
Trebuchet.new(User.new(1, :user)).launch?('power_feature').should be_false
end
it "should allow an always-on strategy" do
Trebuchet.define_strategy(:yes) { |user| true }
Trebuchet.aim("perma-feature", :yes)
Trebuchet.new(User.new 999).launch?("perma-feature").should be_true
Trebuchet.new(User.new nil).launch?("perma-feature").should be_true
end
it "should needs_user? based on block arity" do
# still a good idea to nilcheck within block however
pending "re-enable after switching to { |options, user, request| }"
Trebuchet.define_strategy(:yes) { |user| true }
Trebuchet.define_strategy(:heck_yeah) { |user, request| true }
Trebuchet.define_strategy(:never) { false }
Trebuchet.define_strategy(:always) { true }
Trebuchet::Strategy::Custom.new(:yes).needs_user?.should be_true
Trebuchet::Strategy::Custom.new(:heck_yeah).needs_user?.should be_true
Trebuchet::Strategy::Custom.new(:never).needs_user?.should be_false
Trebuchet::Strategy::Custom.new(:always).needs_user?.should be_false
end
end
================================================
FILE: spec/default_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Default do
it "should not launch if no strategy was defined" do
Trebuchet.new(User.new(rand(2 << 32))).launch?('default').should be_false
end
it "should be named default" do
Trebuchet::Strategy::Default.strategy_name.should == :default
Trebuchet.feature('whatever').strategy.name.should == :default
end
it "should always return false" do
Trebuchet.feature('time_machine').aim(:default)
t = Trebuchet.new User.new(1)
t.launch?('time_machine').should === false
end
it "should return false when missing user" do
Trebuchet.feature('time_machine').aim(:default)
t = Trebuchet.new nil
t.launch?('time_machine').should === false
end
end
================================================
FILE: spec/disabled_backend_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Backend::Disabled do
before do
Trebuchet.backend = :disabled
Trebuchet.backend.should be_a(Trebuchet::Backend::Disabled)
end
it "should not store features" do
Trebuchet.feature('thing').aim(:users, [5]).aim(:percent, 9)
Trebuchet.feature('thing').strategy.should be_a(Trebuchet::Strategy::Default)
Trebuchet::Feature.all.should eql []
end
it "should always return false" do
Trebuchet.feature('time_machine').aim(:disabled)
t = Trebuchet.new User.new(1)
t.launch?('time_machine').should === false
end
after do
Trebuchet.backend = :memory
end
end
================================================
FILE: spec/everyone_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Everyone do
it "should be named everyone" do
Trebuchet::Strategy::Everyone.strategy_name.should == :everyone
Trebuchet.feature('time_machine').aim(:everyone)
Trebuchet.feature('time_machine').strategy.name.should == :everyone
end
it "should always return true" do
Trebuchet.feature('time_machine').aim(:everyone)
t = Trebuchet.new User.new(1)
t.launch?('time_machine').should === true
end
it "should return true when missing user" do
Trebuchet.feature('time_machine').aim(:everyone)
t = Trebuchet.new nil
t.launch?('time_machine').should === true
end
end
================================================
FILE: spec/experiment_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Experiment do
before do
@feature_name = "Photographic Memory"
@experiment_name = "Superhumanity"
end
it "should match a user in a bucket" do
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 1)
strategy = Trebuchet.feature(@feature_name).strategy
# these values just happen to hash for the algorithm and experiment name
strategy.launch_at?(User.new(5)).should be_true
strategy.launch_at?(User.new(4)).should be_false
end
it "should adjust the number of buckets" do
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 1, :total_buckets => 3)
strategy = Trebuchet.feature(@feature_name).strategy
strategy.total_buckets.should == 3
strategy.bucket.should == [1]
strategy.launch_at?(User.new(4)).should be_true
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 2, :total_buckets => 3)
Trebuchet.feature(@feature_name).strategy.launch_at?(User.new(4)).should be_false
end
it "should be mutually exclusive within experiments" do
strategies = (1..10).map do |i|
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => i)
Trebuchet.feature(@feature_name).strategy
end
user_ids = (1..100).to_a
launches = strategies.map do |strategy|
user_ids.select {|user_id| strategy.launch_at?(User.new(user_id))}
end
occurrences = user_ids.map do |user_id|
launches.select{|l| l.include?(user_id)}.size
end
# no user should be in more than one bucket
occurrences.select{|i| i > 1}.size.should == 0
# each user should be in one bucket
occurrences.select{|i| i < 1}.size.should == 0
end
it "should distribute users evenly" do
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 1)
strategy = Trebuchet.feature(@feature_name).strategy
user_ids = (1..10_000).to_a
launches = user_ids.map {|user_id| strategy.launch_at?(User.new(user_id))}
# total should be around 20% (spread evenly across default of 5 buckets)
launch_count = launches.select{|l| l == true}.size
(launch_count * 100 / user_ids.size).round.should == 20
end
it "should have low overlap between experiments" do
other_experiment_name = 'World Peace'
other_feature_name = 'Friendship Rings'
another_experiment_name = 'Space Tourism'
another_feature_name = 'Orbital Disneyland'
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 1)
Trebuchet.aim(other_feature_name, :experiment, :name => other_experiment_name, :bucket => 1)
Trebuchet.aim(another_feature_name, :experiment, :name => another_experiment_name, :bucket => 1)
strategies = [
Trebuchet.feature(@feature_name).strategy,
Trebuchet.feature(other_feature_name).strategy,
Trebuchet.feature(another_feature_name).strategy
]
# find out which users match each strategy
user_ids = (1..10_000).to_a
launches = strategies.map do |strategy|
user_ids.select {|user_id| strategy.launch_at?(User.new(user_id))}
end
# intersect each set with the next
overlaps = []
(0..launches.size).each do |i|
j = (i + 1) % launches.size
overlaps << launches[i] & launches[j]
end
# each group should have about 20% overlap (for 5 buckets)
((19..21) === (overlaps[0].size * 100 / user_ids.size).round).should be_true
((19..21) === (overlaps[1].size * 100 / user_ids.size).round).should be_true
((19..21) === (overlaps[2].size * 100 / user_ids.size).round).should be_true
# 1% or fewer of users should be in all three groups
total_overlap = (launches[0] & launches[1] & launches[2])
(total_overlap.size * 100 / user_ids.size).round.should < 2
end
it "should return false for invalid parameters" do
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 900)
Trebuchet.feature(@feature_name).strategy.should_not be_valid
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 1, :total_buckets => -17)
Trebuchet.feature(@feature_name).strategy.should_not be_valid
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name, :bucket => 10, :total_buckets => 3)
Trebuchet.feature(@feature_name).strategy.should_not be_valid
Trebuchet.aim(@feature_name, :experiment, :name => @experiment_name)
Trebuchet.feature(@feature_name).strategy.should_not be_valid
Trebuchet.aim(@feature_name, :experiment, :bucket => 1)
Trebuchet.feature(@feature_name).strategy.should_not be_valid
end
end
================================================
FILE: spec/feature_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Feature do
def feature
Trebuchet.feature('some_feature')
end
def feature_names
Trebuchet.backend.get_feature_names
end
def archived_feature_names
Trebuchet.backend.get_archived_feature_names
end
describe :aim do
it "should add one strategy" do
feature.aim(:percent, 10)
feature.strategy.name.should be :percent
end
it "should add multiple strategies by chaining" do
feature.aim(:default)
feature.strategy.name.should be :default
feature.aim(:percent, 10).aim(:users, 1)
feature.strategy.name.should be :multiple
strategy_names = feature.strategy.strategies.map{ |s| s.name }
strategy_names.should include(:percent)
strategy_names.should include(:users)
end
it "should obliterate chained strategies" do
feature.aim(:percent, 10).aim(:users, 1)
feature.aim(:default)
feature.strategy.name.should be :default
end
end
describe :adjust do
it "should add one strategy" do
feature.dismantle
feature.adjust(:percent, 10)
feature.strategy.name.should be :percent
end
it "should adjust a strategy in the chain" do
feature.aim(:percent, 10).aim(:users, 1)
feature.strategy.name.should be :multiple
feature.adjust(:users, 2)
feature.strategy.name.should be :multiple
user_strategy = feature.strategy.strategies.detect { |s| s.name == :users }
user_strategy.user_ids.should include(2)
user_strategy.user_ids.should_not include(1)
feature.adjust(:percent, 20)
percent_strategy = feature.strategy.strategies.detect { |s| s.name == :percent }
percent_strategy.percentage.should == 20
end
it "should not obliterate chained strategies" do
feature.aim(:percent, 10).aim(:users, 1)
feature.adjust(:default)
feature.strategy.name.should be :multiple
feature.strategy.strategies.map{ |s| s.name }.should include(:default)
end
end
describe :augment do
it "should add one strategy where none exist" do
feature.dismantle
feature.augment(:percent, 10)
feature.strategy.name.should be :percent
end
it "should append a strategy if others exist" do
feature.aim(:percent, 10)
feature.augment(:users, 1)
feature.strategy.name.should be :multiple
percent_strategy = feature.strategy.strategies.detect { |s| s.name == :percent }
percent_strategy.percentage.should == 10
user_strategy = feature.strategy.strategies.detect { |s| s.name == :users }
user_strategy.user_ids.should include(1)
end
it "should adjust an existing strategy with numeric" do
feature.aim(:percent, 10)
feature.augment(:percent, 5)
feature.strategy.name.should be :percent
feature.strategy.percentage.should be 15
feature.augment(:percent, 5.0)
feature.strategy.percentage.should be 20
end
it "should adjust an existing strategy with set/array" do
feature.aim(:users, [1])
feature.augment(:users, [2,3])
feature.strategy.name.should == :users
feature.strategy.user_ids.sort.should == [1,2,3]
feature.augment(:users, [4])
feature.strategy.user_ids.sort.should == [1,2,3,4]
# this probably should not be allowed
# feature.aim(:users, 1)
# feature.augment(:users, 2)
# feature.strategy.user_ids.sort.should == [1,2]
end
it "should adjust an existing strategy with hash" do
Trebuchet.define_strategy(:role_percent) do |user, options|
percent = options[user.role].to_i
user.id % 100 < percent
end
old_percentages = {:admin => 30, :editor => 50, :publisher => 100}
feature.aim(:role_percent, old_percentages)
new_percentages = {:admin => 100, :reviewer => 10}
feature.augment(:role_percent, new_percentages)
feature.strategy.name.should be :custom
feature.strategy.custom_name.should be :role_percent
feature.strategy.options.should == (old_percentages.merge(new_percentages))
end
end
describe :dismantle do
it "should remove a feature and add it to archived features" do
feature.aim(:users, [1])
feature_names.include?(feature.name).should be_true
feature.dismantle
feature_names.include?(feature.name).should be_false
archived_feature_names.include?(feature.name).should be_true
end
it "should remove archived feature when dismantled feature is redefined" do
feature.dismantle
feature_names.include?(feature.name).should be_false
archived_feature_names.include?(feature.name).should be_true
feature.aim(:percent, 5)
feature_names.include?(feature.name).should be_true
archived_feature_names.include?(feature.name).should be_false
end
end
end
================================================
FILE: spec/logic_and_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::LogicAnd do
it "passes the check only when all conditions are met - case 1" do
Trebuchet.feature('pokemon').aim(
:logic_and,
{
percent: 0,
users: [30, 35],
},
)
t = Trebuchet.new(User.new(30))
t.launch?('pokemon').should === false
t = Trebuchet.new(User.new(111))
t.launch?('pokemon').should === false
end
it "passes the check only when all conditions are met - case 2" do
Trebuchet.feature('pokemon').aim(
:logic_and,
{
percent: 100,
users: [30, 35],
},
)
t = Trebuchet.new(User.new(30))
t.launch?('pokemon').should === true
t = Trebuchet.new(User.new(111))
t.launch?('pokemon').should === false
end
it "nests well" do
Trebuchet.feature('pokemon').aim(
:logic_and,
{
"percent" => 100,
"logic_and" => {
"users" => [90, 91, 92],
"logic_not" => {
"users" => [90],
},
},
},
)
[91, 92].each do |uid|
t = Trebuchet.new(User.new(uid))
t.launch?('pokemon').should === true
end
[1, 3, 5, 7, 90].each do |uid|
t = Trebuchet.new(User.new(uid))
t.launch?('pokemon').should === false
end
end
end
================================================
FILE: spec/logic_base_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::LogicBase do
it "should set @feature on sub-strategies" do
feature = Trebuchet.feature('pokemon')
feature.aim(
:logic_and,
{
percent: 100,
users: [30, 35],
},
)
feature.strategy.feature.name.should == 'pokemon'
feature.strategy.strategies.first.feature.name.should == 'pokemon'
feature.strategy.strategies.last.feature.name.should == 'pokemon'
end
it "should pass user and request to each strategy" do
args = [:foo, 1]
user = mock "User"
request = mock "Request"
strategy = mock "Strategy"
Trebuchet::Strategy.should_receive(:find).with(*args).and_return(strategy)
strategy.should_receive(:launch_at?).with(user, request)
strategy.should_receive(:needs_user?).and_return(false)
s = Trebuchet::Strategy::LogicOr.new({foo: 1})
s.launch_at?(user, request)
end
end
================================================
FILE: spec/logic_not_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::LogicNot do
it "works expectedly as a not operator" do
Trebuchet.feature('pokemon').aim(
:logic_not,
{
users: [30, 35],
},
)
t = Trebuchet.new(User.new(30))
t.launch?('pokemon').should === false
t = Trebuchet.new(User.new(111))
t.launch?('pokemon').should === true
end
end
================================================
FILE: spec/logic_or_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::LogicOr do
it "passes the check as long as one of the children strategies passes" do
Trebuchet.feature('pokemon').aim(
:logic_or,
{
users: [30, 35],
everyone: nil,
nobody: nil,
percent: 30,
},
)
[1, 9, 91, 92, 2016].each do |uid|
t = Trebuchet.new(User.new(uid))
t.launch?('pokemon').should === true
end
end
it "is not launching if no children strategy works" do
Trebuchet.feature('pokemon').aim(
:logic_or,
{
percent: 0,
users: [30, 35],
},
)
t = Trebuchet.new(User.new(30))
t.launch?('pokemon').should === true
t = Trebuchet.new(User.new(111))
t.launch?('pokemon').should === false
end
it "nests well" do
Trebuchet.feature('pokemon').aim(
:logic_or,
{
users: [100],
logic_and: {
users: [90, 91, 92],
logic_not: {
users: [90],
},
},
},
)
[91, 92, 100].each do |uid|
t = Trebuchet.new(User.new(uid))
t.launch?('pokemon').should === true
end
[1, 3, 5, 7, 90].each do |uid|
t = Trebuchet.new(User.new(uid))
t.launch?('pokemon').should === false
end
end
end
================================================
FILE: spec/multiple_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Multiple do
it "should support chaining strategies" do
Trebuchet.feature('time_machine').aim(:percent, 5).aim(:users, [10, 11])
should_launch('time_machine', [31, 36, 10, 11, 197])
should_not_launch('time_machine', [49, 71])
end
it "should always return booleans" do
Trebuchet.feature('time_machine').aim(:percent, 0).aim(:users, [5])
t = Trebuchet.new User.new(5)
t.launch?('time_machine').should === true
t = Trebuchet.new User.new(117)
t.launch?('time_machine').should === false
end
it "should set @feature on sub-strategies" do
feature = Trebuchet.feature('time_machine')
feature.aim(:percent, 10).aim(:users, [5])
feature.strategy.feature.name == feature.name
feature.strategy.strategies.first.feature.name.should == feature.name
feature.strategy.strategies.last.feature.name.should == feature.name
end
it "should pass user and request to each strategy" do
args = [:foo, 1]
user = mock "User"
request = mock "Request"
strategy = mock "Strategy"
Trebuchet::Strategy.should_receive(:find).with(*args).and_return(strategy)
strategy.should_receive(:launch_at?).with(user, request)
multi = Trebuchet::Strategy::Multiple.new(args)
multi.launch_at?(user, request)
end
it "should always return false for needs_user?" do
s = Trebuchet::Strategy::Multiple.new [:default, nil, :invalid, nil]
s.needs_user?.should be_false
s = Trebuchet::Strategy::Multiple.new [:default, nil, :percent, 5]
s.needs_user?.should be_false
end
it "should skip needs_user? sub-strategies if user not present" do
s = Trebuchet::Strategy::Multiple.new [:hostname, 'abc', :users, [1,2,3]]
s.strategies.first.should_receive(:launch_at?)
s.strategies.last.should_not_receive(:launch_at?)
s.launch_at?(nil)
end
end
================================================
FILE: spec/nobody_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Nobody do
it "should be named nobody" do
Trebuchet::Strategy::Nobody.strategy_name.should == :nobody
Trebuchet.feature('time_machine').aim(:nobody)
Trebuchet.feature('time_machine').strategy.name.should == :nobody
end
it "should always return false" do
Trebuchet.feature('time_machine').aim(:nobody)
t = Trebuchet.new User.new(1)
t.launch?('time_machine').should === false
end
it "should return false when missing user" do
Trebuchet.feature('time_machine').aim(:nobody)
t = Trebuchet.new nil
t.launch?('time_machine').should === false
end
end
================================================
FILE: spec/per_denomination_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::PerDenomination do
describe 'launch' do
before(:each) do
Trebuchet.aim('strategy', :per_denomination, { numerator: 50, denominator: 1000 })
end
it "should not launch to unsaved users" do
Trebuchet.new(nil).launch?('strategy').should be_false
end
it "should not launch to users with no IDs" do
Trebuchet.new(User.new(nil)).launch?('strategy').should be_false
end
it "should launch to the correct per_denomination of users" do
launched_users =
(1..10_000).select { |i| Trebuchet.new(User.new(i)).launch?('strategy') }.size
unlaunched_users =
(1..10_000).reject { |i| Trebuchet.new(User.new(i)).launch?('strategy') }.size
# If you run this to larger N it approaches .95 and 0.05. This is
# hash function dependent but it's a nice santity check.
launched_users.should be_within(200).of(500)
unlaunched_users.should be_within(200).of(9500)
end
end
it "should not yank the feature from users when per_denomination is increased" do
Trebuchet.aim('strategy', :per_denomination, { numerator: 10, denominator: 1000 })
first_launched_users =
(1..10_000).select { |i| Trebuchet.new(User.new(i)).launch?('strategy') }
Trebuchet.aim('strategy', :per_denomination, { numerator: 50, denominator: 1000 })
second_launched_users =
(1..10_000).select { |i| Trebuchet.new(User.new(i)).launch?('strategy') }
(second_launched_users & first_launched_users).should eq first_launched_users
end
it "should distribute launches based on the feature name" do
Trebuchet.aim('strategy1', :per_denomination, { numerator: 50, denominator: 1000 })
Trebuchet.aim('strategy2', :per_denomination, { numerator: 50, denominator: 1000 })
per_denomination1_launched_users =
(1..10_000).select { |i| Trebuchet.new(User.new(i)).launch?('strategy1') }
per_denomination2_launched_users =
(1..10_000).select { |i| Trebuchet.new(User.new(i)).launch?('strategy2') }
per_denomination1_launched_users.should_not eq per_denomination2_launched_users
end
it "should always return booleans" do
Trebuchet.aim('strategy', :per_denomination, { numerator: 50, denominator: 1000 })
(1..10_000).each do |i|
[true, false].should include(Trebuchet.new(User.new(i)).launch?('strategy'))
end
end
it "should handle 0 numerator" do
Trebuchet.aim('strategy', :per_denomination, { numerator: 0, denominator: 1000 })
should_not_launch 'strategy', (1..10_000).to_a
end
it "should fail on 0 denominator" do
Trebuchet.aim('strategy', :per_denomination, { numerator: 1000, denominator: 0 })
should_not_launch 'strategy', (1..10_000).to_a # trebuchet silently fails all invalid strategies
end
it "should handle numerator == denominator" do
Trebuchet.aim('strategy', :per_denomination, { numerator: 1000, denominator: 1000 })
should_launch 'strategy', (1..10_000).to_a
end
it "should fail when numerator > denominator" do
Trebuchet.aim('strategy', :per_denomination, { numerator: 1001, denominator: 1000 })
should_not_launch 'strategy', (1..10_000).to_a # trebuchet silently fails all invalid strategies
end
it "should handle garbage arguments" do
ids = (1..20_000).to_a
Trebuchet.feature('strategy').aim(:per_denomination, -1)
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, -150)
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, 'h')
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, 'h')
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, [5, 10])
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, 20..50)
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, nil)
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, :from => 7)
should_not_launch 'strategy', ids
Trebuchet.feature('strategy').aim(:per_denomination, :to => 1)
should_not_launch 'strategy', ids
end
end
================================================
FILE: spec/percent_deprecated_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::PercentDeprecated do
it "should be deprecated" do
Trebuchet::Feature.with_deprecated_strategies_enabled(false) do
expect {
Trebuchet.aim('percentage', :percent_deprecated, 5)
}.to raise_error(/deprecated/)
end
end
it "should not launch to unsaved users, users with no IDs" do
Trebuchet.aim('percentage', :percent_deprecated, 5)
Trebuchet.new(nil).launch?('percentage').should be_false
Trebuchet.new(User.new(nil)).launch?('percentage').should be_false
end
def should_only_launch_to_a_percentage_of_users(feature_name)
Trebuchet.aim('percentage', feature_name, 5)
offset = Trebuchet.feature('percentage').strategy.offset
should_launch 'percentage', offset_ids([0, 1, 2, 3, 4, 100, 101, 102, 103, 104], offset)
should_not_launch('percentage', [5, 6, 105, 106].map{|i| i - offset})
end
it "should only launch to a percentage of users" do
should_only_launch_to_a_percentage_of_users(:percent_deprecated)
end
it "should not yank the feature from users when percentage is increased" do
Trebuchet.aim('percentage', :percent_deprecated, 2)
offset = Trebuchet.feature('percentage').strategy.offset
should_launch 'percentage', offset_ids([0, 1], offset)
should_not_launch 'percentage', offset_ids([2, 3], offset)
Trebuchet.aim('percentage', :percent_deprecated, 4)
should_launch 'percentage', offset_ids([0, 1, 2, 3], offset)
end
it "should create an offset based on the feature name" do
Trebuchet.aim('percentage', :percent_deprecated, 1)
offset = Trebuchet.feature('percentage').strategy.offset
user_id = offset_ids([0], offset).first
should_launch 'percentage', user_id
should_not_launch 'percentage', [user_id - 1]
should_not_launch 'percentage', [user_id + 1]
end
it "should always return booleans" do
Trebuchet.feature('percentage').aim(:percent_deprecated, 1)
offset = Trebuchet.feature('percentage').strategy.offset
should_launch 'percentage', offset_ids([0], offset)
t = Trebuchet.new User.new offset_ids([0], offset).first
t.launch?('percentage').should === true
t = Trebuchet.new User.new(0 - (offset - 1))
t.launch?('percentage').should === false
end
def offset_ids(ids, offset)
ids.to_a.map {|id| id + offset % 100}
end
it "should launch to the right users with percentage" do
Trebuchet.feature('freedom').aim(:percent_deprecated, :percentage => 50)
offset = Trebuchet.feature('freedom').strategy.offset
should_launch 'freedom', offset_ids(0..49, offset)
should_not_launch 'freedom', offset_ids(50..99, offset)
should_launch 'freedom', offset_ids(100..149, offset)
should_not_launch 'freedom', offset_ids(150..199, offset)
should_launch'freedom', offset_ids([200, 201], offset)
should_not_launch 'freedom', offset_ids([250, 251], offset)
end
it "should launch to the right users with wrap" do
Trebuchet.feature('freedom').aim(:percent_deprecated, :from => 99, :to => 1)
should_launch 'freedom', [0, 1, 99, 100, 101, 199, 200, 201, 299]
should_not_launch 'freedom', [97, 98, 102, 103, 198, 202, 298, 302]
end
it "should launch to 1%" do
Trebuchet.feature('freedom').aim(:percent_deprecated, :percentage => 1)
offset = Trebuchet.feature('freedom').strategy.offset
should_launch 'freedom', offset_ids([100, 200, 300, 0], offset)
should_not_launch 'freedom', offset_ids([1, 99, 101, 198, 202, 350], offset)
end
it "should handle 0%" do
Trebuchet.feature('freedom').aim(:percent_deprecated, 0)
should_not_launch 'freedom', (1..200).to_a
end
it "should handle garbage arguments" do
ids = (1..200).to_a
Trebuchet.feature('freedom').aim(:percent_deprecated, -1)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, -150)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, 'h')
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, 'h')
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, [5, 10])
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, 20..50)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, nil)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, :from => 7)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent_deprecated, :to => 1)
should_not_launch 'freedom', ids
end
it "works with from/to > 100" do
# just documenting this side-effect
Trebuchet.feature('freedom').aim(:percent_deprecated, :from => 105, :to => 210)
should_launch 'freedom', [5, 106, 207, 308, 409, 1210]
should_not_launch 'freedom', [4, 99, 111, 150, 215]
end
end
================================================
FILE: spec/percent_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::Percent do
it "should not launch to unsaved users, users with no IDs" do
Trebuchet.aim('percentage', :percent, 5)
Trebuchet.new(nil).launch?('percentage').should be_false
Trebuchet.new(User.new(nil)).launch?('percentage').should be_false
end
it "should only launch to a percentage of users" do
Trebuchet.aim('percentage', :percent, 5)
should_launch 'percentage', [7, 24, 74, 75]
should_not_launch('percentage', [47, 48, 49, 76, 78])
end
it "should launch to the correct percentage of users" do
Trebuchet.aim('percentage', :percent, 5)
map = Hash.new {|a,k| a[k] = 0 }
n = 20000
n.times do |i|
map[Trebuchet.new(User.new(i)).launch?('percentage')] += 1
end
map.each {|k, v|
map[k] = v / n.to_f
}
# If you run this to larger N it approaches .95 and 0.05. This is
# hash function dependent but it's a nice santity check.
map[false].should be_within(0.02).of(0.95)
map[true].should be_within(0.02).of(0.05)
end
it "should not yank the feature from users when percentage is increased" do
Trebuchet.aim('percentage', :percent, 2)
should_launch 'percentage', [7, 74]
should_not_launch 'percentage', [24, 147]
Trebuchet.aim('percentage', :percent, 4)
should_launch 'percentage', [7, 74, 24, 147]
end
it "should distribute launches based on the feature name" do
Trebuchet.aim('percentage1', :percent, 1)
Trebuchet.aim('percentage2', :percent, 1)
should_launch 'percentage1', [9, 145, 186]
should_not_launch 'percentage2', [9, 145, 186]
should_launch 'percentage2', [21, 47]
should_not_launch 'percentage1', [21, 47]
end
it "should always return booleans" do
Trebuchet.aim('percentage1', :percent, 1)
t = Trebuchet.new(User.new(9))
t.launch?('percentage1').should === true
t = Trebuchet.new(User.new(21))
t.launch?('percentage1').should === false
end
it "should handle 0%" do
Trebuchet.feature('freedom').aim(:percent, 0)
should_not_launch 'freedom', (1..200).to_a
end
it "should handle garbage arguments" do
ids = (1..200).to_a
Trebuchet.feature('freedom').aim(:percent, -1)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, -150)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, 'h')
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, 'h')
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, [5, 10])
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, 20..50)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, nil)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, :from => 7)
should_not_launch 'freedom', ids
Trebuchet.feature('freedom').aim(:percent, :to => 1)
should_not_launch 'freedom', ids
end
end
================================================
FILE: spec/redis_backend_spec.rb
================================================
require 'spec_helper'
require 'mock_redis'
require 'trebuchet/backend/redis'
describe Trebuchet::Backend::Redis do
before(:all) do
@backend = Trebuchet.backend
end
before(:each) do
Trebuchet.set_backend :disabled
end
it "should set backend to redis with defaults" do
Trebuchet.backend = :redis
Trebuchet.backend.should be_a(Trebuchet::Backend::Redis)
Trebuchet.set_backend :redis
Trebuchet.backend.should be_a(Trebuchet::Backend::Redis)
end
it "should set redis client if passed in" do
r = Redis.new
Redis.stub!(:new).and_return(nil)
lambda {Trebuchet.set_backend :redis}.should raise_error Trebuchet::BackendInitializationError
Trebuchet.backend.should be_a(Trebuchet::Backend::Disabled)
Trebuchet.backend.instance_variable_get(:@redis).should be_nil
Trebuchet.set_backend :redis, :client => r
Trebuchet.backend.instance_variable_get(:@redis).should eql r
end
it "should pass arguments to Redis.new" do
r = Redis.new
Redis.should_receive(:new).with(:this => 'that', :other => true).and_return(r)
Trebuchet.set_backend :redis, :this => 'that', :other => true
args = [:seven, {8 => 'nine'}]
Redis.should_receive(:new).with(*args).and_return(r)
Trebuchet.set_backend :redis, *args
end
after(:all) do
# cleanup
Trebuchet.backend = @backend
end
end
================================================
FILE: spec/redis_hammerspaced_spec.rb
================================================
require 'spec_helper'
require 'mock_redis'
require 'trebuchet/backend/redis_hammerspaced'
describe Trebuchet::Backend::RedisHammerspaced do
before(:all) do
@backend = Trebuchet.backend
end
before(:each) do
Trebuchet.set_backend :disabled
end
it "should properly get empty feature names and strategies" do
r = Redis.new
Redis.stub!(:new).and_return(nil)
Trebuchet.backend.should be_a(Trebuchet::Backend::Disabled)
Trebuchet.backend.instance_variable_get(:@redis).should be_nil
Trebuchet.set_backend :redis_hammerspaced,
:client => r,
:hammerspace => {},
:skip_check => true
Trebuchet.backend.get_feature_names.should eq []
Trebuchet.backend.get_strategy("adfd").should be_nil
end
it "should properly get feature name list and non-empty strategies" do
r = Redis.new
Redis.stub!(:new).and_return(nil)
Trebuchet.backend.should be_a(Trebuchet::Backend::Disabled)
Trebuchet.backend.instance_variable_get(:@redis).should be_nil
Trebuchet.set_backend :redis_hammerspaced,
:client => r,
:hammerspace => {
"trebuchet/feature-names" => ["foo", "bar"].to_s, # stringified array
"trebuchet/features/foo" => {
"everyone" => [nil],
"users" => [[1, 2, 3]],
}.to_json, # stringified json
},
:skip_check => true
Trebuchet.backend.get_feature_names.should eq ["foo", "bar"]
Trebuchet.backend.get_strategy("foo").should eq [:everyone, nil, :users, [1, 2, 3]]
end
it "should properly invalidate local cache" do
r = Redis.new
Redis.stub!(:new).and_return(nil)
hammerspace = {
"trebuchet/feature-names" => ["foo", "bar"].to_s,
"trebuchet/features/foo" => {
"everyone" => [nil],
"users" => [[1, 2, 3]],
}.to_json
}
def hammerspace.uid
@uid ||= Time.now().to_i
@uid = @uid + 1
end
def hammerspace.close
end
Trebuchet.set_backend :redis_hammerspaced,
:client => r,
:hammerspace => hammerspace,
:skip_check => true
# Force to load strategy to local cache
Trebuchet.backend.get_strategy("foo")
hammerspace["trebuchet/features/foo"] = {
"everyone" => [nil],
"users" => [[1, 2]],
}.to_json
Trebuchet.backend.get_strategy("foo").should eq [:everyone, nil, :users, [1, 2, 3]]
# after refresh we should have the up-to-date strategy
Trebuchet.backend.refresh
Trebuchet.backend.get_strategy("foo").should eq [:everyone, nil, :users, [1, 2]]
end
it "should properly generate hammerspace hash" do
r = Redis.new
Redis.stub!(:new).and_return(nil)
Trebuchet.set_backend :redis_hammerspaced,
:client => r,
:hammerspace => {},
:skip_check => true
feature_names = ["foo", "bar"]
features = [
["everyone", "[null]", "user", "[[1,2,3]]"], # foo
["visitor_experiment", "[{\"name\":\"rate\",\"total_buckets\":5,\"bucket\":1}]"] # bar
]
foo_string = {
"everyone" => [nil],
"user" => [[1,2,3]],
}.to_json
bar_string = {
"visitor_experiment" => [
{
"name" => "rate",
"total_buckets" => 5,
"bucket" => 1,
}
],
}.to_json
last_updated = Time.now.to_i
expected_hash = {
"trebuchet/feature-names" => feature_names.to_json,
"trebuchet/features/foo" => foo_string,
"trebuchet/features/bar" => bar_string,
"trebuchet/last_updated" => last_updated.to_s,
}
Trebuchet.backend.generate_hammerspace_hash(
feature_names,
features,
last_updated
).should eq expected_hash
end
after(:all) do
# cleanup
Trebuchet.backend = @backend
end
end
================================================
FILE: spec/spec_helper.rb
================================================
$:.unshift File.dirname(__FILE__) + '/../lib'
require 'bundler'
# Bundler breaks things
Bundler.require :default, :test
require 'mock_redis'
class Redis < MockRedis ; end
require 'trebuchet'
require 'user'
RSpec.configure do |config|
config.around(:each) { |ex|
Trebuchet::Feature.with_deprecated_strategies_enabled(&ex)
}
end
# # uncomment to run specs against Redis backend instead of Memory backend
# require 'redis'
# Trebuchet.set_backend :redis, Redis.new(:host => '127.0.0.1', :port => 6379)
def should_launch(feature, users)
should_or_should_not_launch(feature, users, be_true)
end
def should_not_launch(feature, users)
should_or_should_not_launch(feature, users, be_false)
end
def should_or_should_not_launch(feature, users, be_true_or_false)
Array(users).each do |user_or_user_id|
user = user_or_user_id.is_a?(User) ? user_or_user_id : User.new(user_or_user_id)
Trebuchet.new(user).launch?(feature).should be_true_or_false
end
end
def mock_request(cookie = nil)
mock 'Request', :cookies => {:visitor => cookie}
end
================================================
FILE: spec/stubbing_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Feature::Stubbing do
before do
Trebuchet.dismantle_stubs
end
describe '#stub' do
it "should stub a feature as launched" do
Trebuchet.feature('test').stub(:launched)
should_launch('test', [0])
end
it "should stub a feature as not launched" do
Trebuchet.feature('test').stub(:not_launched)
should_not_launch('test', [0])
end
it "should employ the stub strategy for features stubbed launched" do
Trebuchet.feature('test').stub(:launched)
Trebuchet.feature('test').strategy.is_a?(Trebuchet::Strategy::Stub)
end
it "should employ the stub strategy for features stubbed not launched" do
Trebuchet.feature('test').stub(:not_launched)
Trebuchet.feature('test').strategy.is_a?(Trebuchet::Strategy::Stub)
end
end
describe '#stubbed?' do
it "should report when features are stubbed launched" do
Trebuchet.feature('test').stub(:launched)
Trebuchet.feature('test').should be_stubbed
end
it "should report when features are stubbed not launched" do
Trebuchet.feature('test').stub(:not_launched)
Trebuchet.feature('test').should be_stubbed
end
end
describe '#dismantle_stubs' do
it "should reset stubbed features" do
Trebuchet.feature('test').stub(:launched)
Trebuchet.dismantle_stubs
Trebuchet::Feature.stubbed_features.should be_empty
end
it "should restore them to their default state" do
Trebuchet.feature('test').stub(:launched)
Trebuchet.dismantle_stubs
should_not_launch('test', [0])
end
end
describe '#stubbed_features' do
it "should report when feature are stubbed" do
Trebuchet.feature('test').stub(:launched)
Trebuchet::Feature.stubbed_features.count.should == 1
end
it "should report when features are stubbed launched" do
Trebuchet.feature('test').stub(:launched)
Trebuchet::Feature.stubbed_features['test'].should == :launched
end
it "should report when features are stubbed not launched" do
Trebuchet.feature('test').stub(:not_launched)
Trebuchet::Feature.stubbed_features['test'].should == :not_launched
end
end
end
================================================
FILE: spec/trebuchet_spec.rb
================================================
require 'spec_helper'
describe Trebuchet do
describe "launch?" do
it "should call launch_at? on feature" do
Trebuchet::Feature.any_instance.should_receive(:launch_at?).once
Trebuchet.new(User.new(1)).launch?('highly_experimental')
end
it "should call launch_at? on feature even if missing user" do
Trebuchet::Feature.any_instance.should_receive(:launch_at?).once
Trebuchet.new(nil).launch?('highly_experimental')
end
it "caches value of launch_at?" do
t = Trebuchet.new(nil)
Trebuchet.feature('highly_experimental').should_receive(:launch_at?).once
Trebuchet.feature('waste_of_time').should_receive(:launch_at?).once
t.launch?('highly_experimental')
t.launch?('waste_of_time')
t.launch?('highly_experimental')
t.launch?('waste_of_time')
end
end
describe "launch" do
it "should execute a block" do
times = 0
Trebuchet.aim('highly_experimental', :users, [1,2])
(Trebuchet.new(User.new(1)).launch('highly_experimental') { times += 1 }).should be_true
(Trebuchet.new(User.new(3)).launch('highly_experimental') { times += 1 }).should be_false
times.should == 1
end
it "should not blow up if block is missing" do
lambda do
Trebuchet.aim('highly_experimental', :users, [1,2])
Trebuchet.new(User.new(1)).launch('highly_experimental').should be_true
Trebuchet.new(User.new(3)).launch('highly_experimental').should be_false
Trebuchet.new(nil).launch('highly_experimental').should be_false
end.should_not raise_error(LocalJumpError)
end
end
describe "logging" do
before(:all) do
Trebuchet.aim('highly_experimental', :users, [1,2])
Trebuchet.aim('disused', :disabled)
end
before(:each) do
Trebuchet.initialize_logs
end
it "should log" do
Trebuchet.logs.should == {}
Trebuchet.new(User.new(1)).launch?('highly_experimental').should == true
Trebuchet.logs['highly_experimental'].should == true
end
it "should log false/nil" do
Trebuchet.logs['complely_fabricated'] == nil
Trebuchet.logs['disused'].should == nil
Trebuchet.new(User.new(1)).launch?('disused') #.should == false
Trebuchet.logs['disused'].should == false
end
it "it should clear logs" do
Trebuchet.new(User.new(1)).launch?('highly_experimental').should == true
Trebuchet.logs['highly_experimental'].should == true
Trebuchet.initialize_logs
Trebuchet.logs.should == {}
end
it "should log from multiple trebuchet instances" do
Trebuchet.new(User.new(1)).launch?('highly_experimental') #.should == true
Trebuchet.new(User.new(1)).launch?('disused') #.should == false
Trebuchet.new(nil).launch?('waste_of_time') #.should == false
Trebuchet.logs['highly_experimental'].should == true
Trebuchet.logs['disused'].should == false
Trebuchet.logs['waste_of_time'].should == false
end
end
describe "exception handling" do
before :all do
class BoomError < StandardError ; end
Trebuchet.define_strategy(:boom) do
raise BoomError.new "BOOM!"
end
Trebuchet.aim('optimism', :boom)
end
it "should swallow exceptions if no exception_handler defined" do
expect { Trebuchet.current.launch?("optimism") }.to_not raise_exception
end
it "should invoke exception_handler if defined" do
@feature = nil
@exception = nil
Trebuchet.exception_handler = lambda { |e, f, t| @exception = e; @feature = f }
Trebuchet.current.launch?("optimism")
@exception.should_not be_nil
@feature.should == 'optimism'
end
it "should allow exception_handler to raise the exception" do
Trebuchet.exception_handler = lambda { |e, f, t| raise e } # useful in development
expect { Trebuchet.current.launch?("optimism") }.to raise_exception(BoomError)
end
it "should accept 0 to 3 arguments" do
Trebuchet.exception_handler = lambda { @last_arg = eval local_variables.last.to_s }
Trebuchet.current.launch?("optimism")
@last_arg.should == nil
Trebuchet.exception_handler = lambda { |e| @last_arg = eval local_variables.last.to_s }
Trebuchet.current.launch?("optimism")
@last_arg.should be_a(StandardError)
Trebuchet.exception_handler = lambda { |e, f| @last_arg = eval local_variables.last.to_s }
Trebuchet.current.launch?("optimism")
@last_arg.should == "optimism"
Trebuchet.exception_handler = lambda { |e, f, t| @last_arg = eval local_variables.last.to_s }
Trebuchet.current.launch?("optimism")
@last_arg.should be_a(Trebuchet)
Trebuchet.exception_handler = lambda { |*args| @args = args }
Trebuchet.current.launch?("optimism")
@args.size.should == 3
@args.last.should be_a(Trebuchet)
end
it "should not blow up if exception_handler is not a proc" do
Trebuchet.exception_handler = "one of my shoes"
expect { Trebuchet.current.launch?("optimism") }.to_not raise_exception
Trebuchet.exception_handler = false
expect { Trebuchet.current.launch?("optimism") }.to_not raise_exception
Trebuchet.exception_handler = true
expect { Trebuchet.current.launch?("optimism") }.to_not raise_exception
Trebuchet.exception_handler = Trebuchet.current
expect { Trebuchet.current.launch?("optimism") }.to_not raise_exception
end
end
it "stores state variables" do
%i{author admin_view admin_edit time_zone}.each do |key|
begin
Trebuchet.public_send :"#{key}=", 1
expect(Trebuchet.public_send(key)).to eq 1
ensure
Trebuchet.public_send :"#{key}=", nil
end
end
end
end
================================================
FILE: spec/user.rb
================================================
class User < Struct.new(:id, :role)
def has_role?(role)
self.role == role
end
end
================================================
FILE: spec/user_id_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::UserId do
it "should only launch to designated users" do
Trebuchet.aim('highly_experimental', :users, [1, 2])
yes = [1, 2]
no = [3, 4]
yes.each do |n|
Trebuchet.new(User.new(n)).launch?('highly_experimental').should be_true
end
no.each do |n|
Trebuchet.new(User.new(n)).launch?('highly_experimental').should be_false
end
end
it "should always return booleans" do
Trebuchet.feature('time_machine').aim(:users, [1])
t = Trebuchet.new User.new(1)
t.launch?('time_machine').should === true
t = Trebuchet.new User.new(117)
t.launch?('time_machine').should === false
end
# this behavior should be deprecated -- causes problems with augment
it "should not break if one id is passed instead of an array" do
Trebuchet.feature('time_machine').aim(:users, 1)
t = Trebuchet.new User.new(1)
t.launch?('time_machine').should === true
end
it "should not break on missing user" do
Trebuchet.feature("the chosen ones").aim(:users, [1,2,3])
t = Trebuchet.new User.new(nil)
t.launch?("the chosen ones").should be_false
t = Trebuchet.new nil
t.launch?("the chosen ones").should be_false
end
end
================================================
FILE: spec/visitor_experiment_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::VisitorExperiment do
before do
@feature_name = "Infrared Vision"
@feature_name2 = "Alligator Tail"
@experiment_name = "Superhumanity"
@user = User.new(0)
@mock_request = mock_request('abcdef')
@trebuchet = Trebuchet.new(@user, @mock_request)
end
it "should match a user in a bucket" do
Trebuchet.aim(@feature_name, :visitor_experiment, :name => @experiment_name, :bucket => 1)
should_not_launch(@feature_name, (1..50).to_a) # should never launch without a request
# these values just happen to hash for the algorithm and experiment name
positive = [5, 14, 15, 198, 200, 549]
negative = [1, 2, 25, 550]
positive.each do |i|
Trebuchet.visitor_id = i
Trebuchet.feature(@feature_name).launch_at?(@user, @mock_request).should be_true
end
negative.each do |i|
Trebuchet.visitor_id = i
Trebuchet.feature(@feature_name).launch_at?(@user, @mock_request).should be_false
end
end
it "should launch nil request to no bucket" do
Trebuchet.aim(@feature_name, :visitor_experiment, :name => @experiment_name, :total_buckets => 2, :bucket => 1)
Trebuchet.aim(@feature_name2, :visitor_experiment, :name => @experiment_name, :total_buckets => 2, :bucket => 2)
Trebuchet.visitor_id = nil
@trebuchet.launch?(@feature_name).should be_false
@trebuchet.launch?(@feature_name2).should be_false
end
end
================================================
FILE: spec/visitor_percent_deprecated_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::VisitorPercentDeprecated do
it "should be deprecated" do
Trebuchet::Feature.with_deprecated_strategies_enabled(false) do
expect {
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 5)
}.to raise_error(/deprecated/)
end
end
it "should not break if no visitor id is set" do
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
t = Trebuchet.new(User.new(0))
t.launch?('some_feature').should == false
end
it "should require a request" do
Trebuchet.visitor_id = 1
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
t = Trebuchet.new(User.new(1), nil)
t.launch?('some_feature').should == false
end
it "should not require a user" do
Trebuchet.visitor_id = 1
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
t = Trebuchet.new(nil, mock_request('12345'))
t.launch?('some_feature').should == true
end
describe 'visitor id integer' do
before do
Trebuchet.visitor_id = 123
end
def should_launch_test(feature_name)
# offset of some_feature is 33
Trebuchet.aim('some_feature', feature_name, 100)
offset = Trebuchet.feature('some_feature').strategy.offset
user = User.new(0)
request = mock_request('12345')
Trebuchet.feature('some_feature').launch_at?(user, request).should == true
visitor_id = Trebuchet.visitor_id.call
Trebuchet.aim('some_feature', feature_name, 91) # 33 + 91 includes 123 % 100
Trebuchet.feature('some_feature').launch_at?(user, request).should == true
Trebuchet.aim('some_feature', feature_name, 90)
Trebuchet.feature('some_feature').launch_at?(user, request).should == false
end
it 'should launch' do
should_launch_test(:visitor_percent_deprecated)
end
end
describe 'visitor id proc' do
before do
Trebuchet.visitor_id = proc { |request| request && request.cookies[:visitor] && request.cookies[:visitor].hash }
end
it 'should not launch if no request is present' do
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
should_not_launch('some_feature', [1000])
end
it 'should launch to a valid session' do
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
t = Trebuchet.new(User.new(0), mock_request('abcdef'))
t.launch?('some_feature').should == true
end
it 'should not launch to a nil session ID' do
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
t = Trebuchet.new(User.new(0), mock_request(nil))
t.launch?('some_feature').should == false
end
end
describe 'visitor id invalid' do
it "should handle nil" do
Trebuchet.visitor_id = nil
Trebuchet.aim('some_feature', :visitor_percent_deprecated, 100)
t = Trebuchet.new(User.new(0), mock_request('abcdef'))
t.launch?('some_feature').should == false
end
end
describe 'percentable' do
before do
@feature = Trebuchet.feature("liberty")
@user = User.new(0)
@request = mock_request('abcdef')
@trebuchet = Trebuchet.new(@user, @request)
end
it "should use from and to" do
@feature.aim(:visitor_percent_deprecated, :from => 5, :to => 10)
Trebuchet.feature("liberty").strategy.offset.should == 0
Trebuchet.visitor_id = 10
Trebuchet.feature("liberty").launch_at?(@user, @request).should == true
Trebuchet.visitor_id = 5
Trebuchet.feature("liberty").launch_at?(@user, @request).should == true
Trebuchet.visitor_id = 4
Trebuchet.feature("liberty").launch_at?(@user, @request).should == false
Trebuchet.visitor_id = 11
Trebuchet.feature("liberty").launch_at?(@user, @request).should == false
end
it "should use a percentage" do
@feature.aim(:visitor_percent_deprecated, :percentage => 25)
offset = @feature.strategy.offset
offset.should == 90
Trebuchet.visitor_id = 24 + offset
Trebuchet.feature("liberty").launch_at?(@user, @request).should == true
Trebuchet.visitor_id = 0 + offset
Trebuchet.feature("liberty").launch_at?(@user, @request).should == true
Trebuchet.visitor_id = 5 + offset
Trebuchet.feature("liberty").launch_at?(@user, @request).should == true
Trebuchet.visitor_id = 25 + offset
Trebuchet.feature("liberty").launch_at?(@user, @request).should == false
end
end
end
================================================
FILE: spec/visitor_percent_strategy_spec.rb
================================================
require 'spec_helper'
describe Trebuchet::Strategy::VisitorPercent do
it "should not break if no visitor id is set" do
Trebuchet.aim('some_feature', :visitor_percent, 100)
t = Trebuchet.new(User.new(0))
t.launch?('some_feature').should == false
end
it "should require a request" do
Trebuchet.visitor_id = 1
Trebuchet.aim('some_feature', :visitor_percent, 100)
t = Trebuchet.new(User.new(1), nil)
t.launch?('some_feature').should == false
end
it "should not require a user" do
Trebuchet.visitor_id = 1
Trebuchet.aim('some_feature', :visitor_percent, 100)
t = Trebuchet.new(nil, mock_request('12345'))
t.launch?('some_feature').should == true
end
describe 'visitor id integer' do
before do
Trebuchet.visitor_id = 123
end
def should_launch_test(feature_name)
end
it 'should launch' do
Trebuchet.aim('some_feature', :visitor_percent, 100)
user = User.new(0)
request = mock_request('12345')
Trebuchet.feature('some_feature').launch_at?(user, request).should == true
visitor_id = Trebuchet.visitor_id.call
Trebuchet.aim('some_feature', :visitor_percent, 91)
Trebuchet.feature('some_feature').launch_at?(user, request).should == true
Trebuchet.aim('some_feature', :visitor_percent, 10)
Trebuchet.feature('some_feature').launch_at?(user, request).should == false
end
end
describe 'visitor id proc' do
before do
Trebuchet.visitor_id = proc { |request| request && request.cookies[:visitor] && request.cookies[:visitor].hash }
end
it 'should not launch if no request is present' do
Trebuchet.aim('some_feature', :visitor_percent, 100)
should_not_launch('some_feature', [1000])
end
it 'should launch to a valid session' do
Trebuchet.aim('some_feature', :visitor_percent, 100)
t = Trebuchet.new(User.new(0), mock_request('abcdef'))
t.launch?('some_feature').should == true
end
it 'should not launch to a nil session ID' do
Trebuchet.aim('some_feature', :visitor_percent, 100)
t = Trebuchet.new(User.new(0), mock_request(nil))
t.launch?('some_feature').should == false
end
end
describe 'visitor id invalid' do
it "should handle nil" do
Trebuchet.visitor_id = nil
Trebuchet.aim('some_feature', :visitor_percent, 100)
t = Trebuchet.new(User.new(0), mock_request('abcdef'))
t.launch?('some_feature').should == false
end
end
end
================================================
FILE: trebuchet.gemspec
================================================
# -*- encoding: utf-8 -*-
$:.push File.expand_path("../lib", __FILE__)
require 'trebuchet'
Gem::Specification.new do |s|
s.name = "trebuchet"
s.version = Trebuchet::VERSION
s.platform = Gem::Platform::RUBY
s.authors = ["Justin Jones", "Tobi Knaup", "Ross Allen"]
s.email = ["justin@airbnb.com"]
s.homepage = "http://www.airbnb.com"
s.summary = %q{Trebuchet launches features at people}
s.description = %q{Wisely choose a strategy, aim, and launch!}
s.files = `git ls-files`.split("\n")
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
s.require_paths = ["lib"]
# redis and memcache are optional
s.add_dependency 'json'
s.add_development_dependency 'rspec', '~> 2.12.0'
s.add_development_dependency 'mock_redis', '~> 0.6.5'
s.add_development_dependency 'rake', '>= 10.0.3'
end