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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE.md +22 -0
- data/README.md +111 -0
- data/app/assets/builds/dashboard.css +2 -0
- data/app/assets/builds/dashboard.js +36 -0
- data/app/assets/builds/skadi.js +1 -0
- data/app/controllers/skadi/application_controller.rb +4 -0
- data/app/controllers/skadi/asset_controller.rb +28 -0
- data/app/controllers/skadi/dashboard_controller.rb +105 -0
- data/app/controllers/skadi/tracking_controller.rb +106 -0
- data/app/helpers/skadi/application_helper.rb +38 -0
- data/app/models/skadi/application_record.rb +5 -0
- data/app/models/skadi/dashboard.rb +83 -0
- data/app/models/skadi/dashboard_query.rb +314 -0
- data/app/models/skadi/dashboard_validator.rb +269 -0
- data/app/models/skadi/demographic.rb +31 -0
- data/app/models/skadi/event.rb +33 -0
- data/app/models/skadi/helpers/sql.rb +86 -0
- data/app/models/skadi/schema.rb +135 -0
- data/app/models/skadi/view.rb +9 -0
- data/app/models/skadi/visit.rb +59 -0
- data/app/views/skadi/dashboard/_layout.html.erb +21 -0
- data/app/views/skadi/dashboard/show.html.erb +18 -0
- data/config/routes.rb +14 -0
- data/lib/generators/skadi/install/USAGE +20 -0
- data/lib/generators/skadi/install/install_generator.rb +46 -0
- data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
- data/lib/skadi/analytics.rb +35 -0
- data/lib/skadi/anonymity_set.rb +38 -0
- data/lib/skadi/configuration.rb +232 -0
- data/lib/skadi/controller_delegate.rb +322 -0
- data/lib/skadi/cookie_manager.rb +81 -0
- data/lib/skadi/engine.rb +38 -0
- data/lib/skadi/url.rb +66 -0
- data/lib/skadi/user_agent.rb +458 -0
- data/lib/skadi/version.rb +3 -0
- data/lib/skadi.rb +26 -0
- 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
|
data/lib/skadi/engine.rb
ADDED
|
@@ -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
|