🔥 Simple, powerful, first-party analytics for Rails
Track visits and events in Ruby, JavaScript, and native apps. Data is stored in your database by default, and you can customize it for any data store as you grow.
📮 Check out Ahoy Email for emails and Field Test for A/B testing
🍊 Battle-tested at Instacart
Add this line to your application’s Gemfile:
gem"ahoy_matey"And run:
bundle install
rails generate ahoy:install
rails db:migrateRestart your web server, open a page in your browser, and a visit will be created 🎉
Track your first event from a controller with:
ahoy.track"My first event",language: "Ruby"Enable the API in config/initializers/ahoy.rb:
Ahoy.api=trueAnd restart your web server.
For Importmap (Rails 7+ default), add to config/importmap.rb:
pin"ahoy",to: "ahoy.js"And add to app/javascript/application.js:
import"ahoy"For Bun, esbuild, rollup.js, or Webpack, run:
bun add ahoy.js
# or
yarn add ahoy.jsAnd add to app/javascript/application.js:
importahoyfrom"ahoy.js"For Sprockets, add to app/assets/javascripts/application.js:
//= require ahoyTrack an event with:
ahoy.track("My second event",{language: "JavaScript"});Check out Ahoy iOS and Ahoy Android.
To enable geocoding, see the Geocoding section.
Ahoy provides a number of options to help with GDPR compliance. See the GDPR section for more info.
When someone visits your website, Ahoy creates a visit with lots of useful information.
- traffic source - referrer, referring domain, landing page
- location - country, region, city, latitude, longitude
- technology - browser, OS, device type
- utm parameters - source, medium, term, content, campaign
Use the current_visit method to access it.
Prevent certain Rails actions from creating visits with:
skip_before_action:track_ahoy_visitThis is typically useful for APIs. If your entire Rails app is an API, you can use:
Ahoy.api_only=trueYou can also defer visit tracking to JavaScript. This is useful for preventing bots (that aren’t detected by their user agent) and users with cookies disabled from creating a new visit on each request. :when_needed will create visits server-side only when needed by events, and false will disable server-side creation completely, discarding events without a visit.
Ahoy.server_side_visits=:when_neededEach event has a name and properties. There are several ways to track events.
ahoy.track"Viewed book",title: "Hot, Flat, and Crowded"Track actions automatically with:
classApplicationController < ActionController::Baseafter_action:track_actionprotecteddeftrack_actionahoy.track"Ran action",request.path_parametersendendahoy.track("Viewed book",{title: "The World is Flat"});See Ahoy.js for a complete list of features.
See the docs for Ahoy iOS and Ahoy Android.
<head><scriptasynccustom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script></head><body><%=amp_event"Viewed article",title: "Analytics with Rails"%></body>Say we want to associate orders with visits. Just add visitable to the model.
classOrder < ApplicationRecordvisitable:ahoy_visitendWhen a visitor places an order, the ahoy_visit_id column is automatically set 🎉
See where orders are coming from with simple joins:
Order.joins(:ahoy_visit).group("referring_domain").countOrder.joins(:ahoy_visit).group("city").countOrder.joins(:ahoy_visit).group("device_type").countHere’s what the migration to add the ahoy_visit_id column should look like:
classAddAhoyVisitToOrders < ActiveRecord::Migration[8.1]defchangeadd_reference:orders,:ahoy_visitendendCustomize the column with:
visitable:sign_up_visitAhoy automatically attaches the current_user to the visit. With Devise, it attaches the user even if they sign in after the visit starts.
With other authentication frameworks, add this to the end of your sign in method:
ahoy.authenticate(user)To see the visits for a given user, create an association:
classUser < ApplicationRecordhas_many:visits,class_name: "Ahoy::Visit"endAnd use:
User.find(123).visitsUse a method besides current_user
Ahoy.user_method=:true_useror use a proc
Ahoy.user_method=->(controller){controller.true_user}To attach the user with Doorkeeper, be sure you have a current_resource_owner method in ApplicationController.
classApplicationController < ActionController::Baseprivatedefcurrent_resource_ownerUser.find(doorkeeper_token.resource_owner_id)ifdoorkeeper_tokenendendBots are excluded from tracking by default. To include them, use:
Ahoy.track_bots=trueAdd your own rules with:
Ahoy.exclude_method=lambdado |controller,request|
request.ip == "192.168.1.1"endBy default, a new visit is created after 4 hours of inactivity. Change this with:
Ahoy.visit_duration=30.minutesBy default, a new visitor_token is generated after 2 years. Change this with:
Ahoy.visitor_duration=30.daysTo track visits across multiple subdomains, use:
Ahoy.cookie_domain=:allSet other cookie options with:
Ahoy.cookie_options={same_site: :lax}You can also disable cookies
Ahoy uses random UUIDs for visit and visitor tokens by default, but you can use your own generator like ULID.
Ahoy.token_generator=->{ULID.generate}You can use Rack::Attack to throttle requests to the API.
classRack::Attackthrottle("ahoy/ip",limit: 20,period: 1.minute)do |req|
ifreq.path.start_with?("/ahoy/")req.ipendendendExceptions are rescued so analytics do not break your app. Ahoy uses Safely to try to report them to a service by default. To customize this, use:
Safely.report_exception_method=->(e){Rollbar.error(e)}Ahoy uses Geocoder for geocoding. We recommend configuring local geocoding or load balancer geocoding so IP addresses are not sent to a 3rd party service. If you do use a 3rd party service and adhere to GDPR, be sure to add it to your subprocessor list. If Ahoy is configured to mask IPs, the masked IP is used (this can reduce accuracy but is better for privacy).
To enable geocoding, add this line to your application’s Gemfile:
gem"geocoder"And update config/initializers/ahoy.rb:
Ahoy.geocode=trueGeocoding is performed in a background job so it doesn’t slow down web requests. The default job queue is :ahoy. Change this with:
Ahoy.job_queue=:low_priorityFor privacy and performance, we recommend geocoding locally.
For city-level geocoding, download the GeoLite2 City database.
Add this line to your application’s Gemfile:
gem"maxminddb"And create config/initializers/geocoder.rb with:
Geocoder.configure(ip_lookup: :geoip2,geoip2: {file: "path/to/GeoLite2-City.mmdb"})For country-level geocoding, install the geoip-database package. It’s preinstalled on Heroku. For Ubuntu, use:
sudo apt-get install geoip-databaseAdd this line to your application’s Gemfile:
gem"geoip"And create config/initializers/geocoder.rb with:
Geocoder.configure(ip_lookup: :maxmind_local,maxmind_local: {file: "/usr/share/GeoIP/GeoIP.dat",package: :country})Some load balancers can add geocoding information to request headers.
Update config/initializers/ahoy.rb with:
Ahoy.geocode=falseclassAhoy::Store < Ahoy::DatabaseStoredeftrack_visit(data)data[:country]=request.headers["<country-header>"]data[:region]=request.headers["<region-header>"]data[:city]=request.headers["<city-header>"]super(data)endendAhoy provides a number of options to help with GDPR compliance.
Update config/initializers/ahoy.rb with:
classAhoy::Store < Ahoy::DatabaseStoredefauthenticate(data)# disables automatic linking of visits and usersendendAhoy.mask_ips=trueAhoy.cookies=:noneThis:
- Masks IP addresses
- Switches from cookies to anonymity sets
- Disables automatic linking of visits and users
If you use JavaScript tracking, also set:
ahoy.configure({cookies: false});Ahoy can mask IPs with the same approach Google Analytics uses for IP anonymization. This means:
- For IPv4, the last octet is set to 0 (
8.8.4.4becomes8.8.4.0) - For IPv6, the last 80 bits are set to zeros (
2001:4860:4860:0:0:0:0:8844becomes2001:4860:4860::)
Ahoy.mask_ips=trueIPs are masked before geolocation is performed.
To mask previously collected IPs, use:
Ahoy::Visit.find_eachdo |visit|
visit.update_column:ip,Ahoy.mask_ip(visit.ip)endAhoy can switch from cookies to anonymity sets. Instead of cookies, visitors with the same IP mask and user agent are grouped together in an anonymity set.
Ahoy.cookies=:noneNote: If Ahoy was installed before v5, add an index before making this change.
Previously set cookies are automatically deleted. If you use JavaScript tracking, also set:
ahoy.configure({cookies: false});Data should only be retained for as long as it’s needed. Delete older data with:
Ahoy::Visit.where("started_at < ?",2.years.ago).find_in_batchesdo |visits|
visit_ids=visits.map(&:id)Ahoy::Event.where(visit_id: visit_ids).delete_allAhoy::Visit.where(id: visit_ids).delete_allendYou can use Rollup to aggregate important data before you do.
Ahoy::Visit.rollup("Visits",interval: "hour")Delete data for a specific user with:
user_id=123visit_ids=Ahoy::Visit.where(user_id: user_id).pluck(:id)Ahoy::Event.where(visit_id: visit_ids).delete_allAhoy::Visit.where(id: visit_ids).delete_allAhoy::Event.where(user_id: user_id).delete_allAhoy is built with developers in mind. You can run the following code in your browser’s console.
Force a new visit
ahoy.reset();// then reload the pageLog messages
ahoy.debug();Turn off logging
ahoy.debug(false);Debug API requests in Ruby
Ahoy.quiet=falseData tracked by Ahoy is sent to your data store. Ahoy ships with a data store that uses your Rails database by default. You can find it in config/initializers/ahoy.rb:
classAhoy::Store < Ahoy::DatabaseStoreendThere are four events data stores can subscribe to:
classAhoy::Store < Ahoy::BaseStoredeftrack_visit(data)# new visitenddeftrack_event(data)# new eventenddefgeocode(data)# visit geocodedenddefauthenticate(data)# user authenticatesendendData stores are designed to be highly customizable so you can scale as you grow. Check out examples for Kafka, RabbitMQ, Fluentd, NATS, NSQ, and Amazon Kinesis Firehose.
classAhoy::Store < Ahoy::DatabaseStoredeftrack_visit(data)data[:accept_language]=request.headers["Accept-Language"]super(data)endendTwo useful methods you can use are request and controller.
You can pass additional visit data from JavaScript with:
ahoy.configure({visitParams: {referral_code: 123}});And use:
classAhoy::Store < Ahoy::DatabaseStoredeftrack_visit(data)data[:referral_code]=request.parameters[:referral_code]super(data)endendclassAhoy::Store < Ahoy::DatabaseStoredefvisit_modelMyVisitenddefevent_modelMyEventendendBlazer is a great tool for exploring your data.
With Active Record, you can do:
Ahoy::Visit.group(:search_keyword).countAhoy::Visit.group(:country).countAhoy::Visit.group(:referring_domain).countChartkick and Groupdate make it easy to visualize the data.
<%= line_chart Ahoy::Visit.group_by_day(:started_at).count %>Ahoy provides a few methods on the event model to make querying easier.
To query on both name and properties, you can use:
Ahoy::Event.where_event("Viewed product",product_id: 123).countOr just query properties with:
Ahoy::Event.where_props(product_id: 123,category: "Books").countGroup by properties with:
Ahoy::Event.group_prop(:product_id,:category).countNote: MySQL and MariaDB always return string keys (including "null" for nil) for group_prop.
It’s easy to create funnels.
viewed_store_ids=Ahoy::Event.where(name: "Viewed store").distinct.pluck(:user_id)added_item_ids=Ahoy::Event.where(user_id: viewed_store_ids,name: "Added item to cart").distinct.pluck(:user_id)viewed_checkout_ids=Ahoy::Event.where(user_id: added_item_ids,name: "Viewed checkout").distinct.pluck(:user_id)The same approach also works with visitor tokens.
Improve query performance by pre-aggregating data with Rollup.
Ahoy::Event.where(name: "Viewed store").rollup("Store views")This is only needed if you have a lot of data.
To forecast future visits and events, check out Prophet.
daily_visits=Ahoy::Visit.group_by_day(:started_at).count# uses GroupdateProphet.forecast(daily_visits)To detect anomalies in visits and events, check out AnomalyDetection.rb.
daily_visits=Ahoy::Visit.group_by_day(:started_at).count# uses GroupdateAnomalyDetection.detect(daily_visits,period: 7)To detect breakouts in visits and events, check out Breakout.
daily_visits=Ahoy::Visit.group_by_day(:started_at).count# uses GroupdateBreakout.detect(daily_visits)To make recommendations based on events, check out Disco.
Generate visit and visitor tokens as UUIDs, and include these values in the Ahoy-Visit and Ahoy-Visitor headers with all requests.
Send a POST request to /ahoy/visits with Content-Type: application/json and a body like:
{
"visit_token": "<visit-token>",
"visitor_token": "<visitor-token>",
"platform": "iOS",
"app_version": "1.0.0",
"os_version": "11.2.6"
}After 4 hours of inactivity, create another visit (use the same visitor token).
Send a POST request to /ahoy/events with Content-Type: application/json and a body like:
{
"visit_token": "<visit-token>",
"visitor_token": "<visitor-token>",
"events": [
{
"id": "<optional-random-id>",
"name": "Viewed item",
"properties": {
"item_id": 123
},
"time": "2025-01-01T00:00:00-07:00"
}
]
}View the changelog
Everyone is encouraged to help improve this project. Here are a few ways you can help:
- Report bugs
- Fix bugs and submit pull requests
- Write, clarify, or fix documentation
- Suggest or add new features
To get started with development:
git clone https://github.com/ankane/ahoy.git
cd ahoy
bundle install
bundle exec rake testTo test different adapters, use:
ADAPTER=postgresql bundle exec rake test
ADAPTER=mysql2 bundle exec rake test
ADAPTER=mongoid bundle exec rake test