determinator 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +5 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +4 -0
  7. data/Guardfile +6 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +34 -0
  10. data/Rakefile +6 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +8 -0
  13. data/determinator.gemspec +31 -0
  14. data/docs/img/determinator.jpg +0 -0
  15. data/examples/determinator-rails/.env +7 -0
  16. data/examples/determinator-rails/.gitignore +15 -0
  17. data/examples/determinator-rails/Gemfile +16 -0
  18. data/examples/determinator-rails/Gemfile.lock +163 -0
  19. data/examples/determinator-rails/Procfile +2 -0
  20. data/examples/determinator-rails/README.md +35 -0
  21. data/examples/determinator-rails/Rakefile +3 -0
  22. data/examples/determinator-rails/app/controllers/application_controller.rb +20 -0
  23. data/examples/determinator-rails/app/controllers/index_controller.rb +9 -0
  24. data/examples/determinator-rails/app/jobs/application_job.rb +2 -0
  25. data/examples/determinator-rails/bin/bundle +3 -0
  26. data/examples/determinator-rails/bin/rails +4 -0
  27. data/examples/determinator-rails/bin/rake +4 -0
  28. data/examples/determinator-rails/config.ru +3 -0
  29. data/examples/determinator-rails/config/application.rb +18 -0
  30. data/examples/determinator-rails/config/boot.rb +3 -0
  31. data/examples/determinator-rails/config/environment.rb +2 -0
  32. data/examples/determinator-rails/config/environments/development.rb +18 -0
  33. data/examples/determinator-rails/config/environments/production.rb +17 -0
  34. data/examples/determinator-rails/config/environments/test.rb +13 -0
  35. data/examples/determinator-rails/config/initializers/determinator.rb +7 -0
  36. data/examples/determinator-rails/config/initializers/filter_parameter_logging.rb +1 -0
  37. data/examples/determinator-rails/config/initializers/new_framework_defaults.rb +3 -0
  38. data/examples/determinator-rails/config/initializers/wrap_parameters.rb +3 -0
  39. data/examples/determinator-rails/config/puma.rb +5 -0
  40. data/examples/determinator-rails/config/routes.rb +6 -0
  41. data/examples/determinator-rails/config/secrets.yml +8 -0
  42. data/examples/determinator-rails/config/sidekiq.yml +2 -0
  43. data/examples/determinator-rails/public/favicon.ico +0 -0
  44. data/examples/determinator-rails/public/robots.txt +5 -0
  45. data/lib/determinator.rb +16 -0
  46. data/lib/determinator/actor_control.rb +41 -0
  47. data/lib/determinator/control.rb +119 -0
  48. data/lib/determinator/feature.rb +48 -0
  49. data/lib/determinator/retrieve/routemaster.rb +70 -0
  50. data/lib/determinator/retrieve/routemaster_indexing_middleware.rb +28 -0
  51. data/lib/determinator/target_group.rb +28 -0
  52. data/lib/determinator/version.rb +3 -0
  53. metadata +193 -0
