pax_global_header00006660000000000000000000000064136417514170014523gustar00rootroot0000000000000052 comment=30dfbe3126c42236ae133fac2b62b0673f88c8fc ahoy-3.0.2/000077500000000000000000000000001364175141700124655ustar00rootroot00000000000000ahoy-3.0.2/.github/000077500000000000000000000000001364175141700140255ustar00rootroot00000000000000ahoy-3.0.2/.github/ISSUE_TEMPLATE.md000066400000000000000000000002151364175141700165300ustar00rootroot00000000000000Hi, Before creating an issue, please check out the Contributing Guide: https://github.com/ankane/ahoy/blob/master/CONTRIBUTING.md Thanks! ahoy-3.0.2/.gitignore000066400000000000000000000002601364175141700144530ustar00rootroot00000000000000*.gem *.rbc .bundle .config .yardoc Gemfile.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/reports test/tmp test/version_tmp tmp *.log *.sqlite *.lock ahoy-3.0.2/.travis.yml000066400000000000000000000007711364175141700146030ustar00rootroot00000000000000dist: bionic language: ruby jobs: include: - rvm: 2.7 gemfile: Gemfile - rvm: 2.6 gemfile: test/gemfiles/rails52.gemfile - rvm: 2.5 gemfile: test/gemfiles/rails51.gemfile - rvm: 2.4 gemfile: test/gemfiles/rails50.gemfile addons: postgresql: 10 services: - postgresql - mysql - mongodb script: bundle exec rake test before_install: - mysqladmin create ahoy_test - createdb ahoy_test notifications: email: on_success: never on_failure: change ahoy-3.0.2/CHANGELOG.md000066400000000000000000000145661364175141700143120ustar00rootroot00000000000000## 3.0.2 (2020-04-03) - Added `cookie_options` ## 3.0.1 (2019-09-21) - Made `Ahoy::Tracker` work outside of requests - Fixed storage of `false` values with customized store - Fixed error with `user_method` and `Rails::InfoController` - Gracefully handle `ActionDispatch::RemoteIp::IpSpoofAttackError` ## 3.0.0 (2019-05-29) - Made Device Detector the default user agent parser - Made v2 the default bot detection version - Removed a large number of dependencies - Removed search keyword detection (most search engines today prevent this) - Removed support for Rails < 5 ## 2.2.1 (2019-05-26) - Updated Ahoy.js to 0.3.4 - Fixed v2 bot detection - Added latitude and longitude to installation ## 2.2.0 (2019-01-04) - Added `amp_event` helper - Improved bot detection for Device Detector ## 2.1.0 (2018-05-18) - Added option for IP masking - Added option to use anonymity sets instead of cookies - Added `user_agent_parser` option - Fixed `visitable` for Rails 4.2 - Removed `search_keyword` from new installs ## 2.0.2 (2018-03-14) - Fixed error on duplicate records - Fixed message when visit not found for geocoding - Better compatibility with GeoLite2 - Better browser compatibility for Ahoy.js ## 2.0.1 (2018-02-26) - Added `Ahoy.server_side_visits = :when_needed` to automatically create visits server-side when needed for events and `visitable` - Better handling of visit duration and expiration in JavaScript ## 2.0.0 (2018-02-25) - Removed dependency on jQuery - Use `navigator.sendBeacon` by default in supported browsers - Added `geocode` event - Added `where_event` method for querying events - Added support for `visitable` and `where_props` to Mongoid - Added `preserve_callbacks` option - Use `json` for MySQL by default - Fixed log silencing Breaking changes - Simpler interface for data stores - Renamed `track_visits_immediately` to `server_side_visits` and enabled by default - Renamed `mount` option to `api` and disabled by default - Enabled `protect_from_forgery` by default - Removed deprecated options - Removed throttling - Removed most built-in stores - Removed support for Rails < 4.2 ## 1.6.1 (2018-02-02) - Added `gin` index on properties for events - Fixed `visitable` options when name not provided ## 1.6.0 (2017-05-01) - Added support for Rails 5.1 ## 1.5.5 (2017-03-23) - Added support for Rails API - Added NATS and NSQ stores ## 1.5.4 (2017-01-22) - Fixed issue with duplicate events - Added support for PostGIS for `where_properties` ## 1.5.3 (2016-10-31) - Fixed error with Rails 5 and Mongoid 6 - Fixed regression with server not generating visit and visitor tokens - Accept UTM parameters as request parameters (for native apps) ## 1.5.2 (2016-08-26) - Better support for Rails 5 ## 1.5.1 (2016-08-19) - Restored throttling after removing side effects ## 1.5.0 (2016-08-19) - Removed throttling due to unintended side effects with its implementation - Ensure basic token requirements - Fixed visit recreation on cookie expiration - Fixed issue where `/ahoy/visits` is called indefinitely when `Ahoy.cookie_domain = :all` ## 1.4.2 (2016-06-21) - Fixed issues with `where_properties` ## 1.4.1 (2016-06-20) - Added `where_properties` method - Added Kafka store - Added `mount` option - Use less intrusive version of `safely` ## 1.4.0 (2016-03-23) - Use `ActiveRecordTokenStore` by default (integer instead of uuid for id) - Detect database for `rails g ahoy:stores:active_record` for easier installation - Use `safely` as default exception handler - Fixed issue with log silencer - Use multi-column indexes on `ahoy_events` table creation ## 1.3.1 (2016-03-22) - Raise errors in test environment ## 1.3.0 (2016-03-06) - Added throttling - Added `max_content_length` and `max_events_per_request` ## 1.2.2 (2016-03-05) - Fixed issue with latest version of `browser` gem - Added support for RabbitMQ - Added support for Amazon Kinesis Firehose - Fixed deprecation warnings in Rails 5 ## 1.2.1 (2015-08-14) - Fixed `SystemStackError: stack level too deep` when used with `activerecord-session_store` ## 1.2.0 (2015-06-07) - Added support for PostgreSQL `jsonb` column type - Added Fluentd store - Added latitude, longitude, and postal_code to visits - Log exclusions ## 1.1.1 (2015-01-05) - Better support for Authlogic - Added `screen_height` and `screen_width` ## 1.1.0 (2014-11-02) - Added `geocode` option - Report errors to service by default - Fixed association mismatch ## 1.0.2 (2014-07-10) - Fixed BSON for Mongoid 3 - Fixed Doorkeeper integration - Fixed user tracking in overridden authenticate method ## 1.0.1 (2014-06-27) - Fixed `visitable` outside of requests ## 1.0.0 (2014-06-18) - Added support for any data store, and Mongoid out of the box - Added `track_visits_immediately` option - Added exception catching and reporting - Visits expire after inactivity, not fixed interval - Added `visit_duration` and `visitor_duration` options ## 0.3.2 (2014-06-15) - Fixed bot exclusion for visits - Fixed user method ## 0.3.1 (2014-06-12) - Fixed visitor cookies when set on server - Added `domain` option for server cookies ## 0.3.0 (2014-06-11) - Added `current_visit_token` and `current_visitor_token` method - Switched to UUIDs - Quiet endpoint requests - Skip server-side bot events - Added `request` argument to `exclude_method` ## 0.2.2 (2014-05-26) - Added `exclude_method` option - Added support for batch events - Fixed cookie encoding - Fixed `options` variable from being modified ## 0.2.1 (2014-05-16) - Fixed IE 8 error - Added `track_bots` option - Added `$authenticate` event ## 0.2.0 (2014-05-13) - Added event tracking (merged ahoy_events) - Added ahoy.js ## 0.1.8 (2014-05-11) - Fixed bug with `user_type` set to `false` instead of `nil` ## 0.1.7 (2014-05-11) - Made cookie functions public for ahoy_events ## 0.1.6 (2014-05-07) - Better user agent parser ## 0.1.5 (2014-05-01) - Added support for Doorkeeper - Added options to `visitable` - Added `landing_params` method ## 0.1.4 (2014-04-27) - Added `ahoy.ready()` and `ahoy.log()` for events ## 0.1.3 (2014-04-24) - Supports `current_user` from `ApplicationController` - Added `ahoy.reset()` - Added `ahoy.debug()` - Added experimental support for native apps - Prefer `ahoy` over `Ahoy` ## 0.1.2 (2014-04-15) - Attach user on Devise sign up - Ability to specify visit model ## 0.1.1 (2014-03-20) - Made most database columns optional - Performance hack for referer-parser ## 0.1.0 (2014-03-19) - First major release ahoy-3.0.2/CONTRIBUTING.md000066400000000000000000000025071364175141700147220ustar00rootroot00000000000000# Contributing First, thanks for wanting to contribute. You’re awesome! :heart: ## Help We’re not able to provide support through GitHub Issues. If you’re looking for help with your code, try posting on [Stack Overflow](https://stackoverflow.com/). All features should be documented. If you don’t see a feature in the docs, assume it doesn’t exist. ## Bugs Think you’ve discovered a bug? 1. Search existing issues to see if it’s been reported. 2. Try the `master` branch to make sure it hasn’t been fixed. ```rb gem "ahoy_matey", github: "ankane/ahoy" ``` If the above steps don’t help, create an issue. Include: - Detailed steps to reproduce - Complete backtraces for exceptions ## New Features If you’d like to discuss a new feature, create an issue and start the title with `[Idea]`. ## Pull Requests Fork the project and create a pull request. A few tips: - Keep changes to a minimum. If you have multiple features or fixes, submit multiple pull requests. - Follow the existing style. The code should read like it’s written by a single person. Feel free to open an issue to get feedback on your idea before spending too much time on it. --- This contributing guide is released under [CCO](https://creativecommons.org/publicdomain/zero/1.0/) (public domain). Use it for your own project without attribution. ahoy-3.0.2/Gemfile000066400000000000000000000001621364175141700137570ustar00rootroot00000000000000source "https://rubygems.org" # Specify your gem's dependencies in ahoy.gemspec gemspec gem "rails", "~> 6.0.0" ahoy-3.0.2/LICENSE.txt000066400000000000000000000020611364175141700143070ustar00rootroot00000000000000Copyright (c) 2014-2019 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. ahoy-3.0.2/README.md000066400000000000000000000363111364175141700137500ustar00rootroot00000000000000# Ahoy :fire: Simple, powerful, first-party 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: Check out [Ahoy Email](https://github.com/ankane/ahoy_email) for emails and [Field Test](https://github.com/ankane/field_test) for A/B testing :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource) [![Build Status](https://travis-ci.org/ankane/ahoy.svg?branch=master)](https://travis-ci.org/ankane/ahoy) ## Installation Add this line to your application’s Gemfile: ```ruby gem 'ahoy_matey' ``` And run: ```sh 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: ```ruby ahoy.track "My first event", language: "Ruby" ``` ### JavaScript, Native Apps, & AMP Enable the API in `config/initializers/ahoy.rb`: ```ruby Ahoy.api = true ``` And restart your web server. ### JavaScript For Rails 6 / Webpacker, run: ```sh yarn add ahoy.js ``` And add to `app/javascript/packs/application.js`: ```javascript import ahoy from "ahoy.js"; ``` For Rails 5 / Sprockets, add to `app/assets/javascripts/application.js`: ```javascript //= require ahoy ``` Track an event with: ```javascript ahoy.track("My second event", {language: "JavaScript"}); ``` ### GDPR Compliance Ahoy provides a number of options to help with GDPR compliance. See the [GDPR section](#gdpr-compliance-1) 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: ```ruby skip_before_action :track_ahoy_visit ``` This is typically useful for APIs. If your entire Rails app is an API, you can use: ```ruby 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. ```ruby Ahoy.server_side_visits = :when_needed ``` ### Events Each event has a `name` and `properties`. There are several ways to track events. #### Ruby ```ruby ahoy.track "Viewed book", title: "Hot, Flat, and Crowded" ``` Track actions automatically with: ```ruby class ApplicationController < ActionController::Base after_action :track_action protected def track_action ahoy.track "Ran action", request.path_parameters end end ``` #### JavaScript ```javascript ahoy.track("Viewed book", {title: "The World is Flat"}); ``` Track events automatically with: ```javascript ahoy.trackAll(); ``` See [Ahoy.js](https://github.com/ankane/ahoy.js) for a complete list of features. #### Native Apps For Android, check out [Ahoy Android](https://github.com/instacart/ahoy-android). For other platforms, see the [API spec](#api-spec). #### AMP ```erb <%= amp_event "Viewed article", title: "Analytics with Rails" %> ``` ### Associated Models Say we want to associate orders with visits. Just add `visitable` to the model. ```ruby 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: ```ruby 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: ```ruby class AddVisitIdToOrders < ActiveRecord::Migration[6.0] def change add_column :orders, :ahoy_visit_id, :bigint end end ``` Customize the column with: ```ruby visitable :sign_up_visit ``` ### Users Ahoy automatically attaches the `current_user` to the visit. With [Devise](https://github.com/plataformatec/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: ```ruby ahoy.authenticate(user) ``` To see the visits for a given user, create an association: ```ruby class User < ApplicationRecord has_many :visits, class_name: "Ahoy::Visit" end ``` And use: ```ruby User.find(123).visits ``` #### Custom User Method Use a method besides `current_user` ```ruby Ahoy.user_method = :true_user ``` or use a proc ```ruby Ahoy.user_method = ->(controller) { controller.true_user } ``` #### Doorkeeper To attach the user with [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper), be sure you have a `current_resource_owner` method in `ApplicationController`. ```ruby 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](https://github.com/nsarno/knock), either include `Knock::Authenticable`in `ApplicationController`: ```ruby class ApplicationController < ActionController::API include Knock::Authenticable end ``` Or include it in Ahoy: ```ruby Ahoy::BaseController.include Knock::Authenticable ``` And use: ```ruby Ahoy.user_method = ->(controller) { controller.send(:authenticate_entity, "user") } ``` ### Exclusions Bots are excluded from tracking by default. To include them, use: ```ruby Ahoy.track_bots = true ``` Add your own rules with: ```ruby 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: ```ruby Ahoy.visit_duration = 30.minutes ``` ### Cookies To track visits across multiple subdomains, use: ```ruby Ahoy.cookie_domain = :all ``` Set other [cookie options](https://api.rubyonrails.org/classes/ActionDispatch/Cookies.html) with: ```ruby Ahoy.cookie_options = {same_site: :lax} ``` ### Geocoding Disable geocoding with: ```ruby Ahoy.geocode = false ``` The default job queue is `:ahoy`. Change this with: ```ruby Ahoy.job_queue = :low_priority ``` #### Geocoding Performance To avoid calls to a remote API, download the [GeoLite2 City database](https://dev.maxmind.com/geoip/geoip2/geolite2/) and configure Geocoder to use it. Add this line to your application’s Gemfile: ```ruby gem 'maxminddb' ``` And create an initializer at `config/initializers/geocoder.rb` with: ```ruby 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](https://github.com/temedica/heroku-buildpack-maxmind-geolite2) 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](https://github.com/recurly/druuid). ```ruby Ahoy.token_generator = -> { Druuid.gen } ``` ### Throttling You can use [Rack::Attack](https://github.com/kickstarter/rack-attack) to throttle requests to the API. ```ruby 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](https://github.com/ankane/safely) to try to report them to a service by default. To customize this, use: ```ruby Safely.report_exception_method = ->(e) { Rollbar.error(e) } ``` ## GDPR Compliance Ahoy provides a number of options to help with [GDPR compliance](https://en.wikipedia.org/wiki/General_Data_Protection_Regulation). Update `config/initializers/ahoy.rb` with: ```ruby 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: ```javascript ahoy.configure({cookies: false}); ``` ### IP Masking Ahoy can mask IPs with the same approach [Google Analytics uses for IP anonymization](https://support.google.com/analytics/answer/2763052). 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::`) ```ruby Ahoy.mask_ips = true ``` IPs are masked before geolocation is performed. To mask previously collected IPs, use: ```ruby 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](https://privacypatterns.org/patterns/Anonymity-set). Instead of cookies, visitors with the same IP mask and user agent are grouped together in an anonymity set. ```ruby 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 ```javascript ahoy.reset(); // then reload the page ``` Log messages ```javascript ahoy.debug(); ``` Turn off logging ```javascript ahoy.debug(false); ``` Debug API requests in Ruby ```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`: ```ruby class Ahoy::Store < Ahoy::DatabaseStore end ``` There are four events data stores can subscribe to: ```ruby 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](docs/Data-Store-Examples.md) for Kafka, RabbitMQ, Fluentd, NATS, NSQ, and Amazon Kinesis Firehose. ### Track Additional Data ```ruby 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: ```javascript ahoy.configure({visitParams: {referral_code: 123}}); ``` And use: ```ruby class Ahoy::Store < Ahoy::DatabaseStore def track_visit(data) data[:referral_code] = request.parameters[:referral_code] super(data) end end ``` ### Use Different Models ```ruby class Ahoy::Store < Ahoy::DatabaseStore def visit_model MyVisit end def event_model MyEvent end end ``` ## Explore the Data [Blazer](https://github.com/ankane/blazer) is a great tool for exploring your data. With ActiveRecord, you can do: ```ruby Ahoy::Visit.group(:search_keyword).count Ahoy::Visit.group(:country).count Ahoy::Visit.group(:referring_domain).count ``` [Chartkick](https://www.chartkick.com/) and [Groupdate](https://github.com/ankane/groupdate) make it easy to visualize the data. ```erb <%= 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: ```ruby Ahoy::Event.where_event("Viewed product", product_id: 123).count ``` Or just query properties with: ```ruby Ahoy::Event.where_props(product_id: 123).count ``` ### Funnels It’s easy to create funnels. ```ruby 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 - [Tracking Metrics with Ahoy and Blazer](https://gorails.com/episodes/internal-metrics-with-ahoy-and-blazer) ## API Spec ### Visits Generate visit and visitor tokens as [UUIDs](https://en.wikipedia.org/wiki/Universally_unique_identifier), 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: ```json { "visit_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: ```json { "visit_token": "", "visitor_token": "", "events": [ { "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: ```ruby gem "browser", "~> 2.0" gem "user_agent_parser" ``` And add to `config/initializers/ahoy.rb`: ```ruby 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`: ```ruby Ahoy.bot_detection_version = 2 ``` ### 2.1 Ahoy recommends [Device Detector](https://github.com/podigee/device_detector) for user agent parsing and makes it the default for new installations. To switch, add to `config/initializers/ahoy.rb`: ```ruby Ahoy.user_agent_parser = :device_detector ``` Backfill existing records with: ```ruby 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](docs/Ahoy-2-Upgrade.md) ## History View the [changelog](https://github.com/ankane/ahoy/blob/master/CHANGELOG.md) ## 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 ahoy-3.0.2/Rakefile000066400000000000000000000002601364175141700141300ustar00rootroot00000000000000require "bundler/gem_tasks" require "rake/testtask" task default: :test Rake::TestTask.new do |t| t.libs << "test" t.pattern = "test/**/*_test.rb" t.warning = false end ahoy-3.0.2/ahoy_matey.gemspec000066400000000000000000000021221364175141700161660ustar00rootroot00000000000000require_relative "lib/ahoy/version" Gem::Specification.new do |spec| spec.name = "ahoy_matey" spec.version = Ahoy::VERSION spec.summary = "Simple, powerful, first-party analytics for Rails" spec.homepage = "https://github.com/ankane/ahoy" spec.license = "MIT" spec.author = "Andrew Kane" spec.email = "andrew@chartkick.com" spec.files = Dir["*.{md,txt}", "{app,config,lib,vendor}/**/*"] spec.require_path = "lib" spec.required_ruby_version = ">= 2.4" spec.add_dependency "activesupport", ">= 5" spec.add_dependency "geocoder", ">= 1.4.5" spec.add_dependency "safely_block", ">= 0.2.1" spec.add_dependency "device_detector" spec.add_development_dependency "bundler" spec.add_development_dependency "rake" spec.add_development_dependency "minitest" spec.add_development_dependency "combustion" spec.add_development_dependency "rails" spec.add_development_dependency "sqlite3" spec.add_development_dependency "pg" spec.add_development_dependency "mysql2" spec.add_development_dependency "mongoid" end ahoy-3.0.2/app/000077500000000000000000000000001364175141700132455ustar00rootroot00000000000000ahoy-3.0.2/app/controllers/000077500000000000000000000000001364175141700156135ustar00rootroot00000000000000ahoy-3.0.2/app/controllers/ahoy/000077500000000000000000000000001364175141700165535ustar00rootroot00000000000000ahoy-3.0.2/app/controllers/ahoy/base_controller.rb000066400000000000000000000020401364175141700222510ustar00rootroot00000000000000module Ahoy class BaseController < ApplicationController filters = _process_action_callbacks.map(&:filter) - Ahoy.preserve_callbacks skip_before_action(*filters, raise: false) skip_after_action(*filters, raise: false) skip_around_action(*filters, raise: false) before_action :verify_request_size before_action :renew_cookies if respond_to?(:protect_from_forgery) protect_from_forgery with: :null_session, if: -> { Ahoy.protect_from_forgery } end protected def ahoy @ahoy ||= Ahoy::Tracker.new(controller: self, api: true) end # set proper ttl if cookie generated from JavaScript # approach is not perfect, as user must reload the page # for new cookie settings to take effect def renew_cookies set_ahoy_cookies if params[:js] && !Ahoy.api_only end def verify_request_size if request.content_length > Ahoy.max_content_length logger.info "[ahoy] Payload too large" render plain: "Payload too large\n", status: 413 end end end end ahoy-3.0.2/app/controllers/ahoy/events_controller.rb000066400000000000000000000017451364175141700226560ustar00rootroot00000000000000module Ahoy class EventsController < Ahoy::BaseController def create events = if params[:name] # legacy API and AMP [request.params] elsif params[:events] request.params[:events] else data = if params[:events_json] request.params[:events_json] else request.body.read end begin ActiveSupport::JSON.decode(data) rescue ActiveSupport::JSON.parse_error # do nothing [] end end events.first(Ahoy.max_events_per_request).each do |event| time = Time.zone.parse(event["time"]) rescue nil # timestamp is deprecated time ||= Time.zone.at(event["time"].to_f) rescue nil options = { id: event["id"], time: time } ahoy.track event["name"], event["properties"], options end render json: {} end end end ahoy-3.0.2/app/controllers/ahoy/visits_controller.rb000066400000000000000000000004721364175141700226670ustar00rootroot00000000000000module Ahoy class VisitsController < BaseController def create ahoy.track_visit render json: { visit_token: ahoy.visit_token, visitor_token: ahoy.visitor_token, # legacy visit_id: ahoy.visit_token, visitor_id: ahoy.visitor_token } end end end ahoy-3.0.2/app/jobs/000077500000000000000000000000001364175141700142025ustar00rootroot00000000000000ahoy-3.0.2/app/jobs/ahoy/000077500000000000000000000000001364175141700151425ustar00rootroot00000000000000ahoy-3.0.2/app/jobs/ahoy/geocode_job.rb000066400000000000000000000003421364175141700177250ustar00rootroot00000000000000# for smooth update from Ahoy 1 -> 2 module Ahoy class GeocodeJob < ActiveJob::Base queue_as { Ahoy.job_queue } def perform(visit) Ahoy::GeocodeV2Job.perform_now(visit.visit_token, visit.ip) end end end ahoy-3.0.2/app/jobs/ahoy/geocode_v2_job.rb000066400000000000000000000013771364175141700203450ustar00rootroot00000000000000module Ahoy class GeocodeV2Job < ActiveJob::Base queue_as { Ahoy.job_queue } def perform(visit_token, ip) location = begin Geocoder.search(ip).first rescue => e Ahoy.log "Geocode error: #{e.class.name}: #{e.message}" nil end if location && location.country.present? data = { country: location.country, region: location.try(:state).presence, city: location.try(:city).presence, postal_code: location.try(:postal_code).presence, latitude: location.try(:latitude).presence, longitude: location.try(:longitude).presence } Ahoy::Tracker.new(visit_token: visit_token).geocode(data) end end end end ahoy-3.0.2/config/000077500000000000000000000000001364175141700137325ustar00rootroot00000000000000ahoy-3.0.2/config/routes.rb000066400000000000000000000003401364175141700155750ustar00rootroot00000000000000Rails.application.routes.draw do mount Ahoy::Engine => "/ahoy" if Ahoy.api end Ahoy::Engine.routes.draw do scope module: "ahoy" do resources :visits, only: [:create] resources :events, only: [:create] end end ahoy-3.0.2/docs/000077500000000000000000000000001364175141700134155ustar00rootroot00000000000000ahoy-3.0.2/docs/Ahoy-2-Upgrade.md000066400000000000000000000061701364175141700163670ustar00rootroot00000000000000# Ahoy 2 Upgrade Ahoy 2.0 brings a number of exciting changes: - jQuery is no longer required - Uses `navigator.sendBeacon` by default in supported browsers - Simpler interface for data stores ## How to Upgrade Update your Gemfile: ```ruby gem 'ahoy_matey', '~> 2' ``` And run: ```sh bundle update ahoy_matey ``` Add to `config/initializers/ahoy.rb`: ```ruby Ahoy.api = true Ahoy.server_side_visits = false ``` You can also try the new `Ahoy.server_side_visits = :when_needed` to automatically create visits server-side when needed for events and `visitable`. If you use `visitable`, add `class_name` to each instance: ```ruby visitable class_name: "Visit" ``` Then follow the instructions for your data store. - [ActiveRecordTokenStore](#activerecordtokenstore) - [ActiveRecordStore](#activerecordstore) - [MongoidStore](#mongoidstore) - [Others](#others) ## Data Stores ### ActiveRecordTokenStore In `config/initializers/ahoy.rb`, replace `Ahoy::Store` with: ```ruby class Ahoy::Store < Ahoy::DatabaseStore def visit_model Visit end end ``` ### ActiveRecordStore Add [uuidtools](https://github.com/sporkmonger/uuidtools) to your Gemfile. In `config/initializers/ahoy.rb`, replace `Ahoy::Store` with: ```ruby class Ahoy::Store < Ahoy::DatabaseStore def track_visit(data) data[:id] = ensure_uuid(data.delete(:visit_token)) data[:visitor_id] = ensure_uuid(data.delete(:visitor_token)) super(data) end def track_event(data) data[:id] = ensure_uuid(data.delete(:event_id)) super(data) end def visit @visit ||= visit_model.find_by(id: ensure_uuid(ahoy.visit_token)) if ahoy.visit_token end def visit_model Visit end UUID_NAMESPACE = UUIDTools::UUID.parse("a82ae811-5011-45ab-a728-569df7499c5f") def ensure_uuid(id) UUIDTools::UUID.parse(id).to_s rescue UUIDTools::UUID.sha1_create(UUID_NAMESPACE, id).to_s end end ``` ### MongoidStore In `config/initializers/ahoy.rb`, replace `Ahoy::Store` with: ```ruby class Ahoy::Store < Ahoy::DatabaseStore def track_visit(data) data[:_id] = binary_uuid(data.delete(:visit_token)) data[:visitor_id] = binary_uuid(data.delete(:visitor_token)) super(data) end def track_event(data) data[:_id] = binary_uuid(data.delete(:event_id)) super(data) end def geocode(data) visit_model.where(id: binary_uuid(ahoy.visit_token)).find_one_and_update({"$set": data}, {upsert: true}) end def visit @visit ||= visit_model.where(id: binary_uuid(ahoy.visit_token)).first if ahoy.visit_token end def visit_model Visit end def binary_uuid(token) token = token.delete("-") if defined?(::BSON) ::BSON::Binary.new(token, :uuid) elsif defined?(::Moped::BSON) ::Moped::BSON::Binary.new(:uuid, token) else token end end end ``` ### Others Check out the [data store examples](Data-Store-Examples.md). ## Throttling Throttling was removed due to limited practical usefulness. See [instructions for adding it back](../README.md#throttling) if you need it. ## Options - The `mount` option was renamed to `api` - The `track_visits_immediately` option was renamed to `server_side_visits` ahoy-3.0.2/docs/Data-Store-Examples.md000066400000000000000000000077501364175141700174670ustar00rootroot00000000000000# Data Store Examples - [Kafka](#kafka) - [RabbitMQ](#rabbitmq) - [Fluentd](#fluentd) - [NATS](#nats) - [NSQ](#nsq) - [Amazon Kinesis Firehose](#amazon-kinesis-firehose) ### Kafka Add [ruby-kafka](https://github.com/zendesk/ruby-kafka) to your Gemfile. ```ruby class Ahoy::Store < Ahoy::BaseStore def track_visit(data) post("ahoy_visits", data) end def track_event(data) post("ahoy_events", data) end def geocode(data) post("ahoy_geocode", data) end def authenticate(data) post("ahoy_auth", data) end private def post(topic, data) producer.produce(data.to_json, topic: topic) end def producer @producer ||= begin client = Kafka.new( seed_brokers: ENV["KAFKA_URL"] || "localhost:9092", logger: Rails.logger ) producer = client.async_producer(delivery_interval: 3) at_exit { producer.shutdown } producer end end end ``` ### RabbitMQ Add [bunny](https://github.com/ruby-amqp/bunny) to your Gemfile. ```ruby class Ahoy::Store < Ahoy::BaseStore def track_visit(data) post("ahoy_visits", data) end def track_event(data) post("ahoy_events", data) end def geocode(data) post("ahoy_geocode", data) end def authenticate(data) post("ahoy_auth", data) end private def post(topic, message) channel.queue(topic, durable: true).publish(message.to_json) end def channel @channel ||= begin conn = Bunny.new conn.start conn.create_channel end end end ``` ### Fluentd Add [fluent-logger](https://github.com/fluent/fluent-logger-ruby) to your Gemfile. ```ruby class Ahoy::Store < Ahoy::BaseStore def track_visit(data) post("ahoy_visits", data) end def track_event(data) post("ahoy_events", data) end def geocode(data) post("ahoy_geocode", data) end def authenticate(data) post("ahoy_auth", data) end private def post(topic, message) logger.post(topic, message) end def logger @logger ||= Fluent::Logger::FluentLogger.new("ahoy", host: "localhost", port: 24224) end end ``` ### NATS Add [nats-pure](https://github.com/nats-io/pure-ruby-nats) to your Gemfile. ```ruby class Ahoy::Store < Ahoy::BaseStore def track_visit(data) post("ahoy_visits", data) end def track_event(data) post("ahoy_events", data) end def geocode(data) post("ahoy_geocode", data) end def authenticate(data) post("ahoy_auth", data) end private def post(topic, data) client.publish(topic, data.to_json) end def client @client ||= begin require "nats/io/client" client = NATS::IO::Client.new client.connect(servers: (ENV["NATS_URL"] || "nats://127.0.0.1:4222").split(",")) client end end end ``` ### NSQ Add [nsq-ruby](https://github.com/wistia/nsq-ruby) to your Gemfile. ```ruby class Ahoy::Store < Ahoy::BaseStore def track_visit(data) post("ahoy_visits", data) end def track_event(data) post("ahoy_events", data) end def geocode(data) post("ahoy_geocode", data) end def authenticate(data) post("ahoy_auth", data) end private def post(topic, data) client.write_to_topic(topic, data.to_json) end def client @client ||= begin require "nsq" client = Nsq::Producer.new( nsqd: ENV["NSQ_URL"] || "127.0.0.1:4150" ) at_exit { client.terminate } client end end end ``` ### Amazon Kinesis Firehose Add [aws-sdk-firehose](https://github.com/aws/aws-sdk-ruby) to your Gemfile. ```ruby class Ahoy::Store < Ahoy::BaseStore def track_visit(data) post("ahoy_visits", data) end def track_event(data) post("ahoy_events", data) end def geocode(data) post("ahoy_geocode", data) end def authenticate(data) post("ahoy_auth", data) end private def post(topic, data) client.put_record( delivery_stream_name: topic, record: {data: "#{data.to_json}\n"} ) end def client @client ||= Aws::Firehose::Client.new end end ``` ahoy-3.0.2/lib/000077500000000000000000000000001364175141700132335ustar00rootroot00000000000000ahoy-3.0.2/lib/ahoy.rb000066400000000000000000000051751364175141700145300ustar00rootroot00000000000000require "ipaddr" # dependencies require "active_support" require "active_support/core_ext" require "geocoder" require "safely/core" # modules require "ahoy/utils" require "ahoy/base_store" require "ahoy/controller" require "ahoy/database_store" require "ahoy/helper" require "ahoy/model" require "ahoy/query_methods" require "ahoy/tracker" require "ahoy/version" require "ahoy/visit_properties" require "ahoy/engine" if defined?(Rails) module Ahoy mattr_accessor :visit_duration self.visit_duration = 4.hours mattr_accessor :visitor_duration self.visitor_duration = 2.years mattr_accessor :cookies self.cookies = true # TODO deprecate in favor of cookie_options mattr_accessor :cookie_domain mattr_accessor :cookie_options self.cookie_options = {} mattr_accessor :server_side_visits self.server_side_visits = true mattr_accessor :quiet self.quiet = true mattr_accessor :geocode self.geocode = true mattr_accessor :max_content_length self.max_content_length = 8192 mattr_accessor :max_events_per_request self.max_events_per_request = 10 mattr_accessor :job_queue self.job_queue = :ahoy mattr_accessor :api self.api = false mattr_accessor :api_only self.api_only = false mattr_accessor :protect_from_forgery self.protect_from_forgery = true mattr_accessor :preserve_callbacks self.preserve_callbacks = [:load_authlogic, :activate_authlogic] mattr_accessor :user_method self.user_method = lambda do |controller| (controller.respond_to?(:current_user, true) && controller.send(:current_user)) || (controller.respond_to?(:current_resource_owner, true) && controller.send(:current_resource_owner)) || nil end mattr_accessor :exclude_method mattr_accessor :track_bots self.track_bots = false mattr_accessor :bot_detection_version self.bot_detection_version = 2 mattr_accessor :token_generator self.token_generator = -> { SecureRandom.uuid } mattr_accessor :mask_ips self.mask_ips = false mattr_accessor :user_agent_parser self.user_agent_parser = :device_detector mattr_accessor :logger def self.log(message) logger.info { "[ahoy] #{message}" } if logger end def self.mask_ip(ip) addr = IPAddr.new(ip) if addr.ipv4? # set last octet to 0 addr.mask(24).to_s else # set last 80 bits to zeros addr.mask(48).to_s end end end ActiveSupport.on_load(:action_controller) do include Ahoy::Controller end ActiveSupport.on_load(:active_record) do extend Ahoy::Model end ActiveSupport.on_load(:action_view) do include Ahoy::Helper end # Mongoid if defined?(ActiveModel) ActiveModel::Callbacks.include(Ahoy::Model) end ahoy-3.0.2/lib/ahoy/000077500000000000000000000000001364175141700141735ustar00rootroot00000000000000ahoy-3.0.2/lib/ahoy/base_store.rb000066400000000000000000000036301364175141700166500ustar00rootroot00000000000000module Ahoy class BaseStore attr_writer :user def initialize(options) @options = options end def track_visit(data) end def track_event(data) end def geocode(data) end def authenticate(data) end def visit end def user @user ||= begin if Ahoy.user_method.respond_to?(:call) Ahoy.user_method.call(controller) else controller.send(Ahoy.user_method) if controller.respond_to?(Ahoy.user_method, true) end end end def exclude? (!Ahoy.track_bots && bot?) || exclude_by_method? end def generate_id Ahoy.token_generator.call end def visit_or_create visit end protected def bot? unless defined?(@bot) @bot = begin if request if Ahoy.user_agent_parser == :device_detector detector = DeviceDetector.new(request.user_agent) if Ahoy.bot_detection_version == 2 detector.bot? || (detector.device_type.nil? && detector.os_name.nil?) else detector.bot? end else # no need to throw friendly error if browser isn't defined # since will error in visit_properties Browser.new(request.user_agent).bot? end else false end end end @bot end def exclude_by_method? if Ahoy.exclude_method if Ahoy.exclude_method.arity == 1 Ahoy.exclude_method.call(controller) else Ahoy.exclude_method.call(controller, request) end else false end end def request @request ||= @options[:request] || controller.try(:request) end def controller @controller ||= @options[:controller] end def ahoy @ahoy ||= @options[:ahoy] end end end ahoy-3.0.2/lib/ahoy/controller.rb000066400000000000000000000022451364175141700167060ustar00rootroot00000000000000module Ahoy module Controller def self.included(base) if base.respond_to?(:helper_method) base.helper_method :current_visit base.helper_method :ahoy end base.before_action :set_ahoy_cookies, unless: -> { Ahoy.api_only } base.before_action :track_ahoy_visit, unless: -> { Ahoy.api_only } base.around_action :set_ahoy_request_store end def ahoy @ahoy ||= Ahoy::Tracker.new(controller: self) end def current_visit ahoy.visit end def set_ahoy_cookies if Ahoy.cookies ahoy.set_visitor_cookie ahoy.set_visit_cookie else # delete cookies if exist ahoy.reset end end def track_ahoy_visit defer = Ahoy.server_side_visits != true if defer && !Ahoy.cookies # avoid calling new_visit?, which triggers a database call elsif ahoy.new_visit? ahoy.track_visit(defer: defer) end end def set_ahoy_request_store previous_value = Thread.current[:ahoy] begin Thread.current[:ahoy] = ahoy yield ensure Thread.current[:ahoy] = previous_value end end end end ahoy-3.0.2/lib/ahoy/database_store.rb000066400000000000000000000051261364175141700175040ustar00rootroot00000000000000module Ahoy class DatabaseStore < BaseStore def track_visit(data) @visit = visit_model.create!(slice_data(visit_model, data)) rescue => e raise e unless unique_exception?(e) # so next call to visit will try to fetch from DB if defined?(@visit) remove_instance_variable(:@visit) end end def track_event(data) visit = visit_or_create(started_at: data[:time]) if visit event = event_model.new(slice_data(event_model, data)) event.visit = visit event.time = visit.started_at if event.time < visit.started_at begin event.save! rescue => e raise e unless unique_exception?(e) end else Ahoy.log "Event excluded since visit not created: #{data[:visit_token]}" end end def geocode(data) visit_token = data.delete(:visit_token) data = slice_data(visit_model, data) if defined?(Mongoid::Document) && visit_model < Mongoid::Document # upsert since visit might not be found due to eventual consistency visit_model.where(visit_token: visit_token).find_one_and_update({"$set": data}, {upsert: true}) elsif visit visit.update!(data) else Ahoy.log "Visit for geocode not found: #{visit_token}" end end def authenticate(_) if visit && visit.respond_to?(:user) && !visit.user begin visit.user = user visit.save! rescue ActiveRecord::AssociationTypeMismatch # do nothing end end end def visit unless defined?(@visit) @visit = visit_model.where(visit_token: ahoy.visit_token).first if ahoy.visit_token end @visit end # if we don't have a visit, let's try to create one first def visit_or_create(started_at: nil) ahoy.track_visit(started_at: started_at) if !visit && Ahoy.server_side_visits visit end protected def visit_model ::Ahoy::Visit end def event_model ::Ahoy::Event end def slice_data(model, data) column_names = model.try(:column_names) || model.attribute_names data.slice(*column_names.map(&:to_sym)).select { |_, v| !v.nil? } end def unique_exception?(e) return true if defined?(ActiveRecord::RecordNotUnique) && e.is_a?(ActiveRecord::RecordNotUnique) return true if defined?(PG::UniqueViolation) && e.is_a?(PG::UniqueViolation) return true if defined?(Mongo::Error::OperationFailure) && e.is_a?(Mongo::Error::OperationFailure) && e.message.include?("duplicate key error") false end end end ahoy-3.0.2/lib/ahoy/engine.rb000066400000000000000000000015341364175141700157700ustar00rootroot00000000000000module Ahoy class Engine < ::Rails::Engine initializer "ahoy", after: "sprockets.environment" do Ahoy.logger ||= Rails.logger # allow Devise to be loaded after Ahoy require "ahoy/warden" if defined?(Warden) next unless Ahoy.quiet # Parse PATH_INFO by assets prefix AHOY_PREFIX = "/ahoy/".freeze # Just create an alias for call in middleware Rails::Rack::Logger.class_eval do def call_with_quiet_ahoy(env) if env["PATH_INFO"].start_with?(AHOY_PREFIX) && logger.respond_to?(:silence) logger.silence do call_without_quiet_ahoy(env) end else call_without_quiet_ahoy(env) end end alias_method :call_without_quiet_ahoy, :call alias_method :call, :call_with_quiet_ahoy end end end end ahoy-3.0.2/lib/ahoy/helper.rb000066400000000000000000000021171364175141700160000ustar00rootroot00000000000000module Ahoy module Helper def amp_event(name, properties = {}) url = Ahoy::Engine.routes.url_helpers.events_url( url_options.slice(:host, :port, :protocol).merge( name: name, properties: properties, screen_width: "SCREEN_WIDTH", screen_height: "SCREEN_HEIGHT", platform: "Web", landing_page: "AMPDOC_URL", referrer: "DOCUMENT_REFERRER", random: "RANDOM" ) ) url = "#{url}&visit_token=${clientId(ahoy_visit)}&visitor_token=${clientId(ahoy_visitor)}" content_tag "amp-analytics" do content_tag "script", type: "application/json" do json_escape({ requests: { pageview: url }, triggers: { trackPageview: { on: "visible", request: "pageview" } }, transport: { beacon: true, xhrpost: true, image: false } }.to_json).html_safe end end end end end ahoy-3.0.2/lib/ahoy/model.rb000066400000000000000000000005661364175141700156270ustar00rootroot00000000000000module Ahoy module Model def visitable(name = :visit, **options) class_eval do belongs_to(name, class_name: "Ahoy::Visit", optional: true, **options) before_create :set_ahoy_visit end class_eval %{ def set_ahoy_visit self.#{name} ||= Thread.current[:ahoy].try(:visit_or_create) end } end end end ahoy-3.0.2/lib/ahoy/query_methods.rb000066400000000000000000000046231364175141700174150ustar00rootroot00000000000000module Ahoy module QueryMethods extend ActiveSupport::Concern module ClassMethods def where_event(name, properties = {}) where(name: name).where_props(properties) end def where_props(properties) relation = self if respond_to?(:columns_hash) column_type = columns_hash["properties"].type adapter_name = connection.adapter_name.downcase else adapter_name = "mongoid" end case adapter_name when "mongoid" relation = where(Hash[properties.map { |k, v| ["properties.#{k}", v] }]) when /mysql/ if column_type == :json properties.each do |k, v| if v.nil? v = "null" elsif v == true v = "true" end relation = relation.where("JSON_UNQUOTE(properties -> ?) = ?", "$.#{k}", v.as_json) end else properties.each do |k, v| relation = relation.where("properties REGEXP ?", "[{,]#{{k.to_s => v}.to_json.sub(/\A\{/, "").sub(/\}\z/, "").gsub("+", "\\\\+")}[,}]") end end when /postgres|postgis/ if column_type == :jsonb relation = relation.where("properties @> ?", properties.to_json) elsif column_type == :json properties.each do |k, v| relation = if v.nil? relation.where("properties ->> ? IS NULL", k.to_s) else relation.where("properties ->> ? = ?", k.to_s, v.as_json.to_s) end end elsif column_type == :hstore properties.each do |k, v| relation = if v.nil? relation.where("properties -> ? IS NULL", k.to_s) else relation.where("properties -> ? = ?", k.to_s, v.to_s) end end else properties.each do |k, v| relation = relation.where("properties SIMILAR TO ?", "%[{,]#{{k.to_s => v}.to_json.sub(/\A\{/, "").sub(/\}\z/, "").gsub("+", "\\\\+")}[,}]%") end end else raise "Adapter not supported: #{adapter_name}" end relation end alias_method :where_properties, :where_props end end end # backward compatibility Ahoy::Properties = Ahoy::QueryMethods ahoy-3.0.2/lib/ahoy/tracker.rb000066400000000000000000000160501364175141700161550ustar00rootroot00000000000000require "active_support/core_ext/digest/uuid" module Ahoy class Tracker UUID_NAMESPACE = "a82ae811-5011-45ab-a728-569df7499c5f" attr_reader :request, :controller def initialize(**options) @store = Ahoy::Store.new(options.merge(ahoy: self)) @controller = options[:controller] @request = options[:request] || @controller.try(:request) @visit_token = options[:visit_token] @user = options[:user] @options = options end # can't use keyword arguments here def track(name, properties = {}, options = {}) if exclude? debug "Event excluded" elsif missing_params? debug "Missing required parameters" else data = { visit_token: visit_token, user_id: user.try(:id), name: name.to_s, properties: properties, time: trusted_time(options[:time]), event_id: options[:id] || generate_id }.select { |_, v| v } @store.track_event(data) end true rescue => e report_exception(e) end def track_visit(defer: false, started_at: nil) if exclude? debug "Visit excluded" elsif missing_params? debug "Missing required parameters" else if defer set_cookie("ahoy_track", true, nil, false) else delete_cookie("ahoy_track") data = { visit_token: visit_token, visitor_token: visitor_token, user_id: user.try(:id), started_at: trusted_time(started_at), }.merge(visit_properties).select { |_, v| v } @store.track_visit(data) Ahoy::GeocodeV2Job.perform_later(visit_token, data[:ip]) if Ahoy.geocode && data[:ip] end end true rescue => e report_exception(e) end def geocode(data) if exclude? debug "Geocode excluded" else data = { visit_token: visit_token }.merge(data).select { |_, v| v } @store.geocode(data) true end rescue => e report_exception(e) end def authenticate(user) if exclude? debug "Authentication excluded" else @store.user = user data = { visit_token: visit_token, user_id: user.try(:id) } @store.authenticate(data) end true rescue => e report_exception(e) end def visit @visit ||= @store.visit end def visit_or_create @visit ||= @store.visit_or_create end def new_visit? Ahoy.cookies ? !existing_visit_token : visit.nil? end def new_visitor? !existing_visitor_token end def set_visit_cookie set_cookie("ahoy_visit", visit_token, Ahoy.visit_duration) end def set_visitor_cookie if new_visitor? set_cookie("ahoy_visitor", visitor_token, Ahoy.visitor_duration) end end def user @user ||= @store.user end def visit_properties @visit_properties ||= request ? Ahoy::VisitProperties.new(request, api: api?).generate : {} end def visit_token @visit_token ||= ensure_token(visit_token_helper) end alias_method :visit_id, :visit_token def visitor_token @visitor_token ||= ensure_token(visitor_token_helper) end alias_method :visitor_id, :visitor_token def reset reset_visit delete_cookie("ahoy_visitor") end def reset_visit delete_cookie("ahoy_visit") delete_cookie("ahoy_events") delete_cookie("ahoy_track") end protected def api? @options[:api] end def missing_params? if Ahoy.cookies && api? && Ahoy.protect_from_forgery !(existing_visit_token && existing_visitor_token) else false end end def set_cookie(name, value, duration = nil, use_domain = true) # safety net return unless Ahoy.cookies && request cookie = Ahoy.cookie_options.merge(value: value) cookie[:expires] = duration.from_now if duration # prefer cookie_options[:domain] over cookie_domain cookie[:domain] ||= Ahoy.cookie_domain if Ahoy.cookie_domain cookie.delete(:domain) unless use_domain request.cookie_jar[name] = cookie end def delete_cookie(name) request.cookie_jar.delete(name) if request && request.cookie_jar[name] end def trusted_time(time = nil) if !time || (api? && !(1.minute.ago..Time.now).cover?(time)) Time.current else time end end def exclude? @store.exclude? end def report_exception(e) if defined?(ActionDispatch::RemoteIp::IpSpoofAttackError) && e.is_a?(ActionDispatch::RemoteIp::IpSpoofAttackError) debug "Tracking excluded due to IP spoofing" else raise e if !defined?(Rails) || Rails.env.development? || Rails.env.test? Safely.report_exception(e) end end def generate_id @store.generate_id end def visit_token_helper @visit_token_helper ||= begin token = existing_visit_token token ||= visit_anonymity_set unless Ahoy.cookies token ||= generate_id unless Ahoy.api_only token end end def visitor_token_helper @visitor_token_helper ||= begin token = existing_visitor_token token ||= visitor_anonymity_set unless Ahoy.cookies token ||= generate_id unless Ahoy.api_only token end end def existing_visit_token @existing_visit_token ||= begin token = visit_header token ||= visit_cookie if Ahoy.cookies && !(api? && Ahoy.protect_from_forgery) token ||= visit_param if api? token end end def existing_visitor_token @existing_visitor_token ||= begin token = visitor_header token ||= visitor_cookie if Ahoy.cookies && !(api? && Ahoy.protect_from_forgery) token ||= visitor_param if api? token end end def visit_anonymity_set @visit_anonymity_set ||= Digest::UUID.uuid_v5(UUID_NAMESPACE, ["visit", Ahoy.mask_ip(request.remote_ip), request.user_agent].join("/")) end def visitor_anonymity_set @visitor_anonymity_set ||= Digest::UUID.uuid_v5(UUID_NAMESPACE, ["visitor", Ahoy.mask_ip(request.remote_ip), request.user_agent].join("/")) end def visit_cookie @visit_cookie ||= request && request.cookies["ahoy_visit"] end def visitor_cookie @visitor_cookie ||= request && request.cookies["ahoy_visitor"] end def visit_header @visit_header ||= request && request.headers["Ahoy-Visit"] end def visitor_header @visitor_header ||= request && request.headers["Ahoy-Visitor"] end def visit_param @visit_param ||= request && request.params["visit_token"] end def visitor_param @visitor_param ||= request && request.params["visitor_token"] end def ensure_token(token) token = Ahoy::Utils.ensure_utf8(token) token.to_s.gsub(/[^a-z0-9\-]/i, "").first(64) if token end def debug(message) Ahoy.log message end end end ahoy-3.0.2/lib/ahoy/utils.rb000066400000000000000000000002471364175141700156630ustar00rootroot00000000000000module Ahoy module Utils def self.ensure_utf8(str) str.encode("UTF-8", "binary", invalid: :replace, undef: :replace, replace: "") if str end end end ahoy-3.0.2/lib/ahoy/version.rb000066400000000000000000000000441364175141700162030ustar00rootroot00000000000000module Ahoy VERSION = "3.0.2" end ahoy-3.0.2/lib/ahoy/visit_properties.rb000066400000000000000000000062721364175141700201410ustar00rootroot00000000000000require "cgi" require "device_detector" require "uri" module Ahoy class VisitProperties attr_reader :request, :params, :referrer, :landing_page def initialize(request, api:) @request = request @params = request.params @referrer = api ? params["referrer"] : request.referer @landing_page = api ? params["landing_page"] : request.original_url end def generate @generate ||= request_properties.merge(tech_properties).merge(traffic_properties).merge(utm_properties) end private def utm_properties landing_params = {} begin landing_uri = URI.parse(landing_page) # could also use Rack::Utils.parse_nested_query landing_params = CGI.parse(landing_uri.query) if landing_uri rescue # do nothing end props = {} %w(utm_source utm_medium utm_term utm_content utm_campaign).each do |name| props[name.to_sym] = params[name] || landing_params[name].try(:first) end props end def traffic_properties uri = URI.parse(referrer) rescue nil { referring_domain: uri.try(:host).try(:first, 255) } end def tech_properties if Ahoy.user_agent_parser == :device_detector client = DeviceDetector.new(request.user_agent) device_type = case client.device_type when "smartphone" "Mobile" when "tv" "TV" else client.device_type.try(:titleize) end { browser: client.name, os: client.os_name, device_type: device_type } else raise "Add browser to your Gemfile to use legacy user agent parsing" unless defined?(Browser) raise "Add user_agent_parser to your Gemfile to use legacy user agent parsing" unless defined?(UserAgentParser) # cache for performance @@user_agent_parser ||= UserAgentParser::Parser.new user_agent = request.user_agent agent = @@user_agent_parser.parse(user_agent) browser = Browser.new(user_agent) device_type = if browser.bot? "Bot" elsif browser.device.tv? "TV" elsif browser.device.console? "Console" elsif browser.device.tablet? "Tablet" elsif browser.device.mobile? "Mobile" else "Desktop" end { browser: agent.name, os: agent.os.name, device_type: device_type } end end # masking based on Google Analytics anonymization # https://support.google.com/analytics/answer/2763052 def ip ip = request.remote_ip if ip && Ahoy.mask_ips Ahoy.mask_ip(ip) else ip end end def request_properties { ip: ip, user_agent: Ahoy::Utils.ensure_utf8(request.user_agent), referrer: referrer, landing_page: landing_page, platform: params["platform"], app_version: params["app_version"], os_version: params["os_version"], screen_height: params["screen_height"], screen_width: params["screen_width"] } end end end ahoy-3.0.2/lib/ahoy/warden.rb000066400000000000000000000002761364175141700160050ustar00rootroot00000000000000Warden::Manager.after_set_user except: :fetch do |user, auth, _| request = ActionDispatch::Request.new(auth.env) ahoy = Ahoy::Tracker.new(request: request) ahoy.authenticate(user) end ahoy-3.0.2/lib/ahoy_matey.rb000066400000000000000000000000171364175141700157150ustar00rootroot00000000000000require "ahoy" ahoy-3.0.2/lib/generators/000077500000000000000000000000001364175141700154045ustar00rootroot00000000000000ahoy-3.0.2/lib/generators/ahoy/000077500000000000000000000000001364175141700163445ustar00rootroot00000000000000ahoy-3.0.2/lib/generators/ahoy/activerecord_generator.rb000066400000000000000000000024561364175141700234200ustar00rootroot00000000000000require "rails/generators/active_record" module Ahoy module Generators class ActiverecordGenerator < Rails::Generators::Base include ActiveRecord::Generators::Migration source_root File.join(__dir__, "templates") class_option :database, type: :string, aliases: "-d" def copy_templates template "database_store_initializer.rb", "config/initializers/ahoy.rb" template "active_record_visit_model.rb", "app/models/ahoy/visit.rb" template "active_record_event_model.rb", "app/models/ahoy/event.rb" migration_template "active_record_migration.rb", "db/migrate/create_ahoy_visits_and_events.rb", migration_version: migration_version puts "\nAlmost set! Last, run:\n\n rails db:migrate" end def properties_type # use connection_config instead of connection.adapter # so database connection isn't needed case ActiveRecord::Base.connection_config[:adapter].to_s when /postg/i # postgres, postgis "jsonb" when /mysql/i "json" else "text" end end def rails52? ActiveRecord::VERSION::STRING >= "5.2" end def migration_version "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" end end end end ahoy-3.0.2/lib/generators/ahoy/base_generator.rb000066400000000000000000000004421364175141700216510ustar00rootroot00000000000000require "rails/generators" module Ahoy module Generators class BaseGenerator < Rails::Generators::Base source_root File.join(__dir__, "templates") def copy_templates template "base_store_initializer.rb", "config/initializers/ahoy.rb" end end end end ahoy-3.0.2/lib/generators/ahoy/install_generator.rb000066400000000000000000000015761364175141700224160ustar00rootroot00000000000000require "rails/generators" module Ahoy module Generators class InstallGenerator < Rails::Generators::Base source_root File.join(__dir__, "templates") def copy_templates activerecord = defined?(ActiveRecord) mongoid = defined?(Mongoid) selection = if activerecord && mongoid puts <<-MSG Which data store would you like to use? 1. ActiveRecord (default) 2. Mongoid 3. Neither MSG ask(">") elsif activerecord "1" elsif mongoid "2" else "3" end case selection when "", "1" invoke "ahoy:activerecord" when "2" invoke "ahoy:mongoid" when "3" invoke "ahoy:base" else abort "Error: must enter a number [1-3]" end end end end end ahoy-3.0.2/lib/generators/ahoy/mongoid_generator.rb000066400000000000000000000010031364175141700223650ustar00rootroot00000000000000require "rails/generators" module Ahoy module Generators class MongoidGenerator < Rails::Generators::Base source_root File.join(__dir__, "templates") def copy_templates template "database_store_initializer.rb", "config/initializers/ahoy.rb" template "mongoid_visit_model.rb", "app/models/ahoy/visit.rb" template "mongoid_event_model.rb", "app/models/ahoy/event.rb" puts "\nAlmost set! Last, run:\n\n rake db:mongoid:create_indexes" end end end end ahoy-3.0.2/lib/generators/ahoy/templates/000077500000000000000000000000001364175141700203425ustar00rootroot00000000000000ahoy-3.0.2/lib/generators/ahoy/templates/active_record_event_model.rb.tt000066400000000000000000000003541364175141700265110ustar00rootroot00000000000000class Ahoy::Event < ApplicationRecord include Ahoy::QueryMethods self.table_name = "ahoy_events" belongs_to :visit belongs_to :user, optional: true<% if properties_type == "text" %> serialize :properties, JSON<% end %> end ahoy-3.0.2/lib/generators/ahoy/templates/active_record_migration.rb.tt000066400000000000000000000027551364175141700262100ustar00rootroot00000000000000class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> def change create_table :ahoy_visits do |t| t.string :visit_token t.string :visitor_token # the rest are recommended but optional # simply remove any you don't want # user t.references :user # standard t.string :ip t.text :user_agent t.text :referrer t.string :referring_domain 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.float :latitude t.float :longitude # utm parameters t.string :utm_source t.string :utm_medium t.string :utm_term t.string :utm_content t.string :utm_campaign # native apps t.string :app_version t.string :os_version t.string :platform t.timestamp :started_at end add_index :ahoy_visits, [:visit_token], unique: true create_table :ahoy_events do |t| t.references :visit t.references :user t.string :name t.<%= properties_type %> :properties t.timestamp :time end add_index :ahoy_events, [:name, :time]<% if properties_type == "jsonb" %><% if rails52? %> add_index :ahoy_events, :properties, using: :gin, opclass: :jsonb_path_ops<% else %> add_index :ahoy_events, "properties jsonb_path_ops", using: "gin"<% end %><% end %> end end ahoy-3.0.2/lib/generators/ahoy/templates/active_record_visit_model.rb.tt000066400000000000000000000002361364175141700265250ustar00rootroot00000000000000class Ahoy::Visit < ApplicationRecord self.table_name = "ahoy_visits" has_many :events, class_name: "Ahoy::Event" belongs_to :user, optional: true end ahoy-3.0.2/lib/generators/ahoy/templates/base_store_initializer.rb.tt000066400000000000000000000004111364175141700260420ustar00rootroot00000000000000class Ahoy::Store < Ahoy::BaseStore def track_visit(data) # do end def track_event(data) # something end def geocode(data) # amazing end def authenticate(data) # !!! end end # set to true for JavaScript tracking Ahoy.api = false ahoy-3.0.2/lib/generators/ahoy/templates/database_store_initializer.rb.tt000066400000000000000000000001441364175141700266770ustar00rootroot00000000000000class Ahoy::Store < Ahoy::DatabaseStore end # set to true for JavaScript tracking Ahoy.api = false ahoy-3.0.2/lib/generators/ahoy/templates/mongoid_event_model.rb.tt000066400000000000000000000004241364175141700253320ustar00rootroot00000000000000class Ahoy::Event include Mongoid::Document # associations belongs_to :visit, index: true belongs_to :user, index: true, optional: true # fields field :name, type: String field :properties, type: Hash field :time, type: Time index({name: 1, time: 1}) end ahoy-3.0.2/lib/generators/ahoy/templates/mongoid_visit_model.rb.tt000066400000000000000000000022311364175141700253450ustar00rootroot00000000000000class Ahoy::Visit include Mongoid::Document # associations has_many :events, class_name: "Ahoy::Event" belongs_to :user, index: true, optional: true # required field :visit_token, type: String field :visitor_token, type: String # the rest are recommended but optional # simply remove the columns you don't want # standard field :ip, type: String field :user_agent, type: String field :referrer, type: String field :referring_domain, type: String field :landing_page, type: String # technology field :browser, type: String field :os, type: String field :device_type, type: String # location field :country, type: String field :region, type: String field :city, type: String field :latitude, type: Float field :longitude, type: Float # utm parameters field :utm_source, type: String field :utm_medium, type: String field :utm_term, type: String field :utm_content, type: String field :utm_campaign, type: String # native apps field :app_version, type: String field :os_version, type: String field :platform, type: String field :started_at, type: Time index({visit_token: 1}, {unique: true}) end ahoy-3.0.2/test/000077500000000000000000000000001364175141700134445ustar00rootroot00000000000000ahoy-3.0.2/test/controller_test.rb000066400000000000000000000022441364175141700172150ustar00rootroot00000000000000require_relative "test_helper" class ControllerTest < ActionDispatch::IntegrationTest def setup Ahoy::Visit.delete_all Ahoy::Event.delete_all end def test_works get products_url assert :success assert_equal 1, Ahoy::Visit.count assert_equal 1, Ahoy::Event.count event = Ahoy::Event.last assert_equal "Viewed products", event.name end def test_bad_visit_cookie make_request(cookies: {"ahoy_visit" => "badtoken\255"}) assert_equal ahoy.visit_token, "badtoken" end def test_bad_visitor_cookie make_request(cookies: {"ahoy_visitor" => "badtoken\255"}) assert_equal ahoy.visitor_token, "badtoken" end def test_bad_visit_header make_request(headers: {"Ahoy-Visit" => "badtoken\255"}) assert_equal ahoy.visit_token, "badtoken" end def test_bad_visitor_header make_request(headers: {"Ahoy-Visitor" => "badtoken\255"}) assert_equal ahoy.visitor_token, "badtoken" end private def make_request(cookies: {}, headers: {}) cookies.each do |k, v| self.cookies[k] = v end get products_url, headers: headers assert_response :success end def ahoy controller.ahoy end end ahoy-3.0.2/test/gemfiles/000077500000000000000000000000001364175141700152375ustar00rootroot00000000000000ahoy-3.0.2/test/gemfiles/rails50.gemfile000066400000000000000000000001511364175141700200450ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: "../../" gem "rails", "~> 5.0.0" gem "sqlite3", "~> 1.3.0" ahoy-3.0.2/test/gemfiles/rails51.gemfile000066400000000000000000000001171364175141700200500ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: "../../" gem "rails", "~> 5.1.0" ahoy-3.0.2/test/gemfiles/rails52.gemfile000066400000000000000000000001171364175141700200510ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: "../../" gem "rails", "~> 5.2.0" ahoy-3.0.2/test/internal/000077500000000000000000000000001364175141700152605ustar00rootroot00000000000000ahoy-3.0.2/test/internal/app/000077500000000000000000000000001364175141700160405ustar00rootroot00000000000000ahoy-3.0.2/test/internal/app/controllers/000077500000000000000000000000001364175141700204065ustar00rootroot00000000000000ahoy-3.0.2/test/internal/app/controllers/products_controller.rb000066400000000000000000000001751364175141700250440ustar00rootroot00000000000000class ProductsController < ActionController::Base def index ahoy.track "Viewed products" render json: {} end end ahoy-3.0.2/test/internal/app/models/000077500000000000000000000000001364175141700173235ustar00rootroot00000000000000ahoy-3.0.2/test/internal/app/models/ahoy/000077500000000000000000000000001364175141700202635ustar00rootroot00000000000000ahoy-3.0.2/test/internal/app/models/ahoy/event.rb000066400000000000000000000003011364175141700217230ustar00rootroot00000000000000class Ahoy::Event < ApplicationRecord include Ahoy::QueryMethods self.table_name = "ahoy_events" belongs_to :visit belongs_to :user, optional: true serialize :properties, JSON end ahoy-3.0.2/test/internal/app/models/ahoy/visit.rb000066400000000000000000000002361364175141700217470ustar00rootroot00000000000000class Ahoy::Visit < ApplicationRecord self.table_name = "ahoy_visits" has_many :events, class_name: "Ahoy::Event" belongs_to :user, optional: true end ahoy-3.0.2/test/internal/app/models/application_record.rb000066400000000000000000000001161364175141700235070ustar00rootroot00000000000000class ApplicationRecord < ActiveRecord::Base self.abstract_class = true end ahoy-3.0.2/test/internal/config/000077500000000000000000000000001364175141700165255ustar00rootroot00000000000000ahoy-3.0.2/test/internal/config/database.yml000066400000000000000000000001001364175141700210030ustar00rootroot00000000000000test: adapter: sqlite3 database: db/combustion_test.sqlite ahoy-3.0.2/test/internal/config/initializers/000077500000000000000000000000001364175141700212335ustar00rootroot00000000000000ahoy-3.0.2/test/internal/config/initializers/ahoy.rb000066400000000000000000000001041364175141700225130ustar00rootroot00000000000000class Ahoy::Store < Ahoy::DatabaseStore end Ahoy.track_bots = true ahoy-3.0.2/test/internal/config/routes.rb000066400000000000000000000001131364175141700203660ustar00rootroot00000000000000Rails.application.routes.draw do resources :products, only: [:index] end ahoy-3.0.2/test/internal/db/000077500000000000000000000000001364175141700156455ustar00rootroot00000000000000ahoy-3.0.2/test/internal/db/schema.rb000066400000000000000000000021261364175141700174330ustar00rootroot00000000000000ActiveRecord::Schema.define do create_table :ahoy_visits do |t| t.string :visit_token t.string :visitor_token # the rest are recommended but optional # simply remove any you don't want # user t.references :user # standard t.string :ip t.text :user_agent t.text :referrer t.string :referring_domain 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.float :latitude t.float :longitude # utm parameters t.string :utm_source t.string :utm_medium t.string :utm_term t.string :utm_content t.string :utm_campaign # native apps t.string :app_version t.string :os_version t.string :platform t.timestamp :started_at end add_index :ahoy_visits, [:visit_token], unique: true create_table :ahoy_events do |t| t.references :visit t.references :user t.string :name t.text :properties t.timestamp :time end add_index :ahoy_events, [:name, :time] end ahoy-3.0.2/test/query_methods/000077500000000000000000000000001364175141700163345ustar00rootroot00000000000000ahoy-3.0.2/test/query_methods/mongoid_test.rb000066400000000000000000000003731364175141700213570ustar00rootroot00000000000000require_relative "../test_helper" class MongoidEvent include Mongoid::Document include Ahoy::QueryMethods field :properties, type: Hash end class MongoidTest < Minitest::Test include QueryMethodsTest def model MongoidEvent end end ahoy-3.0.2/test/query_methods/mysql_json_test.rb000066400000000000000000000003571364175141700221230ustar00rootroot00000000000000require_relative "../test_helper" class MysqlJsonEvent < MysqlBase serialize :properties, JSON if connection.send(:mariadb?) end class MysqlJsonTest < Minitest::Test include QueryMethodsTest def model MysqlJsonEvent end end ahoy-3.0.2/test/query_methods/mysql_text_test.rb000066400000000000000000000003211364175141700221250ustar00rootroot00000000000000require_relative "../test_helper" class MysqlTextEvent < MysqlBase serialize :properties, JSON end class MysqlTextTest < Minitest::Test include QueryMethodsTest def model MysqlTextEvent end end ahoy-3.0.2/test/query_methods/postgresql_hstore_test.rb000066400000000000000000000003151364175141700235060ustar00rootroot00000000000000require_relative "../test_helper" class PostgresqlHstoreEvent < PostgresqlBase end class PostgresqlHstoreTest < Minitest::Test include QueryMethodsTest def model PostgresqlHstoreEvent end end ahoy-3.0.2/test/query_methods/postgresql_json_test.rb000066400000000000000000000003071364175141700231540ustar00rootroot00000000000000require_relative "../test_helper" class PostgresqlJsonEvent < PostgresqlBase end class PostgresqlJsonTest < Minitest::Test include QueryMethodsTest def model PostgresqlJsonEvent end end ahoy-3.0.2/test/query_methods/postgresql_jsonb_test.rb000066400000000000000000000003121364175141700233120ustar00rootroot00000000000000require_relative "../test_helper" class PostgresqlJsonbEvent < PostgresqlBase end class PostgresqlJsonbTest < Minitest::Test include QueryMethodsTest def model PostgresqlJsonbEvent end end ahoy-3.0.2/test/query_methods/postgresql_text_test.rb000066400000000000000000000003451364175141700231710ustar00rootroot00000000000000require_relative "../test_helper" class PostgresqlTextEvent < PostgresqlBase serialize :properties, JSON end class PostgresqlTextTest < Minitest::Test include QueryMethodsTest def model PostgresqlTextEvent end end ahoy-3.0.2/test/support/000077500000000000000000000000001364175141700151605ustar00rootroot00000000000000ahoy-3.0.2/test/support/mongoid.rb000066400000000000000000000002571364175141700171450ustar00rootroot00000000000000Mongoid.logger.level = Logger::WARN Mongo::Logger.logger.level = Logger::WARN Mongoid.configure do |config| config.connect_to("ahoy_test", server_selection_timeout: 1) end ahoy-3.0.2/test/support/mysql.rb000066400000000000000000000007451364175141700166600ustar00rootroot00000000000000ActiveRecord::Base.establish_connection adapter: "mysql2", username: "root", database: "ahoy_test" ActiveRecord::Migration.create_table :mysql_text_events, force: true do |t| t.text :properties end ActiveRecord::Migration.create_table :mysql_json_events, force: true do |t| t.json :properties end class MysqlBase < ActiveRecord::Base include Ahoy::QueryMethods establish_connection adapter: "mysql2", username: "root", database: "ahoy_test" self.abstract_class = true end ahoy-3.0.2/test/support/postgresql.rb000066400000000000000000000014121364175141700177060ustar00rootroot00000000000000ActiveRecord::Base.establish_connection adapter: "postgresql", database: "ahoy_test" ActiveRecord::Migration.enable_extension "hstore" ActiveRecord::Migration.create_table :postgresql_hstore_events, force: true do |t| t.hstore :properties end ActiveRecord::Migration.create_table :postgresql_json_events, force: true do |t| t.json :properties end ActiveRecord::Migration.create_table :postgresql_jsonb_events, force: true do |t| t.jsonb :properties t.index :properties, using: :gin end ActiveRecord::Migration.create_table :postgresql_text_events, force: true do |t| t.text :properties end class PostgresqlBase < ActiveRecord::Base include Ahoy::QueryMethods establish_connection adapter: "postgresql", database: "ahoy_test" self.abstract_class = true end ahoy-3.0.2/test/support/query_methods_test.rb000066400000000000000000000031241364175141700214340ustar00rootroot00000000000000module QueryMethodsTest def setup model.delete_all end def test_empty assert_equal 0, count_events({}) end def test_string create_event value: "world" assert_equal 1, count_events(value: "world") end def test_number create_event value: 1 assert_equal 1, count_events(value: 1) end def test_date today = Date.today create_event value: today assert_equal 1, count_events(value: today) end def test_time now = Time.now create_event value: now assert_equal 1, count_events(value: now) end def test_true create_event value: true assert_equal 1, count_events(value: true) end def test_false create_event value: false assert_equal 1, count_events(value: false) end def test_nil create_event value: nil assert_equal 1, count_events(value: nil) end def test_any create_event hello: "world", prop2: "hi" assert_equal 1, count_events(hello: "world") end def test_multiple create_event prop1: "hi", prop2: "bye" assert_equal 1, count_events(prop1: "hi", prop2: "bye") end def test_multiple_order create_event prop2: "bye", prop1: "hi" assert_equal 1, count_events(prop1: "hi", prop2: "bye") end def test_partial create_event hello: "world" assert_equal 0, count_events(hello: "world", prop2: "hi") end def test_prefix create_event value: 123 assert_equal 0, count_events(value: 1) end def create_event(properties) model.create(properties: properties) end def count_events(properties) model.where_properties(properties).count end end ahoy-3.0.2/test/test_helper.rb000066400000000000000000000015521364175141700163120ustar00rootroot00000000000000require "bundler/setup" require "combustion" Bundler.require(:default) require "minitest/autorun" require "minitest/pride" require "active_record" require "mongoid" Combustion.path = "test/internal" Combustion.initialize! :active_record, :action_controller, :active_job do if ActiveRecord::VERSION::MAJOR < 6 && config.active_record.sqlite3.respond_to?(:represent_boolean_as_integer) config.active_record.sqlite3.represent_boolean_as_integer = true end logger = ActiveSupport::Logger.new(STDOUT) config.active_record.logger = logger if ENV["VERBOSE"] config.action_mailer.logger = logger if ENV["VERBOSE"] end # run setup / migrations require_relative "support/mysql" require_relative "support/postgresql" require_relative "support/mongoid" # restore connection ActiveRecord::Base.establish_connection(:test) require_relative "support/query_methods_test" ahoy-3.0.2/test/tracker_test.rb000066400000000000000000000005101364175141700164570ustar00rootroot00000000000000require_relative "test_helper" class TrackerTest < Minitest::Test def test_no_request ahoy = Ahoy::Tracker.new assert ahoy.track("Some event", some_prop: true) end def test_user_option user = OpenStruct.new(id: "123") ahoy = Ahoy::Tracker.new(user: user) assert_equal ahoy.user.id, user.id end end ahoy-3.0.2/vendor/000077500000000000000000000000001364175141700137625ustar00rootroot00000000000000ahoy-3.0.2/vendor/assets/000077500000000000000000000000001364175141700152645ustar00rootroot00000000000000ahoy-3.0.2/vendor/assets/javascripts/000077500000000000000000000000001364175141700176155ustar00rootroot00000000000000ahoy-3.0.2/vendor/assets/javascripts/ahoy.js000066400000000000000000000352731364175141700211250ustar00rootroot00000000000000/* * Ahoy.js * Simple, powerful JavaScript analytics * https://github.com/ankane/ahoy.js * v0.3.4 * MIT License */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.ahoy = factory()); }(this, (function () { 'use strict'; function isUndefined(value) { return value === undefined; } function isNull(value) { return value === null; } function isObject(value) { return value === Object(value); } function isArray(value) { return Array.isArray(value); } function isDate(value) { return value instanceof Date; } function isBlob(value) { return ( value && typeof value.size === 'number' && typeof value.type === 'string' && typeof value.slice === 'function' ); } function isFile(value) { return ( isBlob(value) && (typeof value.lastModifiedDate === 'object' || typeof value.lastModified === 'number') && typeof value.name === 'string' ); } function isFormData(value) { return value instanceof FormData; } function objectToFormData(obj, cfg, fd, pre) { if (isFormData(cfg)) { pre = fd; fd = cfg; cfg = null; } cfg = cfg || {}; cfg.indices = isUndefined(cfg.indices) ? false : cfg.indices; cfg.nulls = isUndefined(cfg.nulls) ? true : cfg.nulls; fd = fd || new FormData(); if (isUndefined(obj)) { return fd; } else if (isNull(obj)) { if (cfg.nulls) { fd.append(pre, ''); } } else if (isArray(obj)) { if (!obj.length) { var key = pre + '[]'; fd.append(key, ''); } else { obj.forEach(function(value, index) { var key = pre + '[' + (cfg.indices ? index : '') + ']'; objectToFormData(value, cfg, fd, key); }); } } else if (isDate(obj)) { fd.append(pre, obj.toISOString()); } else if (isObject(obj) && !isFile(obj) && !isBlob(obj)) { Object.keys(obj).forEach(function(prop) { var value = obj[prop]; if (isArray(value)) { while (prop.length > 2 && prop.lastIndexOf('[]') === prop.length - 2) { prop = prop.substring(0, prop.length - 2); } } var key = pre ? pre + '[' + prop + ']' : prop; objectToFormData(value, cfg, fd, key); }); } else { fd.append(pre, obj); } return fd; } var objectToFormdata = objectToFormData; // https://www.quirksmode.org/js/cookies.html var Cookies = { set: function (name, value, ttl, domain) { var expires = ""; var cookieDomain = ""; if (ttl) { var date = new Date(); date.setTime(date.getTime() + (ttl * 60 * 1000)); expires = "; expires=" + date.toGMTString(); } if (domain) { cookieDomain = "; domain=" + domain; } document.cookie = name + "=" + escape(value) + expires + cookieDomain + "; path=/"; }, get: function (name) { var i, c; var nameEQ = name + "="; var ca = document.cookie.split(';'); for (i = 0; i < ca.length; i++) { c = ca[i]; while (c.charAt(0) === ' ') { c = c.substring(1, c.length); } if (c.indexOf(nameEQ) === 0) { return unescape(c.substring(nameEQ.length, c.length)); } } return null; } }; var config = { urlPrefix: "", visitsUrl: "/ahoy/visits", eventsUrl: "/ahoy/events", page: null, platform: "Web", useBeacon: true, startOnReady: true, trackVisits: true, cookies: true, cookieDomain: null, headers: {}, visitParams: {}, withCredentials: false }; var ahoy = window.ahoy || window.Ahoy || {}; ahoy.configure = function (options) { for (var key in options) { if (options.hasOwnProperty(key)) { config[key] = options[key]; } } }; // legacy ahoy.configure(ahoy); var $ = window.jQuery || window.Zepto || window.$; var visitId, visitorId, track; var visitTtl = 4 * 60; // 4 hours var visitorTtl = 2 * 365 * 24 * 60; // 2 years var isReady = false; var queue = []; var canStringify = typeof(JSON) !== "undefined" && typeof(JSON.stringify) !== "undefined"; var eventQueue = []; function visitsUrl() { return config.urlPrefix + config.visitsUrl; } function eventsUrl() { return config.urlPrefix + config.eventsUrl; } function isEmpty(obj) { return Object.keys(obj).length === 0; } function canTrackNow() { return (config.useBeacon || config.trackNow) && isEmpty(config.headers) && canStringify && typeof(window.navigator.sendBeacon) !== "undefined" && !config.withCredentials; } // cookies function setCookie(name, value, ttl) { Cookies.set(name, value, ttl, config.cookieDomain || config.domain); } function getCookie(name) { return Cookies.get(name); } function destroyCookie(name) { Cookies.set(name, "", -1); } function log(message) { if (getCookie("ahoy_debug")) { window.console.log(message); } } function setReady() { var callback; while ((callback = queue.shift())) { callback(); } isReady = true; } function ready(callback) { if (isReady) { callback(); } else { queue.push(callback); } } function matchesSelector(element, selector) { var matches = element.matches || element.matchesSelector || element.mozMatchesSelector || element.msMatchesSelector || element.oMatchesSelector || element.webkitMatchesSelector; if (matches) { return matches.apply(element, [selector]); } else { log("Unable to match"); return false; } } function onEvent(eventName, selector, callback) { document.addEventListener(eventName, function (e) { if (matchesSelector(e.target, selector)) { callback(e); } }); } // http://beeker.io/jquery-document-ready-equivalent-vanilla-javascript function documentReady(callback) { document.readyState === "interactive" || document.readyState === "complete" ? callback() : document.addEventListener("DOMContentLoaded", callback); } // https://stackoverflow.com/a/2117523/1177228 function generateId() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); return v.toString(16); }); } function saveEventQueue() { if (config.cookies && canStringify) { setCookie("ahoy_events", JSON.stringify(eventQueue), 1); } } // from rails-ujs function csrfToken() { var meta = document.querySelector("meta[name=csrf-token]"); return meta && meta.content; } function csrfParam() { var meta = document.querySelector("meta[name=csrf-param]"); return meta && meta.content; } function CSRFProtection(xhr) { var token = csrfToken(); if (token) { xhr.setRequestHeader("X-CSRF-Token", token); } } function sendRequest(url, data, success) { if (canStringify) { if ($) { $.ajax({ type: "POST", url: url, data: JSON.stringify(data), contentType: "application/json; charset=utf-8", dataType: "json", beforeSend: CSRFProtection, success: success, headers: config.headers, xhrFields: { withCredentials: config.withCredentials } }); } else { var xhr = new XMLHttpRequest(); xhr.open("POST", url, true); xhr.withCredentials = config.withCredentials; xhr.setRequestHeader("Content-Type", "application/json"); for (var header in config.headers) { if (config.headers.hasOwnProperty(header)) { xhr.setRequestHeader(header, config.headers[header]); } } xhr.onload = function() { if (xhr.status === 200) { success(); } }; CSRFProtection(xhr); xhr.send(JSON.stringify(data)); } } } function eventData(event) { var data = { events: [event] }; if (config.cookies) { data.visit_token = event.visit_token; data.visitor_token = event.visitor_token; } delete event.visit_token; delete event.visitor_token; return data; } function trackEvent(event) { ready( function () { sendRequest(eventsUrl(), eventData(event), 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 trackEventNow(event) { ready( function () { var data = eventData(event); var param = csrfParam(); var token = csrfToken(); if (param && token) { data[param] = token; } // stringify so we keep the type data.events_json = JSON.stringify(data.events); delete data.events; window.navigator.sendBeacon(eventsUrl(), objectToFormdata(data)); }); } function page() { return config.page || window.location.pathname; } function presence(str) { return (str && str.length > 0) ? str : null; } function cleanObject(obj) { for (var key in obj) { if (obj.hasOwnProperty(key)) { if (obj[key] === null) { delete obj[key]; } } } return obj; } function eventProperties(e) { var target = e.target; return cleanObject({ tag: target.tagName.toLowerCase(), id: presence(target.id), "class": presence(target.className), page: page(), section: getClosestSection(target) }); } function getClosestSection(element) { for ( ; element && element !== document; element = element.parentNode) { if (element.hasAttribute('data-section')) { return element.getAttribute('data-section'); } } return null; } function createVisit() { isReady = false; visitId = ahoy.getVisitId(); visitorId = ahoy.getVisitorId(); track = getCookie("ahoy_track"); if (config.cookies === false || config.trackVisits === false) { log("Visit tracking disabled"); setReady(); } else if (visitId && visitorId && !track) { // TODO keep visit alive? log("Active visit"); setReady(); } else { if (!visitId) { visitId = generateId(); setCookie("ahoy_visit", visitId, visitTtl); } // make sure cookies are enabled if (getCookie("ahoy_visit")) { log("Visit started"); if (!visitorId) { visitorId = generateId(); setCookie("ahoy_visitor", visitorId, visitorTtl); } var data = { visit_token: visitId, visitor_token: visitorId, platform: config.platform, landing_page: window.location.href, screen_width: window.screen.width, screen_height: window.screen.height, js: true }; // referrer if (document.referrer.length > 0) { data.referrer = document.referrer; } for (var key in config.visitParams) { if (config.visitParams.hasOwnProperty(key)) { data[key] = config.visitParams[key]; } } log(data); sendRequest(visitsUrl(), data, function () { // wait until successful to destroy destroyCookie("ahoy_track"); setReady(); }); } else { log("Cookies disabled"); setReady(); } } } ahoy.getVisitId = ahoy.getVisitToken = function () { return getCookie("ahoy_visit"); }; ahoy.getVisitorId = ahoy.getVisitorToken = function () { return getCookie("ahoy_visitor"); }; ahoy.reset = function () { destroyCookie("ahoy_visit"); destroyCookie("ahoy_visitor"); destroyCookie("ahoy_events"); destroyCookie("ahoy_track"); return true; }; ahoy.debug = function (enabled) { if (enabled === false) { destroyCookie("ahoy_debug"); } else { setCookie("ahoy_debug", "t", 365 * 24 * 60); // 1 year } return true; }; ahoy.track = function (name, properties) { // generate unique id var event = { name: name, properties: properties || {}, time: (new Date()).getTime() / 1000.0, id: generateId(), js: true }; ready( function () { if (config.cookies && !ahoy.getVisitId()) { createVisit(); } ready( function () { log(event); event.visit_token = ahoy.getVisitId(); event.visitor_token = ahoy.getVisitorId(); if (canTrackNow()) { trackEventNow(event); } else { eventQueue.push(event); saveEventQueue(); // wait in case navigating to reduce duplicate events setTimeout( function () { trackEvent(event); }, 1000); } }); }); return true; }; ahoy.trackView = function (additionalProperties) { var properties = { url: window.location.href, title: document.title, page: page() }; if (additionalProperties) { for(var propName in additionalProperties) { if (additionalProperties.hasOwnProperty(propName)) { properties[propName] = additionalProperties[propName]; } } } ahoy.track("$view", properties); }; ahoy.trackClicks = function () { onEvent("click", "a, button, input[type=submit]", function (e) { var target = e.target; var properties = eventProperties(e); properties.text = properties.tag == "input" ? target.value : (target.textContent || target.innerText || target.innerHTML).replace(/[\s\r\n]+/g, " ").trim(); properties.href = target.href; ahoy.track("$click", properties); }); }; ahoy.trackSubmits = function () { onEvent("submit", "form", function (e) { var properties = eventProperties(e); ahoy.track("$submit", properties); }); }; ahoy.trackChanges = function () { onEvent("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]); } ahoy.start = function () { createVisit(); ahoy.start = function () {}; }; documentReady(function() { if (config.startOnReady) { ahoy.start(); } }); return ahoy; })));