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}
" 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

<% @features.each do |feature| %> <%- has_history = feature.history.length > 0 %> <% if has_history %> <% end %> <% if has_history %> <% end %> <% end %>
Feature Name Launch Strategy Strategy History
<%= feature.name %> <%= strategy feature.strategy %> History


<% @dismantled_features.each do |feature| %> <%- has_history = feature.history.length > 0 %> <% if has_history %> <% end %> <% if has_history %> <% end %> <% end %>
Dismantled Features Strategy History
<%= feature.name %> History
================================================ FILE: app/views/trebuchet_rails/features/timeline.html.erb ================================================

Trebuchet Timeline

<% @history.each do |h| %> <% end %>
Timestamp Feature Name Launch Strategy
<%= @zone.at(h[:timestamp].to_i).strftime("%x %X %Z") %> <%= h[:feature_name] %> <%= strategy h[:strategy] %>
================================================ 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