test_track_rails_client 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (62) hide show
  1. data/LICENSE +19 -0
  2. data/README.md +246 -0
  3. data/Rakefile +30 -0
  4. data/app/assets/javascripts/testTrack.bundle.min.js +1 -0
  5. data/app/controllers/concerns/test_track/controller.rb +26 -0
  6. data/app/controllers/tt/api/v1/application_controller.rb +9 -0
  7. data/app/controllers/tt/api/v1/assignments_controller.rb +5 -0
  8. data/app/controllers/tt/api/v1/identifier_types_controller.rb +5 -0
  9. data/app/controllers/tt/api/v1/identifier_visitors_controller.rb +5 -0
  10. data/app/controllers/tt/api/v1/identifiers_controller.rb +5 -0
  11. data/app/controllers/tt/api/v1/resets_controller.rb +12 -0
  12. data/app/controllers/tt/api/v1/split_configs_controller.rb +9 -0
  13. data/app/controllers/tt/api/v1/split_registries_controller.rb +5 -0
  14. data/app/controllers/tt/api/v1/visitors_controller.rb +5 -0
  15. data/app/helpers/test_track/application_helper.rb +6 -0
  16. data/app/models/concerns/test_track/identity.rb +53 -0
  17. data/app/models/concerns/test_track/remote_model.rb +14 -0
  18. data/app/models/concerns/test_track/required_options.rb +11 -0
  19. data/app/models/test_track/ab_configuration.rb +53 -0
  20. data/app/models/test_track/analytics/mixpanel_client.rb +25 -0
  21. data/app/models/test_track/analytics/safe_wrapper.rb +43 -0
  22. data/app/models/test_track/assignment.rb +29 -0
  23. data/app/models/test_track/config_updater.rb +99 -0
  24. data/app/models/test_track/create_alias_job.rb +18 -0
  25. data/app/models/test_track/fake/split_registry.rb +36 -0
  26. data/app/models/test_track/fake/visitor.rb +41 -0
  27. data/app/models/test_track/fake_server.rb +24 -0
  28. data/app/models/test_track/identity_session_discriminator.rb +34 -0
  29. data/app/models/test_track/notify_assignment_job.rb +31 -0
  30. data/app/models/test_track/offline_session.rb +46 -0
  31. data/app/models/test_track/remote/assignment.rb +20 -0
  32. data/app/models/test_track/remote/assignment_event.rb +15 -0
  33. data/app/models/test_track/remote/fake_server.rb +8 -0
  34. data/app/models/test_track/remote/identifier.rb +26 -0
  35. data/app/models/test_track/remote/identifier_type.rb +13 -0
  36. data/app/models/test_track/remote/split_config.rb +13 -0
  37. data/app/models/test_track/remote/split_registry.rb +36 -0
  38. data/app/models/test_track/remote/visitor.rb +29 -0
  39. data/app/models/test_track/session.rb +167 -0
  40. data/app/models/test_track/unsynced_assignments_notifier.rb +36 -0
  41. data/app/models/test_track/variant_calculator.rb +48 -0
  42. data/app/models/test_track/vary_dsl.rb +88 -0
  43. data/app/models/test_track/visitor.rb +129 -0
  44. data/app/models/test_track/visitor_dsl.rb +11 -0
  45. data/app/views/tt/api/v1/identifier_visitors/show.json.jbuilder +1 -0
  46. data/app/views/tt/api/v1/identifiers/create.json.jbuilder +3 -0
  47. data/app/views/tt/api/v1/split_registries/show.json.jbuilder +3 -0
  48. data/app/views/tt/api/v1/visitors/_show.json.jbuilder +2 -0
  49. data/app/views/tt/api/v1/visitors/show.json.jbuilder +1 -0
  50. data/config/initializers/test_track_api.rb +15 -0
  51. data/config/routes.rb +28 -0
  52. data/lib/generators/test_track/migration_generator.rb +39 -0
  53. data/lib/tasks/pull_in_js_client.rake +7 -0
  54. data/lib/tasks/test_track_rails_client_tasks.rake +15 -0
  55. data/lib/tasks/vendor_deps.rake +36 -0
  56. data/lib/test_track.rb +64 -0
  57. data/lib/test_track_rails_client.rb +5 -0
  58. data/lib/test_track_rails_client/assignment_helper.rb +19 -0
  59. data/lib/test_track_rails_client/engine.rb +14 -0
  60. data/lib/test_track_rails_client/rspec_helpers.rb +5 -0
  61. data/lib/test_track_rails_client/version.rb +3 -0
  62. 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,11 @@
1
+ class TestTrack::VisitorDSL
2
+ def initialize(visitor)
3
+ @visitor = visitor
4
+ end
5
+
6
+ delegate :vary, :ab, :id, to: :visitor
7
+
8
+ private
9
+
10
+ attr_reader :visitor
11
+ end
@@ -0,0 +1 @@
1
+ json.partial! 'tt/api/v1/visitors/show', visitor: @visitor
@@ -0,0 +1,3 @@
1
+ json.visitor do
2
+ json.partial!('tt/api/v1/visitors/show', visitor: @visitor)
3
+ end
@@ -0,0 +1,3 @@
1
+ @active_splits.each do |split|
2
+ json.set! split.name, split.registry
3
+ end
@@ -0,0 +1,2 @@
1
+ json.(visitor, :id)
2
+ json.assignments visitor.assignments, :split_name, :variant, :unsynced
@@ -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
@@ -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,7 @@
1
+ namespace :js_client do
2
+ desc 'pull in testTrack JS Client and move to app/assets/javascripts'
3
+ task :pull do
4
+ sh 'bower install'
5
+ sh 'mv', 'bower_components/test_track_js_client/dist/testTrack.bundle.min.js', 'app/assets/javascripts'
6
+ end
7
+ 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
@@ -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