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