ahoy_matey 2.0.0 → 3.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.
Files changed (52) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +112 -37
  3. data/CONTRIBUTING.md +9 -7
  4. data/LICENSE.txt +1 -1
  5. data/README.md +377 -63
  6. data/app/controllers/ahoy/base_controller.rb +14 -10
  7. data/app/controllers/ahoy/events_controller.rb +1 -1
  8. data/app/controllers/ahoy/visits_controller.rb +1 -0
  9. data/app/jobs/ahoy/geocode_v2_job.rb +3 -4
  10. data/lib/ahoy.rb +57 -2
  11. data/lib/ahoy/base_store.rb +32 -3
  12. data/lib/ahoy/controller.rb +21 -8
  13. data/lib/ahoy/database_store.rb +33 -16
  14. data/lib/ahoy/engine.rb +3 -1
  15. data/lib/ahoy/helper.rb +40 -0
  16. data/lib/ahoy/model.rb +2 -2
  17. data/lib/ahoy/query_methods.rb +46 -1
  18. data/lib/ahoy/tracker.rb +59 -27
  19. data/lib/ahoy/utils.rb +7 -0
  20. data/lib/ahoy/version.rb +1 -1
  21. data/lib/ahoy/visit_properties.rb +73 -37
  22. data/lib/generators/ahoy/activerecord_generator.rb +17 -26
  23. data/lib/generators/ahoy/base_generator.rb +1 -1
  24. data/lib/generators/ahoy/install_generator.rb +1 -1
  25. data/lib/generators/ahoy/mongoid_generator.rb +1 -5
  26. data/lib/generators/ahoy/templates/active_record_event_model.rb.tt +10 -0
  27. data/lib/generators/ahoy/templates/{active_record_migration.rb → active_record_migration.rb.tt} +14 -7
  28. data/lib/generators/ahoy/templates/active_record_visit_model.rb.tt +6 -0
  29. data/lib/generators/ahoy/templates/{base_store_initializer.rb → base_store_initializer.rb.tt} +8 -0
  30. data/lib/generators/ahoy/templates/database_store_initializer.rb.tt +10 -0
  31. data/lib/generators/ahoy/templates/{mongoid_event_model.rb → mongoid_event_model.rb.tt} +1 -1
  32. data/lib/generators/ahoy/templates/{mongoid_visit_model.rb → mongoid_visit_model.rb.tt} +9 -7
  33. data/vendor/assets/javascripts/ahoy.js +539 -552
  34. metadata +27 -204
  35. data/.github/ISSUE_TEMPLATE.md +0 -7
  36. data/.gitignore +0 -17
  37. data/Gemfile +0 -6
  38. data/Rakefile +0 -9
  39. data/ahoy_matey.gemspec +0 -36
  40. data/docs/Ahoy-2-Upgrade.md +0 -147
  41. data/docs/Data-Store-Examples.md +0 -240
  42. data/lib/generators/ahoy/templates/active_record_event_model.rb +0 -10
  43. data/lib/generators/ahoy/templates/active_record_visit_model.rb +0 -6
  44. data/lib/generators/ahoy/templates/database_store_initializer.rb +0 -5
  45. data/test/query_methods/mongoid_test.rb +0 -23
  46. data/test/query_methods/mysql_json_test.rb +0 -18
  47. data/test/query_methods/mysql_text_test.rb +0 -19
  48. data/test/query_methods/postgresql_hstore_test.rb +0 -20
  49. data/test/query_methods/postgresql_json_test.rb +0 -18
  50. data/test/query_methods/postgresql_jsonb_test.rb +0 -19
  51. data/test/query_methods/postgresql_text_test.rb +0 -19
  52. data/test/test_helper.rb +0 -100
@@ -1,15 +1,12 @@
1
1
  module Ahoy
2
2
  class BaseController < ApplicationController
3
3
  filters = _process_action_callbacks.map(&:filter) - Ahoy.preserve_callbacks
4
- if Rails::VERSION::MAJOR >= 5
5
- skip_before_action(*filters, raise: false)
6
- skip_after_action(*filters, raise: false)
7
- skip_around_action(*filters, raise: false)
8
- before_action :verify_request_size
9
- else
10
- skip_action_callback *filters
11
- before_action :verify_request_size
12
- end
4
+ skip_before_action(*filters, raise: false)
5
+ skip_after_action(*filters, raise: false)
6
+ skip_around_action(*filters, raise: false)
7
+
8
+ before_action :verify_request_size
9
+ before_action :renew_cookies
13
10
 
14
11
  if respond_to?(:protect_from_forgery)
