analytic 0.2.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb82e829dff15357a11068a257ee1fb1ff2c4257de41703580a8f320ffee669a
4
- data.tar.gz: 7e1bb82f6cec5b2fa12089fb7fbd3dfed87690da645eb3746ca22017de640f82
3
+ metadata.gz: e283c032e4bc20c3b16244bee1b0a4b65e236626bdd9d75c05ef44116c6aa679
4
+ data.tar.gz: 8f9e592c750579ed57665364b2b9f0fd5c7cf6455e68e134e1b7817eda7c2713
5
5
  SHA512:
6
- metadata.gz: 717be1d6814479751169f69fbdb39bb2413f023a001f0ea9033dbae146fd84357014ba67f3da150f77ac96d44500f45d5cfda0963e8cba428bd876e637e8e678
7
- data.tar.gz: 56c3f9f5f29ee9a6564eca830cf9c895290f0eec4d97f179c9d031acb919ad1ff799018ee557c98a75252c28eec9da70ae445ee165e3ba267ec70b4f94761609
6
+ metadata.gz: b88a20a9fcdebaa18d0702a0dc22ceaf02cf88d383d0f4d3de59558c6073b0d1b85993cc49ef1a19d39d98fed32c321aa82241df42a32440e7bdb8138eab4af6
7
+ data.tar.gz: 9c61764e8b904c336551099e1155c4bcfdcda88f32cd622bb7f38d57adc157bfd8c9a1583cbb9e09e7866a1103c127758239efd0431e00dd4b5aca25160be5f8
data/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  # Analytic
6
6
 
