rails-persona 0.2.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/CHANGELOG.md +14 -0
- data/LICENSE +17 -0
- data/README.md +270 -0
- data/app/models/persona_event.rb +23 -0
- data/db/migrate/20240101000000_create_persona_events.rb +16 -0
- data/lib/persona/async_tracker.rb +26 -0
- data/lib/persona/configuration.rb +22 -0
- data/lib/persona/pruner.rb +23 -0
- data/lib/persona/query.rb +129 -0
- data/lib/persona/railtie.rb +21 -0
- data/lib/persona/summary.rb +38 -0
- data/lib/persona/trackable.rb +105 -0
- data/lib/persona/version.rb +3 -0
- data/lib/persona.rb +20 -0
- metadata +131 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 151d4c30715f7f5cf797f1f6924aa286446a57bdc50b4d7b37b9a269cad539ce
|
|
4
|
+
data.tar.gz: 26210723fafa5b8f2039d658657793603a7c331f1290bf61c13cccd06d3de47e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f104c0ef798b1db64ecea07ebc75609981be6f34239bb3e471d9f10122399c5e8847f969797163da05f260255cfecd7937cfc84b7ffd87cba55608ecc5c6b754
|
|
7
|
+
data.tar.gz: 4707c46dd121ca57529b24418f03b51d5266c4e381d373edc1d9287b77cbc83014dd362521fd76bc9e957da1fa336d69aa0839c82643a9cfcab3e848dbfb19d6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2024-05-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Persona::Trackable` concern with DSL (`persona do track :action end`)
|
|
7
|
+
- `track!` instance method with optional metadata
|
|
8
|
+
- Full query API: `action_count`, `most_frequent_action`, `least_frequent_action`,
|
|
9
|
+
`last_action`, `last_active_at`, `inactive_since?`, `ever_did?`,
|
|
10
|
+
`persona_summary`, `actions_between`, `activity_log`
|
|
11
|
+
- `Persona::Configuration` with `inactivity_threshold_days` and `max_events_per_record`
|
|
12
|
+
- `UntrackedActionError` for safety when tracking undeclared actions
|
|
13
|
+
- Database migration with polymorphic `persona_events` table
|
|
14
|
+
- Railtie for automatic migration path injection
|
data/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Syed M. Ghani
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
data/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# rails-persona 🎭
|
|
2
|
+
|
|
3
|
+
> Model-level behavioral analytics for Rails — own your data, zero external services.
|
|
4
|
+
|
|
5
|
+
[](https://badge.fury.io/rb/rails-persona)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**rails-persona** is a lightweight Rails gem that adds first-class behavioral tracking directly to your ActiveRecord models. Unlike [ahoy](https://github.com/ankane/ahoy), which is focused on HTTP visit and page-view tracking, rails-persona is built for **model-level action tracking** — understanding *what your users actually do* in your app, not just what pages they visit.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Why rails-persona over ahoy?
|
|
13
|
+
|
|
14
|
+
| | ahoy | rails-persona |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| Focus | HTTP visits + page views | Model actions + user behavior |
|
|
17
|
+
| Setup | Controllers + JS snippet | Pure Ruby — one concern |
|
|
18
|
+
| Async | Manual Sidekiq setup | Built-in (`async: true`) |
|
|
19
|
+
| Bulk tracking | ❌ | ✅ `bulk_track!` with `insert_all!` |
|
|
20
|
+
| Class-level analytics | ❌ | ✅ Leaderboards, class summaries |
|
|
21
|
+
| Open tracking mode | ❌ | ✅ No whitelist required |
|
|
22
|
+
| Streak / pattern queries | ❌ | ✅ `daily_activity`, `peak_hour` |
|
|
23
|
+
| Cookies / sessions | Required | Never needed |
|
|
24
|
+
| Works on non-User models | Awkward | First-class |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
gem "rails-persona"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle install
|
|
36
|
+
rails db:migrate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
### 1. Include in any model
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class User < ApplicationRecord
|
|
47
|
+
include Persona::Trackable
|
|
48
|
+
|
|
49
|
+
persona do
|
|
50
|
+
track :login
|
|
51
|
+
track :export_report
|
|
52
|
+
track :view_dashboard
|
|
53
|
+
track :upgrade_plan
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 2. Track actions in your app
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
# In a controller, service, or job:
|
|
62
|
+
current_user.track!(:login)
|
|
63
|
+
current_user.track!(:upgrade_plan, metadata: { plan: "pro", amount: 49 })
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 3. Query behavior
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
user.action_count(:login) # => 42
|
|
70
|
+
user.most_frequent_action # => :login
|
|
71
|
+
user.least_frequent_action # => :upgrade_plan
|
|
72
|
+
user.top_actions(3) # => { login: 42, view_dashboard: 18, export_report: 5 }
|
|
73
|
+
user.last_action # => :export_report
|
|
74
|
+
user.last_active_at # => 2024-05-30 14:22 UTC
|
|
75
|
+
user.first_action # => :login
|
|
76
|
+
user.first_active_at # => 2023-01-10 08:00 UTC
|
|
77
|
+
user.inactive_since? # => false (default threshold: 30 days)
|
|
78
|
+
user.inactive_since?(7) # => false (custom: 7 days)
|
|
79
|
+
user.days_since_last_activity # => 2
|
|
80
|
+
user.ever_did?(:export_report) # => true
|
|
81
|
+
user.never_did?(:upgrade_plan) # => false
|
|
82
|
+
user.action_share(:login) # => 64.6 (% of all events)
|
|
83
|
+
user.total_events # => 65
|
|
84
|
+
|
|
85
|
+
user.persona_summary
|
|
86
|
+
# => { login: 42, view_dashboard: 18, export_report: 5, upgrade_plan: 1 }
|
|
87
|
+
|
|
88
|
+
user.actions_between(1.week.ago, Time.current)
|
|
89
|
+
# => { login: 7, view_dashboard: 3 }
|
|
90
|
+
|
|
91
|
+
user.activity_log(5)
|
|
92
|
+
# => [
|
|
93
|
+
# { action: :export_report, at: 2024-05-30 14:22:00, metadata: {} },
|
|
94
|
+
# { action: :login, at: 2024-05-30 09:01:00, metadata: {} },
|
|
95
|
+
# ]
|
|
96
|
+
|
|
97
|
+
user.daily_activity(30)
|
|
98
|
+
# => { "2024-05-28" => 4, "2024-05-29" => 7, "2024-05-30" => 2 }
|
|
99
|
+
|
|
100
|
+
user.peak_hour
|
|
101
|
+
# => 14 (2pm is when this user is most active)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Class-level analytics
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# Top 10 most active users
|
|
110
|
+
User.persona_leaderboard(limit: 10)
|
|
111
|
+
# => [
|
|
112
|
+
# { record: #<User id=4>, total_events: 128 },
|
|
113
|
+
# { record: #<User id=9>, total_events: 97 },
|
|
114
|
+
# ]
|
|
115
|
+
|
|
116
|
+
# App-wide breakdown of all user actions
|
|
117
|
+
User.persona_class_summary
|
|
118
|
+
# => { login: 8420, view_dashboard: 5210, export_report: 820 }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Bulk tracking (high-performance)
|
|
124
|
+
|
|
125
|
+
Uses `insert_all!` — no N+1, no per-row callbacks:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
user.bulk_track!([:login, :view_dashboard, :export_report])
|
|
129
|
+
user.bulk_track!([:login, :login, :login]) # track repeated actions
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Async tracking (Sidekiq)
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# config/initializers/persona.rb
|
|
138
|
+
Persona.configure do |config|
|
|
139
|
+
config.async = true # fires a Sidekiq job instead of writing inline
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Requires the `sidekiq` gem. Falls back to synchronous if Sidekiq is not available.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Open tracking (no whitelist)
|
|
148
|
+
|
|
149
|
+
If you want to track arbitrary actions without declaring them:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class Post < ApplicationRecord
|
|
153
|
+
include Persona::Trackable
|
|
154
|
+
|
|
155
|
+
persona do
|
|
156
|
+
open_tracking! # any string is valid — no UntrackedActionError raised
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
post.track!("custom_#{SecureRandom.hex(4)}") # works fine
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Works on any model
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
class Post < ApplicationRecord
|
|
169
|
+
include Persona::Trackable
|
|
170
|
+
|
|
171
|
+
persona do
|
|
172
|
+
track :viewed
|
|
173
|
+
track :shared
|
|
174
|
+
track :bookmarked
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
post.track!(:viewed)
|
|
179
|
+
post.action_count(:viewed) # => 128
|
|
180
|
+
post.most_frequent_action # => :viewed
|
|
181
|
+
Post.persona_class_summary # => { viewed: 50_420, shared: 890, bookmarked: 210 }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Configuration
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# config/initializers/persona.rb
|
|
190
|
+
Persona.configure do |config|
|
|
191
|
+
config.inactivity_threshold_days = 14 # default: 30
|
|
192
|
+
config.max_events_per_record = 500 # default: nil (unlimited)
|
|
193
|
+
config.async = true # default: false
|
|
194
|
+
config.auto_prune_after_days = 90 # default: nil (no auto-prune)
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Manual pruning
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
# Delete events older than 60 days for all records
|
|
204
|
+
Persona::Pruner.prune_older_than(60)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Add to a scheduled job (e.g. `whenever` or Sidekiq-Cron):
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# lib/tasks/persona.rake
|
|
211
|
+
namespace :persona do
|
|
212
|
+
desc "Prune old persona events"
|
|
213
|
+
task prune: :environment do
|
|
214
|
+
Persona::Pruner.prune_older_than(Persona.configuration.auto_prune_after_days || 90)
|
|
215
|
+
puts "Pruned old persona events"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Comparison with other gems
|
|
223
|
+
|
|
224
|
+
| Gem | Tracks | rails-persona advantage |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| **ahoy** | HTTP visits, JS events | Model actions, no JS needed, async built-in |
|
|
227
|
+
| **paper_trail** | Model attribute changes | Behavioral patterns, not diffs |
|
|
228
|
+
| **audited** | CRUD audit logs | Who *acted*, not what *changed* |
|
|
229
|
+
| **mixpanel-ruby** | Remote SaaS events | Your DB, no 3rd party, no cost |
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## API reference
|
|
234
|
+
|
|
235
|
+
| Method | Description |
|
|
236
|
+
|--------|-------------|
|
|
237
|
+
| `track!(action, metadata: {})` | Record an action (sync or async) |
|
|
238
|
+
| `bulk_track!(actions)` | Record multiple actions via `insert_all!` |
|
|
239
|
+
| `reset_persona!` | Delete all events for this record |
|
|
240
|
+
| `action_count(action)` | Count of a specific action |
|
|
241
|
+
| `total_events` | Total event count |
|
|
242
|
+
| `most_frequent_action` | Most-performed action |
|
|
243
|
+
| `least_frequent_action` | Least-performed action |
|
|
244
|
+
| `top_actions(n)` | Top N actions by count |
|
|
245
|
+
| `last_action` | Most recent action symbol |
|
|
246
|
+
| `last_active_at` | Timestamp of last action |
|
|
247
|
+
| `first_action` | Earliest action symbol |
|
|
248
|
+
| `first_active_at` | Timestamp of first action |
|
|
249
|
+
| `inactive_since?(days)` | True if no action in N days |
|
|
250
|
+
| `days_since_last_activity` | Integer days since last event |
|
|
251
|
+
| `ever_did?(action)` | True if action occurred |
|
|
252
|
+
| `never_did?(action)` | True if action never occurred |
|
|
253
|
+
| `action_share(action)` | % of all events this action represents |
|
|
254
|
+
| `persona_summary` | Full action → count hash |
|
|
255
|
+
| `actions_between(from, to)` | Actions in a time window |
|
|
256
|
+
| `activity_log(limit)` | Recent events as array of hashes |
|
|
257
|
+
| `daily_activity(days)` | Events grouped by day |
|
|
258
|
+
| `peak_hour` | Hour (0-23) with most activity |
|
|
259
|
+
| `User.persona_leaderboard(limit:)` | Top N most active records |
|
|
260
|
+
| `User.persona_class_summary` | App-wide action breakdown |
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
Bug reports and pull requests welcome at https://github.com/sghani001/rails-persona.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT — © Syed M. Ghani
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class PersonaEvent < ActiveRecord::Base
|
|
2
|
+
belongs_to :trackable, polymorphic: true
|
|
3
|
+
|
|
4
|
+
validates :action, presence: true
|
|
5
|
+
|
|
6
|
+
# ---- Scopes ---------------------------------------------------------------
|
|
7
|
+
scope :for_action, ->(action) { where(action: action.to_s) }
|
|
8
|
+
scope :recent, ->(n = 10) { order(created_at: :desc).limit(n) }
|
|
9
|
+
scope :oldest, -> { order(created_at: :asc) }
|
|
10
|
+
scope :since, ->(time) { where("created_at >= ?", time) }
|
|
11
|
+
scope :before, ->(time) { where("created_at < ?", time) }
|
|
12
|
+
scope :on_day, ->(date) { where(created_at: date.all_day) }
|
|
13
|
+
scope :this_week, -> { since(1.week.ago) }
|
|
14
|
+
scope :this_month, -> { since(1.month.ago) }
|
|
15
|
+
|
|
16
|
+
# ---- Serialization --------------------------------------------------------
|
|
17
|
+
def metadata
|
|
18
|
+
val = super
|
|
19
|
+
val.is_a?(String) ? JSON.parse(val) : val
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreatePersonaEvents < ActiveRecord::Migration[7.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :persona_events do |t|
|
|
4
|
+
t.references :trackable, polymorphic: true, null: false, index: true
|
|
5
|
+
t.string :action, null: false
|
|
6
|
+
t.jsonb :metadata, null: false, default: {}
|
|
7
|
+
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :persona_events, :action
|
|
12
|
+
add_index :persona_events, :created_at
|
|
13
|
+
add_index :persona_events, [:trackable_type, :trackable_id, :action],
|
|
14
|
+
name: "index_persona_events_on_trackable_and_action"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Persona
|
|
2
|
+
class AsyncTracker
|
|
3
|
+
include Sidekiq::Worker if defined?(Sidekiq)
|
|
4
|
+
|
|
5
|
+
def self.track(trackable_type, trackable_id, action, metadata)
|
|
6
|
+
if defined?(Sidekiq)
|
|
7
|
+
perform_async(trackable_type, trackable_id, action, metadata)
|
|
8
|
+
else
|
|
9
|
+
perform(trackable_type, trackable_id, action, metadata)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.perform(trackable_type, trackable_id, action, metadata)
|
|
14
|
+
record = trackable_type.constantize.find(trackable_id)
|
|
15
|
+
PersonaEvent.create!(
|
|
16
|
+
trackable: record,
|
|
17
|
+
action: action,
|
|
18
|
+
metadata: metadata
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def perform(trackable_type, trackable_id, action, metadata)
|
|
23
|
+
self.class.perform(trackable_type, trackable_id, action, metadata)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Persona
|
|
2
|
+
class Configuration
|
|
3
|
+
# Max events stored per trackable record (nil = unlimited)
|
|
4
|
+
attr_accessor :max_events_per_record
|
|
5
|
+
|
|
6
|
+
# Days of inactivity before inactive_since? returns true
|
|
7
|
+
attr_accessor :inactivity_threshold_days
|
|
8
|
+
|
|
9
|
+
# Use Sidekiq for async tracking (requires sidekiq gem)
|
|
10
|
+
attr_accessor :async
|
|
11
|
+
|
|
12
|
+
# Auto-prune events older than N days on each track! call (nil = off)
|
|
13
|
+
attr_accessor :auto_prune_after_days
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@max_events_per_record = nil
|
|
17
|
+
@inactivity_threshold_days = 30
|
|
18
|
+
@async = false
|
|
19
|
+
@auto_prune_after_days = nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Persona
|
|
2
|
+
class Pruner
|
|
3
|
+
# Prune events older than N days globally
|
|
4
|
+
def self.prune_older_than(days)
|
|
5
|
+
PersonaEvent.where("created_at < ?", days.days.ago).delete_all
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Prune per-record if max_events_per_record is set
|
|
9
|
+
def self.enforce_cap_for(record)
|
|
10
|
+
cap = Persona.configuration.max_events_per_record
|
|
11
|
+
return unless cap
|
|
12
|
+
|
|
13
|
+
count = record.persona_events.count
|
|
14
|
+
return unless count > cap
|
|
15
|
+
|
|
16
|
+
oldest_ids = record.persona_events
|
|
17
|
+
.order(created_at: :asc)
|
|
18
|
+
.limit(count - cap)
|
|
19
|
+
.pluck(:id)
|
|
20
|
+
PersonaEvent.where(id: oldest_ids).delete_all
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module Persona
|
|
2
|
+
module Query
|
|
3
|
+
# ---- Counts ---------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
def action_count(action)
|
|
6
|
+
persona_events.for_action(action).count
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def total_events
|
|
10
|
+
persona_events.count
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# ---- Frequency ------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
def most_frequent_action
|
|
16
|
+
persona_events.group(:action).order("count_all DESC").count.keys.first&.to_sym
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def least_frequent_action
|
|
20
|
+
persona_events.group(:action).order("count_all ASC").count.keys.first&.to_sym
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def top_actions(limit = 3)
|
|
24
|
+
persona_events.group(:action).order("count_all DESC").limit(limit).count
|
|
25
|
+
.transform_keys(&:to_sym)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ---- Recency --------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def last_action
|
|
31
|
+
persona_events.recent(1).first&.action&.to_sym
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def last_active_at
|
|
35
|
+
persona_events.recent(1).first&.created_at
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def first_action
|
|
39
|
+
persona_events.oldest.first&.action&.to_sym
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def first_active_at
|
|
43
|
+
persona_events.oldest.first&.created_at
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ---- Inactivity -----------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def inactive_since?(days = Persona.configuration.inactivity_threshold_days)
|
|
49
|
+
return true if last_active_at.nil?
|
|
50
|
+
last_active_at < days.days.ago
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def days_since_last_activity
|
|
54
|
+
return nil if last_active_at.nil?
|
|
55
|
+
((Time.current - last_active_at) / 1.day).to_i
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ---- Presence -------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def ever_did?(action)
|
|
61
|
+
persona_events.for_action(action).exists?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def never_did?(action)
|
|
65
|
+
!ever_did?(action)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ---- Summaries ------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def persona_summary
|
|
71
|
+
persona_events.group(:action).order("count_all DESC").count
|
|
72
|
+
.transform_keys(&:to_sym)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def actions_between(from, to)
|
|
76
|
+
persona_events.since(from).before(to).group(:action).count
|
|
77
|
+
.transform_keys(&:to_sym)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def activity_log(limit = 10)
|
|
81
|
+
persona_events.recent(limit).map do |e|
|
|
82
|
+
{ action: e.action.to_sym, at: e.created_at, metadata: e.metadata }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ---- Streaks / Patterns ---------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# Actions grouped by day for the last N days
|
|
89
|
+
def daily_activity(days = 30)
|
|
90
|
+
persona_events
|
|
91
|
+
.since(days.days.ago)
|
|
92
|
+
.group("DATE(created_at)")
|
|
93
|
+
.order("DATE(created_at) ASC")
|
|
94
|
+
.count
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Peak hour (0-23) when this record is most active
|
|
98
|
+
def peak_hour
|
|
99
|
+
persona_events
|
|
100
|
+
.group("EXTRACT(HOUR FROM created_at)::int")
|
|
101
|
+
.order("count_all DESC")
|
|
102
|
+
.count
|
|
103
|
+
.keys
|
|
104
|
+
.first
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# What % of activity is a specific action?
|
|
108
|
+
def action_share(action)
|
|
109
|
+
total = total_events
|
|
110
|
+
return 0.0 if total.zero?
|
|
111
|
+
(action_count(action).to_f / total * 100).round(1)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ---- Class-level helpers (call on the class, not an instance) ------------
|
|
115
|
+
module ClassMethods
|
|
116
|
+
def persona_leaderboard(limit: 10)
|
|
117
|
+
Persona::Summary.leaderboard(self, limit: limit)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def persona_class_summary
|
|
121
|
+
Persona::Summary.class_summary(self)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.included(base)
|
|
126
|
+
base.extend(ClassMethods)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
|
|
3
|
+
module Persona
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer "persona.load_app_instance_data" do |app|
|
|
6
|
+
Persona::Railtie.instance_variable_set(:@app, app)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "persona.append_migrations" do |app|
|
|
10
|
+
unless app.root.to_s == File.expand_path("../..", __dir__)
|
|
11
|
+
config.paths["db/migrate"].expanded.each do |path|
|
|
12
|
+
app.config.paths["db/migrate"] << path
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
rake_tasks do
|
|
18
|
+
load "tasks/persona_tasks.rake"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Persona
|
|
2
|
+
module Summary
|
|
3
|
+
# Compare two records side by side
|
|
4
|
+
def self.compare(record_a, record_b)
|
|
5
|
+
actions = (record_a.persona_summary.keys + record_b.persona_summary.keys).uniq
|
|
6
|
+
|
|
7
|
+
actions.each_with_object({}) do |action, hash|
|
|
8
|
+
hash[action] = {
|
|
9
|
+
record_a.class.name.downcase + "_#{record_a.id}" => record_a.action_count(action),
|
|
10
|
+
record_b.class.name.downcase + "_#{record_b.id}" => record_b.action_count(action)
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Top N most active records of a given class
|
|
16
|
+
def self.leaderboard(klass, limit: 10)
|
|
17
|
+
PersonaEvent
|
|
18
|
+
.where(trackable_type: klass.name)
|
|
19
|
+
.group(:trackable_id)
|
|
20
|
+
.order("count_all DESC")
|
|
21
|
+
.limit(limit)
|
|
22
|
+
.count
|
|
23
|
+
.map do |id, count|
|
|
24
|
+
{ record: klass.find(id), total_events: count }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Class-wide action breakdown
|
|
29
|
+
def self.class_summary(klass)
|
|
30
|
+
PersonaEvent
|
|
31
|
+
.where(trackable_type: klass.name)
|
|
32
|
+
.group(:action)
|
|
33
|
+
.order("count_all DESC")
|
|
34
|
+
.count
|
|
35
|
+
.transform_keys(&:to_sym)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module Persona
|
|
4
|
+
module Trackable
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
has_many :persona_events, as: :trackable, dependent: :destroy
|
|
9
|
+
include Persona::Query
|
|
10
|
+
|
|
11
|
+
class_attribute :_persona_tracked_actions, default: []
|
|
12
|
+
class_attribute :_persona_open_tracking, default: false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class_methods do
|
|
16
|
+
# DSL: define which actions are trackable
|
|
17
|
+
#
|
|
18
|
+
# persona do
|
|
19
|
+
# track :login
|
|
20
|
+
# track :export
|
|
21
|
+
# open_tracking! # allow any action (no whitelist)
|
|
22
|
+
# end
|
|
23
|
+
def persona(&block)
|
|
24
|
+
builder = PersonaBuilder.new(self)
|
|
25
|
+
builder.instance_eval(&block)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Track an action on this record.
|
|
30
|
+
#
|
|
31
|
+
# user.track!(:login)
|
|
32
|
+
# user.track!(:purchase, metadata: { plan: "pro" })
|
|
33
|
+
# user.track!(:purchase, metadata: { plan: "pro" }, skip_cap: true)
|
|
34
|
+
#
|
|
35
|
+
def track!(action, metadata: {}, skip_cap: false)
|
|
36
|
+
action = action.to_s
|
|
37
|
+
|
|
38
|
+
unless self.class._persona_open_tracking
|
|
39
|
+
if self.class._persona_tracked_actions.any? &&
|
|
40
|
+
!self.class._persona_tracked_actions.include?(action.to_sym)
|
|
41
|
+
raise Persona::UntrackedActionError,
|
|
42
|
+
"'#{action}' is not declared on #{self.class.name}. " \
|
|
43
|
+
"Add `track :#{action}` in your persona block, or call `open_tracking!`."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if Persona.configuration.async && defined?(Sidekiq)
|
|
48
|
+
Persona::AsyncTracker.track(self.class.name, id, action, metadata)
|
|
49
|
+
else
|
|
50
|
+
PersonaEvent.create!(
|
|
51
|
+
trackable: self,
|
|
52
|
+
action: action,
|
|
53
|
+
metadata: metadata
|
|
54
|
+
)
|
|
55
|
+
Persona::Pruner.enforce_cap_for(self) unless skip_cap
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Bulk track multiple actions at once (no cap enforcement between each)
|
|
60
|
+
#
|
|
61
|
+
# user.bulk_track!([:login, :view_dashboard, :export_report])
|
|
62
|
+
#
|
|
63
|
+
def bulk_track!(actions, metadata: {})
|
|
64
|
+
now = Time.current
|
|
65
|
+
rows = actions.map do |action|
|
|
66
|
+
{
|
|
67
|
+
trackable_type: self.class.name,
|
|
68
|
+
trackable_id: id,
|
|
69
|
+
action: action.to_s,
|
|
70
|
+
metadata: metadata,
|
|
71
|
+
created_at: now,
|
|
72
|
+
updated_at: now
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
PersonaEvent.insert_all!(rows)
|
|
76
|
+
Persona::Pruner.enforce_cap_for(self)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reset all events for this record
|
|
80
|
+
def reset_persona!
|
|
81
|
+
persona_events.delete_all
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# -------------------------------------------------------------------------
|
|
85
|
+
# Inner DSL builder
|
|
86
|
+
# -------------------------------------------------------------------------
|
|
87
|
+
class PersonaBuilder
|
|
88
|
+
def initialize(klass)
|
|
89
|
+
@klass = klass
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def track(action)
|
|
93
|
+
@klass._persona_tracked_actions =
|
|
94
|
+
(@klass._persona_tracked_actions + [action.to_sym]).uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Skip whitelist enforcement — any string is valid
|
|
98
|
+
def open_tracking!
|
|
99
|
+
@klass._persona_open_tracking = true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class UntrackedActionError < StandardError; end
|
|
105
|
+
end
|
data/lib/persona.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "persona/version"
|
|
2
|
+
require "persona/configuration"
|
|
3
|
+
require "persona/railtie" if defined?(Rails)
|
|
4
|
+
require "persona/pruner"
|
|
5
|
+
require "persona/async_tracker"
|
|
6
|
+
require "persona/summary"
|
|
7
|
+
require "persona/query"
|
|
8
|
+
require "persona/trackable"
|
|
9
|
+
|
|
10
|
+
module Persona
|
|
11
|
+
class << self
|
|
12
|
+
def configuration
|
|
13
|
+
@configuration ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield(configuration)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-persona
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Syed M. Ghani
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-31 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec-rails
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '6.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '6.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: factory_bot
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '6.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '6.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: sqlite3
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.4'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.4'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop-rails
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '2.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.0'
|
|
83
|
+
description: |
|
|
84
|
+
rails-persona lets you track, query, and understand how users interact with
|
|
85
|
+
your app directly from your Rails models. Define trackable actions with a
|
|
86
|
+
clean DSL, then query frequency, recency, inactivity, and full activity logs
|
|
87
|
+
— all stored in your own database.
|
|
88
|
+
email:
|
|
89
|
+
- syedghani001@gmail.com
|
|
90
|
+
executables: []
|
|
91
|
+
extensions: []
|
|
92
|
+
extra_rdoc_files: []
|
|
93
|
+
files:
|
|
94
|
+
- CHANGELOG.md
|
|
95
|
+
- LICENSE
|
|
96
|
+
- README.md
|
|
97
|
+
- app/models/persona_event.rb
|
|
98
|
+
- db/migrate/20240101000000_create_persona_events.rb
|
|
99
|
+
- lib/persona.rb
|
|
100
|
+
- lib/persona/async_tracker.rb
|
|
101
|
+
- lib/persona/configuration.rb
|
|
102
|
+
- lib/persona/pruner.rb
|
|
103
|
+
- lib/persona/query.rb
|
|
104
|
+
- lib/persona/railtie.rb
|
|
105
|
+
- lib/persona/summary.rb
|
|
106
|
+
- lib/persona/trackable.rb
|
|
107
|
+
- lib/persona/version.rb
|
|
108
|
+
homepage: https://github.com/sghani001/rails-persona
|
|
109
|
+
licenses:
|
|
110
|
+
- MIT
|
|
111
|
+
metadata: {}
|
|
112
|
+
post_install_message:
|
|
113
|
+
rdoc_options: []
|
|
114
|
+
require_paths:
|
|
115
|
+
- lib
|
|
116
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
117
|
+
requirements:
|
|
118
|
+
- - ">="
|
|
119
|
+
- !ruby/object:Gem::Version
|
|
120
|
+
version: 2.7.0
|
|
121
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
122
|
+
requirements:
|
|
123
|
+
- - ">="
|
|
124
|
+
- !ruby/object:Gem::Version
|
|
125
|
+
version: '0'
|
|
126
|
+
requirements: []
|
|
127
|
+
rubygems_version: 3.5.22
|
|
128
|
+
signing_key:
|
|
129
|
+
specification_version: 4
|
|
130
|
+
summary: Attach behavioral analytics to any Rails model — no external tools needed.
|
|
131
|
+
test_files: []
|