Listen to signals from your content.
A Rails engine for tracking page views and content engagement with rich analytics. Track views on any model (Pages, Posts, Events, Profiles) with demographics, device detection, and multi-tenant support.
- Page view tracking with visitor identification
- Use cookies to identify anonymous returning visitors
- Use Redis to deduplicate unique views per day (optional)
- Device detection (mobile, tablet, desktop, hybrid apps)
- Bot filtering (automatically excludes crawlers)
- Background processing (non-blocking with ActiveJob)
- Multi-tenant support (optional)
- Rich analytics with time-based scopes and aggregations
- Polymorphic tracking (works with any model)
- Hybrid app support (Capacitor, Cordova, React Native, Flutter)
- Geolocation (country, city, region) via MaxMind GeoLite2
Add this line to your application's Gemfile:
gem 'content_signals'Then execute:
bundle installRun the install generator:
rails generate content_signals:install
rails db:migrateAdd a page_views_count column to any model you want to track views for:
# Migration example for Pages
class AddPageViewsCountToPages < ActiveRecord::Migration[7.0]
def change
add_column :pages, :page_views_count, :integer, default: 0, null: false
add_index :pages, :page_views_count
end
endImportant: The counter column must be named page_views_count (following Rails counter_cache conventions). The ContentSignals PageView model uses counter_cache: :page_views_count to automatically increment this column when view records are created.
For multiple trackable models:
# Add to any models you want to track
add_column :posts, :page_views_count, :integer, default: 0, null: false
add_column :events, :page_views_count, :integer, default: 0, null: false
add_column :profiles, :page_views_count, :integer, default: 0, null: falseclass PagesController < ApplicationController
include ContentSignals::TrackablePageViews
def show
@page = Page.find(params[:id])
# Page views are automatically tracked!
end
endHow it works:
- When a user visits a tracked page, the concern triggers tracking
- A background job is enqueued to create a detailed PageView record
- The
page_views_countcounter is automatically incremented via Rails counter_cache if the 'hit' is unique for the day - No blocking - the user gets instant response while tracking happens asynchronously
That's it! Page views will now be tracked automatically with:
- Total view counter via counter_cache (incremented when PageView record is created)
- Detailed PageView records with demographics (via background job)
- Visitor identification (authenticated users, device IDs, or anonymous)
- Device and location data
- Bot filtering
Create an initializer config/initializers/content_signals.rb:
ContentSignals.configure do |config|
# Multi-tenancy (optional)
config.multitenancy = false
config.current_tenant_method = :current_tenant_id
config.tenant_model = 'Account'
# Redis for unique visitor tracking (optional)
# When enabled, prevents duplicate tracking of the same visitor on the same day
# Keys expire automatically after 24 hours
# Without Redis: all PageView records are still stored in the database,
# but the same visitor may create multiple records per day
config.redis_enabled = true
config.redis_namespace = 'content_signals'
# MaxMind GeoLite2 database path
config.maxmind_db_path = Rails.root.join('db', 'GeoLite2-City.mmdb')
# Tracking preferences
config.track_bots = false
config.track_admins = false
endAccess rich analytics data through PageView scopes:
# Get page views for a specific page
page = Page.find(1)
page_views = ContentSignals::PageView.where(trackable: page)
# Time-based queries
page_views.today # returns all toyda's records
page_views.today.count # counter for today's views
page_views.yesterday
page_views.this_week
page_views.last_30_days
page_views.this_month
page_views.this_year
# Device filtering
page_views.mobile
page_views.desktop
page_views.tablet
# Location filtering
page_views.from_country('US')
page_views.from_city('New York')
# Platform filtering
page_views.from_website # Regular web browsers
page_views.from_hybrid_app # Mobile app WebViews
page_views.from_native_app # Native mobile apps
# User filtering
page_views.authenticated # Logged-in users
page_views.anonymous # Anonymous visitors
# Analytics aggregations
page_views.unique_count # Unique visitors
page_views.top_countries(10) # Top 10 countries
page_views.top_cities(10) # Top 10 cities
page_views.device_breakdown # Device type distribution
page_views.browser_breakdown # Browser distribution
page_views.location_heatmap # Lat/lng data for maps
# Growth rate
page_views.growth_rate(:this_week, :last_week) # Weekly growth %class AnalyticsController < ApplicationController
def show
@page = Page.find(params[:id])
@views = ContentSignals::PageView.where(trackable: @page).last_30_days
@stats = {
total_views: @views.count,
unique_visitors: @views.unique_count,
top_countries: @views.top_countries(5),
top_cities: @views.top_cities(5),
device_breakdown: @views.device_breakdown,
growth_rate: @views.growth_rate(:this_week, :last_week)
}
end
endBy default, the concern looks for @page, @post, @article, @profile, @event, or @trackable. To customize:
class ArticlesController < ApplicationController
include ContentSignals::TrackablePageViews
def show
@my_article = Article.find(params[:id])
end
private
def find_trackable_for_tracking
@my_article
end
endOverride to add custom skip conditions:
class PagesController < ApplicationController
include ContentSignals::TrackablePageViews
private
def should_skip_tracking?
super || draft_mode? || current_user&.internal?
end
def draft_mode?
params[:draft].present?
end
endTrack views programmatically without the controller concern:
ContentSignals::PageViewTracker.track(
trackable: @page,
user: current_user,
request: request
)For multi-tenant applications:
# config/initializers/content_signals.rb
ContentSignals.configure do |config|
config.multitenancy = true
config.current_tenant_method = :current_account_id
end
# In your ApplicationController
class ApplicationController < ActionController::Base
def current_account_id
Current.account_id # or however you track tenant context
end
endAll PageView queries will automatically scope to the current tenant.
Track views from mobile apps (Capacitor, Cordova, React Native, Flutter):
// In your mobile app
fetch('https://yoursite.com/pages/1', {
headers: {
'X-App-Platform': 'capacitor',
'X-App-Version': '1.2.3',
'X-Device-ID': 'unique-device-uuid'
}
});https://yoursite.com/pages/1?app_platform=capacitor&device_id=abc123&app_version=1.2.3
Views from mobile apps will be automatically detected and tracked with app_platform and device_id.
- Sign up for MaxMind GeoLite2 (free): https://www.maxmind.com/en/geolite2/signup
- Download GeoLite2-City.mmdb database
- Place in:
db/GeoLite2-City.mmdb - Configure path in initializer (shown above)
The gem will automatically enrich page views with:
- Country (code and name)
- City
- Region/State
- Latitude/Longitude
Access individual page view records:
view = ContentSignals::PageView.last
view.trackable # => #<Page id: 1>
view.user # => #<User id: 5> (if authenticated)
view.visitor_id # => "user_5" or "device_abc123" or "anon_1a2b3c"
view.country_name # => "United States"
view.city # => "New York"
view.device_type # => "mobile"
view.browser # => "Chrome"
view.os # => "iOS"
view.app_platform # => "capacitor" (if from mobile app)
view.viewed_at # => 2026-01-06 10:30:00 UTC
# Helper methods
view.authenticated? # => true if user present
view.anonymous? # => true if no user
view.mobile_device? # => true if mobile
view.desktop_device? # => true if desktop
view.hybrid_app? # => true if from mobile app
view.web_browser? # => true if from web browserIf you use a different method name for current user:
class PagesController < ApplicationController
include ContentSignals::TrackablePageViews
private
def current_user_for_tracking
current_person # or whatever your method is called
end
endBy default, tracking jobs use the default queue. To customize:
# config/initializers/content_signals.rb
ContentSignals::TrackPageViewJob.queue_as :analytics# Export to CSV
require 'csv'
CSV.generate(headers: true) do |csv|
csv << ['Date', 'Country', 'City', 'Device', 'Browser']
ContentSignals::PageView.where(trackable: @page).find_each do |view|
csv << [
view.viewed_at.to_date,
view.country_name,
view.city,
view.device_type,
view.browser
]
end
end- Rails >= 7.0
- Ruby >= 3.0
- ActiveJob (for background processing)
- Redis (optional, for unique visitor tracking)
# Gemfile
gem 'maxminddb' # For IP geolocation
gem 'browser' # For device/browser detection
gem 'redis' # For unique visitor tracking- Fast: Counter increments are synchronous (< 1ms)
- Non-blocking: Detailed tracking happens in background jobs
- Scalable: Uses counter caching and database indexes
- Efficient: Redis-based unique visitor deduplication (optional)
In your test environment, you may want to disable tracking:
# config/environments/test.rb
config.after_initialize do
ContentSignals.configure do |config|
config.track_bots = true # Allow test requests
end
endOr stub the tracker in specs:
allow(ContentSignals::PageViewTracker).to receive(:track)After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/content_signals. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
The gem is available as open source under the terms of the MIT License.
Everyone interacting in the ContentSignals project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.