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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +270 -0
  4. data/app/assets/javascripts/rails_analytics/tracker.js +136 -0
  5. data/app/assets/stylesheets/rails_analytics/dashboard.css +156 -0
  6. data/app/controllers/rails_analytics/analytics_controller.rb +91 -0
  7. data/app/controllers/rails_analytics/application_controller.rb +24 -0
  8. data/app/controllers/rails_analytics/dashboards_controller.rb +41 -0
  9. data/app/helpers/rails_analytics/application_helper.rb +12 -0
  10. data/app/jobs/rails_analytics/retention_job.rb +27 -0
  11. data/app/models/rails_analytics/chart_builder.rb +54 -0
  12. data/app/models/rails_analytics/daily_salt.rb +26 -0
  13. data/app/models/rails_analytics/event.rb +15 -0
  14. data/app/models/rails_analytics/visit.rb +45 -0
  15. data/app/views/layouts/rails_analytics.html.erb +26 -0
  16. data/app/views/rails_analytics/dashboards/events.html.erb +50 -0
  17. data/app/views/rails_analytics/dashboards/overview.html.erb +102 -0
  18. data/app/views/rails_analytics/dashboards/visits.html.erb +39 -0
  19. data/config/locales/rails_analytics.en.yml +37 -0
  20. data/config/locales/rails_analytics.pt-BR.yml +37 -0
  21. data/config/routes.rb +11 -0
  22. data/db/migrate/20260906000001_create_rails_analytics_daily_salts.rb +14 -0
  23. data/db/migrate/20260906000002_create_rails_analytics_visits.rb +27 -0
  24. data/db/migrate/20260906000003_create_rails_analytics_events.rb +25 -0
  25. data/docs/dashboard-2.png +0 -0
  26. data/docs/dashboard.png +0 -0
  27. data/lib/generators/rails_analytics/install/install_generator.rb +50 -0
  28. data/lib/generators/rails_analytics/install/templates/initializer.rb +13 -0
  29. data/lib/rails_analytics/configuration.rb +17 -0
  30. data/lib/rails_analytics/engine.rb +32 -0
  31. data/lib/rails_analytics/identity.rb +51 -0
  32. data/lib/rails_analytics/ip_mask.rb +24 -0
  33. data/lib/rails_analytics/rack_attack.rb +22 -0
  34. data/lib/rails_analytics/stats.rb +146 -0
  35. data/lib/rails_analytics/version.rb +5 -0
  36. data/lib/rails_analytics.rb +20 -0
  37. 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
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