diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fef4172..84fe362 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,13 @@ jobs: strategy: fail-fast: false matrix: - rails-version: - - "~> 7.1.0" - - "~> 7.2.0" - - "~> 8.0.0" + include: + - rails-version: "~> 7.1.0" + ruby-shell: ruby33 + - rails-version: "~> 8.0.0" + ruby-shell: ruby33 + - rails-version: "~> 8.1.0" + ruby-shell: ruby33 env: RAILS_VERSION: ${{ matrix.rails-version }} @@ -34,13 +37,13 @@ jobs: uses: cachix/install-nix-action@v31 - name: Install gems - run: nix develop -c bundle install + run: nix develop .#${{ matrix.ruby-shell }} -c bundle install - name: Run Ruby tests - run: nix develop -c bundle exec rails test + run: nix develop .#${{ matrix.ruby-shell }} -c bundle exec rails test - name: Run browser tests - run: nix develop -c bundle exec ruby -Itest test/system/rails_pretty_logger_interaction_test.rb + run: nix develop .#${{ matrix.ruby-shell }} -c bundle exec ruby -Itest test/system/rails_pretty_logger_interaction_test.rb - name: Build gem - run: nix develop -c gem build rails-pretty-logger.gemspec + run: nix develop .#${{ matrix.ruby-shell }} -c gem build rails-pretty-logger.gemspec diff --git a/README.md b/README.md index 4d36074..92d3e9e 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,246 @@ # Rails::Pretty::Logger -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. +Rails Pretty Logger is a Rails engine for browsing application logs from a mounted dashboard. The current line supports Ruby 3.3+, Rails 7.1, and Rails 8, with log filtering, tailing, request grouping, structured JSON rendering, safe clear actions, and optional hourly log rotation. -## Usage -visit http://your-webpage/rails-pretty-logger/dashboards/ then choose your log file, search with date range. -![](log_file.gif) +## Index + +- [Feature overview](#feature-overview) +- [Compatibility](#compatibility) +- [Installation](#installation) +- [Usage](#usage) +- [Dashboard security](#dashboard-security) +- [Configuration](#configuration) +- [Custom log formats](#custom-log-formats) +- [Highlighting](#highlighting) +- [Hourly log rotation](#hourly-log-rotation) +- [Performance and safety](#performance-and-safety) +- [Dependency policy](#dependency-policy) +- [Asset loading](#asset-loading) +- [Development and CI](#development-and-ci) +- [License](#license) + +## Feature overview + +- Mounted Rails engine dashboard for files under `Rails.root/log`. +- Main log and hourly rotated log browsers. +- Date range filtering, content search, severity filtering, and configurable pagination. +- Tail mode that reads the last configured number of lines without loading the whole file. +- Rails request grouping for `Started ...` / `Completed ...` log blocks. +- Structured JSON line rendering with extracted timestamp, severity, message, and metadata. +- Custom parser hook for non-standard log formats. +- English and Turkish locale files. +- `[HIGHLIGHT]` helper support for visually marked log entries. +- Clear log actions with `read_only` protection. +- Safe log file resolution that rejects missing files, invalid paths, and paths outside the app log directory. +- Optional file size guard for large logs. +- Memory and disk-backed line offset indexes for faster pagination and request grouping after the first scan. + +## Compatibility -#### How to use debug Highlighter +| Gem version | Ruby | Rails | Notes | +| --- | --- | --- | --- | +| `0.3.x` | `>= 3.3` | `>= 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. | + +## Installation + +Add this line to your application's Gemfile: +```ruby +gem "rails-pretty-logger" ``` -PrettyLogger.highlight("lorem ipsum") + +For Rails 5 or Rails 6.1 applications, pin the legacy version: + +```ruby +gem "rails-pretty-logger", "0.2.8" ``` -![](highlight.gif) -#### Use Hourly Log Rotation +Then install the bundle: -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. +```bash +bundle install +``` -Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_count: 48) +Run the install generator: -``` -#/config/environments/development.rb +```bash +bin/rails generate rails_pretty_logger:install +``` -require "rails/pretty/logger/config/logger_config" +The generator creates `config/initializers/rails_pretty_logger.rb`, mounts the engine in `config/routes.rb`, and links the engine JavaScript in `app/assets/config/manifest.js` when the host app has a Sprockets manifest. -logger_file = ActiveSupport::TaggedLogging.new(Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_count: 48)) -config.logger = logger_file -``` -![](hour.gif) +You can also mount the engine manually: -#### Split your old logs by hourly +```ruby +mount Rails::Pretty::Logger::Engine => "/rails-pretty-logger" +``` -If you want split your old log files by hourly you can use this rake task below at terminal +## Usage -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 +Visit `/rails-pretty-logger` or `/rails-pretty-logger/dashboards` after mounting the engine. The exact prefix depends on the path you choose in `config/routes.rb`. -for bash usage ```rake app:split_log["new_log_file_name","/path/to/your/log.file"]``` +The dashboard can: -for zch usage ```noglob rake app:split_log["new_log_file_name","/path/to/your/log.file"]``` +- list regular log files from `log/`; +- list hourly rotated files from `log/hourly/`; +- filter log lines by date range, content query, and severity; +- switch between paginated view and tail view; +- group standard Rails request logs; +- render JSON line logs as structured entries; +- clear selected logs when `read_only` is disabled. -## Installation -Add this line to your application's Gemfile: +Severity filtering recognizes `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`, and `UNKNOWN`. For structured JSON logs it checks `severity`, `level`, `log_level`, and nested `log.level` values. + +## Dashboard security +Rails Pretty Logger does not provide its own authentication system. The dashboard can read application logs and, unless `read_only` is enabled, clear log files. Do not expose it publicly without protecting the mount. + +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? ``` -gem "rails-pretty-logger" + +For protected environments, use the authentication or authorization your application already has: + +```ruby +# config/initializers/rails_pretty_logger.rb +Rails::Pretty::Logger.configure do |config| + config.authenticate_with = -> { authenticate_user! } +end ``` -And then execute: -```bash -$ bundle +The hook runs inside the engine controller, so application controller helpers such as `authenticate_user!`, `current_user`, `head`, and `redirect_to` are available when your app defines them. For apps without an admin model, keep the engine development-only or use whichever internal access check already exists in the app. + +## 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 + config.tail_lines = 500 + config.log_line_parser = nil +end ``` -Or install it yourself as: -```bash -$ gem install rails-pretty-logger +| Option | Default | Description | +| --- | --- | --- | +| `authenticate_with` | `nil` | Optional callable run before every engine action. | +| `read_only` | `true` in production, `false` elsewhere | Hides clear buttons and returns `403 Forbidden` from clear endpoints. | +| `max_file_size` | `nil` in the gem, `50.megabytes` in the generated initializer | Returns `413 Payload Too Large` instead of reading files above the limit. | +| `tail_lines` | `500` | Number of lines shown in tail mode. | +| `log_line_parser` | `nil` | Optional callable for extracting metadata from custom log lines. | + +## Custom log formats + +JSON line logs are detected automatically when each line is a JSON object. Common keys such as `@timestamp`, `timestamp`, `time`, `datetime`, `created_at`, `severity`, `level`, `log_level`, `message`, and `msg` are rendered prominently. Other JSON keys are shown as metadata. + +For non-JSON formats, configure a parser that returns metadata for lines it understands: + +```ruby +Rails::Pretty::Logger.configure do |config| + config.log_line_parser = ->(line) do + if (match = line.match(/\A(?\S+) (?\w+) (?[A-Z]+) (?\S+) (?.*)/)) + { + timestamp: match[:timestamp], + severity: match[:severity], + request_method: match[:method], + request_path: match[:path] + } + end + end +end ``` -Mount the engine in your config/routes.rb: +Supported parser keys include `:timestamp`, `:time`, `:datetime`, `:created_at`, `:severity`, `:level`, `:log_level`, `:request_method`, `:method`, `:request_path`, `:path`, `:request_ip`, `:ip`, `:request_started_at`, `:started_at`, `:response_status`, `:status`, `:duration`, and `:request_duration`. + +These keys power date filtering, severity filtering, request grouping, and request summaries. + +## Highlighting + +Use the helper below to write a highlighted log entry: + +```ruby +Rails::Pretty::Logger::PrettyLogger.highlight("lorem ipsum") ``` -mount Rails::Pretty::Logger::Engine => "/rails-pretty-logger" + +Highlighted lines are written with the `[HIGHLIGHT]` tag and rendered with dashboard highlight styling. + +## Hourly log rotation + +Rails Pretty Logger can replace the Rails logger with a logger that rotates files hourly: + +```ruby +# config/environments/development.rb +require "rails/pretty/logger/console_logger" + +config.logger = ActiveSupport::TaggedLogging.new( + Rails::Pretty::Logger::ConsoleLogger.new("rails-pretty-logger", "hourly", file_count: 48) +) ``` -## Contributing +Hourly files are moved under `log/hourly/YYYY/MM/DD/`. `file_count` controls how many rotated hourly files are kept for that logger prefix. Rotation uses a lock file under `tmp/rails_pretty_logger/` and removes empty date directories when old hourly files are deleted. -This project uses a Nix flake and direnv for local development: +To split an existing log file into hourly files, use the rake task below. The first argument is the new file prefix and the second argument is the full path of the log file to split. + +For bash: + +```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] +``` + +## Performance and safety + +Log file paths are resolved under `Rails.root/log`; invalid paths, missing files, and symlinks that escape the log directory are rejected. + +Paginated reads and request grouping use line offset indexes instead of keeping the full selected page in memory. Indexes are cached in memory and persisted under `tmp/cache/rails_pretty_logger/line_indexes`. Cache entries are keyed by the file signature, filters, and parser identity, and clear actions invalidate the related indexes. + +The first index build still scans the selected log once. Tail mode avoids that cost when you only need the latest entries because it reads backwards from the end of the file. + +## Dependency policy + +Runtime dependencies are limited to Rails framework gems: + +- `actionpack` +- `actionview` +- `activesupport` +- `railties` + +The dashboard JavaScript is plain JavaScript and does not require a runtime Node package manager dependency. Browser tests use Playwright only in development/test. + +## Asset loading + +The engine layout loads its JavaScript directly with `javascript_include_tag "rails/pretty/logger/application"`. The install generator links this file in `app/assets/config/manifest.js` when the host app has a Sprockets manifest. + +Importmap pins are not generated because the engine does not use the host app's JavaScript entrypoint. For standard Rails asset pipeline apps, no manual importmap setup is needed. If your app has a custom asset setup or strict CSP, make sure `rails/pretty/logger/application.js` is available through the asset pipeline and allowed by your policy. + +## Development and CI + +This project uses a Nix flake and direnv for local development. After allowing direnv once, commands run inside the project shell automatically: ```bash direnv allow bundle install bundle exec rails test bundle exec ruby -Itest test/system/rails_pretty_logger_interaction_test.rb +gem build rails-pretty-logger.gemspec ``` -CI runs the same test suite against Rails 7.1, 7.2, and 8.0 before PRs and pushes to `main` or `master`. +System tests use Capybara with Playwright Chromium. + +GitHub Actions runs on pull requests and pushes to `main` or `master`. The CI matrix runs Rails 7.1, 8.0, and 8.1 on Ruby 3.3, then executes Ruby tests, browser tests, and gem build checks inside the Nix shell. 1. [Fork][fork] the [official repository][repo]. 2. [Create a topic branch.][branch] @@ -81,8 +249,8 @@ CI runs the same test suite against Rails 7.1, 7.2, and 8.0 before PRs and pushe 5. [Submit a pull request.][pr] ## License -The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). [repo]: https://github.com/MehmetCelik4/rails-pretty-logger/tree/master [fork]: https://help.github.com/articles/fork-a-repo/ 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/assets/stylesheets/rails/pretty/logger/dashboards.css b/app/assets/stylesheets/rails/pretty/logger/dashboards.css index 1b7b8a2..de5c323 100644 --- a/app/assets/stylesheets/rails/pretty/logger/dashboards.css +++ b/app/assets/stylesheets/rails/pretty/logger/dashboards.css @@ -1,196 +1,502 @@ -/* colors: - #ac0010 - red - #89000C - dark red - #f1f1f1 - light grey - #515151 - dark gray - #ffffff - white -*/ body { - color: #000000; - font-family:Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; - font-size:16px; - padding: 0 30px; - margin: 0; background-color: #f1f1f1; + color: #1f2328; + font-family: Gotham, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 15px; + margin: 0; + padding: 0 24px 32px; } + a { - display:inline-block; + color: #2f5f75; text-decoration: none; - background-color: #f1f1f1; - color: #515151; - padding: 5px 5px; - margin: 3px; - border-radius: 5px; - border: #515151 1px solid; - font-family:Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; } + a:hover { - background-color: #89000C; - color: #ffffff; - border: #ffffff 1px solid; -} -.active { - background-color: #ffffff!important; - color: #ac0010!important; - border: #89000C 1px solid!important; + color: #163f52; + text-decoration: underline; } + hr { - margin: 8px 0; - border:0; - border-bottom: #ffffff 1px solid; + border: 0; + border-bottom: 1px solid #d8dee4; + margin: 14px 0; } + .logger_navbar { - background-color: #ac0010; - padding: 20px 30px 10px; - margin: 0 -30px; - color: #f1f1f1; + align-items: center; + background: #24292f; + border-bottom: 3px solid #ac0010; + color: #ffffff; + display: flex; + gap: 16px; + justify-content: space-between; + margin: 0 -24px 20px; + min-height: 68px; + padding: 14px 24px; } -.logger_navbar > p { - margin-bottom: 20px; - margin-left: -8px; + +.logger_navbar__heading h1 { + font-size: 22px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.2; + margin: 0; } -.logger_navbar > p > .dashboard_button { - padding: 8px 10px; - margin: auto 20px auto auto; - color: #f1f1f1; - background-color: #ac0010; - border-radius: 5px; - text-transform: uppercase; - text-decoration: none; - font-family:Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; - border:none; + +.logger_navbar__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; } -.logger_navbar > p > .dashboard_button:hover { - background-color: #89000C; - border: none; + +.dashboard_button, +.logger_navbar__actions a, +.log-toolbar a, +.sort { + align-items: center; + background: #ffffff; + border: 1px solid #c9d1d9; + border-radius: 6px; + color: #24292f; + display: inline-flex; + font-family: Gotham, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 1.2; + min-height: 34px; + padding: 7px 11px; + text-decoration: none; } -.logger_navbar > a { - display: inline-block; + +.dashboard_button:hover, +.logger_navbar__actions a:hover, +.log-toolbar a:hover, +.sort:hover { + background: #f6f8fa; + border-color: #8c959f; + color: #111111; text-decoration: none; - background-color: #ac0010; - color: #f1f1f1; - padding: 8px 10px; - margin: auto auto 10px 20px; - border-radius: 5px; - border: #f1f1f1 1px solid; } -.logger_navbar > .dashboard_button { - border-radius: 5px 0 0 5px; + +.active, +.log-file-strip a.active { + background: #fff5f5; + border-color: #ac0010; + color: #89000c; +} + +.log-file-strip { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 16px; +} + +.log-file-strip__label, +.log-file-list__header h2 { + color: #57606a; + font-size: 13px; + font-weight: 700; + letter-spacing: 0; + margin: 0; + text-transform: uppercase; } -.logger_navbar > .clear_logs { - display: inline-block; +.log-file-strip a { + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + color: #24292f; + font-size: 13px; + padding: 6px 9px; text-decoration: none; - background-color: #ac0010; - color: #f1f1f1; - padding: 8px 10px; - margin: auto auto auto -5px; - border-radius: 0 5px 5px 0; - border: #f1f1f1 1px solid; } -.logger_navbar > a:hover { - color:#f1f1f1; - background-color:#89000C; + +.log-file-strip a:hover { + border-color: #8c959f; + text-decoration: none; +} + +.log-file-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 18px 0 32px; + padding: 0; +} + +.log-file-list__header { + margin-bottom: 2px; +} + +.log-file-row { + align-items: center; + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + display: flex; + gap: 16px; + justify-content: space-between; + list-style: none; + padding: 12px 14px; +} + +.log-file-row__main { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.log-file-row__name { + color: #24292f; + font-size: 15px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.log-file-row__meta { + color: #57606a; + font-size: 12px; +} + +.empty-state { + align-items: center; + display: flex; + justify-content: center; + min-height: 220px; + text-align: center; +} + +.empty-state--inline { + min-height: 140px; +} + +.message { + color: #57606a; + font-size: 20px; + font-weight: 700; + letter-spacing: 0; + margin: 0; +} + +.form-group, +.filter-panel { + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + margin: 16px 0; + padding: 14px; +} + +.form-group p { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; +} + +.form-error { + background: #fff5f5; + border: 1px solid #ffb3b3; + border-radius: 6px; + color: #89000c; + font-weight: 700; + margin: 14px 0; + padding: 10px 12px; +} + +.log-controls { + display: flex; + flex-direction: column; + gap: 12px; +} + +.log-toolbar, +.log_filters, +.hourly_filters { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.log_filters label, +.hourly_filters label, +.form-group label { + color: #57606a; + font-size: 13px; + font-weight: 700; +} + +input, +select { + background: #ffffff; + border: 1px solid #c9d1d9; + border-radius: 6px; + color: #24292f; + font-family: Gotham, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.2; + margin: 0; + min-height: 34px; + padding: 7px 10px; +} + +input:focus, +select:focus { + border-color: #2f81f7; + outline: 2px solid rgba(47, 129, 247, 0.2); +} + +input[type="submit"], +button.clear_logs { + background: #24292f; + border: 1px solid #24292f; + border-radius: 6px; + color: #ffffff; + cursor: pointer; + font-family: Gotham, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-weight: 700; + min-height: 34px; + padding: 7px 12px; } -.logger_navbar > .clear_logs:hover { - color: #89000C; - background-color: #FFFFFF; + +input[type="submit"]:hover { + background: #3b424a; } + .clear_logs_form { - display: inline; + display: inline-flex; + margin: 0; } + .clear_logs_form div { - display: inline; + display: inline-flex; } + button.clear_logs { - cursor: pointer; - font-family:Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; + background: #ac0010; + border-color: #ac0010; + white-space: nowrap; } -p { - padding: 6px 0; - font-family: Consolas, "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", Monaco, "Courier New", "monospace"; - font-size:16px; + +button.clear_logs:hover { + background: #89000c; + border-color: #89000c; } -.form-group { - background-color: #dadada; - padding: 5px 20px; - margin: 15px 0; - border-radius: 5px; + +.pagination { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 14px 0; + padding: 0; } -.form-group p { - color: #515151; - font-family:Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; + +.pagination li { + display: inline-flex; + list-style: none; } -input { - border-radius: 2px; - border: none; - padding: 8px 10px; - margin: 3px 20px 3px 3px; - font-family: Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; - color: #5E5E5E; + +.pagination a { + align-items: center; + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + color: #24292f; + display: inline-flex; + font-size: 13px; + font-weight: 700; + justify-content: center; + min-height: 32px; + min-width: 32px; + text-decoration: none; +} + +.pagination a:hover { + border-color: #8c959f; + text-decoration: none; +} + +.log-entries { + display: flex; + flex-direction: column; + gap: 8px; + margin: 16px 0 32px; +} + +.log-line, +.structured-log, +.log-request { + background: #ffffff; + border: 1px solid #d0d7de; + border-radius: 6px; + color: #24292f; + font-family: Consolas, "Andale Mono", "Lucida Console", Monaco, "Courier New", monospace; font-size: 14px; + line-height: 1.45; + overflow-wrap: anywhere; } -input[type=submit]{ - font-size:16px; - padding: 8px 10px; - border-radius: 3px; - background-color: #5E5E5E; - color: #f1f1f1; - border: none; -} -input[type=submit]:hover { - background-color: #437085; - color: #f1f1f1; -} -.form-group .clear_logs { - background-color: #ac0010; - border: none; - color: #f1f1f1; -} -.form-group .clear_logs:hover { - background-color: #89000C; -} -.hourly-list .search { - margin: 30px 20px 30px 50px; - border-radius: 2px; - border: #515151 1px solid; + +.log-line { padding: 8px 10px; - font-family: Gotham, "Helvetica Neue", Helvetica, Arial, "sans-serif"; - color: #5E5E5E; - font-size: 14px; + white-space: pre-wrap; +} + +.log-line--parameters { + background: #fbfbfb; +} + +.highlight { + background: #fff1b8; + border-color: #d4a72c; } -.hourly-list .hourly_filters { + +.structured-log { + border-left: 4px solid #2f5f75; + padding: 10px 12px; +} + +.structured-log--error, +.structured-log--fatal { + border-left-color: #ac0010; +} + +.structured-log--warn { + border-left-color: #a46b00; +} + +.structured-log__header { + align-items: baseline; display: flex; - align-items: center; - gap: 10px; flex-wrap: wrap; + gap: 8px; +} + +.structured-log__severity, +.log-request__status { + background: #57606a; + border-radius: 4px; + color: #ffffff; + font-family: Gotham, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: 700; + padding: 2px 6px; } -.hourly-list .sort { - font-size:16px; - padding: 8px 20px; - border-radius: 3px; - background-color: #5E5E5E; - color: #f1f1f1; - border: none; + +.structured-log__timestamp, +.log-request__duration { + color: #57606a; + font-size: 12px; } -.hourly-list .sort:hover { - background-color: #437085; - color: #f1f1f1; + +.structured-log__message { + color: #24292f; } -.hourly-list .list { - padding-left: 50px; + +.structured-log__metadata { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + margin: 8px 0 0; + row-gap: 4px; } -.hourly-list .list li{ - list-style: none; - display: block; - width: auto; - margin-right: 50px; - float: left; + +.structured-log__metadata dt { + color: #57606a; + font-weight: 700; + padding-right: 12px; } -.hourly-list .list li h5 { - margin-top: 30px; - margin-bottom: 30px; + +.structured-log__metadata dd { + margin: 0; + overflow-wrap: anywhere; } -.highlight { - background-color:#ffb62a; + +.log-request { + overflow: hidden; +} + +.log-request--error { + border-left: 4px solid #ac0010; +} + +.log-request__summary { + align-items: center; + background: #ffffff; + cursor: pointer; + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px 12px; +} + +.log-request__method { + background: #2f5f75; + border-radius: 4px; + color: #ffffff; + font-family: Gotham, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: 700; + padding: 2px 6px; +} + +.log-request__path { + font-weight: 700; +} + +.log-request__body { + background: #fbfbfb; + border-top: 1px solid #d8dee4; + margin: 0; + overflow-x: auto; + padding: 10px 12px; + white-space: pre-wrap; +} + +@media (max-width: 720px) { + body { + padding: 0 12px 24px; + } + + .logger_navbar { + align-items: stretch; + flex-direction: column; + margin: 0 -12px 16px; + } + + .logger_navbar__actions, + .log-toolbar, + .log_filters, + .hourly_filters, + .form-group p { + align-items: stretch; + flex-direction: column; + } + + .dashboard_button, + .logger_navbar__actions a, + .log-toolbar a, + .sort, + input, + select, + input[type="submit"], + button.clear_logs { + justify-content: center; + width: 100%; + } + + .log-file-row { + align-items: stretch; + flex-direction: column; + } + + .structured-log__metadata { + grid-template-columns: minmax(0, 1fr); + } } diff --git a/app/assets/stylesheets/rails/pretty/logger/list.css b/app/assets/stylesheets/rails/pretty/logger/list.css index 7c7a5fa..b2d4bc8 100644 --- a/app/assets/stylesheets/rails/pretty/logger/list.css +++ b/app/assets/stylesheets/rails/pretty/logger/list.css @@ -1,94 +1 @@ -.clear_logs { - background-color: #F62817 !important ; - color: white; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 14px; -} - - - -.message{ - text-align: center; -} - - -.list { - font-family:sans-serif; -} -td { - padding:10px; - border:solid 1px #eee; -} - -input { - border:solid 1px #ccc; - border-radius: 5px; - padding:7px 14px; - margin-bottom:10px -} -input:focus { - outline:none; - border-color:#aaa; -} -.sort { - padding:8px 30px; - border-radius: 6px; - border:none; - display:inline-block; - color:#fff; - text-decoration: none; - background-color: #28a8e0; - height:30px; -} -.sort:hover { - text-decoration: none; - background-color:#1b8aba; -} -.sort:focus { - outline:none; -} -.sort:after { - display:inline-block; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-bottom: 5px solid transparent; - content:""; - position: relative; - top:-10px; - right:-5px; -} -.sort.asc:after { - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 5px solid #fff; - content:""; - position: relative; - top:4px; - right:-5px; -} -.sort.desc:after { - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-bottom: 5px solid #fff; - content:""; - position: relative; - top:-4px; - right:-5px; -} - -.pagination li { - display: inline-table; -} - -ul { - list-style-type: none; -} +/* Compatibility logical asset for applications that reference rails/pretty/logger/list.css. */ diff --git a/app/controllers/rails/pretty/logger/application_controller.rb b/app/controllers/rails/pretty/logger/application_controller.rb index cee1a2a..2347813 100644 --- a/app/controllers/rails/pretty/logger/application_controller.rb +++ b/app/controllers/rails/pretty/logger/application_controller.rb @@ -6,6 +6,34 @@ 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 + rescue_from Rails::Pretty::Logger::PrettyLogger::FileTooLarge, with: :log_file_too_large + + private + + def authenticate_rails_pretty_logger + 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 e8970e9..206ff15 100644 --- a/app/controllers/rails/pretty/logger/dashboards_controller.rb +++ b/app/controllers/rails/pretty/logger/dashboards_controller.rb @@ -1,11 +1,12 @@ 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 + @log_data = tail_mode? ? @log.tail_log_data : @log.log_data end def index @@ -14,17 +15,21 @@ 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 def dashboard_params - params.permit( :log_file, :utf8, :_method, :authenticity_token, :commit, :page, date_range: [:end, :start, :divider]) + params.permit( :log_file, :mode, :group, :query, :severity, :utf8, :_method, :authenticity_token, :commit, :page, date_range: [:end, :start, :divider]) end def set_logger @log = PrettyLogger.new(dashboard_params) end + + def tail_mode? + dashboard_params[:mode] == "tail" + end end end diff --git a/app/controllers/rails/pretty/logger/hourly_logs_controller.rb b/app/controllers/rails/pretty/logger/hourly_logs_controller.rb index 45aef27..96a70c5 100644 --- a/app/controllers/rails/pretty/logger/hourly_logs_controller.rb +++ b/app/controllers/rails/pretty/logger/hourly_logs_controller.rb @@ -5,9 +5,10 @@ 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 + @log_data = tail_mode? ? @log.tail_log_data : @log.log_data end def index @@ -25,7 +26,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 @@ -47,11 +48,15 @@ def sort_logs(logs) end def hourly_params - params.permit( :log_file, :utf8, :_method, :authenticity_token, :commit, :page, date_range: [:end, :start, :divider]) + params.permit( :log_file, :mode, :group, :query, :severity, :utf8, :_method, :authenticity_token, :commit, :page, date_range: [:end, :start, :divider]) end def set_logger @log = PrettyLogger.new(hourly_params) end + + def tail_mode? + hourly_params[:mode] == "tail" + end end end diff --git a/app/helpers/rails/pretty/logger/application_helper.rb b/app/helpers/rails/pretty/logger/application_helper.rb index b6327d2..ca61342 100644 --- a/app/helpers/rails/pretty/logger/application_helper.rb +++ b/app/helpers/rails/pretty/logger/application_helper.rb @@ -9,5 +9,37 @@ def trim_name(name) index = name.split("/log/").last.capitalize end + def rails_pretty_logger_read_only? + Rails::Pretty::Logger.configuration.read_only? + end + + def rails_pretty_logger_tail_mode? + params[:mode] == "tail" + end + + def rails_pretty_logger_request_grouping? + params[:group] == "request" + end + + def rails_pretty_logger_severity_options + [["All levels", ""]] + Rails::Pretty::Logger::PrettyLogger::SEVERITIES.map { |severity| [severity, severity] } + end + + def rails_pretty_logger_log_base_params(include_group: true) + log_params = { log_file: params[:log_file] } + log_params[:mode] = params[:mode] if params[:mode].present? + log_params[:group] = params[:group] if include_group && params[:group].present? + log_params + end + + def rails_pretty_logger_log_filter_params(include_mode: true, include_group: true) + log_params = { log_file: params[:log_file] } + log_params[:mode] = params[:mode] if include_mode && params[:mode].present? + log_params[:group] = params[:group] if include_group && params[:group].present? + log_params[:query] = params[:query] if params[:query].present? + log_params[:severity] = params[:severity] if params[:severity].present? + log_params + end + end end diff --git a/app/helpers/rails/pretty/logger/dashboards_helper.rb b/app/helpers/rails/pretty/logger/dashboards_helper.rb index 30fcbf4..1da88f6 100644 --- a/app/helpers/rails/pretty/logger/dashboards_helper.rb +++ b/app/helpers/rails/pretty/logger/dashboards_helper.rb @@ -1,11 +1,20 @@ module Rails::Pretty::Logger module DashboardsHelper + STRUCTURED_LOG_PRIMARY_KEYS = %w[@timestamp timestamp time datetime created_at severity level log_level message msg].freeze + def check_highlight(line) - return "
#{line.remove('[HIGHLIGHT]')}
".html_safe if line.include?("[HIGHLIGHT]") - if line.include?("Parameters:") - parse_parameters(line) + rails_pretty_logger_log_entry(line) + end + + def rails_pretty_logger_log_entry(entry) + return rails_pretty_logger_request_group(entry) if entry.is_a?(Hash) + return rails_pretty_logger_structured_log(entry) if rails_pretty_logger_structured_payload(entry) + return tag.div(entry.remove("[HIGHLIGHT]"), class: "log-line highlight") if entry.include?("[HIGHLIGHT]") + + if entry.include?("Parameters:") + tag.div(parse_parameters(entry), class: "log-line log-line--parameters") else - line + tag.div(entry, class: "log-line") end end @@ -36,16 +45,111 @@ 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 + + def rails_pretty_logger_request_group(group) + tag.details(class: rails_pretty_logger_request_group_classes(group), open: true) do + safe_join([ + tag.summary(rails_pretty_logger_request_summary(group), class: "log-request__summary"), + tag.pre(group.fetch(:lines).join, class: "log-request__body") + ]) + end + end + + def rails_pretty_logger_request_summary(group) + return t("rails_pretty_logger.logs.ungrouped_lines") unless group[:type] == :request + + parts = [ + tag.span(group[:method], class: "log-request__method"), + tag.span(group[:path], class: "log-request__path") + ] + parts << tag.span(group[:status], class: "log-request__status") if group[:status].present? + parts << tag.span(group[:duration], class: "log-request__duration") if group[:duration].present? + + safe_join(parts, " ") + end + + def rails_pretty_logger_request_group_classes(group) + classes = ["log-request"] + classes << "log-request--error" if group[:status].to_i >= 500 + classes.join(" ") + end + + def rails_pretty_logger_structured_log(line) + payload = rails_pretty_logger_structured_payload(line) + severity = rails_pretty_logger_structured_value(payload, *Rails::Pretty::Logger::PrettyLogger::STRUCTURED_SEVERITY_KEYS) + severity ||= rails_pretty_logger_structured_nested_log_level(payload) + timestamp = rails_pretty_logger_structured_value(payload, *Rails::Pretty::Logger::PrettyLogger::STRUCTURED_TIMESTAMP_KEYS) + message = payload["message"] || payload["msg"] || line + + tag.div(class: rails_pretty_logger_structured_log_classes(severity)) do + safe_join([ + tag.div(class: "structured-log__header") do + rails_pretty_logger_structured_header(severity, timestamp, message) + end, + rails_pretty_logger_structured_metadata(payload) + ].compact) end end + def rails_pretty_logger_structured_header(severity, timestamp, message) + parts = [] + parts << tag.span(severity, class: "structured-log__severity") if severity.present? + parts << tag.span(timestamp, class: "structured-log__timestamp") if timestamp.present? + parts << tag.strong(message, class: "structured-log__message") + + safe_join(parts) + end + + def rails_pretty_logger_structured_metadata(payload) + metadata = payload.reject { |key, _value| STRUCTURED_LOG_PRIMARY_KEYS.include?(key.to_s) } + return if metadata.blank? + + tag.dl(class: "structured-log__metadata") do + safe_join(metadata.flat_map do |key, value| + [ + tag.dt(key), + tag.dd(value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s) + ] + end) + end + end + + def rails_pretty_logger_structured_log_classes(severity) + classes = ["structured-log"] + classes << "structured-log--#{severity.to_s.downcase}" if severity.present? + classes.join(" ") + end + + def rails_pretty_logger_structured_payload(line) + Rails::Pretty::Logger::PrettyLogger.structured_log_payload(line) + end + + def rails_pretty_logger_structured_value(payload, *keys) + keys.each do |key| + return payload[key].to_s if payload[key].present? + end + + nil + end + + def rails_pretty_logger_structured_nested_log_level(payload) + nested_log = payload["log"] + return unless nested_log.respond_to?(:[]) + + nested_log["level"].to_s.upcase.presence + end end end 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/app/views/partials/_error_pagination.html.erb b/app/views/partials/_error_pagination.html.erb index c89bf4d..0e4948a 100644 --- a/app/views/partials/_error_pagination.html.erb +++ b/app/views/partials/_error_pagination.html.erb @@ -1,21 +1,23 @@ <% if log_data.fetch(:error).present? %> -

