analytic 0.2.0 → 0.5.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 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