ahoy_matey 2.0.0 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
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