├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── .yardopts ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── bundle ├── coderay ├── minitest ├── pry ├── rackup ├── rake ├── rspec └── sequel ├── example ├── Gemfile ├── Gemfile.lock └── config.ru ├── lib ├── roda │ ├── enhanced_logger │ │ ├── current.rb │ │ └── instance.rb │ └── plugins │ │ └── enhanced_logger.rb └── sequel │ └── extensions │ └── enhanced_logger.rb ├── roda-enhanced_logger.gemspec ├── roda-enhanced_logger.png └── spec ├── config.ru ├── enhanced_logger_spec.rb └── spec_helper.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: 14 | - '2.7' 15 | - '3.0' 16 | - '3.1' 17 | - '3.2' 18 | - '3.3' 19 | - '3.4' 20 | name: ${{ matrix.ruby }} 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | - name: Run tests 29 | run: bundle exec rspec 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.yardoc 2 | /spec/examples.txt 3 | /Gemfile.lock 4 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --asset roda-enhanced_logger.png:roda-enhanced_logger.png 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | gemspec 8 | 9 | gem "pry" 10 | gem "rake" 11 | gem "rspec" 12 | gem "sequel", "~> 5.24" 13 | gem "standard" 14 | gem "warning" 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Adam Daniels 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roda Enhanced Logger 2 | 3 | A powerful logger for Roda with a few tricks up it's sleeve. 4 | 5 | - Coloured output per level 6 | - Structured output of query params 7 | - Tracking of database query time and count 8 | - Tracking of blocks which handle path segment 9 | - Tracing missed requests 10 | - Tracing all requests 11 | 12 | ![Enhanced Logger Output](roda-enhanced_logger.png) 13 | 14 | ## Installation 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | gem "roda-enhanced_logger" 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install roda-enhanced_logger 27 | 28 | ## Usage 29 | 30 | For basic usage, simply enable through the `plugin` mechanism. 31 | 32 | 33 | ```ruby 34 | class App < Roda 35 | plugin :enhanced_logger 36 | end 37 | ``` 38 | 39 | If you serve assets through Roda, your logs might be fairly noisy, so you can 40 | filter them. 41 | 42 | ```ruby 43 | plugin :enhanced_logger, filter: ->(path) { path.start_with?("/assets") } 44 | ``` 45 | 46 | By default, EnhancedLogger will attempt to filter passwords and CSRF tokens, 47 | but you can filter other fields too. 48 | 49 | ```ruby 50 | plugin :enhanced_logger, filtered_params: %w[api_key] 51 | ``` 52 | 53 | If there's a `DB` constant defined for Sequel, EnhancedLogger will automatically 54 | use it, but you can pass in a custom value if necessary. 55 | 56 | ```ruby 57 | plugin :enhanced_logger, db: Container[:db] 58 | ``` 59 | 60 | During development, a 404 might catch you off guard for a path that you feel should 61 | exist, so it's handy to trace missed routes to aide in debugging. 62 | 63 | ```ruby 64 | plugin :enhanced_logger, trace_missed: true 65 | ``` 66 | 67 | Or always trace every request. 68 | 69 | ```ruby 70 | plugin :enhanced_logger, trace_all: true 71 | ``` 72 | 73 | ## Contributing 74 | 75 | Bug reports and pull requests are welcome on GitHub at https://github.com/adam12/roda-enhanced_logger. 76 | 77 | I love pull requests! If you fork this project and modify it, please ping me to see 78 | if your changes can be incorporated back into this project. 79 | 80 | That said, if your feature idea is nontrivial, you should probably open an issue to 81 | [discuss it](http://www.igvita.com/2011/12/19/dont-push-your-pull-requests/) 82 | before attempting a pull request. 83 | 84 | ## License 85 | 86 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 || ">= 0.a" 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= begin 65 | env_var_version || cli_arg_version || 66 | lockfile_version || "#{Gem::Requirement.default}.a" 67 | end 68 | end 69 | 70 | def load_bundler! 71 | ENV["BUNDLE_GEMFILE"] ||= gemfile 72 | 73 | # must dup string for RG < 1.8 compatibility 74 | activate_bundler(bundler_version.dup) 75 | end 76 | 77 | def activate_bundler(bundler_version) 78 | if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new("2.0") 79 | bundler_version = "< 2" 80 | end 81 | gem_error = activation_error_handling do 82 | gem "bundler", bundler_version 83 | end 84 | return if gem_error.nil? 85 | require_error = activation_error_handling do 86 | require "bundler/version" 87 | end 88 | return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 89 | warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" 90 | exit 42 91 | end 92 | 93 | def activation_error_handling 94 | yield 95 | nil 96 | rescue StandardError, LoadError => e 97 | e 98 | end 99 | end 100 | 101 | m.load_bundler! 102 | 103 | if m.invoked_as_script? 104 | load Gem.bin_path("bundler", "bundle") 105 | end 106 | -------------------------------------------------------------------------------- /bin/coderay: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'coderay' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("coderay", "coderay") 30 | -------------------------------------------------------------------------------- /bin/minitest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'minitest' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("minitest-sprint", "minitest") 30 | -------------------------------------------------------------------------------- /bin/pry: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pry' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("pry", "pry") 30 | -------------------------------------------------------------------------------- /bin/rackup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rackup' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rack", "rackup") 30 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/sequel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'sequel' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("sequel", "sequel") 30 | -------------------------------------------------------------------------------- /example/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "roda" 4 | gem "sequel" 5 | gem "roda-enhanced_logger", path: ".." 6 | 7 | gem "webrick", "~> 1.7" 8 | 9 | gem "rackup", "~> 2.0" 10 | -------------------------------------------------------------------------------- /example/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | roda-enhanced_logger (0.5.0) 5 | roda (~> 3.19) 6 | tty-logger (>= 0.3, < 1.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | pastel (0.8.0) 12 | tty-color (~> 0.5) 13 | rack (3.0.16) 14 | rackup (2.0.0) 15 | rack (>= 3) 16 | webrick 17 | roda (3.57.0) 18 | rack 19 | sequel (5.58.0) 20 | tty-color (0.6.0) 21 | tty-logger (0.6.0) 22 | pastel (~> 0.8) 23 | webrick (1.7.0) 24 | 25 | PLATFORMS 26 | ruby 27 | 28 | DEPENDENCIES 29 | rackup (~> 2.0) 30 | roda 31 | roda-enhanced_logger! 32 | sequel 33 | webrick (~> 1.7) 34 | 35 | BUNDLED WITH 36 | 2.1.4 37 | -------------------------------------------------------------------------------- /example/config.ru: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require "roda" 4 | require "sequel" 5 | 6 | DB = Sequel.mock 7 | 8 | class App < Roda 9 | plugin :enhanced_logger, 10 | filter: ->(path) { path.start_with?("/favicon.ico") }, 11 | trace_missed: true, 12 | log_time: true 13 | 14 | route do |r| 15 | r.on "foo" do 16 | r.on "bar" do 17 | r.is "baz" do 18 | "foo/bar/baz" 19 | end 20 | end 21 | end 22 | 23 | r.root do 24 | DB[:foos].to_a 25 | "OK" 26 | end 27 | end 28 | end 29 | 30 | run App 31 | -------------------------------------------------------------------------------- /lib/roda/enhanced_logger/current.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require "roda" 4 | 5 | class Roda 6 | module EnhancedLogger 7 | ## 8 | # Data collection for request in current thread 9 | module Current 10 | extend self 11 | 12 | # Increment the accrued database time 13 | # @param value [Numeric] 14 | # the value to increment 15 | # @return [Numeric] 16 | # the updated value 17 | def increment_accrued_database_time(value) 18 | Thread.current[:accrued_database_time] ||= 0 19 | Thread.current[:accrued_database_time] += value 20 | end 21 | 22 | # The accrued database time 23 | # @return [Numeric] 24 | def accrued_database_time 25 | Thread.current[:accrued_database_time] 26 | end 27 | 28 | # Set accrued database time 29 | # @param value [Numeric] 30 | # the value to set 31 | # @return [Numeric] 32 | # the new value 33 | def accrued_database_time=(value) 34 | Thread.current[:accrued_database_time] = value 35 | end 36 | 37 | def increment_database_query_count(value = 1) 38 | Thread.current[:database_query_count] ||= 0 39 | Thread.current[:database_query_count] += value 40 | end 41 | 42 | def database_query_count 43 | Thread.current[:database_query_count] 44 | end 45 | 46 | def database_query_count=(value) 47 | Thread.current[:database_query_count] = value 48 | end 49 | 50 | # Reset the counters 51 | def reset 52 | self.accrued_database_time = nil 53 | self.database_query_count = nil 54 | true 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/roda/enhanced_logger/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require "pathname" 4 | require "roda" 5 | 6 | class Roda 7 | module EnhancedLogger 8 | ## 9 | # Logger instance for this application 10 | class Instance 11 | # Application root 12 | attr_reader :root 13 | 14 | # Log entries generated during request 15 | attr_reader :log_entries 16 | 17 | # Logger instance 18 | attr_reader :logger 19 | 20 | # Route matches during request 21 | attr_reader :matches 22 | 23 | attr_reader :timer 24 | private :timer 25 | 26 | # Callable object to filter log entries 27 | attr_reader :filter 28 | 29 | def initialize(logger, env, instance_id, root, filter) 30 | @logger = logger 31 | @root = root 32 | @log_entries = [] 33 | @matches = [] 34 | @timer = Process.clock_gettime(Process::CLOCK_MONOTONIC) 35 | @filter = filter || proc { false } 36 | if env["enhanced_logger_id"].nil? 37 | @primary = true 38 | env["enhanced_logger_id"] = instance_id 39 | else 40 | @primary = false 41 | end 42 | end 43 | 44 | # Add a matched route handler 45 | def add_match(caller) 46 | @matches << caller 47 | end 48 | 49 | # Add log entry for request 50 | # @param status [Integer] 51 | # status code for the response 52 | # @param request [Roda::RodaRequest] 53 | # request object 54 | # @param trace [Boolean] 55 | # tracing was enabled 56 | def add(status, request, trace = false) 57 | if (last_matched_caller = matches.last) 58 | handler = format("%s:%d", 59 | Pathname(last_matched_caller.path).relative_path_from(root), 60 | last_matched_caller.lineno) 61 | end 62 | 63 | meth = 64 | case status 65 | when 400..499 66 | :warn 67 | when 500..599 68 | :error 69 | else 70 | :info 71 | end 72 | 73 | data = { 74 | duration: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - timer).round(4), 75 | status: status, 76 | verb: request.request_method, 77 | path: request.path, 78 | remaining_path: request.remaining_path, 79 | handler: handler, 80 | params: request.params 81 | } 82 | 83 | if (db = Roda::EnhancedLogger::Current.accrued_database_time) 84 | data[:db] = db.round(6) 85 | end 86 | 87 | if (query_count = Roda::EnhancedLogger::Current.database_query_count) 88 | data[:db_queries] = query_count 89 | end 90 | 91 | if trace 92 | matches.each do |match| 93 | add_log_entry([meth, format(" %s (%s:%s)", 94 | File.readlines(match.path)[match.lineno - 1].strip.sub(" do", ""), 95 | Pathname(match.path).relative_path_from(root), 96 | match.lineno)]) 97 | end 98 | end 99 | 100 | return if filter.call(request.path) 101 | 102 | add_log_entry([meth, "#{request.request_method} #{request.path}", data]) 103 | end 104 | 105 | # This instance is the primary logger 106 | # @return [Boolean] 107 | def primary? 108 | @primary 109 | end 110 | 111 | # Drain the log entry queue, writing each to the logger at their respective level 112 | # @return [Boolean] 113 | def drain 114 | return unless primary? 115 | 116 | log_entries.each do |args| 117 | logger.public_send(*args) 118 | end 119 | 120 | true 121 | end 122 | 123 | # Reset the counters for this thread 124 | # @return [Boolean] 125 | def reset 126 | Roda::EnhancedLogger::Current.reset 127 | end 128 | 129 | private 130 | 131 | def add_log_entry(record) 132 | @log_entries << record 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/roda/plugins/enhanced_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require "pathname" 4 | require "tty-logger" 5 | require "roda/enhanced_logger/current" 6 | require "roda/enhanced_logger/instance" 7 | 8 | class Roda 9 | module RodaPlugins 10 | # The +enhanced_logger+ plugin provides a coloured, single line log 11 | # entry for requests in a Roda application. 12 | # 13 | # Some interesting pieces of the log entry include which line matched the request, 14 | # any time incurred by Sequel DB queries, and the remaining path that might have 15 | # not been matched. 16 | # 17 | # It's mostly suitable in development but would likely be fine in production. 18 | # 19 | # @example Basic configuration 20 | # plugin :enhanced_logger 21 | # 22 | # @example Filter requests to assets 23 | # plugin :enahanced_logger, filter: ->(path) { path.start_with?("/assets") } 24 | # 25 | # @example Filter parameters 26 | # plugin :enhanced_logger, filtered_params: %i[api_key] 27 | # 28 | # @example Log date and time of request 29 | # plugin :enhanced_logger, log_time: true 30 | module EnhancedLogger 31 | DEFAULTS = { 32 | db: nil, 33 | log_time: false, 34 | trace_missed: true, 35 | trace_all: false, 36 | filtered_params: %w[password password_confirmation _csrf], 37 | handlers: [:console] 38 | }.freeze 39 | 40 | def self.load_dependencies(app, _opts = {}) 41 | app.plugin :hooks 42 | app.plugin :match_hook 43 | end 44 | 45 | def self.configure(app, opts = {}) 46 | options = DEFAULTS.merge(opts) 47 | 48 | logger = TTY::Logger.new { |config| 49 | config.handlers = options[:handlers] 50 | config.output = options.fetch(:output) { $stdout } 51 | config.metadata.push(:time, :date) if options[:log_time] 52 | config.filters.data = options[:filtered_params] 53 | config.filters.mask = "" 54 | } 55 | 56 | root = Pathname(app.opts[:root] || Dir.pwd) 57 | 58 | db = options[:db] || (defined?(DB) && DB) 59 | db&.extension :enhanced_logger 60 | 61 | app.match_hook do 62 | callee = caller_locations.find { |location| 63 | location.path.start_with?(root.to_s) 64 | } 65 | 66 | @_enhanced_logger_instance.add_match(callee) 67 | end 68 | 69 | app.before do 70 | @_enhanced_logger_instance = Roda::EnhancedLogger::Instance.new(logger, env, object_id, root, options[:filter]) 71 | end 72 | 73 | app.after do |res| 74 | status, _ = res 75 | @_enhanced_logger_instance.add( 76 | status, 77 | request, 78 | (options[:trace_missed] && status == 404) || options[:trace_all] 79 | ) 80 | 81 | @_enhanced_logger_instance.drain 82 | @_enhanced_logger_instance.reset 83 | end 84 | end 85 | end 86 | 87 | register_plugin :enhanced_logger, EnhancedLogger 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/sequel/extensions/enhanced_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen-string-literal: true 2 | 3 | require "sequel" 4 | 5 | module EnhancedLogger 6 | module Sequel 7 | if ::Sequel::VERSION_NUMBER >= 50240 8 | def skip_logging? 9 | false 10 | end 11 | else 12 | def self.extended(base) 13 | return if base.loggers.any? 14 | 15 | require "logger" 16 | base.loggers = [Logger.new("/dev/null")] 17 | end 18 | end 19 | 20 | def log_duration(duration, _message) 21 | Roda::EnhancedLogger::Current.increment_accrued_database_time(duration) 22 | Roda::EnhancedLogger::Current.increment_database_query_count 23 | 24 | super 25 | end 26 | end 27 | 28 | ::Sequel::Database.register_extension :enhanced_logger, EnhancedLogger::Sequel 29 | end 30 | -------------------------------------------------------------------------------- /roda-enhanced_logger.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |spec| 2 | spec.name = "roda-enhanced_logger" 3 | spec.version = "0.5.0" 4 | spec.authors = ["Adam Daniels"] 5 | spec.email = "adam@mediadrive.ca" 6 | 7 | spec.summary = "An enhanced logger for Roda applications" 8 | spec.homepage = "https://github.com/adam12/roda-enhanced_logger" 9 | spec.license = "MIT" 10 | 11 | spec.files = ["README.md"] + Dir["lib/**/*.rb"] 12 | 13 | spec.add_dependency "roda", "~> 3.19" 14 | spec.add_dependency "tty-logger", ">= 0.3", "< 1.0" 15 | 16 | spec.required_ruby_version = ">= 2.1" 17 | end 18 | -------------------------------------------------------------------------------- /roda-enhanced_logger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adam12/roda-enhanced_logger/3ca34fed9bfe0f10d3a2818147a9b66613f0edea/roda-enhanced_logger.png -------------------------------------------------------------------------------- /spec/config.ru: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "roda" 3 | require "sequel" 4 | 5 | DB = Sequel.connect("mock://") 6 | 7 | app = Class.new(Roda) { 8 | plugin :enhanced_logger, trace_all: true 9 | 10 | route do |r| 11 | r.is "foo", method: :get do 12 | 2.times { DB[:table].to_a } 13 | 14 | "foo" 15 | end 16 | 17 | r.is "bar", method: :get do 18 | "bar" 19 | end 20 | 21 | r.on "baz" do 22 | "baz" 23 | end 24 | 25 | r.on "a" do 26 | r.on "b" do 27 | r.on "c" do 28 | r.is "d" do 29 | "a/b/c/d" 30 | end 31 | end 32 | end 33 | end 34 | end 35 | } 36 | 37 | run app 38 | -------------------------------------------------------------------------------- /spec/enhanced_logger_spec.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "roda" 3 | require "tty-logger" 4 | require "sequel" 5 | require "rack/mock_request" 6 | require "rack/mock_response" 7 | 8 | require "roda/plugins/enhanced_logger" 9 | 10 | RSpec.describe Roda::RodaPlugins::EnhancedLogger do 11 | it "filters log entries" do 12 | expect { 13 | app = Class.new(Roda) { 14 | plugin :enhanced_logger, filter: ->(path) { 15 | path.match?(/assets/) 16 | } 17 | 18 | route do |r| 19 | r.is "assets" do 20 | "assets" 21 | end 22 | 23 | "OK" 24 | end 25 | } 26 | 27 | response = Rack::MockRequest.new(app).get("/assets") 28 | expect(response.body).to eq("assets") 29 | }.to_not output.to_stdout 30 | end 31 | 32 | it "can log time" do 33 | output = StringIO.new 34 | 35 | app = Class.new(Roda) { 36 | plugin :enhanced_logger, log_time: true, output: output 37 | 38 | route do |r| 39 | "OK" 40 | end 41 | } 42 | 43 | time_now = Time.new(2022, 6, 4, 8, 30) 44 | allow(Time).to receive(:now).and_return(time_now) 45 | response = Rack::MockRequest.new(app).get("/") 46 | expect(response.body).to eq("OK") 47 | 48 | expect(output.string).to match "08:30:00.000" 49 | expect(output.string).to match "2022-06-04" 50 | end 51 | 52 | it "logs to stdout by default" do 53 | expect { 54 | app = Class.new(Roda) { 55 | plugin :enhanced_logger 56 | 57 | route do |r| 58 | "OK" 59 | end 60 | } 61 | 62 | response = Rack::MockRequest.new(app).get("/") 63 | expect(response.body).to eq("OK") 64 | }.to output.to_stdout 65 | end 66 | 67 | describe "database logging" do 68 | it "allows custom database object" do 69 | db = Sequel.mock 70 | 71 | app = Class.new(Roda) { 72 | plugin :enhanced_logger, db: db, output: File.new(File::NULL, "w") 73 | 74 | route do |r| 75 | db[:foos].to_a 76 | "OK" 77 | end 78 | } 79 | 80 | response = Rack::MockRequest.new(app).get("/") 81 | 82 | expect(response.body).to eq("OK") 83 | expect(db.sqls).to_not be_empty 84 | end 85 | 86 | it "records accrued database time" do 87 | output = StringIO.new 88 | db = Sequel.mock 89 | 90 | app = Class.new(Roda) { 91 | plugin :enhanced_logger, db: db, handlers: [[:stream, output: output]] 92 | 93 | route do |r| 94 | db[:foos].to_a 95 | "OK" 96 | end 97 | } 98 | 99 | Rack::MockRequest.new(app).get("/") 100 | 101 | expect(output.string).to match(/db=\d+/) 102 | end 103 | 104 | it "records number of queries" do 105 | output = StringIO.new 106 | db = Sequel.mock 107 | 108 | app = Class.new(Roda) { 109 | plugin :enhanced_logger, db: db, handlers: [[:stream, output: output]] 110 | 111 | route do |r| 112 | db[:foos].to_a 113 | "OK" 114 | end 115 | } 116 | 117 | Rack::MockRequest.new(app).get("/") 118 | 119 | expect(output.string).to match(/db_queries=1/) 120 | end 121 | end 122 | 123 | describe "filtered params" do 124 | it "has a default filtered params list" do 125 | output = StringIO.new 126 | app = Class.new(Roda) { 127 | plugin :enhanced_logger, handlers: [[:stream, output: output]] 128 | 129 | route do |r| 130 | "OK" 131 | end 132 | } 133 | 134 | Rack::MockRequest.new(app).post("/", params: {password: "secret"}) 135 | 136 | expect(output.string).to match(/password=/) 137 | end 138 | 139 | it "allows customization of filtered params list" do 140 | output = StringIO.new 141 | 142 | app = Class.new(Roda) { 143 | plugin :enhanced_logger, filtered_params: %i[first_name], 144 | handlers: [[:stream, output: output]] 145 | 146 | route do |r| 147 | "OK" 148 | end 149 | } 150 | 151 | Rack::MockRequest.new(app).post("/", params: {first_name: "Adam"}) 152 | 153 | expect(output.string).to match(/first_name=/) 154 | end 155 | 156 | it "deeply filters params list" do 157 | output = StringIO.new 158 | app = Class.new(Roda) { 159 | plugin :enhanced_logger, handlers: [[:stream, output: output]] 160 | 161 | route do |r| 162 | "OK" 163 | end 164 | } 165 | 166 | Rack::MockRequest.new(app).post("/", params: {user: {password: "secret"}}) 167 | 168 | expect(output.string).to match(/password=/) 169 | end 170 | end 171 | 172 | describe "nested applications" do 173 | it "logs from the top application" do 174 | nested_output = StringIO.new 175 | 176 | nested = Class.new(Roda) { 177 | plugin :enhanced_logger, handlers: [[:stream, output: nested_output]] 178 | 179 | route do |r| 180 | "nested" 181 | end 182 | } 183 | 184 | output = StringIO.new 185 | router = Class.new(Roda) { 186 | plugin :enhanced_logger, handlers: [[:stream, output: output]] 187 | 188 | route do |r| 189 | r.run nested 190 | end 191 | } 192 | 193 | response = Rack::MockRequest.new(router).get("/") 194 | expect(response.body).to eq("nested") 195 | expect(nested_output.string).to be_empty 196 | expect(output.string).to_not be_empty 197 | end 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "pry" 2 | 3 | # This file was generated by the `rspec --init` command. Conventionally, all 4 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 5 | # The generated `.rspec` file contains `--require spec_helper` which will cause 6 | # this file to always be loaded, without a need to explicitly require it in any 7 | # files. 8 | # 9 | # Given that it is always loaded, you are encouraged to keep this file as 10 | # light-weight as possible. Requiring heavyweight dependencies from this file 11 | # will add to the boot time of your test suite on EVERY test run, even for an 12 | # individual file that may not need all of that loaded. Instead, consider making 13 | # a separate helper file that requires the additional dependencies and performs 14 | # the additional setup, and require it from the spec files that actually need 15 | # it. 16 | # 17 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 18 | RSpec.configure do |config| 19 | # rspec-expectations config goes here. You can use an alternate 20 | # assertion/expectation library such as wrong or the stdlib/minitest 21 | # assertions if you prefer. 22 | config.expect_with :rspec do |expectations| 23 | # This option will default to `true` in RSpec 4. It makes the `description` 24 | # and `failure_message` of custom matchers include text for helper methods 25 | # defined using `chain`, e.g.: 26 | # be_bigger_than(2).and_smaller_than(4).description 27 | # # => "be bigger than 2 and smaller than 4" 28 | # ...rather than: 29 | # # => "be bigger than 2" 30 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 31 | end 32 | 33 | # rspec-mocks config goes here. You can use an alternate test double 34 | # library (such as bogus or mocha) by changing the `mock_with` option here. 35 | config.mock_with :rspec do |mocks| 36 | # Prevents you from mocking or stubbing a method that does not exist on 37 | # a real object. This is generally recommended, and will default to 38 | # `true` in RSpec 4. 39 | mocks.verify_partial_doubles = true 40 | end 41 | 42 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 43 | # have no way to turn it off -- the option exists only for backwards 44 | # compatibility in RSpec 3). It causes shared context metadata to be 45 | # inherited by the metadata hash of host groups and examples, rather than 46 | # triggering implicit auto-inclusion in groups with matching metadata. 47 | config.shared_context_metadata_behavior = :apply_to_host_groups 48 | 49 | # This allows you to limit a spec run to individual examples or groups 50 | # you care about by tagging them with `:focus` metadata. When nothing 51 | # is tagged with `:focus`, all examples get run. RSpec also provides 52 | # aliases for `it`, `describe`, and `context` that include `:focus` 53 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 54 | config.filter_run_when_matching :focus 55 | 56 | # Allows RSpec to persist some state between runs in order to support 57 | # the `--only-failures` and `--next-failure` CLI options. We recommend 58 | # you configure your source control system to ignore this file. 59 | config.example_status_persistence_file_path = "spec/examples.txt" 60 | 61 | # Limits the available syntax to the non-monkey patched syntax that is 62 | # recommended. For more details, see: 63 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 64 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 65 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 66 | config.disable_monkey_patching! 67 | 68 | # This setting enables warnings. It's recommended, but in some cases may 69 | # be too noisy due to issues in dependencies. 70 | config.warnings = true 71 | 72 | # Many RSpec users commonly either run the entire suite or an individual 73 | # file, and it's useful to allow more verbose output when running an 74 | # individual spec file. 75 | if config.files_to_run.one? 76 | # Use the documentation formatter for detailed output, 77 | # unless a formatter has already been configured 78 | # (e.g. via a command-line flag). 79 | config.default_formatter = "doc" 80 | end 81 | 82 | # Print the 10 slowest examples and example groups at the 83 | # end of the spec run, to help surface which specs are running 84 | # particularly slow. 85 | config.profile_examples = 10 86 | 87 | # Run specs in random order to surface order dependencies. If you find an 88 | # order dependency and want to debug it, you can fix the order by providing 89 | # the seed, which is printed after each run. 90 | # --seed 1234 91 | config.order = :random 92 | 93 | # Seed global randomization in this process using the `--seed` CLI option. 94 | # Setting this allows you to use `--seed` to deterministically reproduce 95 | # test failures related to randomization by passing the same `--seed` value 96 | # as the one that triggered the failure. 97 | Kernel.srand config.seed 98 | 99 | require "warning" 100 | Gem.path.each do |path| 101 | Warning.ignore(//, path) 102 | end 103 | end 104 | --------------------------------------------------------------------------------