land 0.1.3

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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +6 -0
  3. data/.ruby-version +1 -0
  4. data/Appraisals +15 -0
  5. data/CHANGELOG.md +6 -0
  6. data/Gemfile +20 -0
  7. data/MIT-LICENSE +25 -0
  8. data/README.md +55 -0
  9. data/Rakefile +61 -0
  10. data/TODO.md +1 -0
  11. data/app/helpers/land/helper.rb +5 -0
  12. data/app/jobs/land/application_job.rb +4 -0
  13. data/app/lookups/land/ad_group.rb +9 -0
  14. data/app/lookups/land/ad_type.rb +9 -0
  15. data/app/lookups/land/affiliate.rb +9 -0
  16. data/app/lookups/land/app.rb +9 -0
  17. data/app/lookups/land/bid_match_type.rb +9 -0
  18. data/app/lookups/land/brand.rb +9 -0
  19. data/app/lookups/land/browser.rb +9 -0
  20. data/app/lookups/land/campaign.rb +10 -0
  21. data/app/lookups/land/content.rb +9 -0
  22. data/app/lookups/land/creative.rb +9 -0
  23. data/app/lookups/land/device.rb +9 -0
  24. data/app/lookups/land/device_type.rb +9 -0
  25. data/app/lookups/land/domain.rb +9 -0
  26. data/app/lookups/land/event_type.rb +9 -0
  27. data/app/lookups/land/experiment.rb +9 -0
  28. data/app/lookups/land/http_method.rb +9 -0
  29. data/app/lookups/land/keyword.rb +9 -0
  30. data/app/lookups/land/match_type.rb +9 -0
  31. data/app/lookups/land/medium.rb +9 -0
  32. data/app/lookups/land/mime_type.rb +9 -0
  33. data/app/lookups/land/network.rb +9 -0
  34. data/app/lookups/land/path.rb +10 -0
  35. data/app/lookups/land/placement.rb +9 -0
  36. data/app/lookups/land/platform.rb +10 -0
  37. data/app/lookups/land/position.rb +9 -0
  38. data/app/lookups/land/query_string.rb +10 -0
  39. data/app/lookups/land/search_term.rb +9 -0
  40. data/app/lookups/land/source.rb +9 -0
  41. data/app/lookups/land/subsource.rb +9 -0
  42. data/app/lookups/land/target.rb +9 -0
  43. data/app/lookups/land/user_agent_type.rb +9 -0
  44. data/app/models/concerns/land/table_name.rb +11 -0
  45. data/app/models/land/application_record.rb +5 -0
  46. data/app/models/land/attribution.rb +63 -0
  47. data/app/models/land/cookie.rb +14 -0
  48. data/app/models/land/event.rb +10 -0
  49. data/app/models/land/owner.rb +10 -0
  50. data/app/models/land/ownership.rb +8 -0
  51. data/app/models/land/pageview.rb +21 -0
  52. data/app/models/land/referer.rb +19 -0
  53. data/app/models/land/user_agent.rb +16 -0
  54. data/app/models/land/visit.rb +18 -0
  55. data/bin/console +13 -0
  56. data/bin/rails +22 -0
  57. data/bin/setup +11 -0
  58. data/config.ru +13 -0
  59. data/db/migrate/20200103012916_create_land_schema.rb +227 -0
  60. data/gemfiles/rails_5.0.gemfile +13 -0
  61. data/gemfiles/rails_5.1.gemfile +13 -0
  62. data/gemfiles/rails_5.2.gemfile +13 -0
  63. data/gemfiles/rails_6.0.gemfile +13 -0
  64. data/land.gemspec +56 -0
  65. data/lib/generators/land/install_generator.rb +17 -0
  66. data/lib/generators/templates/land.rb +29 -0
  67. data/lib/land.rb +30 -0
  68. data/lib/land/action.rb +62 -0
  69. data/lib/land/config.rb +62 -0
  70. data/lib/land/engine.rb +18 -0
  71. data/lib/land/tracker.rb +293 -0
  72. data/lib/land/trackers/noop_tracker.rb +8 -0
  73. data/lib/land/trackers/user_tracker.rb +79 -0
  74. data/lib/land/version.rb +5 -0
  75. data/lib/tasks/land_tasks.rake +4 -0
  76. metadata +233 -0
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 5.0.0"
6
+
7
+ group :development, :test do
8
+ gem "combustion"
9
+ gem "pry-rails"
10
+ gem "simplecov", require: false
11
+ end
12
+
13
+ gemspec path: "../"
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 5.1.0"
6
+
7
+ group :development, :test do
8
+ gem "combustion"
9
+ gem "pry-rails"
10
+ gem "simplecov", require: false
11
+ end
12
+
13
+ gemspec path: "../"
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 5.2.0"
6
+
7
+ group :development, :test do
8
+ gem "combustion"
9
+ gem "pry-rails"
10
+ gem "simplecov", require: false
11
+ end
12
+
13
+ gemspec path: "../"
@@ -0,0 +1,13 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 6.0.0"
6
+
7
+ group :development, :test do
8
+ gem "combustion"
9
+ gem "pry-rails"
10
+ gem "simplecov", require: false
11
+ end
12
+
13
+ gemspec path: "../"
@@ -0,0 +1,56 @@
1
+ $:.push File.expand_path("lib", __dir__)
2
+
3
+ # Maintain your gem's version:
4
+ require "land/version"
5
+
6
+ # Describe your gem and declare its dependencies:
7
+ Gem::Specification.new do |gem|
8
+ gem.name = "land"
9
+ gem.version = Land::VERSION
10
+
11
+ gem.authors = ["Erik Peterson"]
12
+ gem.email = ["thecompanygardener@gmail.com"]
13
+
14
+ gem.homepage = "https://github.com/companygardener/land"
15
+
16
+ gem.license = "MIT"
17
+
18
+ gem.summary = %q{Traffic tracking for Rails}
19
+ gem.description = %q{Clickstream tracking for Rails applications.}
20
+
21
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
22
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
23
+ if gem.respond_to?(:metadata)
24
+ gem.metadata["allowed_push_host"] = "https://rubygems.org"
25
+ else
26
+ raise "RubyGems 2.0 or newer is required to protect against " \
27
+ "public gem pushes."
28
+ end
29
+
30
+ gem.metadata["homepage_uri"] = gem.homepage
31
+ gem.metadata["source_code_uri"] = gem.homepage
32
+ gem.metadata["changelog_uri"] = "https://github.com/companygardener/land/blob/master/CHANGELOG.md"
33
+
34
+ # Specify which files should be added to the gem when it is released.
35
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
36
+ gem.files = Dir.chdir(File.expand_path('..', __FILE__)) do
37
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
38
+ end
39
+
40
+ gem.bindir = "exe"
41
+ gem.executables = gem.files.grep(%r{^exe/}) { |f| File.basename(f) }
42
+ gem.require_paths = ["lib"]
43
+
44
+ gem.add_dependency "activerecord", " > 4.0.0"
45
+ gem.add_dependency "lookup_by", "~> 0.11.0"
46
+
47
+ gem.add_development_dependency "pg"
48
+ gem.add_development_dependency "rake"
49
+
50
+ gem.add_development_dependency "appraisal"
51
+
52
+ gem.add_development_dependency "pry"
53
+
54
+ gem.add_development_dependency "rspec"
55
+ gem.add_development_dependency "rspec-rails"
56
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Land
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('../../templates', __FILE__)
9
+
10
+ desc "Create the Land initializer"
11
+
12
+ def copy_initializer
13
+ template "land.rb", "config/initializers/land.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'land'
4
+
5
+ Land.configure do |config|
6
+ # Enable land tracking
7
+ config.enabled = true
8
+
9
+ # Uncomment and modify to skip tracking for given paths.
10
+ # config.untracked_paths = %w[
11
+ # /ping
12
+ # /status
13
+ # ]
14
+
15
+ # Uncomment and modify to skip tracking for given IPs
16
+ # config.untracked_ips = %w[
17
+ # 127.0.0.1
18
+ # 192.168.0.1
19
+ # ]
20
+
21
+ # If request.user_agent is blank, this string is saved instead.
22
+ config.blank_user_agent_string = 'user agent missing'
23
+
24
+ # Database schema for land tables
25
+ config.schema = 'land'
26
+
27
+ # Timeout before a new visit is created
28
+ config.visit_timeout = 1.hour
29
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "land/version"
4
+ require "land/engine"
5
+
6
+ require "lookup_by"
7
+
8
+ module Land
9
+ class Error < StandardError; end
10
+
11
+ autoload :Action, "land/action"
12
+ autoload :Config, "land/config"
13
+ autoload :Tracker, "land/tracker"
14
+
15
+ module Trackers
16
+ autoload :NoopTracker, "land/trackers/noop_tracker"
17
+ autoload :UserTracker, "land/trackers/user_tracker"
18
+ end
19
+
20
+ def self.config
21
+ @config ||= Config.new
22
+ end
23
+
24
+ def self.configure
25
+
26
+ yield config if block_given?
27
+
28
+ config
29
+ end
30
+ end
@@ -0,0 +1,62 @@
1
+ module Land
2
+ module Action
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ prepend_around_action :track_with_land! if Land.config.enabled
7
+ end
8
+
9
+ # Use @land to avoid conflicts in controller namespace
10
+ def track_with_land!
11
+ yield && return if untracked?
12
+
13
+ begin
14
+ @land = Tracker.for(self)
15
+ @land.track
16
+ rescue => e
17
+ begin
18
+ Rails.logger.error e
19
+
20
+ if defined?(NewRelic::Agent) && NewRelic::Agent.respond_to?(:notice_error)
21
+ NewRelic::Agent.notice_error(e)
22
+ end
23
+ rescue
24
+ # eat this error
25
+ end
26
+ end
27
+
28
+ begin
29
+ yield
30
+ rescue => e
31
+ @land.status = ActionDispatch::ExceptionWrapper.status_code_for_exception(e.class.name)
32
+ raise e
33
+ ensure
34
+ begin
35
+ @land.save
36
+ rescue => e
37
+ begin
38
+ Rails.logger.error e
39
+
40
+ if defined?(NewRelic::Agent) && NewRelic::Agent.respond_to?(:notice_error)
41
+ NewRelic::Agent.notice_error(e)
42
+ end
43
+ rescue
44
+ # bubble controller error, not this one
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ def untracked?
51
+ untracked_path? || untracked_ip?
52
+ end
53
+
54
+ def untracked_path?
55
+ Land.config.untracked_paths.include? request.fullpath
56
+ end
57
+
58
+ def untracked_ip?
59
+ Land.config.untracked_ips.include? request.remote_ip
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Land
4
+ class Config < HashWithIndifferentAccess
5
+ attr_reader :enabled, :secure_cookie
6
+
7
+ attr_writer :blank_user_agent_string
8
+ attr_writer :schema, :untracked_ips, :untracked_paths
9
+
10
+ def initialize
11
+ @enabled = false
12
+ @secure_cookie = false
13
+ end
14
+
15
+ def blank_user_agent_string
16
+ @blank_user_agent_string ||= 'user agent missing'
17
+ end
18
+
19
+ def enabled=(value)
20
+ raise ArgumentError unless [true, false].include?(value)
21
+
22
+ @enabled = value
23
+ end
24
+
25
+ def secure_cookie=(value)
26
+ raise ArgumentError unless [true, false].include?(value)
27
+
28
+ @secure_cookie = value
29
+ end
30
+
31
+ def schema
32
+ @schema ||= 'land'
33
+ end
34
+
35
+ def untracked_ips
36
+ @untracked_ips ||= []
37
+ end
38
+
39
+ def add_untracked_ip(ip)
40
+ @untracked_ips << ip
41
+ end
42
+
43
+ def untracked_paths
44
+ @untracked_paths ||= []
45
+ end
46
+
47
+ def add_untracked_path(path)
48
+ @untracked_paths << path
49
+ end
50
+
51
+ def visit_timeout
52
+ @visit_timeout ||= 30.minutes
53
+ end
54
+
55
+ def visit_timeout=(value)
56
+ raise ArgumentError unless [Integer, ActiveSupport::Duration].include?(value.class)
57
+ raise ArgumentError, "must be positive" unless value.positive?
58
+
59
+ @visit_timeout = value
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "action_dispatch"
5
+
6
+ module Land
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Land
9
+
10
+ initializer "land.action_controller" do
11
+ ActiveSupport.on_load :action_controller do
12
+ include Land::Action
13
+
14
+ helper Land::Helper
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+ require "uri"
5
+
6
+ module Land
7
+ class Tracker
8
+ # About ValueTrack parameters
9
+ # https://support.google.com/google-ads/answer/2375447?hl=en&ref_topic=6031980
10
+ #
11
+ # Set up tracking with ValueTrack parameters
12
+ # https://support.google.com/google-ads/answer/6305348?hl=en
13
+ #
14
+ # iOS Campaign Tracker
15
+ # https://developers.google.com/analytics/devguides/collection/ios/v3/campaigns#url-builder
16
+
17
+ # @todo Cake: SUB ID => s1, s2, s3, s4, s5?
18
+ # https://www.affluent.io/blog/affiliate-sub-campaign-sid-tracking-guide/
19
+ #
20
+ # https://strackr.com/subid
21
+ TRACKING_PARAMS = {
22
+ 'ad_group' => %w[ad_group adgroup adset_name ovadgrpid ysmadgrpid],
23
+ 'ad_type' => %w[ad_type adtype],
24
+ 'affiliate' => %w[affiliate aff affid],
25
+ 'app' => %w[aid],
26
+ 'bid_match_type' => %w[bidmatchtype bid_match_type bmt],
27
+ 'brand' => %w[brand brand_name],
28
+ 'campaign' => %w[campaign campaign_name utm_campaign ovcampgid ysmcampgid cn],
29
+ 'click_id' => %w[click_id clickid dclid fbclid gclid gclsrc msclkid zanpid],
30
+ 'content' => %w[content ad_name utm_content cc],
31
+ 'creative' => %w[creative adid ovadid],
32
+ 'device_type' => %w[device_type devicetype device],
33
+ 'experiment' => %w[experiment aceid],
34
+ 'keyword' => %w[keyword kw utm_term ovkey ysmkey],
35
+ 'match_type' => %w[match_type matchtype match ovmtc ysmmtc],
36
+ 'medium' => %w[medium utm_medium cm],
37
+ 'network' => %w[network anid],
38
+ 'placement' => %w[placement],
39
+ 'position' => %w[position adposition ad_position],
40
+ 'search_term' => %w[search_term searchterm q querystring ovraw ysmraw],
41
+ 'source' => %w[source utm_source cs],
42
+ 'subsource' => %w[subsource subid sid effi_id customid afftrack pubref argsite fobs epi ws u1],
43
+ 'target' => %w[target]
44
+ }.freeze
45
+
46
+ TRACKING_KEYS = TRACKING_PARAMS.values.flatten.freeze
47
+ ATTRIBUTION_KEYS = TRACKING_PARAMS.except('click_id').keys
48
+
49
+ TRACKING_PARAMS_TRANSFORM = {
50
+ 'ad_type' => { 'pe' => 'product_extensions',
51
+ 'pla' => 'product_listing' },
52
+
53
+ 'bid_match_type' => { 'bb' => 'bidded broad',
54
+ 'bc' => 'bidded content',
55
+ 'be' => 'bidded exact',
56
+ 'bp' => 'bidded phrase' },
57
+
58
+ 'device_type' => { 'c' => 'computer',
59
+ 'm' => 'mobile',
60
+ 't' => 'tablet' },
61
+
62
+ 'match_type' => { 'b' => 'broad',
63
+ 'c' => 'content',
64
+ 'e' => 'exact',
65
+ 'p' => 'phrase',
66
+ 'std' => 'standard',
67
+ 'adv' => 'advanced',
68
+ 'cnt' => 'content' },
69
+
70
+ 'network' => { 'g' => 'google_search',
71
+ 's' => 'search_partner',
72
+ 'd' => 'display_network' },
73
+
74
+ 'source' => { 'fb' => 'facebook',
75
+ 'ig' => 'instagram',
76
+ 'msg' => 'messenger',
77
+ 'an' => 'audience network' }
78
+ }.freeze
79
+
80
+ TRACKED_PARAMS = TRACKING_PARAMS.values.flatten.freeze
81
+
82
+ UUID_REGEX = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\Z/
83
+ UUID_REGEX_V4 = /\A\h{8}-\h{4}-4\h{3}-[89aAbB]\h{3}-\h{12}\Z/
84
+
85
+ # Compact the session by shortening names
86
+ KEYS = {
87
+ visit_id: 'vid',
88
+ visit_time: 'vt',
89
+ user_agent_hash: 'uh',
90
+ attribution_hash: 'ah',
91
+ referer_hash: 'rh'
92
+ }.freeze
93
+
94
+ attr_accessor :status
95
+
96
+ attr_reader :controller, :events, :visit
97
+
98
+ delegate :request, :response, :session, to: :controller
99
+ delegate :headers, :path, :query_parameters, :referer, :remote_ip, to: :request
100
+
101
+ class << self
102
+ def for(controller)
103
+ type = Land::UserAgent[controller.request.user_agent].try(:user_agent_type)
104
+ type = 'noop' unless Land.config.enabled
105
+ type = 'user' if type.nil?
106
+
107
+ # override
108
+ type = 'user' if controller.request.query_parameters.with_indifferent_access.slice(*TRACKING_KEYS).any?
109
+
110
+ "Land::Trackers::#{type.classify}Tracker".constantize.new(controller)
111
+ end
112
+ end
113
+
114
+ def initialize(controller)
115
+ # Allow subclasses to super from initialize
116
+ fail NotImplementedError, "You must subclass Land::Tracker" if self.class == Tracker
117
+ @controller = controller
118
+ @events = []
119
+ @start_time = Time.now
120
+ @status = nil
121
+ end
122
+
123
+ def create_event(type, meta = {})
124
+ return unless tracking?
125
+
126
+ Event.new(visit_id: @visit_id, event_type: type, meta: meta).tap do |event|
127
+ @events << event
128
+ end
129
+ end
130
+
131
+ def track
132
+ fail NotImplementedError, "You must subclass Land::Tracker" if self.class == Tracker
133
+ end
134
+
135
+ protected
136
+
137
+ def cookies
138
+ request.cookie_jar
139
+ end
140
+
141
+ def cookie
142
+ validate_cookie
143
+ @cookie_id ||= Cookie.create.id
144
+ set_cookie
145
+ end
146
+
147
+ def validate_cookie
148
+ return unless @cookie_id
149
+ return if @cookie_id =~ UUID_REGEX_V4 && Cookie[@cookie_id]
150
+
151
+ # Invalid cookie
152
+ @cookie_id = nil
153
+ @visit_id = nil
154
+ end
155
+
156
+ def set_cookie
157
+ cookies.permanent[:land] = cookie_defaults.merge(value: @cookie_id)
158
+ end
159
+
160
+ def cookie_defaults
161
+ { domain: :all, secure: Land.config.secure_cookie, httponly: true }
162
+ end
163
+
164
+ def referer
165
+ return @referer if @referer
166
+ return unless referer_uri
167
+
168
+ params = Rack::Utils.parse_query referer_uri.query
169
+ attribution = Attribution.lookup params.slice(*ATTRIBUTION_KEYS)
170
+ query = params.except(*ATTRIBUTION_KEYS)
171
+
172
+ begin
173
+ @referer = Referer.where(domain_id: Domain[referer_uri.host],
174
+ path_id: Path[referer_uri.path],
175
+ query_string_id: QueryString[query.to_query],
176
+ attribution_id: attribution.id).first_or_create
177
+ rescue ActiveRecord::RecordNotUnique
178
+ retry
179
+ end
180
+ end
181
+
182
+ def referer_changed?
183
+ external_referer? && referer_hash != @referer_hash
184
+ end
185
+
186
+ def referer_uri
187
+ @referer_uri ||= URI(URI.encode(request.referer)) if request.referer
188
+ end
189
+
190
+ def attribution
191
+ @attribution ||= Attribution.lookup attribution_params
192
+ end
193
+
194
+ def attribution_changed?
195
+ attribution? && attribution_hash != @attribution_hash
196
+ end
197
+
198
+ def attribution_hash
199
+ Attribution.digest attribution_params
200
+ end
201
+
202
+ def user_agent
203
+ return @user_agent if @user_agent
204
+
205
+ user_agent = request.user_agent
206
+ user_agent = Land.config.blank_user_agent_string if user_agent.blank?
207
+
208
+ @user_agent = UserAgent[user_agent]
209
+ end
210
+
211
+ def user_agent_hash
212
+ Digest::SHA2.base64digest user_agent.user_agent
213
+ end
214
+
215
+ def user_agent_changed?
216
+ user_agent_hash != @user_agent_hash
217
+ end
218
+
219
+ def referer_hash
220
+ Digest::SHA2.base64digest request.referer
221
+ end
222
+
223
+ def attribution?
224
+ attribution_params.any?
225
+ end
226
+
227
+ def record_visit
228
+ create_visit if new_visit?
229
+ end
230
+
231
+ def create_visit
232
+ @visit = Visit.new
233
+ visit.attribution = attribution
234
+ visit.cookie_id = @cookie_id
235
+ visit.referer_id = referer.try(:id)
236
+ visit.user_agent_id = user_agent.id
237
+ visit.ip_address = remote_ip
238
+ visit.save!
239
+
240
+ @visit_id = @visit.id
241
+ end
242
+
243
+ def new_visit?
244
+ @visit_id.nil? || referer_changed? || attribution_changed? || user_agent_changed? || visit_stale?
245
+ end
246
+
247
+ def external_referer?
248
+ referer_uri && referer_uri.host != request.host
249
+ end
250
+
251
+ def extract_tracking(params)
252
+ hash = {}
253
+
254
+ TRACKING_PARAMS.each do |key, names|
255
+ param = names.find { |name| params.key?(name) }
256
+ next unless param
257
+ hash[key] = params[param]
258
+ end
259
+
260
+ TRACKING_PARAMS_TRANSFORM.each do |key, transform|
261
+ next unless hash.key? key
262
+ hash[key] = transform[hash[key]] if transform.key? hash[key]
263
+ end
264
+
265
+ hash
266
+ end
267
+
268
+ def attribution_params
269
+ @attribution_params ||= tracking_params.with_indifferent_access.slice(*ATTRIBUTION_KEYS)
270
+ end
271
+
272
+ def query_params
273
+ @query_params ||= request.query_parameters.with_indifferent_access
274
+ end
275
+
276
+ def tracking?
277
+ !!@visit_id
278
+ end
279
+
280
+ def tracking_params
281
+ @tracking_params ||= extract_tracking(query_params)
282
+ end
283
+
284
+ def untracked_params
285
+ query_params.except(*TRACKED_PARAMS)
286
+ end
287
+
288
+ def visit_stale?
289
+ return false unless @last_visit_time
290
+ Time.current - @last_visit_time > Land.config.visit_timeout
291
+ end
292
+ end
293
+ end