From 7dff8e7f4e98d261243313ab77d0b81d05201050 Mon Sep 17 00:00:00 2001
From: mehmet celik
Date: Sun, 10 May 2026 21:39:43 +0300
Subject: [PATCH 01/15] Document supported versions
---
README.md | 49 ++++++++++++++-----
lib/rails/pretty/logger/version.rb | 2 +-
.../rails/pretty/logger/pretty_logger_test.rb | 14 ++++++
.../pretty/logger/split_log_task_test.rb | 39 +++++++++++++++
4 files changed, 91 insertions(+), 13 deletions(-)
create mode 100644 test/rails/pretty/logger/split_log_task_test.rb
diff --git a/README.md b/README.md
index 4d36074..2308080 100644
--- a/README.md
+++ b/README.md
@@ -2,42 +2,61 @@
Pretty Logger is a Rails engine for checking application logs from a mounted dashboard. It supports Rails 7.1+ and Rails 8, highlighted log entries, clearing log files, and optional hourly log rotation.
+## Compatibility
+
+| Gem version | Ruby | Rails | Notes |
+| --- | --- | --- | --- |
+| `0.3.x` | `>= 3.1` | `>= 7.1`, `< 9.0` | Current line. CI runs Rails 7.1, 7.2, and 8.0 with Ruby 3.3. |
+| `0.2.8` | `>= 2.2.2` | `>= 5.0`, `<= 6.1.4.1` | Legacy line for older Rails apps. Pin this version if you still need Rails 5 or Rails 6.1 support. |
+
## Usage
-visit http://your-webpage/rails-pretty-logger/dashboards/ then choose your log file, search with date range.
+
+Visit `http://your-webpage/rails-pretty-logger/dashboards/`, choose a log file, and filter entries by date range. The dashboard can also clear selected log files.
+

