behavior_analytics 2.2.0 → 2.2.2

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.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ module Javascript
5
+ class Client
6
+ def self.generate_script(tracker_url: "/behavior_analytics/track", auto_track: true)
7
+ <<~JAVASCRIPT
8
+ (function() {
9
+ var BehaviorAnalytics = {
10
+ trackerUrl: '#{tracker_url}',
11
+ visitorToken: null,
12
+ visitToken: null,
13
+
14
+ init: function() {
15
+ this.loadVisitorToken();
16
+ #{'this.autoTrack();' if auto_track}
17
+ },
18
+
19
+ loadVisitorToken: function() {
20
+ var token = this.getCookie('behavior_visitor_token');
21
+ if (!token) {
22
+ token = this.generateToken();
23
+ this.setCookie('behavior_visitor_token', token, 730); // 2 years
24
+ }
25
+ this.visitorToken = token;
26
+ },
27
+
28
+ generateToken: function() {
29
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
30
+ var r = Math.random() * 16 | 0;
31
+ var v = c == 'x' ? r : (r & 0x3 | 0x8);
32
+ return v.toString(16);
33
+ });
34
+ },
35
+
36
+ setCookie: function(name, value, days) {
37
+ var expires = '';
38
+ if (days) {
39
+ var date = new Date();
40
+ date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
41
+ expires = '; expires=' + date.toUTCString();
42
+ }
43
+ document.cookie = name + '=' + (value || '') + expires + '; path=/; SameSite=Lax';
44
+ },
45
+
46
+ getCookie: function(name) {
47
+ var nameEQ = name + '=';
48
+ var ca = document.cookie.split(';');
49
+ for (var i = 0; i < ca.length; i++) {
50
+ var c = ca[i];
51
+ while (c.charAt(0) == ' ') c = c.substring(1, c.length);
52
+ if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
53
+ }
54
+ return null;
55
+ },
56
+
57
+ track: function(eventName, properties) {
58
+ var payload = {
59
+ event_name: eventName,
60
+ properties: properties || {},
61
+ visitor_token: this.visitorToken,
62
+ visit_token: this.visitToken,
63
+ page: window.location.pathname,
64
+ referrer: document.referrer,
65
+ user_agent: navigator.userAgent,
66
+ timestamp: new Date().toISOString()
67
+ };
68
+
69
+ this.sendRequest(payload);
70
+ },
71
+
72
+ trackPageView: function() {
73
+ this.track('page_view', {
74
+ path: window.location.pathname,
75
+ title: document.title,
76
+ referrer: document.referrer
77
+ });
78
+ },
79
+
80
+ trackClick: function(element, properties) {
81
+ var props = {
82
+ element: element.tagName.toLowerCase(),
83
+ id: element.id || null,
84
+ class: element.className || null,
85
+ text: element.textContent ? element.textContent.substring(0, 100) : null
86
+ };
87
+
88
+ if (properties) {
89
+ Object.assign(props, properties);
90
+ }
91
+
92
+ this.track('click', props);
93
+ },
94
+
95
+ autoTrack: function() {
96
+ var self = this;
97
+
98
+ // Track page view on load
99
+ if (document.readyState === 'loading') {
100
+ document.addEventListener('DOMContentLoaded', function() {
101
+ self.trackPageView();
102
+ });
103
+ } else {
104
+ this.trackPageView();
105
+ }
106
+
107
+ // Track clicks on elements with data-track attribute
108
+ document.addEventListener('click', function(e) {
109
+ var element = e.target;
110
+ if (element.hasAttribute('data-track')) {
111
+ var trackValue = element.getAttribute('data-track');
112
+ var properties = {};
113
+
114
+ if (element.hasAttribute('data-track-properties')) {
115
+ try {
116
+ properties = JSON.parse(element.getAttribute('data-track-properties'));
117
+ } catch (e) {}
118
+ }
119
+
120
+ self.track(trackValue || 'click', properties);
121
+ }
122
+ });
123
+
124
+ // Track form submissions
125
+ document.addEventListener('submit', function(e) {
126
+ var form = e.target;
127
+ if (form.tagName === 'FORM' && form.hasAttribute('data-track')) {
128
+ var trackValue = form.getAttribute('data-track');
129
+ self.track(trackValue || 'form_submit', {
130
+ form_id: form.id || null,
131
+ form_action: form.action || null
132
+ });
133
+ }
134
+ });
135
+ },
136
+
137
+ sendRequest: function(payload) {
138
+ if (navigator.sendBeacon) {
139
+ var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
140
+ navigator.sendBeacon(this.trackerUrl, blob);
141
+ } else {
142
+ var xhr = new XMLHttpRequest();
143
+ xhr.open('POST', this.trackerUrl, true);
144
+ xhr.setRequestHeader('Content-Type', 'application/json');
145
+ xhr.send(JSON.stringify(payload));
146
+ }
147
+ }
148
+ };
149
+
150
+ // Initialize on load
151
+ if (document.readyState === 'loading') {
152
+ document.addEventListener('DOMContentLoaded', function() {
153
+ BehaviorAnalytics.init();
154
+ });
155
+ } else {
156
+ BehaviorAnalytics.init();
157
+ }
158
+
159
+ // Expose globally
160
+ window.BehaviorAnalytics = BehaviorAnalytics;
161
+ })();
162
+ JAVASCRIPT
163
+ end
164
+ end
165
+ end
166
+ end
167
+
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BehaviorAnalytics
4
- VERSION = "2.2.0"
4
+ VERSION = "2.2.2"
5
5
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ module Visits
5
+ class AutoCreator
6
+ attr_reader :manager, :cookie_name
7
+
8
+ def initialize(manager:, cookie_name: "behavior_visitor_token")
9
+ @manager = manager
10
+ @cookie_name = cookie_name
11
+ end
12
+
13
+ def get_or_create_visit(request:, tenant_id: nil, user_id: nil)
14
+ visitor_token = get_or_create_visitor_token(request)
15
+
16
+ utm_params = extract_utm_params(request)
17
+
18
+ manager.find_or_create_visit(
19
+ visitor_token: visitor_token,
20
+ tenant_id: tenant_id,
21
+ user_id: user_id,
22
+ ip: request.ip,
23
+ user_agent: request.user_agent,
24
+ referrer: request.referer,
25
+ landing_page: request.path,
26
+ utm_params: utm_params
27
+ )
28
+ end
29
+
30
+ def get_or_create_visitor_token(request)
31
+ # Try to get from cookie
32
+ if request.respond_to?(:cookies)
33
+ token = request.cookies[cookie_name]
34
+ return token if token && !token.empty?
35
+ end
36
+
37
+ # Try to get from headers (for API requests)
38
+ if request.respond_to?(:headers)
39
+ token = request.headers["X-Visitor-Token"]
40
+ return token if token && !token.empty?
41
+ end
42
+
43
+ # Generate new token
44
+ SecureRandom.hex(16)
45
+ end
46
+
47
+ def set_visitor_token_cookie(response, visitor_token)
48
+ return unless response.respond_to?(:set_cookie)
49
+
50
+ # Set cookie for 2 years
51
+ secure = if defined?(Rails)
52
+ Rails.env.production?
53
+ else
54
+ ENV["RAILS_ENV"] == "production" || ENV["RACK_ENV"] == "production"
55
+ end
56
+
57
+ response.set_cookie(
58
+ cookie_name,
59
+ value: visitor_token,
60
+ expires: 2.years.from_now,
61
+ httponly: true,
62
+ secure: secure,
63
+ same_site: :lax
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def extract_utm_params(request)
70
+ params = {}
71
+
72
+ if request.respond_to?(:params)
73
+ params[:utm_source] = request.params["utm_source"]
74
+ params[:utm_medium] = request.params["utm_medium"]
75
+ params[:utm_campaign] = request.params["utm_campaign"]
76
+ params[:utm_term] = request.params["utm_term"]
77
+ params[:utm_content] = request.params["utm_content"]
78
+ end
79
+
80
+ params.compact
81
+ end
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module BehaviorAnalytics
7
+ module Visits
8
+ class Manager
9
+ attr_reader :storage_adapter, :visit_duration
10
+
11
+ def initialize(storage_adapter:, visit_duration: 30.minutes, device_detector: nil, geolocation: nil)
12
+ @storage_adapter = storage_adapter
13
+ @visit_duration = visit_duration
14
+ @device_detector = device_detector
15
+ @geolocation = geolocation
16
+ end
17
+
18
+ def find_or_create_visit(visitor_token:, tenant_id: nil, user_id: nil, ip: nil, user_agent: nil,
19
+ referrer: nil, landing_page: nil, utm_params: {})
20
+ # Try to find active visit for this visitor
21
+ active_visit = find_active_visit(visitor_token, user_id)
22
+
23
+ if active_visit && !active_visit.expired?(visit_duration)
24
+ # Update visit if user logged in
25
+ if user_id && active_visit.user_id.nil?
26
+ active_visit.user_id = user_id
27
+ save_visit(active_visit)
28
+ end
29
+ return active_visit
30
+ end
31
+
32
+ # Detect device and geolocation if enabled
33
+ device_info = {}
34
+ geo_info = {}
35
+
36
+ if BehaviorAnalytics.configuration.track_device_info && @device_detector && user_agent
37
+ device_info = @device_detector.detect(user_agent)
38
+ end
39
+
40
+ if BehaviorAnalytics.configuration.track_geolocation && @geolocation && ip
41
+ geo_info = @geolocation.detect(ip)
42
+ end
43
+
44
+ # Create new visit
45
+ visit = Visit.new(
46
+ visitor_token: visitor_token,
47
+ tenant_id: tenant_id || BehaviorAnalytics.configuration.default_tenant_id,
48
+ user_id: user_id,
49
+ ip: ip,
50
+ user_agent: user_agent,
51
+ referrer: referrer,
52
+ landing_page: landing_page || referrer,
53
+ browser: device_info[:browser],
54
+ os: device_info[:os],
55
+ device_type: device_info[:device_type],
56
+ country: geo_info[:country],
57
+ city: geo_info[:city],
58
+ utm_source: utm_params[:utm_source],
59
+ utm_medium: utm_params[:utm_medium],
60
+ utm_campaign: utm_params[:utm_campaign],
61
+ utm_term: utm_params[:utm_term],
62
+ utm_content: utm_params[:utm_content],
63
+ referring_domain: extract_domain(referrer),
64
+ search_keyword: extract_search_keyword(referrer)
65
+ )
66
+
67
+ save_visit(visit)
68
+ visit
69
+ end
70
+
71
+ def find_active_visit(visitor_token, user_id = nil)
72
+ return nil unless storage_adapter.respond_to?(:find_active_visit)
73
+
74
+ visit_data = storage_adapter.find_active_visit(visitor_token, user_id, visit_duration)
75
+ return nil unless visit_data
76
+
77
+ Visit.new(visit_data)
78
+ end
79
+
80
+ def save_visit(visit)
81
+ if storage_adapter.respond_to?(:save_visit)
82
+ storage_adapter.save_visit(visit.to_h)
83
+ end
84
+ end
85
+
86
+ def end_visit(visit_token)
87
+ visit = find_visit_by_token(visit_token)
88
+ return unless visit
89
+
90
+ visit.end!
91
+ save_visit(visit)
92
+ end
93
+
94
+ def find_visit_by_token(visit_token)
95
+ return nil unless storage_adapter.respond_to?(:find_visit_by_token)
96
+
97
+ visit_data = storage_adapter.find_visit_by_token(visit_token)
98
+ return nil unless visit_data
99
+
100
+ Visit.new(visit_data)
101
+ end
102
+
103
+ def link_user_to_visits(visitor_token, user_id)
104
+ return unless storage_adapter.respond_to?(:link_user_to_visits)
105
+
106
+ storage_adapter.link_user_to_visits(visitor_token, user_id)
107
+ end
108
+
109
+ def find_visits_by_user(user_id, limit: 100)
110
+ return [] unless storage_adapter.respond_to?(:find_visits_by_user)
111
+
112
+ visits_data = storage_adapter.find_visits_by_user(user_id, limit: limit)
113
+ visits_data.map { |data| Visit.new(data) }
114
+ end
115
+
116
+ def find_visits_by_visitor(visitor_token, limit: 100)
117
+ return [] unless storage_adapter.respond_to?(:find_visits_by_visitor)
118
+
119
+ visits_data = storage_adapter.find_visits_by_visitor(visitor_token, limit: limit)
120
+ visits_data.map { |data| Visit.new(data) }
121
+ end
122
+
123
+ private
124
+
125
+ def extract_domain(url)
126
+ return nil unless url
127
+ URI.parse(url).host rescue nil
128
+ end
129
+
130
+ def extract_search_keyword(referrer)
131
+ return nil unless referrer
132
+
133
+ uri = URI.parse(referrer) rescue nil
134
+ return nil unless uri
135
+
136
+ # Extract from Google, Bing, etc.
137
+ if uri.host&.include?("google")
138
+ params = URI.decode_www_form(uri.query || "").to_h
139
+ params["q"] || params["query"]
140
+ elsif uri.host&.include?("bing")
141
+ params = URI.decode_www_form(uri.query || "").to_h
142
+ params["q"]
143
+ else
144
+ nil
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ module Visits
5
+ class Visit
6
+ attr_accessor :visit_token, :visitor_token, :tenant_id, :user_id, :ip, :user_agent,
7
+ :referrer, :landing_page, :browser, :os, :device_type, :country, :city,
8
+ :utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content,
9
+ :referring_domain, :search_keyword, :started_at, :ended_at, :created_at, :updated_at
10
+
11
+ def initialize(attributes = {})
12
+ @visit_token = attributes[:visit_token] || SecureRandom.hex(16)
13
+ @visitor_token = attributes[:visitor_token] || SecureRandom.hex(16)
14
+ @tenant_id = attributes[:tenant_id]
15
+ @user_id = attributes[:user_id]
16
+ @ip = attributes[:ip]
17
+ @user_agent = attributes[:user_agent]
18
+ @referrer = attributes[:referrer]
19
+ @landing_page = attributes[:landing_page]
20
+ @browser = attributes[:browser]
21
+ @os = attributes[:os]
22
+ @device_type = attributes[:device_type]
23
+ @country = attributes[:country]
24
+ @city = attributes[:city]
25
+ @utm_source = attributes[:utm_source]
26
+ @utm_medium = attributes[:utm_medium]
27
+ @utm_campaign = attributes[:utm_campaign]
28
+ @utm_term = attributes[:utm_term]
29
+ @utm_content = attributes[:utm_content]
30
+ @referring_domain = attributes[:referring_domain]
31
+ @search_keyword = attributes[:search_keyword]
32
+ @started_at = attributes[:started_at] || Time.now
33
+ @ended_at = attributes[:ended_at]
34
+ @created_at = attributes[:created_at] || Time.now
35
+ @updated_at = attributes[:updated_at] || Time.now
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ visit_token: visit_token,
41
+ visitor_token: visitor_token,
42
+ tenant_id: tenant_id,
43
+ user_id: user_id,
44
+ ip: ip,
45
+ user_agent: user_agent,
46
+ referrer: referrer,
47
+ landing_page: landing_page,
48
+ browser: browser,
49
+ os: os,
50
+ device_type: device_type,
51
+ country: country,
52
+ city: city,
53
+ utm_source: utm_source,
54
+ utm_medium: utm_medium,
55
+ utm_campaign: utm_campaign,
56
+ utm_term: utm_term,
57
+ utm_content: utm_content,
58
+ referring_domain: referring_domain,
59
+ search_keyword: search_keyword,
60
+ started_at: started_at,
61
+ ended_at: ended_at,
62
+ created_at: created_at,
63
+ updated_at: updated_at
64
+ }.compact
65
+ end
66
+
67
+ def duration_seconds
68
+ return nil unless ended_at
69
+ (ended_at - started_at).to_i
70
+ end
71
+
72
+ def active?
73
+ ended_at.nil?
74
+ end
75
+
76
+ def expired?(inactivity_duration = 30.minutes)
77
+ return false unless active?
78
+ (Time.now - started_at) > inactivity_duration
79
+ end
80
+
81
+ def end!
82
+ @ended_at = Time.now
83
+ @updated_at = Time.now
84
+ end
85
+ end
86
+ end
87
+ end
88
+
@@ -205,7 +205,7 @@ module BehaviorAnalytics
205
205
  Tracker.new(options)
