├── .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 | Gem Version 4 | CI Status 5 | RubyGems Downloads 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 | ![Screenshot 1](/screenshot_1.png) 25 | 26 | ![Screenshot 2](/screenshot_2.png) 27 | 28 | ![Screenshot 3](/screenshot_3.png) 29 | 30 | ![Screenshot 4](/screenshot_4.png) 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 | 6 | <%= title %> 7 | 8 | <%= csrf_meta_tags %> 9 | <%= csp_meta_tag %> 10 | 11 | 12 | <%= render "layouts/rails_local_analytics/app_css" %> 13 | 14 | 15 | 16 | 33 | 34 |
35 | <%= yield %> 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/views/rails_local_analytics/dashboard/index.html.erb: -------------------------------------------------------------------------------- 1 | <% 2 | data_columns = params[:group_by].present? ? [params[:group_by]] : display_columns 3 | 4 | if data_columns.first == "url_hostname_and_path" 5 | data_columns = ["URL Hostname", "URL Path"] 6 | elsif data_columns.first == "referrer_hostname_and_path" 7 | data_columns = ["Referrer Hostname", "Referrer Path"] 8 | end 9 | %> 10 | 11 | <% 12 | group_by_opts = [ 13 | ["All", nil], 14 | ] 15 | 16 | group_by_opts += display_columns.map{|x| [x.titleize.sub("Url ", "URL "), x] } 17 | 18 | if display_columns.include?("url_hostname") && display_columns.include?("url_path") 19 | index = group_by_opts.index(["URL Hostname", "url_hostname"]) 20 | group_by_opts.insert( 21 | index, 22 | ["URL Hostname and Path", "url_hostname_and_path"], 23 | ) 24 | end 25 | 26 | if display_columns.include?("referrer_hostname") && display_columns.include?("referrer_path") 27 | index = group_by_opts.index(["Referrer Hostname", "referrer_hostname"]) 28 | group_by_opts.insert( 29 | index, 30 | ["Referrer Hostname and Path", "referrer_hostname_and_path"], 31 | ) 32 | end 33 | %> 34 | 35 |
36 | <%= form_tag url_for(params.except(:start_date, :to).to_unsafe_hash), method: "get", id: "search-form" do %> 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | <% end %> 45 | 46 | <% if params[:filter] %> 47 |
48 | 49 |
50 | <% filter_col, filter_val = params[:filter].split("==") %> 51 | <%= filter_col.titleize.sub("Url ", "URL ") %> = "<%= filter_val %>" 52 |
53 | <%= link_to "Remove Filter", url_for(params.to_unsafe_h.merge(filter: nil)) %> 54 |
55 | <% end %> 56 | 57 |
58 | <%= link_to "Today", url_for(params.merge(start_date: Date.today, end_date: Date.today).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == Date.today && @end_date == Date.today) %> 59 | | 60 | <% yesterday = Date.today - 1.day %> 61 | <%= link_to "Yesterday", url_for(params.merge(start_date: yesterday, end_date: yesterday).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == yesterday && @end_date == yesterday) %> 62 | | 63 | <%= link_to "Last 7 days", url_for(params.merge(start_date: 7.days.ago.to_date, end_date: Date.today).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == 7.days.ago.to_date && @end_date == Date.today) %> 64 | | 65 | <%= link_to "Last 30 days", url_for(params.merge(start_date: 30.days.ago.to_date, end_date: Date.today).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == 30.days.ago.to_date && @end_date == Date.today) %> 66 | | 67 | <%= link_to "Last 3 Months", url_for(params.merge(start_date: 3.months.ago.to_date, end_date: Date.today).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == 3.months.ago.to_date && @end_date == Date.today) %> 68 | | 69 | <%= link_to "Last 6 Months", url_for(params.merge(start_date: 6.months.ago.to_date, end_date: Date.today).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == 6.months.ago.to_date && @end_date == Date.today) %> 70 | | 71 | <%= link_to "Last Year", url_for(params.merge(start_date: 1.year.ago.to_date, end_date: Date.today).to_unsafe_hash), style: ("font-weight: bold;" if @start_date == 1.year.ago.to_date && @end_date == Date.today) %> 72 |
73 |
74 | 75 |

Requests by <%= params[:type].titleize %>

