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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bead5d234d7e827224b697cda617f6ce4e78839e34e419350fc91a9b1c50732d
4
- data.tar.gz: 0c6bf809b8321a256f15d3830d43c2d9955acb9c946cee6dfe7a884603b3037a
3
+ metadata.gz: 658a8852d807dd03e050e0c773ff213459e168dfd37ecb14d707d381ada8362b
4
+ data.tar.gz: c7398303e1217051c62384c7ac78e6c43dc5da35ed689917d7b5bab99b4f4b41
5
5
  SHA512:
6
- metadata.gz: 29c2f5c9e7897e5d3bddda1c64f2cc67b9950c69581435c0dfbf98574f6bc2fc74954685b81499f19f58f62f5754c1c4a846dc4f79029e78ac2b799380be34a6
7
- data.tar.gz: fa54c278f349cafb3068c388c4e9ca2d34bfd091a88a549ee431fc2546604da4b8a84578f362adc24e546ae6122613cadc524d40aa7444d76f9e50de417543f6
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
+
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BehaviorAnalytics
4
- VERSION = "2.2.1"
4
+ VERSION = "2.2.2"
5
5
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ module Visits
5
+ class AutoCreator
6
+ attr_reader :manager, :cookie_name
7
+
8
+ def initialize(manager:, cookie_name: "behavior_visitor_token")
9
+ @manager = manager
10
+ @cookie_name = cookie_name
11
+ end
12
+
13
+ def get_or_create_visit(request:, tenant_id: nil, user_id: nil)
14
+ visitor_token = get_or_create_visitor_token(request)
15
+
16
+ utm_params = extract_utm_params(request)
17
+
18
+ manager.find_or_create_visit(
19
+ visitor_token: visitor_token,
20
+ tenant_id: tenant_id,
21
+ user_id: user_id,
22
+ ip: request.ip,
23
+ user_agent: request.user_agent,
24
+ referrer: request.referer,
25
+ landing_page: request.path,
26
+ utm_params: utm_params
27
+ )
28
+ end
29
+
30
+ def get_or_create_visitor_token(request)
31
+ # Try to get from cookie
32
+ if request.respond_to?(:cookies)
33
+ token = request.cookies[cookie_name]
34
+ return token if token && !token.empty?
35
+ end
36
+
37
+ # Try to get from headers (for API requests)
38
+ if request.respond_to?(:headers)
39
+ token = request.headers["X-Visitor-Token"]
40
+ return token if token && !token.empty?
41
+ end
42
+
43
+ # Generate new token
44
+ SecureRandom.hex(16)
45
+ end
46
+
47
+ def set_visitor_token_cookie(response, visitor_token)
48
+ return unless response.respond_to?(:set_cookie)
49
+
50
+ # Set cookie for 2 years
51
+ secure = if defined?(Rails)
52
+ Rails.env.production?
53
+ else
54
+ ENV["RAILS_ENV"] == "production" || ENV["RACK_ENV"] == "production"
55
+ end
56
+
57
+ response.set_cookie(
58
+ cookie_name,
59
+ value: visitor_token,
60
+ expires: 2.years.from_now,
61
+ httponly: true,
62
+ secure: secure,
63
+ same_site: :lax
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def extract_utm_params(request)
70
+ params = {}
71
+
72
+ if request.respond_to?(:params)
73
+ params[:utm_source] = request.params["utm_source"]
74
+ params[:utm_medium] = request.params["utm_medium"]
75
+ params[:utm_campaign] = request.params["utm_campaign"]
76
+ params[:utm_term] = request.params["utm_term"]
77
+ params[:utm_content] = request.params["utm_content"]
78
+ end
79
+
80
+ params.compact
81
+ end
82
+ end
83
+ end
84
+ end
85
+
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module BehaviorAnalytics
7
+ module Visits
8
+ class Manager
9
+ attr_reader :storage_adapter, :visit_duration
10
+
11
+ def initialize(storage_adapter:, visit_duration: 30.minutes, device_detector: nil, geolocation: nil)
12
+ @storage_adapter = storage_adapter
13
+ @visit_duration = visit_duration
14
+ @device_detector = device_detector
15
+ @geolocation = geolocation
16
+ end
17
+
18
+ def find_or_create_visit(visitor_token:, tenant_id: nil, user_id: nil, ip: nil, user_agent: nil,
19
+ referrer: nil, landing_page: nil, utm_params: {})
20
+ # Try to find active visit for this visitor
21
+ active_visit = find_active_visit(visitor_token, user_id)
22
+
23
+ if active_visit && !active_visit.expired?(visit_duration)
24
+ # Update visit if user logged in
25
+ if user_id && active_visit.user_id.nil?
26
+ active_visit.user_id = user_id
27
+ save_visit(active_visit)
28
+ end
29
+ return active_visit
30
+ end
31
+
32
+ # Detect device and geolocation if enabled
33
+ device_info = {}
34
+ geo_info = {}
35
+
36
+ if BehaviorAnalytics.configuration.track_device_info && @device_detector && user_agent
37
+ device_info = @device_detector.detect(user_agent)
38
+ end
39
+
40
+ if BehaviorAnalytics.configuration.track_geolocation && @geolocation && ip
41
+ geo_info = @geolocation.detect(ip)
42
+ end
43
+
44
+ # Create new visit
45
+ visit = Visit.new(
46
+ visitor_token: visitor_token,
47
+ tenant_id: tenant_id || BehaviorAnalytics.configuration.default_tenant_id,
48
+ user_id: user_id,
49
+ ip: ip,
50
+ user_agent: user_agent,
51
+ referrer: referrer,
52
+ landing_page: landing_page || referrer,
53
+ browser: device_info[:browser],
54
+ os: device_info[:os],
55
+ device_type: device_info[:device_type],
56
+ country: geo_info[:country],
57
+ city: geo_info[:city],
58
+ utm_source: utm_params[:utm_source],
59
+ utm_medium: utm_params[:utm_medium],
60
+ utm_campaign: utm_params[:utm_campaign],
61
+ utm_term: utm_params[:utm_term],
62
+ utm_content: utm_params[:utm_content],
63
+ referring_domain: extract_domain(referrer),
64
+ search_keyword: extract_search_keyword(referrer)
65
+ )
66
+
67
+ save_visit(visit)
68
+ visit
69
+ end
70
+
71
+ def find_active_visit(visitor_token, user_id = nil)
72
+ return nil unless storage_adapter.respond_to?(:find_active_visit)
73
+
74
+ visit_data = storage_adapter.find_active_visit(visitor_token, user_id, visit_duration)
75
+ return nil unless visit_data
76
+
77
+ Visit.new(visit_data)
78
+ end
79
+
80
+ def save_visit(visit)
81
+ if storage_adapter.respond_to?(:save_visit)
82
+ storage_adapter.save_visit(visit.to_h)
83
+ end
84
+ end
85
+
86
+ def end_visit(visit_token)
87
+ visit = find_visit_by_token(visit_token)
88
+ return unless visit
89
+
90
+ visit.end!
91
+ save_visit(visit)
92
+ end
93
+
94
+ def find_visit_by_token(visit_token)
95
+ return nil unless storage_adapter.respond_to?(:find_visit_by_token)
96
+
97
+ visit_data = storage_adapter.find_visit_by_token(visit_token)
98
+ return nil unless visit_data
99
+
100
+ Visit.new(visit_data)
101
+ end
102
+
103
+ def link_user_to_visits(visitor_token, user_id)
104
+ return unless storage_adapter.respond_to?(:link_user_to_visits)
105
+
106
+ storage_adapter.link_user_to_visits(visitor_token, user_id)
107
+ end
108
+
109
+ def find_visits_by_user(user_id, limit: 100)
110
+ return [] unless storage_adapter.respond_to?(:find_visits_by_user)
111
+
112
+ visits_data = storage_adapter.find_visits_by_user(user_id, limit: limit)
113
+ visits_data.map { |data| Visit.new(data) }
114
+ end
115
+
116
+ def find_visits_by_visitor(visitor_token, limit: 100)
117
+ return [] unless storage_adapter.respond_to?(:find_visits_by_visitor)
118
+
119
+ visits_data = storage_adapter.find_visits_by_visitor(visitor_token, limit: limit)
120
+ visits_data.map { |data| Visit.new(data) }
121
+ end
122
+
123
+ private
124
+
125
+ def extract_domain(url)
126
+ return nil unless url
127
+ URI.parse(url).host rescue nil
128
+ end
129
+
130
+ def extract_search_keyword(referrer)
131
+ return nil unless referrer
132
+
133
+ uri = URI.parse(referrer) rescue nil
134
+ return nil unless uri
135
+
136
+ # Extract from Google, Bing, etc.
137
+ if uri.host&.include?("google")
138
+ params = URI.decode_www_form(uri.query || "").to_h
139
+ params["q"] || params["query"]
140
+ elsif uri.host&.include?("bing")
141
+ params = URI.decode_www_form(uri.query || "").to_h
142
+ params["q"]
143
+ else
144
+ nil
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ module Visits
5
+ class Visit
6
+ attr_accessor :visit_token, :visitor_token, :tenant_id, :user_id, :ip, :user_agent,
7
+ :referrer, :landing_page, :browser, :os, :device_type, :country, :city,
8
+ :utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content,
9
+ :referring_domain, :search_keyword, :started_at, :ended_at, :created_at, :updated_at
10
+
11
+ def initialize(attributes = {})
12
+ @visit_token = attributes[:visit_token] || SecureRandom.hex(16)
13
+ @visitor_token = attributes[:visitor_token] || SecureRandom.hex(16)
14
+ @tenant_id = attributes[:tenant_id]
15
+ @user_id = attributes[:user_id]
16
+ @ip = attributes[:ip]
17
+ @user_agent = attributes[:user_agent]
18
+ @referrer = attributes[:referrer]
19
+ @landing_page = attributes[:landing_page]
20
+ @browser = attributes[:browser]
21
+ @os = attributes[:os]
22
+ @device_type = attributes[:device_type]
23
+ @country = attributes[:country]
24
+ @city = attributes[:city]
25
+ @utm_source = attributes[:utm_source]
26
+ @utm_medium = attributes[:utm_medium]
27
+ @utm_campaign = attributes[:utm_campaign]
28
+ @utm_term = attributes[:utm_term]
29
+ @utm_content = attributes[:utm_content]
30
+ @referring_domain = attributes[:referring_domain]
31
+ @search_keyword = attributes[:search_keyword]
32
+ @started_at = attributes[:started_at] || Time.now
33
+ @ended_at = attributes[:ended_at]
34
+ @created_at = attributes[:created_at] || Time.now
35
+ @updated_at = attributes[:updated_at] || Time.now
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ visit_token: visit_token,
41
+ visitor_token: visitor_token,
42
+ tenant_id: tenant_id,
43
+ user_id: user_id,
44
+ ip: ip,
45
+ user_agent: user_agent,
46
+ referrer: referrer,
47
+ landing_page: landing_page,
48
+ browser: browser,
49
+ os: os,
50
+ device_type: device_type,
51
+ country: country,
52
+ city: city,
53
+ utm_source: utm_source,
54
+ utm_medium: utm_medium,
55
+ utm_campaign: utm_campaign,
56
+ utm_term: utm_term,
57
+ utm_content: utm_content,
58
+ referring_domain: referring_domain,
59
+ search_keyword: search_keyword,
60
+ started_at: started_at,
61
+ ended_at: ended_at,
62
+ created_at: created_at,
63
+ updated_at: updated_at
64
+ }.compact
65
+ end
66
+
67
+ def duration_seconds
68
+ return nil unless ended_at
69
+ (ended_at - started_at).to_i
70
+ end
71
+
72
+ def active?
73
+ ended_at.nil?
74
+ end
75
+
76
+ def expired?(inactivity_duration = 30.minutes)
77
+ return false unless active?
78
+ (Time.now - started_at) > inactivity_duration
79
+ end
80
+
81
+ def end!
82
+ @ended_at = Time.now
83
+ @updated_at = Time.now
84
+ end
85
+ end
86
+ end
87
+ end
88
+
@@ -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.1
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