206
206
  end
207
207
 
208
- # Simplified API methods (Ahoy-inspired)
208
+ # Simplified API methods
209
209
  def track(event_name, properties: {}, event_type: :custom, context: nil, **options)
210
210
  # Resolve context automatically if not provided
211
211
  context ||= resolve_default_context
@@ -0,0 +1,159 @@
1
+ // Behavior Analytics JavaScript Client
2
+ // This file can be included in your Rails application
3
+ // Usage: <%= javascript_include_tag 'behavior_analytics' %>
4
+
5
+ (function() {
6
+ var BehaviorAnalytics = {
7
+ trackerUrl: '/behavior_analytics/track',
8
+ visitorToken: null,
9
+ visitToken: null,
10
+
11
+ init: function() {
12
+ this.loadVisitorToken();
13
+ this.autoTrack();
14
+ },
15
+
16
+ loadVisitorToken: function() {
17
+ var token = this.getCookie('behavior_visitor_token');
18
+ if (!token) {
19
+ token = this.generateToken();
20
+ this.setCookie('behavior_visitor_token', token, 730); // 2 years
21
+ }
22
+ this.visitorToken = token;
23
+ },
24
+
25
+ generateToken: function() {
26
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
27
+ var r = Math.random() * 16 | 0;
28
+ var v = c == 'x' ? r : (r & 0x3 | 0x8);
29
+ return v.toString(16);
30
+ });
31
+ },
32
+
33
+ setCookie: function(name, value, days) {
34
+ var expires = '';
35
+ if (days) {
36
+ var date = new Date();
37
+ date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
38
+ expires = '; expires=' + date.toUTCString();
39
+ }
40
+ document.cookie = name + '=' + (value || '') + expires + '; path=/; SameSite=Lax';
41
+ },
42
+
43
+ getCookie: function(name) {
44
+ var nameEQ = name + '=';
45
+ var ca = document.cookie.split(';');
46
+ for (var i = 0; i < ca.length; i++) {
47
+ var c = ca[i];
48
+ while (c.charAt(0) == ' ') c = c.substring(1, c.length);
49
+ if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
50
+ }
51
+ return null;
52
+ },
53
+
54
+ track: function(eventName, properties) {
55
+ var payload = {
56
+ event_name: eventName,
57
+ properties: properties || {},
58
+ visitor_token: this.visitorToken,
59
+ visit_token: this.visitToken,
60
+ page: window.location.pathname,
61
+ referrer: document.referrer,
62
+ user_agent: navigator.userAgent,
63
+ timestamp: new Date().toISOString()
64
+ };
65
+
66
+ this.sendRequest(payload);
67
+ },
68
+
69
+ trackPageView: function() {
70
+ this.track('page_view', {
71
+ path: window.location.pathname,
72
+ title: document.title,
73
+ referrer: document.referrer
74
+ });
75
+ },
76
+
77
+ trackClick: function(element, properties) {
78
+ var props = {
79
+ element: element.tagName.toLowerCase(),
80
+ id: element.id || null,
81
+ class: element.className || null,
82
+ text: element.textContent ? element.textContent.substring(0, 100) : null
83
+ };
84
+
85
+ if (properties) {
86
+ Object.assign(props, properties);
87
+ }
88
+
89
+ this.track('click', props);
90
+ },
91
+
92
+ autoTrack: function() {
93
+ var self = this;
94
+
95
+ // Track page view on load
96
+ if (document.readyState === 'loading') {
97
+ document.addEventListener('DOMContentLoaded', function() {
98
+ self.trackPageView();
99
+ });
100
+ } else {
101
+ this.trackPageView();
102
+ }
103
+
104
+ // Track clicks on elements with data-track attribute
105
+ document.addEventListener('click', function(e) {
106
+ var element = e.target;
107
+ if (element.hasAttribute('data-track')) {
108
+ var trackValue = element.getAttribute('data-track');
109
+ var properties = {};
110
+
111
+ if (element.hasAttribute('data-track-properties')) {
112
+ try {
113
+ properties = JSON.parse(element.getAttribute('data-track-properties'));
114
+ } catch (e) {}
115
+ }
116
+
117
+ self.track(trackValue || 'click', properties);
118
+ }
119
+ });
120
+
121
+ // Track form submissions
122
+ document.addEventListener('submit', function(e) {
123
+ var form = e.target;
124
+ if (form.tagName === 'FORM' && form.hasAttribute('data-track')) {
125
+ var trackValue = form.getAttribute('data-track');
126
+ self.track(trackValue || 'form_submit', {
127
+ form_id: form.id || null,
128
+ form_action: form.action || null
129
+ });
130
+ }
131
+ });
132
+ },
133
+
134
+ sendRequest: function(payload) {
135
+ if (navigator.sendBeacon) {
136
+ var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
137
+ navigator.sendBeacon(this.trackerUrl, blob);
138
+ } else {
139
+ var xhr = new XMLHttpRequest();
140
+ xhr.open('POST', this.trackerUrl, true);
141
+ xhr.setRequestHeader('Content-Type', 'application/json');
142
+ xhr.send(JSON.stringify(payload));
143
+ }
144
+ }
145
+ };
146
+
147
+ // Initialize on load
148
+ if (document.readyState === 'loading') {
149
+ document.addEventListener('DOMContentLoaded', function() {
150
+ BehaviorAnalytics.init();
151
+ });
152
+ } else {
153
+ BehaviorAnalytics.init();
154
+ }
155
+
156
+ // Expose globally
157
+ window.BehaviorAnalytics = BehaviorAnalytics;
158
+ })();
159
+