content_signals 0.1.0 → 0.1.1
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 +4 -4
- data/.rubocop.yml +48 -0
- data/.rubocop_todo.yml +146 -0
- data/COUNTERS.MD +1522 -0
- data/README.md +400 -8
- data/READY_TO_GEM.md +375 -0
- data/STRUCTURE.md +108 -0
- data/lib/content_signals/concerns/trackable_page_views.rb +88 -0
- data/lib/content_signals/configuration.rb +41 -0
- data/lib/content_signals/engine.rb +33 -0
- data/lib/content_signals/jobs/track_page_view_job.rb +43 -0
- data/lib/content_signals/models/page_view.rb +229 -0
- data/lib/content_signals/services/device_detector_service.rb +42 -0
- data/lib/content_signals/services/page_view_tracker.rb +147 -0
- data/lib/content_signals/services/visitor_location_service.rb +80 -0
- data/lib/content_signals/version.rb +1 -1
- data/lib/content_signals.rb +53 -1
- data/lib/generators/content_signals/install_generator.rb +41 -0
- data/lib/generators/content_signals/templates/README +36 -0
- data/lib/generators/content_signals/templates/content_signals.rb.erb +19 -0
- data/lib/generators/content_signals/templates/create_content_signals_page_views.rb.erb +48 -0
- metadata +31 -14
data/COUNTERS.MD
ADDED
|
@@ -0,0 +1,1522 @@
|
|
|
1
|
+
# Page Counters Specification
|
|
2
|
+
|
|
3
|
+
## 🚀 Status: Planning → Gem Creation
|
|
4
|
+
|
|
5
|
+
**This specification will be extracted into an open-source Ruby gem: `content_signals`**
|
|
6
|
+
|
|
7
|
+
- **Gem Name:** `content_signals` ✅ Available on RubyGems & GitHub
|
|
8
|
+
- **Tagline:** "Listen to signals from your content"
|
|
9
|
+
- **Roadmap:** See [GEM_CREATION_ROADMAP.md](GEM_CREATION_ROADMAP.md)
|
|
10
|
+
- **Target:** v0.1.0 release in 2 days (16 hours)
|
|
11
|
+
- **Use Cases:** Stejar CMS (Pages), Eventya (Profiles, Events), any polymorphic model
|
|
12
|
+
- **License:** MIT
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
This document outlines the specifications for implementing content analytics to track page views, demographics, device types, and engagement metrics. Designed to work as a Rails gem with polymorphic trackable models and optional multi-tenant support.
|
|
18
|
+
|
|
19
|
+
## Counter Types
|
|
20
|
+
|
|
21
|
+
### 1. Page Views Counter
|
|
22
|
+
|
|
23
|
+
**Purpose:** Track the total number of times a page has been viewed.
|
|
24
|
+
|
|
25
|
+
**Requirements:**
|
|
26
|
+
- Increment counter each time a page is loaded/visited
|
|
27
|
+
- Track unique vs. total views (optional: distinguish between unique visitors and total page loads)
|
|
28
|
+
- Consider tracking views per time period (daily, weekly, monthly)
|
|
29
|
+
- Exclude bot/crawler traffic from counts
|
|
30
|
+
- Exclude admin/editor views from public counts (optional)
|
|
31
|
+
- Track demographics: country, city, region (using IP geolocation)
|
|
32
|
+
- Store language/locale preferences
|
|
33
|
+
- Track device types (desktop, mobile, tablet, hybrid_app)
|
|
34
|
+
- Track browser and OS information
|
|
35
|
+
- **Support hybrid mobile apps**: Recognize and track WebView/Capacitor/Cordova apps
|
|
36
|
+
- **App-specific tracking**: Accept device_id from mobile apps for consistent visitor identification
|
|
37
|
+
- **Platform identification**: Distinguish between web browser and mobile app contexts
|
|
38
|
+
|
|
39
|
+
**Data Storage:**
|
|
40
|
+
- `page_views_count` (integer) - Total view count on pages table
|
|
41
|
+
- `PageView` model for detailed tracking with:
|
|
42
|
+
- `tenant_id` (bigint, optional) - For multi-tenant applications
|
|
43
|
+
- `trackable_type`, `trackable_id` (polymorphic) - The model being tracked (Page, Profile, Event, etc.)
|
|
44
|
+
- `user_id` (optional), `visitor_id`
|
|
45
|
+
- `ip_address`, `user_agent`
|
|
46
|
+
- `country_code` (string, 2 chars - e.g., 'US', 'RO')
|
|
47
|
+
- `country_name` (string - e.g., 'United States', 'Romania')
|
|
48
|
+
- `city` (string)
|
|
49
|
+
- `region` (string - state/province)
|
|
50
|
+
- `latitude`, `longitude` (decimal, optional)
|
|
51
|
+
- `locale` (string - e.g., 'en-US', 'ro-RO')
|
|
52
|
+
- `device_type` (string - desktop, mobile, tablet, hybrid_app)
|
|
53
|
+
- `browser` (string - Chrome, Firefox, Safari, etc.)
|
|
54
|
+
- `os` (string - Windows, macOS, iOS, Android, etc.)
|
|
55
|
+
- `app_platform` (string - capacitor, cordova, react_native, flutter, null for web)
|
|
56
|
+
- `app_version` (string - mobile app version if applicable)
|
|
57
|
+
- `device_id` (string - persistent device identifier from mobile app)
|
|
58
|
+
- `referrer`, `viewed_at`
|
|
59
|
+
|
|
60
|
+
**Display:**
|
|
61
|
+
- Show view count on page header/stats bar
|
|
62
|
+
- Display in page analytics dashboard with demographics breakdown
|
|
63
|
+
- Show top countries/cities in analytics
|
|
64
|
+
- Display interactive map with visitor locations
|
|
65
|
+
- Show device type distribution (pie chart)
|
|
66
|
+
- Show browser/OS statistics
|
|
67
|
+
- Format large numbers (e.g., 1.2K, 1.5M)
|
|
68
|
+
- Export demographics data as CSV/PDF
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### 2. Followers per Page
|
|
73
|
+
|
|
74
|
+
**Purpose:** Allow users to follow/subscribe to specific pages for updates.
|
|
75
|
+
|
|
76
|
+
**Requirements:**
|
|
77
|
+
- Users can follow/unfollow any public page
|
|
78
|
+
- Track total follower count per page
|
|
79
|
+
- Notify followers when page content is updated (optional)
|
|
80
|
+
- Allow users to view list of pages they follow
|
|
81
|
+
- Allow page owners to view their followers
|
|
82
|
+
|
|
83
|
+
**Data Storage:**
|
|
84
|
+
- `followers_count` (integer) - Cached counter on pages table
|
|
85
|
+
- `PageFollower` model with fields:
|
|
86
|
+
- `page_id` (references pages)
|
|
87
|
+
- `user_id` (references users)
|
|
88
|
+
- `followed_at` (datetime)
|
|
89
|
+
- Unique index on `[page_id, user_id]`
|
|
90
|
+
|
|
91
|
+
**Actions:**
|
|
92
|
+
- `POST /pages/:id/follow` - Follow a page
|
|
93
|
+
- `DELETE /pages/:id/unfollow` - Unfollow a page
|
|
94
|
+
- `GET /pages/:id/followers` - View page followers (permission-based)
|
|
95
|
+
- `GET /users/:id/following_pages` - View pages user follows
|
|
96
|
+
|
|
97
|
+
**Display:**
|
|
98
|
+
- Show follower count on page header
|
|
99
|
+
- Display "Follow" / "Following" button for authenticated users
|
|
100
|
+
- Show follower list in page settings/analytics
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### 3. Bookmarks Counter
|
|
105
|
+
|
|
106
|
+
**Purpose:** Track how many users have bookmarked/saved the page for later reference.
|
|
107
|
+
|
|
108
|
+
**Requirements:**
|
|
109
|
+
- Users can bookmark/unbookmark any page
|
|
110
|
+
- Track total bookmark count per page
|
|
111
|
+
- Allow users to view their bookmarked pages
|
|
112
|
+
- Bookmarks are private to each user
|
|
113
|
+
|
|
114
|
+
**Data Storage:**
|
|
115
|
+
- `bookmarks_count` (integer) - Cached counter on pages table
|
|
116
|
+
- `PageBookmark` model with fields:
|
|
117
|
+
- `page_id` (references pages)
|
|
118
|
+
- `user_id` (references users)
|
|
119
|
+
- `bookmarked_at` (datetime)
|
|
120
|
+
- `notes` (text, optional) - User's private notes about the bookmark
|
|
121
|
+
- Unique index on `[page_id, user_id]`
|
|
122
|
+
|
|
123
|
+
**Actions:**
|
|
124
|
+
- `POST /pages/:id/bookmark` - Bookmark a page
|
|
125
|
+
- `DELETE /pages/:id/unbookmark` - Remove bookmark
|
|
126
|
+
- `GET /users/:id/bookmarks` - View user's bookmarked pages
|
|
127
|
+
|
|
128
|
+
**Display:**
|
|
129
|
+
- Show bookmark count on page header/stats
|
|
130
|
+
- Display bookmark/unbookmark icon/button for authenticated users
|
|
131
|
+
- Show in user's bookmark collection
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
### 4. Shares Counter
|
|
136
|
+
|
|
137
|
+
**Purpose:** Track how many times a page has been shared across different platforms.
|
|
138
|
+
|
|
139
|
+
**Requirements:**
|
|
140
|
+
- Track shares across multiple platforms (social media, email, copy link)
|
|
141
|
+
- Increment counter when share action is triggered
|
|
142
|
+
- Optionally track which platform was used for sharing
|
|
143
|
+
- Generate shareable links with tracking parameters
|
|
144
|
+
|
|
145
|
+
**Data Storage:**
|
|
146
|
+
- `shares_count` (integer) - Total shares counter on pages table
|
|
147
|
+
- Optional: `PageShare` model for detailed tracking:
|
|
148
|
+
- `page_id` (references pages)
|
|
149
|
+
- `user_id` (references users, nullable for anonymous)
|
|
150
|
+
- `platform` (string) - twitter, facebook, linkedin, email, copy_link, etc.
|
|
151
|
+
- `shared_at` (datetime)
|
|
152
|
+
- `referrer` (string, optional)
|
|
153
|
+
|
|
154
|
+
**Share Platforms:**
|
|
155
|
+
- Twitter/X
|
|
156
|
+
- Facebook
|
|
157
|
+
- LinkedIn
|
|
158
|
+
- Email
|
|
159
|
+
- Copy link to clipboard
|
|
160
|
+
- WhatsApp
|
|
161
|
+
- Other platforms as needed
|
|
162
|
+
|
|
163
|
+
**Actions:**
|
|
164
|
+
- `POST /pages/:id/share` - Record a share event
|
|
165
|
+
- Params: `platform` (required)
|
|
166
|
+
- Generate share URLs with UTM parameters for tracking
|
|
167
|
+
|
|
168
|
+
**Display:**
|
|
169
|
+
- Show total share count on page header
|
|
170
|
+
- Display share buttons with platform icons
|
|
171
|
+
- Show share analytics in page dashboard
|
|
172
|
+
- Format large numbers (e.g., 1.2K shares)
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Implementation Considerations
|
|
177
|
+
|
|
178
|
+
### Multi-Tenant Support
|
|
179
|
+
|
|
180
|
+
**Overview:**
|
|
181
|
+
|
|
182
|
+
The analytics system supports multi-tenant Rails applications where data must be isolated by tenant. This is essential for SaaS applications where multiple organizations share the same database.
|
|
183
|
+
|
|
184
|
+
**Configuration:**
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# config/initializers/content_signals.rb
|
|
188
|
+
ContentSignals.configure do |config|
|
|
189
|
+
# Enable multitenancy
|
|
190
|
+
config.multitenancy = true
|
|
191
|
+
|
|
192
|
+
# Method to get current tenant ID (required if multitenancy enabled)
|
|
193
|
+
config.current_tenant_method = :current_tenant_id
|
|
194
|
+
|
|
195
|
+
# Tenant model (optional, for associations)
|
|
196
|
+
config.tenant_model = 'Account' # or 'Organization', 'Company', etc.
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# In your ApplicationController or base controller
|
|
200
|
+
def current_tenant_id
|
|
201
|
+
Current.account_id # or however you store tenant context
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**How it works:**
|
|
206
|
+
|
|
207
|
+
1. **Automatic tenant scoping** - All analytics queries are automatically scoped to current tenant:
|
|
208
|
+
```ruby
|
|
209
|
+
# Automatically filtered by current tenant
|
|
210
|
+
@page.analytics.top_countries
|
|
211
|
+
@page.page_views_count
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
2. **Tenant isolation** - PageView records include `tenant_id`:
|
|
215
|
+
```ruby
|
|
216
|
+
# Each view is associated with a tenant
|
|
217
|
+
PageView.where(tenant_id: current_tenant_id)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
3. **Cross-tenant analytics** (admin only):
|
|
221
|
+
```ruby
|
|
222
|
+
# Bypass tenant scoping (requires admin permissions)
|
|
223
|
+
ContentSignals.unscoped do
|
|
224
|
+
PageView.group(:tenant_id).count
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Multi-tenant patterns supported:**
|
|
229
|
+
|
|
230
|
+
1. **Shared database, shared schema** (row-level isolation):
|
|
231
|
+
```ruby
|
|
232
|
+
# Uses tenant_id column on all tables
|
|
233
|
+
config.multitenancy = true
|
|
234
|
+
config.isolation_strategy = :row_level
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
2. **Shared database, separate schemas** (Apartment gem):
|
|
238
|
+
```ruby
|
|
239
|
+
# Works with Apartment gem
|
|
240
|
+
config.multitenancy = true
|
|
241
|
+
config.isolation_strategy = :schema
|
|
242
|
+
# tenant_id still stored for cross-schema queries
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
3. **Separate databases per tenant**:
|
|
246
|
+
```ruby
|
|
247
|
+
# Each tenant has their own database
|
|
248
|
+
config.multitenancy = false # No tenant_id needed
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Example with ActsAsTenant gem:**
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
# Gemfile
|
|
255
|
+
gem 'acts_as_tenant'
|
|
256
|
+
gem 'content_signals'
|
|
257
|
+
|
|
258
|
+
# app/models/page_view.rb
|
|
259
|
+
class PageView < ApplicationRecord
|
|
260
|
+
acts_as_tenant(:account)
|
|
261
|
+
# content_signals handles the rest
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Automatic tenant scoping in controllers
|
|
265
|
+
class PagesController < ApplicationController
|
|
266
|
+
set_current_tenant_through_filter
|
|
267
|
+
before_action :set_current_tenant
|
|
268
|
+
|
|
269
|
+
def set_current_tenant
|
|
270
|
+
set_current_tenant(current_user.account)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**Tenant-aware caching:**
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
# Redis keys include tenant_id
|
|
279
|
+
"page_view:tenant_123:page_456:visitor_abc:2026-01-06"
|
|
280
|
+
|
|
281
|
+
# Ensures no data leakage between tenants
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Benefits:**
|
|
285
|
+
- ✅ Complete data isolation between tenants
|
|
286
|
+
- ✅ Works with existing multitenancy gems (ActsAsTenant, Apartment)
|
|
287
|
+
- ✅ Optional - can be disabled for single-tenant apps
|
|
288
|
+
- ✅ Prevents accidental cross-tenant data leaks
|
|
289
|
+
- ✅ Supports super-admin cross-tenant analytics
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
### Hybrid Mobile App Support
|
|
294
|
+
|
|
295
|
+
**Identifying Hybrid Apps:**
|
|
296
|
+
|
|
297
|
+
Hybrid apps load your Rails website inside a WebView. To properly track these:
|
|
298
|
+
|
|
299
|
+
1. **Custom Headers** - Apps should send identifying headers:
|
|
300
|
+
```javascript
|
|
301
|
+
// In Capacitor/Ionic app
|
|
302
|
+
import { CapacitorHttp } from '@capacitor/core';
|
|
303
|
+
|
|
304
|
+
CapacitorHttp.request({
|
|
305
|
+
url: 'https://yoursite.com/page',
|
|
306
|
+
headers: {
|
|
307
|
+
'X-App-Platform': 'capacitor',
|
|
308
|
+
'X-App-Version': '1.2.3',
|
|
309
|
+
'X-Device-ID': 'unique-device-uuid'
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Or inject headers in WebView
|
|
314
|
+
webView.setWebViewClient(new WebViewClient() {
|
|
315
|
+
@Override
|
|
316
|
+
public WebResourceResponse shouldInterceptRequest(WebView view, String url) {
|
|
317
|
+
// Add custom headers to all requests
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
2. **URL Parameters** - Alternative approach for initial load:
|
|
323
|
+
```
|
|
324
|
+
https://yoursite.com/page?app_platform=capacitor&device_id=abc123&app_version=1.2.3
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
3. **JavaScript Detection** - Detect WebView in frontend:
|
|
328
|
+
```javascript
|
|
329
|
+
// Check for Capacitor
|
|
330
|
+
const isCapacitor = window.Capacitor !== undefined;
|
|
331
|
+
|
|
332
|
+
// Check for Cordova
|
|
333
|
+
const isCordova = window.cordova !== undefined;
|
|
334
|
+
|
|
335
|
+
// Check for generic WebView
|
|
336
|
+
const isWebView = /wv|WebView|; wv\)/.test(navigator.userAgent);
|
|
337
|
+
|
|
338
|
+
// Send to analytics endpoint
|
|
339
|
+
if (isCapacitor || isCordova || isWebView) {
|
|
340
|
+
fetch('/api/analytics/identify', {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'Content-Type': 'application/json' },
|
|
343
|
+
body: JSON.stringify({
|
|
344
|
+
app_platform: isCapacitor ? 'capacitor' : isCordova ? 'cordova' : 'webview',
|
|
345
|
+
device_id: localStorage.getItem('device_id') || generateDeviceId(),
|
|
346
|
+
app_version: window.APP_VERSION
|
|
347
|
+
})
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Visitor Identification for Apps:**
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
# app/services/page_view_tracker.rb
|
|
356
|
+
def identify_visitor
|
|
357
|
+
# 1. Use authenticated user ID if available
|
|
358
|
+
return "user_#{@user.id}" if @user
|
|
359
|
+
|
|
360
|
+
# 2. Use device_id from mobile app (most reliable for apps)
|
|
361
|
+
device_id = @request.headers['X-Device-ID'] || @request.params[:device_id]
|
|
362
|
+
return "device_#{device_id}" if device_id.present?
|
|
363
|
+
|
|
364
|
+
# 3. Use cookie for web browsers
|
|
365
|
+
cookie_id = @request.cookie_jar.signed[:visitor_id]
|
|
366
|
+
return cookie_id if cookie_id.present?
|
|
367
|
+
|
|
368
|
+
# 4. Fallback to IP + User Agent hash
|
|
369
|
+
"anon_#{Digest::SHA256.hexdigest("#{@request.ip}:#{@request.user_agent}")[0..15]}"
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def detect_app_context
|
|
373
|
+
{
|
|
374
|
+
app_platform: @request.headers['X-App-Platform'] || detect_platform_from_ua,
|
|
375
|
+
app_version: @request.headers['X-App-Version'],
|
|
376
|
+
device_id: @request.headers['X-Device-ID'] || @request.params[:device_id]
|
|
377
|
+
}
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def detect_platform_from_ua
|
|
381
|
+
ua = @request.user_agent.to_s.downcase
|
|
382
|
+
return 'capacitor' if ua.include?('capacitor')
|
|
383
|
+
return 'cordova' if ua.include?('cordova')
|
|
384
|
+
return 'react_native' if ua.include?('react-native')
|
|
385
|
+
return 'flutter' if ua.include?('flutter')
|
|
386
|
+
return 'webview' if ua.match?(/wv|webview/)
|
|
387
|
+
nil
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Device Type Detection:**
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
# app/services/device_detector_service.rb
|
|
395
|
+
def self.detect(user_agent, app_platform: nil)
|
|
396
|
+
browser = Browser.new(user_agent)
|
|
397
|
+
|
|
398
|
+
device_type = if app_platform.present?
|
|
399
|
+
'hybrid_app' # It's from a mobile app
|
|
400
|
+
elsif browser.device.mobile?
|
|
401
|
+
'mobile'
|
|
402
|
+
elsif browser.device.tablet?
|
|
403
|
+
'tablet'
|
|
404
|
+
else
|
|
405
|
+
'desktop'
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
{
|
|
409
|
+
device_type: device_type,
|
|
410
|
+
browser: browser.name,
|
|
411
|
+
browser_version: browser.version,
|
|
412
|
+
os: browser.platform.name,
|
|
413
|
+
os_version: browser.platform.version,
|
|
414
|
+
is_webview: browser.device.mobile? && user_agent.match?(/wv|WebView/i)
|
|
415
|
+
}
|
|
416
|
+
end
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
**Updated Migration:**
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
# Add app-specific columns to page_views table
|
|
423
|
+
create_table :page_views do |t|
|
|
424
|
+
# ... existing columns ...
|
|
425
|
+
|
|
426
|
+
# Hybrid app support
|
|
427
|
+
t.string :app_platform, limit: 20 # capacitor, cordova, react_native, flutter, null
|
|
428
|
+
t.string :app_version, limit: 20 # e.g., "1.2.3"
|
|
429
|
+
t.string :device_id # Persistent device identifier from app
|
|
430
|
+
|
|
431
|
+
# ... rest of columns ...
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
add_index :page_views, :app_platform
|
|
435
|
+
add_index :page_views, :device_id
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Benefits:**
|
|
439
|
+
- ✅ Consistent visitor tracking across app sessions
|
|
440
|
+
- ✅ Distinguish app users from web users
|
|
441
|
+
- ✅ Track app version adoption
|
|
442
|
+
- ✅ Analyze app vs web engagement
|
|
443
|
+
- ✅ Debug app-specific issues
|
|
444
|
+
|
|
445
|
+
---
|
|
446
|
+
|
|
447
|
+
### Geolocation Services - Detailed Comparison
|
|
448
|
+
|
|
449
|
+
#### Recommended: MaxMind GeoLite2 (Free) or GeoIP2 (Paid)
|
|
450
|
+
|
|
451
|
+
**Why MaxMind?**
|
|
452
|
+
- ✅ **Local database** - No API calls, no rate limits, instant lookups (1-2ms)
|
|
453
|
+
- ✅ **Privacy-friendly** - Data never leaves your server
|
|
454
|
+
- ✅ **Cost-effective** - Free tier with decent accuracy (~95% country, ~80% city)
|
|
455
|
+
- ✅ **Battle-tested** - Used by Netflix, Cloudflare, AWS
|
|
456
|
+
- ✅ **Offline-capable** - Works without internet connection
|
|
457
|
+
- ✅ **GDPR compliant** - No data sharing with third parties
|
|
458
|
+
|
|
459
|
+
**MaxMind Options:**
|
|
460
|
+
|
|
461
|
+
| Feature | GeoLite2 City (Free) | GeoIP2 City (Paid) |
|
|
462
|
+
|---------|---------------------|-------------------|
|
|
463
|
+
| Price | Free | $40/month or $0.008/lookup |
|
|
464
|
+
| Country accuracy | ~95% | ~99.8% |
|
|
465
|
+
| City accuracy | ~80% | ~96% |
|
|
466
|
+
| Updates | Weekly | Daily |
|
|
467
|
+
| IP coverage | ~200M IPs | ~400M IPs |
|
|
468
|
+
| Additional data | Basic | ISP, connection type, domain |
|
|
469
|
+
| Support | Community | Commercial |
|
|
470
|
+
|
|
471
|
+
**Setup Steps:**
|
|
472
|
+
|
|
473
|
+
1. **Create MaxMind account** (free): https://www.maxmind.com/en/geolite2/signup
|
|
474
|
+
2. **Generate license key** in account dashboard
|
|
475
|
+
3. **Download database** (GeoLite2-City.mmdb, ~70MB)
|
|
476
|
+
4. **Store in:** `db/GeoLite2-City.mmdb` (add to .gitignore!)
|
|
477
|
+
5. **Set up weekly auto-updates** (via cron job)
|
|
478
|
+
|
|
479
|
+
**Alternatives (if MaxMind doesn't work for you):**
|
|
480
|
+
|
|
481
|
+
| Service | Type | Free Tier | Accuracy | Best For |
|
|
482
|
+
|---------|------|-----------|----------|----------|
|
|
483
|
+
| **IPinfo.io** | API | 50k/month | Excellent | Low-traffic sites |
|
|
484
|
+
| **IP2Location** | Database | LITE version | Good | MaxMind alternative |
|
|
485
|
+
| **ipapi.co** | API | 1k/day | Good | Quick prototyping |
|
|
486
|
+
| **ipgeolocation.io** | API | 30k/month | Good | More generous free tier |
|
|
487
|
+
| **AbstractAPI** | API | 20k/month | Good | Simple API |
|
|
488
|
+
|
|
489
|
+
**When to use APIs instead of local database:**
|
|
490
|
+
- Very low traffic (<1k requests/month)
|
|
491
|
+
- Don't want to manage database updates
|
|
492
|
+
- Need additional data (timezone, currency, threat detection)
|
|
493
|
+
- Prototyping/development
|
|
494
|
+
|
|
495
|
+
**Implementation approach:**
|
|
496
|
+
```ruby
|
|
497
|
+
# Gemfile
|
|
498
|
+
gem 'maxminddb' # For local GeoIP2 database
|
|
499
|
+
gem 'browser' # For device/browser detection
|
|
500
|
+
|
|
501
|
+
# Download GeoLite2 database:
|
|
502
|
+
# 1. Sign up at https://www.maxmind.com/en/geolite2/signup
|
|
503
|
+
# 2. Generate license key
|
|
504
|
+
# 3. Download GeoLite2-City.mmdb
|
|
505
|
+
# 4. Place in: db/GeoLite2-City.mmdb
|
|
506
|
+
|
|
507
|
+
# Auto-update script (run weekly via cron)
|
|
508
|
+
# lib/tasks/maxmind.rake
|
|
509
|
+
namespace :maxmind do
|
|
510
|
+
desc "Update GeoLite2 database"
|
|
511
|
+
task :update do
|
|
512
|
+
require 'net/http'
|
|
513
|
+
require 'zlib'
|
|
514
|
+
require 'rubygems/package'
|
|
515
|
+
|
|
516
|
+
license_key = ENV['MAXMIND_LICENSE_KEY']
|
|
517
|
+
edition_id = 'GeoLite2-City'
|
|
518
|
+
url = "https://download.maxmind.com/app/geoip_download?edition_id=#{edition_id}&license_key=#{license_key}&suffix=tar.gz"
|
|
519
|
+
|
|
520
|
+
# Download, extract, and move to db/
|
|
521
|
+
# (implementation details omitted for brevity)
|
|
522
|
+
puts "GeoLite2 database updated successfully"
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# app/services/visitor_location_service.rb
|
|
527
|
+
class VisitorLocationService
|
|
528
|
+
DB_PATH = Rails.root.join('db', 'GeoLite2-City.mmdb')
|
|
529
|
+
|
|
530
|
+
def self.locate(ip_address)
|
|
531
|
+
return nil if ip_address.blank? || local_ip?(ip_address)
|
|
532
|
+
|
|
533
|
+
# Initialize database connection (cached in production)
|
|
534
|
+
db = @db ||= MaxMindDB.new(DB_PATH)
|
|
535
|
+
result = db.lookup(ip_address)
|
|
536
|
+
|
|
537
|
+
return nil unless result.found?
|
|
538
|
+
|
|
539
|
+
{
|
|
540
|
+
country_code: result.country.iso_code,
|
|
541
|
+
country_name: result.country.name,
|
|
542
|
+
city: result.city.name,
|
|
543
|
+
region: result.subdivisions.most_specific&.name,
|
|
544
|
+
latitude: result.location.latitude,
|
|
545
|
+
longitude: result.location.longitude
|
|
546
|
+
}
|
|
547
|
+
rescue MaxMindDB::Error => e
|
|
548
|
+
Rails.logger.error "MaxMind lookup error: #{e.message}"
|
|
549
|
+
nil
|
|
550
|
+
rescue => e
|
|
551
|
+
Rails.logger.error "Geolocation error: #{e.message}"
|
|
552
|
+
nil
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def self.local_ip?(ip)
|
|
556
|
+
return true if ip.nil?
|
|
557
|
+
ip.start_with?('127.', '192.168.', '10.', '172.', 'fc00:', 'fe80:') ||
|
|
558
|
+
ip == '::1' ||
|
|
559
|
+
ip == 'localhost'
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Fallback to API if database not available
|
|
563
|
+
def self.locate_via_api(ip_address)
|
|
564
|
+
return nil if ip_address.blank? || local_ip?(ip_address)
|
|
565
|
+
|
|
566
|
+
# Using IPinfo.io as fallback
|
|
567
|
+
response = HTTP.get("https://ipinfo.io/#{ip_address}/json", params: { token: ENV['IPINFO_TOKEN'] })
|
|
568
|
+
data = JSON.parse(response.body)
|
|
569
|
+
|
|
570
|
+
return nil unless data['country']
|
|
571
|
+
|
|
572
|
+
lat, lng = data['loc']&.split(',')&.map(&:to_f)
|
|
573
|
+
|
|
574
|
+
{
|
|
575
|
+
country_code: data['country'],
|
|
576
|
+
country_name: ISO3166::Country[data['country']]&.name,
|
|
577
|
+
city: data['city'],
|
|
578
|
+
region: data['region'],
|
|
579
|
+
latitude: lat,
|
|
580
|
+
longitude: lng
|
|
581
|
+
}
|
|
582
|
+
rescue => e
|
|
583
|
+
Rails.logger.error "API geolocation error: #{e.message}"
|
|
584
|
+
nil
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Alternative: IP2Location implementation
|
|
589
|
+
# gem 'ip2location_ruby'
|
|
590
|
+
class VisitorLocationServiceIP2Location
|
|
591
|
+
DB_PATH = Rails.root.join('db', 'IP2LOCATION-LITE-DB5.BIN')
|
|
592
|
+
|
|
593
|
+
def self.locate(ip_address)
|
|
594
|
+
return nil if ip_address.blank?
|
|
595
|
+
|
|
596
|
+
db = IP2Location::Database.new(DB_PATH, :file_io)
|
|
597
|
+
result = db.get_all(ip_address)
|
|
598
|
+
|
|
599
|
+
return nil if result[:status] != 'OK'
|
|
600
|
+
|
|
601
|
+
{
|
|
602
|
+
country_code: result[:country_short],
|
|
603
|
+
country_name: result[:country_long],
|
|
604
|
+
city: result[:city],
|
|
605
|
+
region: result[:region],
|
|
606
|
+
latitude: result[:latitude],
|
|
607
|
+
longitude: result[:longitude]
|
|
608
|
+
}
|
|
609
|
+
rescue => e
|
|
610
|
+
Rails.logger.error "IP2Location error: #{e.message}"
|
|
611
|
+
nil
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# app/services/device_detector_service.rb
|
|
616
|
+
class DeviceDetectorService
|
|
617
|
+
def self.detect(user_agent, app_platform: nil)
|
|
618
|
+
browser = Browser.new(user_agent)
|
|
619
|
+
|
|
620
|
+
{
|
|
621
|
+
device_type: detect_device_type(browser, app_platform),
|
|
622
|
+
browser: browser.name,
|
|
623
|
+
browser_version: browser.version,
|
|
624
|
+
os: browser.platform.name,
|
|
625
|
+
os_version: browser.platform.version,
|
|
626
|
+
is_webview: is_webview?(user_agent)
|
|
627
|
+
}
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def self.detect_device_type(browser, app_platform)
|
|
631
|
+
# If it's from a mobile app, always mark as hybrid_app
|
|
632
|
+
return 'hybrid_app' if app_platform.present?
|
|
633
|
+
return 'mobile' if browser.device.mobile?
|
|
634
|
+
return 'tablet' if browser.device.tablet?
|
|
635
|
+
|
|
636
|
+
'desktop'
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def self.is_webview?(user_agent)
|
|
640
|
+
ua = user_agent.to_s.downcase
|
|
641
|
+
ua.match?(/wv|webview|; wv\)/)
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
### Counter Cache
|
|
647
|
+
- Use Rails counter_cache for performance
|
|
648
|
+
- Update counters using `increment_counter` / `decrement_counter`
|
|
649
|
+
- Consider using background jobs for non-critical counter updates
|
|
650
|
+
|
|
651
|
+
### Privacy & Permissions
|
|
652
|
+
- Respect user privacy settings
|
|
653
|
+
- Allow users to opt-out of tracking (where applicable)
|
|
654
|
+
- Implement proper authorization for viewing follower lists
|
|
655
|
+
- Consider GDPR compliance for personal data
|
|
656
|
+
|
|
657
|
+
### Performance
|
|
658
|
+
- Cache counter values to avoid N+1 queries
|
|
659
|
+
- Use database indexes on foreign keys
|
|
660
|
+
- Consider Redis for real-time counter updates
|
|
661
|
+
- Implement rate limiting on counter actions to prevent abuse
|
|
662
|
+
|
|
663
|
+
### Analytics Integration
|
|
664
|
+
- Store historical data for trend analysis
|
|
665
|
+
- Generate reports on engagement metrics
|
|
666
|
+
- Export data for external analytics tools
|
|
667
|
+
- Track counter changes over time
|
|
668
|
+
|
|
669
|
+
### User Interface
|
|
670
|
+
- Use icons to represent each counter type
|
|
671
|
+
- Implement loading states for counter actions
|
|
672
|
+
- Show confirmation messages for user actions
|
|
673
|
+
- Use optimistic UI updates where appropriate
|
|
674
|
+
- Make counters clickable to show more details (where applicable)
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## Database Migrations
|
|
679
|
+
|
|
680
|
+
### Add counter columns to pages table
|
|
681
|
+
```ruby
|
|
682
|
+
add_column :pages, :page_views_count, :integer, default: 0, null: false
|
|
683
|
+
add_column :pages, :followers_count, :integer, default: 0, null: false
|
|
684
|
+
add_column :pages, :bookmarks_count, :integer, default: 0, null: false
|
|
685
|
+
add_column :pages, :shares_count, :integer, default: 0, null: false
|
|
686
|
+
|
|
687
|
+
add_index :pages, :page_views_count
|
|
688
|
+
add_index :pages, :followers_count
|
|
689
|
+
add_index :pages, :bookmarks_count
|
|
690
|
+
add_index :pages, :shares_count
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### Create association tables
|
|
694
|
+
```ruby
|
|
695
|
+
# PageFollower
|
|
696
|
+
create_table :page_followers do |t|
|
|
697
|
+
t.references :page, null: false, foreign_key: true
|
|
698
|
+
t.references :user, null: false, foreign_key: true
|
|
699
|
+
t.datetime :followed_at, null: false
|
|
700
|
+
t.timestamps
|
|
701
|
+
end
|
|
702
|
+
add_index :page_followers, [:page_id, :user_id], unique: true
|
|
703
|
+
|
|
704
|
+
# PageBookmark
|
|
705
|
+
create_table :page_bookmarks do |t|
|
|
706
|
+
t.references :page, null: false, foreign_key: true
|
|
707
|
+
t.references :user, null: false, foreign_key: true
|
|
708
|
+
t.text :notes
|
|
709
|
+
t.datetime :bookmarked_at, null: false
|
|
710
|
+
t.timestamps
|
|
711
|
+
end
|
|
712
|
+
add_index :page_bookmarks, [:page_id, :user_id], unique: true
|
|
713
|
+
|
|
714
|
+
# PageView - with demographics and multitenancy
|
|
715
|
+
create_table :content_signals_page_views do |t|
|
|
716
|
+
# Multi-tenancy support (optional)
|
|
717
|
+
t.references :tenant, null: true, index: true
|
|
718
|
+
|
|
719
|
+
# Polymorphic trackable (Page, Profile, Event, etc.)
|
|
720
|
+
t.string :trackable_type, null: false
|
|
721
|
+
t.bigint :trackable_id, null: false
|
|
722
|
+
|
|
723
|
+
t.references :user, null: true, foreign_key: true
|
|
724
|
+
t.string :visitor_id, null: false
|
|
725
|
+
t.string :ip_address
|
|
726
|
+
t.string :user_agent
|
|
727
|
+
t.string :referrer
|
|
728
|
+
|
|
729
|
+
# Demographics
|
|
730
|
+
t.string :country_code, limit: 2
|
|
731
|
+
t.string :country_name
|
|
732
|
+
t.string :city
|
|
733
|
+
t.string :region
|
|
734
|
+
t.decimal :latitude, precision: 10, scale: 6
|
|
735
|
+
t.decimal :longitude, precision: 10, scale: 6
|
|
736
|
+
t.string :locale, limit: 10
|
|
737
|
+
|
|
738
|
+
# Device info
|
|
739
|
+
t.string :device_type, limit: 20
|
|
740
|
+
t.string :browser, limit: 50
|
|
741
|
+
t.string :os, limit: 50
|
|
742
|
+
|
|
743
|
+
# Hybrid app support
|
|
744
|
+
t.string :app_platform, limit: 20 # capacitor, cordova, react_native, flutter
|
|
745
|
+
t.string :app_version, limit: 20 # e.g., "1.2.3"
|
|
746
|
+
t.string :device_id # Persistent device identifier from app
|
|
747
|
+
|
|
748
|
+
t.datetime :viewed_at, null: false
|
|
749
|
+
t.timestamps
|
|
750
|
+
|
|
751
|
+
# Indexes for performance
|
|
752
|
+
t.index [:trackable_type, :trackable_id, :viewed_at], name: 'index_page_views_on_trackable_and_date'
|
|
753
|
+
t.index [:trackable_type, :trackable_id, :country_code], name: 'index_page_views_on_trackable_and_country'
|
|
754
|
+
t.index [:trackable_type, :trackable_id, :city], name: 'index_page_views_on_trackable_and_city'
|
|
755
|
+
t.index [:trackable_type, :trackable_id, :device_type], name: 'index_page_views_on_trackable_and_device'
|
|
756
|
+
t.index [:trackable_type, :trackable_id, :app_platform], name: 'index_page_views_on_trackable_and_platform'
|
|
757
|
+
t.index [:tenant_id, :viewed_at], name: 'index_page_views_on_tenant_and_date'
|
|
758
|
+
t.index [:visitor_id, :viewed_at]
|
|
759
|
+
t.index [:device_id]
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# PageShare (optional, for detailed tracking)
|
|
763
|
+
create_table :page_shares do |t|
|
|
764
|
+
t.references :page, null: false, foreign_key: true
|
|
765
|
+
t.references :user, null: true, foreign_key: true
|
|
766
|
+
t.string :platform, null: false
|
|
767
|
+
t.string :referrer
|
|
768
|
+
t.datetime :shared_at, null: false
|
|
769
|
+
t.timestamps
|
|
770
|
+
end
|
|
771
|
+
add_index :page_shares, :page_id
|
|
772
|
+
add_index :page_shares, :platform
|
|
773
|
+
|
|
774
|
+
# PageViewStats - aggregated demographics
|
|
775
|
+
create_table :page_view_stats do |t|
|
|
776
|
+
t.references :page, null: false, foreign_key: true
|
|
777
|
+
t.integer :period_type, null: false # daily, weekly, monthly
|
|
778
|
+
t.datetime :period_start, null: false
|
|
779
|
+
t.integer :total_views, default: 0
|
|
780
|
+
t.integer :unique_views, default: 0
|
|
781
|
+
|
|
782
|
+
# Top countries (JSON or separate table)
|
|
783
|
+
t.jsonb :country_stats # { "US": 1234, "RO": 567, ... }
|
|
784
|
+
t.jsonb :city_stats # { "New York": 234, "Bucharest": 123, ... }
|
|
785
|
+
t.jsonb :device_stats # { "mobile": 789, "desktop": 456, "tablet": 12 }
|
|
786
|
+
|
|
787
|
+
t.timestamps
|
|
788
|
+
|
|
789
|
+
t.index [:page_id, :period_type, :period_start], unique: true
|
|
790
|
+
end
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
---
|
|
794
|
+
|
|
795
|
+
## API Endpoints Summary
|
|
796
|
+
|
|
797
|
+
```
|
|
798
|
+
# Page Views (read-only, auto-incremented on page load)
|
|
799
|
+
GET /cms/pages/:id/views
|
|
800
|
+
GET /cms/pages/:id/analytics/demographics
|
|
801
|
+
GET /cms/pages/:id/analytics/countries
|
|
802
|
+
GET /cms/pages/:id/analytics/cities
|
|
803
|
+
GET /cms/pages/:id/analytics/devices
|
|
804
|
+
|
|
805
|
+
# Followers
|
|
806
|
+
POST /cms/pages/:id/follow
|
|
807
|
+
DELETE /cms/pages/:id/unfollow
|
|
808
|
+
GET /cms/pages/:id/followers
|
|
809
|
+
GET /cms/pages/:id/following_status
|
|
810
|
+
|
|
811
|
+
# Bookmarks
|
|
812
|
+
POST /cms/pages/:id/bookmark
|
|
813
|
+
DELETE /cms/pages/:id/unbookmark
|
|
814
|
+
GET /users/bookmarks
|
|
815
|
+
|
|
816
|
+
# Shares
|
|
817
|
+
POST /cms/pages/:id/share
|
|
818
|
+
GET /cms/pages/:id/shares/stats
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
## Complete Implementation Example
|
|
824
|
+
|
|
825
|
+
### 1. Enhanced PageViewTracker Service
|
|
826
|
+
|
|
827
|
+
```ruby
|
|
828
|
+
# app/services/page_view_tracker.rb
|
|
829
|
+
class PageViewTracker
|
|
830
|
+
def self.track(page:, user:, request:)
|
|
831
|
+
new(page, user, request).track
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def initialize(page, user, request)
|
|
835
|
+
@page = page
|
|
836
|
+
@user = user
|
|
837
|
+
@request = request
|
|
838
|
+
@visitor_id = identify_visitor
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def track
|
|
842
|
+
# Always increment total views (fast, non-blocking)
|
|
843
|
+
@page.increment!(:page_views_count)
|
|
844
|
+
|
|
845
|
+
# Track unique view if first time today
|
|
846
|
+
track_unique_view if unique_today?
|
|
847
|
+
|
|
848
|
+
# Gather all tracking data
|
|
849
|
+
tracking_data = compile_tracking_data
|
|
850
|
+
|
|
851
|
+
# Store detailed view record asynchronously
|
|
852
|
+
TrackPageViewJob.perform_later(@page.id, @user&.id, tracking_data)
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
private
|
|
856
|
+
|
|
857
|
+
def identify_visitor
|
|
858
|
+
# 1. Authenticated user (most reliable)
|
|
859
|
+
return "user_#{@user.id}" if @user
|
|
860
|
+
|
|
861
|
+
# 2. Device ID from mobile app (persistent across app sessions)
|
|
862
|
+
device_id = @request.headers['X-Device-ID'] || @request.params[:device_id]
|
|
863
|
+
return "device_#{device_id}" if device_id.present?
|
|
864
|
+
|
|
865
|
+
# 3. Browser cookie (works for web, not reliable in WebViews)
|
|
866
|
+
cookie_id = @request.cookie_jar.signed[:visitor_id]
|
|
867
|
+
return cookie_id if cookie_id.present?
|
|
868
|
+
|
|
869
|
+
# 4. Fallback to IP + User Agent hash
|
|
870
|
+
"anon_#{Digest::SHA256.hexdigest("#{@request.ip}:#{@request.user_agent}")[0..15]}"
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def unique_today?
|
|
874
|
+
key = "page_view:#{@page.id}:#{@visitor_id}:#{Date.current}"
|
|
875
|
+
!Rails.cache.exist?(key)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def track_unique_view
|
|
879
|
+
key = "page_view:#{@page.id}:#{@visitor_id}:#{Date.current}"
|
|
880
|
+
Rails.cache.write(key, true, expires_in: 24.hours)
|
|
881
|
+
|
|
882
|
+
Rails.cache.increment("page_unique_views:#{@page.id}:#{Date.current}", 1)
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
def compile_tracking_data
|
|
886
|
+
location_data = VisitorLocationService.locate(@request.ip)
|
|
887
|
+
app_context = detect_app_context
|
|
888
|
+
device_data = DeviceDetectorService.detect(
|
|
889
|
+
@request.user_agent,
|
|
890
|
+
app_platform: app_context[:app_platform]
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
{
|
|
894
|
+
tenant_id: current_tenant_id,
|
|
895
|
+
trackable_type: @page.class.name,
|
|
896
|
+
trackable_id: @page.id,
|
|
897
|
+
visitor_id: @visitor_id,
|
|
898
|
+
ip_address: @request.ip,
|
|
899
|
+
user_agent: @request.user_agent,
|
|
900
|
+
referrer: @request.referrer,
|
|
901
|
+
locale: @request.headers['Accept-Language']&.split(',')&.first || I18n.locale.to_s,
|
|
902
|
+
viewed_at: Time.current,
|
|
903
|
+
app_platform: app_context[:app_platform],
|
|
904
|
+
app_version: app_context[:app_version],
|
|
905
|
+
device_id: app_context[:device_id]
|
|
906
|
+
}.merge(location_data || {}).merge(device_data || {})
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def current_tenant_id
|
|
910
|
+
return nil unless ContentSignals.configuration.multitenancy?
|
|
911
|
+
|
|
912
|
+
# Call the configured method to get tenant ID
|
|
913
|
+
method_name = ContentSignals.configuration.current_tenant_method
|
|
914
|
+
@request.env['action_controller.instance']&.send(method_name)
|
|
915
|
+
rescue => e
|
|
916
|
+
Rails.logger.error "Failed to get tenant_id: #{e.message}"
|
|
917
|
+
nil
|
|
918
|
+
|
|
919
|
+
def detect_platform_from_ua
|
|
920
|
+
ua = @request.user_agent.to_s.downcase
|
|
921
|
+
return 'capacitor' if ua.include?('capacitor')
|
|
922
|
+
return 'cordova' if ua.include?('cordova')
|
|
923
|
+
return 'react_native' if ua.include?('react-native')
|
|
924
|
+
return 'flutter' if ua.include?('flutter')
|
|
925
|
+
return 'webview' if ua.match?(/wv|webview/)
|
|
926
|
+
nil
|
|
927
|
+
end
|
|
928
|
+
end
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
### 2. Background Job for Detailed Tracking
|
|
932
|
+
|
|
933
|
+
```ruby
|
|
934
|
+
# app/jobs/track_page_view_job.rb
|
|
935
|
+
class TrackPageViewJob < ApplicationJob
|
|
936
|
+
queue_as :default
|
|
937
|
+
|
|
938
|
+
# Retry on database errors but not on validation errors
|
|
939
|
+
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
|
|
940
|
+
discard_on ActiveRecord::RecordInvalid
|
|
941
|
+
|
|
942
|
+
def perform(trackable_type, trackable_id, user_id, tracking_data)
|
|
943
|
+
ContentSignals::PageView.create!(
|
|
944
|
+
tenant_id: tracking_data[:tenant_id],
|
|
945
|
+
trackable_type: trackable_type,
|
|
946
|
+
trackable_id: trackable_id,
|
|
947
|
+
user_id: user_id,
|
|
948
|
+
visitor_id: tracking_data[:visitor_id],
|
|
949
|
+
ip_address: tracking_data[:ip_address],
|
|
950
|
+
user_agent: tracking_data[:user_agent],
|
|
951
|
+
referrer: tracking_data[:referrer],
|
|
952
|
+
country_code: tracking_data[:country_code],
|
|
953
|
+
country_name: tracking_data[:country_name],
|
|
954
|
+
city: tracking_data[:city],
|
|
955
|
+
region: tracking_data[:region],
|
|
956
|
+
latitude: tracking_data[:latitude],
|
|
957
|
+
longitude: tracking_data[:longitude],
|
|
958
|
+
locale: tracking_data[:locale],
|
|
959
|
+
device_type: tracking_data[:device_type],
|
|
960
|
+
browser: tracking_data[:browser],
|
|
961
|
+
os: tracking_data[:os],
|
|
962
|
+
app_platform: tracking_data[:app_platform],
|
|
963
|
+
app_version: tracking_data[:app_version],
|
|
964
|
+
device_id: tracking_data[:device_id],
|
|
965
|
+
viewed_at: tracking_data[:viewed_at]
|
|
966
|
+
)
|
|
967
|
+
rescue => e
|
|
968
|
+
Rails.logger.error "Failed to track page view: #{e.message}"
|
|
969
|
+
# Optionally send to error tracking service (Sentry, Rollbar, etc.)
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
### 3. PageView Model with Scopes
|
|
975
|
+
|
|
976
|
+
```ruby
|
|
977
|
+
# app/models/content_signals/page_view.rb
|
|
978
|
+
module ContentSignals
|
|
979
|
+
class PageView < ApplicationRecord
|
|
980
|
+
self.table_name = 'content_signals_page_views'
|
|
981
|
+
|
|
982
|
+
# Associations
|
|
983
|
+
belongs_to :trackable, polymorphic: true
|
|
984
|
+
belongs_to :tenant, optional: true
|
|
985
|
+
belongs_to :user, optional: true
|
|
986
|
+
|
|
987
|
+
# Multi-tenant scoping (automatic)
|
|
988
|
+
if ContentSignals.configuration.multitenancy?
|
|
989
|
+
default_scope -> { where(tenant_id: ContentSignals.current_tenant_id) }
|
|
990
|
+
|
|
991
|
+
# Bypass tenant scoping (admin use only)
|
|
992
|
+
scope :unscoped_by_tenant, -> { unscope(where: :tenant_id) }
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
# Time period scopes
|
|
996
|
+
scope :today, -> { where('viewed_at >= ?', Time.current.beginning_of_day) }
|
|
997
|
+
scope :yesterday, -> { where(viewed_at: 1.day.ago.all_day) }
|
|
998
|
+
scope :this_week, -> { where('viewed_at >= ?', Time.current.beginning_of_week) }
|
|
999
|
+
scope :last_week, -> { where(viewed_at: 1.week.ago.beginning_of_week..1.week.ago.end_of_week) }
|
|
1000
|
+
scope :this_month, -> { where('viewed_at >= ?', Time.current.beginning_of_month) }
|
|
1001
|
+
scope :last_30_days, -> { where('viewed_at >= ?', 30.days.ago) }
|
|
1002
|
+
|
|
1003
|
+
# Device scopes
|
|
1004
|
+
scope :mobile, -> { where(device_type: 'mobile') }
|
|
1005
|
+
scope :desktop, -> { where(device_type: 'desktop') }
|
|
1006
|
+
scope :tablet, -> { where(device_type: 'tablet') }
|
|
1007
|
+
|
|
1008
|
+
# Location scopes
|
|
1009
|
+
scope :from_country, ->(country_code) { where(country_code: country_code) }
|
|
1010
|
+
scope :from_city, ->(city) { where(city: city) }
|
|
1011
|
+
|
|
1012
|
+
# Analytics methods
|
|
1013
|
+
def self.unique_count(period = :today)
|
|
1014
|
+
send(period).distinct.count(:visitor_id)
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
def self.top_countries(limit = 10)
|
|
1018
|
+
where.not(country_name: nil)
|
|
1019
|
+
.group(:country_code, :country_name)
|
|
1020
|
+
.order('count_id DESC')
|
|
1021
|
+
.limit(limit)
|
|
1022
|
+
.count(:id)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
def self.top_cities(limit = 10)
|
|
1026
|
+
where.not(city: nil)
|
|
1027
|
+
.group(:city, :country_name)
|
|
1028
|
+
.order('count_id DESC')
|
|
1029
|
+
.limit(limit)
|
|
1030
|
+
.count(:id)
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
def self.device_breakdown
|
|
1034
|
+
group(:device_type)
|
|
1035
|
+
.count(:id)
|
|
1036
|
+
.transform_keys { |k| k || 'unknown' }
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def self.browser_breakdown
|
|
1040
|
+
group(:browser)
|
|
1041
|
+
.order('count_id DESC')
|
|
1042
|
+
.count(:id)
|
|
1043
|
+
.transform_keys { |k| k || 'unknown' }
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
def self.hourly_distribution
|
|
1047
|
+
group("EXTRACT(HOUR FROM viewed_at)::integer")
|
|
1048
|
+
.order("EXTRACT(HOUR FROM viewed_at)::integer")
|
|
1049
|
+
.count(:id)
|
|
1050
|
+
end
|
|
1051
|
+
|
|
1052
|
+
# For map visualization - returns array of [lat, lng, count]
|
|
1053
|
+
def self.location_heatmap
|
|
1054
|
+
where.not(latitude: nil, longitude: nil)
|
|
1055
|
+
.group(:latitude, :longitude)
|
|
1056
|
+
.count(:id)
|
|
1057
|
+
.map { |(lat, lng), count| { lat: lat, lng: lng, count: count } }
|
|
1058
|
+
end
|
|
1059
|
+
end
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
### 4. Controller Integration
|
|
1063
|
+
|
|
1064
|
+
```ruby
|
|
1065
|
+
# app/controllers/concerns/trackable_page_views.rb
|
|
1066
|
+
module TrackablePageViews
|
|
1067
|
+
extend ActiveSupport::Concern
|
|
1068
|
+
|
|
1069
|
+
included do
|
|
1070
|
+
after_action :track_page_view, only: [:show]
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
private
|
|
1074
|
+
|
|
1075
|
+
def track_page_view
|
|
1076
|
+
return if should_skip_tracking?
|
|
1077
|
+
|
|
1078
|
+
PageViewTracker.track(
|
|
1079
|
+
page: @page,
|
|
1080
|
+
user: current_user,
|
|
1081
|
+
request: request
|
|
1082
|
+
)
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
def should_skip_tracking?
|
|
1086
|
+
bot_request? ||
|
|
1087
|
+
admin_viewing? ||
|
|
1088
|
+
preview_mode? ||
|
|
1089
|
+
@page.nil?
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def bot_request?
|
|
1093
|
+
browser = Browser.new(request.user_agent)
|
|
1094
|
+
browser.bot? || browser.search_engine?
|
|
1095
|
+
rescue
|
|
1096
|
+
false
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def admin_viewing?
|
|
1100
|
+
current_user&.admin? || can_edit_page?
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def can_edit_page?
|
|
1104
|
+
return false unless current_user && @page
|
|
1105
|
+
current_user.can?(:edit, @page)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
def preview_mode?
|
|
1109
|
+
params[:preview].present? || session[:preview_mode]
|
|
1110
|
+
end
|
|
1111
|
+
end
|
|
1112
|
+
|
|
1113
|
+
# Include in your pages controller
|
|
1114
|
+
# app/controllers/pages_controller.rb
|
|
1115
|
+
class PagesController < ApplicationController
|
|
1116
|
+
include TrackablePageViews
|
|
1117
|
+
|
|
1118
|
+
def show
|
|
1119
|
+
@page = Page.find(params[:id])
|
|
1120
|
+
# ... rest of your show action
|
|
1121
|
+
end
|
|
1122
|
+
end
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
### 5. Analytics Service
|
|
1126
|
+
|
|
1127
|
+
```ruby
|
|
1128
|
+
# app/services/page_analytics_service.rb
|
|
1129
|
+
class PageAnalyticsService
|
|
1130
|
+
attr_reader :page, :period
|
|
1131
|
+
|
|
1132
|
+
def initialize(page, period: :last_30_days)
|
|
1133
|
+
@page = page
|
|
1134
|
+
@period = period
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
def stats
|
|
1138
|
+
views = page.page_views.send(period)
|
|
1139
|
+
|
|
1140
|
+
{
|
|
1141
|
+
total_views: views.count,
|
|
1142
|
+
unique_views: views.distinct.count(:visitor_id),
|
|
1143
|
+
demographics: {
|
|
1144
|
+
countries: views.top_countries(10),
|
|
1145
|
+
cities: views.top_cities(10),
|
|
1146
|
+
map_data: views.location_heatmap
|
|
1147
|
+
},
|
|
1148
|
+
devices: {
|
|
1149
|
+
types: views.device_breakdown,
|
|
1150
|
+
browsers: views.browser_breakdown.first(10)
|
|
1151
|
+
},
|
|
1152
|
+
timeline: views.group_by_day(:viewed_at, range: period_range).count,
|
|
1153
|
+
hourly_pattern: views.hourly_distribution,
|
|
1154
|
+
top_referrers: top_referrers(views)
|
|
1155
|
+
}
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def export_csv
|
|
1159
|
+
require 'csv'
|
|
1160
|
+
|
|
1161
|
+
CSV.generate(headers: true) do |csv|
|
|
1162
|
+
csv << [
|
|
1163
|
+
'Date', 'Country', 'City', 'Device', 'Browser',
|
|
1164
|
+
'OS', 'Referrer', 'Unique Visitor'
|
|
1165
|
+
]
|
|
1166
|
+
|
|
1167
|
+
page.page_views.send(period).find_each do |view|
|
|
1168
|
+
csv << [
|
|
1169
|
+
view.viewed_at.to_date,
|
|
1170
|
+
view.country_name,
|
|
1171
|
+
view.city,
|
|
1172
|
+
view.device_type,
|
|
1173
|
+
view.browser,
|
|
1174
|
+
view.os,
|
|
1175
|
+
view.referrer,
|
|
1176
|
+
view.visitor_id
|
|
1177
|
+
]
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
private
|
|
1183
|
+
|
|
1184
|
+
def period_range
|
|
1185
|
+
case period
|
|
1186
|
+
when :today then Date.current..Date.current
|
|
1187
|
+
when :this_week then Date.current.beginning_of_week..Date.current
|
|
1188
|
+
when :this_month then Date.current.beginning_of_month..Date.current
|
|
1189
|
+
when :last_30_days then 30.days.ago.to_date..Date.current
|
|
1190
|
+
else 30.days.ago.to_date..Date.current
|
|
1191
|
+
end
|
|
1192
|
+
end
|
|
1193
|
+
|
|
1194
|
+
def top_referrers(views)
|
|
1195
|
+
views.where.not(referrer: nil)
|
|
1196
|
+
.group(:referrer)
|
|
1197
|
+
.order('count_id DESC')
|
|
1198
|
+
.limit(10)
|
|
1199
|
+
.count(:id)
|
|
1200
|
+
end
|
|
1201
|
+
end
|
|
1202
|
+
```
|
|
1203
|
+
|
|
1204
|
+
### 6. Analytics Controller
|
|
1205
|
+
|
|
1206
|
+
```ruby
|
|
1207
|
+
# app/controllers/cms/page_analytics_controller.rb
|
|
1208
|
+
module Cms
|
|
1209
|
+
class PageAnalyticsController < Cms::BaseController
|
|
1210
|
+
before_action :set_page
|
|
1211
|
+
before_action :authorize_analytics_access
|
|
1212
|
+
|
|
1213
|
+
def show
|
|
1214
|
+
@period = params[:period]&.to_sym || :last_30_days
|
|
1215
|
+
@analytics = PageAnalyticsService.new(@page, period: @period)
|
|
1216
|
+
@stats = @analytics.stats
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
def demographics
|
|
1220
|
+
@period = params[:period]&.to_sym || :last_30_days
|
|
1221
|
+
views = @page.page_views.send(@period)
|
|
1222
|
+
|
|
1223
|
+
render json: {
|
|
1224
|
+
countries: views.top_countries(20),
|
|
1225
|
+
cities: views.top_cities(20),
|
|
1226
|
+
map_data: views.location_heatmap
|
|
1227
|
+
}
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def devices
|
|
1231
|
+
@period = params[:period]&.to_sym || :last_30_days
|
|
1232
|
+
views = @page.page_views.send(@period)
|
|
1233
|
+
|
|
1234
|
+
render json: {
|
|
1235
|
+
types: views.device_breakdown,
|
|
1236
|
+
browsers: views.browser_breakdown
|
|
1237
|
+
}
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def export
|
|
1241
|
+
@period = params[:period]&.to_sym || :last_30_days
|
|
1242
|
+
analytics = PageAnalyticsService.new(@page, period: @period)
|
|
1243
|
+
|
|
1244
|
+
send_data analytics.export_csv,
|
|
1245
|
+
filename: "page-#{@page.id}-analytics-#{Date.current}.csv",
|
|
1246
|
+
type: 'text/csv'
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
private
|
|
1250
|
+
|
|
1251
|
+
def set_page
|
|
1252
|
+
@page = Page.find(params[:page_id])
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
def authorize_analytics_access
|
|
1256
|
+
authorize! :read_analytics, @page
|
|
1257
|
+
end
|
|
1258
|
+
end
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
# config/routes.rb
|
|
1262
|
+
namespace :cms do
|
|
1263
|
+
resources :pages do
|
|
1264
|
+
resource :analytics, only: [:show], controller: 'page_analytics' do
|
|
1265
|
+
get :demographics
|
|
1266
|
+
get :devices
|
|
1267
|
+
get :export
|
|
1268
|
+
end
|
|
1269
|
+
end
|
|
1270
|
+
end
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
### 7. Daily Aggregation Job
|
|
1274
|
+
|
|
1275
|
+
```ruby
|
|
1276
|
+
# app/jobs/aggregate_page_views_job.rb
|
|
1277
|
+
class AggregatePageViewsJob < ApplicationJob
|
|
1278
|
+
queue_as :low_priority
|
|
1279
|
+
|
|
1280
|
+
def perform(date = Date.yesterday)
|
|
1281
|
+
Page.find_each do |page|
|
|
1282
|
+
aggregate_for_page(page, date)
|
|
1283
|
+
end
|
|
1284
|
+
end
|
|
1285
|
+
|
|
1286
|
+
private
|
|
1287
|
+
|
|
1288
|
+
def aggregate_for_page(page, date)
|
|
1289
|
+
views = page.page_views.where(viewed_at: date.all_day)
|
|
1290
|
+
|
|
1291
|
+
return if views.empty?
|
|
1292
|
+
|
|
1293
|
+
PageViewStat.create_or_find_by!(
|
|
1294
|
+
page: page,
|
|
1295
|
+
period_type: :daily,
|
|
1296
|
+
period_start: date.beginning_of_day
|
|
1297
|
+
).update!(
|
|
1298
|
+
total_views: views.count,
|
|
1299
|
+
unique_views: views.distinct.count(:visitor_id),
|
|
1300
|
+
country_stats: aggregate_countries(views),
|
|
1301
|
+
city_stats: aggregate_cities(views),
|
|
1302
|
+
device_stats: aggregate_devices(views)
|
|
1303
|
+
)
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
def aggregate_countries(views)
|
|
1307
|
+
views.where.not(country_code: nil)
|
|
1308
|
+
.group(:country_code, :country_name)
|
|
1309
|
+
.count(:id)
|
|
1310
|
+
.transform_keys { |(code, name)| "#{code}:#{name}" }
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def aggregate_cities(views)
|
|
1314
|
+
views.where.not(city: nil)
|
|
1315
|
+
.group(:city)
|
|
1316
|
+
.count(:id)
|
|
1317
|
+
end
|
|
1318
|
+
|
|
1319
|
+
def aggregate_devices(views)
|
|
1320
|
+
views.group(:device_type).count(:id)
|
|
1321
|
+
end
|
|
1322
|
+
end
|
|
1323
|
+
|
|
1324
|
+
# Schedule with sidekiq-cron or whenever
|
|
1325
|
+
# config/schedule.yml (for sidekiq-cron)
|
|
1326
|
+
aggregate_page_views:
|
|
1327
|
+
cron: "0 2 * * *" # Run at 2 AM daily
|
|
1328
|
+
class: "AggregatePageViewsJob"
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
### 8. View Example (Analytics Dashboard)
|
|
1332
|
+
|
|
1333
|
+
```erb
|
|
1334
|
+
<!-- app/views/cms/page_analytics/show.html.erb -->
|
|
1335
|
+
<div class="analytics-dashboard">
|
|
1336
|
+
<div class="analytics-header">
|
|
1337
|
+
<h2>Analytics for <%= @page.title %></h2>
|
|
1338
|
+
|
|
1339
|
+
<%= form_with url: cms_page_analytics_path(@page), method: :get, class: "period-selector" do |f| %>
|
|
1340
|
+
<%= f.select :period,
|
|
1341
|
+
options_for_select([
|
|
1342
|
+
['Today', 'today'],
|
|
1343
|
+
['This Week', 'this_week'],
|
|
1344
|
+
['This Month', 'this_month'],
|
|
1345
|
+
['Last 30 Days', 'last_30_days']
|
|
1346
|
+
], @period),
|
|
1347
|
+
{},
|
|
1348
|
+
onchange: 'this.form.submit()' %>
|
|
1349
|
+
<% end %>
|
|
1350
|
+
|
|
1351
|
+
<%= link_to "Export CSV", export_cms_page_analytics_path(@page, period: @period),
|
|
1352
|
+
class: "btn btn--outline" %>
|
|
1353
|
+
</div>
|
|
1354
|
+
|
|
1355
|
+
<div class="analytics-stats">
|
|
1356
|
+
<div class="stat-card">
|
|
1357
|
+
<h3>Total Views</h3>
|
|
1358
|
+
<p class="stat-value"><%= number_with_delimiter(@stats[:total_views]) %></p>
|
|
1359
|
+
</div>
|
|
1360
|
+
|
|
1361
|
+
<div class="stat-card">
|
|
1362
|
+
<h3>Unique Visitors</h3>
|
|
1363
|
+
<p class="stat-value"><%= number_with_delimiter(@stats[:unique_views]) %></p>
|
|
1364
|
+
</div>
|
|
1365
|
+
</div>
|
|
1366
|
+
|
|
1367
|
+
<div class="analytics-charts">
|
|
1368
|
+
<!-- Timeline Chart -->
|
|
1369
|
+
<div class="chart-container">
|
|
1370
|
+
<h3>Views Over Time</h3>
|
|
1371
|
+
<canvas id="timeline-chart"
|
|
1372
|
+
data-timeline="<%= @stats[:timeline].to_json %>"></canvas>
|
|
1373
|
+
</div>
|
|
1374
|
+
|
|
1375
|
+
<!-- Device Breakdown -->
|
|
1376
|
+
<div class="chart-container">
|
|
1377
|
+
<h3>Device Types</h3>
|
|
1378
|
+
<canvas id="device-chart"
|
|
1379
|
+
data-devices="<%= @stats[:devices][:types].to_json %>"></canvas>
|
|
1380
|
+
</div>
|
|
1381
|
+
|
|
1382
|
+
<!-- Top Countries -->
|
|
1383
|
+
<div class="chart-container">
|
|
1384
|
+
<h3>Top Countries</h3>
|
|
1385
|
+
<table class="analytics-table">
|
|
1386
|
+
<% @stats[:demographics][:countries].each do |(code, name), count| %>
|
|
1387
|
+
<tr>
|
|
1388
|
+
<td><%= name %> (<%= code %>)</td>
|
|
1389
|
+
<td><%= number_with_delimiter(count) %></td>
|
|
1390
|
+
</tr>
|
|
1391
|
+
<% end %>
|
|
1392
|
+
</table>
|
|
1393
|
+
</div>
|
|
1394
|
+
|
|
1395
|
+
<!-- Location Map -->
|
|
1396
|
+
<div class="chart-container full-width">
|
|
1397
|
+
<h3>Visitor Locations</h3>
|
|
1398
|
+
<div id="visitor-map"
|
|
1399
|
+
data-locations="<%= @stats[:demographics][:map_data].to_json %>"
|
|
1400
|
+
style="height: 500px;"></div>
|
|
1401
|
+
</div>
|
|
1402
|
+
</div>
|
|
1403
|
+
</div>
|
|
1404
|
+
|
|
1405
|
+
<!-- Include Chart.js and Leaflet for visualizations -->
|
|
1406
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
1407
|
+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
1408
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
1409
|
+
```
|
|
1410
|
+
|
|
1411
|
+
This complete implementation provides:
|
|
1412
|
+
- ✅ Fast, non-blocking view tracking
|
|
1413
|
+
- ✅ Detailed demographics collection
|
|
1414
|
+
- ✅ Background processing for heavy operations
|
|
1415
|
+
- ✅ Comprehensive analytics with scopes
|
|
1416
|
+
- ✅ Daily aggregation for performance
|
|
1417
|
+
- ✅ CSV export functionality
|
|
1418
|
+
- ✅ Ready-to-use dashboard views
|
|
1419
|
+
- ✅ Error handling and retries
|
|
1420
|
+
|
|
1421
|
+
---
|
|
1422
|
+
|
|
1423
|
+
## Analytics Queries Examples
|
|
1424
|
+
|
|
1425
|
+
```ruby
|
|
1426
|
+
# Top countries for a page
|
|
1427
|
+
PageView.where(page: @page)
|
|
1428
|
+
.group(:country_name)
|
|
1429
|
+
.order('count_id DESC')
|
|
1430
|
+
.limit(10)
|
|
1431
|
+
.count(:id)
|
|
1432
|
+
|
|
1433
|
+
# Top cities
|
|
1434
|
+
PageView.where(page: @page)
|
|
1435
|
+
.where.not(city: nil)
|
|
1436
|
+
.group(:city, :country_name)
|
|
1437
|
+
.order('count_id DESC')
|
|
1438
|
+
.limit(10)
|
|
1439
|
+
.count(:id)
|
|
1440
|
+
|
|
1441
|
+
# Device breakdown
|
|
1442
|
+
PageView.where(page: @page)
|
|
1443
|
+
.group(:device_type)
|
|
1444
|
+
.count(:id)
|
|
1445
|
+
|
|
1446
|
+
# Browser stats
|
|
1447
|
+
PageView.where(page: @page)
|
|
1448
|
+
.group(:browser)
|
|
1449
|
+
.order('count_id DESC')
|
|
1450
|
+
.count(:id)
|
|
1451
|
+
|
|
1452
|
+
# Views over time with location
|
|
1453
|
+
PageView.where(page: @page, country_code: 'US')
|
|
1454
|
+
.group_by_day(:viewed_at)
|
|
1455
|
+
.count
|
|
1456
|
+
|
|
1457
|
+
# Aggregated stats (faster for large datasets)
|
|
1458
|
+
PageViewStat.where(page: @page, period_type: :daily)
|
|
1459
|
+
.where('period_start >= ?', 30.days.ago)
|
|
1460
|
+
.pluck(:period_start, :total_views, :country_stats)
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
---
|
|
1464
|
+
|
|
1465
|
+
## 🎯 Next Steps: Gem Creation
|
|
1466
|
+
|
|
1467
|
+
### Decision Checklist
|
|
1468
|
+
- [ ] Check gem name availability on RubyGems.org
|
|
1469
|
+
- [ ] Decide: Build gem now (2 days) or extract later (3-6 months)?
|
|
1470
|
+
- [ ] Review [GEM_CREATION_ROADMAP.md](GEM_CREATION_ROADMAP.md) for full plan
|
|
1471
|
+
- [ ] Consider: MVP (10 hours) vs Full Release (16 hours)
|
|
1472
|
+
|
|
1473
|
+
### If Proceeding with Gem:
|
|
1474
|
+
1. [ ] Check name: `modest_analytics` on RubyGems
|
|
1475
|
+
2. [ ] Create GitHub repo: `modest_analytics`
|
|
1476
|
+
3. [ ] Run: `bundle gem modest_analytics --test=rspec --ci=github --mit`
|
|
1477
|
+
4. [ ] Copy code from this spec to gem structure
|
|
1478
|
+
5. [ ] Write tests (RSpec)
|
|
1479
|
+
6. [ ] Write README & documentation
|
|
1480
|
+
7. [ ] Publish to RubyGems v0.1.0
|
|
1481
|
+
8. [ ] Integrate into Stejar CMS
|
|
1482
|
+
9. [ ] Integrate into Eventya
|
|
1483
|
+
10. [ ] Announce release!
|
|
1484
|
+
|
|
1485
|
+
### Gem Features (Confirmed)
|
|
1486
|
+
- ✅ Polymorphic trackable (works with any model)
|
|
1487
|
+
- ✅ Multi-tenant support (optional tenant_id scoping)
|
|
1488
|
+
- ✅ Hybrid mobile app support (Capacitor, Cordova, React Native, Flutter)
|
|
1489
|
+
- ✅ IP geolocation (MaxMind GeoLite2)
|
|
1490
|
+
- ✅ Device detection (mobile, desktop, tablet, browser, OS)
|
|
1491
|
+
- ✅ Background job processing (async PageView creation)
|
|
1492
|
+
- ✅ Redis unique tracking (24-hour deduplication)
|
|
1493
|
+
- ✅ Counter caching for performance
|
|
1494
|
+
- ✅ Privacy-focused (local IP database, bot exclusion)
|
|
1495
|
+
|
|
1496
|
+
### Time Estimate
|
|
1497
|
+
- **MVP:** 10-12 hours (basic tracking + simple queries)
|
|
1498
|
+
- **Full Release:** 16 hours (including comprehensive tests & docs)
|
|
1499
|
+
- **Integration:** 2-4 hours per app (Stejar, Eventya)
|
|
1500
|
+
|
|
1501
|
+
### Resources
|
|
1502
|
+
- 📋 **Full Roadmap:** [GEM_CREATION_ROADMAP.md](GEM_CREATION_ROADMAP.md)
|
|
1503
|
+
- 📊 **This Spec:** Complete implementation details (you are here)
|
|
1504
|
+
- 🌐 **MaxMind:** Free GeoLite2 database for geolocation
|
|
1505
|
+
- 🔧 **Browser Gem:** Device/browser detection
|
|
1506
|
+
- ⚡ **Redis:** Optional unique visitor deduplication
|
|
1507
|
+
|
|
1508
|
+
---
|
|
1509
|
+
|
|
1510
|
+
**Ready to ship! 🚢**
|
|
1511
|
+
|
|
1512
|
+
---
|
|
1513
|
+
|
|
1514
|
+
## Testing Requirements
|
|
1515
|
+
|
|
1516
|
+
- Test counter increments/decrements
|
|
1517
|
+
- Test counter cache consistency
|
|
1518
|
+
- Test uniqueness constraints
|
|
1519
|
+
- Test permissions and authorization
|
|
1520
|
+
- Test rate limiting
|
|
1521
|
+
- Test concurrent updates
|
|
1522
|
+
- Test counter reset/recalculation
|