behavior_analytics 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rspec_status +32 -0
- data/README.md +246 -0
- data/Rakefile +8 -0
- data/behavior_analytics.gemspec +41 -0
- data/db/migrate/001_create_behavior_events.rb +31 -0
- data/lib/behavior_analytics/analytics/engine.rb +183 -0
- data/lib/behavior_analytics/context.rb +32 -0
- data/lib/behavior_analytics/event.rb +55 -0
- data/lib/behavior_analytics/integrations/rails.rb +91 -0
- data/lib/behavior_analytics/query.rb +80 -0
- data/lib/behavior_analytics/storage/active_record_adapter.rb +111 -0
- data/lib/behavior_analytics/storage/adapter.rb +28 -0
- data/lib/behavior_analytics/storage/in_memory_adapter.rb +82 -0
- data/lib/behavior_analytics/tracker.rb +124 -0
- data/lib/behavior_analytics/version.rb +5 -0
- data/lib/behavior_analytics.rb +51 -0
- data/lib/generators/behavior_analytics/install_generator.rb +85 -0
- data/lib/generators/behavior_analytics/templates/create_behavior_events.rb +31 -0
- data/sig/behavior_analytics.rbs +4 -0
- metadata +124 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "active_support/concern"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module BehaviorAnalytics
|
|
9
|
+
module Integrations
|
|
10
|
+
module Rails
|
|
11
|
+
unless defined?(ActiveSupport::Concern)
|
|
12
|
+
raise LoadError, "Rails integration requires ActiveSupport. Please add 'activesupport' to your Gemfile."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
around_action :track_behavior_analytics, if: :behavior_analytics_enabled?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
def skip_behavior_tracking(options = {})
|
|
23
|
+
skip_around_action :track_behavior_analytics, options
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def track_behavior_analytics
|
|
30
|
+
start_time = Time.current
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
if should_track?
|
|
34
|
+
context = resolve_tracking_context
|
|
35
|
+
if context&.valid?
|
|
36
|
+
duration_ms = ((Time.current - start_time) * 1000).to_i
|
|
37
|
+
|
|
38
|
+
behavior_tracker.track_api_call(
|
|
39
|
+
context: context,
|
|
40
|
+
method: request.method,
|
|
41
|
+
path: request.path,
|
|
42
|
+
status_code: response.status,
|
|
43
|
+
duration_ms: duration_ms,
|
|
44
|
+
ip: request.remote_ip,
|
|
45
|
+
user_agent: request.user_agent,
|
|
46
|
+
session_id: session.id,
|
|
47
|
+
metadata: {
|
|
48
|
+
controller: controller_name,
|
|
49
|
+
action: action_name,
|
|
50
|
+
format: request.format.to_s
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def behavior_tracker
|
|
58
|
+
@behavior_tracker ||= BehaviorAnalytics.create_tracker(
|
|
59
|
+
storage_adapter: BehaviorAnalytics.configuration.storage_adapter
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_tracking_context
|
|
64
|
+
if BehaviorAnalytics.configuration.context_resolver
|
|
65
|
+
resolver_result = BehaviorAnalytics.configuration.context_resolver.call(request)
|
|
66
|
+
Context.new(resolver_result)
|
|
67
|
+
elsif respond_to?(:current_tenant, true) && respond_to?(:current_user, true)
|
|
68
|
+
Context.new(
|
|
69
|
+
tenant_id: current_tenant&.id,
|
|
70
|
+
user_id: current_user&.id,
|
|
71
|
+
user_type: current_user&.account_type || current_user&.user_type
|
|
72
|
+
)
|
|
73
|
+
else
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def should_track?
|
|
79
|
+
context = resolve_tracking_context
|
|
80
|
+
return false unless context&.valid?
|
|
81
|
+
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def behavior_analytics_enabled?
|
|
86
|
+
BehaviorAnalytics.configuration.storage_adapter.present?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
class Query
|
|
5
|
+
def initialize(storage_adapter)
|
|
6
|
+
@storage_adapter = storage_adapter
|
|
7
|
+
@context = nil
|
|
8
|
+
@options = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def for_tenant(tenant_id)
|
|
12
|
+
ensure_context
|
|
13
|
+
@context.tenant_id = tenant_id
|
|
14
|
+
self
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def for_user(user_id)
|
|
18
|
+
ensure_context
|
|
19
|
+
@context.user_id = user_id
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def for_user_type(user_type)
|
|
24
|
+
ensure_context
|
|
25
|
+
@context.user_type = user_type
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_event_name(event_name)
|
|
30
|
+
@options[:event_name] = event_name
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def with_event_type(event_type)
|
|
35
|
+
@options[:event_type] = event_type
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def since(date)
|
|
40
|
+
@options[:since] = date
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def until(date)
|
|
45
|
+
@options[:until] = date
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def in_range(start_date, end_date)
|
|
50
|
+
since(start_date).until(end_date)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def limit(n)
|
|
54
|
+
@options[:limit] = n
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def order_by(field, direction = :desc)
|
|
59
|
+
@options[:order_by] = { field: field, direction: direction }
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute
|
|
64
|
+
raise Error, "Context must have tenant_id" unless @context&.valid?
|
|
65
|
+
@storage_adapter.events_for_context(@context, @options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def count
|
|
69
|
+
raise Error, "Context must have tenant_id" unless @context&.valid?
|
|
70
|
+
@storage_adapter.event_count(@context, @options)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def ensure_context
|
|
76
|
+
@context ||= Context.new
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module BehaviorAnalytics
|
|
6
|
+
module Storage
|
|
7
|
+
class ActiveRecordAdapter < Adapter
|
|
8
|
+
def initialize(model_class: nil)
|
|
9
|
+
@model_class = model_class || default_model_class
|
|
10
|
+
ensure_model_exists!
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def save_events(events)
|
|
14
|
+
return if events.empty?
|
|
15
|
+
|
|
16
|
+
records = events.map do |event|
|
|
17
|
+
event_hash = event.is_a?(Hash) ? event : event.to_h
|
|
18
|
+
{
|
|
19
|
+
id: event_hash[:id] || SecureRandom.uuid,
|
|
20
|
+
tenant_id: event_hash[:tenant_id],
|
|
21
|
+
user_id: event_hash[:user_id],
|
|
22
|
+
user_type: event_hash[:user_type],
|
|
23
|
+
event_name: event_hash[:event_name],
|
|
24
|
+
event_type: event_hash[:event_type].to_s,
|
|
25
|
+
metadata: event_hash[:metadata] || {},
|
|
26
|
+
session_id: event_hash[:session_id],
|
|
27
|
+
ip: event_hash[:ip],
|
|
28
|
+
user_agent: event_hash[:user_agent],
|
|
29
|
+
duration_ms: event_hash[:duration_ms],
|
|
30
|
+
created_at: event_hash[:created_at] || (defined?(Time.current) ? Time.current : Time.now)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@model_class.insert_all(records)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
raise Error, "Failed to save events: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def events_for_context(context, options = {})
|
|
40
|
+
context.validate!
|
|
41
|
+
|
|
42
|
+
query = @model_class.where(tenant_id: context.tenant_id)
|
|
43
|
+
query = query.where(user_id: context.user_id) if context.user_id
|
|
44
|
+
query = query.where(user_type: context.user_type) if context.user_type
|
|
45
|
+
|
|
46
|
+
query = query.where("created_at >= ?", options[:since]) if options[:since]
|
|
47
|
+
query = query.where("created_at <= ?", options[:until]) if options[:until]
|
|
48
|
+
query = query.where(event_name: options[:event_name]) if options[:event_name]
|
|
49
|
+
query = query.where(event_type: options[:event_type].to_s) if options[:event_type]
|
|
50
|
+
|
|
51
|
+
query = apply_order_by(query, options[:order_by]) if options[:order_by]
|
|
52
|
+
query = query.limit(options[:limit]) if options[:limit]
|
|
53
|
+
|
|
54
|
+
query.map(&:to_h)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def delete_old_events(before_date)
|
|
58
|
+
@model_class.where("created_at < ?", before_date).delete_all
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def event_count(context, options = {})
|
|
62
|
+
query = build_base_query(context, options)
|
|
63
|
+
query.count
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unique_users(context, options = {})
|
|
67
|
+
query = build_base_query(context, options)
|
|
68
|
+
query.distinct.count(:user_id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def default_model_class
|
|
74
|
+
return BehaviorAnalyticsEvent if defined?(BehaviorAnalyticsEvent)
|
|
75
|
+
raise Error, "BehaviorAnalyticsEvent model not found. Please run the migration generator."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ensure_model_exists!
|
|
79
|
+
return if @model_class
|
|
80
|
+
raise Error, "Model class must be provided or BehaviorAnalyticsEvent must be defined"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_base_query(context, options)
|
|
84
|
+
context.validate!
|
|
85
|
+
|
|
86
|
+
query = @model_class.where(tenant_id: context.tenant_id)
|
|
87
|
+
query = query.where(user_id: context.user_id) if context.user_id
|
|
88
|
+
query = query.where(user_type: context.user_type) if context.user_type
|
|
89
|
+
|
|
90
|
+
query = query.where("created_at >= ?", options[:since]) if options[:since]
|
|
91
|
+
query = query.where("created_at <= ?", options[:until]) if options[:until]
|
|
92
|
+
query = query.where(event_name: options[:event_name]) if options[:event_name]
|
|
93
|
+
query = query.where(event_type: options[:event_type].to_s) if options[:event_type]
|
|
94
|
+
|
|
95
|
+
query
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def apply_order_by(query, order_by)
|
|
99
|
+
field = order_by[:field]
|
|
100
|
+
direction = order_by[:direction] || :desc
|
|
101
|
+
|
|
102
|
+
if @model_class.column_names.include?(field.to_s)
|
|
103
|
+
query.order("#{field} #{direction.to_s.upcase}")
|
|
104
|
+
else
|
|
105
|
+
query.order(created_at: direction)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Storage
|
|
5
|
+
class Adapter
|
|
6
|
+
def save_events(events)
|
|
7
|
+
raise NotImplementedError, "#{self.class} must implement #save_events"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def events_for_context(context, options = {})
|
|
11
|
+
raise NotImplementedError, "#{self.class} must implement #events_for_context"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def delete_old_events(before_date)
|
|
15
|
+
raise NotImplementedError, "#{self.class} must implement #delete_old_events"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def event_count(context, options = {})
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #event_count"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unique_users(context, options = {})
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #unique_users"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
module Storage
|
|
5
|
+
class InMemoryAdapter < Adapter
|
|
6
|
+
def initialize
|
|
7
|
+
@events = []
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def save_events(events)
|
|
12
|
+
@mutex.synchronize do
|
|
13
|
+
@events.concat(events.map(&:to_h))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def events_for_context(context, options = {})
|
|
18
|
+
context.validate!
|
|
19
|
+
events = filter_by_context(@events, context)
|
|
20
|
+
|
|
21
|
+
events = filter_by_date_range(events, options[:since], options[:until]) if options[:since] || options[:until]
|
|
22
|
+
events = filter_by_event_name(events, options[:event_name]) if options[:event_name]
|
|
23
|
+
events = filter_by_event_type(events, options[:event_type]) if options[:event_type]
|
|
24
|
+
|
|
25
|
+
events = events.sort_by { |e| e[:created_at] }.reverse
|
|
26
|
+
events = events.first(options[:limit]) if options[:limit]
|
|
27
|
+
|
|
28
|
+
events
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete_old_events(before_date)
|
|
32
|
+
@mutex.synchronize do
|
|
33
|
+
@events.reject! { |e| e[:created_at] < before_date }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def event_count(context, options = {})
|
|
38
|
+
events_for_context(context, options).count
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unique_users(context, options = {})
|
|
42
|
+
events = events_for_context(context, options)
|
|
43
|
+
events.map { |e| e[:user_id] }.compact.uniq.count
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@events.clear
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def filter_by_context(events, context)
|
|
55
|
+
events.select do |event|
|
|
56
|
+
matches_tenant = event[:tenant_id] == context.tenant_id
|
|
57
|
+
matches_user = context.user_id.nil? || event[:user_id] == context.user_id || event[:user_id].nil?
|
|
58
|
+
matches_user_type = context.user_type.nil? || event[:user_type] == context.user_type || event[:user_type].nil?
|
|
59
|
+
|
|
60
|
+
matches_tenant && matches_user && matches_user_type
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def filter_by_date_range(events, since, until_date)
|
|
65
|
+
events.select do |event|
|
|
66
|
+
(since.nil? || event[:created_at] >= since) &&
|
|
67
|
+
(until_date.nil? || event[:created_at] <= until_date)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def filter_by_event_name(events, event_name)
|
|
72
|
+
events.select { |e| e[:event_name] == event_name }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def filter_by_event_type(events, event_type)
|
|
76
|
+
event_type_sym = event_type.is_a?(Symbol) ? event_type : event_type.to_sym
|
|
77
|
+
events.select { |e| e[:event_type] == event_type_sym || e[:event_type].to_sym == event_type_sym }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "thread"
|
|
5
|
+
|
|
6
|
+
module BehaviorAnalytics
|
|
7
|
+
class Tracker
|
|
8
|
+
attr_reader :storage_adapter, :batch_size, :flush_interval, :context_resolver
|
|
9
|
+
|
|
10
|
+
def initialize(options = {})
|
|
11
|
+
@storage_adapter = options[:storage_adapter] || BehaviorAnalytics.configuration.storage_adapter || Storage::InMemoryAdapter.new
|
|
12
|
+
@batch_size = options[:batch_size] || BehaviorAnalytics.configuration.batch_size
|
|
13
|
+
@flush_interval = options[:flush_interval] || BehaviorAnalytics.configuration.flush_interval
|
|
14
|
+
@context_resolver = options[:context_resolver] || BehaviorAnalytics.configuration.context_resolver
|
|
15
|
+
|
|
16
|
+
@buffer = []
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@flush_timer = nil
|
|
19
|
+
start_flush_timer
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def track(context:, event_name:, event_type: :custom, metadata: {}, **options)
|
|
23
|
+
context = normalize_context(context)
|
|
24
|
+
context.validate!
|
|
25
|
+
|
|
26
|
+
event = Event.new(
|
|
27
|
+
tenant_id: context.tenant_id,
|
|
28
|
+
user_id: context.user_id,
|
|
29
|
+
user_type: context.user_type,
|
|
30
|
+
event_name: event_name,
|
|
31
|
+
event_type: event_type,
|
|
32
|
+
metadata: metadata.merge(context.filters),
|
|
33
|
+
session_id: options[:session_id],
|
|
34
|
+
ip: options[:ip],
|
|
35
|
+
user_agent: options[:user_agent],
|
|
36
|
+
duration_ms: options[:duration_ms]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
add_to_buffer(event)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def track_api_call(context:, method:, path:, status_code:, duration_ms: nil, **options)
|
|
43
|
+
track(
|
|
44
|
+
context: context,
|
|
45
|
+
event_name: "api_call",
|
|
46
|
+
event_type: :api_call,
|
|
47
|
+
metadata: {
|
|
48
|
+
method: method,
|
|
49
|
+
path: path,
|
|
50
|
+
status_code: status_code
|
|
51
|
+
}.merge(options[:metadata] || {}),
|
|
52
|
+
duration_ms: duration_ms,
|
|
53
|
+
**options
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def track_feature_usage(context:, feature:, metadata: {}, **options)
|
|
58
|
+
track(
|
|
59
|
+
context: context,
|
|
60
|
+
event_name: "feature_usage",
|
|
61
|
+
event_type: :feature_usage,
|
|
62
|
+
metadata: {
|
|
63
|
+
feature: feature
|
|
64
|
+
}.merge(metadata),
|
|
65
|
+
**options
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def flush
|
|
70
|
+
events_to_flush = nil
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
return if @buffer.empty?
|
|
73
|
+
events_to_flush = @buffer.dup
|
|
74
|
+
@buffer.clear
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return if events_to_flush.empty?
|
|
78
|
+
|
|
79
|
+
@storage_adapter.save_events(events_to_flush)
|
|
80
|
+
restart_flush_timer
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def analytics
|
|
84
|
+
@analytics ||= Analytics::Engine.new(@storage_adapter)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def query
|
|
88
|
+
Query.new(@storage_adapter)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def normalize_context(context)
|
|
94
|
+
return context if context.is_a?(Context)
|
|
95
|
+
Context.new(context)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def add_to_buffer(event)
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
@buffer << event
|
|
101
|
+
flush if @buffer.size >= @batch_size
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def start_flush_timer
|
|
106
|
+
return if @flush_interval.nil? || @flush_interval <= 0
|
|
107
|
+
|
|
108
|
+
@flush_timer = Thread.new do
|
|
109
|
+
loop do
|
|
110
|
+
sleep @flush_interval
|
|
111
|
+
flush
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def restart_flush_timer
|
|
117
|
+
return if @flush_interval.nil? || @flush_interval <= 0
|
|
118
|
+
|
|
119
|
+
@flush_timer&.kill
|
|
120
|
+
start_flush_timer
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "behavior_analytics/version"
|
|
4
|
+
require_relative "behavior_analytics/event"
|
|
5
|
+
require_relative "behavior_analytics/context"
|
|
6
|
+
require_relative "behavior_analytics/tracker"
|
|
7
|
+
require_relative "behavior_analytics/query"
|
|
8
|
+
require_relative "behavior_analytics/storage/adapter"
|
|
9
|
+
require_relative "behavior_analytics/storage/in_memory_adapter"
|
|
10
|
+
require_relative "behavior_analytics/storage/active_record_adapter"
|
|
11
|
+
require_relative "behavior_analytics/analytics/engine"
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
require_relative "behavior_analytics/integrations/rails"
|
|
15
|
+
rescue LoadError
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module BehaviorAnalytics
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
class Configuration
|
|
22
|
+
attr_accessor :storage_adapter, :batch_size, :flush_interval, :context_resolver, :scoring_weights
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@batch_size = 100
|
|
26
|
+
@flush_interval = 300
|
|
27
|
+
@scoring_weights = {
|
|
28
|
+
activity: 0.4,
|
|
29
|
+
unique_users: 0.3,
|
|
30
|
+
feature_diversity: 0.2,
|
|
31
|
+
time_in_trial: 0.1
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
attr_writer :configuration
|
|
38
|
+
|
|
39
|
+
def configuration
|
|
40
|
+
@configuration ||= Configuration.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def configure
|
|
44
|
+
yield(configuration) if block_given?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_tracker(options = {})
|
|
48
|
+
Tracker.new(options)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "rails/generators"
|
|
5
|
+
require "rails/generators/active_record"
|
|
6
|
+
rescue LoadError
|
|
7
|
+
raise LoadError, "Rails generators require Rails. Please add 'rails' to your Gemfile."
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module BehaviorAnalytics
|
|
11
|
+
module Generators
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
include ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("../templates", __FILE__)
|
|
16
|
+
|
|
17
|
+
def create_migration
|
|
18
|
+
migration_template "create_behavior_events.rb",
|
|
19
|
+
"db/migrate/create_behavior_events.rb"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_initializer
|
|
23
|
+
create_file "config/initializers/behavior_analytics.rb", <<~RUBY
|
|
24
|
+
BehaviorAnalytics.configure do |config|
|
|
25
|
+
config.storage_adapter = BehaviorAnalytics::Storage::ActiveRecordAdapter.new(
|
|
26
|
+
model_class: BehaviorAnalyticsEvent
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
config.batch_size = 100
|
|
30
|
+
config.flush_interval = 300
|
|
31
|
+
|
|
32
|
+
config.context_resolver = ->(request) {
|
|
33
|
+
{
|
|
34
|
+
tenant_id: current_tenant&.id,
|
|
35
|
+
user_id: current_user&.id,
|
|
36
|
+
user_type: current_user&.account_type
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
config.scoring_weights = {
|
|
41
|
+
activity: 0.4,
|
|
42
|
+
unique_users: 0.3,
|
|
43
|
+
feature_diversity: 0.2,
|
|
44
|
+
time_in_trial: 0.1
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
RUBY
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_model
|
|
51
|
+
create_file "app/models/behavior_analytics_event.rb", <<~RUBY
|
|
52
|
+
class BehaviorAnalyticsEvent < ApplicationRecord
|
|
53
|
+
self.table_name = "behavior_events"
|
|
54
|
+
|
|
55
|
+
scope :for_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
|
|
56
|
+
scope :for_user, ->(user_id) { where(user_id: user_id) }
|
|
57
|
+
scope :for_user_type, ->(user_type) { where(user_type: user_type) }
|
|
58
|
+
scope :with_event_name, ->(name) { where(event_name: name) }
|
|
59
|
+
scope :with_event_type, ->(type) { where(event_type: type.to_s) }
|
|
60
|
+
scope :since, ->(date) { where("created_at >= ?", date) }
|
|
61
|
+
scope :until, ->(date) { where("created_at <= ?", date) }
|
|
62
|
+
|
|
63
|
+
def to_h
|
|
64
|
+
{
|
|
65
|
+
id: id,
|
|
66
|
+
tenant_id: tenant_id,
|
|
67
|
+
user_id: user_id,
|
|
68
|
+
user_type: user_type,
|
|
69
|
+
event_name: event_name,
|
|
70
|
+
event_type: event_type.to_sym,
|
|
71
|
+
metadata: metadata || {},
|
|
72
|
+
session_id: session_id,
|
|
73
|
+
ip: ip,
|
|
74
|
+
user_agent: user_agent,
|
|
75
|
+
duration_ms: duration_ms,
|
|
76
|
+
created_at: created_at
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
RUBY
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBehaviorEvents < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
create_table :behavior_events do |t|
|
|
6
|
+
t.string :tenant_id, null: false
|
|
7
|
+
t.string :user_id
|
|
8
|
+
t.string :user_type
|
|
9
|
+
t.string :event_name, null: false
|
|
10
|
+
t.string :event_type, null: false
|
|
11
|
+
t.jsonb :metadata, default: {}
|
|
12
|
+
t.string :session_id
|
|
13
|
+
t.string :ip
|
|
14
|
+
t.string :user_agent
|
|
15
|
+
t.integer :duration_ms
|
|
16
|
+
t.datetime :created_at, null: false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
add_index :behavior_events, :tenant_id
|
|
20
|
+
add_index :behavior_events, :user_id
|
|
21
|
+
add_index :behavior_events, :user_type
|
|
22
|
+
add_index :behavior_events, :event_name
|
|
23
|
+
add_index :behavior_events, :event_type
|
|
24
|
+
add_index :behavior_events, :session_id
|
|
25
|
+
add_index :behavior_events, :created_at
|
|
26
|
+
add_index :behavior_events, [:tenant_id, :created_at]
|
|
27
|
+
add_index :behavior_events, [:tenant_id, :user_id, :created_at]
|
|
28
|
+
add_index :behavior_events, [:tenant_id, :event_name, :created_at]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|