analytic 0.3.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: dc45200aa3d02b6b5b0762abc37f93924ea4d025c25ddd671933512b35a2a795
4
- data.tar.gz: 925779556b411685139ff5d8c167bee45b4aed9ec793c523f90a26fcc95685df
3
+ metadata.gz: e283c032e4bc20c3b16244bee1b0a4b65e236626bdd9d75c05ef44116c6aa679
4
+ data.tar.gz: 8f9e592c750579ed57665364b2b9f0fd5c7cf6455e68e134e1b7817eda7c2713
5
5
  SHA512:
6
- metadata.gz: 5e1b269cd08c02ae021f491b3b5dd6076e7be3fe049b1bf50ea4b4ef09ac50ce32dd50c73f16cdd549ed416a1f59b602b8ed4b9994ed6690623559ab14aca8a9
7
- data.tar.gz: 34eea881684f27d927c48c7b3c76240a946cca6551747342017c5f5e9f53c558f391c5c66592ceb8f701af86c0639742fb6d592270a55680a87b6b6684084e22
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,15 +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
- config.ip_v4_mask = 24 # '255.255.255.255' becomes '255.255.255.0'
29
- config.ip_v6_mask = 48 # 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff' becomes 'ffff:ffff:ffff:0000:0000:0000:0000:0000'
30
- end
31
- ```
32
-
33
26
  ## Usage
34
27
 
35
28
  Default inline tracking is configured with:
@@ -38,7 +31,7 @@ Default inline tracking is configured with:
38
31
  class ApplicationController
39
32
  include Analytic::Trackable
40
33
 
41
- before_action { analytic_track! }
34
+ before_action :analytic_track!, if: -> { request.format.html? }
42
35
  end
43
36
  ```
44
37
 
@@ -48,11 +41,105 @@ Alternative job tracking is configured with:
48
41
  class ApplicationController
49
42
  include Analytic::Trackable
50
43
 