+

<%= log_data.fetch(:error) %>

<% end %> -<% if log_data.fetch(:error).blank? %> +<% if log_data.fetch(:error).blank? && log_data.fetch(:logs_count).positive? %> + <% end %> -
diff --git a/app/views/partials/_log_entries.html.erb b/app/views/partials/_log_entries.html.erb new file mode 100644 index 0000000..0ec6c7b --- /dev/null +++ b/app/views/partials/_log_entries.html.erb @@ -0,0 +1,5 @@ +
+ <% log_data.fetch(:paginated_logs).each do |entry| %> + <%= rails_pretty_logger_log_entry(entry) %> + <% end %> +
diff --git a/app/views/partials/_log_filters.html.erb b/app/views/partials/_log_filters.html.erb new file mode 100644 index 0000000..5fcc4a8 --- /dev/null +++ b/app/views/partials/_log_filters.html.erb @@ -0,0 +1,14 @@ +<%= form_with url: send(path), method: :get, scope: nil, class: "log_filters" do |form| %> + <%= form.hidden_field :log_file, value: params[:log_file] %> + <%= form.hidden_field :mode, value: params[:mode] if rails_pretty_logger_tail_mode? %> + <%= form.hidden_field :group, value: params[:group] if rails_pretty_logger_request_grouping? %> + + <%= form.label :query, t("rails_pretty_logger.filters.search") %> + <%= form.search_field :query, value: params[:query], placeholder: t("rails_pretty_logger.filters.search_log_content") %> + + <%= form.label :severity, t("rails_pretty_logger.filters.severity") %> + <%= form.select :severity, rails_pretty_logger_severity_options, selected: params[:severity] %> + + <%= form.submit t("rails_pretty_logger.filters.filter") %> + <%= link_to(t("rails_pretty_logger.filters.clear_filters"), send(path, rails_pretty_logger_log_base_params)) %> +<% end %> diff --git a/app/views/partials/_pretyyloggernavbar.html.erb b/app/views/partials/_pretyyloggernavbar.html.erb index 75dcc10..aee914f 100644 --- a/app/views/partials/_pretyyloggernavbar.html.erb +++ b/app/views/partials/_pretyyloggernavbar.html.erb @@ -1,14 +1,19 @@ -
-

