First commit

Andrew Kane committed Feb 26, 2014
commit 97eee13e55b6098903aa90f3e37ce9a0d61b9de5
Showing 14 changed files with 420 additions and 0 deletions
.gitignore +17 -0
@@ @@ -0,0 +1,17 @@
+ *.gem
+ *.rbc
+ .bundle
+ .config
+ .yardoc
+ Gemfile.lock
+ InstalledFiles
+ _yardoc
+ coverage
+ doc/
+ lib/bundler/man
+ pkg
+ rdoc
+ spec/reports
+ test/tmp
+ test/version_tmp
+ tmp
Gemfile +4 -0
@@ @@ -0,0 +1,4 @@
+ source 'https://rubygems.org'
+
+ # Specify your gem's dependencies in ahoy.gemspec
+ gemspec
LICENSE.txt +22 -0
@@ @@ -0,0 +1,22 @@
+ Copyright (c) 2014 Andrew Kane
+
+ MIT License
+
+ Permission is hereby granted, free of charge, to any person obtaining
+ a copy of this software and associated documentation files (the
+ "Software"), to deal in the Software without restriction, including
+ without limitation the rights to use, copy, modify, merge, publish,
+ distribute, sublicense, and/or sell copies of the Software, and to
+ permit persons to whom the Software is furnished to do so, subject to
+ the following conditions:
+
+ The above copyright notice and this permission notice shall be
+ included in all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
README.md +77 -0
@@ @@ -0,0 +1,77 @@
+ # Ahoy
+
+ Simple, powerful visit tracking for Rails.
+
+ ## Get Started
+
+ Add this line to your application’s Gemfile:
+
+ ```ruby
+ gem "ahoy_matey"
+ ```
+
+ And run the generator. This creates a migration to store visits.
+
+ ```sh
+ rails generate ahoy:install
+ rake db:migrate
+ ```
+
+ Next, include the javascript file in your `app/assets/javascripts/application.js` after jQuery.
+
+ ```javascript
+ //= require jquery
+ //= require ahoy
+ ```
+
+ That’s it.
+
+ ## What You Get
+
+ When a person visits your website, Ahoy creates a visit with lots of useful information.
+
+ - source (referrer, referring domain, campaign, landing page)
+ - location (country, region, and city)
+ - technology (browser, OS, and device type)
+
+ This information is great on it’s own, but super powerful when combined with other models.
+
+ You can store the visit id on any model. For instance, when someone places an order:
+
+ ```ruby
+ Order.create!(
+ visit_id: ahoy_visit.id,
+ # ... more attributes ...
+ )
+ ```
+
+ When you want to explore where most orders are coming from, you can do a number of queries.
+
+ ```ruby
+ Order.joins(:ahoy_visits).group("referring_domain").count
+ Order.joins(:ahoy_visits).group("city").count
+ Order.joins(:ahoy_visits).group("device_type").count
+ ```
+
+ ## Features
+
+ - Excludes search engines
+ - Gracefully degrades when cookies are disabled
+ - Gets campaign from utm_campaign parameter
+
+ # How It Works
+
+ When a user visits your website for the first time, the Javascript library generates a unique visit and visitor id.
+
+ It sends the event to the server.
+
+ A visit cookie is set for 4 hours, and a visitor cookie is set for 2 years.
+
+ ## Contributing
+
+ Everyone is encouraged to help improve this project. Here are a few ways you can help:
+
+ - [Report bugs](https://github.com/ankane/ahoy/issues)
+ - Fix bugs and [submit pull requests](https://github.com/ankane/ahoy/pulls)
+ - Write, clarify, or fix documentation
+ - Suggest or add new features
Rakefile +1 -0
@@ @@ -0,0 +1 @@
+ require "bundler/gem_tasks"
ahoy_matey.gemspec +27 -0
@@ @@ -0,0 +1,27 @@
+ # coding: utf-8
+ lib = File.expand_path('../lib', __FILE__)
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
+ require 'ahoy/version'
+
+ Gem::Specification.new do |spec|
+ spec.name = "ahoy_matey"
+ spec.version = Ahoy::VERSION
+ spec.authors = ["Andrew Kane"]
+ spec.email = ["andrew@chartkick.com"]
+ spec.summary = %q{Simple, powerful visit tracking for Rails}
+ spec.description = %q{Simple, powerful visit tracking for Rails}
+ spec.homepage = "https://github.com/ankane/ahoy"
+ spec.license = "MIT"
+
+ spec.files = `git ls-files -z`.split("\x0")
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
+ spec.require_paths = ["lib"]
+
+ spec.add_dependency "addressable"
+ spec.add_dependency "browser", ">= 0.4.0"
+ spec.add_dependency "geocoder"
+
+ spec.add_development_dependency "bundler", "~> 1.5"
+ spec.add_development_dependency "rake"
+ end
app/controllers/ahoy/visits_controller.rb +75 -0
@@ @@ -0,0 +1,75 @@
+ module Ahoy
+ class VisitsController < ActionController::Base
+
+ def create
+ visit =
+ Ahoy::Visit.new do |v|
+ v.visit_token = params[:visit_token]
+ v.visitor_token = params[:visitor_token]
+ v.ip = request.remote_ip
+ v.user_agent = request.user_agent
+ v.referrer = params[:referrer]
+ v.landing_page = params[:landing_page]
+ v.user = current_user if respond_to?(:current_user)
+ end
+
+ referring_uri = Addressable::URI.parse(params[:referrer]) rescue nil
+ if referring_uri
+ visit.referring_domain = referring_uri.host
+ end
+
+ landing_uri = Addressable::URI.parse(params[:landing_page]) rescue nil
+ if landing_uri
+ visit.campaign = (landing_uri.query_values || {})["utm_campaign"]
+ end
+
+ browser = Browser.new(ua: request.user_agent)
+ visit.browser = browser.name
+
+ # TODO add more
+ visit.os =
+ if browser.android?
+ "Android"
+ elsif browser.ios?
+ "iOS"
+ elsif browser.windows_phone?
+ "Windows Phone"
+ elsif browser.blackberry?
+ "Blackberry"
+ elsif browser.chrome_os?
+ "Chrome OS"
+ elsif browser.mac?
+ "Mac"
+ elsif browser.windows?
+ "Windows"
+ elsif browser.linux?
+ "Linux"
+ end
+
+ visit.device_type =
+ if browser.tv?
+ "TV"
+ elsif browser.console?
+ "Console"
+ elsif browser.tablet?
+ "Tablet"
+ elsif browser.mobile?
+ "Mobile"
+ else
+ "Desktop"
+ end
+
+ # location
+ location = Geocoder.search(request.remote_ip).first rescue nil
+ if location
+ visit.country = location.country.presence
+ visit.region = location.state.presence
+ visit.city = location.city.presence
+ end
+
+ visit.save!
+ render json: {id: visit.id}
+ end
+
+ end
+ end
app/models/ahoy/visit.rb +5 -0
@@ @@ -0,0 +1,5 @@
+ module Ahoy
+ class Visit < ActiveRecord::Base
+ belongs_to :user, polymorphic: true
+ end
+ end
config/routes.rb +7 -0
@@ @@ -0,0 +1,7 @@
+ Rails.application.routes.draw do
+ mount Ahoy::Engine => "/ahoy"
+ end
+
+ Ahoy::Engine.routes.draw do
+ resources :visits, only: [:create]
+ end
ahoy/version.rb b/lib/ahoy/version.rb +3 -0
@@ @@ -0,0 +1,3 @@
+ module Ahoy
+ VERSION = "0.0.1"
+ end
ahoy_matey.rb b/lib/ahoy_matey.rb +10 -0
@@ @@ -0,0 +1,10 @@
+ require "ahoy/version"
+ require "addressable/uri"
+ require "browser"
+ require "geocoder"
+
+ module Ahoy
+ class Engine < ::Rails::Engine
+ isolate_namespace Ahoy
+ end
+ end
generators/ahoy/install_generator.rb b/lib/generators/ahoy/install_generator.rb +29 -0
@@ @@ -0,0 +1,29 @@
+ # 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 Generators
+ class InstallGenerator < 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 "install.rb", "db/migrate/install_ahoy.rb"
+ end
+ end
+ end
+ end
generators/ahoy/templates/install.rb b/lib/generators/ahoy/templates/install.rb +36 -0
@@ @@ -0,0 +1,36 @@
+ class <%= migration_class_name %> < ActiveRecord::Migration
+ def change
+ create_table :ahoy_visits do |t|
+ t.string :visit_token
+ t.string :visitor_token
+ t.integer :user_id
+ t.string :user_type
+ t.string :ip
+ t.text :user_agent
+
+ # acquisition
+ t.text :referrer
+ t.string :referring_domain
+ t.string :campaign
+ # t.string :social_network
+ # t.string :search_engine
+ # t.string :search_keyword
+ t.text :landing_page
+
+ # technology
+ t.string :browser
+ t.string :os
+ t.string :device_type
+
+ # location
+ t.string :country
+ t.string :region
+ t.string :city
+
+ t.timestamp :created_at
+ end
+
+ add_index :ahoy_visits, [:visit_token], unique: true
+ add_index :ahoy_visits, [:user_id, :user_type]
+ end
+ end
vendor/assets/javascripts/ahoy.js +107 -0
@@ @@ -0,0 +1,107 @@
+ /*
+ * Ahoy.js - 0.0.1
+ * Super simple visit tracking
+ * https://github.com/ankane/ahoy
+ * MIT License
+ */
+
+ (function () {
+ "use strict";
+
+ var debugMode = true;
+ var visitTtl, visitorTtl;
+
+ if (debugMode) {
+ visitTtl = 0.2;
+ visitorTtl = 5; // 5 minutes
+ } else {
+ visitTtl = 4 * 60; // 4 hours
+ visitorTtl = 2 * 365 * 24 * 60; // 2 years
+ }
+
+ // cookies
+
+ // http://www.quirksmode.org/js/cookies.html
+ function setCookie(name, value, ttl) {
+ if (ttl) {
+ var date = new Date();
+ date.setTime(date.getTime()+(ttl*60*1000));
+ var expires = "; expires="+date.toGMTString();
+ }
+ else var expires = "";
+ document.cookie = name+"="+value+expires+"; path=/";
+ }
+
+ function getCookie(name) {
+ var nameEQ = name + "=";
+ var ca = document.cookie.split(';');
+ for(var i=0;i < ca.length;i++) {
+ var c = ca[i];
+ while (c.charAt(0)==' ') c = c.substring(1,c.length);
+ if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
+ }
+ return null;
+ }
+
+ // ids
+
+ // https://github.com/klughammer/node-randomstring
+ function generateToken() {
+ 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 debug(message) {
+ console.log(message, visitToken, visitorToken);
+ }
+
+ // main
+
+ var visitToken = getCookie("ahoy_visit");
+ var visitorToken = getCookie("ahoy_visitor");
+
+ if (visitToken && visitorToken) {
+ // TODO keep visit alive?
+ debug("Active visit");
+ } else {
+ if (!visitorToken) {
+ visitorToken = generateToken();
+ setCookie("ahoy_visitor", visitorToken, visitorTtl);
+ }
+
+ // always generate a new visit id here
+ visitToken = generateToken();
+ setCookie("ahoy_visit", visitToken, visitTtl);
+
+ // make sure cookies are enabled
+ if (getCookie("ahoy_visit")) {
+ debug("Visit started");
+
+ var data = {
+ visit_token: visitToken,
+ visitor_token: visitorToken,
+ landing_page: window.location.href
+ };
+
+ // referrer
+ if (document.referrer.length > 0) {
+ data.referrer = document.referrer;
+ }
+
+ debug(data);
+
+ $.post("/ahoy/visits", data);
+ } else {
+ debug("Cookies disabled");
+ }
+ }
+
+ }());