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