determinator 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/Guardfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +34 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/determinator.gemspec +31 -0
- data/docs/img/determinator.jpg +0 -0
- data/examples/determinator-rails/.env +7 -0
- data/examples/determinator-rails/.gitignore +15 -0
- data/examples/determinator-rails/Gemfile +16 -0
- data/examples/determinator-rails/Gemfile.lock +163 -0
- data/examples/determinator-rails/Procfile +2 -0
- data/examples/determinator-rails/README.md +35 -0
- data/examples/determinator-rails/Rakefile +3 -0
- data/examples/determinator-rails/app/controllers/application_controller.rb +20 -0
- data/examples/determinator-rails/app/controllers/index_controller.rb +9 -0
- data/examples/determinator-rails/app/jobs/application_job.rb +2 -0
- data/examples/determinator-rails/bin/bundle +3 -0
- data/examples/determinator-rails/bin/rails +4 -0
- data/examples/determinator-rails/bin/rake +4 -0
- data/examples/determinator-rails/config.ru +3 -0
- data/examples/determinator-rails/config/application.rb +18 -0
- data/examples/determinator-rails/config/boot.rb +3 -0
- data/examples/determinator-rails/config/environment.rb +2 -0
- data/examples/determinator-rails/config/environments/development.rb +18 -0
- data/examples/determinator-rails/config/environments/production.rb +17 -0
- data/examples/determinator-rails/config/environments/test.rb +13 -0
- data/examples/determinator-rails/config/initializers/determinator.rb +7 -0
- data/examples/determinator-rails/config/initializers/filter_parameter_logging.rb +1 -0
- data/examples/determinator-rails/config/initializers/new_framework_defaults.rb +3 -0
- data/examples/determinator-rails/config/initializers/wrap_parameters.rb +3 -0
- data/examples/determinator-rails/config/puma.rb +5 -0
- data/examples/determinator-rails/config/routes.rb +6 -0
- data/examples/determinator-rails/config/secrets.yml +8 -0
- data/examples/determinator-rails/config/sidekiq.yml +2 -0
- data/examples/determinator-rails/public/favicon.ico +0 -0
- data/examples/determinator-rails/public/robots.txt +5 -0
- data/lib/determinator.rb +16 -0
- data/lib/determinator/actor_control.rb +41 -0
- data/lib/determinator/control.rb +119 -0
- data/lib/determinator/feature.rb +48 -0
- data/lib/determinator/retrieve/routemaster.rb +70 -0
- data/lib/determinator/retrieve/routemaster_indexing_middleware.rb +28 -0
- data/lib/determinator/target_group.rb +28 -0
- data/lib/determinator/version.rb +3 -0
- metadata +193 -0
@@ -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,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,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,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 @@
|
|
1
|
+
Rails.application.config.filter_parameters += [:password]
|
@@ -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"] %>
|
Binary file
|
data/lib/determinator.rb
ADDED
@@ -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
|