- <%= link_to(path_name, hourly_logs_path, html_options = {class: "dashboard_button"}) %> -

- Log Files: +
+

<%= t("rails_pretty_logger.navigation.main_logs") %>

+
+ + +
+ + diff --git a/app/views/rails/pretty/logger/dashboards/index.html.erb b/app/views/rails/pretty/logger/dashboards/index.html.erb index c5e7848..e336f97 100644 --- a/app/views/rails/pretty/logger/dashboards/index.html.erb +++ b/app/views/rails/pretty/logger/dashboards/index.html.erb @@ -1,32 +1,48 @@ <% if @log_file_list.count == 0 %> -
-

There is no log file to show

+
+

<%= t("rails_pretty_logger.states.no_logs") %>

<% elsif is_stdout?%> -
-

"RAILS_LOG_TO_STDOUT" is present. no logs kept, remove it for logging

+
+

<%= t("rails_pretty_logger.states.stdout_logging") %>

<% else %>
-

- <%= link_to('Hourly logs', hourly_logs_path, html_options = {class: "dashboard_button"}) %> -

- Log Files: - <% @log_file_list .each do |key, value| %> - <%= link_to(trim_name(value.fetch(:file_name)), - logs_dashboards_path(log_file: value.fetch(:file_name), date_range: { start: time_now, end: time_now }), - 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}? " }}) %> - <% end %> +
+