15
12
  protect_from_forgery with: :null_session, if: -> { Ahoy.protect_from_forgery }
@@ -21,10 +18,17 @@ module Ahoy
21
18
  @ahoy ||= Ahoy::Tracker.new(controller: self, api: true)
22
19
  end
23
20
 
21
+ # set proper ttl if cookie generated from JavaScript
22
+ # approach is not perfect, as user must reload the page
23
+ # for new cookie settings to take effect
24
+ def renew_cookies
25
+ set_ahoy_cookies if params[:js] && !Ahoy.api_only
26
+ end
27
+
24
28
  def verify_request_size
25
29
  if request.content_length > Ahoy.max_content_length
26
30
  logger.info "[ahoy] Payload too large"
27
- render text: "Payload too large\n", status: 413
31
+ render plain: "Payload too large\n", status: 413
28
32
  end
29
33
  end
30
34
  end
@@ -3,7 +3,7 @@ module Ahoy
3
3
  def create
4
4
  events =
5
5
  if params[:name]
6
- # legacy API
6
+ # legacy API and AMP
7
7
  [request.params]
8
8
  elsif params[:events]
9
9
  request.params[:events]
@@ -2,6 +2,7 @@ module Ahoy
2
2
  class VisitsController < BaseController
3
3
  def create
4
4
  ahoy.track_visit
