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
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
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Behavior Analytics
|
|
2
2
|
|
|
3
|
-
A comprehensive Ruby gem for tracking user behavior events with multi-tenant support, visit/session management, device detection, geographic analytics, and advanced querying capabilities
|
|
3
|
+
A comprehensive Ruby gem for tracking user behavior events with multi-tenant support, visit/session management, device detection, geographic analytics, and advanced querying capabilities with enterprise-grade features.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -35,7 +35,7 @@ A comprehensive Ruby gem for tracking user behavior events with multi-tenant sup
|
|
|
35
35
|
- **Event Streaming**: Real-time event pub/sub system
|
|
36
36
|
|
|
37
37
|
### Developer Experience
|
|
38
|
-
- **Simplified API**:
|
|
38
|
+
- **Simplified API**: Simple tracking API with automatic context resolution
|
|
39
39
|
- **Rails Integration**: Automatic API call tracking via middleware with selective tracking
|
|
40
40
|
- **Query Interface**: Fluent query builder for filtering events with advanced aggregations
|
|
41
41
|
- **JavaScript Client**: Frontend tracking with automatic page views and click tracking
|
|
@@ -117,7 +117,7 @@ BehaviorAnalytics.configure do |config|
|
|
|
117
117
|
time_in_trial: 0.1
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
# Visit/Session Management
|
|
120
|
+
# Visit/Session Management
|
|
121
121
|
config.track_visits = true # Enable visit tracking
|
|
122
122
|
config.visit_duration = 30.minutes # Visit expires after 30 min of inactivity
|
|
123
123
|
config.track_device_info = true # Auto-detect device, browser, OS
|
|
@@ -167,9 +167,9 @@ Or use the inline script generator:
|
|
|
167
167
|
|
|
168
168
|
## Usage
|
|
169
169
|
|
|
170
|
-
### Simplified API
|
|
170
|
+
### Simplified API
|
|
171
171
|
|
|
172
|
-
The gem provides a simplified API
|
|
172
|
+
The gem provides a simplified API for easy tracking:
|
|
173
173
|
|
|
174
174
|
```ruby
|
|
175
175
|
# Simple event tracking with automatic context resolution
|
data/behavior_analytics.gemspec
CHANGED
|
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
|
|
|
10
10
|
|
|
11
11
|
spec.summary = "Track user behavior events with visit tracking, device detection, and comprehensive analytics"
|
|
12
12
|
spec.description = "A comprehensive Ruby gem for tracking user behavior events with multi-tenant support, " \
|
|
13
|
-
"visit/session management
|
|
13
|
+
"visit/session management, device & browser detection, geographic analytics, " \
|
|
14
14
|
"referrer tracking, and advanced analytics (engagement scores, funnels, cohorts, retention). " \
|
|
15
15
|
"Supports API calls, feature usage, custom events, and JavaScript client-side tracking."
|
|
16
16
|
spec.homepage = "https://github.com/nerdawey/behavior_analytics"
|
|
@@ -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
|
+
|