ahoy_matey 4.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/LICENSE.txt +1 -1
- data/README.md +57 -67
- data/app/controllers/ahoy/events_controller.rb +13 -2
- data/lib/ahoy/base_store.rb +4 -1
- data/lib/ahoy/controller.rb +9 -4
- data/lib/ahoy/database_store.rb +6 -4
- data/lib/ahoy/query_methods.rb +2 -2
- data/lib/ahoy/tracker.rb +15 -19
- data/lib/ahoy/version.rb +1 -1
- data/lib/ahoy.rb +49 -13
- data/lib/ahoy_matey.rb +1 -1
- data/lib/generators/ahoy/activerecord_generator.rb +19 -11
- data/lib/generators/ahoy/templates/active_record_event_model.rb.tt +1 -1
- data/lib/generators/ahoy/templates/active_record_migration.rb.tt +8 -8
- data/lib/generators/ahoy/templates/mongoid_visit_model.rb.tt +1 -0
- data/vendor/assets/javascripts/ahoy.js +36 -29
- metadata +13 -14
- data/app/jobs/ahoy/geocode_job.rb +0 -11
- /data/{app/jobs → lib}/ahoy/geocode_v2_job.rb +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9161dead45561a928523eb7d5ed70be20c67c8629499c35cb428271d714895a7
|
4
|
+
data.tar.gz: f17eb958ee297a4eda86388e0458a04a2a39de5b868076521eb65dd6e4cbc21a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c68fa7030244573a75b72167d163a8a0f2cfb5ef01e3718980ea25e5c13a5936973d0d5f83d3302f9e610508d94758ec0587f6fe4dd19c7c23668181ea1dcfe0
|
7
|
+
data.tar.gz: 7950a3cd2dda3818e21a6da37ab0d1b49b01d22110c76f0ae1abd15abba7aaa6140db24d70cedb4d5af0c5022f2b95a2f7e5a11339425eefb51240bb9ea17578
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,37 @@
|
|
1
|
+
## 5.2.0 (2024-09-04)
|
2
|
+
|
3
|
+
- Improved error handling for invalid API parameters
|
4
|
+
|
5
|
+
## 5.1.0 (2024-03-26)
|
6
|
+
|
7
|
+
- Added support for Trilogy
|
8
|
+
- Updated Ahoy.js to 0.4.4
|
9
|
+
|
10
|
+
## 5.0.2 (2023-10-05)
|
11
|
+
|
12
|
+
- Excluded visits from Rails health check
|
13
|
+
|
14
|
+
## 5.0.1 (2023-10-01)
|
15
|
+
|
16
|
+
- Fixed error with geocoding with anonymity sets
|
17
|
+
|
18
|
+
## 5.0.0 (2023-10-01)
|
19
|
+
|
20
|
+
- Changed visits to expire with anonymity sets
|
21
|
+
- Fixed error when Active Job is not available
|
22
|
+
- Fixed deprecation warning with Rails 7.1
|
23
|
+
- Dropped support for Ruby < 3 and Rails < 6.1
|
24
|
+
- Dropped support for Mongoid 6
|
25
|
+
|
26
|
+
## 4.2.1 (2023-02-23)
|
27
|
+
|
28
|
+
- Updated Ahoy.js to 0.4.2
|
29
|
+
|
30
|
+
## 4.2.0 (2023-02-07)
|
31
|
+
|
32
|
+
- Added primary key type to generated migration
|
33
|
+
- Updated Ahoy.js to 0.4.1
|
34
|
+
|
1
35
|
## 4.1.0 (2022-06-12)
|
2
36
|
|
3
37
|
- Ensure `exclude_method` is only called once per request
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
@@ -4,13 +4,13 @@
|
|
4
4
|
|
5
5
|
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.
|
6
6
|
|
7
|
-
**Ahoy
|
7
|
+
**Ahoy 5.0 was recently released** - see [how to upgrade](#upgrading)
|
8
8
|
|
9
9
|
: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
|
10
10
|
|
11
11
|
:tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
|
12
12
|
|
13
|
-
[](https://github.com/ankane/ahoy/actions)
|
14
14
|
|
15
15
|
## Installation
|
16
16
|
|
@@ -48,7 +48,7 @@ And restart your web server.
|
|
48
48
|
|
49
49
|
### JavaScript
|
50
50
|
|
51
|
-
For Rails 7
|
51
|
+
For Importmap (Rails 7 default), add to `config/importmap.rb`:
|
52
52
|
|
53
53
|
```ruby
|
54
54
|
pin "ahoy", to: "ahoy.js"
|
@@ -60,7 +60,7 @@ And add to `app/javascript/application.js`:
|
|
60
60
|
import "ahoy"
|
61
61
|
```
|
62
62
|
|
63
|
-
For Rails 6
|
63
|
+
For Webpacker (Rails 6 default), run:
|
64
64
|
|
65
65
|
```sh
|
66
66
|
yarn add ahoy.js
|
@@ -72,7 +72,7 @@ And add to `app/javascript/packs/application.js`:
|
|
72
72
|
import ahoy from "ahoy.js"
|
73
73
|
```
|
74
74
|
|
75
|
-
For
|
75
|
+
For Sprockets, add to `app/assets/javascripts/application.js`:
|
76
76
|
|
77
77
|
```javascript
|
78
78
|
//= require ahoy
|
@@ -197,7 +197,7 @@ Order.joins(:ahoy_visit).group("device_type").count
|
|
197
197
|
Here’s what the migration to add the `ahoy_visit_id` column should look like:
|
198
198
|
|
199
199
|
```ruby
|
200
|
-
class AddAhoyVisitToOrders < ActiveRecord::Migration[7.
|
200
|
+
class AddAhoyVisitToOrders < ActiveRecord::Migration[7.2]
|
201
201
|
def change
|
202
202
|
add_reference :orders, :ahoy_visit
|
203
203
|
end
|
@@ -212,7 +212,7 @@ visitable :sign_up_visit
|
|
212
212
|
|
213
213
|
### Users
|
214
214
|
|
215
|
-
Ahoy automatically attaches the `current_user` to the visit. With [Devise](https://github.com/
|
215
|
+
Ahoy automatically attaches the `current_user` to the visit. With [Devise](https://github.com/heartcombo/devise), it attaches the user even if they sign in after the visit starts.
|
216
216
|
|
217
217
|
With other authentication frameworks, add this to the end of your sign in method:
|
218
218
|
|
@@ -262,28 +262,6 @@ class ApplicationController < ActionController::Base
|
|
262
262
|
end
|
263
263
|
```
|
264
264
|
|
265
|
-
#### Knock
|
266
|
-
|
267
|
-
To attach the user with [Knock](https://github.com/nsarno/knock), either include `Knock::Authenticable`in `ApplicationController`:
|
268
|
-
|
269
|
-
```ruby
|
270
|
-
class ApplicationController < ActionController::API
|
271
|
-
include Knock::Authenticable
|
272
|
-
end
|
273
|
-
```
|
274
|
-
|
275
|
-
Or include it in Ahoy:
|
276
|
-
|
277
|
-
```ruby
|
278
|
-
Ahoy::BaseController.include Knock::Authenticable
|
279
|
-
```
|
280
|
-
|
281
|
-
And use:
|
282
|
-
|
283
|
-
```ruby
|
284
|
-
Ahoy.user_method = ->(controller) { controller.send(:authenticate_entity, "user") }
|
285
|
-
```
|
286
|
-
|
287
265
|
### Exclusions
|
288
266
|
|
289
267
|
Bots are excluded from tracking by default. To include them, use:
|
@@ -308,6 +286,14 @@ By default, a new visit is created after 4 hours of inactivity. Change this with
|
|
308
286
|
Ahoy.visit_duration = 30.minutes
|
309
287
|
```
|
310
288
|
|
289
|
+
### Visitor Duration
|
290
|
+
|
291
|
+
By default, a new `visitor_token` is generated after 2 years. Change this with:
|
292
|
+
|
293
|
+
```ruby
|
294
|
+
Ahoy.visitor_duration = 30.days
|
295
|
+
```
|
296
|
+
|
311
297
|
### Cookies
|
312
298
|
|
313
299
|
To track visits across multiple subdomains, use:
|
@@ -326,15 +312,15 @@ You can also [disable cookies](#anonymity-sets--cookies)
|
|
326
312
|
|
327
313
|
### Token Generation
|
328
314
|
|
329
|
-
Ahoy uses random UUIDs for visit and visitor tokens by default, but you can use your own generator like [
|
315
|
+
Ahoy uses random UUIDs for visit and visitor tokens by default, but you can use your own generator like [ULID](https://github.com/rafaelsales/ulid).
|
330
316
|
|
331
317
|
```ruby
|
332
|
-
Ahoy.token_generator = -> {
|
318
|
+
Ahoy.token_generator = -> { ULID.generate }
|
333
319
|
```
|
334
320
|
|
335
321
|
### Throttling
|
336
322
|
|
337
|
-
You can use [Rack::Attack](https://github.com/
|
323
|
+
You can use [Rack::Attack](https://github.com/rack/rack-attack) to throttle requests to the API.
|
338
324
|
|
339
325
|
```ruby
|
340
326
|
class Rack::Attack
|
@@ -378,13 +364,17 @@ Ahoy.job_queue = :low_priority
|
|
378
364
|
|
379
365
|
### Local Geocoding
|
380
366
|
|
381
|
-
For privacy and performance, we recommend geocoding locally.
|
367
|
+
For privacy and performance, we recommend geocoding locally.
|
368
|
+
|
369
|
+
For city-level geocoding, download the [GeoLite2 City database](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data).
|
370
|
+
|
371
|
+
Add this line to your application’s Gemfile:
|
382
372
|
|
383
373
|
```ruby
|
384
374
|
gem "maxminddb"
|
385
375
|
```
|
386
376
|
|
387
|
-
|
377
|
+
And create `config/initializers/geocoder.rb` with:
|
388
378
|
|
389
379
|
```ruby
|
390
380
|
Geocoder.configure(
|
@@ -401,6 +391,12 @@ For country-level geocoding, install the `geoip-database` package. It’s preins
|
|
401
391
|
sudo apt-get install geoip-database
|
402
392
|
```
|
403
393
|
|
394
|
+
Add this line to your application’s Gemfile:
|
395
|
+
|
396
|
+
```ruby
|
397
|
+
gem "geoip"
|
398
|
+
```
|
399
|
+
|
404
400
|
And create `config/initializers/geocoder.rb` with:
|
405
401
|
|
406
402
|
```ruby
|
@@ -450,7 +446,7 @@ class Ahoy::Store < Ahoy::DatabaseStore
|
|
450
446
|
end
|
451
447
|
|
452
448
|
Ahoy.mask_ips = true
|
453
|
-
Ahoy.cookies =
|
449
|
+
Ahoy.cookies = :none
|
454
450
|
```
|
455
451
|
|
456
452
|
This:
|
@@ -488,20 +484,20 @@ end
|
|
488
484
|
|
489
485
|
### Anonymity Sets & Cookies
|
490
486
|
|
491
|
-
Ahoy can switch from cookies to [anonymity sets](https://privacypatterns.org/patterns/Anonymity-set). Instead of cookies, visitors with the same IP mask and user agent are grouped together in an
|
487
|
+
Ahoy can switch from cookies to [anonymity sets](https://privacypatterns.org/patterns/Anonymity-set). Instead of cookies, visitors with the same IP mask and user agent are grouped together in an anonymity set.
|
492
488
|
|
493
489
|
```ruby
|
494
|
-
Ahoy.cookies =
|
490
|
+
Ahoy.cookies = :none
|
495
491
|
```
|
496
492
|
|
493
|
+
Note: If Ahoy was installed before v5, [add an index](#50) before making this change.
|
494
|
+
|
497
495
|
Previously set cookies are automatically deleted. If you use JavaScript tracking, also set:
|
498
496
|
|
499
497
|
```javascript
|
500
498
|
ahoy.configure({cookies: false});
|
501
499
|
```
|
502
500
|
|
503
|
-
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.
|
504
|
-
|
505
501
|
## Data Retention
|
506
502
|
|
507
503
|
Data should only be retained for as long as it’s needed. Delete older data with:
|
@@ -639,7 +635,7 @@ end
|
|
639
635
|
|
640
636
|
[Blazer](https://github.com/ankane/blazer) is a great tool for exploring your data.
|
641
637
|
|
642
|
-
With
|
638
|
+
With Active Record, you can do:
|
643
639
|
|
644
640
|
```ruby
|
645
641
|
Ahoy::Visit.group(:search_keyword).count
|
@@ -777,19 +773,29 @@ Send a `POST` request to `/ahoy/events` with `Content-Type: application/json` an
|
|
777
773
|
|
778
774
|
## Upgrading
|
779
775
|
|
780
|
-
###
|
776
|
+
### 5.0
|
781
777
|
|
782
|
-
|
778
|
+
Visits now expire with anonymity sets. If using `Ahoy.cookies = false`, a new index is needed.
|
783
779
|
|
784
|
-
|
780
|
+
For Active Record, create a migration with:
|
785
781
|
|
786
|
-
|
782
|
+
```ruby
|
783
|
+
add_index :ahoy_visits, [:visitor_token, :started_at]
|
784
|
+
```
|
787
785
|
|
788
|
-
|
789
|
-
gem "geocoder"
|
790
|
-
```
|
786
|
+
For Mongoid, set:
|
791
787
|
|
792
|
-
|
788
|
+
```ruby
|
789
|
+
class Ahoy::Visit
|
790
|
+
index({visitor_token: 1, started_at: 1})
|
791
|
+
end
|
792
|
+
```
|
793
|
+
|
794
|
+
Create the index before upgrading, and set:
|
795
|
+
|
796
|
+
```ruby
|
797
|
+
Ahoy.cookies = :none
|
798
|
+
```
|
793
799
|
|
794
800
|
## History
|
795
801
|
|
@@ -813,26 +819,10 @@ bundle install
|
|
813
819
|
bundle exec rake test
|
814
820
|
```
|
815
821
|
|
816
|
-
To test
|
822
|
+
To test different adapters, use:
|
817
823
|
|
818
824
|
```sh
|
825
|
+
ADAPTER=postgresql bundle exec rake test
|
826
|
+
ADAPTER=mysql2 bundle exec rake test
|
819
827
|
ADAPTER=mongoid bundle exec rake test
|
820
828
|
```
|
821
|
-
|
822
|
-
To test query methods, use:
|
823
|
-
|
824
|
-
```sh
|
825
|
-
# Postgres
|
826
|
-
createdb ahoy_test
|
827
|
-
bundle exec rake test:query_methods:postgresql
|
828
|
-
|
829
|
-
# SQLite
|
830
|
-
bundle exec rake test:query_methods:sqlite
|
831
|
-
|
832
|
-
# MySQL and MariaDB
|
833
|
-
mysqladmin create ahoy_test
|
834
|
-
bundle exec rake test:query_methods:mysql
|
835
|
-
|
836
|
-
# MongoDB
|
837
|
-
bundle exec rake test:query_methods:mongoid
|
838
|
-
```
|
@@ -17,12 +17,23 @@ module Ahoy
|
|
17
17
|
begin
|
18
18
|
ActiveSupport::JSON.decode(data)
|
19
19
|
rescue ActiveSupport::JSON.parse_error
|
20
|
-
#
|
20
|
+
# TODO change to nil in Ahoy 6
|
21
21
|
[]
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|
25
|
-
|
25
|
+
max_events_per_request = Ahoy.max_events_per_request
|
26
|
+
|
27
|
+
# check before creating any events
|
28
|
+
unless events.is_a?(Array) && events.first(max_events_per_request).all? { |v| v.is_a?(Hash) }
|
29
|
+
logger.info "[ahoy] Invalid parameters"
|
30
|
+
# :unprocessable_entity is probably more correct
|
31
|
+
# but keep consistent with missing parameters for now
|
32
|
+
render plain: "Invalid parameters\n", status: :bad_request
|
33
|
+
return
|
34
|
+
end
|
35
|
+
|
36
|
+
events.first(max_events_per_request).each do |event|
|
26
37
|
time = Time.zone.parse(event["time"]) rescue nil
|
27
38
|
|
28
39
|
# timestamp is deprecated
|
data/lib/ahoy/base_store.rb
CHANGED
@@ -3,6 +3,7 @@ module Ahoy
|
|
3
3
|
attr_writer :user
|
4
4
|
|
5
5
|
def initialize(options)
|
6
|
+
@user = options[:user]
|
6
7
|
@options = options
|
7
8
|
end
|
8
9
|
|
@@ -36,7 +37,9 @@ module Ahoy
|
|
36
37
|
end
|
37
38
|
|
38
39
|
def exclude?
|
39
|
-
(!Ahoy.track_bots && bot?) ||
|
40
|
+
(!Ahoy.track_bots && bot?) ||
|
41
|
+
exclude_by_method? ||
|
42
|
+
(defined?(Rails::HealthController) && controller.is_a?(Rails::HealthController))
|
40
43
|
end
|
41
44
|
|
42
45
|
def generate_id
|
data/lib/ahoy/controller.rb
CHANGED
@@ -5,9 +5,8 @@ module Ahoy
|
|
5
5
|
base.helper_method :current_visit
|
6
6
|
base.helper_method :ahoy
|
7
7
|
end
|
8
|
-
base.before_action :set_ahoy_cookies, unless: -> { Ahoy.api_only }
|
9
8
|
base.before_action :track_ahoy_visit, unless: -> { Ahoy.api_only }
|
10
|
-
base.around_action :set_ahoy_request_store
|
9
|
+
base.around_action :set_ahoy_request_store, unless: -> { Ahoy.api_only }
|
11
10
|
end
|
12
11
|
|
13
12
|
def ahoy
|
@@ -19,7 +18,7 @@ module Ahoy
|
|
19
18
|
end
|
20
19
|
|
21
20
|
def set_ahoy_cookies
|
22
|
-
if Ahoy.cookies
|
21
|
+
if Ahoy.cookies?
|
23
22
|
ahoy.set_visitor_cookie
|
24
23
|
ahoy.set_visit_cookie
|
25
24
|
else
|
@@ -31,11 +30,17 @@ module Ahoy
|
|
31
30
|
def track_ahoy_visit
|
32
31
|
defer = Ahoy.server_side_visits != true
|
33
32
|
|
34
|
-
if defer && !Ahoy.cookies
|
33
|
+
if defer && !Ahoy.cookies?
|
35
34
|
# avoid calling new_visit?, which triggers a database call
|
35
|
+
elsif !Ahoy.cookies? && ahoy.exclude?
|
36
|
+
# avoid calling new_visit?, which triggers a database call
|
37
|
+
# may or may not be a new visit
|
38
|
+
Ahoy.log("Request excluded")
|
36
39
|
elsif ahoy.new_visit?
|
37
40
|
ahoy.track_visit(defer: defer)
|
38
41
|
end
|
42
|
+
|
43
|
+
set_ahoy_cookies
|
39
44
|
end
|
40
45
|
|
41
46
|
def set_ahoy_request_store
|
data/lib/ahoy/database_store.rb
CHANGED
@@ -53,11 +53,13 @@ module Ahoy
|
|
53
53
|
|
54
54
|
def visit
|
55
55
|
unless defined?(@visit)
|
56
|
-
if
|
57
|
-
# find_by raises error by default when not found
|
58
|
-
@visit = visit_model.where(visit_token: ahoy.visit_token).
|
56
|
+
if ahoy.send(:existing_visit_token) || ahoy.instance_variable_get(:@visit_token)
|
57
|
+
# find_by raises error by default with Mongoid when not found
|
58
|
+
@visit = visit_model.where(visit_token: ahoy.visit_token).take if ahoy.visit_token
|
59
|
+
elsif !Ahoy.cookies? && ahoy.visitor_token
|
60
|
+
@visit = visit_model.where(visitor_token: ahoy.visitor_token).where(started_at: Ahoy.visit_duration.ago..).order(started_at: :desc).first
|
59
61
|
else
|
60
|
-
@visit =
|
62
|
+
@visit = nil
|
61
63
|
end
|
62
64
|
end
|
63
65
|
@visit
|
data/lib/ahoy/query_methods.rb
CHANGED
@@ -14,7 +14,7 @@ module Ahoy
|
|
14
14
|
case adapter_name
|
15
15
|
when "mongoid"
|
16
16
|
where(properties.to_h { |k, v| ["properties.#{k}", v] })
|
17
|
-
when /mysql/
|
17
|
+
when /mysql|trilogy/
|
18
18
|
where("JSON_CONTAINS(properties, ?, '$') = 1", properties.to_json)
|
19
19
|
when /postgres|postgis/
|
20
20
|
case columns_hash["properties"].type
|
@@ -54,7 +54,7 @@ module Ahoy
|
|
54
54
|
case adapter_name
|
55
55
|
when "mongoid"
|
56
56
|
raise "Adapter not supported: #{adapter_name}"
|
57
|
-
when /mysql/
|
57
|
+
when /mysql|trilogy/
|
58
58
|
props.each do |prop|
|
59
59
|
quoted_prop = connection.quote("$.#{prop}")
|
60
60
|
relation = relation.group("JSON_UNQUOTE(JSON_EXTRACT(properties, #{quoted_prop}))")
|
data/lib/ahoy/tracker.rb
CHANGED
@@ -49,7 +49,7 @@ module Ahoy
|
|
49
49
|
visit_token: visit_token,
|
50
50
|
visitor_token: visitor_token,
|
51
51
|
user_id: user.try(:id),
|
52
|
-
started_at: trusted_time(started_at)
|
52
|
+
started_at: trusted_time(started_at)
|
53
53
|
}.merge(visit_properties).select { |_, v| v }
|
54
54
|
|
55
55
|
@store.track_visit(data)
|
@@ -99,7 +99,7 @@ module Ahoy
|
|
99
99
|
end
|
100
100
|
|
101
101
|
def new_visit?
|
102
|
-
Ahoy.cookies ? !existing_visit_token : visit.nil?
|
102
|
+
Ahoy.cookies? ? !existing_visit_token : visit.nil?
|
103
103
|
end
|
104
104
|
|
105
105
|
def new_visitor?
|
@@ -145,6 +145,13 @@ module Ahoy
|
|
145
145
|
delete_cookie("ahoy_track")
|
146
146
|
end
|
147
147
|
|
148
|
+
def exclude?
|
149
|
+
unless defined?(@exclude)
|
150
|
+
@exclude = @store.exclude?
|
151
|
+
end
|
152
|
+
@exclude
|
153
|
+
end
|
154
|
+
|
148
155
|
protected
|
149
156
|
|
150
157
|
def api?
|
@@ -153,7 +160,7 @@ module Ahoy
|
|
153
160
|
|
154
161
|
# private, but used by API
|
155
162
|
def missing_params?
|
156
|
-
if Ahoy.cookies && api? && Ahoy.protect_from_forgery
|
163
|
+
if Ahoy.cookies? && api? && Ahoy.protect_from_forgery
|
157
164
|
!(existing_visit_token && existing_visitor_token)
|
158
165
|
else
|
159
166
|
false
|
@@ -162,7 +169,7 @@ module Ahoy
|
|
162
169
|
|
163
170
|
def set_cookie(name, value, duration = nil, use_domain = true)
|
164
171
|
# safety net
|
165
|
-
return unless Ahoy.cookies && request
|
172
|
+
return unless Ahoy.cookies? && request
|
166
173
|
|
167
174
|
cookie = Ahoy.cookie_options.merge(value: value)
|
168
175
|
cookie[:expires] = duration.from_now if duration
|
@@ -184,13 +191,6 @@ module Ahoy
|
|
184
191
|
end
|
185
192
|
end
|
186
193
|
|
187
|
-
def exclude?
|
188
|
-
unless defined?(@exclude)
|
189
|
-
@exclude = @store.exclude?
|
190
|
-
end
|
191
|
-
@exclude
|
192
|
-
end
|
193
|
-
|
194
194
|
def report_exception(e)
|
195
195
|
if defined?(ActionDispatch::RemoteIp::IpSpoofAttackError) && e.is_a?(ActionDispatch::RemoteIp::IpSpoofAttackError)
|
196
196
|
debug "Tracking excluded due to IP spoofing"
|
@@ -207,7 +207,7 @@ module Ahoy
|
|
207
207
|
def visit_token_helper
|
208
208
|
@visit_token_helper ||= begin
|
209
209
|
token = existing_visit_token
|
210
|
-
token ||=
|
210
|
+
token ||= visit&.visit_token unless Ahoy.cookies?
|
211
211
|
token ||= generate_id unless Ahoy.api_only
|
212
212
|
token
|
213
213
|
end
|
@@ -216,7 +216,7 @@ module Ahoy
|
|
216
216
|
def visitor_token_helper
|
217
217
|
@visitor_token_helper ||= begin
|
218
218
|
token = existing_visitor_token
|
219
|
-
token ||= visitor_anonymity_set unless Ahoy.cookies
|
219
|
+
token ||= visitor_anonymity_set unless Ahoy.cookies?
|
220
220
|
token ||= generate_id unless Ahoy.api_only
|
221
221
|
token
|
222
222
|
end
|
@@ -225,7 +225,7 @@ module Ahoy
|
|
225
225
|
def existing_visit_token
|
226
226
|
@existing_visit_token ||= begin
|
227
227
|
token = visit_header
|
228
|
-
token ||= visit_cookie if Ahoy.cookies && !(api? && Ahoy.protect_from_forgery)
|
228
|
+
token ||= visit_cookie if Ahoy.cookies? && !(api? && Ahoy.protect_from_forgery)
|
229
229
|
token ||= visit_param if api?
|
230
230
|
token
|
231
231
|
end
|
@@ -234,16 +234,12 @@ module Ahoy
|
|
234
234
|
def existing_visitor_token
|
235
235
|
@existing_visitor_token ||= begin
|
236
236
|
token = visitor_header
|
237
|
-
token ||= visitor_cookie if Ahoy.cookies && !(api? && Ahoy.protect_from_forgery)
|
237
|
+
token ||= visitor_cookie if Ahoy.cookies? && !(api? && Ahoy.protect_from_forgery)
|
238
238
|
token ||= visitor_param if api?
|
239
239
|
token
|
240
240
|
end
|
241
241
|
end
|
242
242
|
|
243
|
-
def visit_anonymity_set
|
244
|
-
@visit_anonymity_set ||= Digest::UUID.uuid_v5(UUID_NAMESPACE, ["visit", Ahoy.mask_ip(request.remote_ip), request.user_agent].join("/"))
|
245
|
-
end
|
246
|
-
|
247
243
|
def visitor_anonymity_set
|
248
244
|
@visitor_anonymity_set ||= Digest::UUID.uuid_v5(UUID_NAMESPACE, ["visitor", Ahoy.mask_ip(request.remote_ip), request.user_agent].join("/"))
|
249
245
|
end
|
data/lib/ahoy/version.rb
CHANGED
data/lib/ahoy.rb
CHANGED
@@ -7,27 +7,59 @@ require "active_support/core_ext"
|
|
7
7
|
require "safely/core"
|
8
8
|
|
9
9
|
# modules
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
10
|
+
require_relative "ahoy/utils"
|
11
|
+
require_relative "ahoy/base_store"
|
12
|
+
require_relative "ahoy/controller"
|
13
|
+
require_relative "ahoy/database_store"
|
14
|
+
require_relative "ahoy/helper"
|
15
|
+
require_relative "ahoy/model"
|
16
|
+
require_relative "ahoy/query_methods"
|
17
|
+
require_relative "ahoy/tracker"
|
18
|
+
require_relative "ahoy/version"
|
19
|
+
require_relative "ahoy/visit_properties"
|
20
|
+
|
21
|
+
require_relative "ahoy/engine" if defined?(Rails)
|
22
22
|
|
23
23
|
module Ahoy
|
24
|
+
# activejob optional
|
25
|
+
autoload :GeocodeV2Job, "ahoy/geocode_v2_job"
|
26
|
+
|
24
27
|
mattr_accessor :visit_duration
|
25
28
|
self.visit_duration = 4.hours
|
26
29
|
|
27
30
|
mattr_accessor :visitor_duration
|
28
31
|
self.visitor_duration = 2.years
|
29
32
|
|
30
|
-
|
33
|
+
def self.cookies=(value)
|
34
|
+
if value == false
|
35
|
+
if defined?(Mongoid::Document) && defined?(Ahoy::Visit) && Ahoy::Visit < Mongoid::Document
|
36
|
+
raise <<~EOS
|
37
|
+
This feature requires a new index in Ahoy 5. Set:
|
38
|
+
|
39
|
+
class Ahoy::Visit
|
40
|
+
index({visitor_token: 1, started_at: 1})
|
41
|
+
end
|
42
|
+
|
43
|
+
Create the index before upgrading, and set:
|
44
|
+
|
45
|
+
Ahoy.cookies = :none
|
46
|
+
EOS
|
47
|
+
else
|
48
|
+
raise <<~EOS
|
49
|
+
This feature requires a new index in Ahoy 5. Create a migration with:
|
50
|
+
|
51
|
+
add_index :ahoy_visits, [:visitor_token, :started_at]
|
52
|
+
|
53
|
+
Run it before upgrading, and set:
|
54
|
+
|
55
|
+
Ahoy.cookies = :none
|
56
|
+
EOS
|
57
|
+
end
|
58
|
+
end
|
59
|
+
@@cookies = value
|
60
|
+
end
|
61
|
+
|
62
|
+
mattr_reader :cookies
|
31
63
|
self.cookies = true
|
32
64
|
|
33
65
|
# TODO deprecate in favor of cookie_options
|
@@ -94,6 +126,10 @@ module Ahoy
|
|
94
126
|
logger.info { "[ahoy] #{message}" } if logger
|
95
127
|
end
|
96
128
|
|
129
|
+
def self.cookies?
|
130
|
+
cookies && cookies != :none
|
131
|
+
end
|
132
|
+
|
97
133
|
def self.mask_ip(ip)
|
98
134
|
addr = IPAddr.new(ip)
|
99
135
|
if addr.ipv4?
|
data/lib/ahoy_matey.rb
CHANGED
@@ -1 +1 @@
|
|
1
|
-
|
1
|
+
require_relative "ahoy"
|
@@ -21,7 +21,7 @@ module Ahoy
|
|
21
21
|
case adapter
|
22
22
|
when /postg/i # postgres, postgis
|
23
23
|
"jsonb"
|
24
|
-
when /mysql/i
|
24
|
+
when /mysql|trilogy/i
|
25
25
|
"json"
|
26
26
|
else
|
27
27
|
"text"
|
@@ -33,23 +33,31 @@ module Ahoy
|
|
33
33
|
properties_type == "text" || (properties_type == "json" && ActiveRecord::Base.connection.try(:mariadb?))
|
34
34
|
end
|
35
35
|
|
36
|
-
|
37
|
-
|
38
|
-
def adapter
|
39
|
-
if ActiveRecord::VERSION::STRING.to_f >= 6.1
|
40
|
-
ActiveRecord::Base.connection_db_config.adapter.to_s
|
41
|
-
else
|
42
|
-
ActiveRecord::Base.connection_config[:adapter].to_s
|
43
|
-
end
|
36
|
+
def serialize_options
|
37
|
+
ActiveRecord::VERSION::STRING.to_f >= 7.1 ? "coder: JSON" : "JSON"
|
44
38
|
end
|
45
39
|
|
46
|
-
|
47
|
-
|
40
|
+
# use connection_db_config instead of connection.adapter
|
41
|
+
# so database connection isn't needed
|
42
|
+
def adapter
|
43
|
+
ActiveRecord::Base.connection_db_config.adapter.to_s
|
48
44
|
end
|
49
45
|
|
50
46
|
def migration_version
|
51
47
|
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
52
48
|
end
|
49
|
+
|
50
|
+
def primary_key_type
|
51
|
+
", id: :#{key_type}" if key_type
|
52
|
+
end
|
53
|
+
|
54
|
+
def foreign_key_type
|
55
|
+
", type: :#{key_type}" if key_type
|
56
|
+
end
|
57
|
+
|
58
|
+
def key_type
|
59
|
+
Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
|
60
|
+
end
|
53
61
|
end
|
54
62
|
end
|
55
63
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
2
|
def change
|
3
|
-
create_table :ahoy_visits do |t|
|
3
|
+
create_table :ahoy_visits<%= primary_key_type %> do |t|
|
4
4
|
t.string :visit_token
|
5
5
|
t.string :visitor_token
|
6
6
|
|
@@ -8,7 +8,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
|
|
8
8
|
# simply remove any you don't want
|
9
9
|
|
10
10
|
# user
|
11
|
-
t.references :user
|
11
|
+
t.references :user<%= foreign_key_type %>
|
12
12
|
|
13
13
|
# standard
|
14
14
|
t.string :ip
|
@@ -45,18 +45,18 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
|
|
45
45
|
end
|
46
46
|
|
47
47
|
add_index :ahoy_visits, :visit_token, unique: true
|
48
|
+
add_index :ahoy_visits, [:visitor_token, :started_at]
|
48
49
|
|
49
|
-
create_table :ahoy_events do |t|
|
50
|
-
t.references :visit
|
51
|
-
t.references :user
|
50
|
+
create_table :ahoy_events<%= primary_key_type %> do |t|
|
51
|
+
t.references :visit<%= foreign_key_type %>
|
52
|
+
t.references :user<%= foreign_key_type %>
|
52
53
|
|
53
54
|
t.string :name
|
54
55
|
t.<%= properties_type %> :properties
|
55
56
|
t.datetime :time
|
56
57
|
end
|
57
58
|
|
58
|
-
add_index :ahoy_events, [:name, :time]<% if properties_type == "jsonb"
|
59
|
-
add_index :ahoy_events, :properties, using: :gin, opclass: :jsonb_path_ops<%
|
60
|
-
add_index :ahoy_events, "properties jsonb_path_ops", using: "gin"<% end %><% end %>
|
59
|
+
add_index :ahoy_events, [:name, :time]<% if properties_type == "jsonb" %>
|
60
|
+
add_index :ahoy_events, :properties, using: :gin, opclass: :jsonb_path_ops<% end %>
|
61
61
|
end
|
62
62
|
end
|
@@ -1,16 +1,15 @@
|
|
1
1
|
/*!
|
2
|
-
* Ahoy.js
|
2
|
+
* Ahoy.js v0.4.4
|
3
3
|
* Simple, powerful JavaScript analytics
|
4
4
|
* https://github.com/ankane/ahoy.js
|
5
|
-
* v0.4.0
|
6
5
|
* MIT License
|
7
6
|
*/
|
8
7
|
|
9
8
|
(function (global, factory) {
|
10
9
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
|
11
10
|
typeof define === 'function' && define.amd ? define(factory) :
|
12
|
-
(global = global || self, global.ahoy = factory());
|
13
|
-
}(this, (function () { 'use strict';
|
11
|
+
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ahoy = factory());
|
12
|
+
})(this, (function () { 'use strict';
|
14
13
|
|
15
14
|
// https://www.quirksmode.org/js/cookies.html
|
16
15
|
|
@@ -67,7 +66,7 @@
|
|
67
66
|
|
68
67
|
ahoy.configure = function (options) {
|
69
68
|
for (var key in options) {
|
70
|
-
if (
|
69
|
+
if (Object.prototype.hasOwnProperty.call(options, key)) {
|
71
70
|
config[key] = options[key];
|
72
71
|
}
|
73
72
|
}
|
@@ -102,7 +101,7 @@
|
|
102
101
|
function serialize(object) {
|
103
102
|
var data = new FormData();
|
104
103
|
for (var key in object) {
|
105
|
-
if (
|
104
|
+
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
106
105
|
data.append(key, object[key]);
|
107
106
|
}
|
108
107
|
}
|
@@ -170,6 +169,9 @@
|
|
170
169
|
document.addEventListener(eventName, function (e) {
|
171
170
|
var matchedElement = matchesSelector(e.target, selector);
|
172
171
|
if (matchedElement) {
|
172
|
+
var skip = getClosest(matchedElement, "data-ahoy-skip");
|
173
|
+
if (skip !== null && skip !== "false") { return; }
|
174
|
+
|
173
175
|
callback.call(matchedElement, e);
|
174
176
|
}
|
175
177
|
});
|
@@ -186,8 +188,13 @@
|
|
186
188
|
|
187
189
|
// https://stackoverflow.com/a/2117523/1177228
|
188
190
|
function generateId() {
|
189
|
-
|
190
|
-
|
191
|
+
if (window.crypto && window.crypto.randomUUID) {
|
192
|
+
return window.crypto.randomUUID();
|
193
|
+
}
|
194
|
+
|
195
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
196
|
+
var r = Math.random() * 16 | 0;
|
197
|
+
var v = c === 'x' ? r : (r & 0x3 | 0x8);
|
191
198
|
return v.toString(16);
|
192
199
|
});
|
193
200
|
}
|
@@ -237,11 +244,11 @@
|
|
237
244
|
xhr.withCredentials = config.withCredentials;
|
238
245
|
xhr.setRequestHeader("Content-Type", "application/json");
|
239
246
|
for (var header in config.headers) {
|
240
|
-
if (
|
247
|
+
if (Object.prototype.hasOwnProperty.call(config.headers, header)) {
|
241
248
|
xhr.setRequestHeader(header, config.headers[header]);
|
242
249
|
}
|
243
250
|
}
|
244
|
-
xhr.onload = function() {
|
251
|
+
xhr.onload = function () {
|
245
252
|
if (xhr.status === 200) {
|
246
253
|
success();
|
247
254
|
}
|
@@ -266,11 +273,11 @@
|
|
266
273
|
}
|
267
274
|
|
268
275
|
function trackEvent(event) {
|
269
|
-
ahoy.ready(
|
270
|
-
sendRequest(eventsUrl(), eventData(event), function() {
|
276
|
+
ahoy.ready(function () {
|
277
|
+
sendRequest(eventsUrl(), eventData(event), function () {
|
271
278
|
// remove from queue
|
272
279
|
for (var i = 0; i < eventQueue.length; i++) {
|
273
|
-
if (eventQueue[i].id
|
280
|
+
if (eventQueue[i].id === event.id) {
|
274
281
|
eventQueue.splice(i, 1);
|
275
282
|
break;
|
276
283
|
}
|
@@ -281,7 +288,7 @@
|
|
281
288
|
}
|
282
289
|
|
283
290
|
function trackEventNow(event) {
|
284
|
-
ahoy.ready(
|
291
|
+
ahoy.ready(function () {
|
285
292
|
var data = eventData(event);
|
286
293
|
var param = csrfParam();
|
287
294
|
var token = csrfToken();
|
@@ -303,7 +310,7 @@
|
|
303
310
|
|
304
311
|
function cleanObject(obj) {
|
305
312
|
for (var key in obj) {
|
306
|
-
if (
|
313
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
307
314
|
if (obj[key] === null) {
|
308
315
|
delete obj[key];
|
309
316
|
}
|
@@ -318,14 +325,14 @@
|
|
318
325
|
id: presence(this.id),
|
319
326
|
"class": presence(this.className),
|
320
327
|
page: page(),
|
321
|
-
section:
|
328
|
+
section: getClosest(this, "data-section")
|
322
329
|
});
|
323
330
|
}
|
324
331
|
|
325
|
-
function
|
326
|
-
for (
|
327
|
-
if (element.hasAttribute(
|
328
|
-
return element.getAttribute(
|
332
|
+
function getClosest(element, attribute) {
|
333
|
+
for (; element && element !== document; element = element.parentNode) {
|
334
|
+
if (element.hasAttribute(attribute)) {
|
335
|
+
return element.getAttribute(attribute);
|
329
336
|
}
|
330
337
|
}
|
331
338
|
|
@@ -377,7 +384,7 @@
|
|
377
384
|
}
|
378
385
|
|
379
386
|
for (var key in config.visitParams) {
|
380
|
-
if (
|
387
|
+
if (Object.prototype.hasOwnProperty.call(config.visitParams, key)) {
|
381
388
|
data[key] = config.visitParams[key];
|
382
389
|
}
|
383
390
|
}
|
@@ -431,12 +438,12 @@
|
|
431
438
|
js: true
|
432
439
|
};
|
433
440
|
|
434
|
-
ahoy.ready(
|
441
|
+
ahoy.ready(function () {
|
435
442
|
if (config.cookies && !ahoy.getVisitId()) {
|
436
443
|
createVisit();
|
437
444
|
}
|
438
445
|
|
439
|
-
ahoy.ready(
|
446
|
+
ahoy.ready(function () {
|
440
447
|
log(event);
|
441
448
|
|
442
449
|
event.visit_token = ahoy.getVisitId();
|
@@ -449,7 +456,7 @@
|
|
449
456
|
saveEventQueue();
|
450
457
|
|
451
458
|
// wait in case navigating to reduce duplicate events
|
452
|
-
setTimeout(
|
459
|
+
setTimeout(function () {
|
453
460
|
trackEvent(event);
|
454
461
|
}, 1000);
|
455
462
|
}
|
@@ -467,8 +474,8 @@
|
|
467
474
|
};
|
468
475
|
|
469
476
|
if (additionalProperties) {
|
470
|
-
for(var propName in additionalProperties) {
|
471
|
-
if (
|
477
|
+
for (var propName in additionalProperties) {
|
478
|
+
if (Object.prototype.hasOwnProperty.call(additionalProperties, propName)) {
|
472
479
|
properties[propName] = additionalProperties[propName];
|
473
480
|
}
|
474
481
|
}
|
@@ -482,7 +489,7 @@
|
|
482
489
|
}
|
483
490
|
onEvent("click", selector, function (e) {
|
484
491
|
var properties = eventProperties.call(this, e);
|
485
|
-
properties.text = properties.tag
|
492
|
+
properties.text = properties.tag === "input" ? this.value : (this.textContent || this.innerText || this.innerHTML).replace(/[\s\r\n]+/g, " ").trim();
|
486
493
|
properties.href = this.href;
|
487
494
|
ahoy.track("$click", properties);
|
488
495
|
});
|
@@ -526,7 +533,7 @@
|
|
526
533
|
ahoy.start = function () {};
|
527
534
|
};
|
528
535
|
|
529
|
-
documentReady(function() {
|
536
|
+
documentReady(function () {
|
530
537
|
if (config.startOnReady) {
|
531
538
|
ahoy.start();
|
532
539
|
}
|
@@ -534,4 +541,4 @@
|
|
534
541
|
|
535
542
|
return ahoy;
|
536
543
|
|
537
|
-
}))
|
544
|
+
}));
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ahoy_matey
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 5.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-09-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -16,42 +16,42 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '6.1'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '6.1'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: device_detector
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - ">="
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
33
|
+
version: '1'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - ">="
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version:
|
40
|
+
version: '1'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
42
|
+
name: safely_block
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
44
44
|
requirements:
|
45
45
|
- - ">="
|
46
46
|
- !ruby/object:Gem::Version
|
47
|
-
version: '0'
|
47
|
+
version: '0.4'
|
48
48
|
type: :runtime
|
49
49
|
prerelease: false
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
51
51
|
requirements:
|
52
52
|
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
|
-
version: '0'
|
54
|
+
version: '0.4'
|
55
55
|
description:
|
56
56
|
email: andrew@ankane.org
|
57
57
|
executables: []
|
@@ -65,14 +65,13 @@ files:
|
|
65
65
|
- app/controllers/ahoy/base_controller.rb
|
66
66
|
- app/controllers/ahoy/events_controller.rb
|
67
67
|
- app/controllers/ahoy/visits_controller.rb
|
68
|
-
- app/jobs/ahoy/geocode_job.rb
|
69
|
-
- app/jobs/ahoy/geocode_v2_job.rb
|
70
68
|
- config/routes.rb
|
71
69
|
- lib/ahoy.rb
|
72
70
|
- lib/ahoy/base_store.rb
|
73
71
|
- lib/ahoy/controller.rb
|
74
72
|
- lib/ahoy/database_store.rb
|
75
73
|
- lib/ahoy/engine.rb
|
74
|
+
- lib/ahoy/geocode_v2_job.rb
|
76
75
|
- lib/ahoy/helper.rb
|
77
76
|
- lib/ahoy/model.rb
|
78
77
|
- lib/ahoy/query_methods.rb
|
@@ -106,14 +105,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
106
105
|
requirements:
|
107
106
|
- - ">="
|
108
107
|
- !ruby/object:Gem::Version
|
109
|
-
version: '
|
108
|
+
version: '3'
|
110
109
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
111
110
|
requirements:
|
112
111
|
- - ">="
|
113
112
|
- !ruby/object:Gem::Version
|
114
113
|
version: '0'
|
115
114
|
requirements: []
|
116
|
-
rubygems_version: 3.
|
115
|
+
rubygems_version: 3.5.11
|
117
116
|
signing_key:
|
118
117
|
specification_version: 4
|
119
118
|
summary: Simple, powerful, first-party analytics for Rails
|
File without changes
|