analytic 0.1.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -13
  3. data/Rakefile +12 -2
  4. data/app/assets/builds/analytic/application.css +810 -0
  5. data/app/assets/builds/analytic/application.js +5779 -0
  6. data/app/assets/builds/analytic/application.js.map +7 -0
  7. data/app/assets/config/analytic_manifest.js +0 -0
  8. data/app/controllers/analytic/application_controller.rb +6 -0
  9. data/app/controllers/analytic/dashboard_controller.rb +13 -0
  10. data/app/controllers/concerns/analytic/trackable.rb +79 -0
  11. data/app/helpers/analytic/application_helper.rb +6 -0
  12. data/app/jobs/analytic/application_job.rb +6 -0
  13. data/app/jobs/analytic/track_job.rb +25 -0
  14. data/app/mailers/analytic/application_mailer.rb +7 -0
  15. data/app/models/analytic/application_record.rb +7 -0
  16. data/app/models/analytic/dashboard.rb +65 -0
  17. data/app/models/analytic/view.rb +24 -0
  18. data/app/packs/analytic/application/index.ts +1 -0
  19. data/app/packs/analytic/application/initializers/fontawesome.ts +23 -0
  20. data/app/packs/analytic/application/initializers/index.ts +1 -0
  21. data/app/packs/analytic/entrypoints/application.tailwind.css +65 -0
  22. data/app/packs/analytic/entrypoints/application.ts +1 -0
  23. data/app/views/analytic/dashboard/show.html.erb +84 -0
  24. data/app/views/layouts/analytic/application.html.erb +42 -0
  25. data/bin/dev +3 -0
  26. data/bin/rails +13 -0
  27. data/config/routes.rb +7 -0
  28. data/db/migrate/20240805210911_create_analytic_views.rb +24 -0
  29. data/lib/analytic/config.rb +30 -0
  30. data/lib/analytic/engine.rb +11 -0
  31. data/lib/analytic/version.rb +3 -1
  32. data/lib/analytic.rb +15 -3
  33. metadata +36 -13
  34. data/lib/analytic/railtie.rb +0 -4
  35. data/lib/tasks/analytic_tasks.rake +0 -4
  36. /data/{MIT-LICENSE → LICENSE} +0 -0
