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 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
+ [![Gem Version](https://badge.fury.io/rb/rails-persona.svg)](https://badge.fury.io/rb/rails-persona)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](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
@@ -0,0 +1,3 @@
1
+ module Persona
2
+ VERSION = "0.2.0"
3
+ 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: []