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,14 @@
1
+ module TestTrack::RemoteModel
2
+ extend ActiveSupport::Concern
3
+ include FakeableHer::Model
4
+
5
+ included do
6
+ use_api TestTrack::TestTrackApi
7
+ end
8
+
9
+ module ClassMethods
10
+ def faked?
11
+ !TestTrack.enabled?
12
+ end
13
+ end
14
+ end
@@ -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