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"); |
| + | } |
| + | } |
| + | |
| + | }()); |