76 | 77 | 78 | 79 | <% data_columns.each do |header| %> 80 | 83 | <% end %> 84 | 85 | 88 | 89 | 92 | 93 | 94 | 95 | <% @results.each_with_index do |row, row_index| %> 96 | 97 | <% row[0..-2].each_with_index do |value, col_index| %> 98 | 102 | <% end %> 103 | 104 | 108 | 109 | 149 | 150 | <% end %> 151 | 152 |
81 | <%= header.titleize.sub("Url ", "URL ") %> 82 | 86 | Total 87 | 90 | Prev Period Difference 91 |
99 | <% filter_param = "#{data_columns[col_index]}==#{value}" %> 100 | <%= link_to (value || ""), url_for(params.to_unsafe_h.merge(filter: filter_param)), title: "Filter" %> 101 | 105 | <% total = row.last %> 106 | <%= number_with_delimiter(total) %> 107 | 110 | <% if @prev_period_results.nil? %> 111 | <% 112 | diff_params = { 113 | format: :json, 114 | type: params[:type], 115 | start_date: @start_date, 116 | end_date: @end_date, 117 | conditions: {}, 118 | } 119 | 120 | data_columns.each_with_index do |col, col_index| 121 | diff_params[:conditions][col] = row[col_index] 122 | end 123 | 124 | placeholder_id = "diff-placeholder-#{row_index}" 125 | %> 126 | 127 | 128 | 129 |
130 | <% else %> 131 | <% prev_period_row_index = @prev_period_results.index{|prev_period_row| row[0..-2] == prev_period_row[0..-2] } %> 132 | 133 | <% if prev_period_row_index.nil? %> 134 | +<%= number_with_delimiter(total) %> 135 | <% else %> 136 | <% prev_period_row = @prev_period_results.delete_at(prev_period_row_index) %> 137 | 138 | <% prev_period_total = prev_period_row.last %> 139 | <% diff = total - prev_period_total %> 140 | 141 | <% if diff >= 0 %> 142 | +<%= number_with_delimiter(diff) %> 143 | <% else %> 144 | <%= number_with_delimiter(diff) %> 145 | <% end %> 146 | <% end %> 147 | <% end %> 148 |
153 | 154 |
155 | <% if params[:page].present? && params[:page].to_i > 1 %> 156 | <%= link_to "Prev", url_for(params.to_unsafe_h.merge(page: pagination_page_number-1)) %> 157 | | 158 | <% end %> 159 | 160 | <% if @results.size >= RailsLocalAnalytics::DashboardController::PER_PAGE_LIMIT %> 161 | <%= link_to "Next", url_for(params.to_unsafe_h.merge(page: pagination_page_number+1)) %> 162 | <% end %> 163 |
164 | 165 | 213 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | system("cd spec/dummy/ && bundle exec rails s --binding 0.0.0.0", out: STDOUT) 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path('..', __dir__) 6 | ENGINE_PATH = File.expand_path('../lib/rails_local_analytics/engine', __dir__) 7 | APP_PATH = File.expand_path('../spec/dummy/config/application', __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 11 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 12 | 13 | require 'rails/all' 14 | require 'rails/engine/commands' 15 | -------------------------------------------------------------------------------- /config/initializers/browser_monkey_patches.rb: -------------------------------------------------------------------------------- 1 | if Browser::VERSION.to_f < 6.0 2 | Browser::Base.class_eval do 3 | def chromium_based? 4 | false 5 | end 6 | end 7 | 8 | Browser::Chrome.class_eval do 9 | def chromium_based? 10 | true 11 | end 12 | end 13 | 14 | Browser::Edge.class_eval do 15 | def chromium_based? 16 | match? && ua.match?(/\bEdg\b/) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | RailsLocalAnalytics::Engine.routes.draw do 2 | get "/tracked_requests/:type", to: "dashboard#index", as: :tracked_requests 3 | get "/tracked_requests/:type/difference", to: "dashboard#difference", as: :difference_tracked_requests, constraints: {format: :json} 4 | 5 | root to: "application#root" 6 | end 7 | -------------------------------------------------------------------------------- /lib/rails_local_analytics.rb: -------------------------------------------------------------------------------- 1 | require "rails_local_analytics/version" 2 | require "rails_local_analytics/engine" 3 | require "browser/browser" 4 | 5 | module RailsLocalAnalytics 6 | 7 | def self.record_request(request:, custom_attributes: nil) 8 | if request.is_a?(Hash) 9 | request_hash = request 10 | else 11 | ### Make request object generic so that it can be used outside of the controller 12 | 13 | request_hash = { 14 | referrer: request.referrer, 15 | host: request.host, 16 | path: request.path, 17 | user_agent: request.user_agent, 18 | http_accept_language: request.env["HTTP_ACCEPT_LANGUAGE"], 19 | } 20 | end 21 | 22 | json_hash = { 23 | day: Date.today.to_s, 24 | request_hash: request_hash, 25 | custom_attributes: custom_attributes, 26 | } 27 | 28 | if RailsLocalAnalytics.config.background_jobs 29 | json_str = JSON.generate(json_hash) # convert to json string so that its compatible with all job backends 30 | RecordRequestJob.perform_later(json_str) 31 | else 32 | RecordRequestJob.new.perform(json_hash.deep_stringify_keys) 33 | end 34 | end 35 | 36 | def self.config(&block) 37 | c = Config 38 | 39 | if block_given? 40 | block.call(c) 41 | else 42 | return c 43 | end 44 | end 45 | 46 | class Config 47 | DEFAULTS = { 48 | background_jobs: true, 49 | }.freeze 50 | 51 | mattr_reader :background_jobs 52 | 53 | def self.background_jobs=(val) 54 | @@background_jobs = !!val 55 | end 56 | 57 | DEFAULTS.each do |k,v| 58 | self.send("#{k}=", v) 59 | end 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /lib/rails_local_analytics/engine.rb: -------------------------------------------------------------------------------- 1 | module RailsLocalAnalytics 2 | class Engine < ::Rails::Engine 3 | isolate_namespace RailsLocalAnalytics 4 | 5 | initializer "rails_local_analytics.load_static_assets" do |app| 6 | ### Expose static assets 7 | app.middleware.use ::ActionDispatch::Static, "#{root}/public" 8 | end 9 | 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_local_analytics/version.rb: -------------------------------------------------------------------------------- 1 | module RailsLocalAnalytics 2 | VERSION = "1.0.1".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/rails_local_analytics_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :rails_local_analytics do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /public/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/public/.keep -------------------------------------------------------------------------------- /rails_local_analytics.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("lib", __dir__) 2 | 3 | # Maintain your gem's version: 4 | require "rails_local_analytics/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |spec| 8 | spec.name = "rails_local_analytics" 9 | spec.version = RailsLocalAnalytics::VERSION 10 | spec.authors = ["Weston Ganger"] 11 | spec.email = ["weston@westonganger.com"] 12 | spec.homepage = "https://github.com/westonganger/rails_local_analytics" 13 | spec.summary = "Simple, performant, local analytics for Rails. Solves 95% of your needs until your ready to start taking analytics more seriously using another tool." 14 | spec.description = spec.summary 15 | spec.license = "MIT" 16 | 17 | spec.files = Dir["{app,config,db,lib,public}/**/*", "LICENSE", "Rakefile", "README.md", "app/assets/**/{.keep,.gitkeep}"] 18 | 19 | spec.required_ruby_version = ">= 2.6" 20 | 21 | spec.add_dependency "rails", ">= 6.0" 22 | spec.add_dependency "browser" 23 | 24 | spec.add_development_dependency "rspec-rails" 25 | spec.add_development_dependency "rspec-html-matchers" 26 | spec.add_development_dependency "database_cleaner" 27 | spec.add_development_dependency "rails-controller-testing" 28 | end 29 | -------------------------------------------------------------------------------- /screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/screenshot_1.png -------------------------------------------------------------------------------- /screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/screenshot_2.png -------------------------------------------------------------------------------- /screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/screenshot_3.png -------------------------------------------------------------------------------- /screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/screenshot_4.png -------------------------------------------------------------------------------- /spec/dummy/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1 2 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | // this file is only present to make Rails 6.1 and below work in our CI 2 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | after_action :record_page_view 3 | 4 | def example_action 5 | html = <<~HTML 6 | Hello World! 7 |
8 |
9 | Go to Analytics Dashboard 10 | HTML 11 | 12 | render(html: html.html_safe) 13 | end 14 | 15 | private 16 | 17 | def record_page_view 18 | return if !request.format.html? && !request.format.json? 19 | 20 | RailsLocalAnalytics.record_request( 21 | request: request, 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag 'application', media: 'all' %> 9 | 10 | 11 | 12 | <%= yield %> 13 | 14 | 15 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:prepare' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "logger" # Fix for Rails 7.0 and below, https://github.com/rails/rails/pull/54264 4 | 5 | require 'rails/all' 6 | 7 | Bundler.require(*Rails.groups) 8 | require "rails_local_analytics" 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | # Initialize configuration defaults for originally generated Rails version. 13 | if Rails::VERSION::STRING.to_f >= 5.1 14 | config.load_defaults(Rails::VERSION::STRING.to_f) 15 | end 16 | 17 | # Settings in config/environments/* take precedence over those specified here. 18 | # Application configuration can go into files in config/initializers 19 | # -- all .rb files in that directory are automatically loaded after loading 20 | # the framework and any gems in your application. 21 | 22 | config.eager_load = true ### to catch more bugs in development/test environments 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../Gemfile', __dir__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | $LOAD_PATH.unshift File.expand_path('../../../lib', __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | config.action_mailer.default_url_options = { host: 'localhost:3000' } 3 | config.active_record.migration_error = :page_load 4 | config.consider_all_requests_local = true 5 | config.eager_load = true ### helps catch more errors in development 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | config.action_controller.allow_forgery_protection = false 3 | 4 | if Rails::VERSION::STRING.to_f >= 7.1 5 | config.action_dispatch.show_exceptions = :none 6 | else 7 | config.action_dispatch.show_exceptions = false 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | 19 | # If you are using UJS then enable automatic nonce generation 20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 21 | 22 | # Set the nonce only to specific directives 23 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 24 | 25 | # Report CSP violations to a specified URI 26 | # For further information see the following documentation: 27 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 28 | # Rails.application.config.content_security_policy_report_only = true 29 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch("PORT") { 3000 } 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch("RAILS_ENV") { "development" } 18 | 19 | # Specifies the `pidfile` that Puma will use. 20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 21 | 22 | # Specifies the number of `workers` to boot in clustered mode. 23 | # Workers are forked web server processes. If using threads and workers together 24 | # the concurrency of the application would be max `threads` * `workers`. 25 | # Workers do not work on JRuby or Windows (both of which do not support 26 | # processes). 27 | # 28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 29 | 30 | # Use the `preload_app!` method when specifying a `workers` number. 31 | # This directive tells Puma to first boot the application and load code 32 | # before forking the application. This takes advantage of Copy On Write 33 | # process behavior so workers use less memory. 34 | # 35 | # preload_app! 36 | 37 | # Allow puma to be restarted by `rails restart` command. 38 | plugin :tmp_restart 39 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount RailsLocalAnalytics::Engine, at: "/analytics" 3 | root to: "application#example_action" 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20241130223207_create_analytics_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateAnalyticsTables < ActiveRecord::Migration[6.0] 2 | def up 3 | create_table :tracked_requests_by_day_page do |t| 4 | t.date :day, null: false 5 | t.bigint :total, null: false, default: 1 6 | t.string :url_hostname, null: false 7 | t.string :url_path, null: false 8 | t.string :referrer_hostname 9 | t.string :referrer_path 10 | end 11 | add_index :tracked_requests_by_day_page, :day 12 | add_index :tracked_requests_by_day_page, [:url_hostname, :url_path, :day], name: "index_tracked_requests_by_day_page_on_day_and_url" 13 | 14 | create_table :tracked_requests_by_day_site do |t| 15 | t.date :day, null: false 16 | t.bigint :total, null: false, default: 1 17 | t.string :url_hostname, null: false 18 | t.string :platform 19 | t.string :browser_engine 20 | end 21 | add_index :tracked_requests_by_day_site, :day 22 | end 23 | 24 | def down 25 | drop_table :tracked_requests_by_day_page 26 | drop_table :tracked_requests_by_day_site 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/dummy/db/seeds.rb: -------------------------------------------------------------------------------- 1 | site = Set.new 2 | page = Set.new 3 | 4 | hostname_options = ["example.com", "other-site.com", "some-product.example.com"] 5 | platform_options = ["macOS", "Generic Linux", "Windows", "Android", "iOS (iPhone)", "iOS (iPad)"] 6 | browser_engine_options = ["blink", "gecko", "webkit"] 7 | 8 | [Date.today, (Date.today - 1.day)].each do |day| 9 | 100_000.times.each do 10 | site << { 11 | day: day, 12 | url_hostname: hostname_options.sample, 13 | platform: platform_options.sample, 14 | browser_engine: browser_engine_options.sample, 15 | } 16 | 17 | page << { 18 | day: day, 19 | url_hostname: hostname_options.sample, 20 | url_path: "/posts/#{SecureRandom.hex(3)}/", 21 | referrer_hostname: hostname_options.sample, 22 | referrer_path: "/posts/#{SecureRandom.hex(3)}/", 23 | } 24 | end 25 | end 26 | 27 | site = site.to_a 28 | page = page.to_a 29 | 30 | [site, page].each do |list| 31 | list.each_with_index do |entry, index| 32 | list[index] = entry.merge(total: rand(1000)) 33 | end 34 | end 35 | 36 | 37 | TrackedRequestsByDaySite.insert_all(site) 38 | TrackedRequestsByDayPage.insert_all(page) 39 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/rails_local_analytics/c18d5fde6b2528707c61053dd78d8e7cbdf2dfd3/spec/dummy/public/favicon.ico -------------------------------------------------------------------------------- /spec/model/rails_local_analytics_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe RailsLocalAnalytics, type: :model do 4 | include ActiveJob::TestHelper 5 | 6 | it "exposes a version" do 7 | expect(described_class::VERSION).to match(/\d\.\d\.\d/) 8 | end 9 | 10 | context "config.background_jobs" do 11 | it "defaults to true" do 12 | expect(described_class.config.background_jobs).to eq(true) 13 | end 14 | 15 | it "stores a boolean value" do 16 | described_class.config.background_jobs = false 17 | expect(described_class.config.background_jobs).to eq(false) 18 | 19 | described_class.config.background_jobs = "foo" 20 | expect(described_class.config.background_jobs).to eq(true) 21 | 22 | described_class.config.background_jobs = true 23 | expect(described_class.config.background_jobs).to eq(true) 24 | end 25 | end 26 | 27 | context "record_request" do 28 | it "saves to database" do 29 | described_class.record_request( 30 | request: { 31 | host: "http://example.com", 32 | path: "/some/path", 33 | referrer: "http://example.com/some/other/path", 34 | user_agent: "some-user-agent", 35 | http_accept_language: "some-http-accept-language", 36 | }, 37 | ) 38 | 39 | perform_enqueued_jobs 40 | 41 | expect(TrackedRequestsByDaySite.last.attributes.except("id")).to eq({ 42 | "browser_engine" => nil, 43 | "day" => Date.today, 44 | "platform" => "Unknown", 45 | "total" => 1, 46 | "url_hostname" => "http://example.com", 47 | }) 48 | 49 | expect(TrackedRequestsByDayPage.last.attributes.except("id")).to eq({ 50 | "day" => Date.today, 51 | "referrer_hostname" => "example.com", 52 | "referrer_path" => "/some/other/path", 53 | "total" => 1, 54 | "url_hostname" => "http://example.com", 55 | "url_path" => "/some/path", 56 | }) 57 | end 58 | 59 | it "allow custom attributes" do 60 | described_class.record_request( 61 | request: { 62 | host: "http://example.com", 63 | path: "/some/path", 64 | referrer: "http://example.com/some/other/path", 65 | user_agent: "some-user-agent", 66 | http_accept_language: "some-http-accept-language", 67 | }, 68 | custom_attributes: { 69 | site: { 70 | platform: "foo", 71 | }, 72 | page: { 73 | referrer_hostname: "bar", 74 | }, 75 | }, 76 | ) 77 | 78 | perform_enqueued_jobs 79 | 80 | 81 | expect(TrackedRequestsByDaySite.last.platform).to eq("foo") 82 | expect(TrackedRequestsByDayPage.last.referrer_hostname).to eq("bar") 83 | end 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | require_relative 'dummy/config/environment' 6 | 7 | abort("The Rails environment is running in production mode!") if Rails.env.production? 8 | 9 | require 'rspec/rails' 10 | 11 | begin 12 | ActiveRecord::Migration.maintain_test_schema! 13 | rescue ActiveRecord::PendingMigrationError => e 14 | puts e.to_s.strip 15 | exit 1 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.use_transactional_fixtures = true 20 | 21 | config.infer_spec_type_from_file_location! 22 | 23 | config.filter_rails_from_backtrace! 24 | end 25 | 26 | if Rails::VERSION::STRING.to_f <= 6.0 27 | def assert_difference(expression, *args, &block) 28 | expressions = 29 | if expression.is_a?(Hash) 30 | message = args[0] 31 | expression 32 | else 33 | difference = args[0] || 1 34 | message = args[1] 35 | Array(expression).index_with(difference) 36 | end 37 | 38 | exps = expressions.keys.map { |e| 39 | e.respond_to?(:call) ? e : lambda { eval(e, block.binding) } 40 | } 41 | before = exps.map(&:call) 42 | 43 | retval = assert_nothing_raised(&block) 44 | 45 | expressions.zip(exps, before) do |(code, diff), exp, before_value| 46 | actual = exp.call 47 | error = "#{code.inspect} didn't change by #{diff}, but by #{actual - before_value}" 48 | error = "#{message}.\n#{error}" if message 49 | assert_equal(before_value + diff, actual, error) 50 | end 51 | 52 | retval 53 | end 54 | 55 | def assert_nothing_raised 56 | yield.tap { assert(true) } 57 | rescue => error 58 | raise Minitest::UnexpectedError.new(error) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/request/dashboard_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe RailsLocalAnalytics::DashboardController, type: :request do 4 | context "root" do 5 | it "redirects" do 6 | get rails_local_analytics.root_path 7 | expect(response).to redirect_to(rails_local_analytics.tracked_requests_path(type: :page)) 8 | end 9 | end 10 | 11 | context "index" do 12 | before(:all) do 13 | 2.times.each do 14 | TrackedRequestsByDaySite.create!( 15 | day: Date.today, 16 | url_hostname: "foo", 17 | ) 18 | TrackedRequestsByDayPage.create!( 19 | day: Date.today, 20 | url_hostname: "foo", 21 | url_path: "bar", 22 | ) 23 | end 24 | end 25 | 26 | it "renders" do 27 | get rails_local_analytics.tracked_requests_path(type: :foo) 28 | expect(response.status).to eq(404) 29 | end 30 | 31 | it "renders with type param" do 32 | get rails_local_analytics.tracked_requests_path(type: :site) 33 | expect(response.status).to eq(200) 34 | 35 | get rails_local_analytics.tracked_requests_path(type: :page) 36 | expect(response.status).to eq(200) 37 | end 38 | 39 | it "renders with start_date param" do 40 | get rails_local_analytics.tracked_requests_path(type: :site, start_date: 3.days.ago.to_date) 41 | expect(response.status).to eq(200) 42 | 43 | get rails_local_analytics.tracked_requests_path(type: :page, start_date: 3.days.ago.to_date) 44 | expect(response.status).to eq(200) 45 | end 46 | 47 | it "renders with end_date param" do 48 | get rails_local_analytics.tracked_requests_path(type: :site, end_date: 3.days.ago.to_date) 49 | expect(response.status).to eq(200) 50 | 51 | get rails_local_analytics.tracked_requests_path(type: :page, end_date: 3.days.ago.to_date) 52 | expect(response.status).to eq(200) 53 | end 54 | 55 | it "renders with search param" do 56 | get rails_local_analytics.tracked_requests_path(type: :site, search: "foo") 57 | expect(response.status).to eq(200) 58 | 59 | get rails_local_analytics.tracked_requests_path(type: :site, search: "foo bar") 60 | expect(response.status).to eq(200) 61 | 62 | get rails_local_analytics.tracked_requests_path(type: :page, search: "foo") 63 | expect(response.status).to eq(200) 64 | 65 | get rails_local_analytics.tracked_requests_path(type: :page, search: "foo bar") 66 | expect(response.status).to eq(200) 67 | end 68 | 69 | context "params[:group_by]" do 70 | it "raises error when field name is invalid" do 71 | expect { 72 | get rails_local_analytics.tracked_requests_path(type: :site, group_by: "id==some-value") 73 | }.to raise_error(ArgumentError) 74 | end 75 | 76 | it "groups the data" do 77 | get rails_local_analytics.tracked_requests_path(type: :site, group_by: "platform") 78 | expect(response.status).to eq(200) 79 | expect(response.body).to have_tag(:th, text: /Platform/) 80 | expect(response.body).not_to have_tag(:th, text: /Browser Engine/) 81 | 82 | get rails_local_analytics.tracked_requests_path(type: :page, group_by: "referrer_path") 83 | expect(response.status).to eq(200) 84 | expect(response.body).to have_tag(:th, text: /Referrer Path/) 85 | expect(response.body).not_to have_tag(:th, text: /Referrrer Hostname/) 86 | end 87 | 88 | it "handles special fields" do 89 | expect { 90 | get rails_local_analytics.tracked_requests_path(type: :site, group_by: "url_hostname_and_path") 91 | }.to raise_error(ArgumentError) 92 | 93 | expect { 94 | get rails_local_analytics.tracked_requests_path(type: :site, group_by: "referrer_hostname_and_path") 95 | }.to raise_error(ArgumentError) 96 | 97 | get rails_local_analytics.tracked_requests_path(type: :page, group_by: "url_hostname_and_path") 98 | expect(response.status).to eq(200) 99 | expect(response.body).to have_tag(:th, text: /URL Hostname/) 100 | expect(response.body).to have_tag(:th, text: /URL Path/) 101 | expect(response.body).not_to have_tag(:th, text: /Referrer Hostname/) 102 | 103 | get rails_local_analytics.tracked_requests_path(type: :page, group_by: "referrer_hostname_and_path") 104 | expect(response.status).to eq(200) 105 | expect(response.body).to have_tag(:th, text: /Referrer Hostname/) 106 | expect(response.body).to have_tag(:th, text: /Referrer Path/) 107 | expect(response.body).not_to have_tag(:th, text: /URL Hostname/) 108 | end 109 | end 110 | 111 | context "params[:filter]" do 112 | it "raises error when field name is invalid" do 113 | expect { 114 | get rails_local_analytics.tracked_requests_path(type: :site, filter: "id==some-value") 115 | }.to raise_error(ArgumentError) 116 | end 117 | 118 | it "filters on specific field/value combos" do 119 | klass = TrackedRequestsByDaySite 120 | 121 | col = klass.display_columns.first 122 | 123 | klass.create!( 124 | day: Date.today, 125 | col => "some-value", 126 | platform: "foo", 127 | ) 128 | 129 | klass.create!( 130 | day: Date.today, 131 | col => "foo", 132 | ) 133 | 134 | klass.create!( 135 | day: Date.today, 136 | col => "some-value", 137 | platform: "bar", 138 | ) 139 | 140 | get rails_local_analytics.tracked_requests_path(type: :site, filter: "#{col}==some-value") 141 | expect(response.status).to eq(200) 142 | expect(assigns(:results).map(&:first)).to eq(["some-value", "some-value"]) 143 | end 144 | end 145 | end 146 | 147 | context "difference" do 148 | it "raises 404 for non-json requests" do 149 | expect { 150 | get rails_local_analytics.difference_tracked_requests_path(format: :html, type: :site, start_date: Date.today, end_date: Date.today, conditions: {url_hostname: "foo"}) 151 | }.to raise_error(ActionController::RoutingError) 152 | end 153 | 154 | it "works when date range is a single day" do 155 | [Date.today, (Date.today - 1.day)].each do |day| 156 | TrackedRequestsByDaySite.create!( 157 | day: day, 158 | url_hostname: "foo", 159 | total: (day == Date.today ? 5 : 1) 160 | ) 161 | 162 | TrackedRequestsByDaySite.create!( 163 | day: day, 164 | url_hostname: "bar", 165 | ) 166 | end 167 | 168 | get rails_local_analytics.difference_tracked_requests_path(format: :json, type: :site, start_date: Date.today, end_date: Date.today, conditions: {url_hostname: "foo"}) 169 | expect(response.status).to eq(200) 170 | expect(response.parsed_body).to eq({"difference" => 4}) 171 | end 172 | 173 | it "works when date range spans multiple days" do 174 | [Date.today, (Date.today - 1.day), 2.days.ago.to_date, 3.days.ago.to_date].each do |day| 175 | TrackedRequestsByDaySite.create!( 176 | day: day, 177 | url_hostname: "foo", 178 | total: (day == Date.today ? 20 : 5) 179 | ) 180 | 181 | TrackedRequestsByDaySite.create!( 182 | day: day, 183 | url_hostname: "bar", 184 | total: 1, 185 | ) 186 | end 187 | 188 | get rails_local_analytics.difference_tracked_requests_path(format: :json, type: :site, start_date: (Date.today - 1.day), end_date: Date.today, conditions: {url_hostname: "foo"}) 189 | expect(response.status).to eq(200) 190 | expect(response.parsed_body).to eq({"difference" => 15}) 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /spec/request/record_request_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe "RailsLocalAnalytics#record_request", type: :request do 4 | include ActiveJob::TestHelper 5 | 6 | it "saves to database using normal request object" do 7 | get root_path 8 | expect(response.status).to eq(200) 9 | 10 | perform_enqueued_jobs 11 | 12 | expect(TrackedRequestsByDaySite.last.attributes.except("id")).to eq({ 13 | "browser_engine" => nil, 14 | "day" => Date.today, 15 | "platform" => nil, 16 | "total" => 1, 17 | "url_hostname" => "www.example.com", 18 | }) 19 | 20 | expect(TrackedRequestsByDayPage.last.attributes.except("id")).to eq({ 21 | "day" => Date.today, 22 | "referrer_hostname" => nil, 23 | "referrer_path" => nil, 24 | "total" => 1, 25 | "url_hostname" => "www.example.com", 26 | "url_path" => "/", 27 | }) 28 | end 29 | 30 | it "works with config.background_jobs = false" do 31 | RailsLocalAnalytics.config.background_jobs = false 32 | 33 | get root_path 34 | expect(response.status).to eq(200) 35 | 36 | expect(TrackedRequestsByDaySite.last.attributes.except("id")).to eq({ 37 | "browser_engine" => nil, 38 | "day" => Date.today, 39 | "platform" => nil, 40 | "total" => 1, 41 | "url_hostname" => "www.example.com", 42 | }) 43 | 44 | expect(TrackedRequestsByDayPage.last.attributes.except("id")).to eq({ 45 | "day" => Date.today, 46 | "referrer_hostname" => nil, 47 | "referrer_path" => nil, 48 | "total" => 1, 49 | "url_hostname" => "www.example.com", 50 | "url_path" => "/", 51 | }) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | require 'database_cleaner' 3 | require "rspec-html-matchers" 4 | 5 | RSpec.configure do |config| 6 | 7 | config.expect_with :rspec do |expectations| 8 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 9 | end 10 | 11 | config.mock_with :rspec do |mocks| 12 | mocks.verify_partial_doubles = true 13 | end 14 | 15 | config.shared_context_metadata_behavior = :apply_to_host_groups 16 | 17 | config.example_status_persistence_file_path = "spec/examples.txt" 18 | 19 | config.disable_monkey_patching! 20 | 21 | #if config.files_to_run.one? 22 | # config.default_formatter = "doc" 23 | #end 24 | 25 | config.order = :random 26 | 27 | Kernel.srand(config.seed) 28 | 29 | config.before(:suite) do 30 | DatabaseCleaner.clean_with(:truncation) # perform initial cleaning before starting 31 | 32 | DatabaseCleaner.strategy = :truncation 33 | end 34 | 35 | config.around(:each) do |example| 36 | DatabaseCleaner.cleaning do 37 | example.run 38 | end 39 | 40 | RailsLocalAnalytics::Config::DEFAULTS.each do |k,v| 41 | RailsLocalAnalytics.config.send("#{k}=", v) 42 | end 43 | end 44 | 45 | require 'rails-controller-testing' 46 | RSpec.configure do |config| 47 | [:controller, :view, :request].each do |type| 48 | config.include Rails::Controller::Testing::TestProcess, type: type 49 | config.include Rails::Controller::Testing::TemplateAssertions, type: type 50 | config.include Rails::Controller::Testing::Integration, type: type 51 | config.include RSpecHtmlMatchers, type: type 52 | end 53 | end 54 | 55 | end 56 | --------------------------------------------------------------------------------