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