<%= t("rails_pretty_logger.navigation.main_logs") %>

+
+
+ +
"> +
+

<%= t("rails_pretty_logger.navigation.log_files") %>

+
+ + <% @log_file_list.each do |_key, value| %> +
+
+ <%= link_to(trim_name(value.fetch(:file_name)), + logs_dashboards_path(log_file: value.fetch(:file_name), date_range: { start: time_now, end: time_now }), + html_options = {class: "log-file-row__name", + data: { confirm: t("rails_pretty_logger.confirmations.open_file", size: value[:file_size]) }}) %> + <%= t("rails_pretty_logger.logs.file_size", size: value[:file_size]) %> +
+ + <% unless rails_pretty_logger_read_only? %> + <%= button_to(t("rails_pretty_logger.actions.clear"), + clear_logs_dashboards_path(log_file: value.fetch(:file_name)), + method: :post, + class: "clear_logs", + form: { class: "clear_logs_form", + data: { turbo_confirm: t("rails_pretty_logger.confirmations.clear_file", file: value.fetch(:file_name).capitalize) }}) %> + <% end %> +
+ <% 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..c8d9dde 100644 --- a/app/views/rails/pretty/logger/dashboards/logs.html.erb +++ b/app/views/rails/pretty/logger/dashboards/logs.html.erb @@ -1,34 +1,52 @@ -<%= render "partials/pretyyloggernavbar", path_name: 'Hourly logs' %> +<%= render "partials/pretyyloggernavbar", path_name: t("rails_pretty_logger.navigation.hourly_logs") %> -<%= render "partials/error_pagination", log: @log, log_data: @log_data , locals: {path: "logs_dashboards_path"} %> +<% unless rails_pretty_logger_tail_mode? %> + <%= render "partials/error_pagination", log: @log, log_data: @log_data , locals: {path: "logs_dashboards_path"} %> +<% end %> + +
+
+ <% if rails_pretty_logger_tail_mode? %> + <%= link_to(t("rails_pretty_logger.logs.filtered_view"), logs_dashboards_path(rails_pretty_logger_log_filter_params(include_mode: false).merge(date_range: { start: @log.start_date, end: @log.end_date, divider: set_divider(params) }))) %> + <% else %> + <%= link_to(t("rails_pretty_logger.logs.tail_last_lines", count: Rails::Pretty::Logger::PrettyLogger.tail_lines), logs_dashboards_path(rails_pretty_logger_log_filter_params.merge(mode: "tail"))) %> + <% if rails_pretty_logger_request_grouping? %> + <%= link_to(t("rails_pretty_logger.logs.plain_lines"), logs_dashboards_path(rails_pretty_logger_log_filter_params(include_group: false).merge(date_range: { start: @log.start_date, end: @log.end_date, divider: set_divider(params) }))) %> + <% else %> + <%= link_to(t("rails_pretty_logger.logs.group_requests"), logs_dashboards_path(rails_pretty_logger_log_filter_params.merge(group: "request", date_range: { start: @log.start_date, end: @log.end_date, divider: set_divider(params) }))) %> + <% end %> + <% end %> +
+ + <%= render "partials/log_filters", path: "logs_dashboards_path" %> -
<%= form_for :date_range, url: logs_dashboards_path(log_file: params[:log_file]), method: :post do |f| %> + <%= hidden_field_tag :query, params[:query] if params[:query].present? %> + <%= hidden_field_tag :severity, params[:severity] if params[:severity].present? %> + <%= hidden_field_tag :group, params[:group] if params[:group].present? %>

- <%= label(:start, :title, "Start Date:") %> + <%= label(:start, :title, t("rails_pretty_logger.filters.start_date")) %> <%= f.date_field(:start, value: @log.start_date, max: Date.today) %> - <%= label(:end, :title, "End Date:") %> + <%= label(:end, :title, t("rails_pretty_logger.filters.end_date")) %> <%= f.date_field(:end, value: @log.end_date, max: Date.today) %> - <%= label(:divider, :title, "Logs per page:") %> + <%= label(:divider, :title, t("rails_pretty_logger.filters.logs_per_page")) %> <%= f.number_field(:divider, value: set_divider(params)) %> - <%= f.submit "Submit" %> + <%= f.submit t("rails_pretty_logger.actions.submit") %>

<%- end%> -<% if @log_data[:logs_count] > 0 %> +<% if @log_data[:logs_count] > 0 && !rails_pretty_logger_read_only? %>

- <%= button_to("Clear logs", + <%= button_to(t("rails_pretty_logger.actions.clear_logs"), clear_logs_dashboards_path(log_file: params[:log_file]), method: :post, class: "clear_logs", form: { class: "clear_logs_form", - data: { turbo_confirm: "Are you sure to clear all logs? " }}) %> + data: { turbo_confirm: t("rails_pretty_logger.confirmations.clear_all") }}) %>

<% end %>

-<% @log_data[:paginated_logs].each do |line| %> - <%= check_highlight(line) %>
-<% end %> +<%= render "partials/log_entries", log_data: @log_data %> diff --git a/app/views/rails/pretty/logger/hourly_logs/index.html.erb b/app/views/rails/pretty/logger/hourly_logs/index.html.erb index 242fecf..04c5a77 100644 --- a/app/views/rails/pretty/logger/hourly_logs/index.html.erb +++ b/app/views/rails/pretty/logger/hourly_logs/index.html.erb @@ -1,42 +1,48 @@ <% unless @hourly_logs_present %> -
-

There is no log file to show

+
+

<%= t("rails_pretty_logger.states.no_logs") %>

<% else %>
-
-
-

- <%= link_to('Main Logs',dashboards_path, html_options = {class: "dashboard_button"}) %> -

+
+

<%= t("rails_pretty_logger.navigation.hourly_logs") %>

+
+ +
- <%= form_with url: hourly_logs_path, method: :get, scope: nil, class: "hourly_filters" do |form| %> - <%= form.search_field :search, value: params[:search], placeholder: "Search", class: "search" %> + <%= form_with url: hourly_logs_path, method: :get, scope: nil, class: "hourly_filters filter-panel" do |form| %> + <%= form.label :search, t("rails_pretty_logger.filters.search") %> + <%= form.search_field :search, value: params[:search], placeholder: t("rails_pretty_logger.filters.search"), class: "search" %> <%= form.hidden_field :sort, value: params[:sort] %> - <%= form.submit "Search" %> - <%= link_to("Sort #{params[:sort] == 'desc' ? 'asc' : 'desc'}", + <%= form.submit t("rails_pretty_logger.filters.search") %> + <%= link_to(t("rails_pretty_logger.actions.sort", direction: params[:sort] == "desc" ? "asc" : "desc"), hourly_logs_path(search: params[:search], sort: params[:sort] == "desc" ? "asc" : "desc"), class: "sort") %> <% end %> <% if @log_file_list.empty? %> -

There is no log file to show

+
+

<%= t("rails_pretty_logger.states.no_logs") %>

+
<% end %> -
    +
      <% @log_file_list.each do |value| %> -
    • -
      +
    • +
      <%= link_to(modify_name(value.fetch(:file_name)), logs_hourly_logs_path(log_file: value.fetch(:file_name), date_range: { start: time_now, end: time_now }), - html_options = {class: "dashboard_button", - data: { confirm: "Log file size is #{ value.fetch(:file_size) } MB. Are you sure to open this file? " }}) %> -
    • + html_options = {class: "name log-file-row__name", + data: { confirm: t("rails_pretty_logger.confirmations.open_file", size: value.fetch(:file_size)) }}) %> + <%= t("rails_pretty_logger.logs.file_size", size: value.fetch(:file_size)) %> +
<% end %> diff --git a/app/views/rails/pretty/logger/hourly_logs/logs.html.erb b/app/views/rails/pretty/logger/hourly_logs/logs.html.erb index 6f8657b..6b8d445 100644 --- a/app/views/rails/pretty/logger/hourly_logs/logs.html.erb +++ b/app/views/rails/pretty/logger/hourly_logs/logs.html.erb @@ -1,36 +1,56 @@
-

- <%= link_to('Main Logs',dashboards_path, html_options = {class: "dashboard_button"}) %> - <%= link_to('Hourly Logs ', hourly_logs_path, html_options = {class: "dashboard_button"}) %> -

+
+

<%= t("rails_pretty_logger.navigation.hourly_logs") %>

+
+ +
-
+ <% if @log_data[:logs_count] > 0 %> +<% unless rails_pretty_logger_tail_mode? %> <%= render "partials/error_pagination", log: @log, log_data: @log_data, locals: {path: "logs_hourly_logs_path"} %> +<% end %> - <% if @log_data[:logs_count] > 0 %> +
+
+ <% if rails_pretty_logger_tail_mode? %> + <%= link_to(t("rails_pretty_logger.logs.filtered_view"), logs_hourly_logs_path(rails_pretty_logger_log_filter_params(include_mode: false).merge(date_range: { start: @log.start_date, end: @log.end_date, divider: set_divider(params) }))) %> + <% else %> + <%= link_to(t("rails_pretty_logger.logs.tail_last_lines", count: Rails::Pretty::Logger::PrettyLogger.tail_lines), logs_hourly_logs_path(rails_pretty_logger_log_filter_params.merge(mode: "tail"))) %> + <% if rails_pretty_logger_request_grouping? %> + <%= link_to(t("rails_pretty_logger.logs.plain_lines"), logs_hourly_logs_path(rails_pretty_logger_log_filter_params(include_group: false).merge(date_range: { start: @log.start_date, end: @log.end_date, divider: set_divider(params) }))) %> + <% else %> + <%= link_to(t("rails_pretty_logger.logs.group_requests"), logs_hourly_logs_path(rails_pretty_logger_log_filter_params.merge(group: "request", date_range: { start: @log.start_date, end: @log.end_date, divider: set_divider(params) }))) %> + <% end %> + <% end %> +
+ + <%= render "partials/log_filters", path: "logs_hourly_logs_path" %> + + <% if @log_data[:logs_count] > 0 && !rails_pretty_logger_read_only? %>

- <%= button_to("Clear logs", + <%= button_to(t("rails_pretty_logger.actions.clear_logs"), clear_logs_hourly_logs_path(log_file: params[:log_file]), method: :post, class: "clear_logs", form: { class: "clear_logs_form", - data: { turbo_confirm: "Are you sure to clear all logs? " }}) %> + data: { turbo_confirm: t("rails_pretty_logger.confirmations.clear_all") }}) %>

<% end %>
-
+
- <% @log_data[:paginated_logs].each do |line| %> - <%= check_highlight(line) %>
- <% end %> +<%= render "partials/log_entries", log_data: @log_data %> <% else %>
-

There is no log file to show

+

<%= t("rails_pretty_logger.states.no_logs") %>