51
- before_action { analytic_enqueue_track_job! }
44
+ before_action :analytic_enqueue_track_job!, if: -> { request.format.html? }
45
+ end
52
46
  ```
53
47
 
54
48
  _note: a queue such as sidekiq, rescue, etc must be running to see tracking_
55
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
+
56
143
  ## License
57
144
 
58
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
@@ -4,7 +4,7 @@ module Analytic
4
4
  module Trackable
5
5
  # @param params [Hash]
6
6
  def analytic_track!
7
- Analytic::View.create!(
7
+ Analytic::Event.create!(
8
8
  timestamp: Time.current,
9
9
  session_id: analytic_session_id,
10
10
  visitor_id: analytic_visitor_id,
@@ -73,7 +73,7 @@ module Analytic
73
73
 
74
74
  # @return [Hash]
75
75
  def analytic_params
76
- params.permit(:utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content)
76
+ params.slice(*Analytic.config.params)
77
77
  end
78
78
  end
79
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
@@ -11,7 +11,7 @@ module Analytic
11
11
  # @param path [String]
12
12
  # @param params [Hash]
13
13
  def perform(session_id:, visitor_id:, host:, path:, ip:, timestamp: Time.current, params: {})
14
- Analytic::View.create!(
14
+ Analytic::Event.create!(
15
15
  timestamp:,
16
16
  session_id:,
17
17
  visitor_id:,
@@ -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,7 +1,7 @@
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
@@ -13,12 +13,12 @@ module Analytic
13
13
 
14
14
  # @return [Integer]
15
15
  def self.distinct_visitors_count
16
- count('DISTINCT "analytic_views"."visitor_id"')
16
+ count('DISTINCT "analytic_events"."visitor_id"')
17
17
  end
18
18
 
19
19
  # @return [Integer]
20
20
  def self.distinct_sessions_count
21
- count('DISTINCT "analytic_views"."session_id"')
21
+ count('DISTINCT "analytic_events"."session_id"')
22
22
  end
23
23
  end
24
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
@@ -0,0 +1,148 @@
1
+ import { useMemo, type FC } from "react";
2
+
3
+ import { Group } from "@visx/group";
4
+ import { Bar } from "@visx/shape";
5
+ import { scaleBand, scaleLinear } from "@visx/scale";
6
+ import { AxisBottom, AxisLeft } from "@visx/axis";
7
+ import { GridColumns, GridRows } from "@visx/grid";
8
+ import { ParentSize } from "@visx/responsive";
9
+
10
+ const BAR_BG = "#6366f1"; // indigo-500
11
+ const CHART_BG = "#f8fafc"; // slate-50
12
+ const GRID_COLOR = "#e2e8f0"; // slate-200
13
+ const AXIS_TICK_COLOR = GRID_COLOR;
14
+ const AXIS_LINE_COLOR = GRID_COLOR;
15
+
16
+ const CHART_ML = 60; // px
17
+ const CHART_MB = 60; // px
18
+ const CHART_MT = 40; // px
19
+ const CHART_MR = 40; // px
20
+
21
+ const TICK_LABEL_PROPS = {
22
+ fill: "#718096", // slate-600
23
+ fontSize: 10,
24
+ fontFamily: `"ui-sans-serif", "system-ui", "sans-serif"`,
25
+ } as const;
26
+
27
+ type Entry = {
28
+ label: string;
29
+ value: number;
30
+ };
31
+
32
+ const ENTRIES: Entry[] = [
33
+ { label: "Jan, 2024", value: 404 },
34
+ { label: "Feb, 2024", value: 303 },
35
+ { label: "Mar, 2024", value: 250 },
36
+ { label: "Apr, 2024", value: 304 },
37
+ { label: "May, 2024", value: 404 },
38
+ { label: "Jun, 2024", value: 604 },
39
+ ];
40
+
41
+ const Bars: FC<{
42
+ width: number;
43
+ height: number;
44
+ entries: Entry[];
45
+ }> = ({ width, height, entries }) => {
46
+ const xScale = useMemo(
47
+ () =>
48
+ scaleBand<string>({
49
+ range: [0, width],
50
+ round: true,
51
+ domain: entries.map(({ label }) => label),
52
+ padding: 0.4,
53
+ }),
54
+ [width]
55
+ );
56
+ const yScale = useMemo(
57
+ () =>
58
+ scaleLinear<number>({
59
+ range: [height, 0],
60
+ round: true,
61
+ domain: [0, Math.max(...entries.map(({ value }) => value))],
62
+ }),
63
+ [height]
64
+ );
65
+
66
+ return (
67
+ <>
68
+ <GridRows
69
+ scale={yScale}
70
+ width={width}
71
+ height={height}
72
+ stroke={GRID_COLOR}
73
+ />
74
+
75
+ <GridColumns
76
+ scale={xScale}
77
+ width={width}
78
+ height={height}
79
+ stroke={GRID_COLOR}
80
+ />
81
+
82
+ {entries.map((entry, index) => {
83
+ const barWidth = xScale.bandwidth();
84
+ const barHeight = height - yScale(entry.value);
85
+ const barX = xScale(entry.label);
86
+ const barY = height - barHeight;
87
+
88
+ return (
89
+ <Bar
90
+ key={index}
91
+ x={barX}
92
+ y={barY}
93
+ rx={8}
94
+ ry={8}
95
+ width={barWidth}
96
+ height={barHeight}
97
+ fill={BAR_BG}
98
+ />
99
+ );
100
+ })}
101
+
102
+ <AxisBottom
103
+ top={height}
104
+ scale={xScale}
105
+ stroke={AXIS_LINE_COLOR}
106
+ tickStroke={AXIS_TICK_COLOR}
107
+ tickLabelProps={TICK_LABEL_PROPS}
108
+ />
109
+
110
+ <AxisLeft
111
+ left={0}
112
+ scale={yScale}
113
+ stroke={AXIS_LINE_COLOR}
114
+ tickStroke={AXIS_TICK_COLOR}
115
+ tickLabelProps={TICK_LABEL_PROPS}
116
+ />
117
+ </>
118
+ );
119
+ };
120
+
121
+ export const Chart: FC<{
122
+ height?: number;
123
+ entries?: Entry[];
124
+ }> = ({ height = 300, entries = ENTRIES }) => (
125
+ <ParentSize>
126
+ {({ width }) => (
127
+ <svg width={width} height={height}>
128
+ <rect
129
+ x={0}
130
+ y={0}
131
+ rx={4}
132
+ ry={4}
133
+ width={width}
134
+ height={height}
135
+ fill={CHART_BG}
136
+ />
137
+
138
+ <Group top={CHART_MT} left={CHART_ML}>
139
+ <Bars
140
+ width={width - CHART_MR - CHART_ML}
141
+ height={height - CHART_MT - CHART_MB}
142
+ entries={entries}
143
+ />
144
+ </Group>
145
+ </svg>
146
+ )}
147
+ </ParentSize>
148
+ );
@@ -0,0 +1,21 @@
1
+ import { type FC } from "react";
2
+
3
+ import { Chart } from "./chart";
4
+ import { createRoot } from "react-dom/client";
5
+
6
+ const COMPONENTS: Record<string, FC> = {
7
+ Chart,
8
+ };
9
+
10
+ document.addEventListener("DOMContentLoaded", () => {
11
+ const elements = document.querySelectorAll("[data-react]");
12
+ for (const element of elements) {
13
+ const root = createRoot(element);
14
+
15
+ const name = element.getAttribute("data-react");
16
+
17
+ const Component = COMPONENTS[name!];
18
+
19
+ root.render(<Component />);
20
+ }
21
+ });
@@ -1 +1,2 @@
1
1
  import "./initializers";
2
+ import "./components";
@@ -10,14 +10,20 @@ import { faGlobe } from "@fortawesome/free-solid-svg-icons/faGlobe";
10
10
  import { faHouse } from "@fortawesome/free-solid-svg-icons/faHouse";
11
11
  import { faSliders } from "@fortawesome/free-solid-svg-icons/faSliders";
12
12
  import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers";
13
+ import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
14
+ import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
15
+ import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
13
16
 
14
17
  library.add(
18
+ faArrowDown,
19
+ faArrowUp,
15
20
  faClock,
16
21
  faEye,
22
+ faFileCode,
17
23
  faGauge,
18
24
  faGlobe,
19
25
  faHouse,
20
26
  faSliders,
21
27
  faUsers,
22
- faFileCode
28
+ faXmark
23
29
  );