Ahoy

:fire: Simple, powerful analytics for Rails

Track visits and events in Ruby, JavaScript, and native apps. Data is stored in your database by default so you can easily combine it with other data.

:postbox: To track emails, check out Ahoy Email, and for A/B testing, check out Field Test

:tangerine: Battle-tested at Instacart

Build Status

Installation

Add this line to your application’s Gemfile:

gem 'ahoy_matey'

And run:

bundle install
rails generate ahoy:install
rails db:migrate

Restart your web server, open a page in your browser, and a visit will be created :tada:

Track your first event from a controller with:

ahoy.track "My first event", language: "Ruby"

JavaScript, Native Apps, & AMP

Enable the API in config/initializers/ahoy.rb:

Ahoy.api = true

And restart your web server.

JavaScript

For Rails 6 / Webpacker, run:

yarn add ahoy.js

And add to app/javascript/packs/application.js:

import ahoy from "ahoy.js";

For Rails 5 / Sprockets, add to app/assets/javascripts/application.js:

//= require ahoy

Track an event with:

ahoy.track("My second event", {language: "JavaScript"});

GDPR Compliance

Ahoy provides a number of options to help with GDPR compliance. See the GDPR section for more info.

How It Works

Visits

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_visit

This is typically useful for APIs. If your entire Rails app is an API, you can use:

Ahoy.api_only = true

You 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_needed

Events

Each event has a name and properties. There are several ways to track events.

Ruby

ahoy.track "Viewed book", title: "Hot, Flat, and Crowded"

Track actions automatically with:

class ApplicationController < ActionController::Base
  after_action :track_action

  protected

  def track_action
    ahoy.track "Ran action", request.path_parameters
  end
end

JavaScript

ahoy.track("Viewed book", {title: "The World is Flat"});

Track events automatically with:

ahoy.trackAll();

See Ahoy.js for a complete list of features.

Native Apps

For Android, check out Ahoy Android. For other platforms, see the API spec.

AMP

<head>
  <script async custom-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>

Associated Models

Say we want to associate orders with visits. Just add visitable to the model.

class Order < ApplicationRecord
  visitable :ahoy_visit
end

When a visitor places an order, the ahoy_visit_id column is automatically set :tada:

See where orders are coming from with simple joins:

Order.joins(:ahoy_visit).group("referring_domain").count
Order.joins(:ahoy_visit).group("city").count
Order.joins(:ahoy_visit).group("device_type").count

Here’s what the migration to add the ahoy_visit_id column should look like:

class AddVisitIdToOrders < ActiveRecord::Migration[6.0]
  def change
    add_column :orders, :ahoy_visit_id, :bigint
  end
end

Customize the column with:

visitable :sign_up_visit

Users

Ahoy automatically attaches the current_user to the visit. With Devise, it attaches the user even if he or she signs 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:

class User < ApplicationRecord
  has_many :visits, class_name: "Ahoy::Visit"
end

And use:

User.find(123).visits

Custom User Method

Use a method besides current_user

Ahoy.user_method = :true_user

or use a proc

Ahoy.user_method = ->(controller) { controller.true_user }

Doorkeeper

To attach the user with Doorkeeper, be sure you have a current_resource_owner method in ApplicationController.

class ApplicationController < ActionController::Base
  private

  def current_resource_owner
    User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
  end
end

Knock

To attach the user with Knock, either include Knock::Authenticablein ApplicationController:

class ApplicationController < ActionController::API
  include Knock::Authenticable
end

Or include it in Ahoy:

Ahoy::BaseController.include Knock::Authenticable

And use:

Ahoy.user_method = ->(controller) { controller.send(:authenticate_entity, "user") }

Exclusions

Bots are excluded from tracking by default. To include them, use:

Ahoy.track_bots = true

Add your own rules with:

Ahoy.exclude_method = lambda do |controller, request|
  request.ip == "192.168.1.1"
end

Visit Duration

By default, a new visit is created after 4 hours of inactivity. Change this with:

Ahoy.visit_duration = 30.minutes

Multiple Subdomains

To track visits across multiple subdomains, use:

Ahoy.cookie_domain = :all

Geocoding

Disable geocoding with:

Ahoy.geocode = false

Change the job queue with:

Ahoy.job_queue = :low_priority

Geocoding Performance

To avoid calls to a remote API, download the GeoLite2 City database and configure Geocoder to use it.

Add this line to your application’s Gemfile:

gem 'maxminddb'

And create an initializer at config/initializers/geocoder.rb with:

Geocoder.configure(
  ip_lookup: :geoip2,
  geoip2: {
    file: Rails.root.join("lib", "GeoLite2-City.mmdb")
  }
)

