analytic 0.1.0 → 0.3.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 (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