test_track_rails_client 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|