Combined ahoy_events and use ahoy.js

Andrew Kane committed May 14, 2014
commit ccac71dd7afb0758c7169dabcca0e6398662ca84
Showing 14 changed files with 442 additions and 70 deletions
CHANGELOG.md +5 -0
@@ @@ -1,3 +1,8 @@
+ ## 0.2.0
+
+ - Added event tracking (merged ahoy_events)
+ - Added ahoy.js
+
## 0.1.8
- Fixed bug with `user_type` set to `false` instead of `nil`
README.md +149 -47
@@ @@ -1,6 +1,6 @@
# Ahoy
- :fire: Simple, powerful visit tracking for Rails
+ :fire: Simple, powerful analytics for Rails
Visits are stored in **your database** so you can easily combine them with other data.
@@ @@ -11,13 +11,13 @@ You get:
- **technology** - browser, OS, and device type
- **utm parameters** - source, medium, term, content, campaign
- See which campaigns generate the most revenue effortlessly.
+ Track events in:
- ```ruby
- Order.joins(:visit).group("utm_campaign").sum(:revenue)
- ```
+ - JavaScript
+ - Ruby
+ - Native apps
- :seedling: To track events like page views, check out [Ahoy Events](https://github.com/ankane/ahoy_events).
+ And store them wherever you’d like - your database, logs, external services, or all of them.
:postbox: To track emails, check out [Ahoy Email](https://github.com/ankane/ahoy_email).
@@ @@ -135,19 +135,104 @@ or
http://datakick.org/?utm_medium=twitter&utm_campaign=social&utm_source=tweet123
```
- ### Location
+ ### Native Apps
- Ahoy uses [Geocoder](https://github.com/alexreisner/geocoder) for IP-based geocoding.
+ When a user launches the app, create a visit. Send a `POST` request to `/ahoy/visits` with:
- ### Multiple Subdomains
+ - platform - `iOS`, `Android`, etc.
+ - app_version - `1.0.0`
+ - os_version - `7.0.6`
+ - visitor_token - if you have one
- To track visits across multiple subdomains, add this **before** the javascript files.
+ The endpoint will return a JSON response like:
+
+ ```json
+ {
+ "visit_token": "8tx2ziymkwa1WlppnkqxyaBaRlXrEQ3K",
+ "visitor_token": "hYBIV0rBfrIUAiArWweiECt4N9pyiygN"
+ }
+ ```
+
+ Send the visit token in the `Ahoy-Visit` header for all requests.
+
+ After 4 hours, create another visit and use the updated visit token.
+
+ ## Events
+
+ Each event has a `name` and `properties`.
+
+ There are three ways to track events.
+
+ #### JavaScript
```javascript
- var ahoy = {"domain": "yourdomain.com"};
+ ahoy.track("Viewed book", {title: "The World is Flat"});
+ ```
+
+ or track all views and clicks with:
+
+ ```javascript
+ ahoy.trackAll();
```
- ### Development
+ #### Ruby
+
+ ```ruby
+ ahoy.track "Viewed book", title: "Hot, Flat, and Crowded"
+ ```
+
+ #### Native Apps
+
+ Send a `POST` request to `/ahoy/events` with:
+
+ - name
+ - properties
+ - user token (depends on your authentication framework)
+ - `Ahoy-Visit` header
+
+ Requests should have `Content-Type: application/json`.
+
+ ### Storing Events
+
+ You choose how to store events.
+
+ #### ActiveRecord
+
+ Create an `Ahoy::Event` model to store events.
+
+ ```sh
+ rails generate ahoy:events:active_record
+ rake db:migrate
+ ```
+
+ #### Custom
+
+ Create your own subscribers in `config/initializers/ahoy.rb`.
+
+ ```ruby
+ class LogSubscriber
+
+ def track(name, properties, options = {})
+ data = {
+ name: name,
+ properties: properties,
+ time: options[:time].to_i,
+ visit_id: options[:visit].try(:id),
+ user_id: options[:user].try(:id),
+ ip: options[:controller].try(:request).try(:remote_ip)
+ }
+ Rails.logger.info data.to_json
+ end
+
+ end
+
+ # and add it
+ Ahoy.subscribers << LogSubscriber.new
+ ```
+
+ Add as many subscribers as you’d like.
+
+ ## Development
Ahoy is built with developers in mind. You can run the following code in your browser’s console.
@@ @@ -169,27 +254,12 @@ Turn off logging
ahoy.debug(false);
```
- ### Native Apps
-
- When a user launches the app, create a visit. Send a `POST` request to `/ahoy/visits` with:
-
- - platform - `iOS`, `Android`, etc.
- - app_version - `1.0.0`
- - os_version - `7.0.6`
- - visitor_token - if you have one
-
- The endpoint will return a JSON response like:
-
- ```json
- {
- "visit_token": "8tx2ziymkwa1WlppnkqxyaBaRlXrEQ3K",
- "visitor_token": "hYBIV0rBfrIUAiArWweiECt4N9pyiygN"
- }
- ```
-
- Send the visit token in the `Ahoy-Visit` header for all requests.
+ ### More
- After 4 hours, create another visit and use the updated visit token.
+ - Excludes bots
+ - Degrades gracefully when cookies are disabled
+ - Don’t need a field? Just remove it from the migration
+ - Visits are 4 hours by default
### Doorkeeper
@@ @@ -207,24 +277,12 @@ class ApplicationController < ActionController::Base
end
```
- ### More
-
- - Excludes bots
- - Degrades gracefully when cookies are disabled
- - Don’t need a field? Just remove it from the migration
- - Visits are 4 hours by default
-
## Reference
- Use a different model
-
- ```ruby
- Ahoy.visit_model = UserVisit
+ To track visits across multiple subdomains, add this **before** the javascript files.
- # fix for Rails reloader in development
- ActionDispatch::Reloader.to_prepare do
- Ahoy.visit_model = UserVisit
- end
+ ```javascript
+ var ahoy = {"domain": "yourdomain.com"};
```
Change the platform on the web
@@ @@ -266,6 +324,49 @@ Customize visitable
visitable :sign_up_visit, class_name: "Visit"
```
+ Track view
+
+ ```javascript
+ ahoy.trackView();
+ ```
+
+ Track clicks
+
+ ```javascript
+ ahoy.trackClicks();
+ ```
+
+ Track all Rails actions
+
+ ```ruby
+ class ApplicationController < ActionController::Base
+ after_filter :track_action
+
+ protected
+
+ def track_action
+ ahoy.track "Hit action", request.filtered_parameters
+ end
+ end
+ ```
+
+ Use a different model for visits
+
+ ```ruby
+ Ahoy.visit_model = UserVisit
+
+ # fix for Rails reloader in development
+ ActionDispatch::Reloader.to_prepare do
+ Ahoy.visit_model = UserVisit
+ end
+ ```
+
+ Use a different model for events
+
+ ```ruby
+ Ahoy.subscribers << Ahoy::Subscribers::ActiveRecord.new(model: Event)
+ ```
+
## Upgrading
In `0.1.6`, a big improvement was made to `browser` and `os`. Update existing visits with:
@@ @@ -279,6 +380,7 @@ end
## TODO
+ - better readme
- simple dashboard
- turn off modules
app/controllers/ahoy/events_controller.rb +14 -0
@@ @@ -0,0 +1,14 @@
+ module Ahoy
+ class EventsController < Ahoy::BaseController
+
+ def create
+ options = {}
+ if params[:time] and (time = Time.at(params[:time].to_f) rescue nil) and (1.minute.ago..Time.now).cover?(time)
+ options[:time] = time
+ end
+ ahoy.track params[:name], params[:properties], options
+ render json: {}
+ end
+
+ end
+ end
app/controllers/ahoy/visits_controller.rb +1 -1
@@ @@ -2,7 +2,7 @@ module Ahoy
class VisitsController < BaseController
def create
- visit_token = generate_token
+ visit_token = params[:visit_token] || generate_token
visitor_token = params[:visitor_token] || generate_token
visit =
app/models/ahoy/event.rb +10 -0
@@ @@ -0,0 +1,10 @@
+ module Ahoy
+ class Event < ActiveRecord::Base
+ self.table_name = "ahoy_events"
+
+ belongs_to :visit
+ belongs_to :user, polymorphic: true
+
+ serialize :properties, JSON
+ end
+ end
config/routes.rb +1 -0
@@ @@ -5,5 +5,6 @@ end
Ahoy::Engine.routes.draw do
scope module: "ahoy" do
resources :visits, only: [:create]
+ resources :events, only: [:create]
end
end
ahoy/controller.rb b/lib/ahoy/controller.rb +5 -0
@@ @@ -3,6 +3,7 @@ module Ahoy
def self.included(base)
base.helper_method :current_visit
+ base.helper_method :ahoy
base.before_filter do
RequestStore.store[:ahoy_controller] ||= self
end
@@ @@ -15,5 +16,9 @@ module Ahoy
end
end
+ def ahoy
+ @ahoy ||= Ahoy::Tracker.new(controller: self)
+ end
+
end
end
ahoy/subscribers/active_record.rb b/lib/ahoy/subscribers/active_record.rb +21 -0
@@ @@ -0,0 +1,21 @@
+ module Ahoy
+ module Subscribers
+ class ActiveRecord
+
+ def initialize(options = {})
+ @model = options[:model] || Ahoy::Event
+ end
+
+ def track(name, properties, options = {})
+ @model.create! do |e|
+ e.visit = options[:visit]
+ e.user = options[:user]
+ e.name = name
+ e.properties = properties
+ e.time = options[:time]
+ end
+ end
+
+ end
+ end
+ end
ahoy/tracker.rb b/lib/ahoy/tracker.rb +30 -0
@@ @@ -0,0 +1,30 @@
+ module Ahoy
+ class Tracker
+
+ def initialize(options = {})
+ @controller = options[:controller]
+ end
+
+ def track(name, properties, options = {})
+ # publish to each subscriber
+ if @controller
+ options[:controller] ||= @controller
+ options[:user] ||= Ahoy.fetch_user(@controller)
+ if @controller.respond_to?(:current_visit)
+ options[:visit] ||= @controller.current_visit
+ end
+ end
+ options[:time] ||= Time.zone.now
+
+ subscribers = Ahoy.subscribers
+ if subscribers.any?
+ subscribers.each do |subscriber|
+ subscriber.track(name, properties, options)
+ end
+ else
+ $stderr.puts "No subscribers"
+ end
+ end
+
+ end
+ end
ahoy_matey.rb b/lib/ahoy_matey.rb +4 -0
@@ @@ -5,8 +5,10 @@ require "referer-parser"
require "user_agent_parser"
require "request_store"
require "ahoy/version"
+ require "ahoy/tracker"
require "ahoy/controller"
require "ahoy/model"
+ require "ahoy/subscribers/active_record"
require "ahoy/engine"
module Ahoy
@@ @@ -43,6 +45,8 @@ module Ahoy
(controller.respond_to?(:current_user) && controller.current_user) || (controller.respond_to?(:current_resource_owner, true) && controller.send(:current_resource_owner)) || nil
end
+ mattr_accessor :subscribers
+ self.subscribers = []
end
ActionController::Base.send :include, Ahoy::Controller
generators/ahoy/events/active_record_generator.rb b/lib/generators/ahoy/events/active_record_generator.rb +36 -0
@@ @@ -0,0 +1,36 @@
+ # taken from https://github.com/collectiveidea/audited/blob/master/lib/generators/audited/install_generator.rb
+ require "rails/generators"
+ require "rails/generators/migration"
+ require "active_record"
+ require "rails/generators/active_record"
+
+ module Ahoy
+ module Events
+ module Generators
+ class ActiveRecordGenerator < Rails::Generators::Base
+ include Rails::Generators::Migration
+
+ source_root File.expand_path("../templates", __FILE__)
+
+ # Implement the required interface for Rails::Generators::Migration.
+ def self.next_migration_number(dirname) #:nodoc:
+ next_migration_number = current_migration_number(dirname) + 1
+ if ActiveRecord::Base.timestamped_migrations
+ [Time.now.utc.strftime("%Y%m%d%H%M%S"), "%.14d" % next_migration_number].max
+ else
+ "%.3d" % next_migration_number
+ end
+ end
+
+ def copy_migration
+ migration_template "create_events.rb", "db/migrate/create_ahoy_events.rb"
+ end
+
+ def create_initializer
+ template "initializer.rb", "config/initializers/ahoy.rb"
+ end
+
+ end
+ end
+ end
+ end
generators/ahoy/events/templates/create_events.rb b/lib/generators/ahoy/events/templates/create_events.rb +20 -0
@@ @@ -0,0 +1,20 @@
+ class <%= migration_class_name %> < ActiveRecord::Migration
+ def change
+ create_table :ahoy_events do |t|
+ # visit
+ t.references :visit
+
+ # user
+ t.integer :user_id
+ t.string :user_type
+
+ t.string :name
+ t.text :properties
+ t.timestamp :time
+ end
+
+ add_index :ahoy_events, [:visit_id]
+ add_index :ahoy_events, [:user_id, :user_type]
+ add_index :ahoy_events, [:time]
+ end
+ end
generators/ahoy/events/templates/initializer.rb b/lib/generators/ahoy/events/templates/initializer.rb +1 -0
@@ @@ -0,0 +1 @@
+ Ahoy.subscribers << Ahoy::Subscribers::ActiveRecord.new
vendor/assets/javascripts/ahoy.js +145 -22
@@ @@ -10,6 +10,8 @@
var visitorTtl = 2 * 365 * 24 * 60; // 2 years
var isReady = false;
var queue = [];
+ var canStringify = typeof(JSON) !== "undefined" && typeof(JSON.stringify) !== "undefined";
+ var eventQueue = [];
// cookies
@@ @@ -62,25 +64,98 @@
isReady = true;
}
+ function ready(callback) {
+ if (isReady) {
+ callback();
+ } else {
+ queue.push(callback);
+ }
+ }
+
+ // https://github.com/klughammer/node-randomstring
+ function generateId() {
+ var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghiklmnopqrstuvwxyz';
+ var length = 32;
+ var string = '';
+
+ for (var i = 0; i < length; i++) {
+ var randomNumber = Math.floor(Math.random() * chars.length);
+ string += chars.substring(randomNumber, randomNumber + 1);
+ }
+
+ return string;
+ }
+
+ function saveEventQueue() {
+ // TODO add stringify method for IE 7 and under
+ if (canStringify) {
+ setCookie("ahoy_events", JSON.stringify(eventQueue), 1);
+ }
+ }
+
+ function trackEvent(event) {
+ ready( function () {
+ // ensure JSON is defined
+ if (canStringify) {
+ $.ajax({
+ type: "POST",
+ url: "/ahoy/events",
+ data: JSON.stringify(event),
+ contentType: "application/json; charset=utf-8",
+ dataType: "json",
+ success: function() {
+ // remove from queue
+ for (var i = 0; i < eventQueue.length; i++) {
+ if (eventQueue[i].id == event.id) {
+ eventQueue.splice(i, 1);
+ break;
+ }
+ }
+ saveEventQueue();
+ }
+ });
+ }
+ });
+ }
+
+ function eventProperties(e) {
+ var $target = $(e.currentTarget);
+ return {
+ tag: $target.get(0).tagName.toLowerCase(),
+ id: $target.attr("id"),
+ class: $target.attr("class")
+ };
+ }
+
// main
visitToken = getCookie("ahoy_visit");
visitorToken = getCookie("ahoy_visitor");
- if (visitToken && visitorToken && visitToken != "test") {
+ if (visitToken && visitorToken) {
// TODO keep visit alive?
log("Active visit");
setReady();
} else {
- setCookie("ahoy_visit", "test", 1);
+ visitToken = generateId();
+ setCookie("ahoy_visit", visitToken, visitTtl);
// make sure cookies are enabled
if (getCookie("ahoy_visit")) {
log("Visit started");
+ if (!visitorToken) {
+ visitorToken = generateId();
+ setCookie("ahoy_visitor", visitorToken, visitorTtl);
+ }
+
var data = {
+ visit_token: visitToken,
+ visitor_token: visitorToken,
platform: ahoy.platform || "Web",
- landing_page: window.location.href
+ landing_page: window.location.href,
+ screen_width: window.screen.width,
+ screen_height: window.screen.height
};
// referrer
@@ @@ -88,17 +163,9 @@
data.referrer = document.referrer;
}
- if (visitorToken) {
- data.visitor_token = visitorToken;
- }
-
log(data);
- $.post("/ahoy/visits", data, function(response) {
- setCookie("ahoy_visit", response.visit_token, visitTtl);
- setCookie("ahoy_visitor", response.visitor_token, visitorTtl);
- setReady();
- }, "json");
+ $.post("/ahoy/visits", data, setReady, "json");
} else {
log("Cookies disabled");
setReady();
@@ @@ -120,18 +187,74 @@
return true;
};
- ahoy.setCookie = setCookie;
- ahoy.getCookie = getCookie;
- ahoy.destroyCookie = destroyCookie;
- ahoy.log = log;
+ ahoy.track = function (name, properties) {
+ // generate unique id
+ var event = {
+ id: generateId(),
+ name: name,
+ properties: properties,
+ time: (new Date()).getTime() / 1000.0
+ };
+ log(event);
- ahoy.ready = function (callback) {
- if (isReady) {
- callback();
- } else {
- queue.push(callback);
- }
+ eventQueue.push(event);
+ saveEventQueue();
+
+ // wait in case navigating to reduce duplicate events
+ setTimeout( function () {
+ trackEvent(event);
+ }, 1000);
+ };
+
+ ahoy.trackView = function () {
+ var properties = {
+ url: window.location.href,
+ title: document.title
+ };
+ ahoy.track("$view", properties);
+ };
+
+ ahoy.trackClicks = function () {
+ $(document).on("click", "a, button, input[type=submit]", function (e) {
+ var $target = $(e.currentTarget);
+ var properties = eventProperties(e);
+ properties.text = properties.tag == "input" ? $target.val() : $.trim($target.text());
+ properties.href = $target.attr("href");
+ ahoy.track("$click", properties);
+ });
+ };
+
+ ahoy.trackSubmits = function () {
+ $(document).on("submit", "form", function (e) {
+ var properties = eventProperties(e);
+ ahoy.track("$submit", properties);
+ });
};
+ ahoy.trackChanges = function () {
+ $(document).on("change", "input, textarea, select", function (e) {
+ var properties = eventProperties(e);
+ ahoy.track("$change", properties);
+ });
+ };
+
+ ahoy.trackAll = function() {
+ ahoy.trackView();
+ ahoy.trackClicks();
+ ahoy.trackSubmits();
+ ahoy.trackChanges();
+ };
+
+ // push events from queue
+ try {
+ eventQueue = JSON.parse(getCookie("ahoy_events") || "[]");
+ } catch (e) {
+ // do nothing
+ }
+
+ for (var i = 0; i < eventQueue.length; i++) {
+ trackEvent(eventQueue[i]);
+ }
+
window.ahoy = ahoy;
}(window));