test_track_rails_client 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +19 -0
- data/README.md +246 -0
- data/Rakefile +30 -0
- data/app/assets/javascripts/testTrack.bundle.min.js +1 -0
- data/app/controllers/concerns/test_track/controller.rb +26 -0
- data/app/controllers/tt/api/v1/application_controller.rb +9 -0
- data/app/controllers/tt/api/v1/assignments_controller.rb +5 -0
- data/app/controllers/tt/api/v1/identifier_types_controller.rb +5 -0
- data/app/controllers/tt/api/v1/identifier_visitors_controller.rb +5 -0
- data/app/controllers/tt/api/v1/identifiers_controller.rb +5 -0
- data/app/controllers/tt/api/v1/resets_controller.rb +12 -0
- data/app/controllers/tt/api/v1/split_configs_controller.rb +9 -0
- data/app/controllers/tt/api/v1/split_registries_controller.rb +5 -0
- data/app/controllers/tt/api/v1/visitors_controller.rb +5 -0
- data/app/helpers/test_track/application_helper.rb +6 -0
- data/app/models/concerns/test_track/identity.rb +53 -0
- data/app/models/concerns/test_track/remote_model.rb +14 -0
- data/app/models/concerns/test_track/required_options.rb +11 -0
- data/app/models/test_track/ab_configuration.rb +53 -0
- data/app/models/test_track/analytics/mixpanel_client.rb +25 -0
- data/app/models/test_track/analytics/safe_wrapper.rb +43 -0
- data/app/models/test_track/assignment.rb +29 -0
- data/app/models/test_track/config_updater.rb +99 -0
- data/app/models/test_track/create_alias_job.rb +18 -0
- data/app/models/test_track/fake/split_registry.rb +36 -0
- data/app/models/test_track/fake/visitor.rb +41 -0
- data/app/models/test_track/fake_server.rb +24 -0
- data/app/models/test_track/identity_session_discriminator.rb +34 -0
- data/app/models/test_track/notify_assignment_job.rb +31 -0
- data/app/models/test_track/offline_session.rb +46 -0
- data/app/models/test_track/remote/assignment.rb +20 -0
- data/app/models/test_track/remote/assignment_event.rb +15 -0
- data/app/models/test_track/remote/fake_server.rb +8 -0
- data/app/models/test_track/remote/identifier.rb +26 -0
- data/app/models/test_track/remote/identifier_type.rb +13 -0
- data/app/models/test_track/remote/split_config.rb +13 -0
- data/app/models/test_track/remote/split_registry.rb +36 -0
- data/app/models/test_track/remote/visitor.rb +29 -0
- data/app/models/test_track/session.rb +167 -0
- data/app/models/test_track/unsynced_assignments_notifier.rb +36 -0
- data/app/models/test_track/variant_calculator.rb +48 -0
- data/app/models/test_track/vary_dsl.rb +88 -0
- data/app/models/test_track/visitor.rb +129 -0
- data/app/models/test_track/visitor_dsl.rb +11 -0
- data/app/views/tt/api/v1/identifier_visitors/show.json.jbuilder +1 -0
- data/app/views/tt/api/v1/identifiers/create.json.jbuilder +3 -0
- data/app/views/tt/api/v1/split_registries/show.json.jbuilder +3 -0
- data/app/views/tt/api/v1/visitors/_show.json.jbuilder +2 -0
- data/app/views/tt/api/v1/visitors/show.json.jbuilder +1 -0
- data/config/initializers/test_track_api.rb +15 -0
- data/config/routes.rb +28 -0
- data/lib/generators/test_track/migration_generator.rb +39 -0
- data/lib/tasks/pull_in_js_client.rake +7 -0
- data/lib/tasks/test_track_rails_client_tasks.rake +15 -0
- data/lib/tasks/vendor_deps.rake +36 -0
- data/lib/test_track.rb +64 -0
- data/lib/test_track_rails_client.rb +5 -0
- data/lib/test_track_rails_client/assignment_helper.rb +19 -0
- data/lib/test_track_rails_client/engine.rb +14 -0
- data/lib/test_track_rails_client/rspec_helpers.rb +5 -0
- data/lib/test_track_rails_client/version.rb +3 -0
- metadata +345 -0
@@ -0,0 +1,88 @@
|
|
1
|
+
class TestTrack::VaryDSL
|
2
|
+
include TestTrack::RequiredOptions
|
3
|
+
|
4
|
+
attr_reader :defaulted, :default_variant
|
5
|
+
alias defaulted? defaulted
|
6
|
+
|
7
|
+
def initialize(opts = {})
|
8
|
+
@assignment = require_option!(opts, :assignment)
|
9
|
+
@context = require_option!(opts, :context)
|
10
|
+
@split_registry = require_option!(opts, :split_registry, allow_nil: true)
|
11
|
+
raise ArgumentError, "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
12
|
+
raise ArgumentError, "unknown split: #{split_name}" if @split_registry && !split
|
13
|
+
end
|
14
|
+
|
15
|
+
def when(*variants, &block)
|
16
|
+
raise ArgumentError, "must provide at least one variant" unless variants.present?
|
17
|
+
variants.each do |variant|
|
18
|
+
assign_behavior_to_variant(variant, block)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def default(variant, &block)
|
23
|
+
raise ArgumentError, "cannot provide more than one `default`" unless default_variant.nil?
|
24
|
+
@default_variant = assign_behavior_to_variant(variant, block)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
attr_reader :split_registry, :assignment, :context
|
30
|
+
delegate :split_name, to: :assignment
|
31
|
+
|
32
|
+
def split
|
33
|
+
split_registry && split_registry[split_name]
|
34
|
+
end
|
35
|
+
|
36
|
+
def split_variants
|
37
|
+
@split_variants ||= split.keys if split_registry
|
38
|
+
end
|
39
|
+
|
40
|
+
def airbrake_because_vary(msg)
|
41
|
+
Rails.logger.error(msg)
|
42
|
+
Airbrake.notify_or_ignore("vary for \"#{split_name}\" #{msg}")
|
43
|
+
end
|
44
|
+
|
45
|
+
def variant_behaviors
|
46
|
+
@variant_behaviors ||= {}
|
47
|
+
end
|
48
|
+
|
49
|
+
def assign_behavior_to_variant(variant, behavior_proc)
|
50
|
+
variant = variant.to_s
|
51
|
+
|
52
|
+
raise ArgumentError, "must provide block for #{variant}" unless behavior_proc
|
53
|
+
airbrake_because_vary "configures unknown variant \"#{variant}\"" unless variant_acceptable?(variant)
|
54
|
+
|
55
|
+
variant_behaviors[variant] = behavior_proc
|
56
|
+
variant
|
57
|
+
end
|
58
|
+
|
59
|
+
def variant_acceptable?(variant)
|
60
|
+
split_variants ? split_variants.include?(variant) : true # If we're flying blind (with no split registry), assume the dev is correct
|
61
|
+
end
|
62
|
+
|
63
|
+
def default_proc
|
64
|
+
variant_behaviors[default_variant]
|
65
|
+
end
|
66
|
+
|
67
|
+
def run # rubocop:disable Metrics/AbcSize
|
68
|
+
validate!
|
69
|
+
|
70
|
+
if variant_behaviors[assignment.variant]
|
71
|
+
chosen_proc = variant_behaviors[assignment.variant]
|
72
|
+
else
|
73
|
+
chosen_proc = default_proc
|
74
|
+
assignment.variant = default_variant
|
75
|
+
@defaulted = true
|
76
|
+
end
|
77
|
+
assignment.context = context
|
78
|
+
chosen_proc.call
|
79
|
+
end
|
80
|
+
|
81
|
+
def validate!
|
82
|
+
raise ArgumentError, "must provide exactly one `default`" unless default_variant
|
83
|
+
raise ArgumentError, "must provide at least one `when`" unless variant_behaviors.size >= 2
|
84
|
+
return true unless split_variants
|
85
|
+
missing_variants = split_variants - variant_behaviors.keys
|
86
|
+
airbrake_because_vary("does not configure variants #{missing_variants.to_sentence}") && false unless missing_variants.empty?
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
class TestTrack::Visitor
|
2
|
+
include TestTrack::RequiredOptions
|
3
|
+
|
4
|
+
attr_reader :id
|
5
|
+
|
6
|
+
def initialize(opts = {})
|
7
|
+
opts = opts.dup
|
8
|
+
@id = opts.delete(:id)
|
9
|
+
@assignments = opts.delete(:assignments)
|
10
|
+
unless id
|
11
|
+
@id = SecureRandom.uuid
|
12
|
+
@assignments ||= [] # If we're generating a visitor, we don't need to fetch the assignments
|
13
|
+
end
|
14
|
+
raise "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
15
|
+
end
|
16
|
+
|
17
|
+
def vary(split_name, opts = {})
|
18
|
+
opts = opts.dup
|
19
|
+
split_name = split_name.to_s
|
20
|
+
context = require_option!(opts, :context)
|
21
|
+
raise "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
22
|
+
|
23
|
+
raise ArgumentError, "must provide block to `vary` for #{split_name}" unless block_given?
|
24
|
+
v = TestTrack::VaryDSL.new(assignment: assignment_for(split_name), context: context, split_registry: split_registry)
|
25
|
+
yield v
|
26
|
+
v.send :run
|
27
|
+
end
|
28
|
+
|
29
|
+
def ab(split_name, opts = {}) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
30
|
+
opts = opts.dup
|
31
|
+
split_name = split_name.to_s
|
32
|
+
true_variant = opts.delete(:true_variant)
|
33
|
+
context = require_option!(opts, :context)
|
34
|
+
raise "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
35
|
+
|
36
|
+
ab_configuration = TestTrack::ABConfiguration.new split_name: split_name, true_variant: true_variant, split_registry: split_registry
|
37
|
+
|
38
|
+
vary(split_name, context: context) do |v|
|
39
|
+
v.when ab_configuration.variants[:true] do
|
40
|
+
true
|
41
|
+
end
|
42
|
+
v.default ab_configuration.variants[:false] do
|
43
|
+
false
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def assignment_registry
|
49
|
+
@assignment_registry ||= assignments.each_with_object({}) do |assignment, hsh|
|
50
|
+
hsh[assignment.split_name] = assignment
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def unsynced_assignments
|
55
|
+
@unsynced_assignments ||= assignment_registry.values.select(&:unsynced?)
|
56
|
+
end
|
57
|
+
|
58
|
+
def assignment_json
|
59
|
+
assignment_registry.values.each_with_object({}) do |assignment, hsh|
|
60
|
+
hsh[assignment.split_name] = assignment.variant
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def split_registry
|
65
|
+
@split_registry ||= TestTrack::Remote::SplitRegistry.to_hash
|
66
|
+
end
|
67
|
+
|
68
|
+
def link_identifier!(identifier_type, identifier_value)
|
69
|
+
identifier_opts = { identifier_type: identifier_type, visitor_id: id, value: identifier_value.to_s }
|
70
|
+
begin
|
71
|
+
identifier = TestTrack::Remote::Identifier.create!(identifier_opts)
|
72
|
+
merge!(identifier.visitor)
|
73
|
+
rescue *TestTrack::SERVER_ERRORS
|
74
|
+
# If at first you don't succeed, async it - we may not display 100% consistent UX this time,
|
75
|
+
# but subsequent requests will be better off
|
76
|
+
TestTrack::Remote::Identifier.delay.create!(identifier_opts)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.backfill_identity(opts)
|
81
|
+
remote_identifier_visitor = TestTrack::Remote::Visitor.from_identifier(opts[:identifier_type], opts[:identifier_value])
|
82
|
+
visitor = new(
|
83
|
+
id: remote_identifier_visitor.id,
|
84
|
+
assignments: remote_identifier_visitor.assignments
|
85
|
+
)
|
86
|
+
|
87
|
+
TestTrack::CreateAliasJob.new(existing_id: opts[:existing_id], alias_id: visitor.id).perform
|
88
|
+
visitor
|
89
|
+
end
|
90
|
+
|
91
|
+
def offline?
|
92
|
+
@tt_offline
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def assignments
|
98
|
+
@assignments ||= (remote_visitor && remote_visitor.assignments) || []
|
99
|
+
end
|
100
|
+
|
101
|
+
def remote_visitor
|
102
|
+
@remote_visitor ||= TestTrack::Remote::Visitor.find(id) unless tt_offline?
|
103
|
+
rescue *TestTrack::SERVER_ERRORS
|
104
|
+
@tt_offline = true
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
def merge!(other)
|
109
|
+
@id = other.id
|
110
|
+
@assignment_registry = assignment_registry.merge(other.assignment_registry)
|
111
|
+
@unsynced_assignments = nil
|
112
|
+
end
|
113
|
+
|
114
|
+
def tt_offline?
|
115
|
+
@tt_offline || false
|
116
|
+
end
|
117
|
+
|
118
|
+
def assignment_for(split_name)
|
119
|
+
fetch_assignment_for(split_name) || generate_assignment_for(split_name)
|
120
|
+
end
|
121
|
+
|
122
|
+
def fetch_assignment_for(split_name)
|
123
|
+
assignment_registry[split_name] if assignment_registry
|
124
|
+
end
|
125
|
+
|
126
|
+
def generate_assignment_for(split_name)
|
127
|
+
assignment_registry[split_name] = TestTrack::Assignment.new(visitor: self, split_name: split_name)
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
json.partial! 'tt/api/v1/visitors/show', visitor: @visitor
|
@@ -0,0 +1 @@
|
|
1
|
+
json.partial! 'tt/api/v1/visitors/show', visitor: @visitor
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require 'faraday_middleware'
|
2
|
+
|
3
|
+
TestTrack::TestTrackApi = Her::API.new.setup url: ENV['TEST_TRACK_API_URL'] do |c|
|
4
|
+
# request
|
5
|
+
c.request :json
|
6
|
+
|
7
|
+
# response
|
8
|
+
c.use Her::Middleware::DefaultParseJSON
|
9
|
+
|
10
|
+
c.adapter Faraday.default_adapter
|
11
|
+
|
12
|
+
# Set aggressive HTTP timeouts because TestTrack needs to be fast
|
13
|
+
c.options[:open_timeout] = (ENV['TEST_TRACK_OPEN_TIMEOUT'] || 2).to_i # Number of seconds to wait for the connection to open.
|
14
|
+
c.options[:timeout] = (ENV['TEST_TRACK_TIMEOUT'] || 4).to_i # Number of seconds to wait for one block to be read (via one read(2) call).
|
15
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
Rails.application.routes.draw do
|
2
|
+
unless TestTrack.enabled?
|
3
|
+
namespace :tt do
|
4
|
+
namespace :api do
|
5
|
+
namespace :v1 do
|
6
|
+
resource :split_registry, only: :show
|
7
|
+
|
8
|
+
resource :assignment, only: :create
|
9
|
+
|
10
|
+
resource :identifier, only: :create
|
11
|
+
|
12
|
+
resources :visitors, only: :show
|
13
|
+
|
14
|
+
resources :identifier_types, only: [], param: :name do
|
15
|
+
resources :identifiers, only: [], param: :value do
|
16
|
+
resource :visitor, only: :show, controller: 'identifier_visitors'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
resources :split_configs, only: [:create, :destroy]
|
21
|
+
resource :identifier_type, only: :create
|
22
|
+
|
23
|
+
resource :reset, only: :update
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'rails/generators/named_base'
|
2
|
+
|
3
|
+
module TestTrack
|
4
|
+
module Generators
|
5
|
+
class MigrationGenerator < Rails::Generators::NamedBase
|
6
|
+
desc "Creates a test track migration file. Files that start with retire or finish will create migrations that finish a split."
|
7
|
+
|
8
|
+
def create_test_track_migration_file
|
9
|
+
create_file "db/migrate/#{formatted_time_stamp}_#{file_name}.rb", <<-FILE.strip_heredoc
|
10
|
+
class #{file_name.camelize} < ActiveRecord::Migration
|
11
|
+
def change
|
12
|
+
TestTrack.update_config do |c|
|
13
|
+
#{split_command} :#{split_name}
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
FILE
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def formatted_time_stamp
|
23
|
+
Time.zone.now.strftime('%Y%m%d%H%M%S')
|
24
|
+
end
|
25
|
+
|
26
|
+
def split_command
|
27
|
+
@split_command ||= finish_split? ? 'c.finish_split' : 'c.split'
|
28
|
+
end
|
29
|
+
|
30
|
+
def finish_split?
|
31
|
+
file_name.start_with?('retire', 'finish')
|
32
|
+
end
|
33
|
+
|
34
|
+
def split_name
|
35
|
+
file_name.split('_').slice(1, file_name.length).join('_')
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
namespace :test_track do
|
2
|
+
namespace :schema do
|
3
|
+
desc 'Load all Identifier Types and Splits into TestTrack from the schema file'
|
4
|
+
task load: :environment do
|
5
|
+
TestTrack.update_config do |c|
|
6
|
+
c.load_schema
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
unless Rails.env.test?
|
13
|
+
task 'db:schema:load' => ['test_track:schema:load']
|
14
|
+
task 'db:structure:load' => ['test_track:schema:load']
|
15
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
namespace :test_track_rails_client do
|
2
|
+
task :vendor_deps do
|
3
|
+
FileUtils.module_eval do
|
4
|
+
cd "vendor/gems" do
|
5
|
+
rm_r Dir.glob('*')
|
6
|
+
%w(ruby_spec_helpers her fakeable_her).each do |repo|
|
7
|
+
`git clone --depth=1 git@github.com:Betterment/#{repo}.git && rm -rf #{repo}/.git`
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
cd "vendor/gems/ruby_spec_helpers" do
|
12
|
+
rm_r(Dir.glob('.*') - %w(. ..))
|
13
|
+
rm_r Dir.glob('*.md')
|
14
|
+
rm_r %w(
|
15
|
+
Gemfile
|
16
|
+
Gemfile.lock
|
17
|
+
spec
|
18
|
+
), force: true
|
19
|
+
`sed -E -i '' '/license/d' ruby_spec_helpers.gemspec`
|
20
|
+
end
|
21
|
+
|
22
|
+
cd "vendor/gems/fakeable_her" do
|
23
|
+
rm_r(Dir.glob('.*') - %w(. ..))
|
24
|
+
rm_r Dir.glob('*.md')
|
25
|
+
rm_r %w(
|
26
|
+
Gemfile
|
27
|
+
Gemfile.lock
|
28
|
+
Rakefile
|
29
|
+
bin
|
30
|
+
spec
|
31
|
+
), force: true
|
32
|
+
`sed -E -i '' '/license/d' fakeable_her.gemspec`
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
data/lib/test_track.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
# Source vendored gems the hard way in all environments
|
2
|
+
%w(her fakeable_her).each do |gem_name|
|
3
|
+
lib = File.expand_path("../../vendor/gems/#{gem_name}/lib", __FILE__)
|
4
|
+
$LOAD_PATH.push(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require gem_name
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'public_suffix'
|
9
|
+
require 'mixpanel-ruby'
|
10
|
+
require 'resolv'
|
11
|
+
require 'faraday_middleware'
|
12
|
+
require 'request_store'
|
13
|
+
|
14
|
+
module TestTrack
|
15
|
+
module_function
|
16
|
+
|
17
|
+
SERVER_ERRORS = [Faraday::TimeoutError, Her::Errors::RemoteServerError].freeze
|
18
|
+
|
19
|
+
mattr_accessor :enabled_override
|
20
|
+
|
21
|
+
class << self
|
22
|
+
def analytics
|
23
|
+
@analytics ||= wrapper(mixpanel)
|
24
|
+
end
|
25
|
+
|
26
|
+
def analytics=(client)
|
27
|
+
@analytics = client.is_a?(TestTrack::Analytics::SafeWrapper) ? client : wrapper(client)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def wrapper(client)
|
33
|
+
TestTrack::Analytics::SafeWrapper.new(client)
|
34
|
+
end
|
35
|
+
|
36
|
+
def mixpanel
|
37
|
+
TestTrack::Analytics::MixpanelClient.new
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def update_config
|
42
|
+
yield(ConfigUpdater.new)
|
43
|
+
end
|
44
|
+
|
45
|
+
def url
|
46
|
+
return nil unless private_url
|
47
|
+
full_uri = URI.parse(private_url)
|
48
|
+
full_uri.user = nil
|
49
|
+
full_uri.password = nil
|
50
|
+
full_uri.to_s
|
51
|
+
end
|
52
|
+
|
53
|
+
def private_url
|
54
|
+
ENV['TEST_TRACK_API_URL']
|
55
|
+
end
|
56
|
+
|
57
|
+
def enabled?
|
58
|
+
enabled_override.nil? ? !Rails.env.test? : enabled_override
|
59
|
+
end
|
60
|
+
|
61
|
+
def fully_qualified_cookie_domain_enabled?
|
62
|
+
ENV['TEST_TRACK_FULLY_QUALIFIED_COOKIE_DOMAIN_ENABLED'] == '1'
|
63
|
+
end
|
64
|
+
end
|