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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BehaviorAnalytics
4
+ VERSION = "0.1.0"
5
+ end
@@ -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
+
@@ -0,0 +1,4 @@
1
+ module BehaviorAnalytics
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end