New Upstream Release - ruby-ahoy-matey

Ready changes

Summary

Merged new upstream version: 4.2.1 (was: 4.1.0).

Resulting package

Built on 2023-05-22T08:40 (took 4m51s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases ruby-ahoy-matey

Lintian Result

Diff

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..7837119
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,47 @@
+name: build
+on: [push, pull_request]
+jobs:
+  build:
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - ruby: 3.2
+            gemfile: Gemfile
+          - ruby: 3.1
+            gemfile: Gemfile
+          - ruby: "3.0"
+            gemfile: gemfiles/rails61.gemfile
+          - ruby: 2.7
+            gemfile: gemfiles/rails60.gemfile
+          - ruby: 2.6
+            gemfile: gemfiles/rails52.gemfile
+    runs-on: ubuntu-latest
+    env:
+      BUNDLE_GEMFILE: ${{ matrix.gemfile }}
+    steps:
+      - uses: actions/checkout@v3
+      - uses: ruby/setup-ruby@v1
+        with:
+          ruby-version: ${{ matrix.ruby }}
+          bundler-cache: true
+
+      - run: bundle exec rake test
+
+      - uses: ankane/setup-postgres@v1
+        with:
+          database: ahoy_test
+      - run: ADAPTER=postgresql bundle exec rake test
+
+      - uses: ankane/setup-mysql@v1
+        with:
+          database: ahoy_test
+      - run: ADAPTER=mysql2 bundle exec rake test
+
+      - uses: ankane/setup-mariadb@v1
+        with:
+          database: ahoy_test
+      - run: ADAPTER=mysql2 bundle exec rake test
+
+      - uses: ankane/setup-mongodb@v1
+      - run: ADAPTER=mongoid bundle exec rake test
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 426c6be..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,26 +0,0 @@
-dist: 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3baf17..754508f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,72 @@
+## 4.2.1 (2023-02-23)
+
+- Updated Ahoy.js to 0.4.2
+
+## 4.2.0 (2023-02-07)
+
+- Added primary key type to generated migration
+- Updated Ahoy.js to 0.4.1
+
+## 4.1.0 (2022-06-12)
+
+- Ensure `exclude_method` is only called once per request
+- Fixed error with Mongoid when `Mongoid.raise_not_found_error` is `true`
+- Fixed association for Mongoid
+
+## 4.0.3 (2022-01-15)
+
+- Support for `importmap-rails` is no longer experimental
+- Fixed asset precompilation error with `importmap-rails`
+
+## 4.0.2 (2021-11-06)
+
+- Added experimental support for `importmap-rails`
+
+## 4.0.1 (2021-08-18)
+
+- Added support for `where_event`, `where_props`, and `where_group` for SQLite
+- Fixed results with `where_event` for MySQL, MariaDB, and Postgres `hstore`
+- Fixed results with `where_props` and `where_group` when used with other scopes for MySQL, MariaDB, and Postgres `hstore`
+
+## 4.0.0 (2021-08-14)
+
+- Disabled geocoding by default (this was already the case for new installations with 3.2.0+)
+- Made the `geocoder` gem an optional dependency
+- Updated Ahoy.js to 0.4.0
+- Updated API to return 400 status code when missing required parameters
+- Dropped support for Ruby < 2.6 and Rails < 5.2
+
+## 3.3.0 (2021-08-13)
+
+- Added `country_code` to geocoding
+- Updated Ahoy.js to 0.3.9
+- Fixed install generator for MariaDB
+
+## 3.2.0 (2021-03-01)
+
+- Disabled geocoding by default for new installations
+- Fixed deprecation warning with Active Record 6.1
+
+## 3.1.0 (2020-12-04)
+
+- Added `instance` method
+- Added `request` argument to `user_method`
+- Updated Ahoy.js to 0.3.8
+- Removed `exclude_method` call when geocoding
+
+## 3.0.5 (2020-09-09)
+
+- Added `group_prop` method
+- Use `datetime` type in migration
+
+## 3.0.4 (2020-06-07)
+
+- Updated Ahoy.js to 0.3.6
+
+## 3.0.3 (2020-04-17)
+
+- Updated Ahoy.js to 0.3.5
+
 ## 3.0.2 (2020-04-03)
 
 - Added `cookie_options`
diff --git a/Gemfile b/Gemfile
index 01c0c53..7a04528 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,6 +1,17 @@
 source "https://rubygems.org"
 
-# Specify your gem's dependencies in ahoy.gemspec
 gemspec
 
-gem "rails", "~> 6.0.0"
+gem "rails", "~> 7.0.0"
+gem "rake"
+gem "minitest"
+gem "combustion"
+gem "sqlite3"
+gem "pg"
+gem "mysql2"
+gem "mongoid"
+gem "browser", "~> 2.0"
+gem "user_agent_parser"
+
+# for https://github.com/ruby/cgi/pull/29
+gem "cgi", ">= 0.3.6"
diff --git a/LICENSE.txt b/LICENSE.txt
index 440eccd..d4b056d 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -1,4 +1,4 @@
-Copyright (c) 2014-2019 Andrew Kane
+Copyright (c) 2014-2023 Andrew Kane
 
 MIT License
 
diff --git a/README.md b/README.md
index 94f7a06..a4f5cfe 100644
--- a/README.md
+++ b/README.md
@@ -2,20 +2,20 @@
 
 :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.
+Track visits and events in Ruby, JavaScript, and native apps. Data is stored in your database by default, and you can customize it for any data store as you grow.
 
 :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)