If you use Heroku, you can use an unofficial buildpack like this one to avoid including the database in your repo.

Token Generation

Ahoy uses random UUIDs for visit and visitor tokens by default, but you can use your own generator like Druuid.

Ahoy.token_generator = -> { Druuid.gen }

Throttling

You can use Rack::Attack to throttle requests to the API.

class Rack::Attack
  throttle("ahoy/ip", limit: 20, period: 1.minute) do |req|
    if req.path.start_with?("/ahoy/")
      req.ip
    end
  end
end

Exceptions

Exceptions 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) }

GDPR Compliance

Ahoy provides a number of options to help with GDPR compliance.

Update config/initializers/ahoy.rb with:

class Ahoy::Store < Ahoy::DatabaseStore
  def authenticate(data)
    # disables automatic linking of visits and users
  end
end

Ahoy.mask_ips = true
Ahoy.cookies = false

This:

  • 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});

IP Masking

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.4 becomes 8.8.4.0)
  • For IPv6, the last 80 bits are set to zeros (2001:4860:4860:0:0:0:0:8844 becomes 2001:4860:4860::)
Ahoy.mask_ips = true

IPs are masked before geolocation is performed.

To mask previously collected IPs, use:

Ahoy::Visit.find_each do |visit|
  visit.update_column :ip, Ahoy.mask_ip(visit.ip)
end

Anonymity Sets & Cookies

Ahoy 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 = false

Previously set cookies are automatically deleted.

Development

Ahoy 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 page

Log messages

ahoy.debug();

Turn off logging

ahoy.debug(false);

Debug API requests in Ruby

Ahoy.quiet = false

Data Stores

Data 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:

class Ahoy::Store < Ahoy::DatabaseStore
end

There are four events data stores can subscribe to:

class Ahoy::Store < Ahoy::BaseStore
  def track_visit(data)
    # new visit
  end

  def track_event(data)
    # new event
  end

  def geocode(data)
    # visit geocoded
  end

  def authenticate(data)
    # user authenticates
  end
end

Data 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.

Track Additional Data

class Ahoy::Store < Ahoy::DatabaseStore
  def track_visit(data)
    data[:accept_language] = request.headers["Accept-Language"]
    super(data)
  end
end

Two 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:

class Ahoy::Store < Ahoy::DatabaseStore
  def track_visit(data)
    data[:referral_code] = request.parameters[:referral_code]
    super(data)
  end
end

Use Different Models

class Ahoy::Store < Ahoy::DatabaseStore
  def visit_model
    MyVisit
  end

  def event_model
    MyEvent
  end
end

Explore the Data

Blazer is a great tool for exploring your data.

With ActiveRecord, you can do:

Ahoy::Visit.group(:search_keyword).count
Ahoy::Visit.group(:country).count
Ahoy::Visit.group(:referring_domain).count

Chartkick and Groupdate make it easy to visualize the data.

<%= line_chart Ahoy::Visit.group_by_day(:started_at).count %>

Querying Events

Ahoy provides two 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).count

Or just query properties with:

Ahoy::Event.where_props(product_id: 123).count

Funnels

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.

Tutorials

API Spec

Visits

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).

Events

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": "2018-01-01T00:00:00-07:00"
    }
  ]
}

Upgrading

3.0

If you installed Ahoy before 2.1 and want to keep legacy user agent parsing and bot detection, add to your Gemfile:

gem "browser", "~> 2.0"
gem "user_agent_parser"

And add to config/initializers/ahoy.rb:

Ahoy.user_agent_parser = :legacy

2.2

Ahoy now ships with better bot detection if you use Device Detector. This should be more accurate but can significantly reduce the number of visits recorded. For existing installs, it’s opt-in to start. To use it, add to config/initializers/ahoy.rb:

Ahoy.bot_detection_version = 2

2.1

Ahoy recommends Device Detector for user agent parsing and makes it the default for new installations. To switch, add to config/initializers/ahoy.rb:

Ahoy.user_agent_parser = :device_detector

Backfill existing records with:

Ahoy::Visit.find_each do |visit|
  client = DeviceDetector.new(visit.user_agent)
  device_type =
    case client.device_type
    when "smartphone"
      "Mobile"
    when "tv"
      "TV"
    else
      client.device_type.try(:titleize)
    end

  visit.browser = client.name
  visit.os = client.os_name
  visit.device_type = device_type
  visit.save(validate: false) if visit.changed?
end

2.0

See the upgrade guide

History

View the changelog

Contributing

Everyone is encouraged to help improve this project. Here are a few ways you can help: