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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3e4f126ff83e60165d7b93e152c0d3ebd20f354c0d25aad69aaa88503359ea8a
|
|
4
|
+
data.tar.gz: 1222c310f8447a36c79566f5d5aeaea504c057eecdda81f4d2b569fa62c1bc99
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3fe2f936597d8c9b5016e527aa5b3ed86fb3b82f2b7cf5ba41df66c56a5d557a05758069bbaf5051e4960e2f01ad9e41fb4c3251c1f7c7d1808691c90a3272ec
|
|
7
|
+
data.tar.gz: 3e1c42b6e12f012333a44085395f070f7f66ea40851ed73d4f994dcda2acd7013cf0db22ad908461665cc9dd04977c1412e6d6bcdf7f8c334cc4889a22b848a6
|
data/.rspec
ADDED
data/.rspec_status
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
example_id | status | run_time |
|
|
2
|
+
----------------------------------------------- | ------ | --------------- |
|
|
3
|
+
./spec/analytics/engine_spec.rb[1:1:1] | passed | 0.00095 seconds |
|
|
4
|
+
./spec/analytics/engine_spec.rb[1:2:1] | passed | 0.0003 seconds |
|
|
5
|
+
./spec/analytics/engine_spec.rb[1:3:1] | passed | 0.00026 seconds |
|
|
6
|
+
./spec/analytics/engine_spec.rb[1:4:1] | passed | 0.00118 seconds |
|
|
7
|
+
./spec/analytics/engine_spec.rb[1:5:1] | passed | 0.00021 seconds |
|
|
8
|
+
./spec/analytics/engine_spec.rb[1:6:1] | passed | 0.00019 seconds |
|
|
9
|
+
./spec/behavior_analytics_spec.rb[1:1] | passed | 0.0005 seconds |
|
|
10
|
+
./spec/behavior_analytics_spec.rb[1:2:1] | passed | 0.00035 seconds |
|
|
11
|
+
./spec/behavior_analytics_spec.rb[1:3:1] | passed | 0.00009 seconds |
|
|
12
|
+
./spec/context_spec.rb[1:1:1] | passed | 0.00005 seconds |
|
|
13
|
+
./spec/context_spec.rb[1:1:2] | passed | 0.00004 seconds |
|
|
14
|
+
./spec/context_spec.rb[1:1:3] | passed | 0.00004 seconds |
|
|
15
|
+
./spec/context_spec.rb[1:2:1] | passed | 0.00005 seconds |
|
|
16
|
+
./spec/context_spec.rb[1:2:2] | passed | 0.00005 seconds |
|
|
17
|
+
./spec/context_spec.rb[1:3:1] | passed | 0.00106 seconds |
|
|
18
|
+
./spec/event_spec.rb[1:1:1] | passed | 0.0001 seconds |
|
|
19
|
+
./spec/event_spec.rb[1:1:2] | passed | 0.00013 seconds |
|
|
20
|
+
./spec/event_spec.rb[1:1:3] | passed | 0.00012 seconds |
|
|
21
|
+
./spec/event_spec.rb[1:1:4] | passed | 0.00008 seconds |
|
|
22
|
+
./spec/event_spec.rb[1:2:1] | passed | 0.00007 seconds |
|
|
23
|
+
./spec/storage/in_memory_adapter_spec.rb[1:1:1] | passed | 0.00009 seconds |
|
|
24
|
+
./spec/storage/in_memory_adapter_spec.rb[1:2:1] | passed | 0.00009 seconds |
|
|
25
|
+
./spec/storage/in_memory_adapter_spec.rb[1:2:2] | passed | 0.00016 seconds |
|
|
26
|
+
./spec/storage/in_memory_adapter_spec.rb[1:3:1] | passed | 0.00012 seconds |
|
|
27
|
+
./spec/tracker_spec.rb[1:1:1] | passed | 0.00056 seconds |
|
|
28
|
+
./spec/tracker_spec.rb[1:1:2] | passed | 0.00067 seconds |
|
|
29
|
+
./spec/tracker_spec.rb[1:2:1] | passed | 0.0003 seconds |
|
|
30
|
+
./spec/tracker_spec.rb[1:3:1] | passed | 0.00025 seconds |
|
|
31
|
+
./spec/tracker_spec.rb[1:4:1] | passed | 0.00024 seconds |
|
|
32
|
+
./spec/tracker_spec.rb[1:5:1] | passed | 0.00019 seconds |
|
data/README.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Behavior Analytics
|
|
2
|
+
|
|
3
|
+
A Ruby gem for tracking user behavior events with multi-tenant support, computing analytics (engagement scores, time-based trends, feature usage), and supporting API calls, feature usage, and custom events.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Flexible Context Tracking**: Track events with multi-tenant support, user types, and custom filters
|
|
8
|
+
- **Event Buffering**: Efficient batch processing with configurable buffer size and flush intervals
|
|
9
|
+
- **Comprehensive Analytics**:
|
|
10
|
+
- Event counts and aggregations
|
|
11
|
+
- Engagement scoring with customizable weights
|
|
12
|
+
- Time-based analytics (hourly, daily, weekly, monthly)
|
|
13
|
+
- Feature usage statistics
|
|
14
|
+
- **Storage Adapters**:
|
|
15
|
+
- ActiveRecord adapter for production use
|
|
16
|
+
- In-memory adapter for testing
|
|
17
|
+
- **Rails Integration**: Automatic API call tracking via middleware
|
|
18
|
+
- **Query Interface**: Fluent query builder for filtering events
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your application's Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'behavior_analytics'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
And then execute:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
$ bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or install it yourself as:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
$ gem install behavior_analytics
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Rails Setup
|
|
41
|
+
|
|
42
|
+
### 1. Run the generator
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
rails generate behavior_analytics:install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This will:
|
|
49
|
+
- Create a migration for the `behavior_events` table
|
|
50
|
+
- Create an initializer at `config/initializers/behavior_analytics.rb`
|
|
51
|
+
- Create a model at `app/models/behavior_analytics_event.rb`
|
|
52
|
+
|
|
53
|
+
### 2. Run the migration
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
rails db:migrate
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 3. Configure the initializer
|
|
60
|
+
|
|
61
|
+
Edit `config/initializers/behavior_analytics.rb`:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
BehaviorAnalytics.configure do |config|
|
|
65
|
+
# Configure storage adapter (required)
|
|
66
|
+
config.storage_adapter = BehaviorAnalytics::Storage::ActiveRecordAdapter.new(
|
|
67
|
+
model_class: BehaviorAnalyticsEvent
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Configure batching
|
|
71
|
+
config.batch_size = 100
|
|
72
|
+
config.flush_interval = 300 # 5 minutes
|
|
73
|
+
|
|
74
|
+
# Configure context resolver (optional)
|
|
75
|
+
config.context_resolver = ->(request) {
|
|
76
|
+
{
|
|
77
|
+
tenant_id: current_tenant&.id,
|
|
78
|
+
user_id: current_user&.id,
|
|
79
|
+
user_type: current_user&.account_type
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Configure engagement scoring weights
|
|
84
|
+
config.scoring_weights = {
|
|
85
|
+
activity: 0.4,
|
|
86
|
+
unique_users: 0.3,
|
|
87
|
+
feature_diversity: 0.2,
|
|
88
|
+
time_in_trial: 0.1
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 4. Include in ApplicationController
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class ApplicationController < ActionController::Base
|
|
97
|
+
include BehaviorAnalytics::Integrations::Rails
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Usage
|
|
102
|
+
|
|
103
|
+
### Basic Tracking
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# Create a tracker
|
|
107
|
+
tracker = BehaviorAnalytics.create_tracker
|
|
108
|
+
|
|
109
|
+
# Create a context
|
|
110
|
+
context = BehaviorAnalytics::Context.new(
|
|
111
|
+
tenant_id: "org_123",
|
|
112
|
+
user_id: "user_456",
|
|
113
|
+
user_type: "trial"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Track a custom event
|
|
117
|
+
tracker.track(
|
|
118
|
+
context: context,
|
|
119
|
+
event_name: "project_created",
|
|
120
|
+
metadata: { project_id: 789 }
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Track an API call
|
|
124
|
+
tracker.track_api_call(
|
|
125
|
+
context: context,
|
|
126
|
+
method: "POST",
|
|
127
|
+
path: "/api/projects",
|
|
128
|
+
status_code: 201,
|
|
129
|
+
duration_ms: 150
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Track feature usage
|
|
133
|
+
tracker.track_feature_usage(
|
|
134
|
+
context: context,
|
|
135
|
+
feature: "advanced_search",
|
|
136
|
+
metadata: { query: "..." }
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Flush buffered events
|
|
140
|
+
tracker.flush
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Analytics
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
analytics = tracker.analytics
|
|
147
|
+
|
|
148
|
+
# Basic counts
|
|
149
|
+
event_count = analytics.event_count(context, since: 7.days.ago)
|
|
150
|
+
unique_users = analytics.unique_users(context)
|
|
151
|
+
active_days = analytics.active_days(context)
|
|
152
|
+
|
|
153
|
+
# Engagement scoring
|
|
154
|
+
score = analytics.engagement_score(context)
|
|
155
|
+
# => 75.5
|
|
156
|
+
|
|
157
|
+
# Time-based analytics
|
|
158
|
+
timeline = analytics.activity_timeline(context, period: :daily)
|
|
159
|
+
# => { 2024-01-01 => 10, 2024-01-02 => 15, ... }
|
|
160
|
+
|
|
161
|
+
daily = analytics.daily_activity(context, date_range: 7.days.ago..Time.current)
|
|
162
|
+
|
|
163
|
+
# Feature usage
|
|
164
|
+
feature_stats = analytics.feature_usage_stats(context)
|
|
165
|
+
# => { "projects" => 25, "search" => 10, ... }
|
|
166
|
+
|
|
167
|
+
top_features = analytics.top_features(context, limit: 10)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Query Interface
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
query = tracker.query
|
|
174
|
+
|
|
175
|
+
# Build complex queries
|
|
176
|
+
events = query
|
|
177
|
+
.for_tenant("org_123")
|
|
178
|
+
.for_user_type("trial")
|
|
179
|
+
.with_event_type(:feature_usage)
|
|
180
|
+
.since(7.days.ago)
|
|
181
|
+
.limit(100)
|
|
182
|
+
.execute
|
|
183
|
+
|
|
184
|
+
# Count events
|
|
185
|
+
count = query
|
|
186
|
+
.for_tenant("org_123")
|
|
187
|
+
.with_event_name("project_created")
|
|
188
|
+
.count
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Custom Storage Adapter
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class MyCustomAdapter < BehaviorAnalytics::Storage::Adapter
|
|
195
|
+
def save_events(events)
|
|
196
|
+
# Your implementation
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def events_for_context(context, options = {})
|
|
200
|
+
# Your implementation
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ... implement other required methods
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
tracker = BehaviorAnalytics.create_tracker(
|
|
207
|
+
storage_adapter: MyCustomAdapter.new
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Configuration Options
|
|
212
|
+
|
|
213
|
+
- `storage_adapter`: Storage adapter instance (required)
|
|
214
|
+
- `batch_size`: Number of events to buffer before flushing (default: 100)
|
|
215
|
+
- `flush_interval`: Seconds between automatic flushes (default: 300)
|
|
216
|
+
- `context_resolver`: Lambda/proc to resolve context from requests
|
|
217
|
+
- `scoring_weights`: Hash of weights for engagement scoring
|
|
218
|
+
|
|
219
|
+
## Event Types
|
|
220
|
+
|
|
221
|
+
- `:api_call` - HTTP API requests
|
|
222
|
+
- `:feature_usage` - Feature usage events
|
|
223
|
+
- `:custom` - Custom business events
|
|
224
|
+
|
|
225
|
+
## Context
|
|
226
|
+
|
|
227
|
+
The `Context` class encapsulates tracking context:
|
|
228
|
+
|
|
229
|
+
- `tenant_id` (required) - Multi-tenant identifier
|
|
230
|
+
- `user_id` (optional) - User identifier
|
|
231
|
+
- `user_type` (optional) - User type (e.g., "trial", "premium", "admin")
|
|
232
|
+
- `filters` (optional) - Hash of custom filter criteria
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
237
|
+
|
|
238
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
239
|
+
|
|
240
|
+
## Contributing
|
|
241
|
+
|
|
242
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/nerdawey/behavior_analytics.
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/behavior_analytics/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "behavior_analytics"
|
|
7
|
+
spec.version = BehaviorAnalytics::VERSION
|
|
8
|
+
spec.authors = ["nerdawey"]
|
|
9
|
+
spec.email = ["nerdawy@icloud.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Track user behavior events with flexible context filtering and comprehensive analytics"
|
|
12
|
+
spec.description = "A Ruby gem for tracking user behavior events with multi-tenant support, " \
|
|
13
|
+
"computing analytics (engagement scores, time-based trends, feature usage), " \
|
|
14
|
+
"and supporting API calls, feature usage, and custom events."
|
|
15
|
+
spec.homepage = "https://github.com/nerdawey/behavior_analytics"
|
|
16
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
17
|
+
|
|
18
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
21
|
+
|
|
22
|
+
# Specify which files should be added to the gem when it is released.
|
|
23
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
24
|
+
spec.files = Dir.chdir(__dir__) do
|
|
25
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
26
|
+
(File.expand_path(f) == __FILE__) ||
|
|
27
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
# Runtime dependencies
|
|
35
|
+
spec.add_dependency "activesupport", ">= 6.0"
|
|
36
|
+
|
|
37
|
+
# Development dependencies
|
|
38
|
+
spec.add_development_dependency "activerecord", ">= 6.0"
|
|
39
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
40
|
+
spec.add_development_dependency "sqlite3", "~> 1.6"
|
|
41
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBehaviorEvents < ActiveRecord::Migration[7.0]
|
|
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,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "active_support/core_ext/date"
|
|
6
|
+
require "active_support/core_ext/time"
|
|
7
|
+
|
|
8
|
+
module BehaviorAnalytics
|
|
9
|
+
module Analytics
|
|
10
|
+
class Engine
|
|
11
|
+
def initialize(storage_adapter)
|
|
12
|
+
@storage_adapter = storage_adapter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def event_count(context, options = {})
|
|
16
|
+
normalized_context = normalize_context(context)
|
|
17
|
+
normalized_context.validate!
|
|
18
|
+
@storage_adapter.event_count(normalized_context, options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def unique_users(context, options = {})
|
|
22
|
+
normalized_context = normalize_context(context)
|
|
23
|
+
normalized_context.validate!
|
|
24
|
+
@storage_adapter.unique_users(normalized_context, options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def active_days(context, options = {})
|
|
28
|
+
normalized_context = normalize_context(context)
|
|
29
|
+
normalized_context.validate!
|
|
30
|
+
|
|
31
|
+
events = @storage_adapter.events_for_context(normalized_context, options)
|
|
32
|
+
return 0 if events.empty?
|
|
33
|
+
|
|
34
|
+
dates = events.map { |e| date_from_event(e) }.compact.uniq
|
|
35
|
+
dates.count
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def engagement_score(context, options = {})
|
|
39
|
+
normalized_context = normalize_context(context)
|
|
40
|
+
normalized_context.validate!
|
|
41
|
+
|
|
42
|
+
weights = options[:weights] || BehaviorAnalytics.configuration.scoring_weights
|
|
43
|
+
|
|
44
|
+
total_events = event_count(normalized_context, options)
|
|
45
|
+
unique_users_count = unique_users(normalized_context, options)
|
|
46
|
+
active_days_count = active_days(normalized_context, options)
|
|
47
|
+
feature_diversity = feature_count(normalized_context, options)
|
|
48
|
+
|
|
49
|
+
events_score = [total_events / 100.0, 1.0].min
|
|
50
|
+
users_score = [unique_users_count / 10.0, 1.0].min
|
|
51
|
+
days_score = [active_days_count / 7.0, 1.0].min
|
|
52
|
+
features_score = [feature_diversity / 5.0, 1.0].min
|
|
53
|
+
|
|
54
|
+
score = (events_score * weights[:activity]) +
|
|
55
|
+
(users_score * weights[:unique_users]) +
|
|
56
|
+
(days_score * weights[:time_in_trial]) +
|
|
57
|
+
(features_score * weights[:feature_diversity])
|
|
58
|
+
|
|
59
|
+
(score * 100).round(2)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def activity_timeline(context, options = {})
|
|
63
|
+
period = options.delete(:period) || :daily
|
|
64
|
+
normalized_context = normalize_context(context)
|
|
65
|
+
normalized_context.validate!
|
|
66
|
+
|
|
67
|
+
events = @storage_adapter.events_for_context(normalized_context, options)
|
|
68
|
+
return [] if events.empty?
|
|
69
|
+
|
|
70
|
+
grouped = case period
|
|
71
|
+
when :hourly
|
|
72
|
+
group_by_hour(events)
|
|
73
|
+
when :daily
|
|
74
|
+
group_by_day(events)
|
|
75
|
+
when :weekly
|
|
76
|
+
group_by_week(events)
|
|
77
|
+
when :monthly
|
|
78
|
+
group_by_month(events)
|
|
79
|
+
else
|
|
80
|
+
group_by_day(events)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
grouped.map { |period_key, period_events| [period_key, period_events.count] }.to_h
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def daily_activity(context, options = {})
|
|
87
|
+
normalized_context = normalize_context(context)
|
|
88
|
+
normalized_context.validate!
|
|
89
|
+
|
|
90
|
+
options = options.dup
|
|
91
|
+
if options[:date_range]
|
|
92
|
+
date_range = options.delete(:date_range)
|
|
93
|
+
options[:since] = date_range.begin
|
|
94
|
+
options[:until] = date_range.end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
activity_timeline(normalized_context, options.merge(period: :daily))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def feature_usage_stats(context, options = {})
|
|
101
|
+
normalized_context = normalize_context(context)
|
|
102
|
+
normalized_context.validate!
|
|
103
|
+
|
|
104
|
+
events = @storage_adapter.events_for_context(
|
|
105
|
+
normalized_context,
|
|
106
|
+
options.merge(event_type: :feature_usage)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
feature_counts = {}
|
|
110
|
+
events.each do |event|
|
|
111
|
+
feature = event[:metadata]&.dig("feature") || event[:metadata]&.dig(:feature)
|
|
112
|
+
next unless feature
|
|
113
|
+
|
|
114
|
+
feature_counts[feature] ||= 0
|
|
115
|
+
feature_counts[feature] += 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
feature_counts
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def top_features(context, options = {})
|
|
122
|
+
limit = options.delete(:limit) || 10
|
|
123
|
+
stats = feature_usage_stats(context, options)
|
|
124
|
+
stats.sort_by { |_feature, count| -count }.first(limit).to_h
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def normalize_context(context)
|
|
130
|
+
return context if context.is_a?(Context)
|
|
131
|
+
Context.new(context)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def date_from_event(event)
|
|
135
|
+
created_at = event[:created_at] || event["created_at"]
|
|
136
|
+
return nil unless created_at
|
|
137
|
+
|
|
138
|
+
if created_at.is_a?(String)
|
|
139
|
+
created_at = Time.parse(created_at)
|
|
140
|
+
elsif created_at.is_a?(Time)
|
|
141
|
+
created_at = created_at
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
created_at.to_date if created_at.respond_to?(:to_date)
|
|
145
|
+
rescue
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def group_by_hour(events)
|
|
150
|
+
events.group_by do |event|
|
|
151
|
+
created_at = event[:created_at] || event["created_at"]
|
|
152
|
+
next nil unless created_at
|
|
153
|
+
time = created_at.is_a?(Time) ? created_at : Time.parse(created_at.to_s)
|
|
154
|
+
time.beginning_of_hour
|
|
155
|
+
end.compact
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def group_by_day(events)
|
|
159
|
+
events.group_by { |event| date_from_event(event) }.compact
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def group_by_week(events)
|
|
163
|
+
events.group_by do |event|
|
|
164
|
+
date = date_from_event(event)
|
|
165
|
+
date&.beginning_of_week if date
|
|
166
|
+
end.compact
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def group_by_month(events)
|
|
170
|
+
events.group_by do |event|
|
|
171
|
+
date = date_from_event(event)
|
|
172
|
+
date&.beginning_of_month if date
|
|
173
|
+
end.compact
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def feature_count(context, options = {})
|
|
177
|
+
stats = feature_usage_stats(context, options)
|
|
178
|
+
stats.keys.count
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BehaviorAnalytics
|
|
4
|
+
class Context
|
|
5
|
+
attr_accessor :tenant_id, :user_id, :user_type, :filters
|
|
6
|
+
|
|
7
|
+
def initialize(attributes = {})
|
|
8
|
+
@tenant_id = attributes[:tenant_id] || attributes[:tenant]
|
|
9
|
+
@user_id = attributes[:user_id] || attributes[:user]
|
|
10
|
+
@user_type = attributes[:user_type]
|
|
11
|
+
@filters = attributes[:filters] || {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
{
|
|
16
|
+
tenant_id: tenant_id,
|
|
17
|
+
user_id: user_id,
|
|
18
|
+
user_type: user_type,
|
|
19
|
+
filters: filters
|
|
20
|
+
}.compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def valid?
|
|
24
|
+
!tenant_id.nil? && !tenant_id.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate!
|
|
28
|
+
raise Error, "tenant_id is required in context" unless valid?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module BehaviorAnalytics
|
|
6
|
+
class Event
|
|
7
|
+
EVENT_TYPES = %i[api_call feature_usage custom].freeze
|
|
8
|
+
|
|
9
|
+
attr_accessor :id, :tenant_id, :user_id, :user_type, :event_name, :event_type,
|
|
10
|
+
:metadata, :session_id, :ip, :user_agent, :duration_ms, :created_at
|
|
11
|
+
|
|
12
|
+
def initialize(attributes = {})
|
|
13
|
+
@id = attributes[:id] || SecureRandom.uuid
|
|
14
|
+
@tenant_id = attributes[:tenant_id]
|
|
15
|
+
@user_id = attributes[:user_id]
|
|
16
|
+
@user_type = attributes[:user_type]
|
|
17
|
+
@event_name = attributes[:event_name]
|
|
18
|
+
@event_type = attributes[:event_type] || :custom
|
|
19
|
+
@metadata = attributes[:metadata] || {}
|
|
20
|
+
@session_id = attributes[:session_id]
|
|
21
|
+
@ip = attributes[:ip]
|
|
22
|
+
@user_agent = attributes[:user_agent]
|
|
23
|
+
@duration_ms = attributes[:duration_ms]
|
|
24
|
+
@created_at = attributes[:created_at] || Time.now
|
|
25
|
+
|
|
26
|
+
validate!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
id: id,
|
|
32
|
+
tenant_id: tenant_id,
|
|
33
|
+
user_id: user_id,
|
|
34
|
+
user_type: user_type,
|
|
35
|
+
event_name: event_name,
|
|
36
|
+
event_type: event_type,
|
|
37
|
+
metadata: metadata,
|
|
38
|
+
session_id: session_id,
|
|
39
|
+
ip: ip,
|
|
40
|
+
user_agent: user_agent,
|
|
41
|
+
duration_ms: duration_ms,
|
|
42
|
+
created_at: created_at
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate!
|
|
49
|
+
raise Error, "tenant_id is required" if tenant_id.nil? || tenant_id.empty?
|
|
50
|
+
raise Error, "event_name is required" if event_name.nil? || event_name.empty?
|
|
51
|
+
raise Error, "event_type must be one of: #{EVENT_TYPES.join(', ')}" unless EVENT_TYPES.include?(event_type)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|