5
+
5
6
  render json: {
6
7
  visit_token: ahoy.visit_token,
7
8
  visitor_token: ahoy.visitor_token,
@@ -7,14 +7,13 @@ module Ahoy
7
7
  begin
8
8
  Geocoder.search(ip).first
9
9
  rescue => e
10
- $stderr.puts e.message
10
+ Ahoy.log "Geocode error: #{e.class.name}: #{e.message}"
11
11
  nil
12
12
  end
13
13
 
14
- if location
14
+ if location && location.country.present?
15
15
  data = {
16
- visit_token: visit_token,
17
- country: location.try(:country).presence,
16
+ country: location.country,
18
17
  region: location.try(:state).presence,
19
18
  city: location.try(:city).presence,
20
19
  postal_code: location.try(:postal_code).presence,
data/lib/ahoy.rb CHANGED
@@ -1,12 +1,18 @@
1
+ # stdlib
2
+ require "ipaddr"
3
+
4
+ # dependencies
1
5
  require "active_support"
2
6
  require "active_support/core_ext"
3
- require "addressable/uri"
4
7
  require "geocoder"
5
8
  require "safely/core"
6
9
 
10
+ # modules
11
+ require "ahoy/utils"
7
12
  require "ahoy/base_store"
8
13
  require "ahoy/controller"
9
14
  require "ahoy/database_store"
15
+ require "ahoy/helper"
10
16
  require "ahoy/model"
11
17
  require "ahoy/query_methods"
12
18
  require "ahoy/tracker"
@@ -22,8 +28,15 @@ module Ahoy
22
28
  mattr_accessor :visitor_duration
23
29
  self.visitor_duration = 2.years
24
30
 
31
+ mattr_accessor :cookies
32
+ self.cookies = true
33
+
34
+ # TODO deprecate in favor of cookie_options
25
35
  mattr_accessor :cookie_domain
26
36
 
37
+ mattr_accessor :cookie_options
38
+ self.cookie_options = {}
39
+
27
40
  mattr_accessor :server_side_visits
28
41
  self.server_side_visits = true
29
42
 
@@ -56,7 +69,7 @@ module Ahoy
56
69
 
57
70
  mattr_accessor :user_method
58
71
  self.user_method = lambda do |controller|
59
- (controller.respond_to?(:current_user) && controller.current_user) || (controller.respond_to?(:current_resource_owner, true) && controller.send(:current_resource_owner)) || nil
72
+ (controller.respond_to?(:current_user, true) && controller.send(:current_user)) || (controller.respond_to?(:current_resource_owner, true) && controller.send(:current_resource_owner)) || nil
60
73
  end
61
74
 
62
75
  mattr_accessor :exclude_method
@@ -64,8 +77,42 @@ module Ahoy
64
77
  mattr_accessor :track_bots
65
78
  self.track_bots = false
66
79
 
80
+ mattr_accessor :bot_detection_version
81
+ self.bot_detection_version = 2
82
+
67
83
  mattr_accessor :token_generator
68
84
  self.token_generator = -> { SecureRandom.uuid }
85
+
86
+ mattr_accessor :mask_ips
87
+ self.mask_ips = false
88
+
89
+ mattr_accessor :user_agent_parser
90
+ self.user_agent_parser = :device_detector
91
+
92
+ mattr_accessor :logger
93
+
94
+ def self.log(message)
95
+ logger.info { "[ahoy] #{message}" } if logger
96
+ end
97
+
98
+ def self.mask_ip(ip)
99
+ addr = IPAddr.new(ip)
100
+ if addr.ipv4?
101
+ # set last octet to 0
102
+ addr.mask(24).to_s
103
+ else
104
+ # set last 80 bits to zeros
105
+ addr.mask(48).to_s
106
+ end
107
+ end
108
+
109
+ def self.instance
110
+ Thread.current[:ahoy]
111
+ end
112
+
113
+ def self.instance=(value)
114
+ Thread.current[:ahoy] = value
115
+ end
69
116
  end
70
117
 
71
118
  ActiveSupport.on_load(:action_controller) do
@@ -76,7 +123,15 @@ ActiveSupport.on_load(:active_record) do
76
123
  extend Ahoy::Model
77
124
  end
78
125
 
126
+ ActiveSupport.on_load(:action_view) do
127
+ include Ahoy::Helper
128
+ end
129
+
79
130
  # Mongoid
131
+ # TODO use
132
+ # ActiveSupport.on_load(:mongoid) do
133
+ # Mongoid::Document::ClassMethods.include(Ahoy::Model)
134
+ # end
80
135
  if defined?(ActiveModel)
81
136
  ActiveModel::Callbacks.include(Ahoy::Model)
82
137
  end
@@ -24,9 +24,13 @@ module Ahoy
24
24
  def user
25
25
  @user ||= begin
26
26
  if Ahoy.user_method.respond_to?(:call)
27
- Ahoy.user_method.call(controller)
27
+ if Ahoy.user_method.arity == 1
28
+ Ahoy.user_method.call(controller)
29
+ else
30
+ Ahoy.user_method.call(controller, request)
31
+ end
28
32
  else
29
- controller.send(Ahoy.user_method)
33
+ controller.send(Ahoy.user_method) if controller.respond_to?(Ahoy.user_method, true)
30
34
  end
31
35
  end
32
36
  end
@@ -39,10 +43,35 @@ module Ahoy
39
43
  Ahoy.token_generator.call
40
44
  end
41
45
 
46
+ def visit_or_create
47
+ visit
48
+ end
49
+
42
50
  protected
43
51
 
44
52
  def bot?
45
- @bot ||= request ? Browser.new(request.user_agent).bot? : false
53
+ unless defined?(@bot)
54
+ @bot = begin
55
+ if request
56
+ if Ahoy.user_agent_parser == :device_detector
57
+ detector = DeviceDetector.new(request.user_agent)
58
+ if Ahoy.bot_detection_version == 2
59
+ detector.bot? || (detector.device_type.nil? && detector.os_name.nil?)
60
+ else
61
+ detector.bot?
62
+ end
63
+ else
64
+ # no need to throw friendly error if browser isn't defined
65
+ # since will error in visit_properties
66
+ Browser.new(request.user_agent).bot?
67
+ end
68
+ else
69
+ false
70
+ end
71
+ end
72
+ end
73
+
74
+ @bot
46
75
  end
47
76
 
48
77
  def exclude_by_method?
@@ -1,5 +1,3 @@
1
- require "request_store"
2
-
3
1
  module Ahoy
4
2
  module Controller
5
3
  def self.included(base)
@@ -9,7 +7,7 @@ module Ahoy
9
7
  end
10
8
  base.before_action :set_ahoy_cookies, unless: -> { Ahoy.api_only }
11
9
  base.before_action :track_ahoy_visit, unless: -> { Ahoy.api_only }
12
- base.before_action :set_ahoy_request_store
10
+ base.around_action :set_ahoy_request_store
13
11
  end
14
12
 
15
13
  def ahoy
@@ -21,18 +19,33 @@ module Ahoy
21
19
  end
22
20
 
23
21
  def set_ahoy_cookies
24
- ahoy.set_visitor_cookie
25
- ahoy.set_visit_cookie
22
+ if Ahoy.cookies
23
+ ahoy.set_visitor_cookie
24
+ ahoy.set_visit_cookie
25
+ else
26
+ # delete cookies if exist
27
+ ahoy.reset
28
+ end
26
29
  end
27
30
 
28
31
  def track_ahoy_visit
29
- if ahoy.new_visit?
30
- ahoy.track_visit(defer: !Ahoy.server_side_visits)
32
+ defer = Ahoy.server_side_visits != true
33
+
34
+ if defer && !Ahoy.cookies
35
+ # avoid calling new_visit?, which triggers a database call
36
+ elsif ahoy.new_visit?
37
+ ahoy.track_visit(defer: defer)
31
38
  end
32
39
  end
33
40
 
34
41
  def set_ahoy_request_store
35
- RequestStore.store[:ahoy] ||= ahoy
42
+ previous_value = Ahoy.instance
43
+ begin
44
+ Ahoy.instance = ahoy
45
+ yield
46
+ ensure
47
+ Ahoy.instance = previous_value
48
+ end
36
49
  end
37
50
  end
38
51
  end
@@ -4,31 +4,39 @@ module Ahoy
4
4
  @visit = visit_model.create!(slice_data(visit_model, data))
5
5
  rescue => e
6
6
  raise e unless unique_exception?(e)
7
- @visit = nil
7
+
8
+ # so next call to visit will try to fetch from DB
9
+ if defined?(@visit)
10
+ remove_instance_variable(:@visit)
11
+ end
8
12
  end
9
13
 
10
14
  def track_event(data)
11
- # if we don't have a visit, let's try to create one first
12
- ahoy.track_visit unless visit
13
-
14
- event = event_model.new(slice_data(event_model, data))
15
- event.visit = visit
16
- begin
17
- event.save!
18
- rescue => e
19
- raise e unless unique_exception?(e)
15
+ visit = visit_or_create(started_at: data[:time])
16
+ if visit
17
+ event = event_model.new(slice_data(event_model, data))
18
+ event.visit = visit
19
+ event.time = visit.started_at if event.time < visit.started_at
20
+ begin
21
+ event.save!
22
+ rescue => e
23
+ raise e unless unique_exception?(e)
24
+ end
25
+ else
26
+ Ahoy.log "Event excluded since visit not created: #{data[:visit_token]}"
20
27
  end
21
28
  end
22
29
 
23
30
  def geocode(data)
24
- data = slice_data(visit_model, data.except(:visit_token))
31
+ visit_token = data.delete(:visit_token)
32
+ data = slice_data(visit_model, data)
25
33
  if defined?(Mongoid::Document) && visit_model < Mongoid::Document
26
34
  # upsert since visit might not be found due to eventual consistency
27
- visit_model.where(visit_token: ahoy.visit_token).find_one_and_update({"$set": data}, {upsert: true})
35
+ visit_model.where(visit_token: visit_token).find_one_and_update({"$set": data}, {upsert: true})
28
36
  elsif visit
29
- visit.update_attributes(data)
37
+ visit.update!(data)
30
38
  else
31
- $stderr.puts "[ahoy] Visit for geocode not found: #{data[:visit_token]}"
39
+ Ahoy.log "Visit for geocode not found: #{visit_token}"
32
40
  end
33
41
  end
34
42
 
@@ -44,7 +52,16 @@ module Ahoy
44
52
  end
45
53
 
46
54
  def visit
47
- @visit ||= visit_model.where(visit_token: ahoy.visit_token).first if ahoy.visit_token
55
+ unless defined?(@visit)
56
+ @visit = visit_model.where(visit_token: ahoy.visit_token).first if ahoy.visit_token
57
+ end
58
+ @visit
59
+ end
60
+
61
+ # if we don't have a visit, let's try to create one first
62
+ def visit_or_create(started_at: nil)
63
+ ahoy.track_visit(started_at: started_at) if !visit && Ahoy.server_side_visits
64
+ visit
48
65
  end
49
66
 
50
67
  protected
@@ -59,7 +76,7 @@ module Ahoy
59
76
 
60
77
  def slice_data(model, data)
61
78
  column_names = model.try(:column_names) || model.attribute_names
62
- data.slice(*column_names.map(&:to_sym)).select { |_, v| v }
79
+ data.slice(*column_names.map(&:to_sym)).select { |_, v| !v.nil? }
63
80
  end
64
81
 
65
82
  def unique_exception?(e)
data/lib/ahoy/engine.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  module Ahoy
2
2
  class Engine < ::Rails::Engine
3
- initializer "ahoy", after: "sprockets.environment" do |app|
3
+ initializer "ahoy", after: "sprockets.environment" do
4
+ Ahoy.logger ||= Rails.logger
5
+
4
6
  # allow Devise to be loaded after Ahoy
5
7
  require "ahoy/warden" if defined?(Warden)
6
8
 
@@ -0,0 +1,40 @@
1
+ module Ahoy
2
+ module Helper
3
+ def amp_event(name, properties = {})
4
+ url = Ahoy::Engine.routes.url_helpers.events_url(
5
+ url_options.slice(:host, :port, :protocol).merge(
6
+ name: name,
7
+ properties: properties,
8
+ screen_width: "SCREEN_WIDTH",
9
+ screen_height: "SCREEN_HEIGHT",
10
+ platform: "Web",
11
+ landing_page: "AMPDOC_URL",
12
+ referrer: "DOCUMENT_REFERRER",
13
+ random: "RANDOM"
14
+ )
15
+ )
16
+ url = "#{url}&visit_token=${clientId(ahoy_visit)}&visitor_token=${clientId(ahoy_visitor)}"
17
+
18
+ content_tag "amp-analytics" do
19
+ content_tag "script", type: "application/json" do
20
+ json_escape({
21
+ requests: {
22
+ pageview: url
23
+ },
24
+ triggers: {
25
+ trackPageview: {
26
+ on: "visible",
27
+ request: "pageview"
28
+ }
29
+ },
30
+ transport: {
31
+ beacon: true,
32
+ xhrpost: true,
33
+ image: false
34
+ }
35
+ }.to_json).html_safe
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/ahoy/model.rb CHANGED
@@ -2,12 +2,12 @@ module Ahoy
2
2
  module Model
3
3
  def visitable(name = :visit, **options)
4
4
  class_eval do
5
- belongs_to(name, optional: true, class_name: "Ahoy::Visit", **options)
5
+ belongs_to(name, class_name: "Ahoy::Visit", optional: true, **options)
6
6
  before_create :set_ahoy_visit
7
7
  end
8
8
  class_eval %{
9
9
  def set_ahoy_visit
10
- self.#{name} ||= RequestStore.store[:ahoy].try(:visit)
10
+ self.#{name} ||= Ahoy.instance.try(:visit_or_create)
11
11
  end
12
12
  }
13
13
  end
@@ -27,10 +27,11 @@ module Ahoy
27
27
  v = "true"
28
28
  end
29
29
 
30
- relation = relation.where("JSON_UNQUOTE(properties -> ?) = ?", "$.#{k.to_s}", v.as_json)
30
+ relation = relation.where("JSON_UNQUOTE(properties -> ?) = ?", "$.#{k}", v.as_json)
31
31
  end
32
32
  else
33
33
  properties.each do |k, v|
34
+ # TODO cast to json instead
34
35
  relation = relation.where("properties REGEXP ?", "[{,]#{{k.to_s => v}.to_json.sub(/\A\{/, "").sub(/\}\z/, "").gsub("+", "\\\\+")}[,}]")
35
36
  end
36
37
  end
@@ -57,6 +58,7 @@ module Ahoy
57
58
  end
58
59
  else
59
60
  properties.each do |k, v|
61
+ # TODO cast to jsonb instead
60
62
  relation = relation.where("properties SIMILAR TO ?", "%[{,]#{{k.to_s => v}.to_json.sub(/\A\{/, "").sub(/\}\z/, "").gsub("+", "\\\\+")}[,}]%")
61
63
  end
62
64
  end
@@ -66,6 +68,49 @@ module Ahoy
66
68
  relation
67
69
  end
68
70
  alias_method :where_properties, :where_props
71
+
72
+ def group_prop(*props)
73
+ # like with group
74
+ props.flatten!
75
+
76
+ relation = self
77
+ if respond_to?(:columns_hash)
78
+ column_type = columns_hash["properties"].type
79
+ adapter_name = connection.adapter_name.downcase
80
+ else
81
+ adapter_name = "mongoid"
82
+ end
83
+ case adapter_name
84
+ when "mongoid"
85
+ raise "Adapter not supported: #{adapter_name}"
86
+ when /mysql/
87
+ if connection.try(:mariadb?)
88
+ props.each do |prop|
89
+ quoted_prop = connection.quote("$.#{prop}")
90
+ relation = relation.group("JSON_UNQUOTE(JSON_EXTRACT(properties, #{quoted_prop}))")
91
+ end
92
+ else
93
+ column = column_type == :json ? "properties" : "CAST(properties AS JSON)"
94
+ props.each do |prop|
95
+ quoted_prop = connection.quote("$.#{prop}")
96
+ relation = relation.group("JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{quoted_prop}))")
97
+ end
98
+ end
99
+ when /postgres|postgis/
100
+ # convert to jsonb to fix
101
+ # could not identify an equality operator for type json
102
+ # and for text columns
103
+ cast = [:jsonb, :hstore].include?(column_type) ? "" : "::jsonb"
104
+
105
+ props.each do |prop|
106
+ quoted_prop = connection.quote(prop)
107
+ relation = relation.group("properties#{cast} -> #{quoted_prop}")
108
+ end
109
+ else
110
+ raise "Adapter not supported: #{adapter_name}"
111
+ end
112
+ relation
113
+ end
69
114
  end
70
115
  end
71
116
  end