skadi 0.4.0.beta.1

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +111 -0
  5. data/app/assets/builds/dashboard.css +2 -0
  6. data/app/assets/builds/dashboard.js +36 -0
  7. data/app/assets/builds/skadi.js +1 -0
  8. data/app/controllers/skadi/application_controller.rb +4 -0
  9. data/app/controllers/skadi/asset_controller.rb +28 -0
  10. data/app/controllers/skadi/dashboard_controller.rb +105 -0
  11. data/app/controllers/skadi/tracking_controller.rb +106 -0
  12. data/app/helpers/skadi/application_helper.rb +38 -0
  13. data/app/models/skadi/application_record.rb +5 -0
  14. data/app/models/skadi/dashboard.rb +83 -0
  15. data/app/models/skadi/dashboard_query.rb +314 -0
  16. data/app/models/skadi/dashboard_validator.rb +269 -0
  17. data/app/models/skadi/demographic.rb +31 -0
  18. data/app/models/skadi/event.rb +33 -0
  19. data/app/models/skadi/helpers/sql.rb +86 -0
  20. data/app/models/skadi/schema.rb +135 -0
  21. data/app/models/skadi/view.rb +9 -0
  22. data/app/models/skadi/visit.rb +59 -0
  23. data/app/views/skadi/dashboard/_layout.html.erb +21 -0
  24. data/app/views/skadi/dashboard/show.html.erb +18 -0
  25. data/config/routes.rb +14 -0
  26. data/lib/generators/skadi/install/USAGE +20 -0
  27. data/lib/generators/skadi/install/install_generator.rb +46 -0
  28. data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
  29. data/lib/skadi/analytics.rb +35 -0
  30. data/lib/skadi/anonymity_set.rb +38 -0
  31. data/lib/skadi/configuration.rb +232 -0
  32. data/lib/skadi/controller_delegate.rb +322 -0
  33. data/lib/skadi/cookie_manager.rb +81 -0
  34. data/lib/skadi/engine.rb +38 -0
  35. data/lib/skadi/url.rb +66 -0
  36. data/lib/skadi/user_agent.rb +458 -0
  37. data/lib/skadi/version.rb +3 -0
  38. data/lib/skadi.rb +26 -0
  39. metadata +203 -0