+[![Build Status](https://github.com/ankane/ahoy/workflows/build/badge.svg?branch=master)](https://github.com/ankane/ahoy/actions)
 
 ## Installation
 
 Add this line to your application’s Gemfile:
 
 ```ruby
-gem 'ahoy_matey'
+gem "ahoy_matey"
 ```
 
 And run:
@@ -46,6 +46,18 @@ And restart your web server.
 
 ### JavaScript
 
+For Rails 7 / Importmap, add to `config/importmap.rb`:
+
+```ruby
+pin "ahoy", to: "ahoy.js"
+```
+
+And add to `app/javascript/application.js`:
+
+```javascript
+import "ahoy"
+```
+
 For Rails 6 / Webpacker, run:
 
 ```sh
@@ -55,7 +67,7 @@ yarn add ahoy.js
 And add to `app/javascript/packs/application.js`:
 
 ```javascript
-import ahoy from "ahoy.js";
+import ahoy from "ahoy.js"
 ```
 
 For Rails 5 / Sprockets, add to `app/assets/javascripts/application.js`:
@@ -70,6 +82,14 @@ Track an event with:
 ahoy.track("My second event", {language: "JavaScript"});
 ```
 
+### Native Apps
+
+Check out [Ahoy iOS](https://github.com/namolnad/ahoy-ios) and [Ahoy Android](https://github.com/instacart/ahoy-android).
+
+### Geocoding Setup
+
+To enable geocoding, see the [Geocoding section](#geocoding).
+
 ### GDPR Compliance
 
 Ahoy provides a number of options to help with GDPR compliance. See the [GDPR section](#gdpr-compliance-1) for more info.
@@ -135,17 +155,11 @@ end
 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).
+See the docs for [Ahoy iOS](https://github.com/namolnad/ahoy-ios) and [Ahoy Android](https://github.com/instacart/ahoy-android).
 
 #### AMP
 
@@ -181,9 +195,9 @@ 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]
+class AddAhoyVisitToOrders < ActiveRecord::Migration[7.0]
   def change
-    add_column :orders, :ahoy_visit_id, :bigint
+    add_reference :orders, :ahoy_visit
   end
 end
 ```
@@ -196,7 +210,7 @@ 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.
+Ahoy automatically attaches the `current_user` to the visit. With [Devise](https://github.com/plataformatec/devise), it attaches the user even if they sign in after the visit starts.
 
 With other authentication frameworks, add this to the end of your sign in method:
 
@@ -306,71 +320,118 @@ Set other [cookie options](https://api.rubyonrails.org/classes/ActionDispatch/Co
 Ahoy.cookie_options = {same_site: :lax}
 ```
 
-### Geocoding
+You can also [disable cookies](#anonymity-sets--cookies)
 
-Disable geocoding with:
+### 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.geocode = false
+Ahoy.token_generator = -> { Druuid.gen }
 ```
 
-The default job queue is `:ahoy`. Change this with:
+### Throttling
+
+You can use [Rack::Attack](https://github.com/kickstarter/rack-attack) to throttle requests to the API.
 
 ```ruby
-Ahoy.job_queue = :low_priority
+class Rack::Attack
+  throttle("ahoy/ip", limit: 20, period: 1.minute) do |req|
+    if req.path.start_with?("/ahoy/")
+      req.ip
+    end
+  end
+end
 ```
 
-#### Geocoding Performance
+### Exceptions
 
-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.
+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:
 
-Add this line to your application’s Gemfile:
+```ruby
+Safely.report_exception_method = ->(e) { Rollbar.error(e) }
+```
+
+## Geocoding
+
+Ahoy uses [Geocoder](https://github.com/alexreisner/geocoder) for geocoding. We recommend configuring [local geocoding](#local-geocoding) or [load balancer geocoding](#load-balancer-geocoding) so IP addresses are not sent to a 3rd party service. If you do use a 3rd party service and adhere to GDPR, be sure to add it to your subprocessor list. If Ahoy is configured to [mask IPs](#ip-masking), the masked IP is used (this can reduce accuracy but is better for privacy).
+
+To enable geocoding, add this line to your application’s Gemfile:
 
 ```ruby
-gem 'maxminddb'
+gem "geocoder"
 ```
 
-And create an initializer at `config/initializers/geocoder.rb` with:
+And update `config/initializers/ahoy.rb`:
+
+```ruby
+Ahoy.geocode = true
+```
+
+Geocoding is performed in a background job so it doesn’t slow down web requests. The default job queue is `:ahoy`. Change this with:
+
+```ruby
+Ahoy.job_queue = :low_priority
+```
+
+### Local Geocoding
+
+For privacy and performance, we recommend geocoding locally. Add this line to your application’s Gemfile:
+
+```ruby
+gem "maxminddb"
+```
+
+For city-level geocoding, download the [GeoLite2 City database](https://dev.maxmind.com/geoip/geoip2/geolite2/) and create `config/initializers/geocoder.rb` with:
 
 ```ruby
 Geocoder.configure(
   ip_lookup: :geoip2,
   geoip2: {
-    file: Rails.root.join("lib", "GeoLite2-City.mmdb")
+    file: "path/to/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.
+For country-level geocoding, install the `geoip-database` package. It’s preinstalled on Heroku. For Ubuntu, use:
 
-### Token Generation
+```sh
+sudo apt-get install geoip-database
+```
 
-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).
+And create `config/initializers/geocoder.rb` with:
 
 ```ruby
-Ahoy.token_generator = -> { Druuid.gen }
+Geocoder.configure(
+  ip_lookup: :maxmind_local,
+  maxmind_local: {
+    file: "/usr/share/GeoIP/GeoIP.dat",
+    package: :country
+  }
+)
 ```
 
-### Throttling
-
-You can use [Rack::Attack](https://github.com/kickstarter/rack-attack) to throttle requests to the API.
+### Load Balancer Geocoding
 
-```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
-```
+Some load balancers can add geocoding information to request headers.
 
-### Exceptions
+- [nginx](https://nginx.org/en/docs/http/ngx_http_geoip_module.html)
+- [Google Cloud](https://cloud.google.com/load-balancing/docs/custom-headers)
+- [Cloudflare](https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation)
 
-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:
+Update `config/initializers/ahoy.rb` with:
 
 ```ruby
-Safely.report_exception_method = ->(e) { Rollbar.error(e) }
+Ahoy.geocode = false
+
+class Ahoy::Store < Ahoy::DatabaseStore
+  def track_visit(data)
+    data[:country] = request.headers["<country-header>"]
+    data[:region] = request.headers["<region-header>"]
+    data[:city] = request.headers["<city-header>"]
+    super(data)
+  end
+end
 ```
 
 ## GDPR Compliance
@@ -431,7 +492,41 @@ Ahoy can switch from cookies to [anonymity sets](https://privacypatterns.org/pat
 Ahoy.cookies = false
 ```
 
-Previously set cookies are automatically deleted.
+Previously set cookies are automatically deleted. If you use JavaScript tracking, also set:
+
+```javascript
+ahoy.configure({cookies: false});
+```
+
+Note: With anonymity sets, visits no longer expire after 4 hours of inactivity. A new visit is only created when the IP mask or user agent changes (for instance, when a user updates their browser). There are plans to address this in the next major version.
+
+## Data Retention
+
+Data should only be retained for as long as it’s needed. Delete older data with:
+
+```ruby
+Ahoy::Visit.where("started_at < ?", 2.years.ago).find_in_batches do |visits|
+  visit_ids = visits.map(&:id)
+  Ahoy::Event.where(visit_id: visit_ids).delete_all
+  Ahoy::Visit.where(id: visit_ids).delete_all
+end
+```
+
+You can use [Rollup](https://github.com/ankane/rollup) to aggregate important data before you do.
+
+```ruby
+Ahoy::Visit.rollup("Visits", interval: "hour")
+```
+
+Delete data for a specific user with:
+
+```ruby
+user_id = 123
+visit_ids = Ahoy::Visit.where(user_id: user_id).pluck(:id)
+Ahoy::Event.where(visit_id: visit_ids).delete_all
+Ahoy::Visit.where(id: visit_ids).delete_all
+Ahoy::Event.where(user_id: user_id).delete_all
+```
 
 ## Development
 
@@ -558,7 +653,7 @@ Ahoy::Visit.group(:referring_domain).count
 
 ### Querying Events
 
-Ahoy provides two methods on the event model to make querying easier.
+Ahoy provides a few methods on the event model to make querying easier.
 
 To query on both name and properties, you can use:
 
@@ -569,9 +664,17 @@ Ahoy::Event.where_event("Viewed product", product_id: 123).count
 Or just query properties with:
 
 ```ruby
-Ahoy::Event.where_props(product_id: 123).count
+Ahoy::Event.where_props(product_id: 123, category: "Books").count
+```
+
+Group by properties with:
+
+```ruby
+Ahoy::Event.group_prop(:product_id, :category).count
 ```
 
+Note: MySQL and MariaDB always return string keys (including `"null"` for `nil`) for `group_prop`.
+
 ### Funnels
 
 It’s easy to create funnels.
@@ -584,6 +687,47 @@ viewed_checkout_ids = Ahoy::Event.where(user_id: added_item_ids, name: "Viewed c
 
 The same approach also works with visitor tokens.
 
+### Rollups
+
+Improve query performance by pre-aggregating data with [Rollup](https://github.com/ankane/rollup).
+
+```ruby
+Ahoy::Event.where(name: "Viewed store").rollup("Store views")
+```
+
+This is only needed if you have a lot of data.
+
+### Forecasting
+
+To forecast future visits and events, check out [Prophet](https://github.com/ankane/prophet).
+
+```ruby
+daily_visits = Ahoy::Visit.group_by_day(:started_at).count # uses Groupdate
+Prophet.forecast(daily_visits)
+```
+
+### Anomaly Detection
+
+To detect anomalies in visits and events, check out [AnomalyDetection.rb](https://github.com/ankane/AnomalyDetection.rb).
+
+```ruby
+daily_visits = Ahoy::Visit.group_by_day(:started_at).count # uses Groupdate
+AnomalyDetection.detect(daily_visits, period: 7)
+```
+
+### Breakout Detection
+
+To detect breakouts in visits and events, check out [Breakout](https://github.com/ankane/breakout).
+
+```ruby
+daily_visits = Ahoy::Visit.group_by_day(:started_at).count # uses Groupdate
+Breakout.detect(daily_visits)
+```
+
+### Recommendations
+
+To make recommendations based on events, check out [Disco](https://github.com/ankane/disco#ahoy).
+
 ## Tutorials
 
 - [Tracking Metrics with Ahoy and Blazer](https://gorails.com/episodes/internal-metrics-with-ahoy-and-blazer)
@@ -631,62 +775,19 @@ Send a `POST` request to `/ahoy/events` with `Content-Type: application/json` an
 
 ## Upgrading
 
-### 3.0
+### 4.0
 
-If you installed Ahoy before 2.1 and want to keep legacy user agent parsing and bot detection, add to your Gemfile:
+There are two notable changes to geocoding:
 
-```ruby
-gem "browser", "~> 2.0"
-gem "user_agent_parser"
-```
+1. Geocoding is now disabled by default (this was already the case for new installations with 3.2.0+). Check out the instructions for [how to enable it](#geocoding).
 
-And add to `config/initializers/ahoy.rb`:
+2. The `geocoder` gem is now an optional dependency. To use geocoding, add it to your Gemfile:
 
-```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
+  ```ruby
+  gem "geocoder"
+  ```
 
-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)
+Also, check out the [upgrade notes](https://github.com/ankane/ahoy.js#upgrading) for Ahoy.js.
 
 ## History
 
@@ -700,3 +801,20 @@ Everyone is encouraged to help improve this project. Here are a few ways you can
 - Fix bugs and [submit pull requests](https://github.com/ankane/ahoy/pulls)
 - Write, clarify, or fix documentation
 - Suggest or add new features
+
+To get started with development:
+
+```sh
+git clone https://github.com/ankane/ahoy.git
+cd ahoy
+bundle install
+bundle exec rake test
+```
+
+To test different adapters, use:
+
+```sh
+ADAPTER=postgresql bundle exec rake test
+ADAPTER=mysql2 bundle exec rake test
+ADAPTER=mongoid bundle exec rake test
+```
diff --git a/Rakefile b/Rakefile
index 35dafa0..b3bdf10 100644
--- a/Rakefile
+++ b/Rakefile
@@ -4,6 +4,6 @@ require "rake/testtask"
 task default: :test
 Rake::TestTask.new do |t|
   t.libs << "test"
-  t.pattern = "test/**/*_test.rb"
-  t.warning = false
+  t.pattern = "test/*_test.rb"
+  t.warning = false # for bson, mongoid, device_detector, browser
 end
diff --git a/ahoy_matey.gemspec b/ahoy_matey.gemspec
index 94f874c..73f16eb 100644
--- a/ahoy_matey.gemspec
+++ b/ahoy_matey.gemspec
@@ -8,25 +8,14 @@ Gem::Specification.new do |spec|
   spec.license       = "MIT"
 
   spec.author        = "Andrew Kane"
-  spec.email         = "andrew@chartkick.com"
+  spec.email         = "andrew@ankane.org"
 
   spec.files         = Dir["*.{md,txt}", "{app,config,lib,vendor}/**/*"]
   spec.require_path  = "lib"
 
-  spec.required_ruby_version = ">= 2.4"
+  spec.required_ruby_version = ">= 2.6"
 
-  spec.add_dependency "activesupport", ">= 5"
-  spec.add_dependency "geocoder", ">= 1.4.5"
+  spec.add_dependency "activesupport", ">= 5.2"
   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
diff --git a/app/controllers/ahoy/base_controller.rb b/app/controllers/ahoy/base_controller.rb
index 62e197f..e1c6407 100644
--- a/app/controllers/ahoy/base_controller.rb
+++ b/app/controllers/ahoy/base_controller.rb
@@ -5,19 +5,27 @@ module Ahoy
     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
 
+    before_action :verify_request_size
+    before_action :check_params
+    before_action :renew_cookies
+
     protected
 
     def ahoy
       @ahoy ||= Ahoy::Tracker.new(controller: self, api: true)
     end
 
+    def check_params
+      if ahoy.send(:missing_params?)
+        logger.info "[ahoy] Missing required parameters"
+        render plain: "Missing required parameters\n", status: :bad_request
+      end
+    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
@@ -28,7 +36,7 @@ module Ahoy
     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
+        render plain: "Payload too large\n", status: :payload_too_large
       end
     end
   end
diff --git a/app/jobs/ahoy/geocode_job.rb b/app/jobs/ahoy/geocode_job.rb
index 2ea2255..acf8d5e 100644
--- a/app/jobs/ahoy/geocode_job.rb
+++ b/app/jobs/ahoy/geocode_job.rb
@@ -1,4 +1,5 @@
 # for smooth update from Ahoy 1 -> 2
+# TODO remove in 5.0
 module Ahoy
   class GeocodeJob < ActiveJob::Base
     queue_as { Ahoy.job_queue }
diff --git a/app/jobs/ahoy/geocode_v2_job.rb b/app/jobs/ahoy/geocode_v2_job.rb
index db2f076..511927a 100644
--- a/app/jobs/ahoy/geocode_v2_job.rb
+++ b/app/jobs/ahoy/geocode_v2_job.rb
@@ -6,6 +6,8 @@ module Ahoy
       location =
         begin
           Geocoder.search(ip).first
+        rescue NameError
+          raise "Add the geocoder gem to your Gemfile to use geocoding"
         rescue => e
           Ahoy.log "Geocode error: #{e.class.name}: #{e.message}"
           nil
@@ -14,6 +16,7 @@ module Ahoy
       if location && location.country.present?
         data = {
           country: location.country,
+          country_code: location.try(:country_code).presence,
           region: location.try(:state).presence,
           city: location.try(:city).presence,
           postal_code: location.try(:postal_code).presence,
diff --git a/debian/changelog b/debian/changelog
index 4d21c46..e09928f 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,8 +1,10 @@
-ruby-ahoy-matey (3.0.2-2) UNRELEASED; urgency=low
+ruby-ahoy-matey (4.2.1-1) UNRELEASED; urgency=low
 
   * Set upstream metadata fields: Bug-Submit.
+  * New upstream release.
+  * New upstream release.
 
- -- Debian Janitor <janitor@jelmer.uk>  Sun, 16 Aug 2020 21:11:35 -0000
+ -- Debian Janitor <janitor@jelmer.uk>  Mon, 22 May 2023 08:35:50 -0000
 
 ruby-ahoy-matey (3.0.2-1) unstable; urgency=medium
 
diff --git a/gemfiles/rails52.gemfile b/gemfiles/rails52.gemfile
new file mode 100644
index 0000000..852741c
--- /dev/null
+++ b/gemfiles/rails52.gemfile
@@ -0,0 +1,14 @@
+source "https://rubygems.org"
+
+gemspec path: ".."
+
+gem "rails", "~> 5.2.0"
+gem "rake"
+gem "minitest"
+gem "combustion"
+gem "sqlite3"
+gem "pg"
+gem "mysql2"
+gem "mongoid", "~> 6"
+gem "browser", "~> 2.0"
+gem "user_agent_parser"
diff --git a/gemfiles/rails60.gemfile b/gemfiles/rails60.gemfile
new file mode 100644
index 0000000..62455af
--- /dev/null
+++ b/gemfiles/rails60.gemfile
@@ -0,0 +1,17 @@
+source "https://rubygems.org"
+
+gemspec path: ".."
+
+gem "rails", "~> 6.0.0"
+gem "rake"
+gem "minitest"
+gem "combustion"
+gem "sqlite3"
+gem "pg"
+gem "mysql2"
+gem "mongoid", "~> 7"
+gem "browser", "~> 2.0"
+gem "user_agent_parser"
+
+# for https://github.com/ruby/cgi/pull/29
+gem "cgi", ">= 0.3.6"
diff --git a/gemfiles/rails61.gemfile b/gemfiles/rails61.gemfile
new file mode 100644
index 0000000..dc6810c
--- /dev/null
+++ b/gemfiles/rails61.gemfile
@@ -0,0 +1,17 @@
+source "https://rubygems.org"
+
+gemspec path: ".."
+
+gem "rails", "~> 6.1.0"
+gem "rake"
+gem "minitest"
+gem "combustion"
+gem "sqlite3"
+gem "pg"
+gem "mysql2"
+gem "mongoid", "~> 7"
+gem "browser", "~> 2.0"
+gem "user_agent_parser"
+
+# for https://github.com/ruby/cgi/pull/29
+gem "cgi", ">= 0.3.6"
diff --git a/lib/ahoy.rb b/lib/ahoy.rb
index 574c152..a72fe5a 100644
--- a/lib/ahoy.rb
+++ b/lib/ahoy.rb
@@ -1,24 +1,24 @@
+# stdlib
 require "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)
+require_relative "ahoy/utils"
+require_relative "ahoy/base_store"
+require_relative "ahoy/controller"
+require_relative "ahoy/database_store"
+require_relative "ahoy/helper"
+require_relative "ahoy/model"
+require_relative "ahoy/query_methods"
+require_relative "ahoy/tracker"
+require_relative "ahoy/version"
+require_relative "ahoy/visit_properties"
+
+require_relative "ahoy/engine" if defined?(Rails)
 
 module Ahoy
   mattr_accessor :visit_duration
@@ -43,7 +43,7 @@ module Ahoy
   self.quiet = true
 
   mattr_accessor :geocode
-  self.geocode = true
+  self.geocode = false
 
   mattr_accessor :max_content_length
   self.max_content_length = 8192
@@ -104,6 +104,14 @@ module Ahoy
       addr.mask(48).to_s
     end
   end
+
+  def self.instance
+    Thread.current[:ahoy]
+  end
+
+  def self.instance=(value)
+    Thread.current[:ahoy] = value
+  end
 end
 
 ActiveSupport.on_load(:action_controller) do
@@ -118,7 +126,6 @@ ActiveSupport.on_load(:action_view) do
   include Ahoy::Helper
 end
 
-# Mongoid
-if defined?(ActiveModel)
-  ActiveModel::Callbacks.include(Ahoy::Model)
+ActiveSupport.on_load(:mongoid) do
+  Mongoid::Document::ClassMethods.include(Ahoy::Model)
 end
diff --git a/lib/ahoy/base_store.rb b/lib/ahoy/base_store.rb
index fa389a3..2c176fc 100644
--- a/lib/ahoy/base_store.rb
+++ b/lib/ahoy/base_store.rb
@@ -24,7 +24,11 @@ module Ahoy
     def user
       @user ||= begin
         if Ahoy.user_method.respond_to?(:call)
-          Ahoy.user_method.call(controller)
+          if Ahoy.user_method.arity == 1
+            Ahoy.user_method.call(controller)
+          else
+            Ahoy.user_method.call(controller, request)
+          end
         else
           controller.send(Ahoy.user_method) if controller.respond_to?(Ahoy.user_method, true)
         end
diff --git a/lib/ahoy/controller.rb b/lib/ahoy/controller.rb
index d5b46be..f9eebb1 100644
--- a/lib/ahoy/controller.rb
+++ b/lib/ahoy/controller.rb
@@ -39,12 +39,12 @@ module Ahoy
     end
 
     def set_ahoy_request_store
-      previous_value = Thread.current[:ahoy]
+      previous_value = Ahoy.instance
       begin
-        Thread.current[:ahoy] = ahoy
+        Ahoy.instance = ahoy
         yield
       ensure
-        Thread.current[:ahoy] = previous_value
+        Ahoy.instance = previous_value
       end
     end
   end
diff --git a/lib/ahoy/database_store.rb b/lib/ahoy/database_store.rb
index cccd3e4..426f80f 100644
--- a/lib/ahoy/database_store.rb
+++ b/lib/ahoy/database_store.rb
@@ -53,7 +53,12 @@ module Ahoy
 
     def visit
       unless defined?(@visit)
-        @visit = visit_model.where(visit_token: ahoy.visit_token).first if ahoy.visit_token
+        if defined?(Mongoid::Document) && visit_model < Mongoid::Document
+          # find_by raises error by default when not found
+          @visit = visit_model.where(visit_token: ahoy.visit_token).first if ahoy.visit_token
+        else
+          @visit = visit_model.find_by(visit_token: ahoy.visit_token) if ahoy.visit_token
+        end
       end
       @visit
     end
diff --git a/lib/ahoy/engine.rb b/lib/ahoy/engine.rb
index 6794aa6..6ad957e 100644
--- a/lib/ahoy/engine.rb
+++ b/lib/ahoy/engine.rb
@@ -26,5 +26,12 @@ module Ahoy
         alias_method :call, :call_with_quiet_ahoy
       end
     end
+
+    # for importmap
+    initializer "ahoy.importmap" do |app|
+      if defined?(Importmap)
+        app.config.assets.precompile << "ahoy.js"
+      end
+    end
   end
 end
diff --git a/lib/ahoy/model.rb b/lib/ahoy/model.rb
index 75d1246..32301ff 100644
--- a/lib/ahoy/model.rb
+++ b/lib/ahoy/model.rb
@@ -7,7 +7,7 @@ module Ahoy
       end
       class_eval %{
         def set_ahoy_visit
-          self.#{name} ||= Thread.current[:ahoy].try(:visit_or_create)
+          self.#{name} ||= Ahoy.instance.try(:visit_or_create)
         end
       }
     end
diff --git a/lib/ahoy/query_methods.rb b/lib/ahoy/query_methods.rb
index 09dcdfa..324c87e 100644
--- a/lib/ahoy/query_methods.rb
+++ b/lib/ahoy/query_methods.rb
@@ -8,64 +8,78 @@ module Ahoy
       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
+        return all if properties.empty?
+
+        adapter_name = respond_to?(:connection) ? connection.adapter_name.downcase : "mongoid"
         case adapter_name
         when "mongoid"
-          relation = where(Hash[properties.map { |k, v| ["properties.#{k}", v] }])
+          where(properties.to_h { |k, v| ["properties.#{k}", v] })
         when /mysql/
-          if column_type == :json
-            properties.each do |k, v|
+          where("JSON_CONTAINS(properties, ?, '$') = 1", properties.to_json)
+        when /postgres|postgis/
+          case columns_hash["properties"].type
+          when :hstore
+            properties.inject(all) do |relation, (k, v)|
               if v.nil?
-                v = "null"
-              elsif v == true
-                v = "true"
+                relation.where("properties -> ? IS NULL", k.to_s)
+              else
+                relation.where("properties -> ? = ?", k.to_s, v.to_s)
               end
-
-              relation = relation.where("JSON_UNQUOTE(properties -> ?) = ?", "$.#{k}", v.as_json)
             end
+          when :jsonb
+            where("properties @> ?", properties.to_json)
           else
-            properties.each do |k, v|
-              relation = relation.where("properties REGEXP ?", "[{,]#{{k.to_s => v}.to_json.sub(/\A\{/, "").sub(/\}\z/, "").gsub("+", "\\\\+")}[,}]")
+            where("properties::jsonb @> ?", properties.to_json)
+          end
+        when /sqlite/
+          properties.inject(all) do |relation, (k, v)|
+            if v.nil?
+              relation.where("JSON_EXTRACT(properties, ?) IS NULL", "$.#{k}")
+            else
+              relation.where("JSON_EXTRACT(properties, ?) = ?", "$.#{k}", v.as_json)
             end
           end
+        else
+          raise "Adapter not supported: #{adapter_name}"
+        end
+      end
+      alias_method :where_properties, :where_props
+
+      def group_prop(*props)
+        # like with group
+        props.flatten!
+
+        relation = all
+        adapter_name = respond_to?(:connection) ? connection.adapter_name.downcase : "mongoid"
+        case adapter_name
+        when "mongoid"
+          raise "Adapter not supported: #{adapter_name}"
+        when /mysql/
+          props.each do |prop|
+            quoted_prop = connection.quote("$.#{prop}")
+            relation = relation.group("JSON_UNQUOTE(JSON_EXTRACT(properties, #{quoted_prop}))")
+          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
+          # convert to jsonb to fix
+          # could not identify an equality operator for type json
+          # and for text columns
+          column_type = columns_hash["properties"].type
+          cast = [:jsonb, :hstore].include?(column_type) ? "" : "::jsonb"
+
+          props.each do |prop|
+            quoted_prop = connection.quote(prop)
+            relation = relation.group("properties#{cast} -> #{quoted_prop}")
+          end
+        when /sqlite/
+          props.each do |prop|
+            quoted_prop = connection.quote("$.#{prop}")
+            relation = relation.group("JSON_EXTRACT(properties, #{quoted_prop})")
           end
         else
           raise "Adapter not supported: #{adapter_name}"
         end
         relation
       end
-      alias_method :where_properties, :where_props
     end
   end
 end
diff --git a/lib/ahoy/tracker.rb b/lib/ahoy/tracker.rb
index 708fc68..d0c17b7 100644
--- a/lib/ahoy/tracker.rb
+++ b/lib/ahoy/tracker.rb
@@ -19,8 +19,6 @@ module Ahoy
     def track(name, properties = {}, options = {})
       if exclude?
         debug "Event excluded"
-      elsif missing_params?
-        debug "Missing required parameters"
       else
         data = {
           visit_token: visit_token,
@@ -41,8 +39,6 @@ module Ahoy
     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)
@@ -67,16 +63,12 @@ module Ahoy
     end
 
     def geocode(data)
-      if exclude?
-        debug "Geocode excluded"
-      else
-        data = {
-          visit_token: visit_token
-          }.merge(data).select { |_, v| v }
+      data = {
+        visit_token: visit_token
+      }.merge(data).select { |_, v| v }
 
-        @store.geocode(data)
-        true
-      end
+      @store.geocode(data)
+      true
     rescue => e
       report_exception(e)
     end
@@ -159,6 +151,7 @@ module Ahoy
       @options[:api]
     end
 
+    # private, but used by API
     def missing_params?
       if Ahoy.cookies && api? && Ahoy.protect_from_forgery
         !(existing_visit_token && existing_visitor_token)
@@ -192,7 +185,10 @@ module Ahoy
     end
 
     def exclude?
-      @store.exclude?
+      unless defined?(@exclude)
+        @exclude = @store.exclude?
+      end
+      @exclude
     end
 
     def report_exception(e)
diff --git a/lib/ahoy/version.rb b/lib/ahoy/version.rb
index 1b36088..a734511 100644
--- a/lib/ahoy/version.rb
+++ b/lib/ahoy/version.rb
@@ -1,3 +1,3 @@
 module Ahoy
-  VERSION = "3.0.2"
+  VERSION = "4.2.1"
 end
diff --git a/lib/ahoy_matey.rb b/lib/ahoy_matey.rb
index 59229c6..4cb89cb 100644
--- a/lib/ahoy_matey.rb
+++ b/lib/ahoy_matey.rb
@@ -1 +1 @@
-require "ahoy"
+require_relative "ahoy"
diff --git a/lib/generators/ahoy/activerecord_generator.rb b/lib/generators/ahoy/activerecord_generator.rb
index 91e7fcc..8a36787 100644
--- a/lib/generators/ahoy/activerecord_generator.rb
+++ b/lib/generators/ahoy/activerecord_generator.rb
@@ -1,3 +1,4 @@
+require "rails/generators"
 require "rails/generators/active_record"
 
 module Ahoy
@@ -17,9 +18,7 @@ module Ahoy
       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
+        case adapter
         when /postg/i # postgres, postgis
           "jsonb"
         when /mysql/i
@@ -29,13 +28,40 @@ module Ahoy
         end
       end
 
+      # requires database connection to check for MariaDB
+      def serialize_properties?
+        properties_type == "text" || (properties_type == "json" && ActiveRecord::Base.connection.try(:mariadb?))
+      end
+
+      # use connection_config instead of connection.adapter
+      # so database connection isn't needed
+      def adapter
+        if ActiveRecord::VERSION::STRING.to_f >= 6.1
+          ActiveRecord::Base.connection_db_config.adapter.to_s
+        else
+          ActiveRecord::Base.connection_config[:adapter].to_s
+        end
+      end
+
       def rails52?
-        ActiveRecord::VERSION::STRING >= "5.2"
+        ActiveRecord::VERSION::STRING.to_f >= 5.2
       end
 
       def migration_version
         "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
       end
+
+      def primary_key_type
+        ", id: :#{key_type}" if key_type
+      end
+
+      def foreign_key_type
+        ", type: :#{key_type}" if key_type
+      end
+
+      def key_type
+        Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
+      end
     end
   end
 end
diff --git a/lib/generators/ahoy/templates/active_record_event_model.rb.tt b/lib/generators/ahoy/templates/active_record_event_model.rb.tt
index da22db3..aa9e33d 100644
--- a/lib/generators/ahoy/templates/active_record_event_model.rb.tt
+++ b/lib/generators/ahoy/templates/active_record_event_model.rb.tt
@@ -4,7 +4,7 @@ class Ahoy::Event < ApplicationRecord
   self.table_name = "ahoy_events"
 
   belongs_to :visit
-  belongs_to :user, optional: true<% if properties_type == "text" %>
+  belongs_to :user, optional: true<% if serialize_properties? %>
 
   serialize :properties, JSON<% end %>
 end
diff --git a/lib/generators/ahoy/templates/active_record_migration.rb.tt b/lib/generators/ahoy/templates/active_record_migration.rb.tt
index 3531c57..83b1ebf 100644
--- a/lib/generators/ahoy/templates/active_record_migration.rb.tt
+++ b/lib/generators/ahoy/templates/active_record_migration.rb.tt
@@ -1,6 +1,6 @@
 class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
   def change
-    create_table :ahoy_visits do |t|
+    create_table :ahoy_visits<%= primary_key_type %> do |t|
       t.string :visit_token
       t.string :visitor_token
 
@@ -8,7 +8,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
       # simply remove any you don't want
 
       # user
-      t.references :user
+      t.references :user<%= foreign_key_type %>
 
       # standard
       t.string :ip
@@ -41,18 +41,18 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
       t.string :os_version
       t.string :platform
 
-      t.timestamp :started_at
+      t.datetime :started_at
     end
 
-    add_index :ahoy_visits, [:visit_token], unique: true
+    add_index :ahoy_visits, :visit_token, unique: true
 
-    create_table :ahoy_events do |t|
-      t.references :visit
-      t.references :user
+    create_table :ahoy_events<%= primary_key_type %> do |t|
+      t.references :visit<%= foreign_key_type %>
+      t.references :user<%= foreign_key_type %>
 
       t.string :name
       t.<%= properties_type %> :properties
-      t.timestamp :time
+      t.datetime :time
     end
 
     add_index :ahoy_events, [:name, :time]<% if properties_type == "jsonb" %><% if rails52? %>
diff --git a/lib/generators/ahoy/templates/base_store_initializer.rb.tt b/lib/generators/ahoy/templates/base_store_initializer.rb.tt
index 69dec2e..4fcc83a 100644
--- a/lib/generators/ahoy/templates/base_store_initializer.rb.tt
+++ b/lib/generators/ahoy/templates/base_store_initializer.rb.tt
@@ -18,3 +18,8 @@ end
 
 # set to true for JavaScript tracking
 Ahoy.api = false
+
+# set to true for geocoding (and add the geocoder gem to your Gemfile)
+# we recommend configuring local geocoding as well
+# see https://github.com/ankane/ahoy#geocoding
+Ahoy.geocode = false
diff --git a/lib/generators/ahoy/templates/database_store_initializer.rb.tt b/lib/generators/ahoy/templates/database_store_initializer.rb.tt
index 88372d2..fad5c2c 100644
--- a/lib/generators/ahoy/templates/database_store_initializer.rb.tt
+++ b/lib/generators/ahoy/templates/database_store_initializer.rb.tt
@@ -3,3 +3,8 @@ end
 
 # set to true for JavaScript tracking
 Ahoy.api = false
+
+# set to true for geocoding (and add the geocoder gem to your Gemfile)
+# we recommend configuring local geocoding as well
+# see https://github.com/ankane/ahoy#geocoding
+Ahoy.geocode = false
diff --git a/lib/generators/ahoy/templates/mongoid_event_model.rb.tt b/lib/generators/ahoy/templates/mongoid_event_model.rb.tt
index 4d3b1f7..8e2a918 100644
--- a/lib/generators/ahoy/templates/mongoid_event_model.rb.tt
+++ b/lib/generators/ahoy/templates/mongoid_event_model.rb.tt
@@ -2,7 +2,7 @@ class Ahoy::Event
   include Mongoid::Document
 
   # associations
-  belongs_to :visit, index: true
+  belongs_to :visit, class_name: "Ahoy::Visit", index: true
   belongs_to :user, index: true, optional: true
 
   # fields
diff --git a/test/activerecord_generator_test.rb b/test/activerecord_generator_test.rb
new file mode 100644
index 0000000..af1c1d0
--- /dev/null
+++ b/test/activerecord_generator_test.rb
@@ -0,0 +1,30 @@
+require_relative "test_helper"
+
+require "generators/ahoy/activerecord_generator"
+
+class ActiverecordGeneratorTest < Rails::Generators::TestCase
+  tests Ahoy::Generators::ActiverecordGenerator
+  destination File.expand_path("../tmp", __dir__)
+  setup :prepare_destination
+
+  def setup
+    skip if ENV["ADAPTER"] == "mongoid"
+    super
+  end
+
+  def test_works
+    run_generator
+    assert_file "config/initializers/ahoy.rb", /DatabaseStore/
+    assert_file "app/models/ahoy/visit.rb", /Ahoy::Visit < ApplicationRecord/
+    assert_file "app/models/ahoy/event.rb", /Ahoy::Event < ApplicationRecord/
+    assert_migration "db/migrate/create_ahoy_visits_and_events.rb", /create_table/
+  end
+
+  def test_primary_key_type
+    Rails.configuration.generators.stub(:options, {active_record: {primary_key_type: :uuid}}) do
+      run_generator
+    end
+    assert_migration "db/migrate/create_ahoy_visits_and_events.rb", /id: :uuid/
+    assert_migration "db/migrate/create_ahoy_visits_and_events.rb", /type: :uuid/
+  end
+end
diff --git a/test/api_test.rb b/test/api_test.rb
new file mode 100644
index 0000000..dbaa9da
--- /dev/null
+++ b/test/api_test.rb
@@ -0,0 +1,115 @@
+require_relative "test_helper"
+
+class ApiTest < ActionDispatch::IntegrationTest
+  include Rails.application.routes.mounted_helpers
+
+  def setup
+    Ahoy::Visit.delete_all
+    Ahoy::Event.delete_all
+  end
+
+  def test_visit
+    visit_token = random_token
+    visitor_token = random_token
+
+    post ahoy_engine.visits_url, params: {visit_token: visit_token, visitor_token: visitor_token}
+    assert_response :success
+
+    body = JSON.parse(response.body)
+    expected_body = {
+      "visit_token" => visit_token,
+      "visitor_token" => visitor_token,
+      "visit_id" => visit_token,
+      "visitor_id" => visitor_token
+    }
+    assert_equal expected_body, body
+
+    assert_equal 1, Ahoy::Visit.count
+
+    visit = Ahoy::Visit.last
+    assert_equal visit_token, visit.visit_token
+    assert_equal visitor_token, visit.visitor_token
+  end
+
+  def test_event
+    visit = random_visit
+
+    name = "Test"
+    time = Time.current.round
+    event_params = {
+      visit_token: visit.visit_token,
+      visitor_token: visit.visitor_token,
+      events_json: [
+        {
+          id: random_token,
+          name: name,
+          properties: {},
+          time: time
+        }
+      ].to_json
+    }
+    post ahoy_engine.events_url, params: event_params
+    assert_response :success
+
+    assert_equal 1, Ahoy::Event.count
+
+    event = Ahoy::Event.last
+    assert_equal visit, event.visit
+    assert_equal name, event.name
+    assert_equal time, event.time
+  end
+
+  def test_event_params
+    visit = random_visit
+
+    name = "Test"
+    event_params = {
+      visit_token: visit.visit_token,
+      visitor_token: visit.visitor_token,
+      name: name,
+      properties: {}
+    }
+    post ahoy_engine.events_url, params: event_params
+    assert_response :success
+
+    assert_equal 1, Ahoy::Event.count
+
+    event = Ahoy::Event.last
+    assert_equal visit, event.visit
+    assert_equal name, event.name
+  end
+
+  def test_time
+    # todo
+  end
+
+  def test_before_action
+    post ahoy_engine.visits_url, params: {visit_token: random_token, visitor_token: random_token}
+    assert_nil controller.ran_before_action
+  end
+
+  def test_renew_cookies
+    post ahoy_engine.visits_url, params: {visit_token: random_token, visitor_token: random_token, js: true}
+    assert_equal ["ahoy_visit"], response.cookies.keys
+  end
+
+  def test_max_content_length
+    with_options(max_content_length: 1) do
+      post ahoy_engine.visits_url, params: {visit_token: random_token, visitor_token: random_token}
+      assert_response 413
+      assert_equal "Payload too large\n", response.body
+    end
+  end
+
+  def random_visit
+    Ahoy::Visit.create!(
+      visit_token: random_token,
+      visitor_token: random_token,
+      started_at: Time.current.round # so it's not ahead of event
+    )
+  end
+
+  def random_token
+    SecureRandom.uuid
+  end
+end
diff --git a/test/base_generator_test.rb b/test/base_generator_test.rb
new file mode 100644
index 0000000..5662fba
--- /dev/null
+++ b/test/base_generator_test.rb
@@ -0,0 +1,14 @@
+require_relative "test_helper"
+
+require "generators/ahoy/base_generator"
+
+class BaseGeneratorTest < Rails::Generators::TestCase
+  tests Ahoy::Generators::BaseGenerator
+  destination File.expand_path("../tmp", __dir__)
+  setup :prepare_destination
+
+  def test_works
+    run_generator
+    assert_file "config/initializers/ahoy.rb", /BaseStore/
+  end
+end
diff --git a/test/controller_test.rb b/test/controller_test.rb
index c06ee91..15d7d78 100644
--- a/test/controller_test.rb
+++ b/test/controller_test.rb
@@ -1,20 +1,130 @@
 require_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_response :success
 
     assert_equal 1, Ahoy::Visit.count
     assert_equal 1, Ahoy::Event.count
 
     event = Ahoy::Event.last
     assert_equal "Viewed products", event.name
+    assert_equal({}, event.properties)
+  end
+
+  def test_instance
+    post products_url
+    assert_response :success
+
+    assert_equal 1, Ahoy::Visit.count
+    assert_equal 1, Ahoy::Event.count
+
+    event = Ahoy::Event.last
+    assert_equal "Created product", event.name
+    product = Product.last
+    assert_equal({"product_id" => product.id}, event.properties)
+  end
+
+  def test_server_side_visits_true
+    with_options(server_side_visits: true) do
+      get list_products_url
+      assert_equal 1, Ahoy::Visit.count
+    end
+  end
+
+  def test_server_side_visits_false
+    with_options(server_side_visits: false) do
+      get products_url
+      assert_equal 0, Ahoy::Visit.count
+      assert_equal ["ahoy_track", "ahoy_visit", "ahoy_visitor"], response.cookies.keys.sort
+    end
+  end
+
+  def test_server_side_visits_when_needed
+    with_options(server_side_visits: :when_needed) do
+      get list_products_url
+      assert_equal 0, Ahoy::Visit.count
+      get products_url
+      assert_equal 1, Ahoy::Visit.count
+    end
+  end
+
+  def test_skip_before_action
+    get no_visit_products_url
+    assert_equal 0, Ahoy::Visit.count
+  end
+
+  def test_api_only
+    with_options(api_only: true) do
+      get list_products_url
+      assert_equal 0, Ahoy::Visit.count
+      assert_empty response.cookies
+    end
+  end
+
+  def test_visit_duration
+    get products_url
+    travel 5.hours do
+      get products_url
+    end
+    assert_equal 2, Ahoy::Visit.count
+    assert_equal 1, Ahoy::Visit.pluck(:visitor_token).uniq.count
+  end
+
+  def test_visit_duration_cookies_false
+    with_options(cookies: false) do
+      get products_url
+      travel 5.hours do
+        get products_url
+      end
+      assert_equal 1, Ahoy::Visit.count
+      assert_equal 1, Ahoy::Visit.pluck(:visitor_token).uniq.count
+    end
+  end
+
+  def test_visitor_duration
+    get products_url
+    travel 3.years do
+      get products_url
+    end
+    assert_equal 2, Ahoy::Visit.count
+    assert_equal 2, Ahoy::Visit.pluck(:visitor_token).uniq.count
+  end
+
+  def test_visitor_duration_cookies_false
+    with_options(cookies: false) do
+      get products_url
+      travel 3.years do
+        get products_url
+      end
+      assert_equal 1, Ahoy::Visit.count
+      assert_equal 1, Ahoy::Visit.pluck(:visitor_token).uniq.count
+    end
+  end
+
+  def test_mask_ips
+    with_options(mask_ips: true) do
+      get products_url
+      assert_equal "127.0.0.0", Ahoy::Visit.last.ip
+    end
+  end
+
+  def test_mask_ips_ipv6
+    with_options(mask_ips: true) do
+      get products_url, env: {"REMOTE_ADDR" => "2001:4860:4860:0:0:0:0:8844"}
+      assert_equal "2001:4860:4860::", Ahoy::Visit.last.ip
+    end
+  end
+
+  def test_token_generator
+    token_generator = -> { "test-token" }
+    with_options(token_generator: token_generator) do
+      get products_url
+      visit = Ahoy::Visit.last
+      assert_equal "test-token", visit.visit_token
+      assert_equal "test-token", visit.visitor_token
+    end
   end
 
   def test_bad_visit_cookie
diff --git a/test/cookies_test.rb b/test/cookies_test.rb
new file mode 100644
index 0000000..576297c
--- /dev/null
+++ b/test/cookies_test.rb
@@ -0,0 +1,57 @@
+require_relative "test_helper"
+
+class CookiesTest < ActionDispatch::IntegrationTest
+  def test_cookies_true
+    get products_url
+    assert_equal ["ahoy_visit", "ahoy_visitor"], response.cookies.keys.sort
+  end
+
+  def test_cookies_false
+    with_options(cookies: false) do
+      get products_url
+      assert_empty response.cookies
+      visit = Ahoy::Visit.last
+      # deterministic tokens
+      # difference due to Rails 7 bugfix
+      # https://github.com/rails/rails/issues/37681
+      # https://github.com/rails/rails/pull/37682
+      if Rails::VERSION::MAJOR >= 7
+        assert_equal "f53976f4-229b-5ff7-9b66-98bbbbfac543", visit.visit_token
+        assert_equal "93dc5253-3a3b-561d-8d53-fb5476f02eca", visit.visitor_token
+      else
+        assert_equal "8924a60c-5c50-5d80-b38d-e6c68fcd0958", visit.visit_token
+        assert_equal "64dcde66-9659-5473-897e-5abd59f8b89f", visit.visitor_token
+      end
+
+      get products_url
+      assert_equal 1, Ahoy::Visit.count
+      assert_equal 2, Ahoy::Visit.last.events.count
+    end
+  end
+
+  def test_cookies_false_deletes_cookies
+    self.cookies["ahoy_visit"] = "test-token"
+    self.cookies["ahoy_visitor"] = "test-token"
+    self.cookies["ahoy_track"] = "true"
+
+    with_options(cookies: false) do
+      get products_url
+      expired = "max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT"
+      assert_equal 3, response.headers["Set-Cookie"].scan(expired).size
+    end
+  end
+
+  def test_cookie_options
+    with_options(cookie_options: {same_site: :lax}) do
+      get products_url
+      assert_match "SameSite=Lax", response.header["Set-Cookie"]
+    end
+  end
+
+  def test_cookie_domain
+    with_options(cookie_domain: :all) do
+      get products_url
+      assert_match "domain=.example.com", response.header["Set-Cookie"]
+    end
+  end
+end
diff --git a/test/exclude_test.rb b/test/exclude_test.rb
new file mode 100644
index 0000000..72fd0f5
--- /dev/null
+++ b/test/exclude_test.rb
@@ -0,0 +1,69 @@
+require_relative "test_helper"
+
+class ExcludeTest < ActionDispatch::IntegrationTest
+  def test_track_bots_true
+    with_options(track_bots: true) do
+      get products_url, headers: {"User-Agent" => bot_user_agent}
+      assert_equal 1, Ahoy::Visit.count
+    end
+  end
+
+  def test_track_bots_false
+    with_options(track_bots: false) do
+      get products_url, headers: {"User-Agent" => bot_user_agent}
+      assert_equal 0, Ahoy::Visit.count
+    end
+  end
+
+  def test_bot_detection_version_1
+    with_options(track_bots: false, bot_detection_version: 1) do
+      get products_url, headers: {"User-Agent" => ""}
+      assert_equal 1, Ahoy::Visit.count
+    end
+  end
+
+  def test_bot_detection_version_2
+    with_options(track_bots: false, bot_detection_version: 2) do
+      get products_url, headers: {"User-Agent" => ""}
+      assert_equal 0, Ahoy::Visit.count
+    end
+  end
+
+  def test_exclude_method
+    calls = 0
+    exclude_method = lambda do |controller, request|
+      calls += 1
+      request.parameters["exclude"] == "t"
+    end
+    with_options(exclude_method: exclude_method) do
+      get products_url, params: {"exclude" => "t"}
+      assert_equal 0, Ahoy::Visit.count
+      assert_equal 1, calls
+      get products_url
+      assert_equal 1, Ahoy::Visit.count
+      assert_equal 2, calls
+    end
+  end
+
+  def test_exclude_method_cookies_false
+    calls = 0
+    exclude_method = lambda do |controller, request|
+      calls += 1
+      request.parameters["exclude"] == "t"
+    end
+    with_options(exclude_method: exclude_method, cookies: false) do
+      get products_url, params: {"exclude" => "t"}
+      assert_equal 0, Ahoy::Visit.count
+      assert_equal 1, calls
+      get products_url
+      assert_equal 1, Ahoy::Visit.count
+      assert_equal 2, calls
+    end
+  end
+
+  private
+
+  def bot_user_agent
+    "Mozilla/5.0 (compatible; DuckDuckBot-Https/1.1; https://duckduckgo.com/duckduckbot)"
+  end
+end
diff --git a/test/gemfiles/rails50.gemfile b/test/gemfiles/rails50.gemfile
deleted file mode 100644
index 32361e6..0000000
--- a/test/gemfiles/rails50.gemfile
+++ /dev/null
@@ -1,6 +0,0 @@
-source "https://rubygems.org"
-
-gemspec path: "../../"
-
-gem "rails", "~> 5.0.0"
-gem "sqlite3", "~> 1.3.0"
diff --git a/test/gemfiles/rails51.gemfile b/test/gemfiles/rails51.gemfile
deleted file mode 100644
index 40256bc..0000000
--- a/test/gemfiles/rails51.gemfile
+++ /dev/null
@@ -1,5 +0,0 @@
-source "https://rubygems.org"
-
-gemspec path: "../../"
-
-gem "rails", "~> 5.1.0"
diff --git a/test/gemfiles/rails52.gemfile b/test/gemfiles/rails52.gemfile
deleted file mode 100644
index d09c2ec..0000000
--- a/test/gemfiles/rails52.gemfile
+++ /dev/null
@@ -1,5 +0,0 @@
-source "https://rubygems.org"
-
-gemspec path: "../../"
-
-gem "rails", "~> 5.2.0"
diff --git a/test/geocode_test.rb b/test/geocode_test.rb
new file mode 100644
index 0000000..89eb324
--- /dev/null
+++ b/test/geocode_test.rb
@@ -0,0 +1,33 @@
+require_relative "test_helper"
+
+class GeocodeTest < ActionDispatch::IntegrationTest
+  include ActiveJob::TestHelper # for Rails < 6
+
+  def test_geocode_true
+    with_options(geocode: true) do
+      assert_enqueued_with(job: Ahoy::GeocodeV2Job, queue: "ahoy") do
+        get products_url
+      end
+    end
+  end
+
+  def test_geocode_false
+    with_options(geocode: false) do
+      get products_url
+      assert_equal 0, enqueued_jobs.size
+    end
+  end
+
+  def test_geocode_default
+    get products_url
+    assert_equal 0, enqueued_jobs.size
+  end
+
+  def test_job_queue
+    with_options(geocode: true, job_queue: :low_priority) do
+      assert_enqueued_with(job: Ahoy::GeocodeV2Job, queue: "low_priority") do
+        get products_url
+      end
+    end
+  end
+end
diff --git a/test/internal/app/controllers/application_controller.rb b/test/internal/app/controllers/application_controller.rb
new file mode 100644
index 0000000..1d0cc3b
--- /dev/null
+++ b/test/internal/app/controllers/application_controller.rb
@@ -0,0 +1,8 @@
+class ApplicationController < ActionController::Base
+  attr_accessor :ran_before_action
+  before_action :run_before_action
+
+  def run_before_action
+    self.ran_before_action = true
+  end
+end
diff --git a/test/internal/app/controllers/products_controller.rb b/test/internal/app/controllers/products_controller.rb
index f57d33f..3359cbd 100644
--- a/test/internal/app/controllers/products_controller.rb
+++ b/test/internal/app/controllers/products_controller.rb
@@ -1,6 +1,36 @@
-class ProductsController < ActionController::Base
+class ProductsController < ApplicationController
+  skip_before_action :track_ahoy_visit, only: [:no_visit]
+
   def index
     ahoy.track "Viewed products"
-    render json: {}
+    head :ok
+  end
+
+  def list
+    head :ok
+  end
+
+  def create
+    Product.create!
+    head :ok
+  end
+
+  def authenticate
+    ahoy.authenticate(User.last)
+    head :ok
+  end
+
+  def no_visit
+    head :ok
+  end
+
+  private
+
+  def current_user
+    @current_user ||= User.last
+  end
+
+  def true_user
+    @true_user ||= User.create!(name: "True User")
   end
 end
diff --git a/test/internal/app/models/product.rb b/test/internal/app/models/product.rb
new file mode 100644
index 0000000..bc5b63d
--- /dev/null
+++ b/test/internal/app/models/product.rb
@@ -0,0 +1,9 @@
+class Product < ApplicationRecord
+  visitable :ahoy_visit
+
+  after_create :track_create
+
+  def track_create
+    Ahoy.instance.track("Created product", product_id: id)
+  end
+end
diff --git a/test/internal/app/models/user.rb b/test/internal/app/models/user.rb
new file mode 100644
index 0000000..379658a
--- /dev/null
+++ b/test/internal/app/models/user.rb
@@ -0,0 +1,2 @@
+class User < ApplicationRecord
+end
diff --git a/test/internal/config/database.yml b/test/internal/config/database.yml
index b978119..7444180 100644
--- a/test/internal/config/database.yml
+++ b/test/internal/config/database.yml
@@ -1,3 +1,3 @@
 test:
-  adapter:  sqlite3
-  database: db/combustion_test.sqlite
+  adapter:  <%= ENV["ADAPTER"] || "sqlite3" %>
+  database: <%= ["postgresql", "mysql2"].include?(ENV["ADAPTER"]) ? "ahoy_test" : "db/combustion_test.sqlite" %>
diff --git a/test/internal/config/initializers/ahoy.rb b/test/internal/config/initializers/ahoy.rb
index 638277b..c720d25 100644
--- a/test/internal/config/initializers/ahoy.rb
+++ b/test/internal/config/initializers/ahoy.rb
@@ -2,3 +2,5 @@ class Ahoy::Store < Ahoy::DatabaseStore
 end
 
 Ahoy.track_bots = true
+Ahoy.api = true
+Ahoy.quiet = false
diff --git a/test/internal/config/routes.rb b/test/internal/config/routes.rb
index 30c77fd..3d26e14 100644
--- a/test/internal/config/routes.rb
+++ b/test/internal/config/routes.rb
@@ -1,3 +1,7 @@
 Rails.application.routes.draw do
-  resources :products, only: [:index]
+  resources :products, only: [:index, :create] do
+    get :list, on: :collection
+    get :authenticate, on: :collection
+    get :no_visit, on: :collection
+  end
 end
diff --git a/test/internal/db/schema.rb b/test/internal/db/schema.rb
index c0c4f01..76b39dd 100644
--- a/test/internal/db/schema.rb
+++ b/test/internal/db/schema.rb
@@ -40,7 +40,7 @@ ActiveRecord::Schema.define do
     t.string :os_version
     t.string :platform
 
-    t.timestamp :started_at
+    t.datetime :started_at
   end
 
   add_index :ahoy_visits, [:visit_token], unique: true
@@ -51,8 +51,17 @@ ActiveRecord::Schema.define do
 
     t.string :name
     t.text :properties
-    t.timestamp :time
+    t.datetime :time
   end
 
   add_index :ahoy_events, [:name, :time]
+
+  create_table :products do |t|
+    t.string :name
+    t.references :ahoy_visit
+  end
+
+  create_table :users do |t|
+    t.string :name
+  end
 end
diff --git a/test/mongoid_generator_test.rb b/test/mongoid_generator_test.rb
new file mode 100644
index 0000000..54828e7
--- /dev/null
+++ b/test/mongoid_generator_test.rb
@@ -0,0 +1,16 @@
+require_relative "test_helper"
+
+require "generators/ahoy/mongoid_generator"
+
+class MongoidGeneratorTest < Rails::Generators::TestCase
+  tests Ahoy::Generators::MongoidGenerator
+  destination File.expand_path("../tmp", __dir__)
+  setup :prepare_destination
+
+  def test_works
+    run_generator
+    assert_file "config/initializers/ahoy.rb", /DatabaseStore/
+    assert_file "app/models/ahoy/visit.rb", /Mongoid::Document/
+    assert_file "app/models/ahoy/event.rb", /Mongoid::Document/
+  end
+end
diff --git a/test/query_methods/mongoid_test.rb b/test/query_methods/mongoid_test.rb
index 9a0e05e..d536ffe 100644
--- a/test/query_methods/mongoid_test.rb
+++ b/test/query_methods/mongoid_test.rb
@@ -1,9 +1,10 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class MongoidEvent
   include Mongoid::Document
   include Ahoy::QueryMethods
 
+  field :name, type: String
   field :properties, type: Hash
 end
 
diff --git a/test/query_methods/mysql_json_test.rb b/test/query_methods/mysql_json_test.rb
index c6a01de..ba2ad20 100644
--- a/test/query_methods/mysql_json_test.rb
+++ b/test/query_methods/mysql_json_test.rb
@@ -1,4 +1,4 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class MysqlJsonEvent < MysqlBase
   serialize :properties, JSON if connection.send(:mariadb?)
diff --git a/test/query_methods/mysql_text_test.rb b/test/query_methods/mysql_text_test.rb
index d282419..f963bac 100644
--- a/test/query_methods/mysql_text_test.rb
+++ b/test/query_methods/mysql_text_test.rb
@@ -1,4 +1,4 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class MysqlTextEvent < MysqlBase
   serialize :properties, JSON
diff --git a/test/query_methods/postgresql_hstore_test.rb b/test/query_methods/postgresql_hstore_test.rb
index 8f0b1c0..e951dcb 100644
--- a/test/query_methods/postgresql_hstore_test.rb
+++ b/test/query_methods/postgresql_hstore_test.rb
@@ -1,4 +1,4 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class PostgresqlHstoreEvent < PostgresqlBase
 end
diff --git a/test/query_methods/postgresql_json_test.rb b/test/query_methods/postgresql_json_test.rb
index ed8ba20..91468f6 100644
--- a/test/query_methods/postgresql_json_test.rb
+++ b/test/query_methods/postgresql_json_test.rb
@@ -1,4 +1,4 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class PostgresqlJsonEvent < PostgresqlBase
 end
diff --git a/test/query_methods/postgresql_jsonb_test.rb b/test/query_methods/postgresql_jsonb_test.rb
index 8152c0d..e8283ea 100644
--- a/test/query_methods/postgresql_jsonb_test.rb
+++ b/test/query_methods/postgresql_jsonb_test.rb
@@ -1,4 +1,4 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class PostgresqlJsonbEvent < PostgresqlBase
 end
diff --git a/test/query_methods/postgresql_text_test.rb b/test/query_methods/postgresql_text_test.rb
index c206249..5d11934 100644
--- a/test/query_methods/postgresql_text_test.rb
+++ b/test/query_methods/postgresql_text_test.rb
@@ -1,4 +1,4 @@
-require_relative "../test_helper"
+require_relative "query_methods_helper"
 
 class PostgresqlTextEvent < PostgresqlBase
   serialize :properties, JSON
diff --git a/test/query_methods/query_methods_helper.rb b/test/query_methods/query_methods_helper.rb
new file mode 100644
index 0000000..3cc68ce
--- /dev/null
+++ b/test/query_methods/query_methods_helper.rb
@@ -0,0 +1,14 @@
+require_relative "../test_helper"
+
+case ENV["ADAPTER"]
+when "mysql2"
+  require_relative "../support/mysql"
+when "postgresql"
+  require_relative "../support/postgresql"
+when "mongoid"
+  require_relative "../support/mongoid"
+else
+  require_relative "../support/sqlite"
+end
+
+require_relative "../support/query_methods_test"
diff --git a/test/query_methods/sqlite_text_test.rb b/test/query_methods/sqlite_text_test.rb
new file mode 100644
index 0000000..5b8a741
--- /dev/null
+++ b/test/query_methods/sqlite_text_test.rb
@@ -0,0 +1,14 @@
+require_relative "query_methods_helper"
+
+class SqliteTextEvent < SqliteBase
+  self.table_name = "text_events"
+  serialize :properties, JSON
+end
+
+class SqliteTextTest < Minitest::Test
+  include QueryMethodsTest
+
+  def model
+    SqliteTextEvent
+  end
+end
diff --git a/test/query_methods_test.rb b/test/query_methods_test.rb
new file mode 100644
index 0000000..df05a3a
--- /dev/null
+++ b/test/query_methods_test.rb
@@ -0,0 +1,15 @@
+require_relative "test_helper"
+
+case ENV["ADAPTER"]
+when "mysql2"
+  require_relative "query_methods/mysql_json_test"
+  require_relative "query_methods/mysql_text_test"
+when "postgresql"
+  require_relative "query_methods/postgresql_hstore_test"
+  require_relative "query_methods/postgresql_json_test"
+  require_relative "query_methods/postgresql_jsonb_test"
+when "mongoid"
+  require_relative "query_methods/mongoid_test"
+else
+  require_relative "query_methods/sqlite_text_test"
+end
diff --git a/test/support/mongoid_models/ahoy/event.rb b/test/support/mongoid_models/ahoy/event.rb
new file mode 100644
index 0000000..8e2a918
--- /dev/null
+++ b/test/support/mongoid_models/ahoy/event.rb
@@ -0,0 +1,14 @@
+class Ahoy::Event
+  include Mongoid::Document
+
+  # associations
+  belongs_to :visit, class_name: "Ahoy::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
diff --git a/test/support/mongoid_models/ahoy/visit.rb b/test/support/mongoid_models/ahoy/visit.rb
new file mode 100644
index 0000000..1838a29
--- /dev/null
+++ b/test/support/mongoid_models/ahoy/visit.rb
@@ -0,0 +1,49 @@
+class 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
diff --git a/test/support/mongoid_models/product.rb b/test/support/mongoid_models/product.rb
new file mode 100644
index 0000000..7ba8183
--- /dev/null
+++ b/test/support/mongoid_models/product.rb
@@ -0,0 +1,13 @@
+class Product
+  include Mongoid::Document
+
+  field :name, type: String
+
+  visitable :ahoy_visit
+
+  after_create :track_create
+
+  def track_create
+    Ahoy.instance.track("Created product", product_id: id)
+  end
+end
diff --git a/test/support/mongoid_models/user.rb b/test/support/mongoid_models/user.rb
new file mode 100644
index 0000000..ab58969
--- /dev/null
+++ b/test/support/mongoid_models/user.rb
@@ -0,0 +1,5 @@
+class User
+  include Mongoid::Document
+
+  field :name, type: String
+end
diff --git a/test/support/mysql.rb b/test/support/mysql.rb
index a3d91b5..701e03e 100644
--- a/test/support/mysql.rb
+++ b/test/support/mysql.rb
@@ -1,15 +1,16 @@
-ActiveRecord::Base.establish_connection adapter: "mysql2", username: "root", database: "ahoy_test"
+ActiveRecord::Schema.define do
+  create_table :mysql_text_events, force: true do |t|
+    t.string :name
+    t.text :properties
+  end
 
-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
+  create_table :mysql_json_events, force: true do |t|
+    t.string :name
+    t.json :properties
+  end
 end
 
 class MysqlBase < ActiveRecord::Base
   include Ahoy::QueryMethods
-  establish_connection adapter: "mysql2", username: "root", database: "ahoy_test"
   self.abstract_class = true
 end
diff --git a/test/support/postgresql.rb b/test/support/postgresql.rb
index d8734af..185d163 100644
--- a/test/support/postgresql.rb
+++ b/test/support/postgresql.rb
@@ -1,26 +1,29 @@
-ActiveRecord::Base.establish_connection adapter: "postgresql", database: "ahoy_test"
+ActiveRecord::Schema.define do
+  enable_extension "hstore"
 
-ActiveRecord::Migration.enable_extension "hstore"
+  create_table :postgresql_hstore_events, force: true do |t|
+    t.string :name
+    t.hstore :properties
+  end
 
-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
+  create_table :postgresql_json_events, force: true do |t|
+    t.string :name
+    t.json :properties
+  end
 
-ActiveRecord::Migration.create_table :postgresql_jsonb_events, force: true do |t|
-  t.jsonb :properties
-  t.index :properties, using: :gin
-end
+  create_table :postgresql_jsonb_events, force: true do |t|
+    t.string :name
+    t.jsonb :properties
+    t.index :properties, using: :gin
+  end
 
-ActiveRecord::Migration.create_table :postgresql_text_events, force: true do |t|
-  t.text :properties
+  create_table :postgresql_text_events, force: true do |t|
+    t.string :name
+    t.text :properties
+  end
 end
 
 class PostgresqlBase < ActiveRecord::Base
   include Ahoy::QueryMethods
-  establish_connection adapter: "postgresql", database: "ahoy_test"
   self.abstract_class = true
 end
diff --git a/test/support/query_methods_test.rb b/test/support/query_methods_test.rb
index 7e83353..583e3a3 100644
--- a/test/support/query_methods_test.rb
+++ b/test/support/query_methods_test.rb
@@ -69,11 +69,123 @@ module QueryMethodsTest
     assert_equal 0, count_events(value: 1)
   end
 
+  def test_group_string
+    skip unless group_supported?
+
+    create_event value: "hello"
+    create_event value: "hello"
+    create_event value: "world"
+    expected = {"hello" => 2, "world" => 1}
+    assert_equal expected, group_events
+  end
+
+  def test_group_number
+    skip unless group_supported?
+
+    create_event value: 1
+    create_event value: 1
+    create_event value: 9
+
+    expected = {1 => 2, 9 => 1}
+    expected.transform_keys!(&:to_s) if mysql? || mariadb? || hstore?
+
+    assert_equal expected, group_events
+  end
+
+  def test_group_boolean
+    skip unless group_supported?
+
+    create_event value: true
+    create_event value: true
+    create_event value: false
+
+    expected = {true => 2, false => 1}
+    expected.transform_keys! { |k| k ? 1 : 0 } if sqlite?
+    expected.transform_keys!(&:to_s) if mysql? || mariadb? || hstore?
+
+    assert_equal expected, group_events
+  end
+
+  def test_group_nil
+    skip unless group_supported?
+
+    create_event value: nil
+    create_event value: nil
+    create_event value: "world"
+
+    expected = {nil => 2, "world" => 1}
+    expected.transform_keys! { |k| k.nil? ? "null" : k.to_s } if mysql? || mariadb?
+
+    assert_equal expected, group_events
+  end
+
+  def test_group_multiple
+    skip unless group_supported?
+
+    create_event value: "hello", other: 1
+    create_event value: "hello", other: 1
+    create_event value: "hello", other: 2
+    create_event value: "world", other: 2
+
+    expected = {["hello", 1] => 2, ["hello", 2] => 1, ["world", 2] => 1}
+    expected.transform_keys! { |k| k.map(&:to_s) } if mysql? || mariadb? || hstore?
+
+    assert_equal expected, model.group_prop(:value, :other).count
+    assert_equal expected, model.group_prop([:value, :other]).count
+  end
+
+  def test_where_event
+    model.create!(name: "Test 1", properties: {value: 1})
+    model.create!(name: "Test 1", properties: {value: 2})
+    model.create!(name: "Test 2", properties: {value: 1})
+    assert_equal 2, model.where_event("Test 1").count
+    assert_equal 1, model.where_event("Test 1", {value: 1}).count
+  end
+
+  def test_scopes
+    model.create!(name: "Test 1", properties: {value: "hello"})
+    model.create!(name: "Test 1", properties: {value: "world"})
+    model.create!(name: "Test 2", properties: {value: "hello"})
+
+    assert_equal 2, model.where_props(value: "hello").count
+    assert_equal 1, model.where(name: "Test 1").where_props(value: "hello").count
+    assert_equal 1, model.where_props(value: "hello").where(name: "Test 1").count
+
+    if group_supported?
+      assert_equal({"hello" => 1, "world" => 1}, model.where(name: "Test 1").group_prop(:value).count)
+      assert_equal({"hello" => 1, "world" => 1}, model.where_event("Test 1").group_prop(:value).count)
+    end
+  end
+
   def create_event(properties)
     model.create(properties: properties)
   end
 
   def count_events(properties)
-    model.where_properties(properties).count
+    model.where_props(properties).count
+  end
+
+  def group_events
+    model.group_prop(:value).count
+  end
+
+  def sqlite?
+    self.class.name =~ /sqlite/i
+  end
+
+  def mysql?
+    self.class.name =~ /mysql/i && !model.connection.try(:mariadb?)
+  end
+
+  def mariadb?
+    self.class.name =~ /mysql/i && model.connection.try(:mariadb?)
+  end
+
+  def hstore?
+    self.class.name == "PostgresqlHstoreTest"
+  end
+
+  def group_supported?
+    self.class.name != "MongoidTest"
   end
 end
diff --git a/test/support/sqlite.rb b/test/support/sqlite.rb
new file mode 100644
index 0000000..66b7173
--- /dev/null
+++ b/test/support/sqlite.rb
@@ -0,0 +1,11 @@
+ActiveRecord::Schema.define do
+  create_table :text_events, force: true do |t|
+    t.string :name
+    t.text :properties
+  end
+end
+
+class SqliteBase < ActiveRecord::Base
+  include Ahoy::QueryMethods
+  self.abstract_class = true
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index f8ae4f8..7d1296b 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -3,26 +3,67 @@ require "combustion"
 Bundler.require(:default)
 require "minitest/autorun"
 require "minitest/pride"
-require "active_record"
-require "mongoid"
+
+ENV["ADAPTER"] ||= "sqlite3"
+puts "Using #{ENV["ADAPTER"]}"
+
+logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil)
+
+frameworks = [:action_controller, :active_job]
+
+if ENV["ADAPTER"] == "mongoid"
+  require_relative "support/mongoid"
+
+  Dir.glob("support/mongoid_models/**/*.rb", base: __dir__) do |file|
+    require_relative file
+  end
+
+  Mongoid.logger = logger
+  Mongo::Logger.logger = logger
+else
+  frameworks << :active_record
+end
 
 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
+Combustion.initialize!(*frameworks) do
+  if ENV["ADAPTER"] != "mongoid"
+    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
+    config.active_record.logger = logger
+  end
+
+  if ActiveSupport::VERSION::MAJOR >= 7
+    config.active_support.use_rfc4122_namespaced_uuids = true
   end
 
-  logger = ActiveSupport::Logger.new(STDOUT)
-  config.active_record.logger = logger if ENV["VERBOSE"]
-  config.action_mailer.logger = logger if ENV["VERBOSE"]
+  config.action_controller.logger = logger
+  config.active_job.logger = logger
 end
 
-# run setup / migrations
-require_relative "support/mysql"
-require_relative "support/postgresql"
-require_relative "support/mongoid"
+Ahoy.logger = logger
 
-# restore connection
-ActiveRecord::Base.establish_connection(:test)
+class Minitest::Test
+  def setup
+    Ahoy::Visit.delete_all
+    Ahoy::Event.delete_all
+    User.delete_all
+  end
 
-require_relative "support/query_methods_test"
+  def with_options(options)
+    previous_options = {}
+    options.each_key do |k|
+      previous_options[k] = Ahoy.send(k)
+    end
+    begin
+      options.each do |k, v|
+        Ahoy.send("#{k}=", v)
+      end
+      yield
+    ensure
+      previous_options.each do |k, v|
+        Ahoy.send("#{k}=", v)
+      end
+    end
+  end
+end
diff --git a/test/tracker_test.rb b/test/tracker_test.rb
index 920ae59..51f40a7 100644
--- a/test/tracker_test.rb
+++ b/test/tracker_test.rb
@@ -3,12 +3,36 @@ require_relative "test_helper"
 class TrackerTest < Minitest::Test
   def test_no_request
     ahoy = Ahoy::Tracker.new
-    assert ahoy.track("Some event", some_prop: true)
+    ahoy.track("Some event", some_prop: true)
+
+    event = Ahoy::Event.last
+    assert_equal "Some event", event.name
+    assert_equal({"some_prop" => true}, event.properties)
+    assert_nil event.user_id
+  end
+
+  def test_no_cookies
+    request = ActionDispatch::TestRequest.create
+
+    with_options(cookies: false) do
+      ahoy = Ahoy::Tracker.new(request: request)
+      ahoy.track("Some event", some_prop: true)
+    end
+
+    event = Ahoy::Event.last
+    assert_equal "Some event", event.name
+    assert_equal({"some_prop" => true}, event.properties)
+    assert_nil event.user_id
   end
 
   def test_user_option
-    user = OpenStruct.new(id: "123")
+    user = OpenStruct.new(id: 123)
     ahoy = Ahoy::Tracker.new(user: user)
     assert_equal ahoy.user.id, user.id
+
+    ahoy.track("Some event", some_prop: true)
+
+    event = Ahoy::Event.last
+    assert_equal user.id, event.user_id
   end
 end
diff --git a/test/user_test.rb b/test/user_test.rb
new file mode 100644
index 0000000..65bd892
--- /dev/null
+++ b/test/user_test.rb
@@ -0,0 +1,44 @@
+require_relative "test_helper"
+
+class UserTest < ActionDispatch::IntegrationTest
+  def test_user
+    User.create!(name: "Test User")
+    get products_url
+    visit = Ahoy::Visit.last
+    assert_equal "Test User", visit.user.name
+  end
+
+  def test_user_method_symbol
+    with_options(user_method: :true_user) do
+      get products_url
+      visit = Ahoy::Visit.last
+      assert_equal "True User", visit.user.name
+    end
+  end
+
+  def test_user_method_callable
+    with_options(user_method: ->(controller) { controller.send(:true_user) }) do
+      get products_url
+      visit = Ahoy::Visit.last
+      assert_equal "True User", visit.user.name
+    end
+  end
+
+  def test_user_method_callable_request
+    with_options(user_method: ->(controller, request) { request.env["action_controller.instance"].send(:true_user) }) do
+      get products_url
+      visit = Ahoy::Visit.last
+      assert_equal "True User", visit.user.name
+    end
+  end
+
+  def test_authenticate
+    get products_url
+    visit = Ahoy::Visit.last
+    assert_nil visit.user
+    user = User.create!
+    get authenticate_products_url
+    visit.reload
+    assert_equal user, visit.user
+  end
+end
diff --git a/test/visit_properties_test.rb b/test/visit_properties_test.rb
new file mode 100644
index 0000000..fbab68d
--- /dev/null
+++ b/test/visit_properties_test.rb
@@ -0,0 +1,55 @@
+require_relative "test_helper"
+
+class VisitPropertiesTest < ActionDispatch::IntegrationTest
+  def test_standard
+    referrer = "http://www.example.com"
+    get products_url, headers: {"Referer" => referrer}
+
+    visit = Ahoy::Visit.last
+    assert_equal referrer, visit.referrer
+    assert_equal "www.example.com", visit.referring_domain
+    assert_equal "http://www.example.com/products", visit.landing_page
+    assert_equal "127.0.0.1", visit.ip
+  end
+
+  def test_utm_params
+    get products_url(
+      utm_source: "test-source",
+      utm_medium: "test-medium",
+      utm_term: "test-term",
+      utm_content: "test-content",
+      utm_campaign: "test-campaign"
+    )
+
+    visit = Ahoy::Visit.last
+    assert_equal "test-source", visit.utm_source
+    assert_equal "test-medium", visit.utm_medium
+    assert_equal "test-term", visit.utm_term
+    assert_equal "test-content", visit.utm_content
+    assert_equal "test-campaign", visit.utm_campaign
+  end
+
+  def test_tech
+    user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:78.0) Gecko/20100101 Firefox/78.0"
+    get products_url, headers: {"User-Agent" => user_agent}
+
+    visit = Ahoy::Visit.last
+    assert_equal user_agent, visit.user_agent
+    assert_equal "Firefox", visit.browser
+    assert_equal "Mac", visit.os
+    assert_equal "Desktop", visit.device_type
+  end
+
+  def test_legacy_user_agent_parser
+    with_options(user_agent_parser: :legacy) do
+      user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:78.0) Gecko/20100101 Firefox/78.0"
+      get products_url, headers: {"User-Agent" => user_agent}
+
+      visit = Ahoy::Visit.last
+      assert_equal user_agent, visit.user_agent
+      assert_equal "Firefox", visit.browser
+      assert_equal "Mac OS X", visit.os
+      assert_equal "Desktop", visit.device_type
+    end
+  end
+end
diff --git a/test/visitable_test.rb b/test/visitable_test.rb
new file mode 100644
index 0000000..ad89b35
--- /dev/null
+++ b/test/visitable_test.rb
@@ -0,0 +1,9 @@
+require_relative "test_helper"
+
+class VisitableTest < ActionDispatch::IntegrationTest
+  def test_visitable
+    post products_url
+    visit = Ahoy::Visit.last
+    assert_equal visit, Product.last.ahoy_visit
+  end
+end
diff --git a/vendor/assets/javascripts/ahoy.js b/vendor/assets/javascripts/ahoy.js
index 221b473..f9f9c06 100644
--- a/vendor/assets/javascripts/ahoy.js
+++ b/vendor/assets/javascripts/ahoy.js
@@ -1,113 +1,15 @@
-/*
- * Ahoy.js
+/*!
+ * Ahoy.js v0.4.2
  * 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;
+  (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ahoy = factory());
+})(this, (function () { 'use strict';
 
   // https://www.quirksmode.org/js/cookies.html
 
@@ -123,7 +25,7 @@
       if (domain) {
         cookieDomain = "; domain=" + domain;
       }
-      document.cookie = name + "=" + escape(value) + expires + cookieDomain + "; path=/";
+      document.cookie = name + "=" + escape(value) + expires + cookieDomain + "; path=/; samesite=lax";
     },
     get: function (name) {
       var i, c;
@@ -155,14 +57,16 @@
     cookieDomain: null,
     headers: {},
     visitParams: {},
-    withCredentials: false
+    withCredentials: false,
+    visitDuration: 4 * 60, // default 4 hours
+    visitorDuration: 2 * 365 * 24 * 60 // default 2 years
   };
 
   var ahoy = window.ahoy || window.Ahoy || {};
 
   ahoy.configure = function (options) {
     for (var key in options) {
-      if (options.hasOwnProperty(key)) {
+      if (Object.prototype.hasOwnProperty.call(options, key)) {
         config[key] = options[key];
       }
     }
@@ -173,8 +77,6 @@
 
   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";
@@ -196,6 +98,16 @@
     return (config.useBeacon || config.trackNow) && isEmpty(config.headers) && canStringify && typeof(window.navigator.sendBeacon) !== "undefined" && !config.withCredentials;
   }
 
+  function serialize(object) {
+    var data = new FormData();
+    for (var key in object) {
+      if (Object.prototype.hasOwnProperty.call(object, key)) {
+        data.append(key, object[key]);
+      }
+    }
+    return data;
+  }
+
   // cookies
 
   function setCookie(name, value, ttl) {
@@ -224,13 +136,13 @@
     isReady = true;
   }
 
-  function ready(callback) {
+  ahoy.ready = function (callback) {
     if (isReady) {
       callback();
     } else {
       queue.push(callback);
     }
-  }
+  };
 
   function matchesSelector(element, selector) {
     var matches = element.matches ||
@@ -241,30 +153,44 @@
       element.webkitMatchesSelector;
 
     if (matches) {
-      return matches.apply(element, [selector]);
+      if (matches.apply(element, [selector])) {
+        return element;
+      } else if (element.parentElement) {
+        return matchesSelector(element.parentElement, selector);
+      }
+      return null;
     } else {
       log("Unable to match");
-      return false;
+      return null;
     }
   }
 
   function onEvent(eventName, selector, callback) {
     document.addEventListener(eventName, function (e) {
-      if (matchesSelector(e.target, selector)) {
-        callback(e);
+      var matchedElement = matchesSelector(e.target, selector);
+      if (matchedElement) {
+        var skip = getClosest(matchedElement, "data-ahoy-skip");
+        if (skip !== null && skip !== "false") { return; }
+
+        callback.call(matchedElement, e);
       }
     });
   }
 
   // http://beeker.io/jquery-document-ready-equivalent-vanilla-javascript
   function documentReady(callback) {
-    document.readyState === "interactive" || document.readyState === "complete" ? callback() : document.addEventListener("DOMContentLoaded", callback);
+    if (document.readyState === "interactive" || document.readyState === "complete") {
+      setTimeout(callback, 0);
+    } else {
+      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 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+      var r = Math.random() * 16 | 0;
+      var v = c === 'x' ? r : (r & 0x3 | 0x8);
       return v.toString(16);
     });
   }
@@ -294,7 +220,7 @@
 
   function sendRequest(url, data, success) {
     if (canStringify) {
-      if ($) {
+      if ($ && $.ajax) {
         $.ajax({
           type: "POST",
           url: url,
@@ -314,11 +240,11 @@
         xhr.withCredentials = config.withCredentials;
         xhr.setRequestHeader("Content-Type", "application/json");
         for (var header in config.headers) {
-          if (config.headers.hasOwnProperty(header)) {
+          if (Object.prototype.hasOwnProperty.call(config.headers, header)) {
             xhr.setRequestHeader(header, config.headers[header]);
           }
         }
-        xhr.onload = function() {
+        xhr.onload = function () {
           if (xhr.status === 200) {
             success();
           }
@@ -343,11 +269,11 @@
   }
 
   function trackEvent(event) {
-    ready( function () {
-      sendRequest(eventsUrl(), eventData(event), function() {
+    ahoy.ready(function () {
+      sendRequest(eventsUrl(), eventData(event), function () {
         // remove from queue
         for (var i = 0; i < eventQueue.length; i++) {
-          if (eventQueue[i].id == event.id) {
+          if (eventQueue[i].id === event.id) {
             eventQueue.splice(i, 1);
             break;
           }
@@ -358,7 +284,7 @@
   }
 
   function trackEventNow(event) {
-    ready( function () {
+    ahoy.ready(function () {
       var data = eventData(event);
       var param = csrfParam();
       var token = csrfToken();
@@ -366,7 +292,7 @@
       // stringify so we keep the type
       data.events_json = JSON.stringify(data.events);
       delete data.events;
-      window.navigator.sendBeacon(eventsUrl(), objectToFormdata(data));
+      window.navigator.sendBeacon(eventsUrl(), serialize(data));
     });
   }
 
@@ -380,7 +306,7 @@
 
   function cleanObject(obj) {
     for (var key in obj) {
-      if (obj.hasOwnProperty(key)) {
+      if (Object.prototype.hasOwnProperty.call(obj, key)) {
         if (obj[key] === null) {
           delete obj[key];
         }
@@ -389,21 +315,20 @@
     return obj;
   }
 
-  function eventProperties(e) {
-    var target = e.target;
+  function eventProperties() {
     return cleanObject({
-      tag: target.tagName.toLowerCase(),
-      id: presence(target.id),
-      "class": presence(target.className),
+      tag: this.tagName.toLowerCase(),
+      id: presence(this.id),
+      "class": presence(this.className),
       page: page(),
-      section: getClosestSection(target)
+      section: getClosest(this, "data-section")
     });
   }
 
-  function getClosestSection(element) {
-    for ( ; element && element !== document; element = element.parentNode) {
-      if (element.hasAttribute('data-section')) {
-        return element.getAttribute('data-section');
+  function getClosest(element, attribute) {
+    for (; element && element !== document; element = element.parentNode) {
+      if (element.hasAttribute(attribute)) {
+        return element.getAttribute(attribute);
       }
     }
 
@@ -427,7 +352,7 @@
     } else {
       if (!visitId) {
         visitId = generateId();
-        setCookie("ahoy_visit", visitId, visitTtl);
+        setCookie("ahoy_visit", visitId, config.visitDuration);
       }
 
       // make sure cookies are enabled
@@ -436,7 +361,7 @@
 
         if (!visitorId) {
           visitorId = generateId();
-          setCookie("ahoy_visitor", visitorId, visitorTtl);
+          setCookie("ahoy_visitor", visitorId, config.visitorDuration);
         }
 
         var data = {
@@ -455,7 +380,7 @@
         }
 
         for (var key in config.visitParams) {
-          if (config.visitParams.hasOwnProperty(key)) {
+          if (Object.prototype.hasOwnProperty.call(config.visitParams, key)) {
             data[key] = config.visitParams[key];
           }
         }
@@ -509,12 +434,12 @@
       js: true
     };
 
-    ready( function () {
+    ahoy.ready(function () {
       if (config.cookies && !ahoy.getVisitId()) {
         createVisit();
       }
 
-      ready( function () {
+      ahoy.ready(function () {
         log(event);
 
         event.visit_token = ahoy.getVisitId();
@@ -527,7 +452,7 @@
           saveEventQueue();
 
           // wait in case navigating to reduce duplicate events
-          setTimeout( function () {
+          setTimeout(function () {
             trackEvent(event);
           }, 1000);
         }
@@ -545,8 +470,8 @@
     };
 
     if (additionalProperties) {
-      for(var propName in additionalProperties) {
-        if (additionalProperties.hasOwnProperty(propName)) {
+      for (var propName in additionalProperties) {
+        if (Object.prototype.hasOwnProperty.call(additionalProperties, propName)) {
           properties[propName] = additionalProperties[propName];
         }
       }
@@ -554,37 +479,39 @@
     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.trackClicks = function (selector) {
+    if (selector === undefined) {
+      throw new Error("Missing selector");
+    }
+    onEvent("click", selector, function (e) {
+      var properties = eventProperties.call(this, e);
+      properties.text = properties.tag === "input" ? this.value : (this.textContent || this.innerText || this.innerHTML).replace(/[\s\r\n]+/g, " ").trim();
+      properties.href = this.href;
       ahoy.track("$click", properties);
     });
   };
 
-  ahoy.trackSubmits = function () {
-    onEvent("submit", "form", function (e) {
-      var properties = eventProperties(e);
+  ahoy.trackSubmits = function (selector) {
+    if (selector === undefined) {
+      throw new Error("Missing selector");
+    }
+    onEvent("submit", selector, function (e) {
+      var properties = eventProperties.call(this, e);
       ahoy.track("$submit", properties);
     });
   };
 
-  ahoy.trackChanges = function () {
-    onEvent("change", "input, textarea, select", function (e) {
-      var properties = eventProperties(e);
+  ahoy.trackChanges = function (selector) {
+    log("trackChanges is deprecated and will be removed in 0.5.0");
+    if (selector === undefined) {
+      throw new Error("Missing selector");
+    }
+    onEvent("change", selector, function (e) {
+      var properties = eventProperties.call(this, 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") || "[]");
@@ -602,7 +529,7 @@
     ahoy.start = function () {};
   };
 
-  documentReady(function() {
+  documentReady(function () {
     if (config.startOnReady) {
       ahoy.start();
     }
@@ -610,4 +537,4 @@
 
   return ahoy;
 
-})));
+}));

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/share/rubygems-integration/all/specifications/ahoy_matey-4.2.1.gemspec

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/share/rubygems-integration/all/specifications/ahoy_matey-3.0.2.gemspec

No differences were encountered in the control files

More details

Full run details