test_track_rails_client 0.9.0

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