├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── CHANGELOG.md
├── Gemfile
├── LICENSE
├── README.md
├── Rakefile
├── app
├── controllers
│ └── rails_local_analytics
│ │ ├── application_controller.rb
│ │ └── dashboard_controller.rb
├── jobs
│ └── rails_local_analytics
│ │ ├── application_job.rb
│ │ └── record_request_job.rb
├── models
│ ├── rails_local_analytics
│ │ └── application_record.rb
│ ├── tracked_requests_by_day_page.rb
│ └── tracked_requests_by_day_site.rb
└── views
│ ├── layouts
│ └── rails_local_analytics
│ │ ├── _app_css.html.erb
│ │ └── application.html.erb
│ └── rails_local_analytics
│ └── dashboard
│ └── index.html.erb
├── bin
├── dev
└── rails
├── config
├── initializers
│ └── browser_monkey_patches.rb
└── routes.rb
├── lib
├── rails_local_analytics.rb
├── rails_local_analytics
│ ├── engine.rb
│ └── version.rb
└── tasks
│ └── rails_local_analytics_tasks.rake
├── public
└── .keep
├── rails_local_analytics.gemspec
├── screenshot_1.png
├── screenshot_2.png
├── screenshot_3.png
├── screenshot_4.png
└── spec
├── dummy
├── .ruby-version
├── Rakefile
├── app
│ ├── assets
│ │ └── config
│ │ │ └── manifest.js
│ ├── channels
│ │ └── application_cable
│ │ │ ├── channel.rb
│ │ │ └── connection.rb
│ ├── controllers
│ │ ├── application_controller.rb
│ │ └── concerns
│ │ │ └── .keep
│ ├── helpers
│ │ └── application_helper.rb
│ ├── jobs
│ │ └── application_job.rb
│ ├── mailers
│ │ └── application_mailer.rb
│ ├── models
│ │ ├── application_record.rb
│ │ └── concerns
│ │ │ └── .keep
│ └── views
│ │ └── layouts
│ │ ├── application.html.erb
│ │ ├── mailer.html.erb
│ │ └── mailer.text.erb
├── bin
│ ├── rails
│ ├── rake
│ └── setup
├── config.ru
├── config
│ ├── application.rb
│ ├── boot.rb
│ ├── cable.yml
│ ├── database.yml
│ ├── environment.rb
│ ├── environments
│ │ ├── development.rb
│ │ └── test.rb
│ ├── initializers
│ │ ├── application_controller_renderer.rb
│ │ ├── backtrace_silencers.rb
│ │ ├── content_security_policy.rb
│ │ ├── cookies_serializer.rb
│ │ ├── filter_parameter_logging.rb
│ │ ├── inflections.rb
│ │ ├── mime_types.rb
│ │ └── wrap_parameters.rb
│ ├── locales
│ │ └── en.yml
│ ├── puma.rb
│ ├── routes.rb
│ └── storage.yml
├── db
│ ├── migrate
│ │ └── 20241130223207_create_analytics_tables.rb
│ └── seeds.rb
├── lib
│ └── assets
│ │ └── .keep
├── log
│ └── .keep
└── public
│ ├── 404.html
│ ├── 422.html
│ ├── 500.html
│ ├── apple-touch-icon-precomposed.png
│ ├── apple-touch-icon.png
│ └── favicon.ico
├── model
└── rails_local_analytics_spec.rb
├── rails_helper.rb
├── request
├── dashboard_controller_spec.rb
└── record_request_spec.rb
└── spec_helper.rb
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | push:
4 | branches: ['master']
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 |
11 | env:
12 | RAILS_ENV: test
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | include:
18 | ### TEST RUBY VERSIONS
19 | - ruby: "2.6"
20 | - ruby: "2.7"
21 | - ruby: "3.0"
22 | db_gem_version: "~> 1.4" # fixes sqlite3 gem dependency issue
23 | - ruby: "3.1"
24 | - ruby: "3.2"
25 | - ruby: "3.3"
26 | - ruby: "3.4"
27 | ### TEST RAILS VERSIONS
28 | - ruby: "2.6"
29 | rails_version: "~> 6.0.0"
30 | - ruby: "2.6"
31 | rails_version: "~> 6.1.0"
32 | - ruby: "3.3"
33 | rails_version: "~> 7.0.0"
34 | db_gem_version: "~> 1.4" # fixes sqlite3 gem dependency issue
35 | - ruby: "3.4"
36 | rails_version: "~> 7.1.0"
37 | - ruby: "3.4"
38 | rails_version: "~> 7.2.0"
39 | - ruby: "3.4"
40 | rails_version: "~> 8.0.0"
41 |
42 | steps:
43 | - uses: actions/checkout@v3
44 |
45 | - name: Set env variables
46 | run: |
47 | echo "RAILS_VERSION=${{ matrix.rails_version }}" >> "$GITHUB_ENV"
48 | echo "DB_GEM=${{ matrix.db_gem }}" >> "$GITHUB_ENV"
49 | echo "DB_GEM_VERSION=${{ matrix.db_gem_version }}" >> "$GITHUB_ENV"
50 |
51 | - name: Install ruby
52 | uses: ruby/setup-ruby@v1
53 | with:
54 | ruby-version: "${{ matrix.ruby }}"
55 | bundler-cache: false ### not compatible with ENV-style Gemfile
56 |
57 | - name: Run test
58 | run: |
59 | bundle install
60 | bundle exec rake db:create
61 | bundle exec rake db:migrate
62 | RUBYOPT='--enable-frozen-string-literal' bundle exec rake test
63 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bundle/
2 | log/*.log
3 | pkg/
4 |
5 | spec/dummy/db/*.sqlite3
6 | spec/dummy/db/*.sqlite3*
7 | spec/dummy/log/*.log
8 | spec/dummy/log/*.log*
9 | spec/dummy/storage/
10 | spec/dummy/tmp/
11 | spec/dummy/db/schema.rb
12 |
13 | Gemfile.lock
14 |
15 | **/.DS_Store
16 | .DS_Store
17 |
18 | .rspec
19 | spec/examples.txt
20 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ### Unreleased
4 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v1.0.0...master)
5 | - Nothing yet
6 |
7 | ### v1.0.1 - Jan 17 2025
8 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v1.0.0...v1.0.1)
9 | - [#22](https://github.com/westonganger/rails_local_analytics/pull/22) - Completely remove usage of sprockets or propshaft
10 |
11 | ### v1.0.0 - Jan 17 2025
12 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v0.2.4...v1.0.0)
13 | - There are no functional changes. This release v1.0.0 is to signal that its stable and ready for widespread usage.
14 |
15 | ### v0.2.4 - Dec 20 2024
16 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v0.2.3...v0.2.4)
17 | - [#18](https://github.com/westonganger/rails_local_analytics/pull/18) - Fix KeyError when using `config.background_jobs = false` due to not stringifying the keys
18 |
19 | ### v0.2.3 - Dec 17 2024
20 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v0.2.2...v0.2.3)
21 | - [#16](https://github.com/westonganger/rails_local_analytics/pull/16) - Fix issue with sprockets manifest compilation due to gemspec.files directive excluding hidden .keep files in images and javascripts folders
22 |
23 | ### v0.2.2 - Dec 12 2024
24 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v0.2.1...v0.2.2)
25 | - [#14](https://github.com/westonganger/rails_local_analytics/pull/14) - Fix bugs with group by and end date
26 | - [#13](https://github.com/westonganger/rails_local_analytics/pull/13) - Add ability to group by hostname and path combined
27 |
28 | ### v0.2.1 - Dec 4 2024
29 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v0.2.0...v0.2.1)
30 | - [#12](https://github.com/westonganger/rails_local_analytics/pull/12) - Ensure table width is constrained to the width of the screen
31 | - [#11](https://github.com/westonganger/rails_local_analytics/pull/11) - Add ability to filter on specific field/value combos
32 | - [#10](https://github.com/westonganger/rails_local_analytics/pull/10) - Dont use `Integer#to_fs` as its not available in Rails 6.x
33 |
34 | ### v0.2.0 - Dec 4 2024
35 | - [View Diff](https://github.com/westonganger/rails_local_analytics/compare/v0.1.0...v0.2.0)
36 | - [#9](https://github.com/westonganger/rails_local_analytics/pull/9) - Add links to load difference for paginated responses, use type instead of class name as keys in `:custom_attributes`, Remove table sorting capabilities, remove jquery
37 | - [#8](https://github.com/westonganger/rails_local_analytics/pull/8) - Improve performance significantly using SQL GROUP_BY and pagination of 1000 per page, app tested using seed of 100,000 records
38 | - [#7](https://github.com/westonganger/rails_local_analytics/pull/7) - Create separate tabs for page/site analytics, improve routes, fix multi_search, add more time quicklinks
39 | - [#6](https://github.com/westonganger/rails_local_analytics/pull/6) - Inline javascript file
40 | - [#5](https://github.com/westonganger/rails_local_analytics/pull/5) - Show page analytics on dashboard by default instead of site analytics
41 | - [#4](https://github.com/westonganger/rails_local_analytics/pull/4) - Make search form auto-submit upon changing any fields
42 | - [#3](https://github.com/westonganger/rails_local_analytics/pull/3) - Use `sanitize_sql_like` and `AND` in `multi_search` scope
43 | - [#2](https://github.com/westonganger/rails_local_analytics/pull/2) - Do not downcase URLs
44 | - [#1](https://github.com/westonganger/rails_local_analytics/pull/1) - Backport `Browser#chromium_based?` when `browser` gem version is 5.x or below.
45 |
46 | ### v0.1.0 - Dec 3 2024
47 | - Initial gem release
48 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3 |
4 | # Declare your gem's dependencies in coders_log.gemspec.
5 | # Bundler will treat runtime dependencies like base dependencies, and
6 | # development dependencies will be added by default to the :development group.
7 | gemspec
8 |
9 | # Declare any dependencies that are still in development here instead of in
10 | # your gemspec. These might include edge Rails or gems from your path or
11 | # Git. Remember to move these dependencies to your gemspec before releasing
12 | # your gem to rubygems.org.
13 |
14 | # To use a debugger
15 | # gem 'byebug', group: [:development, :test]
16 |
17 | def get_env(name)
18 | (ENV[name] && !ENV[name].empty?) ? ENV[name] : nil
19 | end
20 |
21 | rails_version = get_env("RAILS_VERSION")
22 |
23 | gem "rails", rails_version
24 |
25 | db_gem = get_env("DB_GEM") || "sqlite3"
26 | gem db_gem, get_env("DB_GEM_VERSION")
27 |
28 | if db_gem == "sqlite3" && get_env("RAILS_VERSION").to_f >= 7.2
29 | gem "activerecord-enhancedsqlite3-adapter"
30 | end
31 |
32 | group :development do
33 | gem "puma"
34 | end
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2024 Weston Ganger
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rails Local Analytics
2 |
3 |
4 |
5 |
6 |
7 | Simple, performant, local analytics for Rails. Solves 95% of your needs until your ready to start taking analytics more seriously using another tool.
8 |
9 | Out of the box the following request details are tracked:
10 |
11 | - day
12 | - total (count per day)
13 | - url_hostname (site)
14 | - url_path (page)
15 | - referrer_hostname
16 | - referrer_path
17 | - platform (ios, android, linux, osx, windows, etc)
18 | - [browser_engine](https://en.wikipedia.org/wiki/Comparison_of_browser_engines) (blink, gecko, webkit, or nil)
19 |
20 | It is fully customizable to store more details if desired.
21 |
22 | ## Screenshots
23 |
24 | 
25 |
26 | 
27 |
28 | 
29 |
30 | 
31 |
32 | ## Installation
33 |
34 | ```ruby
35 | # Gemfile
36 | gem "rails_local_analytics"
37 | ```
38 |
39 | Add the following migration to your app:
40 |
41 | ```
42 | bundle exec rails g migration CreateAnalyticsTables
43 | ```
44 |
45 | ```ruby
46 | # db/migrations/..._create_analytics_tables.rb
47 |
48 | class CreateAnalyticsTables < ActiveRecord::Migration[6.0]
49 | def up
50 | create_table :tracked_requests_by_day_page do |t|
51 | t.date :day, null: false
52 | t.bigint :total, null: false, default: 1
53 | t.string :url_hostname, null: false
54 | t.string :url_path, null: false
55 | t.string :referrer_hostname
56 | t.string :referrer_path
57 | end
58 | add_index :tracked_requests_by_day_page, :day
59 |
60 | create_table :tracked_requests_by_day_site do |t|
61 | t.date :day, null: false
62 | t.bigint :total, null: false, default: 1
63 | t.string :url_hostname, null: false
64 | t.string :platform
65 | t.string :browser_engine
66 | end
67 | add_index :tracked_requests_by_day_site, :day
68 | end
69 |
70 | def down
71 | drop_table :tracked_requests_by_day_page
72 | drop_table :tracked_requests_by_day_site
73 | end
74 | end
75 | ```
76 | The reason we store our analytics in two separate tables to keep the cardinality of our data low. You are permitted to store everything in only one table but I recommend you try the 2 table approach first and see if it meets your needs. See the performance optimization section for more details.
77 |
78 | Add the route for the analytics dashboard at the desired endpoint:
79 |
80 | ```ruby
81 | # config/routes.rb
82 | mount RailsLocalAnalytics::Engine, at: "/admin/analytics"
83 | ```
84 |
85 | Its generally recomended to use a background job (especially since we now have [`solid_queue`](https://github.com/rails/solid_queue/)). If you would like to disable background jobs you can use the following config option:
86 |
87 | ```ruby
88 | # config/initializers/rails_local_analytics.rb
89 | # RailsLocalAnalytics.config.background_jobs = false # defaults to true
90 | ```
91 |
92 | The next step is to collect traffic.
93 |
94 | ## Recording requests
95 |
96 | There are two types of analytics that we mainly target:
97 |
98 | - Site level analytics
99 | * Stored in the table `tracked_requests_by_day_site`
100 | - Page level analytics
101 | * Stored in the table `tracked_requests_by_day_page`
102 |
103 | Your controllers have to manually call `RailsLocalAnalytics.record_request`. For example:
104 |
105 | ```ruby
106 | class ApplicationController < ActionController::Base
107 | after_action :record_page_view
108 |
109 | private
110 |
111 | def record_page_view
112 | return if !request.format.html? && !request.format.json?
113 |
114 | ### We accept manual overrides of any of the database fields
115 | ### For example if you wanted to track bots:
116 | site_based_attrs = {}
117 | if some_custom_bot_detection_method
118 | site_based_attrs[:platform] = "bot"
119 | end
120 |
121 | RailsLocalAnalytics.record_request(
122 | request: request,
123 | custom_attributes: { # optional
124 | site: site_based_attrs,
125 | page: {},
126 | },
127 | )
128 | end
129 | end
130 | ```
131 |
132 | If you need to add more data to your events you can simply add more columns to the analytics tables and then populate these columns using the `:custom_attributes` argument.
133 |
134 | Some examples of additional things you may want to track:
135 |
136 | - Bot detection
137 | * Bot detection is difficult. As such we dont try to include it by default. Recommended gem for detection is [`crawler_detect`](https://github.com/loadkpi/crawler_detect)
138 | * One option is to consider not tracking bots at all in your analytics, just a thought
139 | * You may not need to store this in a new column, one example pattern could be to store this data in the existing `platform` database field
140 | - Country detection
141 | * Country detection is difficult. As such we dont try to include it by default.
142 | * Consider using language detection instead
143 | - Language detection
144 | * You can gather the language from the `request.env["HTTP_ACCEPT_LANGUAGE"]` or `browser.accept_language.first.full`
145 | - Users or organizations
146 | * You may want to track your users or another model which is a core tenant to your particular application
147 |
148 |
149 | ## Performance Optimization Techniques
150 |
151 | There are a few techniques that you can use to tailor the database for your particular needs. Heres a few examples:
152 |
153 | - If you drop any database columns from the analytics tables this will not cause any issues. It will continue to function as normal.
154 | - `url_hostname` column
155 | * If you wont ever have multi-site needs then you can consider removing this column
156 | * If storage space is an issue you may consider switching to an enum column as the number of permutations is probably something that can be anticipated.
157 | - `referrer_host` and `referrer_path` columns
158 | * Consider just storing "local" or nil instead if the request originated from your website
159 | - `platform` and `browser_engine` columns
160 | * Consider dropping either of these if you do not need this information
161 | - If you want to store everything in one table (which I dont think most people actually need) then you can simply only create one table (I recommend `tracked_requests_by_day_page`) with all of the fields from both tables. This gem will automatically populate all the same fields. You should NOT need to use `:custom_attributes` in this scenario.
162 |
163 | ## Usage where a request object is not available
164 |
165 | If you are not in a controller or do not have access to the request object then you may pass in a hash representation. For example:
166 |
167 | ```ruby
168 | RailsLocalAnalytics.record_request(
169 | request: {
170 | host: "http://example.com",
171 | path: "/some/path",
172 | referrer: "http://example.com/some/other/path",
173 | user_agent: "some-user-agent",
174 | http_accept_language: "some-http-accept-language",
175 | },
176 | # ...
177 | )
178 | ```
179 |
180 | ## Deleting old data
181 |
182 | By default all data is retained indefinately. If you would like to have automated deletion of the data, you might use the following example technique:
183 |
184 | ```ruby
185 | class ApplicationController
186 | after_action :record_page_view
187 |
188 | private
189 |
190 | def record_page_view
191 | # perform other logic and call RailsLocalAnalytics.record_request
192 |
193 | TrackedRequestsByDayPage.where("day < ?", 3.months.ago).delete_all
194 | TrackedRequestsByDaySite.where("day < ?", 3.months.ago).delete_all
195 | end
196 | end
197 | ```
198 |
199 | ## Page Performance Tracking
200 |
201 | We dont do any page performance tracking (request/response time, etc), this gem only specializes in analytics.
202 |
203 | If you are looking for a simple performance tracking solution, I highly recommend the gem [`inner_performance`](https://github.com/mbajur/inner_performance)
204 |
205 | ## Development
206 |
207 | Run server using: `bin/dev` or `cd test/dummy/; rails s`
208 |
209 | ## Testing
210 |
211 | ```
212 | bundle exec rspec
213 | ```
214 |
215 | We can locally test different versions of Rails using `ENV['RAILS_VERSION']`
216 |
217 | ```
218 | export RAILS_VERSION=7.0
219 | bundle install
220 | bundle exec rspec
221 | ```
222 |
223 | ## Credits
224 |
225 | Created & Maintained by [Weston Ganger](https://westonganger.com) - [@westonganger](https://github.com/westonganger)
226 |
227 | Imitated some parts of [`active_analytics`](https://github.com/BaseSecrete/active_analytics). Thanks to them for the aggregate database schema idea.
228 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'bundler/setup'
2 |
3 | APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4 | load 'rails/tasks/engine.rake'
5 |
6 | load 'rails/tasks/statistics.rake'
7 |
8 | require 'bundler/gem_tasks'
9 |
10 | require 'rspec/core/rake_task'
11 | RSpec::Core::RakeTask.new(:spec)
12 |
13 | task test: [:spec]
14 |
15 | task default: [:spec]
16 |
--------------------------------------------------------------------------------
/app/controllers/rails_local_analytics/application_controller.rb:
--------------------------------------------------------------------------------
1 | module RailsLocalAnalytics
2 | class ApplicationController < ActionController::Base
3 |
4 | def root
5 | redirect_to tracked_requests_path(type: :page)
6 | end
7 |
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/app/controllers/rails_local_analytics/dashboard_controller.rb:
--------------------------------------------------------------------------------
1 | module RailsLocalAnalytics
2 | class DashboardController < ApplicationController
3 | PER_PAGE_LIMIT = 1000
4 |
5 | helper_method :pagination_page_number
6 | helper_method :display_columns
7 |
8 | def index
9 | params[:type] ||= "page"
10 |
11 | case params[:type]
12 | when "site"
13 | @klass = TrackedRequestsByDaySite
14 | when "page"
15 | @klass = TrackedRequestsByDayPage
16 | else
17 | head 404
18 | return
19 | end
20 |
21 | if params[:start_date].present?
22 | @start_date = Date.parse(params[:start_date])
23 | else
24 | @start_date = Date.today
25 | end
26 |
27 | if params[:end_date].present?
28 | @end_date = Date.parse(params[:end_date])
29 | else
30 | @end_date = Date.today
31 | end
32 |
33 | if @end_date < @start_date
34 | @end_date = @start_date
35 | end
36 |
37 | @results = fetch_records(@start_date, @end_date)
38 |
39 | if @results.size < PER_PAGE_LIMIT
40 | prev_start_date, prev_end_date = get_prev_dates(@start_date, @end_date)
41 |
42 | @prev_period_results = fetch_records(prev_start_date, prev_end_date)
43 |
44 | if @prev_period_results.size >= PER_PAGE_LIMIT
45 | @prev_period_results = nil
46 | end
47 | end
48 | end
49 |
50 | def difference
51 | case params.require(:type)
52 | when "site"
53 | @klass = TrackedRequestsByDaySite
54 | when "page"
55 | @klass = TrackedRequestsByDayPage
56 | end
57 |
58 | start_date = Date.parse(params.require(:start_date))
59 | end_date = Date.parse(params.require(:end_date))
60 |
61 | prev_start_date, prev_end_date = get_prev_dates(start_date, end_date)
62 |
63 | difference_where_conditions = params.require(:conditions).permit(*display_columns)
64 |
65 | current_total = fetch_records(
66 | start_date,
67 | end_date,
68 | difference_where_conditions: difference_where_conditions,
69 | ).first
70 |
71 | prev_total = fetch_records(
72 | prev_start_date,
73 | prev_end_date,
74 | difference_where_conditions: difference_where_conditions,
75 | ).first
76 |
77 | if prev_total
78 | diff = current_total - prev_total
79 | else
80 | diff = current_total
81 | end
82 |
83 | render json: {difference: diff}
84 | end
85 |
86 | private
87 |
88 | def fetch_records(start_date, end_date, difference_where_conditions: nil)
89 | aggregate_sql_field = "SUM(total)"
90 |
91 | tracked_requests = @klass
92 | .where("day >= ?", start_date)
93 | .where("day <= ?", end_date)
94 | .order("#{aggregate_sql_field} DESC")
95 |
96 | if difference_where_conditions
97 | tracked_requests = tracked_requests.where(difference_where_conditions)
98 | else
99 | tracked_requests = tracked_requests
100 | .limit(PER_PAGE_LIMIT)
101 | .offset(PER_PAGE_LIMIT * (pagination_page_number-1))
102 |
103 | if params[:filter].present?
104 | col, val = params[:filter].split("==")
105 |
106 | if display_columns.include?(col)
107 | tracked_requests = tracked_requests.where(col => val)
108 | else
109 | raise ArgumentError
110 | end
111 | end
112 | end
113 |
114 | if params[:search].present?
115 | tracked_requests = tracked_requests.multi_search(params[:search])
116 | end
117 |
118 | if params[:group_by].blank?
119 | pluck_columns = display_columns.dup
120 | else
121 | case params[:group_by]
122 | when "url_hostname_and_path"
123 | if display_columns.include?("url_hostname") && display_columns.include?("url_path")
124 | pluck_columns = [:url_hostname, :url_path]
125 | else
126 | raise ArgumentError
127 | end
128 | when "referrer_hostname_and_path"
129 | if display_columns.include?("referrer_hostname") && display_columns.include?("referrer_path")
130 | pluck_columns = [:referrer_hostname, :referrer_path]
131 | else
132 | raise ArgumentError
133 | end
134 | when *display_columns
135 | pluck_columns = [params[:group_by]]
136 | else
137 | raise ArgumentError
138 | end
139 | end
140 |
141 | group_by = pluck_columns.dup
142 |
143 | if difference_where_conditions
144 | pluck_columns = [aggregate_sql_field]
145 | else
146 | pluck_columns << aggregate_sql_field
147 | end
148 |
149 | tracked_requests
150 | .group(*group_by)
151 | .pluck(*pluck_columns)
152 | end
153 |
154 | def pagination_page_number
155 | page = params[:page].presence.to_i || 1
156 | page = 1 if page.zero?
157 | page
158 | end
159 |
160 | def get_prev_dates(start_date, end_date)
161 | if start_date == end_date
162 | prev_start_date = start_date - 1.day
163 | prev_end_date = prev_start_date
164 | else
165 | duration = end_date - start_date
166 | prev_start_date = start_date - duration
167 | prev_end_date = end_date - duration
168 | end
169 | return [prev_start_date, prev_end_date]
170 | end
171 |
172 | def display_columns
173 | @display_columns ||= @klass.display_columns
174 | end
175 |
176 | end
177 | end
178 |
--------------------------------------------------------------------------------
/app/jobs/rails_local_analytics/application_job.rb:
--------------------------------------------------------------------------------
1 | module RailsLocalAnalytics
2 | class ApplicationJob < ActiveJob::Base
3 |
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/app/jobs/rails_local_analytics/record_request_job.rb:
--------------------------------------------------------------------------------
1 | module RailsLocalAnalytics
2 | class RecordRequestJob < ApplicationJob
3 | def perform(json)
4 | if json.is_a?(String)
5 | json = JSON.parse(json)
6 | end
7 |
8 | request_hash = json.fetch("request_hash")
9 |
10 | custom_attributes_by_type = json.fetch("custom_attributes")
11 |
12 | ["site", "page"].each do |type|
13 | case type
14 | when "site"
15 | klass = TrackedRequestsByDaySite
16 | when "page"
17 | klass = TrackedRequestsByDayPage
18 | end
19 |
20 | custom_attrs = custom_attributes_by_type && custom_attributes_by_type[type]
21 |
22 | attrs = build_attrs(klass, custom_attrs, request_hash)
23 |
24 | attrs["day"] = json.fetch("day")
25 |
26 | existing_record = klass.find_by(attrs)
27 |
28 | if existing_record
29 | existing_record.increment!(:total, 1)
30 | else
31 | klass.create!(attrs)
32 | end
33 | end
34 | end
35 |
36 | private
37 |
38 | def build_attrs(klass, attrs, request_hash)
39 | attrs ||= {}
40 |
41 | field = "url_hostname"
42 | if !skip_field?(field, attrs, klass)
43 | attrs[field] = request_hash.fetch("host")
44 | end
45 |
46 | field = "url_path"
47 | if !skip_field?(field, attrs, klass)
48 | attrs[field] = request_hash.fetch("path")
49 | end
50 |
51 | if request_hash.fetch("referrer").present?
52 | field = "referrer_hostname"
53 | if !skip_field?(field,attrs, klass)
54 | referrer_hostname, referrer_path = split_referrer(request_hash.fetch("referrer"))
55 | attrs[field] = referrer_hostname
56 | end
57 |
58 | field = "referrer_path"
59 | if !skip_field?(field, attrs, klass)
60 | if referrer_path.nil?
61 | referrer_hostname, referrer_path = split_referrer(request_hash.fetch("referrer"))
62 | end
63 | attrs[field] = referrer_path
64 | end
65 | end
66 |
67 | if request_hash.fetch("user_agent").present?
68 | field = "platform"
69 | if !skip_field?(field, attrs, klass)
70 | browser ||= create_browser_object(request_hash)
71 | attrs[field] = browser.platform.name
72 | end
73 |
74 | field = "browser_engine"
75 | if !skip_field?(field, attrs, klass)
76 | browser ||= create_browser_object(request_hash)
77 | attrs[field] = get_browser_engine(browser)
78 | end
79 | end
80 |
81 | return attrs
82 | end
83 |
84 | def split_referrer(referrer)
85 | uri = URI(referrer)
86 |
87 | if uri.host.present?
88 | return [
89 | uri.host,
90 | uri.path.presence,
91 | ]
92 | else
93 | strings = referrer.split("/", 2)
94 | return [
95 | strings[0],
96 | (strings[1].present? ? "/#{strings[1]}" : nil),
97 | ]
98 | end
99 | end
100 |
101 | def get_browser_engine(browser)
102 | if browser.webkit?
103 | # must come before all other checks because Firefox/Chrome on iOS devices is actually using Safari under the hood
104 | "webkit"
105 | elsif browser.chromium_based?
106 | "blink"
107 | elsif browser.firefox?
108 | "gecko"
109 | else
110 | nil # store nothing, data is not useful
111 | end
112 | end
113 |
114 | def create_browser_object(request_hash)
115 | Browser.new(
116 | request_hash.fetch("user_agent"),
117 | accept_language: request_hash.fetch("http_accept_language"),
118 | )
119 | end
120 |
121 | def skip_field?(field, attrs, klass)
122 | attrs&.has_key?(field) || !klass.column_names.include?(field)
123 | end
124 |
125 | end
126 | end
127 |
--------------------------------------------------------------------------------
/app/models/rails_local_analytics/application_record.rb:
--------------------------------------------------------------------------------
1 | module RailsLocalAnalytics
2 | class ApplicationRecord < ActiveRecord::Base
3 | self.abstract_class = true
4 |
5 | scope :multi_search, ->(full_str){
6 | if full_str.present?
7 | relation = self
8 |
9 | full_str.split(' ').each do |str|
10 | like = connection.adapter_name.downcase.to_s == "postgres" ? "ILIKE" : "LIKE"
11 |
12 | sql_conditions = []
13 |
14 | display_columns.each do |col|
15 | sql_conditions << "(#{col} #{like} :search)"
16 | end
17 |
18 | relation = relation.where(sql_conditions.join(" OR "), search: "%#{sanitize_sql_like(str)}%")
19 | end
20 |
21 | next relation
22 | end
23 | }
24 |
25 | def self.display_columns
26 | column_names - ["id", "created_at", "updated_at", "total", "day"]
27 | end
28 |
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/app/models/tracked_requests_by_day_page.rb:
--------------------------------------------------------------------------------
1 | class TrackedRequestsByDayPage < RailsLocalAnalytics::ApplicationRecord
2 | self.table_name = "tracked_requests_by_day_page"
3 |
4 | before_create do
5 | self.total ||= 1
6 | end
7 |
8 | end
9 |
--------------------------------------------------------------------------------
/app/models/tracked_requests_by_day_site.rb:
--------------------------------------------------------------------------------
1 | class TrackedRequestsByDaySite < RailsLocalAnalytics::ApplicationRecord
2 | self.table_name = "tracked_requests_by_day_site"
3 |
4 | before_create do
5 | self.total ||= 1
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_local_analytics/_app_css.html.erb:
--------------------------------------------------------------------------------
1 |
25 |
--------------------------------------------------------------------------------
/app/views/layouts/rails_local_analytics/application.html.erb:
--------------------------------------------------------------------------------
1 | <% title = RailsLocalAnalytics.name.titleize %>
2 |
3 |
4 |
5 |