<% end %> diff --git a/config/locales/rails_pretty_logger.en.yml b/config/locales/rails_pretty_logger.en.yml new file mode 100644 index 0000000..75618ef --- /dev/null +++ b/config/locales/rails_pretty_logger.en.yml @@ -0,0 +1,35 @@ +en: + rails_pretty_logger: + actions: + clear: "Clear" + clear_logs: "Clear logs" + sort: "Sort %{direction}" + submit: "Submit" + confirmations: + clear_all: "Are you sure to clear all logs?" + clear_file: "Are you sure to clear all logs from %{file}?" + open_file: "Log file size is %{size} MB. Are you sure to open this file?" + filters: + clear_filters: "Clear filters" + end_date: "End Date:" + filter: "Filter" + logs_per_page: "Logs per page:" + search: "Search" + search_log_content: "Search log content" + severity: "Severity" + start_date: "Start Date:" + logs: + file_size: "%{size} MB" + filtered_view: "Filtered view" + group_requests: "Group requests" + plain_lines: "Plain lines" + tail_last_lines: "Tail last %{count} lines" + ungrouped_lines: "Ungrouped lines" + navigation: + hourly_logs: "Hourly logs" + log_files: "Log files" + main_logs: "Main logs" + pagination: "Pagination" + states: + no_logs: "There is no log file to show" + stdout_logging: "RAILS_LOG_TO_STDOUT is present. No logs are being kept." diff --git a/config/locales/rails_pretty_logger.tr.yml b/config/locales/rails_pretty_logger.tr.yml new file mode 100644 index 0000000..d67a413 --- /dev/null +++ b/config/locales/rails_pretty_logger.tr.yml @@ -0,0 +1,35 @@ +tr: + rails_pretty_logger: + actions: + clear: "Temizle" + clear_logs: "Logları temizle" + sort: "%{direction} sırala" + submit: "Gönder" + confirmations: + clear_all: "Tüm logları temizlemek istediğine emin misin?" + clear_file: "%{file} dosyasındaki tüm logları temizlemek istediğine emin misin?" + open_file: "Log dosyası %{size} MB. Bu dosyayı açmak istediğine emin misin?" + filters: + clear_filters: "Filtreleri temizle" + end_date: "Bitiş tarihi:" + filter: "Filtrele" + logs_per_page: "Sayfa başına log:" + search: "Ara" + search_log_content: "Log içeriğinde ara" + severity: "Seviye" + start_date: "Başlangıç tarihi:" + logs: + file_size: "%{size} MB" + filtered_view: "Filtrelenmiş görünüm" + group_requests: "İstekleri grupla" + plain_lines: "Düz satırlar" + tail_last_lines: "Son %{count} satır" + ungrouped_lines: "Gruplanmamış satırlar" + navigation: + hourly_logs: "Saatlik loglar" + log_files: "Log dosyaları" + main_logs: "Ana loglar" + pagination: "Sayfalama" + states: + no_logs: "Gösterilecek log dosyası yok" + stdout_logging: "RAILS_LOG_TO_STDOUT mevcut. Log dosyası tutulmuyor." diff --git a/flake.nix b/flake.nix index 53e9707..edbb025 100644 --- a/flake.nix +++ b/flake.nix @@ -15,15 +15,13 @@ playwright = pkgs.writeShellScriptBin "playwright" '' exec ${pkgs.nodejs}/bin/node ${pkgs.playwright-driver}/cli.js "$@" ''; - in - { - default = pkgs.mkShell { + mkShell = ruby: pkgs.mkShell { packages = [ pkgs.gcc pkgs.libyaml.dev pkgs.nodejs pkgs.pkg-config - pkgs.ruby_3_3 + ruby pkgs.playwright-driver.browsers playwright ]; @@ -37,6 +35,10 @@ export PLAYWRIGHT_CLI_EXECUTABLE_PATH="${playwright}/bin/playwright" ''; }; + in + { + default = mkShell pkgs.ruby_3_3; + ruby33 = mkShell pkgs.ruby_3_3; }); }; } diff --git a/highlight.gif b/highlight.gif deleted file mode 100644 index 02dc306..0000000 Binary files a/highlight.gif and /dev/null differ diff --git a/hour.gif b/hour.gif deleted file mode 100644 index c995da8..0000000 Binary files a/hour.gif and /dev/null differ diff --git a/lib/generators/rails_pretty_logger/install/install_generator.rb b/lib/generators/rails_pretty_logger/install/install_generator.rb new file mode 100644 index 0000000..f6fd3c7 --- /dev/null +++ b/lib/generators/rails_pretty_logger/install/install_generator.rb @@ -0,0 +1,29 @@ +require "rails/generators" + +module RailsPrettyLogger + module Generators + class InstallGenerator < Rails::Generators::Base + JAVASCRIPT_MANIFEST_LINK = "//= link rails/pretty/logger/application.js".freeze + + source_root File.expand_path("templates", __dir__) + + def copy_initializer + template "rails_pretty_logger.rb", "config/initializers/rails_pretty_logger.rb" + end + + def mount_engine + route %(mount Rails::Pretty::Logger::Engine => "/rails-pretty-logger") + end + + def link_javascript_asset + manifest = "app/assets/config/manifest.js" + manifest_path = File.join(destination_root, manifest) + return unless File.exist?(manifest_path) + return if File.read(manifest_path).include?(JAVASCRIPT_MANIFEST_LINK) + + separator = File.read(manifest_path).end_with?("\n") ? "" : "\n" + append_to_file manifest, "#{separator}#{JAVASCRIPT_MANIFEST_LINK}\n" + end + end + end +end diff --git a/lib/generators/rails_pretty_logger/install/templates/rails_pretty_logger.rb b/lib/generators/rails_pretty_logger/install/templates/rails_pretty_logger.rb new file mode 100644 index 0000000..d4cb051 --- /dev/null +++ b/lib/generators/rails_pretty_logger/install/templates/rails_pretty_logger.rb @@ -0,0 +1,20 @@ +Rails::Pretty::Logger.configure do |config| + # The hook runs inside the Rails Pretty Logger engine controller. + # Example for apps that expose authenticate_user!: + # config.authenticate_with = -> { authenticate_user! } + config.authenticate_with = nil + + # Production dashboards should usually be read-only unless clearing logs is explicitly desired. + config.read_only = Rails.env.production? + + # Set to nil to allow any log file size. + config.max_file_size = 50.megabytes + + # Number of lines shown by the tail view. + config.tail_lines = 500 + + # Optional parser for custom log formats. Return nil for lines the parser does not handle. + # Supported keys include :timestamp, :severity, :request_method, :request_path, + # :request_ip, :response_status, and :duration. + # config.log_line_parser = ->(line) { nil } +end diff --git a/lib/rails/pretty/logger.rb b/lib/rails/pretty/logger.rb index d12c840..0612551 100644 --- a/lib/rails/pretty/logger.rb +++ b/lib/rails/pretty/logger.rb @@ -1,14 +1,39 @@ require "active_support/core_ext/object/blank" require "active_support/core_ext/string/conversions" +require "digest" +require "fileutils" +require "json" +require "pathname" +require "rails/pretty/logger/configuration" require "rails/pretty/logger/engine" module Rails::Pretty::Logger + def self.configuration + @configuration ||= Configuration.new + end + + def self.configure + yield configuration + end + + def self.reset_configuration! + @configuration = Configuration.new + end class PrettyLogger + class InvalidLogFile < StandardError; end + class FileTooLarge < StandardError; end + SEVERITIES = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze + LINE_INDEX_CACHE_LIMIT = 32 + TAIL_READ_CHUNK_SIZE = 64 * 1024 + STRUCTURED_TIMESTAMP_KEYS = %w[@timestamp timestamp time datetime created_at].freeze + STRUCTURED_SEVERITY_KEYS = %w[severity level log_level].freeze + + 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 +48,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 @@ -43,8 +97,129 @@ def self.logs_atr(log_files) log end + def self.ensure_file_size_within_limit!(log_file) + max_file_size = Rails::Pretty::Logger.configuration.max_file_size + return if max_file_size.blank? + + raise FileTooLarge if File.size(log_file) > max_file_size.to_i + end + + def self.tail_lines + Rails::Pretty::Logger.configuration.tail_lines.to_i.positive? ? Rails::Pretty::Logger.configuration.tail_lines.to_i : 500 + end + + def self.structured_log_payload(line) + payload = JSON.parse(line) + payload if payload.is_a?(Hash) + rescue JSON::ParserError, TypeError + nil + end + + def self.custom_log_metadata(line) + parser = Rails::Pretty::Logger.configuration.log_line_parser + return {} unless parser.respond_to?(:call) + + metadata = parser.call(line) + metadata.is_a?(Hash) ? metadata : {} + end + + def self.fetch_line_index_cache(cache_key, signature) + entry_key = [cache_key, signature] + + line_index_cache_mutex.synchronize do + if line_index_cache.key?(entry_key) + line_index_cache_order.delete(entry_key) + line_index_cache_order << entry_key + return line_index_cache.fetch(entry_key) + end + end + + if (offsets = read_persistent_line_index(cache_key, signature)) + store_line_index_cache(entry_key, offsets) + return offsets + end + + offsets = yield.freeze + write_persistent_line_index(cache_key, signature, offsets) + store_line_index_cache(entry_key, offsets) + + offsets + end + + def self.store_line_index_cache(entry_key, offsets) + line_index_cache_mutex.synchronize do + line_index_cache[entry_key] = offsets + line_index_cache_order.delete(entry_key) + line_index_cache_order << entry_key + + while line_index_cache_order.length > LINE_INDEX_CACHE_LIMIT + line_index_cache.delete(line_index_cache_order.shift) + end + end + end + + def self.clear_line_index_cache! + clear_line_index_memory_cache! + FileUtils.rm_rf(line_index_cache_root) + end + + def self.clear_line_index_memory_cache! + line_index_cache_mutex.synchronize do + line_index_cache.clear + line_index_cache_order.clear + end + end + + def self.clear_line_index_cache_for!(log_file) + line_index_cache_mutex.synchronize do + line_index_cache.delete_if { |(cache_key, _signature), _offsets| cache_key.first == log_file || cache_key[1] == log_file } + line_index_cache_order.delete_if { |cache_key, _signature| cache_key.first == log_file || cache_key[1] == log_file } + end + FileUtils.rm_rf(line_index_cache_root) + end + + def self.line_index_cache + @line_index_cache ||= {} + end + + def self.line_index_cache_order + @line_index_cache_order ||= [] + end + + def self.line_index_cache_mutex + @line_index_cache_mutex ||= Mutex.new + end + + def self.read_persistent_line_index(cache_key, signature) + payload = Marshal.load(File.binread(line_index_cache_path(cache_key))) + return unless payload[:signature] == signature + + payload[:offsets].freeze + rescue Errno::ENOENT, EOFError, TypeError, ArgumentError + nil + end + + def self.write_persistent_line_index(cache_key, signature, offsets) + FileUtils.mkdir_p(line_index_cache_root) + path = line_index_cache_path(cache_key) + temp_path = "#{path}.#{$$}.tmp" + File.binwrite(temp_path, Marshal.dump(signature: signature, offsets: offsets)) + FileUtils.mv(temp_path, path) + rescue SystemCallError, TypeError, ArgumentError + FileUtils.rm_f(temp_path) if temp_path + end + + def self.line_index_cache_path(cache_key) + line_index_cache_root.join("#{Digest::SHA256.hexdigest(Marshal.dump(cache_key))}.marshal") + end + + def self.line_index_cache_root + Rails.root.join("tmp", "cache", "rails_pretty_logger", "line_indexes") + end + def clear_logs File.open(@log_file, File::TRUNC) {} + self.class.clear_line_index_cache_for!(@log_file) end def start_date @@ -56,44 +231,71 @@ 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? + + each_filtered_log_line_with_offset(file) { |line, _offset| yield line } + end + + def each_filtered_log_line_with_offset(file) + return enum_for(:each_filtered_log_line_with_offset, file) unless block_given? + start = false - IO.foreach(file) do |line| + each_raw_log_line_with_offset(file) do |line, offset| if get_date_from_log_line(line) start = true - arr.push(line) + yield line, offset elsif start && !(line_include_date?(line)) - arr.push(line) + yield line, offset 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) - if @filter_params[:log_file].include?("test") || @filter_params[:log_file].include?("hourly") - get_test_logs(file) + each_log_line(file).to_a + end + + def each_log_line(file) + return enum_for(:each_log_line, file) unless block_given? + + each_log_line_with_offset(file) { |line, _offset| yield line } + end + + def each_log_line_with_offset(file) + return enum_for(:each_log_line_with_offset, file) unless block_given? + + if test_log?(file) || hourly_log?(file) + each_raw_log_line_with_offset(file) { |line, offset| yield line, offset } else - filter_logs_with_date(file) + each_filtered_log_line_with_offset(file) { |line, offset| yield line, offset } + end + end + + def each_raw_log_line_with_offset(file) + return enum_for(:each_raw_log_line_with_offset, file) unless block_given? + + File.open(file, "r") do |io| + until io.eof? + offset = io.pos + line = io.gets + yield line, offset if line + end end end def get_date_from_log_line(line) - params = @filter_params[:date_range] - if line_include_date?(line) - date_string_index = line.index("at ") - string_date = line[date_string_index .. date_string_index + 13] - date = string_date.to_date.strftime("%Y-%m-%d") + date = date_from_log_line(line) + if date.present? start_date = @filter_params.dig(:date_range, :start) end_date = @filter_params.dig(:date_range, :end) if start_date.present? && end_date.present? @@ -105,7 +307,7 @@ def get_date_from_log_line(line) end def line_include_date?(line) - line.include?("Started") + date_from_log_line(line).present? end def validate_date @@ -123,25 +325,337 @@ 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 .. - (@filter_params[:page].to_i * divider) + divider ] + return grouped_log_data(error, divider) if request_grouping? + + page_start = @filter_params[:page].to_i * divider + page_end = page_start + divider + + self.class.ensure_file_size_within_limit!(@log_file) + + line_offsets = cached_log_line_offsets + paginated_logs = read_log_lines_at_offsets(@log_file, line_offsets[page_start...page_end] || []) + data = {} - data[:logs_count] = logs_count + data[:logs_count] = (line_offsets.length.to_f / divider).ceil data[:paginated_logs] = paginated_logs data[:error] = error return data end + def tail_log_data + self.class.ensure_file_size_within_limit!(@log_file) + + lines = tail_lines(@log_file, self.class.tail_lines).select { |line| line_matches_filters?(line) } + { + logs_count: lines.any? ? 1 : 0, + paginated_logs: lines, + error: nil + } + end + def set_divider_value if @filter_params[:date_range].blank? 100 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 + + def line_matches_filters?(line) + query = @filter_params[:query].to_s.strip + return false if query.present? && !line.downcase.include?(query.downcase) + + severity = @filter_params[:severity].to_s.upcase + return true unless SEVERITIES.include?(severity) + + structured_severity = structured_log_severity(line) + return structured_severity == severity if structured_severity.present? + + line.match?(/\b#{Regexp.escape(severity)}\b/i) + end + + def tail_lines(file, count) + count = count.to_i + return [] unless count.positive? + + File.open(file, "rb") do |io| + offset = io.size + return [] if offset.zero? + + chunks = [] + newline_count = 0 + + while offset.positive? && newline_count <= count + chunk_size = [TAIL_READ_CHUNK_SIZE, offset].min + offset -= chunk_size + io.seek(offset) + + chunk = io.read(chunk_size) + chunks.unshift(chunk) + newline_count += chunk.count("\n") + end + + chunks.join.lines.last(count).map { |line| line.force_encoding(Encoding.default_external) } + end + end + + def request_grouping? + @filter_params[:group].to_s == "request" + end + + def cached_log_line_offsets + self.class.fetch_line_index_cache(log_line_index_cache_key, log_file_signature) do + build_log_line_offsets + end + end + + def build_log_line_offsets + offsets = [] + + each_log_line_with_offset(@log_file) do |line, offset| + offsets << offset if line_matches_filters?(line) + end + + offsets + end + + def read_log_lines_at_offsets(file, offsets) + File.open(file, "r") do |io| + offsets.map do |offset| + io.seek(offset) + io.gets + end.compact + end + end + + def log_line_index_cache_key + [ + @log_file, + date_filtered_log? ? start_date : nil, + date_filtered_log? ? end_date : nil, + @filter_params[:query].to_s.strip.downcase, + normalized_severity_filter, + Rails::Pretty::Logger.configuration.log_line_parser&.object_id + ] + end + + def log_file_signature + stat = File.stat(@log_file) + [stat.size, stat.mtime.to_f, stat.ctime.to_f] + end + + def date_filtered_log? + !test_log?(@log_file) && !hourly_log?(@log_file) + end + + def normalized_severity_filter + severity = @filter_params[:severity].to_s.upcase + SEVERITIES.include?(severity) ? severity : nil + end + + def grouped_log_data(error, divider) + self.class.ensure_file_size_within_limit!(@log_file) + + page_start = @filter_params[:page].to_i * divider + page_end = page_start + divider + matching_groups = matching_request_group_index + + paginated_groups = (matching_groups[page_start...page_end] || []).map { |group| request_group_from_index(group) } + + { + logs_count: (matching_groups.length.to_f / divider).ceil, + paginated_logs: paginated_groups, + error: error + } + end + + def each_request_group(file) + return enum_for(:each_request_group, file) unless block_given? + + cached_request_group_index.each do |group| + yield request_group_from_index(group) + end + end + + def cached_request_group_index + self.class.fetch_line_index_cache(request_group_index_cache_key, log_file_signature) do + build_request_group_index + end + end + + def build_request_group_index + groups = [] + current_group = nil + + each_log_line_with_offset(@log_file) do |line, offset| + if (metadata = request_start_metadata(line)) + groups << current_group if current_group + current_group = metadata.merge(line_offsets: [offset]) + elsif current_group + current_group[:line_offsets] << offset + current_group.merge!(request_completion_metadata(line) || {}) + else + current_group = { type: :ungrouped, line_offsets: [offset] } + end end + + groups << current_group if current_group + groups + end + + def matching_request_group_index + groups = cached_request_group_index + return groups unless filtered_request_groups? + + groups.select { |group| group_matches_filters?(request_group_from_index(group)) } + end + + def request_group_from_index(group) + group.merge(lines: read_log_lines_at_offsets(@log_file, group.fetch(:line_offsets))).tap do |request_group| + request_group.delete(:line_offsets) + end + end + + def filtered_request_groups? + @filter_params[:query].to_s.strip.present? || normalized_severity_filter.present? + end + + def group_matches_filters?(group) + group.fetch(:lines).any? { |line| line_matches_filters?(line) } + end + + def request_group_index_cache_key + [ + :request_groups, + @log_file, + date_filtered_log? ? start_date : nil, + date_filtered_log? ? end_date : nil, + Rails::Pretty::Logger.configuration.log_line_parser&.object_id + ] + end + + def request_start_metadata(line) + metadata = custom_log_metadata(line) + request_method = metadata_value(metadata, :request_method, :method) + request_path = metadata_value(metadata, :request_path, :path) + + if request_method.present? && request_path.present? + return { + type: :request, + method: request_method.to_s, + path: request_path.to_s, + ip: metadata_value(metadata, :request_ip, :ip), + started_at: metadata_value(metadata, :request_started_at, :started_at, :timestamp, :time).to_s + } + end + + match = line.strip.match(/\AStarted\s+(?[A-Z]+)\s+"(?[^"]+)"(?:\s+for\s+(?\S+))?\s+at\s+(?.+)\z/) + return unless match + + { + type: :request, + method: match[:method], + path: match[:path], + ip: match[:ip], + started_at: match[:timestamp].strip + } + end + + def request_completion_metadata(line) + metadata = custom_log_metadata(line) + response_status = metadata_value(metadata, :response_status, :status) + duration = metadata_value(metadata, :duration, :request_duration) + + if response_status.present? || duration.present? + return { + status: response_status.to_s, + duration: duration.to_s + } + end + + match = line.strip.match(/\ACompleted\s+(?\d{3}).*?\sin\s+(?[\d.]+ms)/) + return unless match + + { + status: match[:status], + duration: match[:duration] + } + end + + def date_from_log_line(line) + timestamp = custom_log_timestamp(line) || request_timestamp(line) || structured_log_timestamp(line) + timestamp&.to_date&.strftime("%Y-%m-%d") + rescue Date::Error, NoMethodError + nil + end + + def request_timestamp(line) + match = line.strip.match(/\AStarted\s+.*\sat\s+(?.+)\z/) + match[:timestamp] if match + end + + def structured_log_timestamp(line) + payload = self.class.structured_log_payload(line) + return unless payload + + STRUCTURED_TIMESTAMP_KEYS.each do |key| + return payload[key].to_s if payload[key].present? + end + + nil + end + + def structured_log_severity(line) + severity = custom_log_severity(line) + return severity if severity.present? + + payload = self.class.structured_log_payload(line) + return unless payload + + STRUCTURED_SEVERITY_KEYS.each do |key| + severity = payload[key].to_s.upcase + return severity if SEVERITIES.include?(severity) + end + + nested_log = payload["log"] + return unless nested_log.respond_to?(:[]) + + nested_severity = nested_log["level"].to_s.upcase + nested_severity if SEVERITIES.include?(nested_severity) + end + + def custom_log_timestamp(line) + metadata_value(custom_log_metadata(line), :timestamp, :time, :datetime, :created_at) + end + + def custom_log_severity(line) + severity = metadata_value(custom_log_metadata(line), :severity, :level, :log_level).to_s.upcase + severity if SEVERITIES.include?(severity) + end + + def custom_log_metadata(line) + self.class.custom_log_metadata(line) + end + + def metadata_value(metadata, *keys) + keys.each do |key| + string_key = key.to_s + return metadata[string_key] if metadata.key?(string_key) + return metadata[key] if metadata.key?(key) + end + + nil 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/configuration.rb b/lib/rails/pretty/logger/configuration.rb new file mode 100644 index 0000000..f6a8669 --- /dev/null +++ b/lib/rails/pretty/logger/configuration.rb @@ -0,0 +1,18 @@ +module Rails::Pretty::Logger + class Configuration + attr_accessor :authenticate_with, :log_line_parser, :max_file_size, :tail_lines + attr_writer :read_only + + def initialize + @authenticate_with = nil + @log_line_parser = nil + @read_only = Rails.env.production? + @max_file_size = nil + @tail_lines = 500 + end + + def read_only? + @read_only == true + 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/engine.rb b/lib/rails/pretty/logger/engine.rb index 2626240..9553381 100644 --- a/lib/rails/pretty/logger/engine.rb +++ b/lib/rails/pretty/logger/engine.rb @@ -5,16 +5,21 @@ class Engine < ::Rails::Engine isolate_namespace Rails::Pretty::Logger initializer "rails_pretty_logger.assets" do |app| - if app.config.respond_to?(:assets) - stylesheets_path = root.join("app/assets/stylesheets").to_s - app.config.assets.paths << stylesheets_path unless app.config.assets.paths.include?(stylesheets_path) + assets_config = app.config.assets if app.config.respond_to?(:assets) + + if assets_config.respond_to?(:paths) && assets_config.respond_to?(:precompile) + %w[stylesheets javascripts].each do |asset_path| + path = root.join("app/assets", asset_path).to_s + assets_config.paths << path unless assets_config.paths.include?(path) + end %w[ + rails/pretty/logger/application.js rails/pretty/logger/application.css rails/pretty/logger/dashboards.css rails/pretty/logger/list.css ].each do |asset| - app.config.assets.precompile << asset unless app.config.assets.precompile.include?(asset) + assets_config.precompile << asset unless assets_config.precompile.include?(asset) end end end diff --git a/lib/rails/pretty/logger/rails_logger.rb b/lib/rails/pretty/logger/rails_logger.rb index 0fb3d83..f1d6020 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 ) @@ -98,48 +94,70 @@ 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') + 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 + + 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 - age_file = "#{@filename}.#{suffix}" + 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 - 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 + def rotation_lock_path + Rails.root.join("tmp", "rails_pretty_logger", "#{File.basename(@filename)}.rotate.lock").to_s + end + + def available_log_path(path) + candidate = path + index = 0 + while File.exist?(candidate) + index += 1 + candidate = "#{path}.#{index}" end + candidate + 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) } + 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 - @dev.close rescue nil + 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 - 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 - @dev = create_logfile(@filename) - return true + 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) 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/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/log_file.gif b/log_file.gif deleted file mode 100644 index 8a93cf0..0000000 Binary files a/log_file.gif and /dev/null differ diff --git a/rails-pretty-logger.gemspec b/rails-pretty-logger.gemspec index 3452fbb..358e261 100644 --- a/rails-pretty-logger.gemspec +++ b/rails-pretty-logger.gemspec @@ -9,7 +9,7 @@ Gem::Specification.new do |spec| spec.description = "Rails Pretty Logger provides a mounted dashboard for browsing log files, highlighting entries, clearing logs, and reading hourly rotated log files." spec.homepage = "https://github.com/MehmetCelik4/rails-pretty-logger" spec.license = "MIT" - spec.required_ruby_version = ">= 3.1" + spec.required_ruby_version = ">= 3.3" spec.metadata = { "source_code_uri" => spec.homepage, "changelog_uri" => "#{spec.homepage}/releases" diff --git a/test/generators/install_generator_test.rb b/test/generators/install_generator_test.rb new file mode 100644 index 0000000..dc08025 --- /dev/null +++ b/test/generators/install_generator_test.rb @@ -0,0 +1,58 @@ +require "test_helper" +require "rails/generators" +require "rails/generators/test_case" +require "generators/rails_pretty_logger/install/install_generator" + +class InstallGeneratorTest < Rails::Generators::TestCase + tests RailsPrettyLogger::Generators::InstallGenerator + destination Rails.root.join("tmp", "generators", "install") + + setup do + prepare_destination + FileUtils.mkdir_p(File.join(destination_root, "config")) + FileUtils.mkdir_p(File.join(destination_root, "app/assets/config")) + File.write(File.join(destination_root, "config", "routes.rb"), <<~RUBY) + Rails.application.routes.draw do + end + RUBY + File.write(File.join(destination_root, "app/assets/config/manifest.js"), <<~JS) + //= link_tree ../images + //= link_directory ../stylesheets .css + JS + end + + test "creates initializer" do + run_generator + + assert_file "config/initializers/rails_pretty_logger.rb" do |initializer| + assert_includes initializer, "Rails::Pretty::Logger.configure" + assert_includes initializer, "config.read_only = Rails.env.production?" + assert_includes initializer, "config.max_file_size = 50.megabytes" + assert_includes initializer, "config.tail_lines = 500" + assert_includes initializer, "config.log_line_parser" + end + end + + test "mounts engine route" do + run_generator + + assert_file "config/routes.rb", /mount Rails::Pretty::Logger::Engine => "\/rails-pretty-logger"/ + end + + test "links javascript asset in sprockets manifest" do + run_generator + + assert_file "app/assets/config/manifest.js" do |manifest| + assert_includes manifest, "//= link rails/pretty/logger/application.js" + end + end + + test "does not duplicate javascript asset link" do + manifest_path = File.join(destination_root, "app/assets/config/manifest.js") + File.write(manifest_path, "//= link rails/pretty/logger/application.js\n") + + run_generator + + assert_equal 1, File.read(manifest_path).scan("//= link rails/pretty/logger/application.js").count + end +end diff --git a/test/integration/dashboard_test.rb b/test/integration/dashboard_test.rb index c87c0bd..65c27ad 100644 --- a/test/integration/dashboard_test.rb +++ b/test/integration/dashboard_test.rb @@ -17,6 +17,24 @@ class DashboardTest < ActionDispatch::IntegrationTest assert_includes response.body, "Dashboard_test.log" end + test "authentication hook can block engine access" do + Rails::Pretty::Logger.configure do |config| + config.authenticate_with = -> { head :unauthorized } + end + + get "/rails-pretty-logger/dashboards" + + assert_response :unauthorized + end + + test "legacy authentication hook can block engine access" do + Rails.application.config.x.rails_pretty_logger.authenticate_with = -> { head :unauthorized } + + get "/rails-pretty-logger/dashboards" + + assert_response :unauthorized + end + test "renders selected log file" do get "/rails-pretty-logger/dashboards/logs", params: { log_file: @log_file.to_s, @@ -30,6 +48,102 @@ class DashboardTest < ActionDispatch::IntegrationTest assert_includes response.body, "Completed 200 OK" end + test "filters selected log file by content query and severity" do + File.write(@log_file, <<~LOG) + INFO payment accepted + ERROR payment failed + ERROR profile failed + LOG + + get "/rails-pretty-logger/dashboards/logs", params: { + log_file: @log_file.to_s, + query: "payment", + severity: "ERROR" + } + + assert_response :success + assert_includes response.body, "ERROR payment failed" + assert_not_includes response.body, "INFO payment accepted" + assert_not_includes response.body, "ERROR profile failed" + end + + test "renders request grouped selected log file" do + File.write(@log_file, <<~LOG) + Started GET "/grouped" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + Processing by GroupedController#index as HTML + Completed 200 OK in 12ms + LOG + + get "/rails-pretty-logger/dashboards/logs", params: { + log_file: @log_file.to_s, + group: "request", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s + } + } + + assert_response :success + assert_includes response.body, "log-request" + assert_includes response.body, "GET" + assert_includes response.body, "/grouped" + assert_includes response.body, "Completed 200 OK" + assert_includes response.body, "Plain lines" + end + + test "renders structured json log entries" do + File.write(@log_file, <<~LOG) + {"timestamp":"#{Date.current.iso8601}T10:01:00Z","level":"ERROR","message":"payment failed","request_id":"abc-123"} + LOG + + get "/rails-pretty-logger/dashboards/logs", params: { + log_file: @log_file.to_s, + query: "payment", + severity: "ERROR", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s + } + } + + assert_response :success + assert_includes response.body, "structured-log" + assert_includes response.body, "payment failed" + assert_includes response.body, "request_id" + assert_includes response.body, "abc-123" + end + + test "preserves log filters in pagination links" do + File.write(@log_file, "ERROR payment failed\nERROR payment failed again\n") + + get "/rails-pretty-logger/dashboards/logs", params: { + log_file: @log_file.to_s, + query: "payment", + severity: "ERROR", + date_range: { + divider: "1" + } + } + + assert_response :success + assert_includes response.body, "query=payment" + assert_includes response.body, "severity=ERROR" + end + + test "renders selected log file in tail mode" do + Rails::Pretty::Logger.configure { |config| config.tail_lines = 1 } + + get "/rails-pretty-logger/dashboards/logs", params: { + log_file: @log_file.to_s, + mode: "tail" + } + + assert_response :success + assert_includes response.body, "Completed 200 OK" + assert_not_includes response.body, "Started GET" + assert_includes response.body, "Filtered view" + end + test "clears selected log file" do post "/rails-pretty-logger/dashboards/clear_logs", params: { log_file: @log_file.to_s @@ -38,4 +152,108 @@ 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 "read only mode blocks clearing selected log file" do + Rails::Pretty::Logger.configure { |config| config.read_only = true } + + post "/rails-pretty-logger/dashboards/clear_logs", params: { + log_file: @log_file.to_s + } + + assert_response :forbidden + assert_includes File.read(@log_file), "Completed 200 OK" + end + + test "read only mode hides clear buttons" do + Rails::Pretty::Logger.configure { |config| config.read_only = true } + + get "/rails-pretty-logger/dashboards" + + assert_response :success + assert_not_includes response.body, "clear_logs" + end + + test "rejects selected log file when max file size is exceeded" do + Rails::Pretty::Logger.configure { |config| config.max_file_size = 1 } + + 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 413 + assert_includes response.body, "Log file is too large" + 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/configuration_test.rb b/test/rails/pretty/logger/configuration_test.rb new file mode 100644 index 0000000..423ffb0 --- /dev/null +++ b/test/rails/pretty/logger/configuration_test.rb @@ -0,0 +1,43 @@ +require "test_helper" + +module Rails + module Pretty + module Logger + class ConfigurationTest < ActiveSupport::TestCase + test "has safe defaults" do + configuration = Rails::Pretty::Logger.configuration + + assert_nil configuration.authenticate_with + assert_nil configuration.log_line_parser + assert_nil configuration.max_file_size + assert_equal 500, configuration.tail_lines + assert_not configuration.read_only? + end + + test "defaults to read only in production" do + Rails.stub(:env, ActiveSupport::StringInquirer.new("production")) do + assert Rails::Pretty::Logger::Configuration.new.read_only? + end + end + + test "can be configured" do + auth_hook = -> { head :unauthorized } + + Rails::Pretty::Logger.configure do |config| + config.authenticate_with = auth_hook + config.log_line_parser = ->(line) { { severity: "INFO" } if line.include?("INFO") } + config.read_only = true + config.max_file_size = 1024 + config.tail_lines = 200 + end + + assert_same auth_hook, Rails::Pretty::Logger.configuration.authenticate_with + assert Rails::Pretty::Logger.configuration.log_line_parser.call("INFO custom") + assert Rails::Pretty::Logger.configuration.read_only? + assert_equal 1024, Rails::Pretty::Logger.configuration.max_file_size + assert_equal 200, Rails::Pretty::Logger.configuration.tail_lines + 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 f750424..fc4f811 100644 --- a/test/rails/pretty/logger/pretty_logger_test.rb +++ b/test/rails/pretty/logger/pretty_logger_test.rb @@ -1,4 +1,6 @@ require "test_helper" +require "minitest/mock" +require "stringio" module Rails module Pretty @@ -32,6 +34,466 @@ class PrettyLoggerTest < ActiveSupport::TestCase assert_includes data[:paginated_logs].first, Date.current.to_s end + test "filters log data by content query and severity" do + File.write(@log_file, <<~LOG) + INFO normal request + WARN payment warning + ERROR payment failed + LOG + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: @log_file.to_s, + query: "payment", + severity: "ERROR" + ) + ) + + data = logger.log_data + + assert_equal 1, data[:logs_count] + assert_equal ["ERROR payment failed\n"], data[:paginated_logs] + end + + test "filters structured json log data by date content and severity" do + structured_log = Rails.root.join("log", "structured_production.log") + File.write(structured_log, <<~LOG) + {"timestamp":"#{Date.yesterday.iso8601}T10:00:00Z","level":"ERROR","message":"payment from yesterday failed"} + {"timestamp":"#{Date.current.iso8601}T10:00:00Z","level":"INFO","message":"payment accepted"} + {"timestamp":"#{Date.current.iso8601}T10:01:00Z","level":"ERROR","message":"payment failed","request_id":"abc-123"} + LOG + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: structured_log.to_s, + query: "payment", + severity: "ERROR", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s + } + ) + ) + + data = logger.log_data + + assert_equal 1, data[:logs_count] + assert_equal 1, data[:paginated_logs].count + assert_includes data[:paginated_logs].first, "payment failed" + assert_includes data[:paginated_logs].first, "abc-123" + ensure + FileUtils.rm_f(structured_log) if structured_log + end + + test "uses configured log line parser for date and severity filters" do + custom_log = Rails.root.join("log", "custom_parser_production.log") + File.write(custom_log, <<~LOG) + CUSTOM #{Date.yesterday.iso8601} ERROR payment failed yesterday + CUSTOM #{Date.current.iso8601} INFO payment accepted + CUSTOM #{Date.current.iso8601} ERROR payment failed today + LOG + Rails::Pretty::Logger.configure do |config| + config.log_line_parser = ->(line) do + if (match = line.match(/\ACUSTOM (?\d{4}-\d{2}-\d{2}) (?\w+)/)) + { timestamp: "#{match[:date]}T10:00:00Z", severity: match[:severity] } + end + end + end + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: custom_log.to_s, + query: "payment", + severity: "ERROR", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s + } + ) + ) + + data = logger.log_data + + assert_equal 1, data[:logs_count] + assert_equal ["CUSTOM #{Date.current.iso8601} ERROR payment failed today\n"], data[:paginated_logs] + ensure + FileUtils.rm_f(custom_log) if custom_log + end + + test "ignores unknown severity filters" do + File.write(@log_file, "ERROR unknown severity should still render\n") + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: @log_file.to_s, + severity: "NOPE" + ) + ) + + assert_equal ["ERROR unknown severity should still render\n"], logger.log_data[:paginated_logs] + end + + test "groups rails request logs when request grouping is enabled" do + File.write(@log_file, <<~LOG) + Started GET "/first" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + Processing by HomeController#index as HTML + Completed 200 OK in 12ms + Started POST "/second" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:18:00 +0300 + ERROR payment failed + Completed 500 Internal Server Error in 25ms + LOG + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: @log_file.to_s, + group: "request", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s, + divider: "1" + } + ) + ) + + data = logger.log_data + group = data[:paginated_logs].first + + assert_equal 2, data[:logs_count] + assert_equal :request, group[:type] + assert_equal "GET", group[:method] + assert_equal "/first", group[:path] + assert_equal "200", group[:status] + assert_equal "12ms", group[:duration] + assert_includes group[:lines].join, "Processing by HomeController" + end + + test "uses configured log line parser for request grouping" do + custom_log = Rails.root.join("log", "custom_request_parser_production.log") + File.write(custom_log, <<~LOG) + REQ #{Date.current.iso8601} PATCH /custom + custom request body + RESP 202 7ms + LOG + Rails::Pretty::Logger.configure do |config| + config.log_line_parser = ->(line) do + if (match = line.match(/\AREQ (?\d{4}-\d{2}-\d{2}) (?\w+) (?\S+)/)) + { + timestamp: "#{match[:date]}T11:00:00Z", + request_method: match[:method], + request_path: match[:path] + } + elsif (match = line.match(/\ARESP (?\d{3}) (?\S+)/)) + { + response_status: match[:status], + duration: match[:duration] + } + end + end + end + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: custom_log.to_s, + group: "request", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s + } + ) + ) + + group = logger.log_data[:paginated_logs].first + + assert_equal :request, group[:type] + assert_equal "PATCH", group[:method] + assert_equal "/custom", group[:path] + assert_equal "202", group[:status] + assert_equal "7ms", group[:duration] + assert_includes group[:lines].join, "custom request body" + ensure + FileUtils.rm_f(custom_log) if custom_log + end + + test "filters request groups by matching lines inside the group" do + File.write(@log_file, <<~LOG) + Started GET "/first" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + INFO payment accepted + Completed 200 OK in 12ms + Started POST "/second" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:18:00 +0300 + ERROR payment failed + Completed 500 Internal Server Error in 25ms + LOG + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: @log_file.to_s, + group: "request", + query: "payment", + severity: "ERROR", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s, + divider: "10" + } + ) + ) + + data = logger.log_data + + assert_equal 1, data[:logs_count] + assert_equal "/second", data[:paginated_logs].first[:path] + assert_includes data[:paginated_logs].first[:lines].join, "ERROR payment failed" + end + + test "reuses cached request group index for repeated grouping" do + File.write(@log_file, <<~LOG) + Started GET "/first" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + Completed 200 OK in 12ms + Started POST "/second" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:18:00 +0300 + Completed 500 Internal Server Error in 25ms + LOG + params = { + log_file: @log_file.to_s, + group: "request", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s, + divider: "1" + } + } + + PrettyLogger.new(ActionController::Parameters.new(params)).log_data + logger = PrettyLogger.new(ActionController::Parameters.new(params.merge(page: "1"))) + logger.define_singleton_method(:build_request_group_index) do + flunk "grouped log_data should reuse cached request group index" + end + + data = logger.log_data + + assert_equal 2, data[:logs_count] + assert_equal "/second", data[:paginated_logs].first[:path] + end + + test "loads request group index from persistent cache" do + File.write(@log_file, <<~LOG) + Started GET "/persisted" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + Completed 200 OK in 12ms + LOG + params = { + log_file: @log_file.to_s, + group: "request", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s + } + } + + PrettyLogger.new(ActionController::Parameters.new(params)).log_data + PrettyLogger.clear_line_index_memory_cache! + logger = PrettyLogger.new(ActionController::Parameters.new(params)) + logger.define_singleton_method(:build_request_group_index) do + flunk "grouped log_data should load persisted request group index" + end + + data = logger.log_data + + assert_equal 1, data[:logs_count] + assert_equal "/persisted", data[:paginated_logs].first[:path] + 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 "reuses cached line offsets for repeated large log pagination" do + large_log = Rails.root.join("log", "cached_large_production.log") + File.open(large_log, "w") do |file| + 60.times do |index| + file.puts %(Started GET "/cached/#{index}" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300) + end + end + params = { + log_file: large_log.to_s, + page: "0", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s, + divider: "10" + } + } + + PrettyLogger.new(ActionController::Parameters.new(params)).log_data + logger = PrettyLogger.new(ActionController::Parameters.new(params.merge(page: "1"))) + logger.define_singleton_method(:build_log_line_offsets) do + flunk "log_data should reuse cached offsets instead of scanning the file again" + end + + data = logger.log_data + + assert_equal 6, data[:logs_count] + assert_equal 10, data[:paginated_logs].count + assert_includes data[:paginated_logs].first, "/cached/10" + ensure + FileUtils.rm_f(large_log) if large_log + end + + test "loads cached line offsets from persistent cache" do + large_log = Rails.root.join("log", "persisted_cached_large_production.log") + File.open(large_log, "w") do |file| + 20.times do |index| + file.puts %(Started GET "/persisted-cached/#{index}" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300) + end + end + params = { + log_file: large_log.to_s, + page: "0", + date_range: { + start: Date.current.to_s, + end: Date.current.to_s, + divider: "10" + } + } + + PrettyLogger.new(ActionController::Parameters.new(params)).log_data + PrettyLogger.clear_line_index_memory_cache! + logger = PrettyLogger.new(ActionController::Parameters.new(params.merge(page: "1"))) + logger.define_singleton_method(:build_log_line_offsets) do + flunk "log_data should load persisted offsets instead of scanning the file again" + end + + data = logger.log_data + + assert_equal 2, data[:logs_count] + assert_equal 10, data[:paginated_logs].count + assert_includes data[:paginated_logs].first, "/persisted-cached/10" + ensure + FileUtils.rm_f(large_log) if large_log + end + + test "invalidates cached line offsets when log file changes" do + large_log = Rails.root.join("log", "changing_large_production.log") + File.write(large_log, %(Started GET "/before" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300\n)) + params = ActionController::Parameters.new( + log_file: large_log.to_s, + date_range: { + start: Date.current.to_s, + end: Date.current.to_s, + divider: "10" + } + ) + + PrettyLogger.new(params).log_data + File.open(large_log, "a") do |file| + file.puts %(Started GET "/after" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:18:00 +0300) + end + + data = PrettyLogger.new(params).log_data + + assert_equal 1, data[:logs_count] + assert_includes data[:paginated_logs].join, "/after" + ensure + FileUtils.rm_f(large_log) if large_log + end + + test "returns only the configured tail lines" do + tail_log = Rails.root.join("log", "tail_production.log") + File.open(tail_log, "w") do |file| + 10.times { |index| file.puts "TAIL ENTRY #{index}" } + end + Rails::Pretty::Logger.configure { |config| config.tail_lines = 3 } + logger = PrettyLogger.new(ActionController::Parameters.new(log_file: tail_log.to_s)) + + data = logger.tail_log_data + + assert_equal 1, data[:logs_count] + assert_equal ["TAIL ENTRY 7\n", "TAIL ENTRY 8\n", "TAIL ENTRY 9\n"], data[:paginated_logs] + ensure + FileUtils.rm_f(tail_log) if tail_log + end + + test "tail log data reads from the end of the file" do + tail_log = Rails.root.join("log", "tail_reverse_read_production.log") + File.open(tail_log, "w") do |file| + 2_000.times { |index| file.puts "TAIL REVERSE ENTRY #{index}" } + end + Rails::Pretty::Logger.configure { |config| config.tail_lines = 2 } + logger = PrettyLogger.new(ActionController::Parameters.new(log_file: tail_log.to_s)) + + data = nil + IO.stub(:foreach, ->(*) { flunk "tail_log_data should not scan the file from the first line" }) do + data = logger.tail_log_data + end + + assert_equal ["TAIL REVERSE ENTRY 1998\n", "TAIL REVERSE ENTRY 1999\n"], data[:paginated_logs] + ensure + FileUtils.rm_f(tail_log) if tail_log + end + + test "tail log data preserves the final line without a trailing newline" do + tail_log = Rails.root.join("log", "tail_without_newline_production.log") + File.write(tail_log, "TAIL ENTRY 1\nTAIL ENTRY 2\nTAIL ENTRY 3") + Rails::Pretty::Logger.configure { |config| config.tail_lines = 2 } + logger = PrettyLogger.new(ActionController::Parameters.new(log_file: tail_log.to_s)) + + data = logger.tail_log_data + + assert_equal ["TAIL ENTRY 2\n", "TAIL ENTRY 3"], data[:paginated_logs] + ensure + FileUtils.rm_f(tail_log) if tail_log + end + + test "filters tail log data by content query and severity" do + tail_log = Rails.root.join("log", "tail_filter_production.log") + File.write(tail_log, <<~LOG) + INFO payment succeeded + ERROR payment failed + ERROR other failure + LOG + logger = PrettyLogger.new( + ActionController::Parameters.new( + log_file: tail_log.to_s, + query: "payment", + severity: "ERROR" + ) + ) + + data = logger.tail_log_data + + assert_equal 1, data[:logs_count] + assert_equal ["ERROR payment failed\n"], data[:paginated_logs] + ensure + FileUtils.rm_f(tail_log) if tail_log + end + + test "uses a safe default when tail lines is invalid" do + Rails::Pretty::Logger.configure { |config| config.tail_lines = 0 } + + assert_equal 500, PrettyLogger.tail_lines + end + test "validates date ranges" do logger = PrettyLogger.new( ActionController::Parameters.new( @@ -53,11 +515,85 @@ 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 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 "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( + 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 + 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/rails_logger_test.rb b/test/rails/pretty/logger/rails_logger_test.rb new file mode 100644 index 0000000..b22132b --- /dev/null +++ b/test/rails/pretty/logger/rails_logger_test.rb @@ -0,0 +1,226 @@ +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) + FileUtils.rm_rf(Rails.root.join("tmp", "rails_pretty_logger")) + 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 "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 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) + 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 + + 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 +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 diff --git a/test/system/rails_pretty_logger_interaction_test.rb b/test/system/rails_pretty_logger_interaction_test.rb index 908dd1f..4667877 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,200 @@ 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_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 "opens tail view for a log file" do + File.open(@log_file, "w") do |file| + 520.times { |index| file.puts "TAIL SYSTEM ENTRY #{index}" } + end + + visit "/rails-pretty-logger" + + accept_confirm do + click_link "System.log" + end + + click_link "Tail last 500 lines" + + assert_text "TAIL SYSTEM ENTRY 519" + assert_no_text "TAIL SYSTEM ENTRY 0" + click_link "Filtered view" assert_selector "input[name='date_range[start]']", visible: false end - test "filters hourly log files with server-rendered GET params" do + test "groups request logs in the browser" do + File.write(@log_file, <<~LOG) + Started GET "/grouped" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + Processing by GroupedController#index as HTML + Completed 200 OK in 12ms + Started POST "/grouped" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:18:00 +0300 + ERROR grouped failure + Completed 500 Internal Server Error in 25ms + LOG + + visit "/rails-pretty-logger" + + accept_confirm do + click_link "System.log" + end + + click_link "Group requests" + + assert_selector ".log-request", count: 2 + assert_text "GET" + assert_text "/grouped" + assert_text "Completed 500 Internal Server Error" + click_link "Plain lines" + assert_no_selector ".log-request" + assert_text "ERROR grouped failure" + end + + test "filters log content and severity in the browser" do + File.write(@log_file, <<~LOG) + Started GET "/payments" for 127.0.0.1 at #{Date.current.strftime("%Y-%m-%d")} 11:17:00 +0300 + INFO payment accepted + ERROR payment failed + ERROR profile failed + LOG + + visit "/rails-pretty-logger" + + accept_confirm do + click_link "System.log" + end + + fill_in "Search", with: "payment" + select "ERROR", from: "Severity" + click_button "Filter" + + assert_text "ERROR payment failed" + assert_no_text "INFO payment accepted" + assert_no_text "ERROR profile failed" + + click_link "Tail last 500 lines" + + assert_text "ERROR payment failed" + assert_no_text "INFO payment accepted" + assert_no_text "ERROR profile failed" + 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 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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 0772d5c..532f14d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -7,3 +7,11 @@ require_relative "support/dummy_log" FileUtils.mkdir_p(Rails.root.join("log")) + +class ActiveSupport::TestCase + teardown do + Rails::Pretty::Logger.reset_configuration! + Rails::Pretty::Logger::PrettyLogger.clear_line_index_cache! + Rails.application.config.x.rails_pretty_logger.authenticate_with = nil + end +end