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.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/behavior_analytics.gemspec +1 -1
- data/db/migrate/003_create_behavior_visits.rb +38 -0
- data/db/migrate/004_add_visit_fields_to_events.rb +12 -0
- data/lib/behavior_analytics/analytics/geographic.rb +59 -0
- data/lib/behavior_analytics/analytics/referrer.rb +82 -0
- data/lib/behavior_analytics/cleanup/retention_policy.rb +39 -0
- data/lib/behavior_analytics/cleanup/scheduler.rb +42 -0
- data/lib/behavior_analytics/detection/device_detector.rb +104 -0
- data/lib/behavior_analytics/detection/geolocation.rb +67 -0
- data/lib/behavior_analytics/helpers/tracking_helper.rb +67 -0
- data/lib/behavior_analytics/identification/user_resolver.rb +55 -0
- data/lib/behavior_analytics/javascript/client.rb +167 -0
- data/lib/behavior_analytics/version.rb +1 -1
- data/lib/behavior_analytics/visits/auto_creator.rb +85 -0
- data/lib/behavior_analytics/visits/manager.rb +150 -0
- data/lib/behavior_analytics/visits/visit.rb +88 -0
- data/lib/behavior_analytics.rb +1 -1
- data/vendor/assets/javascripts/behavior_analytics.js +159 -0
- metadata +20 -5
|
@@ -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
|
+
|
|
@@ -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
|
+
|
data/lib/behavior_analytics.rb
CHANGED
|
@@ -205,7 +205,7 @@ module BehaviorAnalytics
|
|
|
205
205
|
Tracker.new(options)
|
|
206
206
|
end
|
|
207
207
|
|
|
208
|
-
# Simplified API methods
|
|
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
|
+
|