test_track_rails_client 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- 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,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,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
|