behavior_analytics 2.2.1 → 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/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/vendor/assets/javascripts/behavior_analytics.js +159 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 658a8852d807dd03e050e0c773ff213459e168dfd37ecb14d707d381ada8362b
|
|
4
|
+
data.tar.gz: c7398303e1217051c62384c7ac78e6c43dc5da35ed689917d7b5bab99b4f4b41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a795b6a6ca62eebe154b73a7a5b3377e1b305b7c1fce76e8da2acecc4535a69e34fe39914b4491a014929d55121848a06706f0d2c111262f4661453b08b7c4f9
|
|
7
|
+
data.tar.gz: 237f9c73911e9c42cf1922158e9724f094a32fffee340077046509f3b966495af9e4d1f4bf3db442e4f9ac0ccb8a688927f9bf3db5b099d0be434ff5255bb52d
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBehaviorVisits < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :behavior_visits do |t|
|
|
6
|
+
t.string :visit_token, null: false, index: true
|
|
7
|
+
t.string :visitor_token, null: false, index: true
|
|
8
|
+
t.string :tenant_id, index: true
|
|
9
|
+
t.string :user_id, index: true
|
|
10
|
+
t.string :ip
|
|
11
|
+
t.text :user_agent
|
|
12
|
+
t.string :referrer
|
|
13
|
+
t.string :landing_page
|
|
14
|
+
t.string :browser
|
|
15
|
+
t.string :os
|
|
16
|
+
t.string :device_type
|
|
17
|
+
t.string :country
|
|
18
|
+
t.string :city
|
|
19
|
+
t.string :utm_source
|
|
20
|
+
t.string :utm_medium
|
|
21
|
+
t.string :utm_campaign
|
|
22
|
+
t.string :utm_term
|
|
23
|
+
t.string :utm_content
|
|
24
|
+
t.string :referring_domain
|
|
25
|
+
t.string :search_keyword
|
|
26
|
+
t.datetime :started_at, null: false
|
|
27
|
+
t.datetime :ended_at
|
|
28
|
+
t.datetime :created_at, null: false
|
|
29
|
+
t.datetime :updated_at, null: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
add_index :behavior_visits, [:tenant_id, :started_at]
|
|
33
|
+
add_index :behavior_visits, [:visitor_token, :started_at]
|
|
34
|
+
add_index :behavior_visits, [:user_id, :started_at]
|
|
35
|
+
add_index :behavior_visits, [:visit_token, :visitor_token]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddVisitFieldsToEvents < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
add_column :behavior_events, :visit_id, :string, index: true unless column_exists?(:behavior_events, :visit_id)
|
|
6
|
+
add_column :behavior_events, :visitor_id, :string, index: true unless column_exists?(:behavior_events, :visitor_id)
|
|
7
|
+
|
|
8
|
+
add_index :behavior_events, [:visit_id, :created_at] unless index_exists?(:behavior_events, [:visit_id, :created_at])
|
|
9
|
+
add_index :behavior_events, [:visitor_id, :created_at] unless index_exists?(:behavior_events, [:visitor_id, :created_at])
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Analytics
|
|
5
|
+
class Geographic
|
|
6
|
+
attr_reader :storage_adapter
|
|
7
|
+
|
|
8
|
+
def initialize(storage_adapter:)
|
|
9
|
+
@storage_adapter = storage_adapter
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def country_breakdown(context, options = {})
|
|
13
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
14
|
+
|
|
15
|
+
visits = storage_adapter.query_visits(context, options)
|
|
16
|
+
visits.group_by { |v| v[:country] }
|
|
17
|
+
.map { |country, visits| { country: country, count: visits.size } }
|
|
18
|
+
.sort_by { |r| -r[:count] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def city_breakdown(context, options = {})
|
|
22
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
23
|
+
|
|
24
|
+
visits = storage_adapter.query_visits(context, options)
|
|
25
|
+
visits.group_by { |v| v[:city] }
|
|
26
|
+
.map { |city, visits| { city: city, count: visits.size } }
|
|
27
|
+
.sort_by { |r| -r[:count] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def country_city_breakdown(context, options = {})
|
|
31
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
32
|
+
|
|
33
|
+
visits = storage_adapter.query_visits(context, options)
|
|
34
|
+
visits.group_by { |v| [v[:country], v[:city]] }
|
|
35
|
+
.map { |(country, city), visits| { country: country, city: city, count: visits.size } }
|
|
36
|
+
.sort_by { |r| -r[:count] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def device_breakdown(context, options = {})
|
|
40
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
41
|
+
|
|
42
|
+
visits = storage_adapter.query_visits(context, options)
|
|
43
|
+
visits.group_by { |v| v[:device_type] }
|
|
44
|
+
.map { |device, visits| { device: device, count: visits.size } }
|
|
45
|
+
.sort_by { |r| -r[:count] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def browser_breakdown(context, options = {})
|
|
49
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
50
|
+
|
|
51
|
+
visits = storage_adapter.query_visits(context, options)
|
|
52
|
+
visits.group_by { |v| v[:browser] }
|
|
53
|
+
.map { |browser, visits| { browser: browser, count: visits.size } }
|
|
54
|
+
.sort_by { |r| -r[:count] }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module BehaviorAnalytics
|
|
6
|
+
module Analytics
|
|
7
|
+
class Referrer
|
|
8
|
+
attr_reader :storage_adapter
|
|
9
|
+
|
|
10
|
+
def initialize(storage_adapter:)
|
|
11
|
+
@storage_adapter = storage_adapter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def source_breakdown(context, options = {})
|
|
15
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
16
|
+
|
|
17
|
+
visits = storage_adapter.query_visits(context, options)
|
|
18
|
+
visits.group_by { |v| extract_source(v[:referrer]) }
|
|
19
|
+
.map { |source, visits| { source: source || "direct", count: visits.size } }
|
|
20
|
+
.sort_by { |r| -r[:count] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def utm_source_breakdown(context, options = {})
|
|
24
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
25
|
+
|
|
26
|
+
visits = storage_adapter.query_visits(context, options)
|
|
27
|
+
visits.group_by { |v| v[:utm_source] }
|
|
28
|
+
.map { |source, visits| { utm_source: source || "none", count: visits.size } }
|
|
29
|
+
.sort_by { |r| -r[:count] }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def utm_campaign_breakdown(context, options = {})
|
|
33
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
34
|
+
|
|
35
|
+
visits = storage_adapter.query_visits(context, options)
|
|
36
|
+
visits.group_by { |v| v[:utm_campaign] }
|
|
37
|
+
.map { |campaign, visits| { utm_campaign: campaign || "none", count: visits.size } }
|
|
38
|
+
.sort_by { |r| -r[:count] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def search_keyword_breakdown(context, options = {})
|
|
42
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
43
|
+
|
|
44
|
+
visits = storage_adapter.query_visits(context, options)
|
|
45
|
+
visits.select { |v| v[:search_keyword] }
|
|
46
|
+
.group_by { |v| v[:search_keyword] }
|
|
47
|
+
.map { |keyword, visits| { keyword: keyword, count: visits.size } }
|
|
48
|
+
.sort_by { |r| -r[:count] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def referring_domain_breakdown(context, options = {})
|
|
52
|
+
return [] unless storage_adapter.respond_to?(:query_visits)
|
|
53
|
+
|
|
54
|
+
visits = storage_adapter.query_visits(context, options)
|
|
55
|
+
visits.group_by { |v| v[:referring_domain] }
|
|
56
|
+
.map { |domain, visits| { domain: domain || "direct", count: visits.size } }
|
|
57
|
+
.sort_by { |r| -r[:count] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def extract_source(referrer)
|
|
63
|
+
return nil unless referrer
|
|
64
|
+
|
|
65
|
+
uri = URI.parse(referrer) rescue nil
|
|
66
|
+
return nil unless uri
|
|
67
|
+
|
|
68
|
+
host = uri.host&.downcase
|
|
69
|
+
return "google" if host&.include?("google")
|
|
70
|
+
return "bing" if host&.include?("bing")
|
|
71
|
+
return "yahoo" if host&.include?("yahoo")
|
|
72
|
+
return "facebook" if host&.include?("facebook")
|
|
73
|
+
return "twitter" if host&.include?("twitter") || host&.include?("x.com")
|
|
74
|
+
return "linkedin" if host&.include?("linkedin")
|
|
75
|
+
return "reddit" if host&.include?("reddit")
|
|
76
|
+
|
|
77
|
+
host
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Cleanup
|
|
5
|
+
class RetentionPolicy
|
|
6
|
+
attr_reader :visit_retention_days, :event_retention_days
|
|
7
|
+
|
|
8
|
+
def initialize(visit_retention_days: 90, event_retention_days: 365)
|
|
9
|
+
@visit_retention_days = visit_retention_days
|
|
10
|
+
@event_retention_days = event_retention_days
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def should_delete_visit?(visit)
|
|
14
|
+
return false unless visit[:started_at] || visit[:created_at]
|
|
15
|
+
|
|
16
|
+
visit_date = visit[:started_at] || visit[:created_at]
|
|
17
|
+
cutoff_date = visit_retention_days.days.ago
|
|
18
|
+
|
|
19
|
+
visit_date < cutoff_date
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def should_delete_event?(event)
|
|
23
|
+
return false unless event[:created_at]
|
|
24
|
+
|
|
25
|
+
cutoff_date = event_retention_days.days.ago
|
|
26
|
+
event[:created_at] < cutoff_date
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def visits_cutoff_date
|
|
30
|
+
visit_retention_days.days.ago
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def events_cutoff_date
|
|
34
|
+
event_retention_days.days.ago
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Cleanup
|
|
5
|
+
class Scheduler
|
|
6
|
+
attr_reader :storage_adapter, :retention_policy
|
|
7
|
+
|
|
8
|
+
def initialize(storage_adapter:, retention_policy:)
|
|
9
|
+
@storage_adapter = storage_adapter
|
|
10
|
+
@retention_policy = retention_policy
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cleanup_visits
|
|
14
|
+
return unless storage_adapter.respond_to?(:delete_old_visits)
|
|
15
|
+
|
|
16
|
+
cutoff_date = retention_policy.visits_cutoff_date
|
|
17
|
+
deleted_count = storage_adapter.delete_old_visits(cutoff_date)
|
|
18
|
+
|
|
19
|
+
BehaviorAnalytics.configuration.debug("Deleted #{deleted_count} old visits", context: { cutoff_date: cutoff_date })
|
|
20
|
+
deleted_count
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def cleanup_events
|
|
24
|
+
return unless storage_adapter.respond_to?(:delete_old_events)
|
|
25
|
+
|
|
26
|
+
cutoff_date = retention_policy.events_cutoff_date
|
|
27
|
+
deleted_count = storage_adapter.delete_old_events(cutoff_date)
|
|
28
|
+
|
|
29
|
+
BehaviorAnalytics.configuration.debug("Deleted #{deleted_count} old events", context: { cutoff_date: cutoff_date })
|
|
30
|
+
deleted_count
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def cleanup_all
|
|
34
|
+
{
|
|
35
|
+
visits: cleanup_visits,
|
|
36
|
+
events: cleanup_events
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Detection
|
|
5
|
+
class DeviceDetector
|
|
6
|
+
def initialize(strategy: :simple)
|
|
7
|
+
@strategy = strategy
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def detect(user_agent)
|
|
11
|
+
return {} unless user_agent
|
|
12
|
+
|
|
13
|
+
case @strategy
|
|
14
|
+
when :browser
|
|
15
|
+
detect_with_browser_gem(user_agent)
|
|
16
|
+
when :user_agent_parser
|
|
17
|
+
detect_with_user_agent_parser(user_agent)
|
|
18
|
+
else
|
|
19
|
+
detect_simple(user_agent)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def detect_simple(user_agent)
|
|
26
|
+
ua = user_agent.downcase
|
|
27
|
+
result = {
|
|
28
|
+
browser: detect_browser(ua),
|
|
29
|
+
os: detect_os(ua),
|
|
30
|
+
device_type: detect_device_type(ua)
|
|
31
|
+
}
|
|
32
|
+
result.compact
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def detect_browser(ua)
|
|
36
|
+
return "Chrome" if ua.include?("chrome") && !ua.include?("edg")
|
|
37
|
+
return "Safari" if ua.include?("safari") && !ua.include?("chrome")
|
|
38
|
+
return "Firefox" if ua.include?("firefox")
|
|
39
|
+
return "Edge" if ua.include?("edg")
|
|
40
|
+
return "Opera" if ua.include?("opera") || ua.include?("opr")
|
|
41
|
+
return "Internet Explorer" if ua.include?("msie") || ua.include?("trident")
|
|
42
|
+
"Unknown"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def detect_os(ua)
|
|
46
|
+
return "iOS" if ua.include?("iphone") || ua.include?("ipad") || ua.include?("ipod")
|
|
47
|
+
return "Android" if ua.include?("android")
|
|
48
|
+
return "Windows" if ua.include?("windows")
|
|
49
|
+
return "Mac OS" if ua.include?("mac os") || ua.include?("macintosh")
|
|
50
|
+
return "Linux" if ua.include?("linux")
|
|
51
|
+
return "Unix" if ua.include?("unix")
|
|
52
|
+
"Unknown"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def detect_device_type(ua)
|
|
56
|
+
ua_lower = ua.downcase
|
|
57
|
+
return "mobile" if ua_lower.include?("mobile") || ua_lower.include?("iphone") || ua_lower.include?("android")
|
|
58
|
+
return "tablet" if ua_lower.include?("tablet") || ua_lower.include?("ipad")
|
|
59
|
+
return "desktop" if ua_lower.include?("windows") || ua_lower.include?("mac") || ua_lower.include?("linux")
|
|
60
|
+
"unknown"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def detect_with_browser_gem(user_agent)
|
|
64
|
+
begin
|
|
65
|
+
require "browser"
|
|
66
|
+
browser = Browser.new(user_agent)
|
|
67
|
+
{
|
|
68
|
+
browser: browser.name,
|
|
69
|
+
browser_version: browser.version,
|
|
70
|
+
os: browser.platform.name,
|
|
71
|
+
os_version: browser.platform.version,
|
|
72
|
+
device_type: browser.device.mobile? ? "mobile" : (browser.device.tablet? ? "tablet" : "desktop")
|
|
73
|
+
}
|
|
74
|
+
rescue LoadError, StandardError
|
|
75
|
+
detect_simple(user_agent)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def detect_with_user_agent_parser(user_agent)
|
|
80
|
+
begin
|
|
81
|
+
require "user_agent_parser"
|
|
82
|
+
parser = UserAgentParser.parse(user_agent)
|
|
83
|
+
{
|
|
84
|
+
browser: parser.family,
|
|
85
|
+
browser_version: parser.version.to_s,
|
|
86
|
+
os: parser.os&.family,
|
|
87
|
+
os_version: parser.os&.version&.to_s,
|
|
88
|
+
device_type: detect_device_type_from_parser(parser)
|
|
89
|
+
}
|
|
90
|
+
rescue LoadError, StandardError
|
|
91
|
+
detect_simple(user_agent)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_device_type_from_parser(parser)
|
|
96
|
+
device = parser.device
|
|
97
|
+
return "mobile" if device&.family&.downcase&.include?("mobile")
|
|
98
|
+
return "tablet" if device&.family&.downcase&.include?("tablet")
|
|
99
|
+
"desktop"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Detection
|
|
5
|
+
class Geolocation
|
|
6
|
+
def initialize(strategy: :simple)
|
|
7
|
+
@strategy = strategy
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def detect(ip_address)
|
|
11
|
+
return {} unless ip_address
|
|
12
|
+
return {} if ip_address == "127.0.0.1" || ip_address == "::1" || ip_address.start_with?("192.168.") || ip_address.start_with?("10.")
|
|
13
|
+
|
|
14
|
+
case @strategy
|
|
15
|
+
when :geocoder
|
|
16
|
+
detect_with_geocoder(ip_address)
|
|
17
|
+
when :maxmind
|
|
18
|
+
detect_with_maxmind(ip_address)
|
|
19
|
+
else
|
|
20
|
+
detect_simple(ip_address)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def detect_simple(ip_address)
|
|
27
|
+
# Simple detection - returns empty for now
|
|
28
|
+
# In production, you'd want to use a proper geolocation service
|
|
29
|
+
{}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def detect_with_geocoder(ip_address)
|
|
33
|
+
begin
|
|
34
|
+
require "geocoder"
|
|
35
|
+
result = Geocoder.search(ip_address).first
|
|
36
|
+
return {} unless result
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
country: result.country,
|
|
40
|
+
country_code: result.country_code,
|
|
41
|
+
city: result.city,
|
|
42
|
+
region: result.region,
|
|
43
|
+
latitude: result.latitude,
|
|
44
|
+
longitude: result.longitude,
|
|
45
|
+
timezone: result.data&.dig("timezone")
|
|
46
|
+
}.compact
|
|
47
|
+
rescue LoadError, StandardError => e
|
|
48
|
+
BehaviorAnalytics.configuration.log_error(e, context: { ip: ip_address }) if BehaviorAnalytics.configuration.debug_mode
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def detect_with_maxmind(ip_address)
|
|
54
|
+
begin
|
|
55
|
+
require "maxminddb"
|
|
56
|
+
# This would require MaxMind GeoIP2 database
|
|
57
|
+
# For now, return empty - users can implement their own
|
|
58
|
+
{}
|
|
59
|
+
rescue LoadError, StandardError => e
|
|
60
|
+
BehaviorAnalytics.configuration.log_error(e, context: { ip: ip_address }) if BehaviorAnalytics.configuration.debug_mode
|
|
61
|
+
{}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Helpers
|
|
5
|
+
module TrackingHelper
|
|
6
|
+
def track_event(event_name, properties: {}, event_type: :custom, **options)
|
|
7
|
+
context = resolve_tracking_context
|
|
8
|
+
return unless context&.valid?
|
|
9
|
+
|
|
10
|
+
tracker.track(
|
|
11
|
+
context: context,
|
|
12
|
+
event_name: event_name,
|
|
13
|
+
event_type: event_type,
|
|
14
|
+
metadata: properties,
|
|
15
|
+
**options
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def track_page_view(path: nil, properties: {}, **options)
|
|
20
|
+
path ||= request.path if respond_to?(:request)
|
|
21
|
+
track_event("page_view", properties: { path: path }.merge(properties), **options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def track_click(element:, properties: {}, **options)
|
|
25
|
+
track_event("click", properties: { element: element }.merge(properties), **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def track_form_submit(form_name:, properties: {}, **options)
|
|
29
|
+
track_event("form_submit", properties: { form_name: form_name }.merge(properties), **options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def track_conversion(conversion_name:, value: nil, properties: {}, **options)
|
|
33
|
+
props = { conversion_name: conversion_name }
|
|
34
|
+
props[:value] = value if value
|
|
35
|
+
track_event("conversion", properties: props.merge(properties), **options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def resolve_tracking_context
|
|
41
|
+
# Try to resolve context from current request/controller
|
|
42
|
+
if respond_to?(:current_user, true)
|
|
43
|
+
Context.new(
|
|
44
|
+
tenant_id: respond_to?(:current_tenant, true) ? current_tenant&.id : BehaviorAnalytics.configuration.default_tenant_id,
|
|
45
|
+
user_id: current_user&.id,
|
|
46
|
+
user_type: current_user&.account_type || current_user&.user_type
|
|
47
|
+
)
|
|
48
|
+
elsif respond_to?(:request, true) && request.respond_to?(:remote_ip)
|
|
49
|
+
# Anonymous tracking
|
|
50
|
+
Context.new(
|
|
51
|
+
tenant_id: BehaviorAnalytics.configuration.default_tenant_id
|
|
52
|
+
)
|
|
53
|
+
else
|
|
54
|
+
# Fallback to default context
|
|
55
|
+
Context.new(
|
|
56
|
+
tenant_id: BehaviorAnalytics.configuration.default_tenant_id
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tracker
|
|
62
|
+
@tracker ||= BehaviorAnalytics.create_tracker
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Identification
|
|
5
|
+
class UserResolver
|
|
6
|
+
attr_reader :visit_manager
|
|
7
|
+
|
|
8
|
+
def initialize(visit_manager:)
|
|
9
|
+
@visit_manager = visit_manager
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def identify_user(user_id, visitor_token: nil, request: nil)
|
|
13
|
+
return unless user_id
|
|
14
|
+
|
|
15
|
+
# If visitor_token is provided, link all anonymous visits to this user
|
|
16
|
+
if visitor_token
|
|
17
|
+
visit_manager.link_user_to_visits(visitor_token, user_id)
|
|
18
|
+
elsif request
|
|
19
|
+
# Try to get visitor token from request
|
|
20
|
+
visitor_token = get_visitor_token_from_request(request)
|
|
21
|
+
visit_manager.link_user_to_visits(visitor_token, user_id) if visitor_token
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def merge_visits(visitor_token, user_id)
|
|
26
|
+
visit_manager.link_user_to_visits(visitor_token, user_id)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get_visitor_token_from_request(request)
|
|
30
|
+
# Try cookie first
|
|
31
|
+
if request.respond_to?(:cookies)
|
|
32
|
+
token = request.cookies["behavior_visitor_token"]
|
|
33
|
+
return token if token && !token.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Try header
|
|
37
|
+
if request.respond_to?(:headers)
|
|
38
|
+
token = request.headers["X-Visitor-Token"]
|
|
39
|
+
return token if token && !token.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def get_user_visits(user_id, limit: 100)
|
|
46
|
+
visit_manager.find_visits_by_user(user_id, limit: limit)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_visitor_visits(visitor_token, limit: 100)
|
|
50
|
+
visit_manager.find_visits_by_visitor(visitor_token, limit: limit)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
@@ -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
|
+
|
|
@@ -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
|
+
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: behavior_analytics
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.2.
|
|
4
|
+
version: 2.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- nerdawey
|
|
@@ -84,21 +84,32 @@ files:
|
|
|
84
84
|
- behavior_analytics.gemspec
|
|
85
85
|
- db/migrate/001_create_behavior_events.rb
|
|
86
86
|
- db/migrate/002_enhance_behavior_events_v2.rb
|
|
87
|
+
- db/migrate/003_create_behavior_visits.rb
|
|
88
|
+
- db/migrate/004_add_visit_fields_to_events.rb
|
|
87
89
|
- lib/behavior_analytics.rb
|
|
88
90
|
- lib/behavior_analytics/analytics/cohorts.rb
|
|
89
91
|
- lib/behavior_analytics/analytics/engine.rb
|
|
90
92
|
- lib/behavior_analytics/analytics/funnels.rb
|
|
93
|
+
- lib/behavior_analytics/analytics/geographic.rb
|
|
94
|
+
- lib/behavior_analytics/analytics/referrer.rb
|
|
91
95
|
- lib/behavior_analytics/analytics/retention.rb
|
|
96
|
+
- lib/behavior_analytics/cleanup/retention_policy.rb
|
|
97
|
+
- lib/behavior_analytics/cleanup/scheduler.rb
|
|
92
98
|
- lib/behavior_analytics/context.rb
|
|
93
99
|
- lib/behavior_analytics/debug/inspector.rb
|
|
100
|
+
- lib/behavior_analytics/detection/device_detector.rb
|
|
101
|
+
- lib/behavior_analytics/detection/geolocation.rb
|
|
94
102
|
- lib/behavior_analytics/event.rb
|
|
95
103
|
- lib/behavior_analytics/export/csv_exporter.rb
|
|
96
104
|
- lib/behavior_analytics/export/json_exporter.rb
|
|
105
|
+
- lib/behavior_analytics/helpers/tracking_helper.rb
|
|
97
106
|
- lib/behavior_analytics/hooks/callback.rb
|
|
98
107
|
- lib/behavior_analytics/hooks/manager.rb
|
|
99
108
|
- lib/behavior_analytics/hooks/webhook.rb
|
|
109
|
+
- lib/behavior_analytics/identification/user_resolver.rb
|
|
100
110
|
- lib/behavior_analytics/integrations/rails.rb
|
|
101
111
|
- lib/behavior_analytics/integrations/rails/middleware.rb
|
|
112
|
+
- lib/behavior_analytics/javascript/client.rb
|
|
102
113
|
- lib/behavior_analytics/jobs/active_event_job.rb
|
|
103
114
|
- lib/behavior_analytics/jobs/delayed_event_job.rb
|
|
104
115
|
- lib/behavior_analytics/jobs/sidekiq_event_job.rb
|
|
@@ -123,9 +134,13 @@ files:
|
|
|
123
134
|
- lib/behavior_analytics/throttling/limiter.rb
|
|
124
135
|
- lib/behavior_analytics/tracker.rb
|
|
125
136
|
- lib/behavior_analytics/version.rb
|
|
137
|
+
- lib/behavior_analytics/visits/auto_creator.rb
|
|
138
|
+
- lib/behavior_analytics/visits/manager.rb
|
|
139
|
+
- lib/behavior_analytics/visits/visit.rb
|
|
126
140
|
- lib/generators/behavior_analytics/install_generator.rb
|
|
127
141
|
- lib/generators/behavior_analytics/templates/create_behavior_events.rb
|
|
128
142
|
- sig/behavior_analytics.rbs
|
|
143
|
+
- vendor/assets/javascripts/behavior_analytics.js
|
|
129
144
|
homepage: https://github.com/nerdawey/behavior_analytics
|
|
130
145
|
licenses:
|
|
131
146
|
- MIT
|