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,86 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
module Helpers
|
|
3
|
+
class Sql
|
|
4
|
+
class << self
|
|
5
|
+
def operator(model, operator)
|
|
6
|
+
return case operator
|
|
7
|
+
when "like"
|
|
8
|
+
ilike(model)
|
|
9
|
+
when "not like"
|
|
10
|
+
"NOT #{ilike(model)}"
|
|
11
|
+
else
|
|
12
|
+
operator
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Provides the keyword for case insensitive LIKE
|
|
17
|
+
def ilike(model)
|
|
18
|
+
case model.connection.adapter_name
|
|
19
|
+
when "PostgreSQL"
|
|
20
|
+
"ILIKE"
|
|
21
|
+
when "Mysql2", "SQLite"
|
|
22
|
+
"LIKE"
|
|
23
|
+
else
|
|
24
|
+
raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def string_before_separator(model, field, separator)
|
|
29
|
+
case model.connection.adapter_name
|
|
30
|
+
when "PostgreSQL"
|
|
31
|
+
"SPLIT_PART(#{model.table_name}.#{field}, '#{separator}', 1)"
|
|
32
|
+
when "Mysql2"
|
|
33
|
+
"SUBSTRING_INDEX(#{model.table_name}.#{field}, '#{separator}', 1)"
|
|
34
|
+
when "SQLite"
|
|
35
|
+
"SUBSTR(#{model.table_name}.#{field}, 1, INSTR(#{model.table_name}.#{field}, '#{separator}') - 1)"
|
|
36
|
+
else
|
|
37
|
+
raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def time_series(time_series, model, date_field)
|
|
42
|
+
case time_series
|
|
43
|
+
when "daily"
|
|
44
|
+
day_of_date(model, date_field)
|
|
45
|
+
when "weekly"
|
|
46
|
+
week_of_date(model, date_field)
|
|
47
|
+
when "monthly"
|
|
48
|
+
month_of_date(model, date_field)
|
|
49
|
+
else
|
|
50
|
+
raise ::Skadi::Dashboard::DatasetConfigurationError.new("The time_series #{time_series} is invalid")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def day_of_date(_model, date_field)
|
|
55
|
+
"DATE(#{date_field})"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def week_of_date(model, date_field)
|
|
59
|
+
case model.connection.adapter_name
|
|
60
|
+
when "PostgreSQL"
|
|
61
|
+
"DATE_TRUNC('week', #{date_field})::date"
|
|
62
|
+
when "Mysql2"
|
|
63
|
+
"DATE_SUB(DATE(#{date_field}), INTERVAL WEEKDAY(#{date_field}) DAY)"
|
|
64
|
+
when "SQLite"
|
|
65
|
+
"DATE(#{date_field}, '-' || ((CAST(STRFTIME('%w', #{date_field}) AS INTEGER) + 6) % 7) || ' days')"
|
|
66
|
+
else
|
|
67
|
+
raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def month_of_date(model, date_field)
|
|
72
|
+
case model.connection.adapter_name
|
|
73
|
+
when "PostgreSQL"
|
|
74
|
+
"DATE_TRUNC('month', #{date_field})::date"
|
|
75
|
+
when "Mysql2"
|
|
76
|
+
"DATE_FORMAT(#{date_field}, '%Y-%m-01')"
|
|
77
|
+
when "SQLite"
|
|
78
|
+
"DATE(#{date_field}, 'start of month')"
|
|
79
|
+
else
|
|
80
|
+
raise ::Skadi::Dashboard::UnsupportedDatabaseError.new("The database adapter #{model.connection.adapter_name} is not supported")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
class Schema
|
|
3
|
+
class << self
|
|
4
|
+
# Extract out the most common field config into a const
|
|
5
|
+
FILTER_AND_SPLIT = { filter: true, split: true }
|
|
6
|
+
private_constant :FILTER_AND_SPLIT
|
|
7
|
+
|
|
8
|
+
# A hash where the key represents a dataset name and the value is its configuration
|
|
9
|
+
def database_schema
|
|
10
|
+
return @_database_schema if defined?(@_database_schema)
|
|
11
|
+
|
|
12
|
+
@_database_schema ||= {
|
|
13
|
+
# A hash with the following keys:
|
|
14
|
+
# model: The model for this dataset
|
|
15
|
+
# visit_key: used for chart-level visit filters
|
|
16
|
+
# count_sql: The SQL expression used to count this field. Defaults to COUNT().
|
|
17
|
+
# fields: A hash where the key represents a field and the value is its configuration
|
|
18
|
+
visits: {
|
|
19
|
+
model: Skadi::Visit,
|
|
20
|
+
visit_key: "id",
|
|
21
|
+
# If the table has a date field, the fields Hash expects a value for :date
|
|
22
|
+
fields: {
|
|
23
|
+
# A hash with the following keys:
|
|
24
|
+
# label: The label to show in the front end
|
|
25
|
+
# type: The datatype, one of :one_of, :date, :string, :number, :boolean. Defaults to :string.
|
|
26
|
+
# filter: True if this field can be filtered
|
|
27
|
+
# split: True if this field can be split
|
|
28
|
+
# sql: The SQL expression used to get this value for derived fields
|
|
29
|
+
# options: A list of possible values
|
|
30
|
+
# description: used in the front end as help text for this field
|
|
31
|
+
date: {
|
|
32
|
+
type: :date,
|
|
33
|
+
filter: true,
|
|
34
|
+
sql: "skadi_visits.created_at",
|
|
35
|
+
},
|
|
36
|
+
landing_page: FILTER_AND_SPLIT,
|
|
37
|
+
referrer_domain: {
|
|
38
|
+
**FILTER_AND_SPLIT,
|
|
39
|
+
sql: Helpers::Sql.string_before_separator(Skadi::Visit, "referrer", "/"),
|
|
40
|
+
},
|
|
41
|
+
utm_source: FILTER_AND_SPLIT,
|
|
42
|
+
utm_medium: FILTER_AND_SPLIT,
|
|
43
|
+
utm_term: FILTER_AND_SPLIT,
|
|
44
|
+
utm_content: FILTER_AND_SPLIT,
|
|
45
|
+
utm_campaign: FILTER_AND_SPLIT,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
views: {
|
|
49
|
+
model: Skadi::View,
|
|
50
|
+
visit_key: "visit_id",
|
|
51
|
+
fields: {
|
|
52
|
+
date: {
|
|
53
|
+
type: :date,
|
|
54
|
+
filter: true,
|
|
55
|
+
sql: "skadi_views.created_at",
|
|
56
|
+
},
|
|
57
|
+
verified: {
|
|
58
|
+
type: :boolean,
|
|
59
|
+
filter: true,
|
|
60
|
+
},
|
|
61
|
+
controller: FILTER_AND_SPLIT,
|
|
62
|
+
action: {
|
|
63
|
+
filter: true,
|
|
64
|
+
},
|
|
65
|
+
controller_and_action: {
|
|
66
|
+
split: true,
|
|
67
|
+
sql: "CONCAT(skadi_views.controller, '::', skadi_views.action)",
|
|
68
|
+
},
|
|
69
|
+
path: FILTER_AND_SPLIT,
|
|
70
|
+
verb: {
|
|
71
|
+
type: :one_of,
|
|
72
|
+
filter: true,
|
|
73
|
+
split: true,
|
|
74
|
+
description: "Typically, GET requests are page views, and POST, PUT, PATCH and DELETE are form submissions.",
|
|
75
|
+
options: %w[GET POST PUT PATCH DELETE],
|
|
76
|
+
},
|
|
77
|
+
version: FILTER_AND_SPLIT,
|
|
78
|
+
exit_page: FILTER_AND_SPLIT,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
events: {
|
|
82
|
+
model: Skadi::Event,
|
|
83
|
+
visit_key: "visit_id",
|
|
84
|
+
fields: {
|
|
85
|
+
date: {
|
|
86
|
+
type: :date,
|
|
87
|
+
filter: true,
|
|
88
|
+
sql: "skadi_events.created_at",
|
|
89
|
+
},
|
|
90
|
+
name: FILTER_AND_SPLIT,
|
|
91
|
+
**(Skadi.configuration.dashboard_custom_event_fields || {}),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
demographics: {
|
|
95
|
+
model: Demographic,
|
|
96
|
+
count_sql: "COALESCE(SUM(skadi_demographics.count), 0)",
|
|
97
|
+
fields: {
|
|
98
|
+
date: {
|
|
99
|
+
type: :date,
|
|
100
|
+
filter: true,
|
|
101
|
+
sql: "skadi_demographics.recorded_on",
|
|
102
|
+
},
|
|
103
|
+
uri: FILTER_AND_SPLIT,
|
|
104
|
+
name: FILTER_AND_SPLIT,
|
|
105
|
+
value: FILTER_AND_SPLIT,
|
|
106
|
+
count: {
|
|
107
|
+
**FILTER_AND_SPLIT,
|
|
108
|
+
type: :number,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
**(Skadi.configuration.dashboard_custom_schema || {}),
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def frontend_schema
|
|
117
|
+
return @_frontend_schema if defined?(@_frontend_schema)
|
|
118
|
+
|
|
119
|
+
# Redact the SQL from the dashboard schema
|
|
120
|
+
schema = database_schema.deep_dup
|
|
121
|
+
schema.each_value do |dataset|
|
|
122
|
+
dataset.keys.each do |key|
|
|
123
|
+
dataset.delete(key) unless key == :fields
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
dataset[:fields].each_value do |field|
|
|
127
|
+
field.delete(:sql)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@_frontend_schema = schema
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
class Visit < ApplicationRecord
|
|
3
|
+
has_many :views, class_name: "Skadi::View", inverse_of: :visit
|
|
4
|
+
has_many :events, class_name: "Skadi::Event", inverse_of: :visit
|
|
5
|
+
|
|
6
|
+
def token = visit_token
|
|
7
|
+
|
|
8
|
+
def self.find_active_visit_for(tracking_token, user)
|
|
9
|
+
return nil if tracking_token.nil? && user.nil?
|
|
10
|
+
|
|
11
|
+
visit_query = nil
|
|
12
|
+
if tracking_token
|
|
13
|
+
visit_query = where(tracking_token: tracking_token)
|
|
14
|
+
.and(where("created_at > ?", Skadi.configuration.visit_duration.ago))
|
|
15
|
+
end
|
|
16
|
+
if user&.persisted?
|
|
17
|
+
user_visit_query = where(user_id: user.id)
|
|
18
|
+
.and(where("created_at > ?", Skadi.configuration.visit_duration.ago))
|
|
19
|
+
|
|
20
|
+
visit_query = visit_query ? visit_query.or(user_visit_query) : user_visit_query
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
visit = visit_query&.order(created_at: :desc)&.limit(1)&.first
|
|
24
|
+
|
|
25
|
+
# If the user has changed since the last visit, create a new visit
|
|
26
|
+
return nil if visit&.user_id && user&.persisted? && visit.user_id != user.id
|
|
27
|
+
|
|
28
|
+
visit
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param tracking_token [String, nil]
|
|
32
|
+
# @param user [ActiveModel::Model, nil]
|
|
33
|
+
# @param request [ActionDispatch::Request]
|
|
34
|
+
# @return [Skadi::Visit]
|
|
35
|
+
def self.build_from(tracking_token, user_id = nil, request = nil, cookies_enabled: nil)
|
|
36
|
+
if cookies_enabled.nil?
|
|
37
|
+
cookies_enabled = request&.cookie_jar&.key?("skadi_id") || false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
new(
|
|
41
|
+
visit_token: SecureRandom.uuid_v7,
|
|
42
|
+
tracking_token: tracking_token,
|
|
43
|
+
user_id: user_id,
|
|
44
|
+
|
|
45
|
+
referrer: request ? Skadi::Url.redact_and_normalise_url(request.referrer) : nil,
|
|
46
|
+
landing_page: request ? Skadi::Url.view_path_from_request(request) : nil,
|
|
47
|
+
|
|
48
|
+
utm_source: request ? request.query_parameters["utm_source"] : nil,
|
|
49
|
+
utm_medium: request ? request.query_parameters["utm_medium"] : nil,
|
|
50
|
+
utm_term: request ? request.query_parameters["utm_term"] : nil,
|
|
51
|
+
utm_content: request ? request.query_parameters["utm_content"] : nil,
|
|
52
|
+
utm_campaign: request ? request.query_parameters["utm_campaign"] : nil,
|
|
53
|
+
|
|
54
|
+
verified: false,
|
|
55
|
+
cookies_enabled: cookies_enabled,
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<%# locals: () %>
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<title>📈 Skadi Analytics</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
|
+
<%= csrf_meta_tags %>
|
|
8
|
+
<%= csp_meta_tag %>
|
|
9
|
+
|
|
10
|
+
<%= yield :head %>
|
|
11
|
+
</head>
|
|
12
|
+
|
|
13
|
+
<body class="flex flex-col gap-4 min-h-screen">
|
|
14
|
+
<%= yield %>
|
|
15
|
+
<footer class="fixed bottom-2 right-6">
|
|
16
|
+
<p class="text-sm text-right text-dawn-900/65 font-semibold">
|
|
17
|
+
<a href="https://github.com/mwnciau/skadi" target="_blank">Powered by Skadi</a>
|
|
18
|
+
</p>
|
|
19
|
+
</footer>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# locals: (dashboard_configuration:, dataset_schema:, can_edit:, can_dangerously_use_sql:) %>
|
|
2
|
+
|
|
3
|
+
<% content_for :head do %>
|
|
4
|
+
<%= stylesheet_link_tag "#{Skadi::Engine.routes.url_helpers.dashboard_css_path}?#{Skadi::VERSION}", media: "all" %>
|
|
5
|
+
<%= javascript_include_tag "#{Skadi::Engine.routes.url_helpers.dashboard_js_path}?#{Skadi::VERSION}",
|
|
6
|
+
data: {
|
|
7
|
+
"dashboard-config": dashboard_configuration.to_json,
|
|
8
|
+
"dataset-schema": dataset_schema.to_json,
|
|
9
|
+
"can-edit": can_edit ? "true" : "false",
|
|
10
|
+
"can-dangerously-use-sql": can_dangerously_use_sql ? "true" : "false",
|
|
11
|
+
"fetch-data-path": dashboard_data_path,
|
|
12
|
+
"update-dashboard-path": dashboard_update_path,
|
|
13
|
+
} %>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<%= render "layout" do %>
|
|
17
|
+
<div id="skadi-dashboard"></div>
|
|
18
|
+
<% end %>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Skadi::Engine.routes.draw do
|
|
2
|
+
root to: "dashboard#show"
|
|
3
|
+
|
|
4
|
+
post "/", to: "tracking#track", as: :tracking_endpoint
|
|
5
|
+
|
|
6
|
+
get "/skadi.js", to: "asset#tracking_script", as: :tracking_script
|
|
7
|
+
get "/dashboard.css", to: "asset#dashboard_css", as: :dashboard_css
|
|
8
|
+
get "/dashboard.js", to: "asset#dashboard_js", as: :dashboard_js
|
|
9
|
+
|
|
10
|
+
get "/dashboard", to: "dashboard#show", as: :dashboard
|
|
11
|
+
post "/dashboard/update", to: "dashboard#update", as: :dashboard_update
|
|
12
|
+
|
|
13
|
+
post "/data", to: "dashboard#data", as: :dashboard_data
|
|
14
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Creates a migration for the Skadi Analytics tables in your application.
|
|
3
|
+
|
|
4
|
+
Run `rails db:migrate` afterwards to apply the migration.
|
|
5
|
+
|
|
6
|
+
Options:
|
|
7
|
+
--db-engine=ENGINE # Database engine: postgres, mysql, sqlite
|
|
8
|
+
# Default: postgres
|
|
9
|
+
--user-id-type=TYPE # Column type of your users table primary key:
|
|
10
|
+
# bigint, integer, uuid, string
|
|
11
|
+
# Default: bigint
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
rails generate skadi:install
|
|
15
|
+
|
|
16
|
+
This will create:
|
|
17
|
+
db/migrate/<timestamp>_install_skadi.rb
|
|
18
|
+
|
|
19
|
+
For a MySQL app with UUID user ids:
|
|
20
|
+
rails generate skadi:install --db-engine=mysql --user-id-type=uuid
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Skadi
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
include Rails::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
desc "Generates the Skadi analytics migration"
|
|
13
|
+
|
|
14
|
+
VALID_DB_ENGINES = [ :postgres, :mysql, :sqlite ]
|
|
15
|
+
class_option :db_engine,
|
|
16
|
+
type: :string,
|
|
17
|
+
default: "postgres",
|
|
18
|
+
desc: "Database engine (postgres, mysql, sqlite)"
|
|
19
|
+
|
|
20
|
+
VALID_USER_ID_TYPES = [ :bigint, :integer, :uuid, :string ]
|
|
21
|
+
class_option :user_id_type,
|
|
22
|
+
type: :string,
|
|
23
|
+
default: "bigint",
|
|
24
|
+
desc: "Column type of your users table primary key (bigint, integer, uuid, string)"
|
|
25
|
+
|
|
26
|
+
def self.next_migration_number(dirname) = ::ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
27
|
+
|
|
28
|
+
def create_migration_file
|
|
29
|
+
raise Error.new("Invalid database engine specified") unless VALID_DB_ENGINES.include?(db_engine)
|
|
30
|
+
raise Error.new("Invalid user id type specified") unless VALID_USER_ID_TYPES.include?(user_id_type)
|
|
31
|
+
|
|
32
|
+
migration_template "skadi_migration.rb.erb",
|
|
33
|
+
"db/migrate/install_skadi.rb"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Thor requires us to use a private block rather than private on individual definitions
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def user_id_type = options[:user_id_type].to_sym
|
|
40
|
+
def db_engine = options[:db_engine].to_sym
|
|
41
|
+
def uuid_column_type = (db_engine == :postgres) ? :uuid : :string
|
|
42
|
+
def uuid_column_options = (db_engine == :postgres) ? "" : ", limit: 36"
|
|
43
|
+
def json_column_type = (db_engine == :postgres) ? :jsonb : :json
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :skadi_visits do |t|
|
|
4
|
+
# A random uuid identifying the visit. This will be sent to the front-end to allow updates to the visit javascript flag without exposing details about the site analytics to the user.
|
|
5
|
+
t.<%= uuid_column_type %> :visit_token<%= uuid_column_options %>, null: false
|
|
6
|
+
|
|
7
|
+
# A token identifying the user. This will either be a token generated from an anonymity set based on the user's IP and User Agent, or it will be sourced from a cookie.
|
|
8
|
+
t.<%= uuid_column_type %> :tracking_token<%= uuid_column_options %>
|
|
9
|
+
|
|
10
|
+
# The ID of a logged in user. The FK is intentionally absent in case the host app doesn't have a users table.
|
|
11
|
+
<%- if user_id_type == :uuid && db_engine != :postgres -%>
|
|
12
|
+
t.references :user, type: :string, limit: 36, index: false
|
|
13
|
+
<%- else -%>
|
|
14
|
+
t.references :user, type: :<%= user_id_type %>, index: false
|
|
15
|
+
<%- end -%>
|
|
16
|
+
|
|
17
|
+
t.text :referrer
|
|
18
|
+
t.text :landing_page
|
|
19
|
+
|
|
20
|
+
# Standard UTM parameters
|
|
21
|
+
t.text :utm_source
|
|
22
|
+
t.text :utm_medium
|
|
23
|
+
t.text :utm_term
|
|
24
|
+
t.text :utm_content
|
|
25
|
+
t.text :utm_campaign
|
|
26
|
+
|
|
27
|
+
# Whether the visit has been verified by the front-end
|
|
28
|
+
t.boolean :verified, null: false, default: false
|
|
29
|
+
|
|
30
|
+
t.boolean :cookies_enabled, null: false, default: false
|
|
31
|
+
|
|
32
|
+
t.timestamps
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
add_index :skadi_visits, :visit_token, unique: true
|
|
36
|
+
add_index :skadi_visits, :created_at
|
|
37
|
+
add_index :skadi_visits, [:tracking_token, :created_at]
|
|
38
|
+
add_index :skadi_visits, [:user_id, :created_at]
|
|
39
|
+
|
|
40
|
+
create_table :skadi_views do |t|
|
|
41
|
+
# Intentionally left nullable as not all requests will have a visit in the case a user has requested no tracking.
|
|
42
|
+
t.references :visit, foreign_key: { to_table: :skadi_visits, on_delete: :cascade }, index: false
|
|
43
|
+
|
|
44
|
+
# A random uuid identifying the view. This will be sent to the front-end to allow updates to the view metrics without exposing details about the site analytics to the user.
|
|
45
|
+
t.<%= uuid_column_type %> :view_token<%= uuid_column_options %>, null: false
|
|
46
|
+
|
|
47
|
+
t.string :controller, null: false
|
|
48
|
+
t.string :action, null: false
|
|
49
|
+
t.string :verb, null: false
|
|
50
|
+
t.text :path, null: false
|
|
51
|
+
t.<%= json_column_type %> :query_params
|
|
52
|
+
|
|
53
|
+
# The page the user clicked on when leaving the page
|
|
54
|
+
t.text :exit_page
|
|
55
|
+
|
|
56
|
+
# Whether the view has been verified by the front-end
|
|
57
|
+
t.boolean :verified, null: false, default: false
|
|
58
|
+
|
|
59
|
+
# Version of the page presented to the user (e.g. branch A/B)
|
|
60
|
+
t.string :version
|
|
61
|
+
|
|
62
|
+
t.timestamps
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
add_index :skadi_views, :view_token, unique: true
|
|
66
|
+
add_index :skadi_views, :created_at
|
|
67
|
+
add_index :skadi_views, [:path, :created_at]
|
|
68
|
+
add_index :skadi_views, [:visit_id, :created_at]
|
|
69
|
+
|
|
70
|
+
create_table :skadi_events do |t|
|
|
71
|
+
# Intentionally left nullable as not all requests will have a visit in the case a user has requested no tracking.
|
|
72
|
+
t.references :visit, foreign_key: { to_table: :skadi_visits, on_delete: :cascade }, index: false
|
|
73
|
+
|
|
74
|
+
# Intentionally left nullable as not all requests will have a visit in the case a user has requested no tracking.
|
|
75
|
+
t.references :view, foreign_key: { to_table: :skadi_views, on_delete: :cascade }, index: false
|
|
76
|
+
|
|
77
|
+
t.string :name, null: false
|
|
78
|
+
t.<%= json_column_type %> :properties
|
|
79
|
+
|
|
80
|
+
t.datetime :created_at, null: false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
add_index :skadi_events, :created_at
|
|
84
|
+
add_index :skadi_events, [:name, :created_at]
|
|
85
|
+
add_index :skadi_events, [:view_id, :created_at]
|
|
86
|
+
add_index :skadi_events, [:visit_id, :created_at]
|
|
87
|
+
<%- if db_engine == :postgres -%>
|
|
88
|
+
add_index :skadi_events, :properties, using: :gin, opclass: :jsonb_path_ops
|
|
89
|
+
<%- end -%>
|
|
90
|
+
|
|
91
|
+
# Store demographic data separately so that it cannot be used to identify users
|
|
92
|
+
# E.g. screen size, language, timezone, pointer type (mouse, touch), can-hover, prefers reduced motion, prefers contrast, forced colours, prefers dark mode
|
|
93
|
+
create_table :skadi_demographics do |t|
|
|
94
|
+
t.string :uri, null: false
|
|
95
|
+
t.string :name, null: false
|
|
96
|
+
t.string :value, null: false
|
|
97
|
+
t.date :recorded_on, null: false
|
|
98
|
+
t.integer :count, null: false, default: 0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
add_index :skadi_demographics, [:uri, :name, :value, :recorded_on], unique: true
|
|
102
|
+
|
|
103
|
+
create_table :skadi_dashboards do |t|
|
|
104
|
+
t.string :name, null: false
|
|
105
|
+
t.text :description
|
|
106
|
+
t.<%= json_column_type %> :configuration, null: false
|
|
107
|
+
|
|
108
|
+
t.timestamps
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Adds Skadi Analytics helper methods to your controller, and enabled automatic tracking if configured.
|
|
2
|
+
module Skadi
|
|
3
|
+
module Analytics
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
around_action :skadi_track
|
|
8
|
+
|
|
9
|
+
# Make skadi available in the view for when we output the frontend script
|
|
10
|
+
helper_method :skadi
|
|
11
|
+
|
|
12
|
+
# Add the Skadi view helper methods
|
|
13
|
+
helper Skadi::ApplicationHelper
|
|
14
|
+
|
|
15
|
+
# Disable Skadi tracking for the current controller
|
|
16
|
+
def self.do_not_track!(**kwargs)
|
|
17
|
+
skip_around_action :skadi_track, **kwargs
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def skadi
|
|
22
|
+
@_skadi ||= Skadi::ControllerDelegate.new(self)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def skadi_track
|
|
26
|
+
skadi._prepare
|
|
27
|
+
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
skadi._persist
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
delegate :do_not_track!, to: :skadi
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
module AnonymitySet
|
|
3
|
+
# Generates a unique token for the given IP and user agent
|
|
4
|
+
# @return [String]
|
|
5
|
+
def self.calculate(ip, user_agent)
|
|
6
|
+
user_fingerprint = "#{ip}|#{user_agent}"
|
|
7
|
+
|
|
8
|
+
hash = OpenSSL::HMAC.hexdigest("sha256", pepper, user_fingerprint)
|
|
9
|
+
|
|
10
|
+
# We want a UUID-like string to be compatible with the uuid type if the database is PostgreSQL, but don't need a valid UUID
|
|
11
|
+
"#{hash[0, 8]}-#{hash[8, 4]}-#{hash[12, 4]}-#{hash[16, 4]}-#{hash[20, 12]}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Generate a pepper to be used in the anonymity set hash
|
|
15
|
+
# @return [String]
|
|
16
|
+
def self.pepper
|
|
17
|
+
duration = Skadi.configuration.anonymity_set_duration
|
|
18
|
+
reset_hour = Skadi.configuration.anonymity_set_reset_hour
|
|
19
|
+
|
|
20
|
+
current_time = Time.current
|
|
21
|
+
pepper_expiry = current_time + duration
|
|
22
|
+
|
|
23
|
+
if reset_hour
|
|
24
|
+
# We always want to truncate the duration to the reset hour, so if the expiry hour is before the reset hour, go back to the previous day
|
|
25
|
+
pepper_expiry -= 1.day if pepper_expiry.hour < reset_hour
|
|
26
|
+
|
|
27
|
+
pepper_expiry = pepper_expiry.change(hour: reset_hour, min: 0, sec: 0)
|
|
28
|
+
|
|
29
|
+
# Handle the case where the duration is less than one day, so the expiry is in the past
|
|
30
|
+
pepper_expiry += 1.day if pepper_expiry < current_time
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Rails.cache.fetch("#{Skadi.configuration.anonymity_set_cache_key}/v1", expires_in: [ 1, pepper_expiry.to_i - current_time.to_i ].max) do
|
|
34
|
+
SecureRandom.hex(32)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|