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,46 @@
1
+ class TestTrack::OfflineSession
2
+ def initialize(identifier_type, identifier_value)
3
+ @identifier_type = identifier_type
4
+ @identifier_value = identifier_value
5
+ end
6
+
7
+ def self.with_visitor_for(identifier_type, identifier_value)
8
+ raise ArgumentError, "must provide block to `with_visitor_for`" unless block_given?
9
+
10
+ new(identifier_type, identifier_value).send :manage do |visitor_dsl|
11
+ yield visitor_dsl
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :identifier_type, :identifier_value
18
+
19
+ def visitor
20
+ @visitor ||= TestTrack::Visitor.new(
21
+ id: remote_visitor.id,
22
+ assignments: remote_visitor.assignments
23
+ )
24
+ end
25
+
26
+ def remote_visitor
27
+ @remote_visitor ||= TestTrack::Remote::Visitor.from_identifier(identifier_type, identifier_value)
28
+ end
29
+
30
+ def manage
31
+ yield TestTrack::VisitorDSL.new(visitor)
32
+ ensure
33
+ notify_unsynced_assignments! if unsynced_assignments?
34
+ end
35
+
36
+ def unsynced_assignments?
37
+ visitor.unsynced_assignments.present?
38
+ end
39
+
40
+ def notify_unsynced_assignments!
41
+ TestTrack::UnsyncedAssignmentsNotifier.new(
42
+ visitor_id: visitor.id,
43
+ assignments: visitor.unsynced_assignments
44
+ ).notify
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ class TestTrack::Remote::Assignment
2
+ include TestTrack::RemoteModel
3
+
4
+ attributes :visitor_id, :split_name, :variant, :unsynced
5
+
6
+ validates :visitor_id, :split_name, :variant, :mixpanel_result, presence: true
7
+
8
+ def unsynced?
9
+ unsynced || variant_changed?
10
+ end
11
+
12
+ def self.fake_instance_attributes(id)
13
+ {
14
+ split_name: "split_#{id}",
15
+ variant: "true",
16
+ context: "context",
17
+ unsynced: false
18
+ }
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ class TestTrack::Remote::AssignmentEvent
2
+ include TestTrack::RemoteModel
3
+
4
+ collection_path '/api/v1/assignment_event'
5
+
6
+ attributes :visitor_id, :split_name, :unsynced
7
+
8
+ validates :visitor_id, :split_name, :mixpanel_result, presence: true
9
+
10
+ alias unsynced? unsynced
11
+
12
+ def fake_save_response_attributes
13
+ nil # :no_content is the expected response type
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ class TestTrack::Remote::FakeServer
2
+ include TestTrack::RemoteModel
3
+
4
+ def self.reset!(seed)
5
+ raise('Cannot reset FakeServer if TestTrack is enabled.') if TestTrack.enabled?
6
+ put('api/v1/reset', seed: seed)
7
+ end
8
+ end
@@ -0,0 +1,26 @@
1
+ class TestTrack::Remote::Identifier
2
+ include TestTrack::RemoteModel
3
+
4
+ collection_path '/api/v1/identifier'
5
+
6
+ has_one :remote_visitor, data_key: :visitor, class_name: "TestTrack::Remote::Visitor"
7
+
8
+ attributes :identifier_type, :visitor_id, :value
9
+
10
+ validates :identifier_type, :visitor_id, :value, presence: true
11
+
12
+ def fake_save_response_attributes
13
+ { visitor: { id: visitor_id, assignments: [] } }
14
+ end
15
+
16
+ def visitor
17
+ @visitor ||= TestTrack::Visitor.new(visitor_opts!)
18
+ end
19
+
20
+ private
21
+
22
+ def visitor_opts!
23
+ raise("Visitor data unavailable until you save this identifier.") unless attributes[:remote_visitor]
24
+ { id: remote_visitor.id, assignments: remote_visitor.assignments }
25
+ end
26
+ end
@@ -0,0 +1,13 @@
1
+ class TestTrack::Remote::IdentifierType
2
+ include TestTrack::RemoteModel
3
+
4
+ collection_path '/api/v1/identifier_type'
5
+
6
+ attributes :name
7
+
8
+ validates :name, presence: true
9
+
10
+ def fake_save_response_attributes
11
+ nil # :no_content is the expected response type
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ class TestTrack::Remote::SplitConfig
2
+ include TestTrack::RemoteModel
3
+
4
+ collection_path '/api/v1/split_configs'
5
+
6
+ attributes :name, :weighting_registry
7
+
8
+ validates :name, :weighting_registry, presence: true
9
+
10
+ def fake_save_response_attributes
11
+ nil # :no_content is the expected response type
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ class TestTrack::Remote::SplitRegistry
2
+ include TestTrack::RemoteModel
3
+
4
+ CACHE_KEY = 'test_track_split_registry'.freeze
5
+
6
+ collection_path '/api/v1/split_registry'
7
+
8
+ def self.fake_instance_attributes(_)
9
+ ::TestTrack::Fake::SplitRegistry.instance.to_h
10
+ end
11
+
12
+ def self.instance
13
+ # TODO: FakeableHer needs to make this faking a feature of `get`
14
+ if faked?
15
+ new(fake_instance_attributes(nil))
16
+ else
17
+ get('/api/v1/split_registry')
18
+ end
19
+ end
20
+
21
+ def self.reset
22
+ Rails.cache.delete(CACHE_KEY)
23
+ end
24
+
25
+ def self.to_hash
26
+ if faked?
27
+ instance.attributes.freeze
28
+ else
29
+ Rails.cache.fetch(CACHE_KEY, expires_in: 5.seconds) do
30
+ instance.attributes
31
+ end.freeze
32
+ end
33
+ rescue *TestTrack::SERVER_ERRORS
34
+ nil # if we can't get a split registry
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ class TestTrack::Remote::Visitor
2
+ include TestTrack::RemoteModel
3
+
4
+ collection_path '/api/v1/visitors'
5
+
6
+ has_many :assignments
7
+
8
+ def self.from_identifier(identifier_type, identifier_value)
9
+ raise "must provide an identifier_type" unless identifier_type.present?
10
+ raise "must provide an identifier_value" unless identifier_value.present?
11
+
12
+ # TODO: FakeableHer needs to make this faking a feature of `get`
13
+ if faked?
14
+ new(fake_instance_attributes(nil))
15
+ else
16
+ get("/api/v1/identifier_types/#{identifier_type}/identifiers/#{identifier_value}/visitor")
17
+ end
18
+ end
19
+
20
+ def self.fake_instance_attributes(_)
21
+ {
22
+ id: "fake_visitor_id",
23
+ assignments: [
24
+ TestTrack::Remote::Assignment.fake_instance_attributes(1),
25
+ TestTrack::Remote::Assignment.fake_instance_attributes(2)
26
+ ]
27
+ }
28
+ end
29
+ end
@@ -0,0 +1,167 @@
1
+ require 'delayed_job'
2
+ require 'delayed_job_active_record'
3
+
4
+ class TestTrack::Session
5
+ COOKIE_LIFESPAN = 1.year # Used for tt_visitor_id cookie
6
+
7
+ def initialize(controller)
8
+ @controller = controller
9
+ end
10
+
11
+ def manage
12
+ yield
13
+ ensure
14
+ manage_cookies!
15
+ notify_unsynced_assignments! if sync_assignments?
16
+ create_alias! if signed_up?
17
+ end
18
+
19
+ def visitor_dsl
20
+ @visitor_dsl ||= TestTrack::VisitorDSL.new(visitor)
21
+ end
22
+
23
+ def state_hash
24
+ {
25
+ url: TestTrack.url,
26
+ cookieDomain: cookie_domain,
27
+ registry: visitor.split_registry,
28
+ assignments: visitor.assignment_json
29
+ }
30
+ end
31
+
32
+ def log_in!(identifier_type, identifier_value, opts = {})
33
+ @visitor = TestTrack::Visitor.new if opts[:forget_current_visitor]
34
+ visitor.link_identifier!(identifier_type, identifier_value)
35
+ self.mixpanel_distinct_id = visitor.id
36
+ true
37
+ end
38
+
39
+ def sign_up!(identifier_type, identifier_value)
40
+ visitor.link_identifier!(identifier_type, identifier_value)
41
+ @signed_up = true
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :controller, :signed_up
47
+ alias signed_up? signed_up
48
+
49
+ def visitor
50
+ @visitor ||= TestTrack::Visitor.new(id: cookies[:tt_visitor_id])
51
+ end
52
+
53
+ def set_cookie(name, value)
54
+ cookies[name] = {
55
+ value: value,
56
+ domain: cookie_domain,
57
+ secure: request.ssl?,
58
+ httponly: false,
59
+ expires: COOKIE_LIFESPAN.from_now
60
+ }
61
+ end
62
+
63
+ def cookie_domain
64
+ @cookie_domain ||= _cookie_domain
65
+ end
66
+
67
+ def _cookie_domain
68
+ if bare_ip_address?
69
+ request.host
70
+ elsif TestTrack.fully_qualified_cookie_domain_enabled?
71
+ fully_qualified_domain
72
+ else
73
+ wildcard_domain
74
+ end
75
+ end
76
+
77
+ def bare_ip_address?
78
+ request.host.match(Resolv::AddressRegex)
79
+ end
80
+
81
+ def fully_qualified_domain
82
+ public_suffix_host.name
83
+ end
84
+
85
+ def wildcard_domain
86
+ "." + public_suffix_host.domain
87
+ end
88
+
89
+ def public_suffix_host
90
+ @public_suffix_host ||= PublicSuffix.parse(request.host)
91
+ end
92
+
93
+ def manage_cookies!
94
+ set_cookie(mixpanel_cookie_name, mixpanel_cookie.to_json)
95
+ set_cookie(:tt_visitor_id, visitor.id)
96
+ end
97
+
98
+ def request
99
+ controller.request
100
+ end
101
+
102
+ def cookies
103
+ controller.send(:cookies)
104
+ end
105
+
106
+ def notify_unsynced_assignments!
107
+ payload = {
108
+ mixpanel_distinct_id: mixpanel_distinct_id,
109
+ visitor_id: visitor.id,
110
+ assignments: visitor.unsynced_assignments
111
+ }
112
+ ActiveSupport::Notifications.instrument('test_track.notify_unsynced_assignments', payload) do
113
+ ##
114
+ # This block creates an unbounded number of threads up to 1 per request.
115
+ # This can potentially cause issues under high load, in which case we should move to a thread pool/work queue.
116
+ Thread.new do
117
+ TestTrack::UnsyncedAssignmentsNotifier.new(payload).notify
118
+ end
119
+ end
120
+ end
121
+
122
+ def create_alias!
123
+ create_alias_job = TestTrack::CreateAliasJob.new(
124
+ existing_id: mixpanel_distinct_id,
125
+ alias_id: visitor.id
126
+ )
127
+ Delayed::Job.enqueue(create_alias_job)
128
+ end
129
+
130
+ def sync_assignments?
131
+ !visitor.offline? && visitor.unsynced_assignments.present?
132
+ end
133
+
134
+ def mixpanel_distinct_id
135
+ mixpanel_cookie['distinct_id']
136
+ end
137
+
138
+ def mixpanel_distinct_id=(value)
139
+ mixpanel_cookie['distinct_id'] = value
140
+ end
141
+
142
+ def mixpanel_cookie
143
+ @mixpanel_cookie ||= read_mixpanel_cookie || generate_mixpanel_cookie
144
+ end
145
+
146
+ def read_mixpanel_cookie
147
+ mixpanel_cookie = cookies[mixpanel_cookie_name]
148
+ begin
149
+ JSON.parse(mixpanel_cookie) if mixpanel_cookie
150
+ rescue JSON::ParserError
151
+ Rails.logger.error("malformed mixpanel JSON from cookie #{URI.unescape(mixpanel_cookie)}")
152
+ nil
153
+ end
154
+ end
155
+
156
+ def generate_mixpanel_cookie
157
+ { 'distinct_id' => visitor.id }
158
+ end
159
+
160
+ def mixpanel_token
161
+ ENV['MIXPANEL_TOKEN'] || raise("ENV['MIXPANEL_TOKEN'] must be set")
162
+ end
163
+
164
+ def mixpanel_cookie_name
165
+ "mp_#{mixpanel_token}_mixpanel"
166
+ end
167
+ end
@@ -0,0 +1,36 @@
1
+ class TestTrack::UnsyncedAssignmentsNotifier
2
+ attr_reader :mixpanel_distinct_id, :visitor_id, :assignments
3
+
4
+ def initialize(opts)
5
+ @visitor_id = opts.delete(:visitor_id)
6
+ @mixpanel_distinct_id = opts.delete(:mixpanel_distinct_id) || visitor_id
7
+ @assignments = opts.delete(:assignments)
8
+
9
+ %w(visitor_id assignments).each do |param_name|
10
+ raise "#{param_name} must be present" unless send(param_name).present?
11
+ end
12
+ raise "unknown opts: #{opts.keys.to_sentence}" if opts.present?
13
+ end
14
+
15
+ def notify
16
+ assignments.each do |assignment|
17
+ build_notify_assignment_job(assignment).tap do |job|
18
+ begin
19
+ job.perform
20
+ rescue *TestTrack::SERVER_ERRORS
21
+ Delayed::Job.enqueue(build_notify_assignment_job(assignment))
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def build_notify_assignment_job(assignment)
30
+ TestTrack::NotifyAssignmentJob.new(
31
+ mixpanel_distinct_id: mixpanel_distinct_id,
32
+ visitor_id: visitor_id,
33
+ assignment: assignment
34
+ )
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ require 'digest'
2
+
3
+ class TestTrack::VariantCalculator
4
+ include TestTrack::RequiredOptions
5
+
6
+ attr_reader :visitor, :split_name
7
+
8
+ delegate :split_registry, to: :visitor
9
+
10
+ def initialize(opts = {})
11
+ @visitor = require_option!(opts, :visitor)
12
+ @split_name = require_option!(opts, :split_name)
13
+ raise "unknown opts: #{opts.keys.to_sentence}" if opts.present?
14
+ end
15
+
16
+ def variant
17
+ return nil unless split_registry
18
+ @variant ||= _variant || raise("Assignment bucket out of range. #{assignment_bucket} unmatched in #{split_name}: #{weighting}")
19
+ end
20
+
21
+ def _variant
22
+ bucket_ceiling = 0
23
+ sorted_variants.detect do |variant|
24
+ bucket_ceiling += weighting[variant]
25
+ bucket_ceiling > assignment_bucket
26
+ end
27
+ end
28
+
29
+ def sorted_variants
30
+ weighting.keys.sort
31
+ end
32
+
33
+ def weighting
34
+ @weighting ||= split_registry[split_name] || raise("TestTrack split '#{split_name}' not found. Need to write/run a migration?")
35
+ end
36
+
37
+ def assignment_bucket
38
+ @assignment_bucket ||= hash_fixnum % 100
39
+ end
40
+
41
+ def hash_fixnum
42
+ split_visitor_hash.slice(0, 8).to_i(16)
43
+ end
44
+
45
+ def split_visitor_hash
46
+ Digest::MD5.new.update(split_name.to_s + visitor.id.to_s).hexdigest
47
+ end
48
+ end