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.
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