File without changes
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class DashboardController < ApplicationController
5
+ include Analytic::Trackable
6
+ before_action { analytic_track! }
7
+
8
+ # GET /
9
+ def show
10
+ @dashboard = Dashboard.new(period: params[:period])
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ module Trackable
5
+ # @param params [Hash]
6
+ def analytic_track!
7
+ Analytic::View.create!(
8
+ timestamp: Time.current,
9
+ session_id: analytic_session_id,
10
+ visitor_id: analytic_visitor_id,
11
+ ip: analytic_ip,
12
+ host: analytic_host,
13
+ path: analytic_path,
14
+ referrer: analytic_referrer,
15
+ user_agent: request.user_agent,
16
+ params: analytic_params
17
+ )
18
+ end
19
+
20
+ # @param params [Hash]
21
+ def analytic_enqueue_track_job!
22
+ Analytic::TrackJob.perform_later(
23
+ session_id: analytic_session_id,
24
+ visitor_id: analytic_visitor_id,
25
+ ip: analytic_ip,
26
+ host: analytic_host,
27
+ path: analytic_path,
28
+ referrer: analytic_referrer,
29
+ user_agent: request.user_agent,
30
+ params: analytic_params
31
+ )
32
+ end
33
+
34
+ # @return [String]
35
+ def analytic_session_id
36
+ session[:analytic_session_id] ||= SecureRandom.uuid
37
+ end
38
+
39
+ # @return [String]
40
+ def analytic_visitor_id
41
+ cookies.permanent[:analytic_visitor_id] ||= SecureRandom.uuid
42
+ end
43
+
44
+ # @return [IPAddr]
45
+ def analytic_ip
46
+ ip_addr = IPAddr.new(request.remote_ip)
47
+
48
+ return ip_addr.mask(Analytic.config.ip_v4_mask) if Analytic.config.ip_v4_mask? && ip_addr.ipv4?
49
+ return ip_addr.mask(Analytic.config.ip_v6_mask) if Analytic.config.ip_v6_mask? && ip_addr.ipv6?
50
+
51
+ ip_addr
52
+ end
53
+
54
+ # @return [String]
55
+ def analytic_host
56
+ request.host
57
+ end
58
+
59
+ # @return [String]
60
+ def analytic_path
61
+ request.path
62
+ end
63
+
64
+ # @return [String]
65
+ def analytic_referrer
66
+ request.referer
67
+ end
68
+
69
+ # @return [String]
70
+ def analytic_user_agent
71
+ request.user_agent
72
+ end
73
+
74
+ # @return [Hash]
75
+ def analytic_params
76
+ params.permit(:utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ module ApplicationHelper
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class TrackJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ # @param timestamp [Timestamp]
8
+ # @param session_id [String]
9
+ # @param visitor_id [String]
10
+ # @param host [String]
11
+ # @param path [String]
12
+ # @param params [Hash]
13
+ def perform(session_id:, visitor_id:, host:, path:, ip:, timestamp: Time.current, params: {})
14
+ Analytic::View.create!(
15
+ timestamp:,
16
+ session_id:,
17
+ visitor_id:,
18
+ host:,
19
+ path:,
20
+ ip:,
21
+ params:
22
+ )
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class ApplicationMailer < ActionMailer::Base
5
+ layout 'mailer'
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Dashboard
5
+ PAGES_LIMIT = 8
6
+
7
+ # @param period [String] today, yesterday, week, month, year
8
+ def initialize(period:)
9
+ @period = period
10
+ end
11
+
12
+ # @return [Integer]
13
+ delegate :count, to: :views
14
+
15
+ # @return [Integer]
16
+ delegate :distinct_visitors_count, to: :views
17
+
18
+ # @return [Integer]
19
+ delegate :distinct_sessions_count, to: :views
20
+
21
+ # @return [String]
22
+ def name
23
+ if @period
24
+ "#{@period.capitalize} | Dashboard"
25
+ else
26
+ 'Dashboard'
27
+ end
28
+ end
29
+
30
+ # @return [Array<Array(String, String, Integer)>]
31
+ def pages
32
+ views
33
+ .group(:host)
34
+ .group(:path)
35
+ .order(count: :desc)
36
+ .limit(PAGES_LIMIT)
37
+ .pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
38
+ end
39
+
40
+ protected
41
+
42
+ # @return [Analytic::View::ActiveRecord_Relation]
43
+ def views
44
+ @views ||= Analytic::View.within(range)
45
+ end
46
+
47
+ # @return [Range<Time>]
48
+ def range
49
+ return unless @period
50
+
51
+ case @period
52
+ when 'today' then timezone.today.all_day
53
+ when 'yesterday' then timezone.yesterday.all_day
54
+ when 'week' then timezone.now.all_week
55
+ when 'month' then timezone.now.all_month
56
+ when 'year' then timezone.now.all_year
57
+ end
58
+ end
59
+
60
+ # @return [ActiveSupport::TimeZone]
61
+ def timezone
62
+ Analytic.config.timezone || Time.zone
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class View < ApplicationRecord
5
+ validates :timestamp, presence: true
6
+ validates :visitor_id, presence: true
7
+ validates :session_id, presence: true
8
+ validates :path, presence: true
9
+ validates :host, presence: true
10
+ validates :ip, presence: true
11
+
12
+ scope :within, ->(range) { where(timestamp: range) if range }
13
+
14
+ # @return [Integer]
15
+ def self.distinct_visitors_count
16
+ count('DISTINCT "analytic_views"."visitor_id"')
17
+ end
18
+
19
+ # @return [Integer]
20
+ def self.distinct_sessions_count
21
+ count('DISTINCT "analytic_views"."session_id"')
22
+ end
23
+ end
24
+ end
@@ -0,0 +1 @@
1
+ import "./initializers";
@@ -0,0 +1,23 @@
1
+ import "@fortawesome/fontawesome-free";
2
+
3
+ import { library } from "@fortawesome/fontawesome-svg-core";
4
+
5
+ import { faClock } from "@fortawesome/free-solid-svg-icons/faClock";
6
+ import { faEye } from "@fortawesome/free-solid-svg-icons/faEye";
7
+ import { faFileCode } from "@fortawesome/free-solid-svg-icons/faFileCode";
8
+ import { faGauge } from "@fortawesome/free-solid-svg-icons/faGauge";
9
+ import { faGlobe } from "@fortawesome/free-solid-svg-icons/faGlobe";
10
+ import { faHouse } from "@fortawesome/free-solid-svg-icons/faHouse";
11
+ import { faSliders } from "@fortawesome/free-solid-svg-icons/faSliders";
12
+ import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
13
+
14
+ library.add(
15
+ faClock,
16
+ faEye,
17
+ faGauge,
18
+ faGlobe,
19
+ faHouse,
20
+ faSliders,
21
+ faUsers,
22
+ faFileCode
23
+ );
@@ -0,0 +1 @@
1
+ import "./fontawesome";
@@ -0,0 +1,65 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer components {
6
+ .pill {
7
+ @apply bg-slate-200 text-slate-800 hover:text-white hover:bg-indigo-600 rounded-full px-3 py-2;
8
+ }
9
+
10
+ .card {
11
+ @apply shadow-md rounded bg-white;
12
+ }
13
+
14
+ .card__header {
15
+ @apply px-5 py-4 border-b border-slate-200;
16
+ }
17
+
18
+ .card__footer {
19
+ @apply px-5 py-4 border-t border-slate-200;
20
+ }
21
+
22
+ .card__content {
23
+ @apply px-5 py-4
24
+ }
25
+
26
+ .card__title {
27
+ @apply uppercase text-slate-600 font-semibold text-sm flex gap-2 items-center;
28
+ }
29
+
30
+ .card__value {
31
+ @apply uppercase text-slate-800 font-extrabold text-lg;
32
+ }
33
+
34
+ .table {
35
+ @apply w-full divide-y divide-slate-200;
36
+ }
37
+
38
+ .table tbody {
39
+ @apply divide-y divide-slate-200;
40
+ }
41
+
42
+ .table td {
43
+ @apply px-3 py-2 text-left;
44
+ }
45
+
46
+ .table th {
47
+ @apply px-3 py-2 text-left;
48
+ }
49
+
50
+ .table td:first-child {
51
+ @apply pl-0;
52
+ }
53
+
54
+ .table td:last-child {
55
+ @apply pr-0;
56
+ }
57
+
58
+ .table th:first-child {
59
+ @apply pl-0;
60
+ }
61
+
62
+ .table th:last-child {
63
+ @apply pr-0;
64
+ }
65
+ }
@@ -0,0 +1 @@
1
+ import "../application";
@@ -0,0 +1,84 @@
1
+ <%- provide(:title, @dashboard.name) %>
2
+
3
+ <div class="space-y-4">
4
+ <div class="flex gap-2">
5
+ <%= link_to 'Today', analytic.dashboard_path(period: 'today'), class: 'pill' %>
6
+ <%= link_to 'Yesterday', analytic.dashboard_path(period: 'yesterday'), class: 'pill' %>
7
+ <%= link_to 'Week', analytic.dashboard_path(period: 'week'), class: 'pill' %>
8
+ <%= link_to 'Month', analytic.dashboard_path(period: 'month'), class: 'pill' %>
9
+ <%= link_to 'Year', analytic.dashboard_path(period: 'year'), class: 'pill' %>
10
+ </div>
11
+
12
+ <div class="grid grid-flow-col justify-stretch gap-4">
13
+ <div class="card">
14
+ <div class="card__header">
15
+ <div class="card__title">
16
+ <%= tag.i class: "fa-solid fa-users" %>
17
+ Visitors
18
+ </div>
19
+ </div>
20
+ <div class="card__content">
21
+ <div class="card__value">
22
+ <%= number_with_delimiter @dashboard.distinct_visitors_count %>
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="card">
28
+ <div class="card__header">
29
+ <div class="card__title">
30
+ <%= tag.i class: "fa-solid fa-globe" %>
31
+ Sessions
32
+ </div>
33
+ </div>
34
+ <div class="card__content">
35
+ <div class="card__value">
36
+ <%= number_with_delimiter @dashboard.distinct_sessions_count %>
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="card">
42
+ <div class="card__header">
43
+ <div class="card__title">
44
+ <%= tag.i class: "fa-solid fa-eye" %>
45
+ Views
46
+ </div>
47
+ </div>
48
+ <div class="card__content">
49
+ <div class="card__value">
50
+ <%= number_with_delimiter @dashboard.count %>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="card">
57
+ <div class="card__header">
58
+ <div class="card__title">
59
+ <%= tag.i class: "fa-solid fa-file-code" %>
60
+ Pages
61
+ </div>
62
+ </div>
63
+ <div class="card__content">
64
+ <table class="table">
65
+ <thead>
66
+ <tr>
67
+ <th scope="col">Host</th>
68
+ <th scope="col">Path</th>
69
+ <th scope="col">Views</th>
70
+ </tr>
71
+ </thead>
72
+ <tbody>
73
+ <%- @dashboard.pages.each do |(host, path, views)| -%>
74
+ <tr>
75
+ <td><%= host %></td>
76
+ <td><%= path %></td>
77
+ <td><%= number_with_delimiter views %></td>
78
+ </tr>
79
+ <%- end -%>
80
+ </tbody>
81
+ </table>
82
+ </div>
83
+ </div>
84
+ </div>
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= yield(:title) %> | Analytic</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <%= javascript_include_tag 'analytic/application' %>
8
+ <%= stylesheet_link_tag 'analytic/application' %>
9
+ <%= %>
10
+ </head>
11
+ <body class="bg-slate-50">
12
+
13
+ <header class="bg-indigo-600 text-white py-2">
14
+ <div class="container mx-auto px-4">
15
+ <div class="flex justify-between">
16
+ <nav class="flex gap-2">
17
+ <%= link_to analytic.root_path, class: "flex gap-2 rounded items-center px-3 py-2 font-semibold hover:bg-indigo-800" do %>
18
+ <%= tag.i class: "fa-solid fa-gauge" %>
19
+ Analytic
20
+ <% end %>
21
+ </nav>
22
+ <nav class="flex gap-2">
23
+ <%= link_to analytic.root_path, class: "flex gap-2 rounded items-center px-3 py-2 hover:bg-indigo-800" do %>
24
+ <%= tag.i class: "fa-solid fa-house" %>
25
+ Dashboard
26
+ <% end %>
27
+ </nav>
28
+ </div>
29
+ </div>
30
+ </header>
31
+
32
+ <main class="container mx-auto px-4 py-8">
33
+ <%= yield %>
34
+ </main>
35
+
36
+ <footer class="container mx-auto px-4 text-center">
37
+ <div class="flex gap-2 justify-center">
38
+ <%= time_tag(Time.current) %>
39
+ </div>
40
+ </footer>
41
+ </body>
42
+ </html>
data/bin/dev ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ foreman start -f Procfile.dev
data/bin/rails ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ ENGINE_ROOT = File.expand_path('..', __dir__)
6
+ ENGINE_PATH = File.expand_path('../lib/analytic/engine', __dir__)
7
+ APP_PATH = File.expand_path('../spec/dummy/config/application', __dir__)
8
+
9
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
10
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
11
+
12
+ require 'rails/all'
13
+ require 'rails/engine/commands'
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ Analytic::Engine.routes.draw do
4
+ root to: 'dashboard#show'
5
+
6
+ get '/dashboard/(period/:period)', to: 'dashboard#show', as: :dashboard
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAnalyticViews < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :analytic_views do |t|
6
+ t.uuid :visitor_id, null: false
7
+ t.uuid :session_id, null: false
8
+ t.inet :ip, null: false
9
+ t.string :path, null: false
10
+ t.string :host, null: false
11
+ t.jsonb :params, null: false, default: {}
12
+ t.datetime :timestamp, null: false
13
+ t.text :referrer, null: true
14
+ t.text :user_agent, null: true
15
+
16
+ t.timestamps
17
+
18
+ t.index %i[timestamp path]
19
+ t.index %i[timestamp visitor_id]
20
+ t.index %i[timestamp session_id]
21
+ t.index %i[timestamp referrer]
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Config
5
+ # @return [String]
6
+ attr_accessor :timezone
7
+
8
+ # @return [Integer]
9
+ attr_accessor :ip_v4_mask
10
+
11
+ # @return [Integer]
12
+ attr_accessor :ip_v6_mask
13
+
14
+ def initialize
15
+ @timezone = Time.zone
16
+ @ip_v4_mask = 24 # e.g. 255.255.255.255 => '255.255.255.0/255.255.255.0'
17
+ @ip_v6_mask = 48 # e.g. 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' => 'ffff:ffff:ffff:0000:0000:0000:0000:0000'
18
+ end
19
+
20
+ # @return [Boolean]
21
+ def ip_v4_mask?
22
+ @ip_v4_mask.present?
23
+ end
24
+
25
+ # @return [Boolean]
26
+ def ip_v6_mask?
27
+ @ip_v6_mask.present?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Analytic
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ end
10
+ end
11
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Analytic
2
- VERSION = "0.1.0"
4
+ VERSION = '0.3.0'
3
5
  end
data/lib/analytic.rb CHANGED
@@ -1,6 +1,18 @@
1
- require "analytic/version"
2
- require "analytic/railtie"
1
+ # frozen_string_literal: true
2
+
3
+ require 'analytic/config'
4
+ require 'analytic/version'
5
+ require 'analytic/engine'
3
6
 
4
7
  module Analytic
5
- # Your code goes here...
8
+ # @return [Analytic::Config]
9
+ def self.config
10
+ @config ||= Config.new
11
+ end
12
+
13
+ # @yield [config]
14
+ # @yieldparam config [Analytic::Config]
15
+ def self.configure
16
+ yield config
17
+ end
6
18
  end