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,11 @@
|
|
1
|
+
module TestTrack::RequiredOptions
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
private
|
5
|
+
|
6
|
+
def require_option!(opts, opt_name, my_opts = {})
|
7
|
+
opt_provided = my_opts[:allow_nil] ? opts.key?(opt_name) : opts[opt_name]
|
8
|
+
raise(ArgumentError, "Must provide #{opt_name}") unless opt_provided
|
9
|
+
opts.delete(opt_name)
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
class TestTrack::ABConfiguration
|
2
|
+
include TestTrack::RequiredOptions
|
3
|
+
|
4
|
+
def initialize(opts)
|
5
|
+
@split_name = require_option!(opts, :split_name).to_s
|
6
|
+
true_variant = require_option!(opts, :true_variant, allow_nil: true)
|
7
|
+
@split_registry = require_option!(opts, :split_registry, allow_nil: true)
|
8
|
+
raise ArgumentError, "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
9
|
+
|
10
|
+
@true_variant = true_variant.to_s if true_variant
|
11
|
+
|
12
|
+
raise ArgumentError, "unknown split: #{split_name}" if @split_registry && !split
|
13
|
+
end
|
14
|
+
|
15
|
+
def variants
|
16
|
+
@variants ||= build_variant_hash
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def build_variant_hash
|
22
|
+
airbrake_because_ab("configures split with more than 2 variants") if split_variants && split_variants.size > 2
|
23
|
+
{ true: true_variant, false: false_variant }
|
24
|
+
end
|
25
|
+
|
26
|
+
def true_variant
|
27
|
+
@true_variant ||= true
|
28
|
+
end
|
29
|
+
|
30
|
+
def false_variant
|
31
|
+
@false_variant ||= non_true_variants.present? ? non_true_variants.sort.first : false
|
32
|
+
end
|
33
|
+
|
34
|
+
attr_reader :split_name, :split_registry
|
35
|
+
|
36
|
+
def split
|
37
|
+
split_registry && split_registry[split_name]
|
38
|
+
end
|
39
|
+
|
40
|
+
def split_variants
|
41
|
+
@split_variants ||= split.keys if split_registry
|
42
|
+
end
|
43
|
+
|
44
|
+
def non_true_variants
|
45
|
+
split_variants - [true_variant.to_s] if split_variants
|
46
|
+
end
|
47
|
+
|
48
|
+
def airbrake_because_ab(msg)
|
49
|
+
msg = "A/B for \"#{split_name}\" #{msg}"
|
50
|
+
Rails.logger.error(msg)
|
51
|
+
Airbrake.notify_or_ignore(msg)
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module TestTrack::Analytics
|
2
|
+
class MixpanelClient
|
3
|
+
delegate :alias, to: :mixpanel
|
4
|
+
|
5
|
+
def track_assignment(visitor_id, assignment, params = {})
|
6
|
+
distinct_id = params.delete(:mixpanel_distinct_id) || visitor_id
|
7
|
+
mixpanel.track(distinct_id, 'SplitAssigned', split_properties(assignment).merge(TTVisitorID: visitor_id))
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def mixpanel
|
13
|
+
raise "ENV['MIXPANEL_TOKEN'] must be set" unless ENV['MIXPANEL_TOKEN']
|
14
|
+
@mixpanel ||= Mixpanel::Tracker.new(ENV['MIXPANEL_TOKEN'])
|
15
|
+
end
|
16
|
+
|
17
|
+
def split_properties(assignment)
|
18
|
+
{
|
19
|
+
SplitName: assignment.split_name,
|
20
|
+
SplitVariant: assignment.variant,
|
21
|
+
SplitContext: assignment.context
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module TestTrack::Analytics
|
2
|
+
class SafeWrapper
|
3
|
+
attr_reader :underlying
|
4
|
+
|
5
|
+
def initialize(underlying)
|
6
|
+
@underlying = underlying
|
7
|
+
end
|
8
|
+
|
9
|
+
def error_handler=(handler)
|
10
|
+
raise ArgumentError, "error_handler must be a lambda" unless handler.lambda?
|
11
|
+
raise ArgumentError, "error_handler must accept 1 argument" unless handler.arity == 1
|
12
|
+
@error_handler = handler
|
13
|
+
end
|
14
|
+
|
15
|
+
def track_assignment(visitor_id, assignment, params = {})
|
16
|
+
safe_action { underlying.track_assignment(visitor_id, assignment, params) }
|
17
|
+
end
|
18
|
+
|
19
|
+
def alias(visitor_id, existing_id)
|
20
|
+
safe_action { underlying.alias(visitor_id, existing_id) }
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def error_handler
|
26
|
+
@error_handler || ->(e) do
|
27
|
+
if Object.const_defined?(:Airbrake)
|
28
|
+
Airbrake.notify e
|
29
|
+
else
|
30
|
+
Rails.logger.error e
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def safe_action
|
36
|
+
yield
|
37
|
+
true
|
38
|
+
rescue StandardError => e
|
39
|
+
error_handler.call e
|
40
|
+
false
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
class TestTrack::Assignment
|
2
|
+
include TestTrack::RequiredOptions
|
3
|
+
|
4
|
+
attr_accessor :context
|
5
|
+
attr_reader :visitor, :split_name
|
6
|
+
attr_writer :variant
|
7
|
+
|
8
|
+
def initialize(opts = {})
|
9
|
+
@visitor = require_option!(opts, :visitor)
|
10
|
+
@split_name = require_option!(opts, :split_name).to_s
|
11
|
+
raise ArgumentError, "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
12
|
+
end
|
13
|
+
|
14
|
+
def variant
|
15
|
+
@variant ||= _variant
|
16
|
+
end
|
17
|
+
|
18
|
+
def unsynced?
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def _variant
|
25
|
+
return if visitor.offline?
|
26
|
+
variant = TestTrack::VariantCalculator.new(visitor: visitor, split_name: split_name).variant
|
27
|
+
variant && variant.to_s
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
class TestTrack::ConfigUpdater
|
2
|
+
def initialize(schema_file_path = "#{Rails.root}/db/test_track_schema.yml")
|
3
|
+
@schema_file_path = schema_file_path
|
4
|
+
end
|
5
|
+
|
6
|
+
def split(name, weighting_registry)
|
7
|
+
create_split(name, weighting_registry)
|
8
|
+
|
9
|
+
name = name.to_s
|
10
|
+
splits[name] = weighting_registry.stringify_keys
|
11
|
+
splits.except!(*(splits.keys - remote_splits.keys - [name]))
|
12
|
+
|
13
|
+
persist_schema!
|
14
|
+
end
|
15
|
+
|
16
|
+
def drop_split(name)
|
17
|
+
TestTrack::Remote::SplitConfig.destroy_existing(name)
|
18
|
+
|
19
|
+
splits.except!(name.to_s)
|
20
|
+
|
21
|
+
persist_schema!
|
22
|
+
end
|
23
|
+
alias finish_split drop_split # to support older migrations written with `finish_split`
|
24
|
+
|
25
|
+
def identifier_type(name)
|
26
|
+
create_identifier_type(name)
|
27
|
+
|
28
|
+
identifier_types << name.to_s
|
29
|
+
|
30
|
+
persist_schema!
|
31
|
+
end
|
32
|
+
|
33
|
+
def load_schema
|
34
|
+
identifier_types.each do |name|
|
35
|
+
create_identifier_type(name)
|
36
|
+
end
|
37
|
+
|
38
|
+
splits.each do |name, weighting_registry|
|
39
|
+
create_split(name, weighting_registry)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
attr_reader :schema_file_path
|
46
|
+
|
47
|
+
def create_split(name, weighting_registry)
|
48
|
+
TestTrack::Remote::SplitConfig.new(name: name, weighting_registry: weighting_registry).tap do |split_config|
|
49
|
+
raise split_config.errors.full_messages.join("\n") unless split_config.save
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def create_identifier_type(name)
|
54
|
+
TestTrack::Remote::IdentifierType.new(name: name).tap do |identifier_type|
|
55
|
+
raise identifier_type.errors.full_messages.join("\n") unless identifier_type.save
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def remote_splits
|
60
|
+
unless @remote_splits
|
61
|
+
TestTrack::Remote::SplitRegistry.reset
|
62
|
+
@remote_splits = TestTrack::Remote::SplitRegistry.to_hash
|
63
|
+
end
|
64
|
+
@remote_splits
|
65
|
+
end
|
66
|
+
|
67
|
+
def persist_schema!
|
68
|
+
File.open(schema_file_path, "w") do |f|
|
69
|
+
f.write YAML.dump("identifier_types" => identifier_types.sort, "splits" => sorted_splits)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def identifier_types
|
74
|
+
@identifier_types ||= Set.new(schema_file_hash["identifier_types"] || [])
|
75
|
+
end
|
76
|
+
|
77
|
+
def splits
|
78
|
+
@splits ||= schema_file_hash["splits"] || {}
|
79
|
+
end
|
80
|
+
|
81
|
+
def sorted_splits
|
82
|
+
sorted = Hash[splits.sort]
|
83
|
+
sorted.each do |split_name, weighting_registry|
|
84
|
+
sorted[split_name] = Hash[weighting_registry.sort]
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def schema_file_hash
|
89
|
+
@schema_hash ||= YAML.load(schema_file_contents) || {}
|
90
|
+
end
|
91
|
+
|
92
|
+
def schema_file_contents
|
93
|
+
@schema_file_contents ||= schema_file_exists? ? File.open(schema_file_path, "r").read : ""
|
94
|
+
end
|
95
|
+
|
96
|
+
def schema_file_exists?
|
97
|
+
File.exist?(schema_file_path)
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class TestTrack::CreateAliasJob
|
2
|
+
attr_reader :existing_id, :alias_id
|
3
|
+
|
4
|
+
def initialize(opts)
|
5
|
+
@existing_id = opts.delete(:existing_id)
|
6
|
+
@alias_id = opts.delete(:alias_id)
|
7
|
+
|
8
|
+
%w(existing_id alias_id).each do |param_name|
|
9
|
+
raise "#{param_name} must be present" unless send(param_name).present?
|
10
|
+
end
|
11
|
+
raise "unknown opts: #{opts.keys.to_sentence}" if opts.present?
|
12
|
+
end
|
13
|
+
|
14
|
+
def perform
|
15
|
+
return unless TestTrack.enabled?
|
16
|
+
TestTrack.analytics.alias(alias_id, existing_id)
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
class TestTrack::Fake::SplitRegistry
|
2
|
+
Split = Struct.new(:name, :registry)
|
3
|
+
|
4
|
+
def self.instance
|
5
|
+
@instance ||= new
|
6
|
+
end
|
7
|
+
|
8
|
+
def to_h
|
9
|
+
if test_track_schema_yml.present?
|
10
|
+
test_track_schema_yml[:splits]
|
11
|
+
else
|
12
|
+
{}
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def splits
|
17
|
+
to_h.map do |split, registry|
|
18
|
+
Split.new(split, registry)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def test_track_schema_yml
|
25
|
+
unless instance_variable_defined?(:@test_track_schema_yml)
|
26
|
+
@test_track_schema_yml = _test_track_schema_yml
|
27
|
+
end
|
28
|
+
@test_track_schema_yml
|
29
|
+
end
|
30
|
+
|
31
|
+
def _test_track_schema_yml
|
32
|
+
YAML.load_file("#{Rails.root}/db/test_track_schema.yml").with_indifferent_access
|
33
|
+
rescue
|
34
|
+
nil
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'digest'
|
2
|
+
|
3
|
+
class TestTrack::Fake::Visitor
|
4
|
+
attr_reader :id
|
5
|
+
|
6
|
+
Assignment = Struct.new(:split_name, :variant, :unsynced, :context)
|
7
|
+
|
8
|
+
def self.instance
|
9
|
+
@instance ||= new(TestTrack::FakeServer.seed)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.reset!
|
13
|
+
@instance = nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def initialize(id)
|
17
|
+
@id = id
|
18
|
+
end
|
19
|
+
|
20
|
+
def assignments
|
21
|
+
@assignments ||= _assignments
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def _assignments
|
27
|
+
TestTrack::Fake::SplitRegistry.instance.splits.map do |split|
|
28
|
+
index = hash_fixnum(split.name) % split.registry.keys.size
|
29
|
+
variant = split.registry.keys[index]
|
30
|
+
Assignment.new(split.name, variant, false, "the_context")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def hash_fixnum(split_name)
|
35
|
+
split_visitor_hash(split_name).slice(0, 8).to_i(16)
|
36
|
+
end
|
37
|
+
|
38
|
+
def split_visitor_hash(split_name)
|
39
|
+
Digest::MD5.new.update(split_name.to_s + id.to_s).hexdigest
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
class TestTrack::FakeServer
|
2
|
+
class << self
|
3
|
+
def split_registry
|
4
|
+
TestTrack::Fake::SplitRegistry.instance.splits
|
5
|
+
end
|
6
|
+
|
7
|
+
def visitor
|
8
|
+
TestTrack::Fake::Visitor.instance
|
9
|
+
end
|
10
|
+
|
11
|
+
def assignments
|
12
|
+
TestTrack::Fake::Visitor.instance.assignments
|
13
|
+
end
|
14
|
+
|
15
|
+
def reset!(seed)
|
16
|
+
TestTrack::Fake::Visitor.reset!
|
17
|
+
@seed = Integer(seed)
|
18
|
+
end
|
19
|
+
|
20
|
+
def seed
|
21
|
+
@seed || raise('TestTrack::FakeServer seed not set. Call TestTrack::FakeServer.reset!(seed) to set seed.')
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class TestTrack::IdentitySessionDiscriminator
|
2
|
+
attr_reader :identity
|
3
|
+
|
4
|
+
def initialize(identity)
|
5
|
+
@identity = identity
|
6
|
+
end
|
7
|
+
|
8
|
+
def controller
|
9
|
+
@controller ||= RequestStore[:test_track_controller]
|
10
|
+
end
|
11
|
+
|
12
|
+
def participate_in_online_session?
|
13
|
+
authenticated_resource_matches_identity?
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def authenticated_resource_matches_identity?
|
19
|
+
controller_has_authenticated_resource? && controller.send(authenticated_resource_method_name) == identity
|
20
|
+
end
|
21
|
+
|
22
|
+
def controller_has_authenticated_resource?
|
23
|
+
# pass true to `respond_to?` to include private methods
|
24
|
+
web_context? && controller.respond_to?(authenticated_resource_method_name, true)
|
25
|
+
end
|
26
|
+
|
27
|
+
def web_context?
|
28
|
+
controller.present?
|
29
|
+
end
|
30
|
+
|
31
|
+
def authenticated_resource_method_name
|
32
|
+
@authenticated_resource_method_name ||= "current_#{identity.class.model_name.element}"
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class TestTrack::NotifyAssignmentJob
|
2
|
+
attr_reader :mixpanel_distinct_id, :visitor_id, :assignment
|
3
|
+
|
4
|
+
def initialize(opts)
|
5
|
+
@visitor_id = opts.delete(:visitor_id)
|
6
|
+
@mixpanel_distinct_id = opts.delete(:mixpanel_distinct_id)
|
7
|
+
@assignment = opts.delete(:assignment)
|
8
|
+
|
9
|
+
%w(visitor_id assignment).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 perform
|
16
|
+
TestTrack::Remote::AssignmentEvent.create!(
|
17
|
+
visitor_id: visitor_id,
|
18
|
+
split_name: assignment.split_name,
|
19
|
+
context: assignment.context,
|
20
|
+
mixpanel_result: track
|
21
|
+
)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def track
|
27
|
+
return "failure" unless TestTrack.enabled?
|
28
|
+
result = TestTrack.analytics.track_assignment(visitor_id, assignment, mixpanel_distinct_id: mixpanel_distinct_id)
|
29
|
+
result ? "success" : "failure"
|
30
|
+
end
|
31
|
+
end
|