@@ -0,0 +1,232 @@
1
+ module Skadi
2
+ class Configuration
3
+ class Error < StandardError; end
4
+
5
+ def initialize
6
+ validators.each do |attribute, validator_configuration|
7
+ send("#{attribute}=", validator_configuration[:default])
8
+ end
9
+ end
10
+
11
+ cattr_accessor :validators
12
+ self.validators = {}
13
+
14
+ def self.validates(attribute, expecting, default:, &block)
15
+ validators[attribute] = { expecting: expecting, default: default, validator: block }
16
+ end
17
+
18
+ # When enabled, users will automatically be tracked by their anonymity set.
19
+ # An anonymity set keeps track of a user using a hash of their IP address and User Agent. A cryptographic pepper is added to the hash, which, when discarded, makes the generated token no longer able to be used to track the user.
20
+ # When disabled, views and events will not be linked to a visitor without explicit consent to use anonymity sets or tracking cookies.
21
+ # This option defined the default behaviour for using anonymity sets, but the consent cookie, if it exists, will always take precedence over this configuration option.
22
+ # Defaults to false.
23
+ # @return [Boolean]
24
+ attr_accessor :use_anonymity_sets
25
+ validates(:use_anonymity_sets, "boolean", default: false) { |it| it == true || it == false }
26
+
27
+ # When enabled, visits will be tracked by using the logged in user. See the :user_model and :user_controller_method configuration options.
28
+ # When disabled, users will not be saved to visits without explicit consent.
29
+ # This option defined the default behaviour for tracking users, but the consent cookie, if it exists, will always take precedence over this configuration option.
30
+ # Defaults to false.
31
+ # @return [Boolean]
32
+ attr_accessor :track_users
33
+ validates(:track_users, "boolean", default: false) { |it| it == true || it == false }
34
+
35
+ attr_accessor :anonymity_set_cache_key
36
+ validates(:anonymity_set_cache_key, "string", default: "skadi/anonymity_set_pepper") { |it| it.is_a?(String) && it.present? }
37
+
38
+ # How long an anonymity set should last before expiring. Defaults to 1 day.
39
+ # @return [ActiveSupport::Duration]
40
+ attr_accessor :anonymity_set_duration
41
+ validates(:anonymity_set_duration, "ActiveSupport::Duration", default: 1.day) { |it| it.is_a?(ActiveSupport::Duration) }
42
+
43
+ # Set the hour of the day to reset the anonymity set. Set to false to strictly use the set duration. Defaults to 3 (3am).
44
+ # @return [Integer, false]
45
+ attr_accessor :anonymity_set_reset_hour
46
+ validates(:anonymity_set_reset_hour, "Integer or false", default: 3) do |it|
47
+ next true if it == false
48
+
49
+ it.is_a?(Integer) && it >= 0 && it <= 23
50
+ end
51
+
52
+ # How long a visit should last before expiring. Note: visits that cross anonymity set boundaries will be counted as two visits. Defaults to 2 hours.
53
+ # @return [ActiveSupport::Duration]
54
+ attr_accessor :visit_duration
55
+ validates(:visit_duration, "ActiveSupport::Duration", default: 2.hours) { |it| it.is_a?(ActiveSupport::Duration) }
56
+
57
+ # The parent app's User class, used to link visits to users
58
+ # @return [Class, nil]
59
+ attr_accessor :user_model
60
+ validates(:user_model, "string or nil", default: nil) do |it, configuration|
61
+ next true if it.nil?
62
+ next false unless it.is_a?(String)
63
+
64
+ klass = it.constantize
65
+
66
+ next false unless klass.is_a?(Class) && klass < ActiveRecord::Base
67
+
68
+ # Update the user_model ref to the actual class rather than the string
69
+ configuration.user_model = klass
70
+
71
+ true
72
+ end
73
+
74
+ # Method in the host application's ApplicationController that returns the current logged-in user. An AR Model or nil should be returned. Used to track users; if this is nil or set to a non-existent method, user tracking is disabled. Defaults to nil (disabled).
75
+ # @return [Symbol, nil]
76
+ attr_accessor :user_controller_method
77
+ validates(:user_controller_method, "Symbol or nil", default: nil) { |it| it.nil? || it.is_a?(Symbol) }
78
+
79
+ # Enable filtering of query parameters to prevent sensitive data being exposed. Defaults to true.
80
+ # @return [Boolean]
81
+ attr_accessor :use_query_param_whitelist
82
+ validates(:use_query_param_whitelist, "boolean", default: true) { |it| it == true || it == false }
83
+
84
+ # An array of query parameter keys to whitelist for storage in URLs.
85
+ # @return [Array<Symbol>]
86
+ attr_accessor :query_param_whitelist
87
+ validates(:query_param_whitelist, "Array<Symbol>", default: []) { |it| it.is_a?(Array) && it.all?(Symbol) }
88
+
89
+ # Maximum length of the referrer and exit page URLs. Defaults to 2048.
90
+ # @return [Integer]
91
+ attr_accessor :max_url_length
92
+ validates(:max_url_length, "Integer", default: 2048) { |it| it.is_a?(Integer) && it >= 0 }
93
+
94
+ # The database connection to use for Skadi models
95
+ # @see ActiveRecord::ConnectionHandling.connects_to
96
+ # @return [Hash, nil]
97
+ attr_accessor :db_connects_to
98
+ validates(:db_connects_to, "Hash compatible with ActiveRecord::ConnectionHandling#connects_to", default: nil) do |it|
99
+ next true if it.nil?
100
+
101
+ allowed_keys = [ :database, :shards ].freeze
102
+ it.is_a?(Hash) && !it.empty? && it.keys.all? { |key| allowed_keys.include?(key) }
103
+ end
104
+
105
+ # Whether to store the domain when tracking views. Can be useful when using multiple domains or subdomains. Defaults to false.
106
+ # @return [Boolean]
107
+ attr_accessor :store_domain_in_views
108
+ validates(:store_domain_in_views, "boolean", default: false) { |it| it == true || it == false }
109
+
110
+ # Sets a limit on the size of the tracking beacon. Defaults to 1KB.
111
+ # @return [Integer]
112
+ attr_accessor :max_tracking_payload_size
113
+ validates(:max_tracking_payload_size, "integer", default: 1_024) { |it| it.is_a?(Integer) && it > 0 }
114
+
115
+ # The domain to use when setting cookies. Set to include subdomains. Defaults to nil, which will not specify a domain when setting a cookie.
116
+ # @return [String, nil]
117
+ attr_accessor :cookie_domain
118
+ validates(:cookie_domain, "string or nil", default: nil) { |it| it.nil? || (it.present? && it.is_a?(String)) }
119
+
120
+ # Whether to track visits by suspected bots, detected via the browser user agent. Defaults to `Rails.env.local?` (true
121
+ # for development and testing environments, and false for production/other environments).
122
+ attr_accessor :track_bots
123
+ validates(:track_bots, "boolean", default: Rails.env.local?) { |it| it == true || it == false }
124
+
125
+ # Helper method to return the inverse of :track_bots
126
+ def do_not_track_bots? = !@track_bots
127
+
128
+ ###########################
129
+ # Dashboard configuration #
130
+ ###########################
131
+
132
+ # Method in the host application's ApplicationController that returns true if the current request is allowed to view the Skadi dashboard. Defaults to nil (disabled).
133
+ # @return [Symbol, nil]
134
+ attr_accessor :dashboard_view_controller_method
135
+ validates(:dashboard_view_controller_method, "Symbol or nil", default: nil) { |it| it.nil? || it.is_a?(Symbol) }
136
+
137
+ # Method in the host application's ApplicationController that returns true if the current request is allowed to edit Skadi dashboards. Defaults to nil (disabled).
138
+ # @return [Symbol, nil]
139
+ attr_accessor :dashboard_edit_controller_method
140
+ validates(:dashboard_edit_controller_method, "Symbol or nil", default: nil) { |it| it.nil? || it.is_a?(Symbol) }
141
+
142
+ # Method in the host application's ApplicationController that returns true if the current request is allowed to edit raw SQL in the Skadi dashboard. Note that exposing SQL to users is dangerous and could lead to data loss. Defaults to nil (disabled).
143
+ # @return [Symbol, nil]
144
+ attr_accessor :dashboard_dangerously_use_sql_controller_method
145
+ validates(:dashboard_dangerously_use_sql_controller_method, "Symbol or nil", default: nil) { |it| it.nil? || it.is_a?(Symbol) }
146
+
147
+ # Use this to add custom fields to the events dataset in the dashboard. This should be set to a hash with values of the format:
148
+ # {
149
+ # label: The label to show in the front end,
150
+ # type: The datatype, one of :date, :string, :number, :boolean. Defaults to :string if omitted.
151
+ # filter: `true` if this field can be filtered
152
+ # split: `true` if this field can be split
153
+ # sql: The SQL expression used to get this value for derived fields
154
+ # options: An array of possible values
155
+ # description: used in the front end as help text for this field
156
+ # }
157
+ # For example,
158
+ # {clicks: {type: :number, filter: true, split: true, sql: "properties->>'clicks'"}}
159
+ # See Skadi::Schema for reference
160
+ # @return [Array<Hash>, nil]
161
+ attr_accessor :dashboard_custom_event_fields
162
+ validates(:dashboard_custom_event_fields, "Hash or nil", default: nil) do |it|
163
+ next true if it.nil?
164
+ next false unless it.is_a?(Hash)
165
+
166
+ next it.all? do |_key, item|
167
+ item.is_a?(Hash) && (item.keys.map(&:to_s) - %w[label type filter split sql option description]).empty?
168
+ end
169
+ end
170
+
171
+ # Use this to add custom database tables to the Skadi dashboard. See Skadi::Schema for reference.
172
+ attr_accessor :dashboard_custom_schema
173
+ validates(:dashboard_custom_schema, "a valid schema or nil", default: nil) do |it|
174
+ next true if it.nil?
175
+ unless it.is_a?(Hash)
176
+ error! "Skadi.configuration.dashboard_custom_schema must be a hash"
177
+ next false
178
+ end
179
+
180
+ tables_valid = it.all? do |_table_name, table_schema|
181
+ unless table_schema.is_a?(Hash)
182
+ error! "Skadi.configuration.dashboard_custom_schema values must be a hash"
183
+ next false
184
+ end
185
+
186
+ unless table_schema[:model].is_a?(String)
187
+ error! "Skadi.configuration.dashboard_custom_schema hash values must have a :model key"
188
+ next false
189
+ end
190
+
191
+ klass = table_schema[:model].constantize
192
+ unless klass.is_a?(Class) && klass < ActiveRecord::Base
193
+ error! "Skadi.configuration.dashboard_custom_schema hash values model key must be the name of a valid model"
194
+ next false
195
+ end
196
+
197
+ table_schema[:model] = klass
198
+
199
+ unless table_schema[:fields].is_a?(Hash)
200
+ error! "Skadi.configuration.dashboard_custom_schema fields must be a hash"
201
+ end
202
+
203
+ next true
204
+ end
205
+
206
+ next tables_valid
207
+ end
208
+
209
+
210
+ def validate!
211
+ validators.each do |attribute, validator_configuration|
212
+ validator = validator_configuration[:validator]
213
+ expecting = validator_configuration[:expecting]
214
+ value = send(attribute)
215
+
216
+ if validator.call(value, self)
217
+ next
218
+ end
219
+
220
+ error! "Skadi.configuration.#{attribute} error! Expecting a #{expecting}, but got a #{value.class}"
221
+ end
222
+ end
223
+
224
+ private def error!(error)
225
+ if Rails.env.local?
226
+ raise Error.new(error)
227
+ else
228
+ Rails.logger.error error
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,322 @@
1
+ module Skadi
2
+ class ControllerDelegate
3
+ # The controller that instantiated us
4
+ # @return [ActionController::Base]
5
+ attr_reader :controller
6
+
7
+ attr_reader :view, :visit, :events, :demographics
8
+
9
+ # The parsed user agent
10
+ # @return [Skadi::UserAgent]
11
+ def user_agent = @_user_agent ||= UserAgent.new(request.user_agent || "")
12
+
13
+ # Whether the current request has recorded a new visit
14
+ # @return [TrueClass, FalseClass]
15
+ def new_visit? = @new_visit
16
+
17
+ # The current request
18
+ # @return [ActionDispatch::Request]
19
+ private def request = controller.request
20
+
21
+ # The skadi cookie manager for the current request
22
+ # @return [Skadi::CookieManager]
23
+ private def cookie_manager = @_skadi_cookies ||= Skadi::CookieManager.new(controller.request)
24
+
25
+ # @param [ActionController::Base] controller
26
+ def initialize(controller, bot_protection: true)
27
+ @controller = controller
28
+
29
+ @events = []
30
+ @demographics = []
31
+
32
+ @do_not_track = bot_protection && Skadi.configuration.do_not_track_bots? && user_agent.bot?
33
+ @new_visit = false
34
+ end
35
+
36
+ # Internal. Performs the before-action tasks: build the visit and view, and process user agent
37
+ # info.
38
+ def _prepare
39
+ return if do_not_track?
40
+
41
+ build_visit
42
+ build_view
43
+ queue_user_agent_demographics
44
+ rescue => e
45
+ # Analytics must not interfere with the host app's request on failure
46
+ Rails.logger.error("Skadi: failed to prepare analytics for #{controller.controller_name}##{controller.action_name} (visit: #{@visit.try(:id).inspect}, view: #{@view.try(:id).inspect}, events: #{@events.count}, demographics: #{@demographics.count}): #{e.class}, #{e.message}; Line: #{e.backtrace&.first}")
47
+
48
+ # Ensure errors are visible in test and development
49
+ raise if Rails.env.local?
50
+ end
51
+
52
+ # Internal. Manually set the view and visit for the current request.
53
+ def _attach(view: nil, visit: nil)
54
+ @visit = visit
55
+ @view = view
56
+ end
57
+
58
+ # Internal. Saves the visit, view and any events or demographics after the controller action.
59
+ def _persist
60
+ return if do_not_track?
61
+
62
+ @visit&.save
63
+ if @view
64
+ @view.visit = @visit
65
+ @view.save
66
+ end
67
+
68
+ if @events.any?
69
+ Skadi::Event.redact_and_insert(@events, visit: @visit, view: @view)
70
+ end
71
+
72
+ if @demographics.any?
73
+ Demographic.create_or_increment_all(@demographics)
74
+ end
75
+
76
+ cookie_manager.renew!
77
+ rescue => e
78
+ # Analytics must not interfere with the host app's request on failure
79
+ Rails.logger.error("Skadi: failed to persist analytics for #{controller.controller_name}##{controller.action_name} (visit: #{@visit.try(:id).inspect}, view: #{@view.try(:id).inspect}, events: #{@events.count}, demographics: #{@demographics.count}): #{e.class}, #{e.message}; Line: #{e.backtrace&.first}")
80
+
81
+ # Ensure errors are visible in test and development
82
+ raise if Rails.env.local?
83
+ end
84
+
85
+ # Whether Skadi tracking has been disabled for the current request
86
+ # @return [Boolean]
87
+ def do_not_track?
88
+ @do_not_track
89
+ end
90
+
91
+ # Disable tracking for the current request
92
+ def do_not_track!
93
+ @do_not_track = true
94
+ end
95
+
96
+ # Create or increment a demographic with a given name or value. If the action_specific parameter
97
+ # is set to true, the demographic is linked specifically to the current action. Demographics are
98
+ # not linked to any other individual data. E.g:
99
+ #
100
+ # skadi.demographic("browser", "Chrome")
101
+ # skadi.demographic("branch", "A", action_specific: true)
102
+ #
103
+ # If the name/value/action combination doesn't exist for the current date, a new row is added to
104
+ # the database with count set to 1. If it does, the existing record's count is incremented.
105
+ #
106
+ # @param [String] name
107
+ # @param [String] value
108
+ # @param [TrueClass, FalseClass] action_specific
109
+ def demographic(name, value, action_specific: false, uri: nil)
110
+ raise ArgumentError.new "Skadi::ControllerDelegate.demographic expects String as first parameter, got #{name.is_a?(String) ? "empty string" : name.class.name}" unless name.is_a?(String) && name.present?
111
+ raise ArgumentError.new "Skadi::ControllerDelegate.demographic expects String as second parameter, got #{value.is_a?(String) ? "empty string" : value.class.name}" unless value.is_a?(String) && value.present?
112
+
113
+ demographic = { name:, value:, uri: action_specific ? (uri || request.route_uri_pattern) : nil }
114
+
115
+ @demographics << demographic
116
+ end
117
+
118
+ # Create an event with the given name and properties. By default, events are linked to the
119
+ # current visit and view, but if the sensitive parameter is set to true, the event is not linked
120
+ # to the visit and view, and the time of the event is set to the start of the current day.
121
+ #
122
+ # @param [String] name
123
+ # @param [Hash] properties
124
+ # @param [TrueClass, FalseClass] sensitive
125
+ def event(name, properties = {}, sensitive: false)
126
+ raise ArgumentError.new "Skadi::ControllerDelegate.event expects String as first parameter, got #{name.is_a?(String) ? "empty string" : name.class.name}" unless name.is_a?(String) && name.present?
127
+ raise ArgumentError.new "Skadi::ControllerDelegate.event expects Hash as second parameter, got #{properties.class.name}" unless properties.is_a?(Hash)
128
+
129
+ event = { name:, properties:, sensitive: }
130
+
131
+ @events << event
132
+ end
133
+
134
+ # Set consent for tracking by anonymity set
135
+ # @param [TrueClass, FalseClass] consent
136
+ def anonymity_set_consent!(consent)
137
+ anonymity_set = AnonymitySet.calculate(request.remote_ip, request.user_agent)
138
+
139
+ if consent
140
+ cookie_manager.use_anonymity_sets = true
141
+
142
+ # If a visit is attached to the request, we update it with the anonymity set token
143
+ if @visit
144
+ @visit.tracking_token ||= anonymity_set
145
+ else
146
+ # Build the visit without the request, because the current request is likely not the original first request
147
+ @visit = Visit.build_from(anonymity_set)
148
+ @view.visit = @visit if @view
149
+ end
150
+ else
151
+ cookie_manager.use_anonymity_sets = false
152
+
153
+ return if @visit.nil?
154
+
155
+ # Check to see if the currrent visit is using an anonymity set
156
+ if @visit&.tracking_token && !@visit.cookies_enabled
157
+ # If so, delete it from the db so existing data is anonymised instantly
158
+ # Note: this needs to be a DB update because there may be other visits outside the visit limit
159
+ Skadi::Visit.where(tracking_token: anonymity_set).update_all(tracking_token: nil)
160
+
161
+ # Update the local instance of the visit if it uses anonymity sets so it doesn't get re-set when saved
162
+ @visit.tracking_token = nil if @visit.tracking_token == anonymity_set
163
+ end
164
+ end
165
+ end
166
+
167
+ def anonymity_set_consent?
168
+ cookie_value = cookie_manager.use_anonymity_sets
169
+ return cookie_value unless cookie_value.nil?
170
+
171
+ # There is no explicit consent or opt-out, so we use the configured default value
172
+ return Skadi.configuration.use_anonymity_sets
173
+ end
174
+
175
+ # Set consent for tracking by cookie
176
+ # @param [TrueClass, FalseClass] consent
177
+ def cookie_consent!(consent)
178
+ if consent
179
+ return unless cookie_manager.tracking_token.nil?
180
+
181
+ # Re-use an existing cookie-based token
182
+ tracking_token = @visit&.tracking_token if @visit&.cookies_enabled
183
+ tracking_token ||= ::SecureRandom.uuid_v7
184
+
185
+ cookie_manager.tracking_token = tracking_token
186
+
187
+ # Update the existing visit with the tracking token if we've generated a new one
188
+ if @visit
189
+ @visit.tracking_token = tracking_token
190
+ @visit.cookies_enabled = true
191
+ else
192
+ @visit = Visit.build_from(tracking_token, cookies_enabled: true)
193
+ @view.visit = @visit if @view
194
+ end
195
+ else
196
+ cookie_manager.tracking_token = nil
197
+
198
+ return if @visit.nil?
199
+
200
+ # No need to anonymise existing sessions here because there is no way to link to the user once the tracking token is deleted.
201
+ @visit.cookies_enabled = false
202
+
203
+ # If the user has opted in for anonymity sets
204
+ if cookie_manager.use_anonymity_sets == true || (Skadi.configuration.use_anonymity_sets && cookie_manager.use_anonymity_sets != false)
205
+ @visit.tracking_token = AnonymitySet.calculate(request.remote_ip, request.user_agent)
206
+ end
207
+ end
208
+ end
209
+
210
+ def cookie_consent? = cookie_manager.tracking_token.present?
211
+
212
+ # Set consent for tracking by logged in user
213
+ # @param [TrueClass, FalseClass] consent
214
+ def user_consent!(consent)
215
+ tracked_user_id = @visit&.user_id || logged_in_user&.id
216
+
217
+ if consent
218
+ cookie_manager.track_users = true
219
+
220
+ unless tracked_user_id.nil?
221
+ if @visit
222
+ @visit.user_id = tracked_user_id
223
+ else
224
+ # Build the visit without the request, because the current request is likely not the original first request
225
+ @visit = Visit.build_from(nil, tracked_user_id)
226
+ @view.visit = @visit if @view
227
+ end
228
+ end
229
+ else
230
+ cookie_manager.track_users = false
231
+
232
+ # If there is a logged in user, we delete the user id from any rows that match
233
+ unless tracked_user_id.nil?
234
+ # Note: this needs a DB update because there may be other visits outside the visit limit
235
+ Skadi::Visit.where(user_id: tracked_user_id).update_all(user_id: nil)
236
+
237
+ # Update the local instance of the current visit so it doesn't get re-set when saved
238
+ @visit.user_id = nil if @visit
239
+ end
240
+ end
241
+ end
242
+
243
+ def user_consent?
244
+ cookie_value = cookie_manager.track_users
245
+ return cookie_value unless cookie_value.nil?
246
+
247
+ # There is no explicit consent or opt-out, so we use the configured default value
248
+ return Skadi.configuration.track_users
249
+ end
250
+
251
+ private def build_visit
252
+ user, has_utm_params, has_external_referrer = nil
253
+
254
+ tracking_token = cookie_manager.tracking_token
255
+
256
+ if tracking_token.nil? && anonymity_set_consent?
257
+ tracking_token = AnonymitySet.calculate(request.remote_ip, request.user_agent)
258
+ end
259
+
260
+ if user_consent?
261
+ user = logged_in_user
262
+ end
263
+
264
+ @visit = Visit.find_active_visit_for(tracking_token, user)
265
+
266
+ if @visit
267
+ # Update the user if the user has logged in since the last view
268
+ @visit.user_id = user.id if user && @visit.user_id.nil?
269
+
270
+ # Ensure the cookie consent status is up to date
271
+ @visit.cookies_enabled = cookie_consent?
272
+
273
+ return
274
+ end
275
+
276
+ unless tracking_token || user
277
+ has_utm_params = request.query_parameters.keys.any? { |it| it.to_s.start_with?("utm_") }
278
+ has_external_referrer = request.referrer.present? && controller.url_from(request.referrer).nil?
279
+ end
280
+
281
+ # Only create a visit if we have some useful data or way of tracking users across pages
282
+ return unless tracking_token || user || has_utm_params || has_external_referrer
283
+
284
+ @visit = Visit.build_from(tracking_token, user&.id, request)
285
+ @new_visit = true
286
+ end
287
+
288
+ private def build_view
289
+ @view = View.new(
290
+ view_token: SecureRandom.uuid_v7,
291
+ verified: false,
292
+ controller: controller.controller_name,
293
+ action: controller.action_name,
294
+ verb: request.request_method,
295
+ path: Url.view_path_from_request(request),
296
+ query_params: Url.whitelist_query_params(request.query_parameters),
297
+ )
298
+ end
299
+
300
+ private def queue_user_agent_demographics
301
+ # Only track the user agent data when we are recording a new visit so we don't duplicate the data
302
+ return unless new_visit?
303
+
304
+ demographic "Browser", user_agent.browser
305
+ demographic "Browser version", "#{user_agent.browser} #{user_agent.browser_version}"
306
+ demographic "Browser engine", user_agent.engine
307
+ demographic "Browser engine version", "#{user_agent.engine} #{user_agent.engine_version}"
308
+ demographic "Operating system", user_agent.os
309
+ end
310
+
311
+ private def logged_in_user
312
+ return @logged_in_user if defined?(@logged_in_user)
313
+
314
+ return nil if Skadi.configuration.user_controller_method.nil?
315
+ return nil unless controller.respond_to?(Skadi.configuration.user_controller_method, true)
316
+
317
+ @logged_in_user = controller.send(Skadi.configuration.user_controller_method)
318
+
319
+ return @logged_in_user
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,81 @@
1
+ module Skadi
2
+ class CookieManager
3
+ ANONYMITY_SET_KEY = "skadi_anonymity_set"
4
+ TRACKING_TOKEN_KEY = "skadi_id"
5
+ TRACK_USER_KEY = "skadi_track_user"
6
+ UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
7
+
8
+ # @return [ActionDispatch::Cookies::CookieJar]
9
+ attr_reader :cookies, :request
10
+
11
+ def initialize(request)
12
+ @request = request
13
+ @cookies = request.cookie_jar
14
+ end
15
+
16
+ def renew!
17
+ set_cookie ANONYMITY_SET_KEY, cookies[ANONYMITY_SET_KEY] if [ "1", "0" ].include?(cookies[ANONYMITY_SET_KEY])
18
+ set_cookie TRACK_USER_KEY, cookies[TRACK_USER_KEY] if [ "1", "0" ].include?(cookies[TRACK_USER_KEY])
19
+
20
+ if cookies.has_key? TRACKING_TOKEN_KEY
21
+ token = tracking_token
22
+ if token
23
+ set_cookie TRACKING_TOKEN_KEY, token
24
+ else
25
+ # If the key is set, but the token returned by `tracking_token()` is nil, then the cookie is malformed and we delete it
26
+ delete_cookie TRACKING_TOKEN_KEY
27
+ end
28
+ end
29
+ end
30
+
31
+ def use_anonymity_sets
32
+ return true if cookies[ANONYMITY_SET_KEY] == "1"
33
+ return false if cookies[ANONYMITY_SET_KEY] == "0"
34
+ end
35
+
36
+ def use_anonymity_sets=(new_value)
37
+ set_cookie ANONYMITY_SET_KEY, new_value ? "1" : "0"
38
+ end
39
+
40
+ def track_users
41
+ return true if cookies[TRACK_USER_KEY] == "1"
42
+ return false if cookies[TRACK_USER_KEY] == "0"
43
+ end
44
+
45
+ def track_users=(new_value)
46
+ set_cookie TRACK_USER_KEY, new_value ? "1" : "0"
47
+ end
48
+
49
+ def tracking_token
50
+ token = cookies[TRACKING_TOKEN_KEY]
51
+
52
+ # The cookie is user input, so we ensure the cookie is a UUID as expected
53
+ token&.match(UUID_REGEX) ? token : nil
54
+ end
55
+
56
+ def tracking_token=(new_value)
57
+ if new_value.nil?
58
+ delete_cookie(TRACKING_TOKEN_KEY)
59
+
60
+ return
61
+ end
62
+
63
+ set_cookie TRACKING_TOKEN_KEY, new_value
64
+ end
65
+
66
+ private def set_cookie(name, value)
67
+ cookies[name] = {
68
+ value:,
69
+ domain: Skadi.configuration.cookie_domain,
70
+ httponly: true,
71
+ secure: Rails.env.production? || request.ssl?,
72
+ same_site: :lax,
73
+ expires: 1.year.from_now,
74
+ }
75
+ end
76
+
77
+ private def delete_cookie(name)
78
+ cookies.delete(name, domain: Skadi.configuration.cookie_domain)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,38 @@
1
+ module Skadi
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Skadi
4
+
5
+ config.after_initialize do
6
+ # Validate the configuration and output any errors to the Rails log
7
+ Skadi.configuration.validate!
8
+
9
+ validate_cache_store!
10
+
11
+ if Skadi.configuration.db_connects_to
12
+ Skadi::ApplicationRecord.connects_to(**Skadi.configuration.db_connects_to)
13
+ end
14
+
15
+ if Skadi.configuration.user_model
16
+ Skadi::Visit.belongs_to :user, class_name: Skadi.configuration.user_model.to_s, optional: true
17
+ end
18
+ end
19
+
20
+ def self.validate_cache_store!
21
+ # Only the anonymity sets uses the cache
22
+ return unless Skadi.configuration.use_anonymity_sets
23
+
24
+ # A different cache store is common for development and testing
25
+ return if Rails.env.local?
26
+
27
+ if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
28
+ Rails.logger.warn("Skadi: anonymity sets are enabled but Rails.cache is a ActiveSupport::Cache::NullStore. The pepper won't be saved so anonymity sets have been disabled.")
29
+
30
+ Skadi.configuration.use_anonymity_sets = false
31
+ end
32
+
33
+ if Rails.cache.is_a?(ActiveSupport::Cache::MemoryStore)
34
+ Rails.logger.warn("Skadi: anonymity sets are enabled but Rails.cache is a ActiveSupport::Cache::MemoryStore. If Rails is running across multiple processes or servers, the pepper won't be shared across processes, breaking anonymity-set grouping. Use a shared store (SolidCache/Redis/Memcached).")
35
+ end
36
+ end
37
+ end
38
+ end