-#### How to use debug Highlighter
+#### How to use debug highlighter
+```ruby
+Rails::Pretty::Logger::PrettyLogger.highlight("lorem ipsum")
```
-PrettyLogger.highlight("lorem ipsum")
-```
+

#### Use Hourly Log Rotation
-Add these lines below to environment config file which you want to override its logger, first argument for name of the log file, second argument for keeping hourly logs, file count for limiting the logs files.
+Add these lines to the environment config where you want to override the Rails logger. The first argument is the log file name, the second argument enables hourly rotation, and `file_count` limits how many hourly files are kept.
Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_count: 48)
-```
-#/config/environments/development.rb
+```ruby
+# config/environments/development.rb
require "rails/pretty/logger/config/logger_config"
logger_file = ActiveSupport::TaggedLogging.new(Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_count: 48))
config.logger = logger_file
-```
+```
+

#### Split your old logs by hourly
-If you want split your old log files by hourly you can use this rake task below at terminal
+If you want to split old log files into hourly files, use the rake task below.
-argument takes what will be new files names start with, and with the second one will take the full path of your log file which will be splitted
+The first argument is the new file prefix and the second argument is the full path of the log file to split.
-for bash usage ```rake app:split_log["new_log_file_name","/path/to/your/log.file"]```
+For bash:
-for zch usage ```noglob rake app:split_log["new_log_file_name","/path/to/your/log.file"]```
+```bash
+bin/rails 'split_log[new_log_file_name,/path/to/your/log.file]'
+```
+
+For zsh:
+
+```zsh
+noglob bin/rails split_log[new_log_file_name,/path/to/your/log.file]
+```
## Installation
Add this line to your application's Gemfile:
@@ -46,6 +65,12 @@ Add this line to your application's Gemfile:
gem "rails-pretty-logger"
```
+For Rails 5 or Rails 6.1 applications, pin the legacy version:
+
+```ruby
+gem "rails-pretty-logger", "0.2.8"
+```
+
And then execute:
```bash
$ bundle
diff --git a/lib/rails/pretty/logger/version.rb b/lib/rails/pretty/logger/version.rb
index 57be755..c4d72f2 100644
--- a/lib/rails/pretty/logger/version.rb
+++ b/lib/rails/pretty/logger/version.rb
@@ -1,7 +1,7 @@
module Rails
module Pretty
module Logger
- VERSION = '0.2.8'
+ VERSION = "0.3.0"
end
end
end
diff --git a/test/rails/pretty/logger/pretty_logger_test.rb b/test/rails/pretty/logger/pretty_logger_test.rb
index f750424..f448baa 100644
--- a/test/rails/pretty/logger/pretty_logger_test.rb
+++ b/test/rails/pretty/logger/pretty_logger_test.rb
@@ -1,4 +1,5 @@
require "test_helper"
+require "stringio"
module Rails
module Pretty
@@ -58,6 +59,19 @@ class PrettyLoggerTest < ActiveSupport::TestCase
assert_empty File.read(@log_file)
end
+
+ test "highlight writes a tagged log entry" do
+ original_logger = Rails.logger
+ output = StringIO.new
+ Rails.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(output))
+
+ PrettyLogger.highlight("readme marker")
+
+ assert_includes output.string, "HIGHLIGHT"
+ assert_includes output.string, "readme marker"
+ ensure
+ Rails.logger = original_logger
+ end
end
end
end
diff --git a/test/rails/pretty/logger/split_log_task_test.rb b/test/rails/pretty/logger/split_log_task_test.rb
new file mode 100644
index 0000000..c6bcf06
--- /dev/null
+++ b/test/rails/pretty/logger/split_log_task_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+require "rake"
+
+class SplitLogTaskTest < ActiveSupport::TestCase
+ setup do
+ Rails.application.load_tasks unless Rake::Task.task_defined?("split_log")
+ Rake::Task["split_log"].reenable
+
+ @source_log = Rails.root.join("log", "old_production.log")
+ File.write(@source_log, <<~LOG)
+ Started GET "/first" for 127.0.0.1 at 2026-05-10 11:17:00 +0300
+ Processing by TestController#index as HTML
+ Completed 200 OK in 12ms
+ Started GET "/second" for 127.0.0.1 at 2026-05-10 12:01:00 +0300
+ Processing by TestController#index as HTML
+ Completed 200 OK in 9ms
+ LOG
+ end
+
+ teardown do
+ FileUtils.rm_f(@source_log)
+ FileUtils.rm_rf(Rails.root.join("log", "hourly"))
+ end
+
+ test "splits old logs into hourly files" do
+ output, = capture_io do
+ Rake::Task["split_log"].invoke("archive", @source_log.to_s)
+ end
+
+ first_hour = Rails.root.join("log", "hourly", "2026", "05", "10", "archive.log.20260510_1100")
+ second_hour = Rails.root.join("log", "hourly", "2026", "05", "10", "archive.log.20260510_1200")
+
+ assert_includes output, "It's done"
+ assert_path_exists first_hour
+ assert_path_exists second_hour
+ assert_includes File.read(first_hour), "/first"
+ assert_includes File.read(second_hour), "/second"
+ end
+end
From e15b86dad7a43bf745df51613b92ba98ba3709cf Mon Sep 17 00:00:00 2001
From: mehmet celik
Date: Sun, 10 May 2026 22:05:56 +0300
Subject: [PATCH 02/15] Harden log dashboard and rotation cleanup
---
README.md | 24 ++++-
.../pretty/logger/application_controller.rb | 15 +++
.../pretty/logger/dashboards_controller.rb | 2 +-
.../pretty/logger/hourly_logs_controller.rb | 2 +-
.../rails/pretty/logger/dashboards_helper.rb | 20 ++--
lib/rails/pretty/logger.rb | 56 +++++++++--
.../pretty/logger/active_support_logger.rb | 4 +-
.../pretty/logger/config/logger_config.rb | 16 ----
lib/rails/pretty/logger/console_logger.rb | 4 +-
lib/rails/pretty/logger/rails_logger.rb | 40 ++++----
lib/tasks/rails/pretty/logger_tasks.rake | 59 +++++++-----
test/integration/dashboard_test.rb | 69 ++++++++++++++
.../logger/active_support_logger_test.rb | 32 +++++++
.../rails/pretty/logger/pretty_logger_test.rb | 27 ++++++
test/rails/pretty/logger/rails_logger_test.rb | 94 +++++++++++++++++++
15 files changed, 385 insertions(+), 79 deletions(-)
create mode 100644 test/rails/pretty/logger/active_support_logger_test.rb
create mode 100644 test/rails/pretty/logger/rails_logger_test.rb
diff --git a/README.md b/README.md
index 2308080..d3fb5bc 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_c
```ruby
# config/environments/development.rb
-require "rails/pretty/logger/config/logger_config"
+require "rails/pretty/logger/console_logger"
logger_file = ActiveSupport::TaggedLogging.new(Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_count: 48))
config.logger = logger_file
@@ -86,6 +86,28 @@ Mount the engine in your config/routes.rb:
mount Rails::Pretty::Logger::Engine => "/rails-pretty-logger"
```
+### Protecting the dashboard
+
+Rails Pretty Logger does not provide its own authentication system. The dashboard can read and clear log files, so do not expose it publicly in production.
+
+For local-only use, mount it only in development:
+
+```ruby
+# config/routes.rb
+mount Rails::Pretty::Logger::Engine => "/rails-pretty-logger" if Rails.env.development?
+```
+
+For production use, protect it with whatever authentication or authorization your app already uses. If you prefer to keep the mount simple, configure a hook that runs before every engine action:
+
+```ruby
+# config/initializers/rails_pretty_logger.rb
+Rails.application.config.x.rails_pretty_logger.authenticate_with = -> {
+ authenticate_user!
+}
+```
+
+The hook is evaluated inside the engine controller, so controller helpers such as `authenticate_user!`, `current_user`, `head`, and `redirect_to` are available when your application defines them.
+
## Contributing
This project uses a Nix flake and direnv for local development:
diff --git a/app/controllers/rails/pretty/logger/application_controller.rb b/app/controllers/rails/pretty/logger/application_controller.rb
index cee1a2a..ce99759 100644
--- a/app/controllers/rails/pretty/logger/application_controller.rb
+++ b/app/controllers/rails/pretty/logger/application_controller.rb
@@ -6,6 +6,21 @@ class ApplicationController < ActionController::Base
helper Rails::Pretty::Logger::DashboardsHelper
protect_from_forgery with: :exception
+
+ before_action :authenticate_rails_pretty_logger
+
+ rescue_from Rails::Pretty::Logger::PrettyLogger::InvalidLogFile, with: :invalid_log_file
+
+ private
+
+ def authenticate_rails_pretty_logger
+ auth_hook = Rails.application.config.x.rails_pretty_logger.authenticate_with
+ instance_exec(&auth_hook) if auth_hook.respond_to?(:call)
+ end
+
+ def invalid_log_file
+ render plain: "Invalid log file", status: :bad_request
+ end
end
end
end
diff --git a/app/controllers/rails/pretty/logger/dashboards_controller.rb b/app/controllers/rails/pretty/logger/dashboards_controller.rb
index e8970e9..703c1e3 100644
--- a/app/controllers/rails/pretty/logger/dashboards_controller.rb
+++ b/app/controllers/rails/pretty/logger/dashboards_controller.rb
@@ -14,7 +14,7 @@ def index
def clear_logs
@log.clear_logs
- redirect_to logs_dashboards_path({log_file: params[:log_file]})
+ redirect_to logs_dashboards_path({log_file: @log.log_file})
end
private
diff --git a/app/controllers/rails/pretty/logger/hourly_logs_controller.rb b/app/controllers/rails/pretty/logger/hourly_logs_controller.rb
index 45aef27..105d370 100644
--- a/app/controllers/rails/pretty/logger/hourly_logs_controller.rb
+++ b/app/controllers/rails/pretty/logger/hourly_logs_controller.rb
@@ -25,7 +25,7 @@ def index
def clear_logs
@log.clear_logs
- redirect_to hourly_logs_path({log_file: params[:log_file]})
+ redirect_to hourly_logs_path({log_file: @log.log_file})
end
private
diff --git a/app/helpers/rails/pretty/logger/dashboards_helper.rb b/app/helpers/rails/pretty/logger/dashboards_helper.rb
index 30fcbf4..528265a 100644
--- a/app/helpers/rails/pretty/logger/dashboards_helper.rb
+++ b/app/helpers/rails/pretty/logger/dashboards_helper.rb
@@ -1,7 +1,8 @@
module Rails::Pretty::Logger
module DashboardsHelper
def check_highlight(line)
- return "
#{line.remove('[HIGHLIGHT]')}
".html_safe if line.include?("[HIGHLIGHT]")
+ return tag.div(line.remove("[HIGHLIGHT]"), class: "highlight") if line.include?("[HIGHLIGHT]")
+
if line.include?("Parameters:")
parse_parameters(line)
else
@@ -36,14 +37,17 @@ def is_page_active(index, params)
end
def parse_parameters(line)
- parameters = line[line.index("Parameters:") + 12 ..line.length]
- hash = JSON.parse parameters.gsub('=>', ':') rescue nil
- if hash.nil?
- line
- else
- h = hash.reduce(" Parameters: ") {|memo, (k,v)| memo += " #{k}: #{v}, "} rescue nil
- h.html_safe rescue nil
+ parameters = line[line.index("Parameters:") + "Parameters:".length..]
+ hash = JSON.parse(parameters.gsub("=>", ":"))
+ parts = [tag.strong("Parameters:"), tag.br]
+ hash.each do |key, value|
+ parts << tag.strong("#{key}: ")
+ parts << value.to_s
+ parts << ", "
end
+ safe_join(parts)
+ rescue JSON::ParserError, TypeError
+ line
end
diff --git a/lib/rails/pretty/logger.rb b/lib/rails/pretty/logger.rb
index d12c840..165e6b9 100644
--- a/lib/rails/pretty/logger.rb
+++ b/lib/rails/pretty/logger.rb
@@ -1,14 +1,19 @@
require "active_support/core_ext/object/blank"
require "active_support/core_ext/string/conversions"
+require "fileutils"
+require "pathname"
require "rails/pretty/logger/engine"
module Rails::Pretty::Logger
class PrettyLogger
+ class InvalidLogFile < StandardError; end
+
+ attr_reader :log_file
def initialize(params)
- @log_file = params[:log_file]
@filter_params = params
+ @log_file = self.class.resolve_log_file(params[:log_file])
end
def self.logger
@@ -23,13 +28,42 @@ def self.file_size(log_file)
File.size?("#{log_file}").to_f / 2**20
end
+ def self.log_root
+ Rails.root.join("log")
+ end
+
+ def self.resolve_log_file(log_file)
+ raise InvalidLogFile if log_file.blank?
+
+ candidate = Pathname.new(log_file.to_s)
+ candidate = log_root.join(candidate) unless candidate.absolute?
+
+ root_path = real_log_root
+ real_path = candidate.realpath
+
+ unless real_path.to_s == root_path.to_s || real_path.to_s.start_with?("#{root_path}/")
+ raise InvalidLogFile
+ end
+
+ raise InvalidLogFile unless real_path.file?
+
+ real_path.to_s
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError
+ raise InvalidLogFile
+ end
+
+ def self.real_log_root
+ FileUtils.mkdir_p(log_root)
+ log_root.realpath
+ end
+
def self.get_log_file_list
- log_files = Dir["#{File.join(Rails.root, 'log')}/**.*"]
+ log_files = Dir[File.join(log_root, "*")].select { |file| File.file?(file) }
logs_atr(log_files)
end
def self.get_hourly_log_file_list
- log_files = Dir["#{Rails.root}/log/hourly/**/*.*"].sort
+ log_files = Dir[File.join(log_root, "hourly", "**", "*")].select { |file| File.file?(file) }.sort
logs_atr(log_files)
end
@@ -81,7 +115,7 @@ def get_test_logs(file)
end
def get_logs_from_file(file)
- if @filter_params[:log_file].include?("test") || @filter_params[:log_file].include?("hourly")
+ if test_log?(file) || hourly_log?(file)
get_test_logs(file)
else
filter_logs_with_date(file)
@@ -125,8 +159,7 @@ def log_data
divider = set_divider_value
logs = get_logs_from_file(@log_file)
logs_count = (logs.count.to_f / divider).ceil
- paginated_logs = logs[ @filter_params[:page].to_i * divider ..
- (@filter_params[:page].to_i * divider) + divider ]
+ paginated_logs = logs[@filter_params[:page].to_i * divider, divider] || []
data = {}
data[:logs_count] = logs_count
data[:paginated_logs] = paginated_logs
@@ -140,9 +173,18 @@ def set_divider_value
elsif @filter_params[:date_range][:divider].blank?
100
else
- @filter_params[:date_range][:divider].to_i
+ divider = @filter_params[:date_range][:divider].to_i
+ divider.positive? ? divider : 100
end
end
+ def test_log?(file)
+ File.basename(file).include?("test")
+ end
+
+ def hourly_log?(file)
+ file.include?("#{File::SEPARATOR}hourly#{File::SEPARATOR}")
+ end
+
end
end
diff --git a/lib/rails/pretty/logger/active_support_logger.rb b/lib/rails/pretty/logger/active_support_logger.rb
index f6c8b8d..adb7d00 100644
--- a/lib/rails/pretty/logger/active_support_logger.rb
+++ b/lib/rails/pretty/logger/active_support_logger.rb
@@ -76,8 +76,8 @@ def self.broadcast(logger) # :nodoc:
end
end
- def initialize(*args)
- super
+ def initialize(*args, **kwargs)
+ super(*args, **kwargs)
@formatter = SimpleFormatter.new
after_initialize if respond_to? :after_initialize
end
diff --git a/lib/rails/pretty/logger/config/logger_config.rb b/lib/rails/pretty/logger/config/logger_config.rb
index cf68635..4cea20b 100644
--- a/lib/rails/pretty/logger/config/logger_config.rb
+++ b/lib/rails/pretty/logger/config/logger_config.rb
@@ -1,17 +1 @@
require "rails/pretty/logger/console_logger"
-require "rails/pretty/logger/active_support_logger"
-
-
-module Rails
- module Pretty
- module Logger
- module Config
-
- class LoggerConfig < Rails::Application
-
- end
-
- end
- end
- end
-end
diff --git a/lib/rails/pretty/logger/console_logger.rb b/lib/rails/pretty/logger/console_logger.rb
index d4d7c59..bbfb245 100644
--- a/lib/rails/pretty/logger/console_logger.rb
+++ b/lib/rails/pretty/logger/console_logger.rb
@@ -5,8 +5,8 @@ module Rails::Pretty::Logger
class ConsoleLogger < ActiveSupportLogger
- def initialize(*args)
- super(*args)
+ def initialize(*args, **kwargs)
+ super(*args, **kwargs)
@formatter = ConsoleFormatter.new
end
end
diff --git a/lib/rails/pretty/logger/rails_logger.rb b/lib/rails/pretty/logger/rails_logger.rb
index 0fb3d83..7da5280 100644
--- a/lib/rails/pretty/logger/rails_logger.rb
+++ b/lib/rails/pretty/logger/rails_logger.rb
@@ -10,14 +10,10 @@ def initialize(logdev, shift_age = 0, shift_size = 1048576, file_count: nil, lev
progname: nil, formatter: nil, datetime_format: nil,
shift_period_suffix: '%Y%m%d')
- self.level = level
- self.progname = progname
- @default_formatter = Formatter.new
- self.datetime_format = datetime_format
- self.formatter = formatter
+ super(nil, level: level, progname: progname, formatter: formatter, datetime_format: datetime_format)
@logdev = nil
if logdev
- log_name = "log/" + logdev + ".log"
+ log_name = Rails.root.join("log", "#{logdev}.log").to_s
@logdev = LoggerDevice.new(log_name, :shift_age => shift_age,
:shift_size => shift_size,
:shift_period_suffix => shift_period_suffix, file_count: file_count )
@@ -121,27 +117,35 @@ def shift_log_period(period_end)
end
end
- #delete old files
- log_files = Dir[ File.join(Rails.root, 'log', 'hourly') + "/#{suffix_year}/**/*"].reject {|fn| File.directory?(fn) }
- while (log_files.length > @file_count) do
- arr = log_files.reduce([]){|memo, log_file| memo << File.ctime(log_file).to_i}
- file_index = arr.index(arr.min)
- file_path = log_files[file_index]
- delete_old_file(file_path)
- log_files = Dir[ File.join(Rails.root, 'log', 'hourly') + "/#{suffix_year}/**/*"].reject {|fn| File.directory?(fn) }
- end
-
@dev.close rescue nil
File.rename("#{@filename}", age_file)
- old_log_path = Rails.root.join(age_file)
new_path = File.join(Rails.root, 'log', 'hourly', suffix_year, suffix_month, suffix_day)
FileUtils.mkdir_p new_path
- FileUtils.mv old_log_path, new_path, :force => true
+ FileUtils.mv age_file, new_path, :force => true
+ delete_old_hourly_files
@dev = create_logfile(@filename)
return true
end
+ def delete_old_hourly_files
+ log_files = hourly_log_files
+ while log_files.length > @file_count
+ delete_old_file(log_files.min_by { |log_file| hourly_log_sort_key(log_file) })
+ log_files = hourly_log_files
+ end
+ end
+
+ def hourly_log_files
+ log_prefix = "#{File.basename(@filename)}."
+ Dir[File.join(Rails.root, 'log', 'hourly', '**', '*')]
+ .select { |file| File.file?(file) && File.basename(file).start_with?(log_prefix) }
+ end
+
+ def hourly_log_sort_key(file)
+ File.basename(file)[/\.([0-9]{8}_[0-9]{4})(?:\.[0-9]+)?\z/, 1] || File.mtime(file).utc.strftime("%Y%m%d_%H%M")
+ end
+
def delete_old_file(file_path)
day_dir = File.dirname(file_path)
month_dir = File.expand_path("..",day_dir)
diff --git a/lib/tasks/rails/pretty/logger_tasks.rake b/lib/tasks/rails/pretty/logger_tasks.rake
index cce2253..eeb9626 100644
--- a/lib/tasks/rails/pretty/logger_tasks.rake
+++ b/lib/tasks/rails/pretty/logger_tasks.rake
@@ -1,33 +1,46 @@
+require "fileutils"
+
desc "Split log with hourly"
-task :split_log, [:log_name, :log_path] do |t, arg|
+task :split_log, [:log_name, :log_path] => :environment do |_task, arg|
+ log_name = File.basename(arg[:log_name].to_s)
+ log_path = arg[:log_path].to_s
- start = false
- new_path = nil
- file_path = nil
+ abort "Usage: bin/rails 'split_log[new_log_file_name,/path/to/log.file]'" if log_name.blank? || log_path.blank?
+ abort "Log file does not exist: #{log_path}" unless File.file?(log_path)
- def get_date(line)
- if line.include?("Started")
- date_index = line.index("at ")
- date = line[date_index .. date_index + 18]
- date.to_datetime
- end
+ parse_date = lambda do |line|
+ next unless line.include?("Started")
+
+ date_index = line.index("at ")
+ next unless date_index
+
+ line[date_index..date_index + 18].to_datetime
+ rescue ArgumentError
+ nil
end
- IO.foreach(arg[:log_path]) do |line|
- date = get_date(line) rescue nil
- if date
- start = true
- new_path = File.join(Rails.root, 'log', 'hourly', date.strftime('%Y'), date.strftime('%m'), date.strftime('%d'))
- FileUtils.mkdir_p new_path unless File.directory?(new_path)
- file_path = "#{new_path}/#{arg[:log_name]}.log.#{date.strftime('%Y%m%d_%H00')}"
- File.open(file_path, 'a') do |file|
- file << line
- end
- elsif start
- File.open(file_path, 'a') do |file|
- file << line
+ current_file_path = nil
+ output = nil
+
+ begin
+ IO.foreach(log_path) do |line|
+ if (date = parse_date.call(line))
+ new_path = File.join(Rails.root, 'log', 'hourly', date.strftime('%Y'), date.strftime('%m'), date.strftime('%d'))
+ file_path = File.join(new_path, "#{log_name}.log.#{date.strftime('%Y%m%d_%H00')}")
+
+ if file_path != current_file_path
+ output&.close
+ FileUtils.mkdir_p new_path
+ output = File.open(file_path, "a")
+ current_file_path = file_path
+ end
end
+
+ output << line if output
end
+ ensure
+ output&.close
end
+
puts "It's done"
end
diff --git a/test/integration/dashboard_test.rb b/test/integration/dashboard_test.rb
index c87c0bd..22b173d 100644
--- a/test/integration/dashboard_test.rb
+++ b/test/integration/dashboard_test.rb
@@ -38,4 +38,73 @@ class DashboardTest < ActionDispatch::IntegrationTest
assert_redirected_to "/rails-pretty-logger/dashboards/logs?log_file=#{CGI.escape(@log_file.to_s)}"
assert_empty File.read(@log_file)
end
+
+ test "rejects log files outside the Rails log directory" do
+ outside_log = Rails.root.join("tmp", "outside.log")
+ FileUtils.mkdir_p(outside_log.dirname)
+ File.write(outside_log, "outside log")
+
+ get "/rails-pretty-logger/dashboards/logs", params: {
+ log_file: outside_log.to_s,
+ date_range: {
+ start: Date.current.to_s,
+ end: Date.current.to_s
+ }
+ }
+
+ assert_response :bad_request
+ ensure
+ FileUtils.rm_f(outside_log) if outside_log
+ end
+
+ test "does not clear files outside the Rails log directory" do
+ outside_log = Rails.root.join("tmp", "outside-clear.log")
+ FileUtils.mkdir_p(outside_log.dirname)
+ File.write(outside_log, "outside log")
+
+ post "/rails-pretty-logger/dashboards/clear_logs", params: {
+ log_file: outside_log.to_s
+ }
+
+ assert_response :bad_request
+ assert_equal "outside log", File.read(outside_log)
+ ensure
+ FileUtils.rm_f(outside_log) if outside_log
+ end
+
+ test "escapes highlighted log content" do
+ File.write(@log_file, "#{DummyLog.entry}[HIGHLIGHT]\n")
+
+ get "/rails-pretty-logger/dashboards/logs", params: {
+ log_file: @log_file.to_s,
+ date_range: {
+ start: Date.current.to_s,
+ end: Date.current.to_s
+ }
+ }
+
+ assert_response :success
+ assert_includes response.body, "<script>alert(1)</script>"
+ assert_not_includes response.body, ""
+ end
+
+ test "escapes parameter log content" do
+ File.write(@log_file, <<~LOG)
+ Started GET "/rails-pretty-logger/dashboards" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300
+ Parameters: {"query"=>""}
+ Completed 200 OK in 12ms
+ LOG
+
+ get "/rails-pretty-logger/dashboards/logs", params: {
+ log_file: @log_file.to_s,
+ date_range: {
+ start: Date.current.to_s,
+ end: Date.current.to_s
+ }
+ }
+
+ assert_response :success
+ assert_includes response.body, "<script>alert(1)</script>"
+ assert_not_includes response.body, ""
+ end
end
diff --git a/test/rails/pretty/logger/active_support_logger_test.rb b/test/rails/pretty/logger/active_support_logger_test.rb
new file mode 100644
index 0000000..c00a773
--- /dev/null
+++ b/test/rails/pretty/logger/active_support_logger_test.rb
@@ -0,0 +1,32 @@
+require "test_helper"
+require "stringio"
+require "rails/pretty/logger/active_support_logger"
+
+module Rails
+ module Pretty
+ module Logger
+ class ActiveSupportLoggerTest < ActiveSupport::TestCase
+ test "broadcast writes log entries to both loggers" do
+ primary_output = StringIO.new
+ broadcast_output = StringIO.new
+ logger = ::ActiveSupport::Logger.new(primary_output)
+ broadcast_logger = ::ActiveSupport::Logger.new(broadcast_output)
+
+ logger.extend ActiveSupportLogger.broadcast(broadcast_logger)
+ logger.info("aggregation message")
+
+ assert_includes primary_output.string, "aggregation message"
+ assert_includes broadcast_output.string, "aggregation message"
+ end
+
+ test "logger_outputs_to detects logger devices" do
+ output = StringIO.new
+ logger = ::ActiveSupport::Logger.new(output)
+
+ assert ActiveSupportLogger.logger_outputs_to?(logger, output)
+ assert_not ActiveSupportLogger.logger_outputs_to?(logger, StringIO.new)
+ end
+ end
+ end
+ end
+end
diff --git a/test/rails/pretty/logger/pretty_logger_test.rb b/test/rails/pretty/logger/pretty_logger_test.rb
index f448baa..05fcc6a 100644
--- a/test/rails/pretty/logger/pretty_logger_test.rb
+++ b/test/rails/pretty/logger/pretty_logger_test.rb
@@ -60,6 +60,33 @@ class PrettyLoggerTest < ActiveSupport::TestCase
assert_empty File.read(@log_file)
end
+ test "rejects log files outside the Rails log directory" do
+ outside_log = Rails.root.join("tmp", "pretty_logger_outside.log")
+ FileUtils.mkdir_p(outside_log.dirname)
+ File.write(outside_log, DummyLog.entry)
+
+ assert_raises PrettyLogger::InvalidLogFile do
+ PrettyLogger.new(ActionController::Parameters.new(log_file: outside_log.to_s))
+ end
+ ensure
+ FileUtils.rm_f(outside_log) if outside_log
+ end
+
+ test "uses default divider when divider is not positive" do
+ logger = PrettyLogger.new(
+ ActionController::Parameters.new(
+ log_file: @log_file.to_s,
+ date_range: {
+ start: Date.current.to_s,
+ end: Date.current.to_s,
+ divider: "0"
+ }
+ )
+ )
+
+ assert_equal 1, logger.log_data[:logs_count]
+ end
+
test "highlight writes a tagged log entry" do
original_logger = Rails.logger
output = StringIO.new
diff --git a/test/rails/pretty/logger/rails_logger_test.rb b/test/rails/pretty/logger/rails_logger_test.rb
new file mode 100644
index 0000000..f9c5491
--- /dev/null
+++ b/test/rails/pretty/logger/rails_logger_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+require "rails/pretty/logger/console_logger"
+
+module Rails
+ module Pretty
+ module Logger
+ class RailsLoggerTest < ActiveSupport::TestCase
+ setup do
+ @log_name = "rotation_test"
+ @log_file = Rails.root.join("log", "#{@log_name}.log")
+ @hourly_root = Rails.root.join("log", "hourly")
+ FileUtils.rm_f(@log_file)
+ FileUtils.rm_rf(@hourly_root)
+ end
+
+ teardown do
+ @logger&.close
+ FileUtils.rm_f(@log_file)
+ FileUtils.rm_rf(@hourly_root)
+ end
+
+ test "rotates current log file into the hourly directory" do
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 5)
+ @logger.info("rotated message")
+
+ logdev.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+
+ rotated_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100")
+
+ assert_path_exists rotated_file
+ assert_path_exists @log_file
+ assert_includes File.read(rotated_file), "rotated message"
+ end
+
+ test "keeps hourly rotated files within file_count" do
+ old_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1000")
+ FileUtils.mkdir_p(old_file.dirname)
+ File.write(old_file, "old hourly log")
+
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 1)
+ @logger.info("new hourly log")
+
+ logdev.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+
+ rotated_files = Dir[Rails.root.join("log", "hourly", "2026", "**", "*")].reject { |file| File.directory?(file) }
+ new_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100")
+
+ assert_equal 1, rotated_files.count
+ assert_path_exists new_file
+ assert_not File.exist?(old_file)
+ end
+
+ test "keeps hourly cleanup scoped to the current log name" do
+ old_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1000")
+ other_log_file = Rails.root.join("log", "hourly", "2026", "05", "10", "other.log.20260510_0900")
+ FileUtils.mkdir_p(old_file.dirname)
+ File.write(old_file, "old hourly log")
+ File.write(other_log_file, "other hourly log")
+
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 1)
+ @logger.info("new hourly log")
+
+ logdev.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+
+ own_rotated_files = Dir[Rails.root.join("log", "hourly", "**", "#{@log_name}.log.*")]
+ new_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100")
+
+ assert_equal [new_file.to_s], own_rotated_files.sort
+ assert_path_exists other_log_file
+ end
+
+ test "removes empty hourly directories after deleting old files" do
+ old_file = Rails.root.join("log", "hourly", "2025", "01", "01", "#{@log_name}.log.20250101_0100")
+ FileUtils.mkdir_p(old_file.dirname)
+ File.write(old_file, "old hourly log")
+
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 1)
+ @logger.info("new hourly log")
+
+ logdev.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+
+ assert_not File.exist?(old_file)
+ assert_not Dir.exist?(Rails.root.join("log", "hourly", "2025"))
+ end
+
+ private
+
+ def logdev
+ @logger.instance_variable_get(:@logdev)
+ end
+ end
+ end
+ end
+end
From 99af92006dd81fefbc07f80bbefe89496d43bfe6 Mon Sep 17 00:00:00 2001
From: mehmet celik
Date: Sun, 10 May 2026 22:20:54 +0300
Subject: [PATCH 03/15] Tighten browser coverage and CI matrix
---
.github/workflows/ci.yml | 2 +-
README.md | 4 +-
app/assets/config/manifest.js | 1 +
.../rails/pretty/logger/application.js | 20 +++
.../rails/pretty/logger/application.html.erb | 1 +
lib/rails/pretty/logger/rails_logger.rb | 26 ++--
test/integration/dashboard_test.rb | 11 ++
.../rails/pretty/logger/pretty_logger_test.rb | 34 +++++
test/rails/pretty/logger/rails_logger_test.rb | 17 +++
.../rails_pretty_logger_interaction_test.rb | 118 ++++++++++++++++--
10 files changed, 209 insertions(+), 25 deletions(-)
create mode 100644 app/assets/javascripts/rails/pretty/logger/application.js
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fef4172..9c795cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,8 +20,8 @@ jobs:
matrix:
rails-version:
- "~> 7.1.0"
- - "~> 7.2.0"
- "~> 8.0.0"
+ - "~> 8.1.0"
env:
RAILS_VERSION: ${{ matrix.rails-version }}
diff --git a/README.md b/README.md
index d3fb5bc..15ce6f6 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Pretty Logger is a Rails engine for checking application logs from a mounted das
| Gem version | Ruby | Rails | Notes |
| --- | --- | --- | --- |
-| `0.3.x` | `>= 3.1` | `>= 7.1`, `< 9.0` | Current line. CI runs Rails 7.1, 7.2, and 8.0 with Ruby 3.3. |
+| `0.3.x` | `>= 3.1` | `>= 7.1`, `< 9.0` | Current line. CI runs Rails 7.1, 8.0, and 8.1 with Ruby 3.3. |
| `0.2.8` | `>= 2.2.2` | `>= 5.0`, `<= 6.1.4.1` | Legacy line for older Rails apps. Pin this version if you still need Rails 5 or Rails 6.1 support. |
## Usage
@@ -119,7 +119,7 @@ bundle exec rails test
bundle exec ruby -Itest test/system/rails_pretty_logger_interaction_test.rb
```
-CI runs the same test suite against Rails 7.1, 7.2, and 8.0 before PRs and pushes to `main` or `master`.
+CI runs the same test suite against Rails 7.1, 8.0, and 8.1 before PRs and pushes to `main` or `master`.
1. [Fork][fork] the [official repository][repo].
2. [Create a topic branch.][branch]
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
index ee01f8f..1fd88a3 100644
--- a/app/assets/config/manifest.js
+++ b/app/assets/config/manifest.js
@@ -1 +1,2 @@
//= link_directory ../stylesheets/rails/pretty/logger .css
+//= link_directory ../javascripts/rails/pretty/logger .js
diff --git a/app/assets/javascripts/rails/pretty/logger/application.js b/app/assets/javascripts/rails/pretty/logger/application.js
new file mode 100644
index 0000000..0d20dbb
--- /dev/null
+++ b/app/assets/javascripts/rails/pretty/logger/application.js
@@ -0,0 +1,20 @@
+(function () {
+ function confirmMessage(element) {
+ return element.dataset.turboConfirm || element.dataset.confirm;
+ }
+
+ document.addEventListener("click", function (event) {
+ var element = event.target.closest("a[data-confirm], a[data-turbo-confirm]");
+
+ if (!element) return;
+
+ var message = confirmMessage(element);
+ if (message && !window.confirm(message)) event.preventDefault();
+ });
+
+ document.addEventListener("submit", function (event) {
+ var message = confirmMessage(event.target);
+
+ if (message && !window.confirm(message)) event.preventDefault();
+ });
+})();
diff --git a/app/views/layouts/rails/pretty/logger/application.html.erb b/app/views/layouts/rails/pretty/logger/application.html.erb
index 1fddae3..bf908b5 100644
--- a/app/views/layouts/rails/pretty/logger/application.html.erb
+++ b/app/views/layouts/rails/pretty/logger/application.html.erb
@@ -11,6 +11,7 @@
"rails/pretty/logger/list",
media: "all"
) %>
+ <%= javascript_include_tag "rails/pretty/logger/application", defer: true %>
diff --git a/lib/rails/pretty/logger/rails_logger.rb b/lib/rails/pretty/logger/rails_logger.rb
index 7da5280..1e948f9 100644
--- a/lib/rails/pretty/logger/rails_logger.rb
+++ b/lib/rails/pretty/logger/rails_logger.rb
@@ -104,30 +104,30 @@ def shift_log_period(period_end)
suffix = period_end.strftime('%Y%m%d_%H%M')
end
- age_file = "#{@filename}.#{suffix}"
-
- if FileTest.exist?(age_file)
- # try to avoid filename crash caused by Timestamp change.
- idx = 0
- # .99 can be overridden; avoid too much file search with 'loop do'
- while idx < 100
- idx += 1
- age_file = "#{@filename}.#{suffix}.#{idx}"
- break unless FileTest.exist?(age_file)
- end
- end
+ age_file = available_log_path("#{@filename}.#{suffix}")
@dev.close rescue nil
File.rename("#{@filename}", age_file)
new_path = File.join(Rails.root, 'log', 'hourly', suffix_year, suffix_month, suffix_day)
FileUtils.mkdir_p new_path
- FileUtils.mv age_file, new_path, :force => true
+ destination = available_log_path(File.join(new_path, File.basename(age_file)))
+ FileUtils.mv age_file, destination
delete_old_hourly_files
@dev = create_logfile(@filename)
return true
end
+ def available_log_path(path)
+ candidate = path
+ index = 0
+ while File.exist?(candidate)
+ index += 1
+ candidate = "#{path}.#{index}"
+ end
+ candidate
+ end
+
def delete_old_hourly_files
log_files = hourly_log_files
while log_files.length > @file_count
diff --git a/test/integration/dashboard_test.rb b/test/integration/dashboard_test.rb
index 22b173d..9f2906e 100644
--- a/test/integration/dashboard_test.rb
+++ b/test/integration/dashboard_test.rb
@@ -17,6 +17,17 @@ class DashboardTest < ActionDispatch::IntegrationTest
assert_includes response.body, "Dashboard_test.log"
end
+ test "authentication hook can block engine access" do
+ previous_hook = Rails.application.config.x.rails_pretty_logger.authenticate_with
+ Rails.application.config.x.rails_pretty_logger.authenticate_with = -> { head :unauthorized }
+
+ get "/rails-pretty-logger/dashboards"
+
+ assert_response :unauthorized
+ ensure
+ Rails.application.config.x.rails_pretty_logger.authenticate_with = previous_hook
+ end
+
test "renders selected log file" do
get "/rails-pretty-logger/dashboards/logs", params: {
log_file: @log_file.to_s,
diff --git a/test/rails/pretty/logger/pretty_logger_test.rb b/test/rails/pretty/logger/pretty_logger_test.rb
index 05fcc6a..e42fe68 100644
--- a/test/rails/pretty/logger/pretty_logger_test.rb
+++ b/test/rails/pretty/logger/pretty_logger_test.rb
@@ -54,6 +54,19 @@ class PrettyLoggerTest < ActiveSupport::TestCase
assert logs.all? { |log| log.key?(:file_size) }
end
+ test "does not include hourly files in the main log file list" do
+ hourly_file = Rails.root.join("log", "hourly", "2026", "05", "10", "pretty_logger_test.log.20260510_1100")
+ FileUtils.mkdir_p(hourly_file.dirname)
+ File.write(hourly_file, DummyLog.entry)
+
+ logs = PrettyLogger.get_log_file_list.values
+
+ assert logs.any? { |log| log[:file_name] == @log_file.to_s }
+ assert_not logs.any? { |log| log[:file_name] == hourly_file.to_s }
+ ensure
+ FileUtils.rm_rf(Rails.root.join("log", "hourly"))
+ end
+
test "clears a selected log file" do
PrettyLogger.new(ActionController::Parameters.new(log_file: @log_file.to_s)).clear_logs
@@ -72,6 +85,27 @@ class PrettyLoggerTest < ActiveSupport::TestCase
FileUtils.rm_f(outside_log) if outside_log
end
+ test "rejects symlinks that point outside the Rails log directory" do
+ outside_log = Rails.root.join("tmp", "pretty_logger_symlink_target.log")
+ log_link = Rails.root.join("log", "pretty_logger_symlink.log")
+ FileUtils.mkdir_p(outside_log.dirname)
+ File.write(outside_log, DummyLog.entry)
+ FileUtils.ln_s(outside_log, log_link)
+
+ assert_raises PrettyLogger::InvalidLogFile do
+ PrettyLogger.new(ActionController::Parameters.new(log_file: log_link.to_s))
+ end
+ ensure
+ FileUtils.rm_f(log_link) if log_link
+ FileUtils.rm_f(outside_log) if outside_log
+ end
+
+ test "resolves relative log file names inside the Rails log directory" do
+ logger = PrettyLogger.new(ActionController::Parameters.new(log_file: @log_file.basename.to_s))
+
+ assert_equal @log_file.realpath.to_s, logger.log_file
+ end
+
test "uses default divider when divider is not positive" do
logger = PrettyLogger.new(
ActionController::Parameters.new(
diff --git a/test/rails/pretty/logger/rails_logger_test.rb b/test/rails/pretty/logger/rails_logger_test.rb
index f9c5491..35e761a 100644
--- a/test/rails/pretty/logger/rails_logger_test.rb
+++ b/test/rails/pretty/logger/rails_logger_test.rb
@@ -32,6 +32,23 @@ class RailsLoggerTest < ActiveSupport::TestCase
assert_includes File.read(rotated_file), "rotated message"
end
+ test "does not overwrite an existing hourly file for the same timestamp" do
+ existing_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100")
+ FileUtils.mkdir_p(existing_file.dirname)
+ File.write(existing_file, "existing hourly log")
+
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 5)
+ @logger.info("new rotated message")
+
+ logdev.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+
+ collision_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100.1")
+
+ assert_includes File.read(existing_file), "existing hourly log"
+ assert_path_exists collision_file
+ assert_includes File.read(collision_file), "new rotated message"
+ end
+
test "keeps hourly rotated files within file_count" do
old_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1000")
FileUtils.mkdir_p(old_file.dirname)
diff --git a/test/system/rails_pretty_logger_interaction_test.rb b/test/system/rails_pretty_logger_interaction_test.rb
index 908dd1f..8e1d0ca 100644
--- a/test/system/rails_pretty_logger_interaction_test.rb
+++ b/test/system/rails_pretty_logger_interaction_test.rb
@@ -2,13 +2,17 @@
class RailsPrettyLoggerInteractionTest < ApplicationSystemTestCase
setup do
- @log_file = Rails.root.join("log", "system_test.log")
+ @log_file = Rails.root.join("log", "system.log")
@hourly_dir = Rails.root.join("log", "hourly", "2026", "05", "10")
@hourly_file = @hourly_dir.join("production.log.20260510_1100")
+ @older_hourly_dir = Rails.root.join("log", "hourly", "2025", "04", "09")
+ @older_hourly_file = @older_hourly_dir.join("production.log.20250409_0800")
FileUtils.mkdir_p(@hourly_dir)
- File.write(@log_file, DummyLog.entry)
- File.write(@hourly_file, DummyLog.entry)
+ FileUtils.mkdir_p(@older_hourly_dir)
+ File.write(@log_file, dashboard_log)
+ File.write(@hourly_file, hourly_log("HOURLY 2026 ENTRY", Time.local(2026, 5, 10, 11, 0, 0)))
+ File.write(@older_hourly_file, hourly_log("HOURLY 2025 ENTRY", Time.local(2025, 4, 9, 8, 0, 0)))
end
teardown do
@@ -20,29 +24,125 @@ class RailsPrettyLoggerInteractionTest < ApplicationSystemTestCase
visit "/rails-pretty-logger"
assert_selector "link[rel='stylesheet'][href*='rails/pretty/logger/application']", visible: false
+ assert_selector "script[src*='rails/pretty/logger/application']", visible: false
assert_equal "rgb(241, 241, 241)", page.evaluate_script("getComputedStyle(document.body).backgroundColor")
end
- test "opens a log file from the dashboard" do
+ test "opens a log file and filters it by date range" do
visit "/rails-pretty-logger"
- click_link "System_test.log"
+ accept_confirm do
+ click_link "System.log"
+ end
- assert_text "Completed 200 OK"
- assert_selector "input[name='date_range[start]']", visible: false
+ assert_text "TODAY ENTRY"
+ assert_no_text "YESTERDAY ENTRY"
+
+ find("input[name='date_range[start]']").set(Date.yesterday.to_s)
+ find("input[name='date_range[end]']").set(Date.yesterday.to_s)
+ click_button "Submit"
+
+ assert_text "YESTERDAY ENTRY"
+ assert_no_text "TODAY ENTRY"
+ end
+
+ test "clear logs form requires confirmation" do
+ visit "/rails-pretty-logger"
+
+ accept_confirm do
+ click_link "System.log"
+ end
+
+ assert_text "TODAY ENTRY"
+
+ dismiss_confirm do
+ click_button "Clear logs"
+ end
+
+ assert_text "TODAY ENTRY"
+ assert_includes File.read(@log_file), "TODAY ENTRY"
+
+ accept_confirm do
+ click_button "Clear logs"
+ end
+
+ assert_no_text "TODAY ENTRY"
+ assert_empty File.read(@log_file)
end
- test "filters hourly log files with server-rendered GET params" do
+ test "filters sorts and opens hourly log files" do
visit "/rails-pretty-logger/hourly_logs"
- assert_text "2026/05/10"
+ assert_equal ["2025/04/09 : 0800", "2026/05/10 : 1100"], hourly_log_links
+
+ click_link "Sort desc"
+
+ assert_equal ["2026/05/10 : 1100", "2025/04/09 : 0800"], hourly_log_links
fill_in "Search", with: "missing-log"
click_button "Search"
assert_no_text "2026/05/10"
+ assert_no_text "2025/04/09"
fill_in "Search", with: "2026"
click_button "Search"
assert_text "2026/05/10"
+ assert_no_text "2025/04/09"
+
+ accept_confirm do
+ click_link "2026/05/10 : 1100"
+ end
+
+ assert_text "HOURLY 2026 ENTRY"
+ assert_no_text "HOURLY 2025 ENTRY"
+ end
+
+ test "shows hourly empty state" do
+ FileUtils.rm_rf(Rails.root.join("log", "hourly"))
+
+ visit "/rails-pretty-logger/hourly_logs"
+
+ assert_text "There is no log file to show"
+ end
+
+ test "does not execute escaped log content in the browser" do
+ File.write(@log_file, <<~LOG)
+ Started GET "/xss" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300
+ [HIGHLIGHT]
+ Parameters: {"payload"=>""}
+ LOG
+
+ visit "/rails-pretty-logger"
+
+ accept_confirm do
+ click_link "System.log"
+ end
+
+ assert_text ""
+ assert_text ""
+ assert_not page.evaluate_script("window.__railsPrettyLoggerHighlightXss === true")
+ assert_not page.evaluate_script("window.__railsPrettyLoggerParamsXss === true")
+ end
+
+ private
+
+ def dashboard_log
+ <<~LOG
+ Started GET "/today" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300
+ Completed TODAY ENTRY
+ Started GET "/yesterday" for 127.0.0.1 at #{Date.yesterday.strftime("%Y-%m-%d")} 11:17:00 +0300
+ Completed YESTERDAY ENTRY
+ LOG
+ end
+
+ def hourly_log(message, time)
+ <<~LOG
+ Started GET "/hourly" for 127.0.0.1 at #{time.strftime("%Y-%m-%d")} #{time.strftime("%H:%M:%S")} +0300
+ Completed #{message}
+ LOG
+ end
+
+ def hourly_log_links
+ page.all(".name").map(&:text)
end
end
From dd595650da2db8f168f3d39c309192a6488dbff5 Mon Sep 17 00:00:00 2001
From: mehmet celik
Date: Sun, 10 May 2026 22:30:16 +0300
Subject: [PATCH 04/15] Cover large logs and concurrent rotation
---
lib/rails/pretty/logger.rb | 43 ++++---
lib/rails/pretty/logger/rails_logger.rb | 48 +++++---
.../rails/pretty/logger/pretty_logger_test.rb | 34 ++++++
test/rails/pretty/logger/rails_logger_test.rb | 115 ++++++++++++++++++
4 files changed, 208 insertions(+), 32 deletions(-)
diff --git a/lib/rails/pretty/logger.rb b/lib/rails/pretty/logger.rb
index 165e6b9..6e584ba 100644
--- a/lib/rails/pretty/logger.rb
+++ b/lib/rails/pretty/logger.rb
@@ -90,35 +90,41 @@ def end_date
end
def filter_logs_with_date(file)
- arr = []
+ each_filtered_log_line(file).to_a
+ end
+
+ def each_filtered_log_line(file)
+ return enum_for(:each_filtered_log_line, file) unless block_given?
+
start = false
IO.foreach(file) do |line|
if get_date_from_log_line(line)
start = true
- arr.push(line)
+ yield line
elsif start && !(line_include_date?(line))
- arr.push(line)
+ yield line
else
start = false
end
end
- return arr
end
def get_test_logs(file)
- arr = []
- IO.foreach(file) do |line|
- arr.push(line)
- end
- return arr
+ IO.foreach(file).to_a
end
def get_logs_from_file(file)
+ each_log_line(file).to_a
+ end
+
+ def each_log_line(file)
+ return enum_for(:each_log_line, file) unless block_given?
+
if test_log?(file) || hourly_log?(file)
- get_test_logs(file)
+ IO.foreach(file) { |line| yield line }
else
- filter_logs_with_date(file)
+ each_filtered_log_line(file) { |line| yield line }
end
end
@@ -157,11 +163,18 @@ def validate_date
def log_data
error = validate_date
divider = set_divider_value
- logs = get_logs_from_file(@log_file)
- logs_count = (logs.count.to_f / divider).ceil
- paginated_logs = logs[@filter_params[:page].to_i * divider, divider] || []
+ line_count = 0
+ paginated_logs = []
+ page_start = @filter_params[:page].to_i * divider
+ page_end = page_start + divider
+
+ each_log_line(@log_file) do |line|
+ paginated_logs << line if line_count >= page_start && line_count < page_end
+ line_count += 1
+ end
+
data = {}
- data[:logs_count] = logs_count
+ data[:logs_count] = (line_count.to_f / divider).ceil
data[:paginated_logs] = paginated_logs
data[:error] = error
return data
diff --git a/lib/rails/pretty/logger/rails_logger.rb b/lib/rails/pretty/logger/rails_logger.rb
index 1e948f9..f1d6020 100644
--- a/lib/rails/pretty/logger/rails_logger.rb
+++ b/lib/rails/pretty/logger/rails_logger.rb
@@ -94,28 +94,42 @@ def initialize(log = nil, shift_age: nil, shift_size: nil, shift_period_suffix:
end
def shift_log_period(period_end)
- suffix = period_end.strftime(@shift_period_suffix)
+ with_rotation_lock do
+ suffix = period_end.strftime(@shift_period_suffix)
- suffix_year = period_end.strftime('%Y')
- suffix_month = period_end.strftime('%m')
- suffix_day = period_end.strftime('%d')
+ suffix_year = period_end.strftime('%Y')
+ suffix_month = period_end.strftime('%m')
+ suffix_day = period_end.strftime('%d')
- if @shift_age == 'hourly'
- suffix = period_end.strftime('%Y%m%d_%H%M')
- end
+ if @shift_age == 'hourly'
+ suffix = period_end.strftime('%Y%m%d_%H%M')
+ end
+
+ age_file = available_log_path("#{@filename}.#{suffix}")
+
+ @dev.close rescue nil
- age_file = available_log_path("#{@filename}.#{suffix}")
+ File.rename("#{@filename}", age_file)
+ new_path = File.join(Rails.root, 'log', 'hourly', suffix_year, suffix_month, suffix_day)
+ FileUtils.mkdir_p new_path
+ destination = available_log_path(File.join(new_path, File.basename(age_file)))
+ FileUtils.mv age_file, destination
+ delete_old_hourly_files
+ @dev = create_logfile(@filename)
+ true
+ end
+ end
- @dev.close rescue nil
+ def with_rotation_lock
+ FileUtils.mkdir_p(File.dirname(rotation_lock_path))
+ File.open(rotation_lock_path, File::RDWR | File::CREAT, 0644) do |lock|
+ lock.flock(File::LOCK_EX)
+ yield
+ end
+ end
- File.rename("#{@filename}", age_file)
- new_path = File.join(Rails.root, 'log', 'hourly', suffix_year, suffix_month, suffix_day)
- FileUtils.mkdir_p new_path
- destination = available_log_path(File.join(new_path, File.basename(age_file)))
- FileUtils.mv age_file, destination
- delete_old_hourly_files
- @dev = create_logfile(@filename)
- return true
+ def rotation_lock_path
+ Rails.root.join("tmp", "rails_pretty_logger", "#{File.basename(@filename)}.rotate.lock").to_s
end
def available_log_path(path)
diff --git a/test/rails/pretty/logger/pretty_logger_test.rb b/test/rails/pretty/logger/pretty_logger_test.rb
index e42fe68..bba5d35 100644
--- a/test/rails/pretty/logger/pretty_logger_test.rb
+++ b/test/rails/pretty/logger/pretty_logger_test.rb
@@ -33,6 +33,40 @@ class PrettyLoggerTest < ActiveSupport::TestCase
assert_includes data[:paginated_logs].first, Date.current.to_s
end
+ test "paginates large log files without materializing the full log array" do
+ large_log = Rails.root.join("log", "large_production.log")
+ File.open(large_log, "w") do |file|
+ 1_000.times do |index|
+ file.puts %(Started GET "/large/#{index}" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300)
+ file.puts "Completed LARGE ENTRY #{index}"
+ end
+ end
+
+ logger = PrettyLogger.new(
+ ActionController::Parameters.new(
+ log_file: large_log.to_s,
+ page: "3",
+ date_range: {
+ start: Date.current.to_s,
+ end: Date.current.to_s,
+ divider: "25"
+ }
+ )
+ )
+
+ logger.define_singleton_method(:get_logs_from_file) do |_file|
+ flunk "log_data should stream lines instead of loading the full log file"
+ end
+
+ data = logger.log_data
+
+ assert_equal 80, data[:logs_count]
+ assert_equal 25, data[:paginated_logs].count
+ assert_includes data[:paginated_logs].join, "LARGE ENTRY"
+ ensure
+ FileUtils.rm_f(large_log) if large_log
+ end
+
test "validates date ranges" do
logger = PrettyLogger.new(
ActionController::Parameters.new(
diff --git a/test/rails/pretty/logger/rails_logger_test.rb b/test/rails/pretty/logger/rails_logger_test.rb
index 35e761a..b22132b 100644
--- a/test/rails/pretty/logger/rails_logger_test.rb
+++ b/test/rails/pretty/logger/rails_logger_test.rb
@@ -17,6 +17,7 @@ class RailsLoggerTest < ActiveSupport::TestCase
@logger&.close
FileUtils.rm_f(@log_file)
FileUtils.rm_rf(@hourly_root)
+ FileUtils.rm_rf(Rails.root.join("tmp", "rails_pretty_logger"))
end
test "rotates current log file into the hourly directory" do
@@ -49,6 +50,102 @@ class RailsLoggerTest < ActiveSupport::TestCase
assert_includes File.read(collision_file), "new rotated message"
end
+ test "keeps both repeated DST hour rotations" do
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 5)
+ @logger.info("first dst hour")
+ logdev.shift_log_period(Time.new(2026, 11, 1, 1, 0, 0, "-04:00"))
+
+ @logger.info("second dst hour")
+ logdev.shift_log_period(Time.new(2026, 11, 1, 1, 0, 0, "-05:00"))
+
+ first_file = Rails.root.join("log", "hourly", "2026", "11", "01", "#{@log_name}.log.20261101_0100")
+ second_file = Rails.root.join("log", "hourly", "2026", "11", "01", "#{@log_name}.log.20261101_0100.1")
+
+ assert_includes File.read(first_file), "first dst hour"
+ assert_includes File.read(second_file), "second dst hour"
+ end
+
+ test "calculates next hourly rotation across spring DST jump" do
+ skip "timezone data is not available" unless timezone_data_path
+
+ with_timezone("America/New_York") do
+ next_rotation = RailsLogger::Period.next_rotate_time(Time.local(2026, 3, 8, 1, 30, 0), "hourly")
+
+ assert_equal "2026-03-08 03:00:00 -0400", next_rotation.strftime("%Y-%m-%d %H:%M:%S %z")
+ end
+ end
+
+ test "waits for an existing rotation lock before moving files" do
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 5)
+ @logger.info("locked rotation message")
+ lock_path = Rails.root.join("tmp", "rails_pretty_logger", "#{@log_name}.log.rotate.lock")
+ rotated_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100")
+ FileUtils.mkdir_p(lock_path.dirname)
+ lock_file = File.open(lock_path, File::RDWR | File::CREAT, 0644)
+ lock_file.flock(File::LOCK_EX)
+ error = nil
+
+ thread = Thread.new do
+ logdev.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+ rescue => exception
+ error = exception
+ end
+
+ sleep 0.2
+
+ assert thread.alive?
+ assert_not File.exist?(rotated_file)
+
+ lock_file.flock(File::LOCK_UN)
+ thread.join(2)
+
+ assert_nil error
+ assert_not thread.alive?
+ assert_path_exists rotated_file
+ assert_includes File.read(rotated_file), "locked rotation message"
+ ensure
+ thread&.kill if thread&.alive?
+ lock_file&.flock(File::LOCK_UN) rescue nil
+ lock_file&.close
+ end
+
+ test "concurrent rotations use unique destination files" do
+ @logger = ConsoleLogger.new(@log_name, "hourly", file_count: 5)
+ second_logger = ConsoleLogger.new(@log_name, "hourly", file_count: 5)
+ @logger.info("first concurrent message")
+ second_logger.info("second concurrent message")
+ errors = Queue.new
+ ready = Queue.new
+ start = Queue.new
+ devices = [logdev, second_logger.instance_variable_get(:@logdev)]
+
+ threads = devices.map do |device|
+ Thread.new do
+ ready << true
+ start.pop
+ device.shift_log_period(Time.local(2026, 5, 10, 11, 0, 0))
+ rescue => exception
+ errors << exception
+ end
+ end
+
+ devices.length.times { ready.pop }
+ devices.length.times { start << true }
+ threads.each { |thread| thread.join(2) }
+ exceptions = []
+ exceptions << errors.pop until errors.empty?
+
+ rotated_files = Dir[Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1100*")]
+
+ assert threads.none?(&:alive?)
+ assert_empty exceptions
+ assert_equal 2, rotated_files.count
+ assert_equal rotated_files.uniq.sort, rotated_files.sort
+ ensure
+ second_logger&.close
+ threads&.each { |thread| thread.kill if thread.alive? }
+ end
+
test "keeps hourly rotated files within file_count" do
old_file = Rails.root.join("log", "hourly", "2026", "05", "10", "#{@log_name}.log.20260510_1000")
FileUtils.mkdir_p(old_file.dirname)
@@ -105,6 +202,24 @@ class RailsLoggerTest < ActiveSupport::TestCase
def logdev
@logger.instance_variable_get(:@logdev)
end
+
+ def with_timezone(zone)
+ old_tz = ENV["TZ"]
+ old_tzdir = ENV["TZDIR"]
+ ENV["TZDIR"] = timezone_data_path
+ ENV["TZ"] = zone
+ yield
+ ensure
+ old_tz.nil? ? ENV.delete("TZ") : ENV["TZ"] = old_tz
+ old_tzdir.nil? ? ENV.delete("TZDIR") : ENV["TZDIR"] = old_tzdir
+ end
+
+ def timezone_data_path
+ @timezone_data_path ||= begin
+ Dir["/nix/store/*tzdata*/share/zoneinfo"].find { |path| File.exist?(File.join(path, "America", "New_York")) } ||
+ ("/usr/share/zoneinfo" if File.exist?("/usr/share/zoneinfo/America/New_York"))
+ end
+ end
end
end
end
From 73e7116e6e8ef30543dcb6e2b3ae191c563c2a2b Mon Sep 17 00:00:00 2001
From: mehmet celik
Date: Sun, 10 May 2026 22:43:45 +0300
Subject: [PATCH 05/15] Add logger configuration API
---
README.md | 21 ++++++--
.../pretty/logger/application_controller.rb | 15 +++++-
.../pretty/logger/dashboards_controller.rb | 3 +-
.../pretty/logger/hourly_logs_controller.rb | 1 +
.../rails/pretty/logger/application_helper.rb | 4 ++
.../pretty/logger/dashboards/index.html.erb | 14 +++---
.../pretty/logger/dashboards/logs.html.erb | 2 +-
.../pretty/logger/hourly_logs/logs.html.erb | 2 +-
lib/rails/pretty/logger.rb | 22 +++++++++
lib/rails/pretty/logger/configuration.rb | 16 +++++++
test/integration/dashboard_test.rb | 48 +++++++++++++++++--
.../rails/pretty/logger/configuration_test.rb | 31 ++++++++++++
test/test_helper.rb | 7 +++
13 files changed, 170 insertions(+), 16 deletions(-)
create mode 100644 lib/rails/pretty/logger/configuration.rb
create mode 100644 test/rails/pretty/logger/configuration_test.rb
diff --git a/README.md b/README.md
index 15ce6f6..2faac28 100644
--- a/README.md
+++ b/README.md
@@ -101,13 +101,28 @@ For production use, protect it with whatever authentication or authorization you
```ruby
# config/initializers/rails_pretty_logger.rb
-Rails.application.config.x.rails_pretty_logger.authenticate_with = -> {
- authenticate_user!
-}
+Rails::Pretty::Logger.configure do |config|
+ config.authenticate_with = -> { authenticate_user! }
+end
```
The hook is evaluated inside the engine controller, so controller helpers such as `authenticate_user!`, `current_user`, `head`, and `redirect_to` are available when your application defines them.
+### Configuration
+
+Rails Pretty Logger can be configured from an initializer:
+
+```ruby
+# config/initializers/rails_pretty_logger.rb
+Rails::Pretty::Logger.configure do |config|
+ config.authenticate_with = -> { authenticate_user! }
+ config.read_only = Rails.env.production?
+ config.max_file_size = 50.megabytes
+end
+```
+
+`read_only` hides clear buttons and returns `403 Forbidden` from clear endpoints. `max_file_size` is optional; when set, files larger than the limit return `413 Payload Too Large` instead of being read through the dashboard.
+
## Contributing
This project uses a Nix flake and direnv for local development:
diff --git a/app/controllers/rails/pretty/logger/application_controller.rb b/app/controllers/rails/pretty/logger/application_controller.rb
index ce99759..2347813 100644
--- a/app/controllers/rails/pretty/logger/application_controller.rb
+++ b/app/controllers/rails/pretty/logger/application_controller.rb
@@ -10,17 +10,30 @@ class ApplicationController < ActionController::Base
before_action :authenticate_rails_pretty_logger
rescue_from Rails::Pretty::Logger::PrettyLogger::InvalidLogFile, with: :invalid_log_file
+ rescue_from Rails::Pretty::Logger::PrettyLogger::FileTooLarge, with: :log_file_too_large
private
def authenticate_rails_pretty_logger
- auth_hook = Rails.application.config.x.rails_pretty_logger.authenticate_with
+ auth_hook = Rails::Pretty::Logger.configuration.authenticate_with || legacy_authenticate_with
instance_exec(&auth_hook) if auth_hook.respond_to?(:call)
end
+ def ensure_writable_rails_pretty_logger
+ head :forbidden if Rails::Pretty::Logger.configuration.read_only?
+ end
+
def invalid_log_file
render plain: "Invalid log file", status: :bad_request
end
+
+ def log_file_too_large
+ render plain: "Log file is too large", status: 413
+ end
+
+ def legacy_authenticate_with
+ Rails.application.config.x.rails_pretty_logger.authenticate_with
+ end
end
end
end
diff --git a/app/controllers/rails/pretty/logger/dashboards_controller.rb b/app/controllers/rails/pretty/logger/dashboards_controller.rb
index 703c1e3..787e700 100644
--- a/app/controllers/rails/pretty/logger/dashboards_controller.rb
+++ b/app/controllers/rails/pretty/logger/dashboards_controller.rb
@@ -1,8 +1,9 @@
require_dependency "rails/pretty/logger/application_controller"
-module Rails::Pretty::Logger
+ module Rails::Pretty::Logger
class DashboardsController < ApplicationController
before_action :set_logger, except: [:index]
+ before_action :ensure_writable_rails_pretty_logger, only: [:clear_logs]
def logs
@log_data = @log.log_data
diff --git a/app/controllers/rails/pretty/logger/hourly_logs_controller.rb b/app/controllers/rails/pretty/logger/hourly_logs_controller.rb
index 105d370..ada3d65 100644
--- a/app/controllers/rails/pretty/logger/hourly_logs_controller.rb
+++ b/app/controllers/rails/pretty/logger/hourly_logs_controller.rb
@@ -5,6 +5,7 @@ class HourlyLogsController < ApplicationController
PER_PAGE = 12
before_action :set_logger, except: [:index]
+ before_action :ensure_writable_rails_pretty_logger, only: [:clear_logs]
def logs
@log_data = @log.log_data
diff --git a/app/helpers/rails/pretty/logger/application_helper.rb b/app/helpers/rails/pretty/logger/application_helper.rb
index b6327d2..34acd2a 100644
--- a/app/helpers/rails/pretty/logger/application_helper.rb
+++ b/app/helpers/rails/pretty/logger/application_helper.rb
@@ -9,5 +9,9 @@ def trim_name(name)
index = name.split("/log/").last.capitalize
end
+ def rails_pretty_logger_read_only?
+ Rails::Pretty::Logger.configuration.read_only?
+ end
+
end
end
diff --git a/app/views/rails/pretty/logger/dashboards/index.html.erb b/app/views/rails/pretty/logger/dashboards/index.html.erb
index c5e7848..a5a8fbc 100644
--- a/app/views/rails/pretty/logger/dashboards/index.html.erb
+++ b/app/views/rails/pretty/logger/dashboards/index.html.erb
@@ -20,12 +20,14 @@
html_options = {class: "dashboard_button",
data: { confirm: "Log file size is #{ value[:file_size] } MB. Are you sure to open this file? " }}) %>
- <%= button_to("x",
- clear_logs_dashboards_path(log_file: value.fetch(:file_name)),
- method: :post,
- class: "clear_logs",
- form: { class: "clear_logs_form",
- data: { turbo_confirm: "Are you sure to clear all logs from #{value.fetch(:file_name).capitalize}? " }}) %>
+ <% unless rails_pretty_logger_read_only? %>
+ <%= button_to("x",
+ clear_logs_dashboards_path(log_file: value.fetch(:file_name)),
+ method: :post,
+ class: "clear_logs",
+ form: { class: "clear_logs_form",
+ data: { turbo_confirm: "Are you sure to clear all logs from #{value.fetch(:file_name).capitalize}? " }}) %>
+ <% end %>
<% end %>
diff --git a/app/views/rails/pretty/logger/dashboards/logs.html.erb b/app/views/rails/pretty/logger/dashboards/logs.html.erb
index 8153038..c39de70 100644
--- a/app/views/rails/pretty/logger/dashboards/logs.html.erb
+++ b/app/views/rails/pretty/logger/dashboards/logs.html.erb
@@ -15,7 +15,7 @@
<%- end%>
-<% if @log_data[:logs_count] > 0 %>
+<% if @log_data[:logs_count] > 0 && !rails_pretty_logger_read_only? %>