@@ -0,0 +1,2 @@
1
+ web: bundle exec rails s
2
+ worker: bundle exec sidekiq
@@ -0,0 +1,35 @@
1
+ # Rails and Determinator example
2
+
3
+ This example Rails app has been configured so that Determinator is correctly configured, and (with an instance of the Actor Tracking Service and Routemaster running alongside) it will correctly determine feature rollout and experiment variant selection.
4
+
5
+ ## Points of interest
6
+
7
+ ### `config/initializers/determinator.rb`
8
+
9
+ This file sets up the singleton Determinator instance for the application.
10
+
11
+ ### `config/routes.rb`
12
+
13
+ Using Determinator with Routemaster means that you must expose an endpoint to be informed of changes to Features. Determinator makes it easy to set this up with the `#configure_rails_router` helper method.
14
+
15
+ ### `Procfile`
16
+
17
+ Bear in mind that, because routemaster depends on background workers to populate the cache, Sidekiq (or Resque) must be running alongside the app.
18
+
19
+ ### `app/controllers/index_controller.rb`
20
+
21
+ An example of how Determinator can be used for feature flags.
22
+
23
+ ### `app/controllers/application_controller.rb`
24
+
25
+ An example of how a GUID could be assigned to every visitor to the site. Storing this in the session means it will be reset upon log out.
26
+
27
+ The `determinator` method memoizes the instance of the `ActorControl` helper class for ease of use throughout this request.
28
+
29
+ ### `config/application.rb`
30
+
31
+ Ensure you've required the job runner backend appropriate for your set up. Routemaster Drain currently supports Sidekiq and Resque.
32
+
33
+ ### `config/sidekiq.yml`
34
+
35
+ This example uses Sidekiq as the background processor, ensure you've set it up correctly for notifications to cache in the background.
@@ -0,0 +1,3 @@
1
+ require_relative 'config/application'
2
+
3
+ Rails.application.load_tasks
@@ -0,0 +1,20 @@
1
+ class ApplicationController < ActionController::API
2
+ def current_user
3
+ # DETERMINATOR: This would return a User object in most applications
4
+ # http://guides.rubyonrails.org/action_controller_overview.html#accessing-the-session
5
+ nil
6
+ end
7
+
8
+ def guid
9
+ session[:guid] ||= SecureRandom.uuid
10
+ end
11
+
12
+ def determinator
13
+ # DETERMINATOR: A memoized instance of the ActorControl helper class
14
+ # which allows simple use throughout the app
15
+ @_determinator ||= Determinator.instance.for_actor(
16
+ id: current_user && current_user.id || nil,
17
+ guid: guid
18
+ )
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ class IndexController < ApplicationController
2
+ def show
3
+ if determinator.feature_flag_on?(:colloquial_welcome)
4
+ render json: { welcome: 'hi world' }
5
+ else
6
+ render json: { welcome: 'hello world' }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,2 @@
1
+ class ApplicationJob < ActiveJob::Base
2
+ end
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
3
+ load Gem.bin_path('bundler', 'bundle')
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ APP_PATH = File.expand_path('../config/application', __dir__)
3
+ require_relative '../config/boot'
4
+ require 'rails/commands'
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../config/boot'
3
+ require 'rake'
4
+ Rake.application.run
@@ -0,0 +1,3 @@
1
+ require_relative 'config/environment'
2
+
3
+ run Rails.application
@@ -0,0 +1,18 @@
1
+ require_relative 'boot'
2
+
3
+ require "rails"
4
+ require "active_model/railtie"
5
+ require "active_job/railtie"
6
+ require "action_controller/railtie"
7
+ require "action_view/railtie"
8
+
9
+ # DETERMINATOR: We must explicitly require the routemaster backend we want to use
10
+ require "routemaster/jobs/backends/sidekiq"
11
+
12
+ Bundler.require(*Rails.groups)
13
+
14
+ module DeterminatorExample
15
+ class Application < Rails::Application
16
+ config.api_only = true
17
+ end
18
+ end
@@ -0,0 +1,3 @@
1
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
2
+
3
+ require 'bundler/setup'
@@ -0,0 +1,2 @@
1
+ require_relative 'application'
2
+ Rails.application.initialize!
@@ -0,0 +1,18 @@
1
+ Rails.application.configure do
2
+ config.cache_classes = false
3
+ config.eager_load = false
4
+ config.consider_all_requests_local = true
5
+ if Rails.root.join('tmp/caching-dev.txt').exist?
6
+ config.action_controller.perform_caching = true
7
+
8
+ config.cache_store = :memory_store
9
+ config.public_file_server.headers = {
10
+ 'Cache-Control' => 'public, max-age=172800'
11
+ }
12
+ else
13
+ config.action_controller.perform_caching = false
14
+
15
+ config.cache_store = :null_store
16
+ end
17
+ config.active_support.deprecation = :log
18
+ end
@@ -0,0 +1,17 @@
1
+ Rails.application.configure do
2
+ config.cache_classes = true
3
+ config.eager_load = true
4
+ config.consider_all_requests_local = false
5
+ config.action_controller.perform_caching = true
6
+ config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
7
+ config.log_level = :debug
8
+ config.log_tags = [ :request_id ]
9
+ config.i18n.fallbacks = true
10
+ config.active_support.deprecation = :notify
11
+ config.log_formatter = ::Logger::Formatter.new
12
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
13
+ logger = ActiveSupport::Logger.new(STDOUT)
14
+ logger.formatter = config.log_formatter
15
+ config.logger = ActiveSupport::TaggedLogging.new(logger)
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ Rails.application.configure do
2
+ config.cache_classes = true
3
+ config.eager_load = false
4
+ config.public_file_server.enabled = true
5
+ config.public_file_server.headers = {
6
+ 'Cache-Control' => 'public, max-age=3600'
7
+ }
8
+ config.consider_all_requests_local = true
9
+ config.action_controller.perform_caching = false
10
+ config.action_dispatch.show_exceptions = false
11
+ config.action_controller.allow_forgery_protection = false
12
+ config.active_support.deprecation = :stderr
13
+ end
@@ -0,0 +1,7 @@
1
+ require 'determinator/retrieve/routemaster'
2
+
3
+ Determinator.configure(
4
+ retrieval: Determinator::Retrieve::Routemaster.new(
5
+ discovery_url: 'https://florence.dev/'
6
+ )
7
+ )
@@ -0,0 +1 @@
1
+ Rails.application.config.filter_parameters += [:password]
@@ -0,0 +1,3 @@
1
+ ActiveSupport.to_time_preserves_timezone = true
2
+ ActiveSupport.halt_callback_chains_on_return_false = false
3
+ Rails.application.config.ssl_options = { hsts: { subdomains: true } }
@@ -0,0 +1,3 @@
1
+ ActiveSupport.on_load(:action_controller) do
2
+ wrap_parameters format: [:json]
3
+ end
@@ -0,0 +1,5 @@
1
+ threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i
2
+ threads threads_count, threads_count
3
+ port ENV.fetch("PORT") { 3000 }
4
+ environment ENV.fetch("RAILS_ENV") { "development" }
5
+ plugin :tmp_restart
@@ -0,0 +1,6 @@
1
+ Rails.application.routes.draw do
2
+ root to: 'index#show'
3
+
4
+ # DETERMINATOR: Make sure the routemaster routes are mapped to the containing app.
5
+ Determinator.instance.retrieval.configure_rails_router(self)
6
+ end
@@ -0,0 +1,8 @@
1
+ development:
2
+ secret_key_base: 2e9ca36ffd3a739ffc56c560e81083f8a9d924049406a595c114dd4b709e87de3989cb013df72812c3dabe6e4035288eb6b79355608a34392dfda1cadf1f6690
3
+
4
+ test:
5
+ secret_key_base: 03b771d3fe30c854fea8fa6a122f48c4ea37a73c4fdb7a75139d0879f991fa59c02ea816a0981b6444b1db5403f47982019c6305b86481e7ffb129c706837ec7
6
+
7
+ production:
8
+ secret_key_base: <%= ENV["SECRET_KEY_BASE"] %>
@@ -0,0 +1,2 @@
1
+ :queues:
2
+ - routemaster
@@ -0,0 +1,5 @@
1
+ # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2
+ #
3
+ # To ban all spiders from the entire site uncomment the next two lines:
4
+ # User-agent: *
5
+ # Disallow: /
@@ -0,0 +1,16 @@
1
+ require 'determinator/version'
2
+ require 'determinator/control'
3
+ require 'determinator/feature'
4
+ require 'determinator/target_group'
5
+ require 'determinator/retrieve/routemaster'
6
+
7
+ module Determinator
8
+ def self.configure(retrieval:)
9
+ @instance = Control.new(retrieval: retrieval)
10
+ end
11
+
12
+ def self.instance
13
+ raise "No singleton Determinator instance defined" unless @instance
14
+ @instance
15
+ end
16
+ end
@@ -0,0 +1,41 @@
1
+ module Determinator
2
+ # A decorator to provide syntactic sugar for Determinator::Control.
3
+ # Useful for contexts where the actor remains constant (eg. inside
4
+ # the request cycle in a webapp)
5
+ class ActorControl
6
+ attr_reader :id, :guid, :default_constraints
7
+
8
+ def initialize(controller, id: nil, guid: nil, default_constraints: {})
9
+ @id = id
10
+ @guid = guid
11
+ @default_constraints = default_constraints
12
+ @controller = controller
13
+ end
14
+
15
+ def which_variant(name, constraints: {})
16
+ controller.which_variant(
17
+ name,
18
+ id: id,
19
+ guid: guid,
20
+ constraints: default_constraints.merge(constraints)
21
+ )
22
+ end
23
+
24
+ def feature_flag_on?(name, constraints: {})
25
+ controller.feature_flag_on?(
26
+ name,
27
+ id: id,
28
+ guid: guid,
29
+ constraints: default_constraints.merge(constraints)
30
+ )
31
+ end
32
+
33
+ def inspect
34
+ "#<Determinator::ActorControl id=#{id.inspect} guid=#{guid.inspect}>"
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :controller
40
+ end
41
+ end
@@ -0,0 +1,119 @@
1
+ require 'digest/md5'
2
+ require 'determinator/actor_control'
3
+
4
+ module Determinator
5
+ class Control
6
+ attr_reader :retrieval
7
+
8
+ def initialize(retrieval:)
9
+ @retrieval = retrieval
10
+ end
11
+
12
+ # @return [ActorControl] A helper object removing the need to know id and guid everywhere
13
+ def for_actor(id: nil, guid: nil, default_constraints: {})
14
+ ActorControl.new(self, id: id, guid: guid, default_constraints: default_constraints)
15
+ end
16
+
17
+ # Determines whether a specific feature is on or off for the given actor
18
+ #
19
+ # @return [true,false] Whether the feature is on (true) or off (false) for this actor
20
+ def feature_flag_on?(name, id: nil, guid: nil, constraints: {})
21
+ determinate(name, id: id, guid: guid, constraints: constraints) do |feature|
22
+ feature.feature_flag?
23
+ end
24
+ end
25
+
26
+ # Determines what an actor should see for a specific experiment
27
+ #
28
+ # @return [false,String] Returns false, if the actor is not in this experiment, or otherwise the variant name.
29
+ def which_variant(name, id: nil, guid: nil, constraints: {})
30
+ determinate(name, id: id, guid: guid, constraints: constraints) do |feature|
31
+ feature.experiment?
32
+ end
33
+ end
34
+
35
+ def inspect
36
+ '#<Determinator::Control>'
37
+ end
38
+
39
+ private
40
+
41
+ Indicators = Struct.new(:rollout, :variant)
42
+
43
+ def determinate(name, id:, guid:, constraints:)
44
+ feature = retrieval.retrieve(name)
45
+ return false unless feature
46
+
47
+ # Calling method can place constraints on the feature, eg. experiment only
48
+ return false if block_given? && !yield(feature)
49
+
50
+ # Overrides take precedence
51
+ return feature.override_value_for(id) if feature.overridden_for?(id)
52
+
53
+ target_group = choose_target_group(feature, constraints)
54
+ # Given constraints have excluded this actor from this experiment
55
+ return false unless target_group
56
+
57
+ indicators = indicators_for(feature, id, guid)
58
+ # This actor isn't described in enough detail to form indicators
59
+ return false unless indicators
60
+
61
+ # Actor's indicator has excluded them from the feature
62
+ return false if indicators.rollout >= target_group.rollout
63
+
64
+ # Features don't need variant determination and, at this stage,
65
+ # they have been rolled out to.
66
+ return true unless feature.experiment?
67
+
68
+ variant_for(feature, indicators.variant)
69
+ end
70
+
71
+ def choose_target_group(feature, constraints)
72
+ feature.target_groups.select { |tg|
73
+ tg.constraints.reduce(true) do |fit, (scope, *required)|
74
+ present = [*constraints[scope]]
75
+ fit && (required.flatten & present.flatten).any?
76
+ end
77
+ # Must choose target group deterministically, if more than one match
78
+ }.sort_by { |tg| tg.rollout }.last
79
+ end
80
+
81
+ def indicators_for(feature, id, guid)
82
+ # If we're slicing by guid then we never pay attention to id
83
+ actor_identifier = case feature.bucket_type
84
+ when :id then id
85
+ when :guid then guid
86
+ when :fallback then id || guid
87
+ end
88
+ # No identified means not enough info was given by the caller
89
+ # to determine an outcome for this feature
90
+ return unless actor_identifier
91
+
92
+ # Cryptographic hash (will have random distribution)
93
+ hash = Digest::MD5.new
94
+ hash.update [feature.identifier, actor_identifier].map(&:to_s).join(',')
95
+
96
+ # Use lowest 16 bits for rollout indicator
97
+ # Use next 16 bits for variant indicator
98
+ rollout, variant = hash.digest.unpack("nn")
99
+
100
+ Indicators.new(rollout, variant)
101
+ end
102
+
103
+ def variant_for(feature, indicator)
104
+ # Scale up the weights so the variants fit within the possible space for the variant indicator
105
+ variant_weight_total = feature.variants.values.reduce(:+)
106
+ scale_factor = 65_535 / variant_weight_total.to_f
107
+
108
+ # Find the variant the indicator sits within
109
+ previous_upper_bound = 0
110
+ feature.variants.each do |name, weight|
111
+ new_upper_bound = previous_upper_bound + scale_factor * weight
112
+ return name if indicator <= new_upper_bound
113
+ previous_upper_bound = new_upper_bound
114
+ end
115
+
116
+ raise ArgumentError, "A variant should have been found by this point, there is a bug in the code."
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,48 @@
1
+ module Determinator
2
+ # A model for an individual feature or experiment
3
+ #
4
+ # @attr_reader [nil,Hash<String,Integer>] variants The variants for this experiment, with the name of the variant as the key and the weight as the value. Will be nil for non-experiments.
5
+ class Feature
6
+ attr_reader :name, :identifier, :bucket_type, :variants, :target_groups
7
+
8
+ BUCKET_TYPES = %i(id guid fallback)
9
+
10
+ def initialize(name:, identifier:, bucket_type:, target_groups:, variants: {}, overrides: {})
11
+ @name = name.to_s
12
+ @identifier = identifier.to_s
13
+ @variants = variants
14
+ @target_groups = target_groups
15
+
16
+ @bucket_type = bucket_type.to_sym
17
+ raise ArgumentError, "Unknown bucket type: #{bucket_type}" unless BUCKET_TYPES.include?(@bucket_type)
18
+
19
+ # To prevent confusion between actor id data types
20
+ @overrides = Hash[overrides.map { |k, v| [k.to_s, v] }]
21
+ end
22
+
23
+ # @return [true,false] Is this feature an experiment?
24
+ def experiment?
25
+ variants.any?
26
+ end
27
+
28
+ # @return [true,false] Is this feature a feature flag?
29
+ def feature_flag?
30
+ variants.empty?
31
+ end
32
+
33
+ # Is this feature overridden for the given actor id?
34
+ #
35
+ # @return [true,false] Whether this feature is overridden for this actor
36
+ def overridden_for?(id)
37
+ overrides.has_key?(id.to_s)
38
+ end
39
+
40
+ def override_value_for(id)
41
+ overrides[id.to_s]
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :overrides
47
+ end
48
+ end