rails_analytics 1.0.0
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/MIT-LICENSE +20 -0
- data/README.md +270 -0
- data/app/assets/javascripts/rails_analytics/tracker.js +136 -0
- data/app/assets/stylesheets/rails_analytics/dashboard.css +156 -0
- data/app/controllers/rails_analytics/analytics_controller.rb +91 -0
- data/app/controllers/rails_analytics/application_controller.rb +24 -0
- data/app/controllers/rails_analytics/dashboards_controller.rb +41 -0
- data/app/helpers/rails_analytics/application_helper.rb +12 -0
- data/app/jobs/rails_analytics/retention_job.rb +27 -0
- data/app/models/rails_analytics/chart_builder.rb +54 -0
- data/app/models/rails_analytics/daily_salt.rb +26 -0
- data/app/models/rails_analytics/event.rb +15 -0
- data/app/models/rails_analytics/visit.rb +45 -0
- data/app/views/layouts/rails_analytics.html.erb +26 -0
- data/app/views/rails_analytics/dashboards/events.html.erb +50 -0
- data/app/views/rails_analytics/dashboards/overview.html.erb +102 -0
- data/app/views/rails_analytics/dashboards/visits.html.erb +39 -0
- data/config/locales/rails_analytics.en.yml +37 -0
- data/config/locales/rails_analytics.pt-BR.yml +37 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260906000001_create_rails_analytics_daily_salts.rb +14 -0
- data/db/migrate/20260906000002_create_rails_analytics_visits.rb +27 -0
- data/db/migrate/20260906000003_create_rails_analytics_events.rb +25 -0
- data/docs/dashboard-2.png +0 -0
- data/docs/dashboard.png +0 -0
- data/lib/generators/rails_analytics/install/install_generator.rb +50 -0
- data/lib/generators/rails_analytics/install/templates/initializer.rb +13 -0
- data/lib/rails_analytics/configuration.rb +17 -0
- data/lib/rails_analytics/engine.rb +32 -0
- data/lib/rails_analytics/identity.rb +51 -0
- data/lib/rails_analytics/ip_mask.rb +24 -0
- data/lib/rails_analytics/rack_attack.rb +22 -0
- data/lib/rails_analytics/stats.rb +146 -0
- data/lib/rails_analytics/version.rb +5 -0
- data/lib/rails_analytics.rb +20 -0
- metadata +95 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
class DashboardsController < ApplicationController
|
|
5
|
+
def overview
|
|
6
|
+
since = default_since
|
|
7
|
+
daily_series = Stats.visits_by_day(since: since)
|
|
8
|
+
|
|
9
|
+
@stats = {
|
|
10
|
+
total_visits: Stats.total_visits(since: since),
|
|
11
|
+
total_unique: Stats.total_unique_visitors(since: since),
|
|
12
|
+
total_events: Stats.total_events(since: since),
|
|
13
|
+
bounce_rate: Stats.bounce_rate(since: since),
|
|
14
|
+
top_sources: Stats.top_sources(limit: 10, since: since),
|
|
15
|
+
top_events: Stats.top_events(limit: 10, since: since),
|
|
16
|
+
devices: Stats.devices(since: since),
|
|
17
|
+
utm_breakdown: Stats.utm_breakdown(since: since)
|
|
18
|
+
}
|
|
19
|
+
@chart = ChartBuilder.new(
|
|
20
|
+
daily_series.map { |date, count| { date: date, count: count } }
|
|
21
|
+
).build
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def events
|
|
25
|
+
@event_name = params[:name]
|
|
26
|
+
@page = (params[:page] || 1).to_i
|
|
27
|
+
@events = Stats.events_paginated(name: @event_name.presence, page: @page, since: default_since)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def visits
|
|
31
|
+
@page = (params[:page] || 1).to_i
|
|
32
|
+
@visits = Stats.visits_paginated(page: @page, since: default_since)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def default_since
|
|
38
|
+
RailsAnalytics.config.since_default.ago
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
module ApplicationHelper
|
|
5
|
+
def rails_analytics_tracker_tag(options = {})
|
|
6
|
+
endpoint = options[:endpoint] || RailsAnalytics.config.mount_path
|
|
7
|
+
tag.script(src: "#{endpoint}/tracker.js",
|
|
8
|
+
data: { rails_analytics: true, endpoint: endpoint },
|
|
9
|
+
defer: true)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
class RetentionJob < ActiveJob::Base
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
RETENTION_PERIOD = 6.months
|
|
8
|
+
|
|
9
|
+
def perform
|
|
10
|
+
cutoff = RETENTION_PERIOD.ago
|
|
11
|
+
deleted_visits = 0
|
|
12
|
+
deleted_events = 0
|
|
13
|
+
|
|
14
|
+
Visit.where(Visit.arel_table[:started_at].lt(cutoff)).find_in_batches(batch_size: 1000) do |batch|
|
|
15
|
+
visit_ids = batch.map(&:id)
|
|
16
|
+
|
|
17
|
+
events_count = Event.where(visit_id: visit_ids).delete_all
|
|
18
|
+
visits_count = Visit.where(id: visit_ids).delete_all
|
|
19
|
+
|
|
20
|
+
deleted_events += events_count
|
|
21
|
+
deleted_visits += visits_count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Rails.logger.info "[rails_analytics] RetentionJob: deleted #{deleted_visits} visits and #{deleted_events} events older than #{cutoff}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
# SVG line chart. Server-rendered, zero JS.
|
|
5
|
+
class ChartBuilder
|
|
6
|
+
WIDTH = 800
|
|
7
|
+
HEIGHT = 220
|
|
8
|
+
PAD = 8
|
|
9
|
+
RIGHT_PAD = 40
|
|
10
|
+
|
|
11
|
+
def initialize(series)
|
|
12
|
+
@series = series # Array<Hash> with :date and :count
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
return empty_chart if @series.empty?
|
|
17
|
+
|
|
18
|
+
max = [@series.map { |s| s[:count] }.max.to_f, 1].max
|
|
19
|
+
points = @series.map.with_index do |s, i|
|
|
20
|
+
x = pad(i)
|
|
21
|
+
y = HEIGHT - PAD - ((s[:count] / max) * (HEIGHT - PAD * 2)).round
|
|
22
|
+
"#{x.round(2)},#{y.round(2)}"
|
|
23
|
+
end.join(" ")
|
|
24
|
+
|
|
25
|
+
<<~SVG
|
|
26
|
+
<svg viewBox="0 0 #{WIDTH + RIGHT_PAD} #{HEIGHT}" role="img" aria-label="#{I18n.t('rails_analytics.chart.visits_per_day')}" xmlns="http://www.w3.org/2000/svg">
|
|
27
|
+
<polyline points="#{points}" fill="none" stroke="var(--ra-accent, #3b82f6)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
|
|
28
|
+
#{dots(points)}
|
|
29
|
+
</svg>
|
|
30
|
+
SVG
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def pad(index)
|
|
36
|
+
PAD + index * ((WIDTH - PAD) / [@series.length - 1, 1].max.to_f)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dots(points_str)
|
|
40
|
+
points_str.split(" ").map do |pt|
|
|
41
|
+
x, y = pt.split(",")
|
|
42
|
+
%(<circle cx="#{x}" cy="#{y}" r="3" fill="var(--ra-accent, #3b82f6)" />)
|
|
43
|
+
end.join
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def empty_chart
|
|
47
|
+
<<~SVG
|
|
48
|
+
<svg viewBox="0 0 #{WIDTH} #{HEIGHT}" role="img" xmlns="http://www.w3.org/2000/svg">
|
|
49
|
+
<text x="#{WIDTH / 2}" y="#{HEIGHT / 2}" text-anchor="middle" fill="var(--ra-muted, #6b7280)">#{I18n.t('rails_analytics.chart.no_data')}</text>
|
|
50
|
+
</svg>
|
|
51
|
+
SVG
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module RailsAnalytics
|
|
7
|
+
class DailySalt < ActiveRecord::Base
|
|
8
|
+
self.table_name = "rails_analytics_daily_salts"
|
|
9
|
+
|
|
10
|
+
validates :date, presence: true, uniqueness: true
|
|
11
|
+
validates :salt, presence: true
|
|
12
|
+
|
|
13
|
+
def self.for_date(date = Date.today)
|
|
14
|
+
find_or_create_by!(date: date) do |ds|
|
|
15
|
+
ds.salt = SecureRandom.hex(32)
|
|
16
|
+
end.salt
|
|
17
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
|
|
18
|
+
derive_salt(date)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.derive_salt(date)
|
|
22
|
+
key = Rails.application.try(:secret_key_base) || Rails.application.try(:secrets).try(:secret_key_base) || "rails_analytics_fallback"
|
|
23
|
+
Digest::SHA256.hexdigest("#{key}:#{date.iso8601}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
class Event < ActiveRecord::Base
|
|
5
|
+
self.table_name = "rails_analytics_events"
|
|
6
|
+
|
|
7
|
+
belongs_to :visit, class_name: "RailsAnalytics::Visit"
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :time, presence: true
|
|
11
|
+
|
|
12
|
+
scope :since, ->(time) { where(arel_table[:time].gteq(time)) }
|
|
13
|
+
scope :named, ->(name) { where(name: name) }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
class Visit < ActiveRecord::Base
|
|
5
|
+
self.table_name = "rails_analytics_visits"
|
|
6
|
+
|
|
7
|
+
has_many :events, class_name: "RailsAnalytics::Event", dependent: :delete_all
|
|
8
|
+
|
|
9
|
+
validates :anonymity_key, presence: true
|
|
10
|
+
validates :masked_ip, presence: true
|
|
11
|
+
validates :started_at, presence: true
|
|
12
|
+
|
|
13
|
+
scope :since, ->(time) { where(arel_table[:started_at].gteq(time)) }
|
|
14
|
+
scope :for_key, ->(key) { where(anonymity_key: key) }
|
|
15
|
+
|
|
16
|
+
INACTIVITY_WINDOW = 4.hours
|
|
17
|
+
|
|
18
|
+
def self.find_or_create_for(anonymity_key, attrs = {})
|
|
19
|
+
recent = for_key(anonymity_key)
|
|
20
|
+
.where(started_at: (attrs[:started_at] || Time.current) - INACTIVITY_WINDOW..)
|
|
21
|
+
.order(started_at: :desc)
|
|
22
|
+
.first
|
|
23
|
+
|
|
24
|
+
return recent if recent
|
|
25
|
+
|
|
26
|
+
create!(attrs.merge(anonymity_key: anonymity_key))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.parse_utm_params(path)
|
|
30
|
+
return {} if path.blank?
|
|
31
|
+
|
|
32
|
+
query = URI.parse(path).query.to_s
|
|
33
|
+
params = URI.decode_www_form(query).to_h
|
|
34
|
+
{
|
|
35
|
+
utm_source: params["utm_source"],
|
|
36
|
+
utm_medium: params["utm_medium"],
|
|
37
|
+
utm_campaign: params["utm_campaign"],
|
|
38
|
+
utm_term: params["utm_term"],
|
|
39
|
+
utm_content: params["utm_content"]
|
|
40
|
+
}
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%# app/views/layouts/rails_analytics.html.erb %>
|
|
2
|
+
<!DOCTYPE html>
|
|
3
|
+
<html lang="<%= I18n.locale %>">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title><%= t("rails_analytics.title") %></title>
|
|
8
|
+
<link rel="stylesheet" href="<%= request.base_url %>/rails_analytics/dashboard.css" media="all">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header class="ra-header">
|
|
12
|
+
<h1 class="ra-brand"><%= t("rails_analytics.title") %></h1>
|
|
13
|
+
<nav class="ra-nav">
|
|
14
|
+
<%= link_to t("rails_analytics.nav.overview"), rails_analytics.root_path %>
|
|
15
|
+
<%= link_to t("rails_analytics.nav.events"), rails_analytics.events_path %>
|
|
16
|
+
<%= link_to t("rails_analytics.nav.visits"), rails_analytics.visits_path %>
|
|
17
|
+
</nav>
|
|
18
|
+
</header>
|
|
19
|
+
<main class="ra-main">
|
|
20
|
+
<%= yield %>
|
|
21
|
+
</main>
|
|
22
|
+
<footer class="ra-footer">
|
|
23
|
+
<span><%= t("rails_analytics.footer") %></span>
|
|
24
|
+
</footer>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<%# app/views/rails_analytics/dashboards/events.html.erb %>
|
|
2
|
+
<section class="ra-card">
|
|
3
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.nav.events") %></h2>
|
|
4
|
+
|
|
5
|
+
<div class="ra-filter">
|
|
6
|
+
<%= form_tag rails_analytics.events_path, method: :get do %>
|
|
7
|
+
<%= select_tag :name,
|
|
8
|
+
options_for_select(
|
|
9
|
+
[[t("rails_analytics.filter.all_events"), ""]] +
|
|
10
|
+
RailsAnalytics::Stats.event_counts_by_name.keys.map { |n| [n, n] },
|
|
11
|
+
@event_name
|
|
12
|
+
),
|
|
13
|
+
onchange: "this.form.submit()" %>
|
|
14
|
+
<noscript><%= submit_tag t("rails_analytics.filter.filter") %></noscript>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% if @events[:data].any? %>
|
|
19
|
+
<table class="ra-table">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr>
|
|
22
|
+
<th><%= t("rails_analytics.tables.date") %></th>
|
|
23
|
+
<th><%= t("rails_analytics.tables.event_name") %></th>
|
|
24
|
+
<th class="ra-num"><%= t("rails_analytics.tables.count") %></th>
|
|
25
|
+
</tr>
|
|
26
|
+
</thead>
|
|
27
|
+
<tbody>
|
|
28
|
+
<% @events[:data].each do |row| %>
|
|
29
|
+
<tr>
|
|
30
|
+
<td class="ra-mono"><%= row[:date] %></td>
|
|
31
|
+
<td class="ra-mono"><%= row[:name] %></td>
|
|
32
|
+
<td class="ra-num"><%= number_with_delimiter(row[:count]) %></td>
|
|
33
|
+
</tr>
|
|
34
|
+
<% end %>
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
|
|
38
|
+
<div class="ra-pagination">
|
|
39
|
+
<% if @page > 1 %>
|
|
40
|
+
<%= link_to t("rails_analytics.pagination.previous"), rails_analytics.events_path(page: @page - 1, name: @event_name) %>
|
|
41
|
+
<% end %>
|
|
42
|
+
<span class="current"><%= @page %></span>
|
|
43
|
+
<% if @events[:data].length == @events[:per_page] %>
|
|
44
|
+
<%= link_to t("rails_analytics.pagination.next"), rails_analytics.events_path(page: @page + 1, name: @event_name) %>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
<% else %>
|
|
48
|
+
<p class="ra-empty"><%= t("rails_analytics.empty") %></p>
|
|
49
|
+
<% end %>
|
|
50
|
+
</section>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<%# app/views/rails_analytics/dashboards/overview.html.erb %>
|
|
2
|
+
<section class="ra-kpis">
|
|
3
|
+
<div class="ra-card">
|
|
4
|
+
<span class="ra-kpi-label"><%= t("rails_analytics.kpi.visits") %></span>
|
|
5
|
+
<strong class="ra-kpi-value"><%= number_with_delimiter(@stats[:total_visits]) %></strong>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="ra-card">
|
|
8
|
+
<span class="ra-kpi-label"><%= t("rails_analytics.kpi.unique_visitors") %></span>
|
|
9
|
+
<strong class="ra-kpi-value"><%= number_with_delimiter(@stats[:total_unique]) %></strong>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="ra-card">
|
|
12
|
+
<span class="ra-kpi-label"><%= t("rails_analytics.kpi.events") %></span>
|
|
13
|
+
<strong class="ra-kpi-value"><%= number_with_delimiter(@stats[:total_events]) %></strong>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="ra-card">
|
|
16
|
+
<span class="ra-kpi-label"><%= t("rails_analytics.kpi.bounce_rate") %></span>
|
|
17
|
+
<strong class="ra-kpi-value"><%= (@stats[:bounce_rate] * 100).round(1) %>%</strong>
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
|
|
21
|
+
<section class="ra-card ra-chart-card">
|
|
22
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.chart.visits_per_day") %></h2>
|
|
23
|
+
<div class="ra-chart">
|
|
24
|
+
<%= raw @chart %>
|
|
25
|
+
</div>
|
|
26
|
+
</section>
|
|
27
|
+
|
|
28
|
+
<section class="ra-grid">
|
|
29
|
+
<div class="ra-card">
|
|
30
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.tables.top_sources") %></h2>
|
|
31
|
+
<% if @stats[:top_sources].any? %>
|
|
32
|
+
<table class="ra-table">
|
|
33
|
+
<thead>
|
|
34
|
+
<tr><th><%= t("rails_analytics.tables.source") %></th><th class="ra-num"><%= t("rails_analytics.tables.count") %></th></tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody>
|
|
37
|
+
<% @stats[:top_sources].each do |ref, count| %>
|
|
38
|
+
<tr><td class="ra-mono"><%= ref %></td><td class="ra-num"><%= number_with_delimiter(count) %></td></tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
<% else %>
|
|
43
|
+
<p class="ra-empty"><%= t("rails_analytics.empty") %></p>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="ra-card">
|
|
48
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.tables.top_events") %></h2>
|
|
49
|
+
<% if @stats[:top_events].any? %>
|
|
50
|
+
<table class="ra-table">
|
|
51
|
+
<thead>
|
|
52
|
+
<tr><th><%= t("rails_analytics.tables.event_name") %></th><th class="ra-num"><%= t("rails_analytics.tables.count") %></th></tr>
|
|
53
|
+
</thead>
|
|
54
|
+
<tbody>
|
|
55
|
+
<% @stats[:top_events].each do |name, count| %>
|
|
56
|
+
<tr><td class="ra-mono"><%= name %></td><td class="ra-num"><%= number_with_delimiter(count) %></td></tr>
|
|
57
|
+
<% end %>
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
<% else %>
|
|
61
|
+
<p class="ra-empty"><%= t("rails_analytics.empty") %></p>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<% if @stats[:utm_breakdown].any? %>
|
|
67
|
+
<section class="ra-card">
|
|
68
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.tables.utm") %></h2>
|
|
69
|
+
<table class="ra-table ra-utm-table">
|
|
70
|
+
<thead>
|
|
71
|
+
<tr>
|
|
72
|
+
<th><%= t("rails_analytics.tables.source") %></th>
|
|
73
|
+
<th><%= t("rails_analytics.tables.campaign") %></th>
|
|
74
|
+
<th><%= t("rails_analytics.tables.medium") %></th>
|
|
75
|
+
<th class="ra-num"><%= t("rails_analytics.tables.count") %></th>
|
|
76
|
+
</tr>
|
|
77
|
+
</thead>
|
|
78
|
+
<tbody>
|
|
79
|
+
<% @stats[:utm_breakdown].each do |row| %>
|
|
80
|
+
<tr>
|
|
81
|
+
<td class="ra-mono"><%= row["utm_source"] %></td>
|
|
82
|
+
<td class="ra-mono"><%= row["utm_campaign"] %></td>
|
|
83
|
+
<td class="ra-mono"><%= row["utm_medium"] %></td>
|
|
84
|
+
<td class="ra-num"><%= number_with_delimiter(row["count"]) %></td>
|
|
85
|
+
</tr>
|
|
86
|
+
<% end %>
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
</section>
|
|
90
|
+
<% end %>
|
|
91
|
+
|
|
92
|
+
<section class="ra-card">
|
|
93
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.tables.devices") %></h2>
|
|
94
|
+
<div class="ra-devices">
|
|
95
|
+
<% @stats[:devices].each do |device, count| %>
|
|
96
|
+
<div class="ra-device">
|
|
97
|
+
<span class="ra-kpi-label"><%= device %></span>
|
|
98
|
+
<strong class="ra-kpi-value"><%= number_with_delimiter(count) %></strong>
|
|
99
|
+
</div>
|
|
100
|
+
<% end %>
|
|
101
|
+
</div>
|
|
102
|
+
</section>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%# app/views/rails_analytics/dashboards/visits.html.erb %>
|
|
2
|
+
<section class="ra-card">
|
|
3
|
+
<h2 class="ra-card-title"><%= t("rails_analytics.nav.visits") %></h2>
|
|
4
|
+
|
|
5
|
+
<% if @visits[:data].any? %>
|
|
6
|
+
<table class="ra-table">
|
|
7
|
+
<thead>
|
|
8
|
+
<tr>
|
|
9
|
+
<th><%= t("rails_analytics.tables.date") %></th>
|
|
10
|
+
<th class="ra-num"><%= t("rails_analytics.tables.visits") %></th>
|
|
11
|
+
<th class="ra-num"><%= t("rails_analytics.tables.unique_visitors") %></th>
|
|
12
|
+
<th><%= t("rails_analytics.tables.top_source") %></th>
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
<tbody>
|
|
16
|
+
<% @visits[:data].each do |row| %>
|
|
17
|
+
<tr>
|
|
18
|
+
<td class="ra-mono"><%= row[:date] %></td>
|
|
19
|
+
<td class="ra-num"><%= number_with_delimiter(row[:visits]) %></td>
|
|
20
|
+
<td class="ra-num"><%= number_with_delimiter(row[:unique_visitors]) %></td>
|
|
21
|
+
<td class="ra-mono"><%= row[:top_source] %></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<% end %>
|
|
24
|
+
</tbody>
|
|
25
|
+
</table>
|
|
26
|
+
|
|
27
|
+
<div class="ra-pagination">
|
|
28
|
+
<% if @page > 1 %>
|
|
29
|
+
<%= link_to t("rails_analytics.pagination.previous"), rails_analytics.visits_path(page: @page - 1) %>
|
|
30
|
+
<% end %>
|
|
31
|
+
<span class="current"><%= @page %></span>
|
|
32
|
+
<% if @visits[:data].length == @visits[:per_page] %>
|
|
33
|
+
<%= link_to t("rails_analytics.pagination.next"), rails_analytics.visits_path(page: @page + 1) %>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% else %>
|
|
37
|
+
<p class="ra-empty"><%= t("rails_analytics.empty") %></p>
|
|
38
|
+
<% end %>
|
|
39
|
+
</section>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
en:
|
|
2
|
+
rails_analytics:
|
|
3
|
+
title: "Rails Analytics"
|
|
4
|
+
nav:
|
|
5
|
+
overview: "Overview"
|
|
6
|
+
events: "Events"
|
|
7
|
+
visits: "Visits"
|
|
8
|
+
footer: "Aggregated and anonymous data — 6 month retention"
|
|
9
|
+
kpi:
|
|
10
|
+
visits: "Visits"
|
|
11
|
+
unique_visitors: "Unique visitors"
|
|
12
|
+
events: "Events"
|
|
13
|
+
bounce_rate: "Bounce rate"
|
|
14
|
+
chart:
|
|
15
|
+
visits_per_day: "Visits per day"
|
|
16
|
+
no_data: "No data"
|
|
17
|
+
tables:
|
|
18
|
+
top_sources: "Traffic sources"
|
|
19
|
+
top_events: "Top events"
|
|
20
|
+
devices: "Devices"
|
|
21
|
+
utm: "UTM"
|
|
22
|
+
date: "Date"
|
|
23
|
+
visits: "Visits"
|
|
24
|
+
unique_visitors: "Unique visitors"
|
|
25
|
+
top_source: "Top source"
|
|
26
|
+
event_name: "Event name"
|
|
27
|
+
count: "Count"
|
|
28
|
+
source: "Source"
|
|
29
|
+
campaign: "Campaign"
|
|
30
|
+
medium: "Medium"
|
|
31
|
+
empty: "No data recorded yet."
|
|
32
|
+
filter:
|
|
33
|
+
all_events: "All events"
|
|
34
|
+
filter: "Filter"
|
|
35
|
+
pagination:
|
|
36
|
+
previous: "Previous"
|
|
37
|
+
next: "Next"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
pt-BR:
|
|
2
|
+
rails_analytics:
|
|
3
|
+
title: "Rails Analytics"
|
|
4
|
+
nav:
|
|
5
|
+
overview: "Visão geral"
|
|
6
|
+
events: "Eventos"
|
|
7
|
+
visits: "Visitas"
|
|
8
|
+
footer: "Dados agregados e anônimos — retenção 6 meses"
|
|
9
|
+
kpi:
|
|
10
|
+
visits: "Visitas"
|
|
11
|
+
unique_visitors: "Visitantes únicos"
|
|
12
|
+
events: "Eventos"
|
|
13
|
+
bounce_rate: "Taxa de rejeição"
|
|
14
|
+
chart:
|
|
15
|
+
visits_per_day: "Visitas por dia"
|
|
16
|
+
no_data: "Sem dados"
|
|
17
|
+
tables:
|
|
18
|
+
top_sources: "Origem do tráfego"
|
|
19
|
+
top_events: "Clics por botão"
|
|
20
|
+
devices: "Dispositivos"
|
|
21
|
+
utm: "UTM"
|
|
22
|
+
date: "Data"
|
|
23
|
+
visits: "Visitas"
|
|
24
|
+
unique_visitors: "Visitantes únicos"
|
|
25
|
+
top_source: "Origem principal"
|
|
26
|
+
event_name: "Nome do evento"
|
|
27
|
+
count: "Contagem"
|
|
28
|
+
source: "Origem"
|
|
29
|
+
campaign: "Campanha"
|
|
30
|
+
medium: "Mídia"
|
|
31
|
+
empty: "Nenhum dado registrado ainda."
|
|
32
|
+
filter:
|
|
33
|
+
all_events: "Todos os eventos"
|
|
34
|
+
filter: "Filtrar"
|
|
35
|
+
pagination:
|
|
36
|
+
previous: "Anterior"
|
|
37
|
+
next: "Próximo"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RailsAnalytics::Engine.routes.draw do
|
|
4
|
+
get "tracker.js" => "analytics#tracker"
|
|
5
|
+
get "dashboard.css" => "analytics#dashboard_css"
|
|
6
|
+
post "collect" => "analytics#collect"
|
|
7
|
+
get "token" => "analytics#token"
|
|
8
|
+
root to: "dashboards#overview"
|
|
9
|
+
get "events" => "dashboards#events"
|
|
10
|
+
get "visits" => "dashboards#visits"
|
|
11
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailsAnalyticsDailySalts < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rails_analytics_daily_salts do |t|
|
|
6
|
+
t.date :date, null: false
|
|
7
|
+
t.string :salt, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :rails_analytics_daily_salts, :date, unique: true
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailsAnalyticsVisits < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rails_analytics_visits do |t|
|
|
6
|
+
t.string :anonymity_key, null: false
|
|
7
|
+
t.string :masked_ip, null: false
|
|
8
|
+
t.string :referrer_domain
|
|
9
|
+
t.text :landing_page_path
|
|
10
|
+
t.string :device_type
|
|
11
|
+
t.string :viewport
|
|
12
|
+
t.string :language
|
|
13
|
+
t.string :country
|
|
14
|
+
t.string :utm_source
|
|
15
|
+
t.string :utm_medium
|
|
16
|
+
t.string :utm_campaign
|
|
17
|
+
t.string :utm_term
|
|
18
|
+
t.string :utm_content
|
|
19
|
+
t.datetime :started_at, null: false
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_index :rails_analytics_visits, :started_at
|
|
25
|
+
add_index :rails_analytics_visits, [:anonymity_key, :started_at], name: "idx_visits_anonymity_started"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRailsAnalyticsEvents < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :rails_analytics_events do |t|
|
|
6
|
+
t.references :visit, null: false, foreign_key: { to_table: :rails_analytics_visits }
|
|
7
|
+
t.string :name, null: false
|
|
8
|
+
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
|
9
|
+
t.jsonb :properties, default: {}
|
|
10
|
+
else
|
|
11
|
+
t.json :properties, default: {}
|
|
12
|
+
end
|
|
13
|
+
t.datetime :time, null: false
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :rails_analytics_events, :name
|
|
19
|
+
add_index :rails_analytics_events, :time
|
|
20
|
+
add_index :rails_analytics_events, [:name, :time]
|
|
21
|
+
if ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
|
|
22
|
+
add_index :rails_analytics_events, :properties, using: :gin
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
Binary file
|
data/docs/dashboard.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAnalytics
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
desc "Instala o rails_analytics: copia migrations, monta engine, cria initializer e pina tracker no importmap."
|
|
8
|
+
|
|
9
|
+
def copy_migrations
|
|
10
|
+
migrations_dir = RailsAnalytics::Engine.root.join("db/migrate")
|
|
11
|
+
Dir["#{migrations_dir}/*.rb"].sort.each do |migration|
|
|
12
|
+
filename = File.basename(migration)
|
|
13
|
+
copy_file migration, "db/migrate/#{filename}"
|
|
14
|
+
end
|
|
15
|
+
say_status :ok, "Migrations copiadas para db/migrate/"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_initializer
|
|
19
|
+
template "initializer.rb", "config/initializers/rails_analytics.rb"
|
|
20
|
+
say_status :ok, "Initializer criado em config/initializers/rails_analytics.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def mount_engine
|
|
24
|
+
route %(mount RailsAnalytics::Engine => RailsAnalytics.config.mount_path)
|
|
25
|
+
say_status :ok, "Engine montada em config/routes.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pin_tracker
|
|
29
|
+
importmap_path = "config/importmap.rb"
|
|
30
|
+
if File.exist?(importmap_path)
|
|
31
|
+
unless File.read(importmap_path).include?("rails_analytics/tracker")
|
|
32
|
+
append_to_file importmap_path, %(\npin "rails_analytics/tracker", to: RailsAnalytics.config.mount_path + "/tracker.js"\n)
|
|
33
|
+
say_status :ok, "Tracker pinado em config/importmap.rb"
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
say_status :skipped, "config/importmap.rb não encontrado — adicione manualmente:", :yellow
|
|
37
|
+
say %( Adicione ao seu layout: <script src="<%= RailsAnalytics.config.mount_path %>/tracker.js" defer></script>), :yellow
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def instructions
|
|
42
|
+
say "\n✅ rails_analytics instalado! Próximos passos:", :green
|
|
43
|
+
say " 1. bin/rails db:migrate"
|
|
44
|
+
say " 2. Configure a autenticação no initializer: config/initializers/rails_analytics.rb"
|
|
45
|
+
say " 3. Acesse o dashboard em #{RailsAnalytics.config.mount_path}"
|
|
46
|
+
say ""
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|