7
+ ![Demo](https://raw.githubusercontent.com/ksylvest/analytic/refs/heads/main/demo.png)
8
+
7
9
  Analytic provides visitor / session / view tracking without the need for any third-party service.
8
10
 
9
11
  ## Installation
@@ -21,13 +23,6 @@ Rails.application.routes.draw do
21
23
  end
22
24
  ```
23
25
 
24
- ```ruby
25
- # config/initializer/analytic.rb
26
- Analytic.configure do |config|
27
- config.timezone = Time.find_zone('Canada/Pacific') # default is `Time.zone`
28
- end
29
- ```
30
-
31
26
  ## Usage
32
27
 
33
28
  Default inline tracking is configured with:
@@ -36,7 +31,7 @@ Default inline tracking is configured with:
36
31
  class ApplicationController
37
32
  include Analytic::Trackable
38
33
 
39
- before_action { analytic_track! }
34
+ before_action :analytic_track!, if: -> { request.format.html? }
40
35
  end
41
36
  ```
42
37
 
@@ -46,11 +41,105 @@ Alternative job tracking is configured with:
46
41
  class ApplicationController
47
42
  include Analytic::Trackable
48
43
 
49
- before_action { analytic_enqueue_track_job! }
44
+ before_action :analytic_enqueue_track_job!, if: -> { request.format.html? }
45
+ end
50
46
  ```
51
47
 
52
48
  _note: a queue such as sidekiq, rescue, etc must be running to see tracking_
53
49
 
50
+ ## Configuration
51
+
52
+ ### Authentication the Dashboard
53
+
54
+ By default **Analytic** is not authenticated. To authenticate using `Rack::Auth` generate a random username and password:
55
+
56
+ ```ruby
57
+ $ bin/rails secret # generate a secret
58
+ $ bin/rails credentials:edit
59
+ ```
60
+
61
+ ```yaml
62
+ analytic:
63
+ username: abc...
64
+ password: def...
65
+ ```
66
+
67
+ ```ruby
68
+ # config/initializers/analytic.rb
69
+
70
+ def same?(src, dst)
71
+ # https://api.rubyonrails.org/classes/ActiveSupport/SecurityUtils.html
72
+ ActiveSupport::SecurityUtils.secure_compare(src, dst)
73
+ end
74
+
75
+ Analytic.configure do |config|
76
+ unless Rails.env.local?
77
+ config.use Rack::Auth::Basic do |username, password|
78
+ credentials = Rails.application.credentials.analytic
79
+ same?(username, credentials.username) && same?(password, credentials.password)
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Capturing Extra Parameters
86
+
87
+ By default **Analytic** tracks `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, `ref` and `source` parameters for each request. This list can be customized using:
88
+
89
+ ```ruby
90
+ # config/initializer/analytic.rb
91
+ Analytic.configure do |config|
92
+ config.params << :gclid # e.g. Google
93
+ config.params << :msclkid # e.g. Bing
94
+ end
95
+ ```
96
+
97
+ ### Changing the Time Zone
98
+
99
+ By default **Analytic** uses `Time.zone`. The time zone can be changed using:
100
+
101
+ ```ruby
102
+ # config/application.rb
103
+ class Application < Rails::Application
104
+ config.time_zone = 'Canada/Pacific'
105
+ end
106
+ ```
107
+
108
+ The time zone may also be specified for **Analytic** using any `ActiveSupport::TimeZone`:
109
+
110
+ ```ruby
111
+ # config/initializer/analytic.rb
112
+ Analytic.configure do |config|
113
+ config.time_zone = Time.find_zone('Canada/Pacific')
114
+ end
115
+ ```
116
+
117
+ ### Changing the Database
118
+
119
+ By default **Analytic** uses your apps main database. The database can be changed using:
120
+
121
+ ```ruby
122
+ # config/initializer/analytic.rb
123
+ Analytic.configure do |config|
124
+ config.connects_to = { database: { writing: :primary, reading: :replica } }
125
+ end
126
+ ```
127
+
128
+ ### Overriding IP Masking Rules
129
+
130
+ By default IP addresses are masked as follows:
131
+
132
+ - IPv4: limit to leading 24-bits (e.g. '255.255.255.255' becomes '255.255.255.0')
133
+ - IPv6: limit to leading 48-bits (e.g. 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000')
134
+
135
+ To override both an `ip_v4_mask` and `ip_v6_mask` are assignable:
136
+
137
+ ```ruby
138
+ Analytic.configure do |config|
139
+ config.ip_v4_mask = 24 # nil skips masking
140
+ config.ip_v6_mask = 48 # nil skips masking
141
+ ```
142
+
54
143
  ## License
55
144
 
56
145
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -10,4 +10,9 @@ RSpec::Core::RakeTask.new(:spec)
10
10
 
11
11
  RuboCop::RakeTask.new
12
12
 
13
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
14
+
15
+ load 'rails/tasks/engine.rake'
16
+ load 'rails/tasks/statistics.rake'
17
+
13
18
  task default: %i[spec rubocop]
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><defs><linearGradient id="a" x1="0%" x2="100%" y1="100%" y2="0%"><stop offset="0%" stop-color="#06B6D4"/><stop offset="49.9%" stop-color="#6366F1"/><stop offset="100%" stop-color="#D946EF"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><rect width="512" height="512" fill="url(#a)" rx="256"/><circle cx="256" cy="256" r="184" stroke="#FFF" stroke-width="48"/><rect width="48" height="128" x="232" y="210" fill="#FFF" rx="16"/><rect width="48" height="96" x="168" y="242" fill="#FFF" rx="16"/><rect width="48" height="164" x="296" y="174" fill="#FFF" rx="16"/></g></svg>
@@ -3,7 +3,8 @@
3
3
  module Analytic
4
4
  class DashboardController < ApplicationController
5
5
  include Analytic::Trackable
6
- before_action { analytic_track! }
6
+
7
+ before_action :analytic_track!, if: -> { request.format.html? }
7
8
 
8
9
  # GET /
9
10
  def show
@@ -3,25 +3,31 @@
3
3
  module Analytic
4
4
  module Trackable
5
5
  # @param params [Hash]
6
- def analytic_track!(params: {})
7
- Analytic::View.create!(
6
+ def analytic_track!
7
+ Analytic::Event.create!(
8
8
  timestamp: Time.current,
9
9
  session_id: analytic_session_id,
10
10
  visitor_id: analytic_visitor_id,
11
- host: request.host,
12
- path: request.path,
13
- params:
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
14
17
  )
15
18
  end
16
19
 
17
20
  # @param params [Hash]
18
- def analytic_enqueue_track_job!(params: {})
21
+ def analytic_enqueue_track_job!
19
22
  Analytic::TrackJob.perform_later(
20
23
  session_id: analytic_session_id,
21
24
  visitor_id: analytic_visitor_id,
22
- host: request.host,
23
- path: request.path,
24
- params:
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
25
31
  )
26
32
  end
27
33
 
@@ -34,5 +40,40 @@ module Analytic
34
40
  def analytic_visitor_id
35
41
  cookies.permanent[:analytic_visitor_id] ||= SecureRandom.uuid
36
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.slice(*Analytic.config.params)
77
+ end
37
78
  end
38
79
  end
@@ -2,5 +2,47 @@
2
2
 
3
3
  module Analytic
4
4
  module ApplicationHelper
5
+ def react(component:)
6
+ tag.div(data: { react: component })
7
+ end
8
+
9
+ # @param icon [String] e.g. fa fa-user
10
+ def fa_icon_tag(icon)
11
+ tag.i(class: icon)
12
+ end
13
+
14
+ # @param delta [Float]
15
+ # @return [String]
16
+ def delta_icon_tag(delta)
17
+ if delta.zero?
18
+ fa_icon_tag('fa-solid fa-xmark')
19
+ elsif delta.positive?
20
+ fa_icon_tag('fa-solid fa-arrow-up')
21
+ elsif delta.negative?
22
+ fa_icon_tag('fa-solid fa-arrow-down')
23
+ end
24
+ end
25
+
26
+ # @param delta [Float]
27
+ # @return [String]
28
+ def delta_percentage_tag(delta)
29
+ tag.span(number_to_percentage(delta.abs * 100.0, precision: 2), class: 'delta__value')
30
+ end
31
+
32
+ # @param delta [Float, nil]
33
+ def delta_tag(delta)
34
+ return if delta.nil?
35
+
36
+ modifier =
37
+ if delta.zero? then 'delta--neutral'
38
+ elsif delta.positive? then 'delta--positive'
39
+ elsif delta.negative? then 'delta--negative'
40
+ end
41
+
42
+ tag.div(class: "delta #{modifier}") do
43
+ concat(delta_icon_tag(delta))
44
+ concat(delta_percentage_tag(delta))
45
+ end
46
+ end
5
47
  end
6
48
  end
@@ -10,12 +10,14 @@ module Analytic
10
10
  # @param host [String]
11
11
  # @param path [String]
12
12
  # @param params [Hash]
13
- def perform(session_id:, visitor_id:, host:, path:, timestamp: Time.current, params: {})
14
- Analytic::View.create!(
13
+ def perform(session_id:, visitor_id:, host:, path:, ip:, timestamp: Time.current, params: {})
14
+ Analytic::Event.create!(
15
+ timestamp:,
15
16
  session_id:,
16
17
  visitor_id:,
17
18
  host:,
18
19
  path:,
20
+ ip:,
19
21
  params:
20
22
  )
21
23
  end
@@ -3,5 +3,7 @@
3
3
  module Analytic
4
4
  class ApplicationRecord < ActiveRecord::Base
5
5
  self.abstract_class = true
6
+
7
+ connects_to(**Analytic.config.connects_to) if Analytic.config.connects_to?
6
8
  end
7
9
  end
@@ -4,19 +4,40 @@ module Analytic
4
4
  class Dashboard
5
5
  PAGES_LIMIT = 8
6
6
 
7
+ # @return [String]
8
+ attr_reader :period
9
+
7
10
  # @param period [String] today, yesterday, week, month, year
8
11
  def initialize(period:)
9
12
  @period = period
10
13
  end
11
14
 
12
- # @return [Integer]
13
- delegate :count, to: :views
15
+ # @return [Stat]
16
+ def views
17
+ Stat.new(
18
+ current: current.count,
19
+ prior: prior&.count,
20
+ name: 'Views'
21
+ )
22
+ end
14
23
 
15
- # @return [Integer]
16
- delegate :distinct_visitors_count, to: :views
24
+ # @return [Stat]
25
+ def visitors
26
+ Stat.new(
27
+ current: current.distinct_visitors_count,
28
+ prior: prior&.distinct_visitors_count,
29
+ name: 'Visitors'
30
+ )
31
+ end
17
32
 
18
- # @return [Integer]
19
- delegate :distinct_sessions_count, to: :views
33
+ # @return [Stat]
34
+ def sessions
35
+ Stat.new(
36
+ current: current.distinct_sessions_count,
37
+ prior: prior&.distinct_sessions_count,
38
+ name: 'Sessions'
39
+ )
40
+ end
20
41
 
21
42
  # @return [String]
22
43
  def name
@@ -27,39 +48,50 @@ module Analytic
27
48
  end
28
49
  end
29
50
 
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'))
51
+ # @return [Period]
52
+ def current
53
+ @current ||= Period.new(range:)
38
54
  end
39
55
 
40
- protected
56
+ # @return [Period]
57
+ def prior
58
+ return unless range?
59
+ return @prior if defined?(@prior)
41
60
 
42
- # @return [Analytic::View::ActiveRecord_Relation]
43
- def views
44
- @views ||= Analytic::View.within(range)
61
+ @prior ||= Period.new(range: ((range.min - duration)..(range.max - duration)))
62
+ end
63
+
64
+ # @return [Boolean]
65
+ def range?
66
+ @period.present?
45
67
  end
46
68
 
47
69
  # @return [Range<Time>]
48
70
  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
71
+ (now - duration)..now if range?
72
+ end
73
+
74
+ # @return [Interval]
75
+ def duration
76
+ @duration ||=
77
+ case @period
78
+ when '24h' then 24.hours
79
+ when '7d' then 7.days
80
+ when '4w' then 4.weeks
81
+ when '12m' then 12.months
82
+ end
83
+ end
84
+
85
+ protected
86
+
87
+ # @return [Time]
88
+ def now
89
+ @now ||= time_zone.now
58
90
  end
59
91
 
60
92
  # @return [ActiveSupport::TimeZone]
61
- def timezone
62
- Analytic.config.timezone || Time.zone
93
+ def time_zone
94
+ @time_zone ||= Analytic.config.time_zone || Time.zone
63
95
  end
64
96
  end
65
97
  end
@@ -1,23 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Analytic
4
- class View < ApplicationRecord
4
+ class Event < ApplicationRecord
5
5
  validates :timestamp, presence: true
6
6
  validates :visitor_id, presence: true
7
7
  validates :session_id, presence: true
8
8
  validates :path, presence: true
9
9
  validates :host, presence: true
10
+ validates :ip, presence: true
10
11
 
11
12
  scope :within, ->(range) { where(timestamp: range) if range }
12
13
 
13
14
  # @return [Integer]
14
15
  def self.distinct_visitors_count
15
- count('DISTINCT "analytic_views"."visitor_id"')
16
+ count('DISTINCT "analytic_events"."visitor_id"')
16
17
  end
17
18
 
18
19
  # @return [Integer]
19
20
  def self.distinct_sessions_count
20
- count('DISTINCT "analytic_views"."session_id"')
21
+ count('DISTINCT "analytic_events"."session_id"')
21
22
  end
22
23
  end
23
24
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Period
5
+ PAGES_LIMIT = 8
6
+
7
+ # @return [Range <Time>]
8
+ attr_accessor :range
9
+
10
+ # @param dates [Range<Time>]
11
+ def initialize(range:)
12
+ @range = range
13
+ end
14
+
15
+ # @return [Integer]
16
+ delegate :count, to: :views
17
+
18
+ # @return [Integer]
19
+ delegate :distinct_visitors_count, to: :views
20
+
21
+ # @return [Integer]
22
+ delegate :distinct_sessions_count, to: :views
23
+
24
+ # @return [Array<Array(String, String, Integer)>]
25
+ def pages
26
+ views
27
+ .group(:host)
28
+ .group(:path)
29
+ .order(count: :desc)
30
+ .limit(PAGES_LIMIT)
31
+ .pluck(:host, :path, Arel.sql('COUNT(*) AS count'))
32
+ end
33
+
34
+ protected
35
+
36
+ # @return [Analytic::Event::ActiveRecord_Relation]
37
+ def views
38
+ @views ||= Analytic::Event.within(@range)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Analytic
4
+ class Stat
5
+ # @param current [Integer]
6
+ # @param prior [Integer]
7
+ # @param name [String]
8
+ def initialize(current:, prior:, name:)
9
+ @current = current
10
+ @prior = prior
11
+ @name = name
12
+ end
13
+
14
+ # @return [Integer]
15
+ def count
16
+ @current
17
+ end
18
+
19
+ # e.g. prior = 4 / current = 5 / delta = + 0.25
20
+ # e.g. prior = 4 / current = 3 / delta = - 0.25
21
+ #
22
+ # @return [Float, nil]
23
+ def delta
24
+ return if @prior.nil?
25
+ return if @prior.zero?
26
+
27
+ (@current - @prior).fdiv(@prior)
28
+ end
29
+ end
30
+ end