├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .standard.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── UPGRADE.md ├── app ├── controllers │ └── solid_errors │ │ ├── application_controller.rb │ │ └── errors_controller.rb ├── mailers │ └── solid_errors │ │ └── error_mailer.rb ├── models │ └── solid_errors │ │ ├── backtrace.rb │ │ ├── backtrace_line.rb │ │ ├── error.rb │ │ ├── occurrence.rb │ │ └── record.rb └── views │ ├── layouts │ └── solid_errors │ │ ├── _style.html │ │ └── application.html.erb │ └── solid_errors │ ├── error_mailer │ ├── error_occurred.html.erb │ └── error_occurred.text.erb │ ├── errors │ ├── _actions.html.erb │ ├── _delete_button.html.erb │ ├── _error.html.erb │ ├── _resolve_button.html.erb │ ├── _row.html.erb │ ├── index.html.erb │ └── show.html.erb │ └── occurrences │ ├── _collection.html.erb │ └── _occurrence.html.erb ├── bin ├── console └── setup ├── config ├── locales │ └── en.yml └── routes.rb ├── images ├── index-screenshot.png └── show-screenshot.png ├── lib ├── generators │ └── solid_errors │ │ └── install │ │ ├── USAGE │ │ ├── install_generator.rb │ │ └── templates │ │ └── db │ │ └── errors_schema.rb ├── solid_errors.rb └── solid_errors │ ├── engine.rb │ ├── sanitizer.rb │ ├── subscriber.rb │ └── version.rb ├── sig └── solid_errors.rbs ├── solid_errors.gemspec └── test ├── dummy ├── app │ ├── controllers │ │ └── application_controller.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ └── application_record.rb │ └── views │ │ └── layouts │ │ └── application.html.erb ├── bin │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ └── test.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ └── routes.rb ├── db │ ├── errors.sqlite3 │ ├── queue.sqlite3 │ └── test.sqlite3 ├── log │ └── .keep ├── storage │ ├── .keep │ └── test.sqlite3 └── tmp │ ├── .keep │ ├── local_secret.txt │ ├── pids │ └── .keep │ └── storage │ └── .keep ├── test_helper.rb └── test_solid_errors.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.1.4' 18 | - '3.2.3' 19 | - '3.3.0' 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - name: Run the default task 28 | run: bundle exec rake 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | .DS_Store 10 | test/dummy/log/*.log 11 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 3.0 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.6.1] - 2024-09-19 4 | 5 | - Fix the install generator by putting the schema in the db/ directory ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/62)) 6 | 7 | ## [0.6.0] - 2024-09-09 8 | 9 | - Slim down installer and have it use a final schema instead of migrations ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/60)) 10 | - Fix rails 8 warning: "Drawing a route with a hash key name is deprecated" ([@dorianmariecom](https://github.com/fractaledmind/solid_errors/pull/59)) 11 | 12 | ## [0.5.0] - 2024-08-22 13 | 14 | - introduce ability to view resolved errors and delete them ([@acoffman](https://github.com/fractaledmind/solid_errors/pull/56)) 15 | - loosen Rails dependency constraints to allow for Rails 8 ([@dorianmariecom](https://github.com/fractaledmind/solid_errors/pull/58)) 16 | - update README.md with rails error reporting example ([@stillhart](https://github.com/fractaledmind/solid_errors/pull/57)) 17 | 18 | ## [0.4.3] - 2024-06-21 19 | 20 | - fix `SolidErrors.send_emails?` that always returns true ([@defkode](https://github.com/fractaledmind/solid_errors/pull/46)) 21 | - Highlight source line ([@Bhacaz](https://github.com/fractaledmind/solid_errors/pull/47)) 22 | - Latest occurrences first ([@Bhacaz](https://github.com/fractaledmind/solid_errors/pull/48)) 23 | - Fix partial not being found with different inflector [@emilioeduardob](https://github.com/fractaledmind/solid_errors/pull/50) 24 | 25 | ## [0.4.2] - 2024-04-10 26 | 27 | - Fix bug in error page when using Postgres ([@mintuhouse](https://github.com/fractaledmind/solid_errors/pull/43)) 28 | 29 | ## [0.4.1] - 2024-04-09 30 | 31 | - Ensure only first occurrence is open on error page ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/42)) 32 | 33 | ## [0.4.0] - 2024-04-09 34 | 35 | - Add email notifications ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/3)) 36 | - Add `fingerprint` column to `SolidErrors::Error` model ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/10)) 37 | - read more about the upgrade process in the [UPGRADE.md](./UPGRADE.md) file 38 | - Paginate error occurrences ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/39)) 39 | - Ensure footer sticks to bottom of page ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/40)) 40 | - Fix `errors/index` view ([@dorianmariecom](https://github.com/fractaledmind/solid_errors/pull/25)) 41 | - Fix `occurrences/_occurrence` partial ([@fractaledmind](https://github.com/fractaledmind/solid_errors/pull/28)) 42 | - Add documentation on ejecting views ([@dorianmariecom](https://github.com/fractaledmind/solid_errors/pull/30)) 43 | - Only declare necessary Rails sub-systems as dependencies ([@everton](https://github.com/fractaledmind/solid_errors/pull/35)) 44 | - Force :en locale inside ErrorsController to avoid missing translations ([@everton](https://github.com/fractaledmind/solid_errors/pull/36)) 45 | - Avoid triggering N+1 on index page ([luizkowalski](https://github.com/fractaledmind/solid_errors/pull/38)) 46 | 47 | ## [0.3.5] - 2024-02-06 48 | 49 | - Fix issue with `gsub!` on a frozen string ([@joelmoss](https://github.com/fractaledmind/solid_errors/pull/9)) 50 | 51 | ## [0.3.4] - 2024-01-29 52 | 53 | - Ensure that setting username/password with Ruby triggers the basic authentication 54 | 55 | ## [0.3.3] - 2024-01-28 56 | 57 | - Properly fix the setup issues 58 | - Add the needed locale file 59 | 60 | ## [0.3.2] - 2024-01-28 61 | 62 | - Fix belongs_to reference in Occurrence model 63 | 64 | ## [0.3.1] - 2024-01-28 65 | 66 | - Fix incorrect table reference name and long index name in the migration template 67 | 68 | ## [0.3.0] - 2024-01-28 69 | 70 | - Proper release with full coherent functionality and documentation 71 | 72 | ## [0.2.0] - 2024-01-14 73 | 74 | - Initial release 75 | 76 | ## [0.1.0] - 2024-01-14 77 | 78 | - Reserve gem name 79 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at stephen_margheim@epam.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in solid_errors.gemspec 6 | gemspec 7 | 8 | gem "rake", "~> 13.0" 9 | 10 | gem "minitest", "~> 5.0" 11 | 12 | gem "standard", "~> 1.3" 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | solid_errors (0.6.1) 5 | actionmailer (>= 7.0) 6 | actionpack (>= 7.0) 7 | actionview (>= 7.0) 8 | activerecord (>= 7.0) 9 | activesupport (>= 7.0) 10 | railties (>= 7.0) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actionmailer (7.2.0) 16 | actionpack (= 7.2.0) 17 | actionview (= 7.2.0) 18 | activejob (= 7.2.0) 19 | activesupport (= 7.2.0) 20 | mail (>= 2.8.0) 21 | rails-dom-testing (~> 2.2) 22 | actionpack (7.2.0) 23 | actionview (= 7.2.0) 24 | activesupport (= 7.2.0) 25 | nokogiri (>= 1.8.5) 26 | racc 27 | rack (>= 2.2.4, < 3.2) 28 | rack-session (>= 1.0.1) 29 | rack-test (>= 0.6.3) 30 | rails-dom-testing (~> 2.2) 31 | rails-html-sanitizer (~> 1.6) 32 | useragent (~> 0.16) 33 | actionview (7.2.0) 34 | activesupport (= 7.2.0) 35 | builder (~> 3.1) 36 | erubi (~> 1.11) 37 | rails-dom-testing (~> 2.2) 38 | rails-html-sanitizer (~> 1.6) 39 | activejob (7.2.0) 40 | activesupport (= 7.2.0) 41 | globalid (>= 0.3.6) 42 | activemodel (7.2.0) 43 | activesupport (= 7.2.0) 44 | activerecord (7.2.0) 45 | activemodel (= 7.2.0) 46 | activesupport (= 7.2.0) 47 | timeout (>= 0.4.0) 48 | activesupport (7.2.0) 49 | base64 50 | bigdecimal 51 | concurrent-ruby (~> 1.0, >= 1.3.1) 52 | connection_pool (>= 2.2.5) 53 | drb 54 | i18n (>= 1.6, < 2) 55 | logger (>= 1.4.2) 56 | minitest (>= 5.1) 57 | securerandom (>= 0.3) 58 | tzinfo (~> 2.0, >= 2.0.5) 59 | ast (2.4.2) 60 | base64 (0.2.0) 61 | bigdecimal (3.1.8) 62 | builder (3.3.0) 63 | concurrent-ruby (1.3.4) 64 | connection_pool (2.4.1) 65 | crass (1.0.6) 66 | date (3.3.4) 67 | drb (2.2.1) 68 | erubi (1.13.0) 69 | globalid (1.2.1) 70 | activesupport (>= 6.1) 71 | i18n (1.14.5) 72 | concurrent-ruby (~> 1.0) 73 | io-console (0.7.2) 74 | irb (1.14.0) 75 | rdoc (>= 4.0.0) 76 | reline (>= 0.4.2) 77 | json (2.7.2) 78 | language_server-protocol (3.17.0.3) 79 | lint_roller (1.1.0) 80 | logger (1.6.0) 81 | loofah (2.22.0) 82 | crass (~> 1.0.2) 83 | nokogiri (>= 1.12.0) 84 | mail (2.8.1) 85 | mini_mime (>= 0.1.1) 86 | net-imap 87 | net-pop 88 | net-smtp 89 | mini_mime (1.1.5) 90 | minitest (5.25.1) 91 | net-imap (0.4.14) 92 | date 93 | net-protocol 94 | net-pop (0.1.2) 95 | net-protocol 96 | net-protocol (0.2.2) 97 | timeout 98 | net-smtp (0.5.0) 99 | net-protocol 100 | nokogiri (1.16.7-arm64-darwin) 101 | racc (~> 1.4) 102 | nokogiri (1.16.7-x86_64-linux) 103 | racc (~> 1.4) 104 | parallel (1.26.3) 105 | parser (3.3.4.2) 106 | ast (~> 2.4.1) 107 | racc 108 | psych (5.1.2) 109 | stringio 110 | racc (1.8.1) 111 | rack (3.1.7) 112 | rack-session (2.0.0) 113 | rack (>= 3.0.0) 114 | rack-test (2.1.0) 115 | rack (>= 1.3) 116 | rackup (2.1.0) 117 | rack (>= 3) 118 | webrick (~> 1.8) 119 | rails-dom-testing (2.2.0) 120 | activesupport (>= 5.0.0) 121 | minitest 122 | nokogiri (>= 1.6) 123 | rails-html-sanitizer (1.6.0) 124 | loofah (~> 2.21) 125 | nokogiri (~> 1.14) 126 | railties (7.2.0) 127 | actionpack (= 7.2.0) 128 | activesupport (= 7.2.0) 129 | irb (~> 1.13) 130 | rackup (>= 1.0.0) 131 | rake (>= 12.2) 132 | thor (~> 1.0, >= 1.2.2) 133 | zeitwerk (~> 2.6) 134 | rainbow (3.1.1) 135 | rake (13.2.1) 136 | rdoc (6.7.0) 137 | psych (>= 4.0.0) 138 | regexp_parser (2.9.2) 139 | reline (0.5.9) 140 | io-console (~> 0.5) 141 | rexml (3.3.6) 142 | strscan 143 | rubocop (1.65.1) 144 | json (~> 2.3) 145 | language_server-protocol (>= 3.17.0) 146 | parallel (~> 1.10) 147 | parser (>= 3.3.0.2) 148 | rainbow (>= 2.2.2, < 4.0) 149 | regexp_parser (>= 2.4, < 3.0) 150 | rexml (>= 3.2.5, < 4.0) 151 | rubocop-ast (>= 1.31.1, < 2.0) 152 | ruby-progressbar (~> 1.7) 153 | unicode-display_width (>= 2.4.0, < 3.0) 154 | rubocop-ast (1.32.1) 155 | parser (>= 3.3.1.0) 156 | rubocop-performance (1.21.1) 157 | rubocop (>= 1.48.1, < 2.0) 158 | rubocop-ast (>= 1.31.1, < 2.0) 159 | ruby-progressbar (1.13.0) 160 | securerandom (0.3.1) 161 | sqlite3 (2.0.4-arm64-darwin) 162 | sqlite3 (2.0.4-x86_64-linux-gnu) 163 | standard (1.40.0) 164 | language_server-protocol (~> 3.17.0.2) 165 | lint_roller (~> 1.0) 166 | rubocop (~> 1.65.0) 167 | standard-custom (~> 1.0.0) 168 | standard-performance (~> 1.4) 169 | standard-custom (1.0.2) 170 | lint_roller (~> 1.0) 171 | rubocop (~> 1.50) 172 | standard-performance (1.4.0) 173 | lint_roller (~> 1.1) 174 | rubocop-performance (~> 1.21.0) 175 | stringio (3.1.1) 176 | strscan (3.1.0) 177 | thor (1.3.1) 178 | timeout (0.4.1) 179 | tzinfo (2.0.6) 180 | concurrent-ruby (~> 1.0) 181 | unicode-display_width (2.5.0) 182 | useragent (0.16.10) 183 | webrick (1.8.1) 184 | zeitwerk (2.6.17) 185 | 186 | PLATFORMS 187 | arm64-darwin 188 | x86_64-linux 189 | 190 | DEPENDENCIES 191 | minitest (~> 5.0) 192 | rake (~> 13.0) 193 | solid_errors! 194 | sqlite3 195 | standard (~> 1.3) 196 | 197 | BUNDLED WITH 198 | 2.5.17 199 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Stephen Margheim 4 | Copyright (c) 2024 Honeybadger Industries LLC 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solid Errors 2 | 3 |

4 | 5 | GEM Version 6 | 7 | 8 | GEM Downloads 9 | 10 | 11 | Ruby Style 12 | 13 | 14 | Tests 15 | 16 | 17 | Sponsors 18 | 19 | 20 | Ruby.Social Follow 21 | 22 | 23 | Twitter Follow 24 | 25 |

26 | 27 | 28 | Solid Errors is a DB-based, app-internal exception tracker for Rails applications, designed with simplicity and performance in mind. It uses the new [Rails error reporting API](https://guides.rubyonrails.org/error_reporting.html) to store uncaught exceptions in the database, and provides a simple UI for viewing and managing exceptions. 29 | 30 | > [!WARNING] 31 | > The current point release of Rails (7.1.3.2) has a bug which severely limits the utility of Solid Errors. Exceptions raised during a web request *are not* reported to Rails' error reporter. There is a fix in the `main` branch, but it has not been released in a new point release. As such, Solid Errors is **not** production-ready unless you are running Rails from the `main` branch or until a new point version is released and you upgrade. 32 | > The original bug report can be found [here](https://github.com/rails/rails/issues/51002) and the pull request making the fix is [here](https://github.com/rails/rails/pull/51050). I will try to backport the fix into the gem directly, but I haven't quite figured it out yet. 33 | 34 | 35 | ## Installation 36 | 37 | Install the gem and add to the application's Gemfile by executing: 38 | ```bash 39 | $ bundle add solid_errors 40 | ``` 41 | 42 | If bundler is not being used to manage dependencies, install the gem by executing: 43 | ```bash 44 | $ gem install solid_errors 45 | ``` 46 | 47 | After installing the gem, run the installer: 48 | ```bash 49 | $ rails generate solid_errors:install 50 | ``` 51 | 52 | This will create the `db/errors_schema.rb` file. 53 | 54 | You will then have to add the configuration for the errors database in `config/database.yml`. If you're using sqlite, it'll look something like this: 55 | 56 | ```yaml 57 | production: 58 | primary: 59 | <<: *default 60 | database: storage/production.sqlite3 61 | errors: 62 | <<: *default 63 | database: storage/production_errors.sqlite3 64 | migrations_paths: db/errors_migrate 65 | ``` 66 | 67 | ...or if you're using MySQL/PostgreSQL/Trilogy: 68 | 69 | ```yaml 70 | production: 71 | primary: &primary_production 72 | <<: *default 73 | database: app_production 74 | username: app 75 | password: <%= ENV["APP_DATABASE_PASSWORD"] %> 76 | errors: 77 | <<: *primary_production 78 | database: app_production_errors 79 | migrations_paths: db/errors_migrate 80 | ``` 81 | 82 | > [!NOTE] 83 | > Calling `bin/rails solid_errors:install` will automatically add `config.solid_errors.connects_to = { database: { writing: :errors } }` to `config/environments/production.rb`, so no additional configuration is needed there (although you must make sure that you use the `errors` name in `database.yml` for this to match!). But if you want to use Solid Errors in a different environment (like staging or even development), you'll have to manually add that `config.solid_errors.connects_to` line to the respective environment file. And, as always, make sure that the name you're using for the database in `config/database.yml` matches the name you use in `config.solid_errors.connects_to`. 84 | 85 | Then run `db:prepare` in production to ensure the database is created and the schema is loaded. 86 | 87 | Then mount the engine in your `config/routes.rb` file: 88 | ```ruby 89 | authenticate :user, -> (user) { user.admin? } do 90 | mount SolidErrors::Engine, at: "/solid_errors" 91 | end 92 | ``` 93 | 94 | > [!NOTE] 95 | > Be sure to [secure the dashboard](#authentication) in production. 96 | 97 | ## Usage 98 | 99 | All exceptions are recorded automatically. No additional code required. 100 | 101 | Please consult the [official guides](https://guides.rubyonrails.org/error_reporting.html) for an introduction to the error reporting API. 102 | 103 | There are intentionally few features; you can view and resolve errors. That’s it. The goal is to provide a simple, lightweight, and performant solution for tracking exceptions in your Rails application. If you need more features, you should probably use a 3rd party service like [Honeybadger](https://www.honeybadger.io/), whose MIT-licensed [Ruby agent gem](https://github.com/honeybadger-io/honeybadger-ruby) provided a couple of critical pieces of code for this project. 104 | 105 | ### Manually reporting an Error 106 | 107 | Errors can be added to Solid Errors via the [Rails error reporter](https://guides.rubyonrails.org/error_reporting.html). 108 | 109 | There are [three ways](https://guides.rubyonrails.org/error_reporting.html#using-the-error-reporter) you can use the error reporter: 110 | 111 | `Rails.error.handle` will report any error raised within the block. It will then swallow the error, and the rest of your code outside the block will continue as normal. 112 | 113 | ```ruby 114 | result = Rails.error.handle do 115 | 1 + '1' # raises TypeError 116 | end 117 | result # => nil 118 | 1 + 1 # This will be executed 119 | ``` 120 | 121 | `Rails.error.record` will report errors to all registered subscribers and then re-raise the error, meaning that the rest of your code won't execute. 122 | 123 | ```ruby 124 | Rails.error.record do 125 | 1 + '1' # raises TypeError 126 | end 127 | 1 + 1 # This won't be executed 128 | ``` 129 | 130 | You can also manually report errors by calling `Rails.error.report`: 131 | 132 | ```ruby 133 | begin 134 | # code 135 | rescue StandardError => e 136 | Rails.error.report(e) 137 | end 138 | ``` 139 | 140 | All 3 reporting APIs (`#handle`, `#record`, and `#report`) support the following options, which are then passed along to all registered subscribers: 141 | 142 | * `handled`: a Boolean to indicate if the error was handled. This is set to `true` by default. `#record` sets this to `false`. 143 | * `severity`: a Symbol describing the severity of the error. Expected values are: `:error`, `:warning`, and `:info`. `#handle` sets this to `:warning`, while `#record` sets it to `:error`. 144 | * `context`: a Hash to provide more context about the error, like request or user details 145 | * `source`: a String about the source of the error. The default source is `"application"`. Errors reported by internal libraries may set other sources; the Redis cache library may use "redis_cache_store.active_support", for instance. Your subscriber can use the source to ignore errors you aren't interested in. 146 | 147 | ```ruby 148 | Rails.error.handle(context: { user_id: user.id }, severity: :info) do 149 | # ... 150 | end 151 | ``` 152 | 153 | ### Configuration 154 | 155 | You can configure Solid Errors via the Rails configuration object, under the `solid_errors` key. Currently, 6 configuration options are available: 156 | 157 | * `connects_to` - The database configuration to use for the Solid Errors database. See [Database Configuration](#database-configuration) for more information. 158 | * `username` - The username to use for HTTP authentication. See [Authentication](#authentication) for more information. 159 | * `password` - The password to use for HTTP authentication. See [Authentication](#authentication) for more information. 160 | * `sends_email` - Whether or not to send emails when an error occurs. See [Email notifications](#email-notifications) for more information. 161 | * `email_from` - The email address to send a notification from. See [Email notifications](#email-notifications) for more information. 162 | * `email_to` - The email address(es) to send a notification to. See [Email notifications](#email-notifications) for more information. 163 | * `email_subject_prefix` - Prefix added to the subject line for email notifications. See [Email notifications](#email-notifications) for more information. 164 | 165 | ### Database Configuration 166 | 167 | `config.solid_errors.connects_to` takes a custom database configuration hash that will be used in the abstract `SolidErrors::Record` Active Record model. This is required to use a different database than the main app ([but the primary database can also be used](#single-database-configuration)). For example: 168 | 169 | ```ruby 170 | # Use a single separate DB for Solid Errors 171 | config.solid_errors.connects_to = { database: { writing: :solid_errors, reading: :solid_errors } } 172 | ``` 173 | 174 | or 175 | 176 | ```ruby 177 | # Use a separate primary/replica pair for Solid Errors 178 | config.solid_errors.connects_to = { database: { writing: :solid_errors_primary, reading: :solid_errors_replica } } 179 | ``` 180 | 181 | #### Single Database Configuration 182 | 183 | Running Solid Errors in a separate database is recommended, but it's also possible to use one single database for both the app and the errors. Just follow these steps to add errors to the primary database: 184 | 185 | 1. Copy the contents of `db/errors_schema.rb` into a normal migration and delete `db/errors_schema.rb` 186 | 2. Remove `config.solid_errors.connects_to` from your configuration files. 187 | 3. Migrate your database. 188 | 189 | You won't have multiple databases, so `database.yml` doesn't need to have the errors database configuration. 190 | 191 | #### Authentication 192 | 193 | Solid Errors does not restrict access out of the box. You must secure the dashboard yourself. However, it does provide basic HTTP authentication that can be used with basic authentication or Devise. All you need to do is setup a username and password. 194 | 195 | There are two ways to setup a username and password. First, you can use the `SOLIDERRORS_USERNAME` and `SOLIDERRORS_PASSWORD` environment variables: 196 | 197 | ```ruby 198 | ENV["SOLIDERRORS_USERNAME"] = "frodo" 199 | ENV["SOLIDERRORS_PASSWORD"] = "ikeptmysecrets" 200 | ``` 201 | 202 | Second, you can set the `SolidErrors.username` and `SolidErrors.password` variables in an initializer: 203 | 204 | ```ruby 205 | # Set authentication credentials for Solid Errors 206 | config.solid_errors.username = Rails.application.credentials.solid_errors.username 207 | config.solid_errors.password = Rails.application.credentials.solid_errors.password 208 | ``` 209 | 210 | Either way, if you have set a username and password, Solid Errors will use basic HTTP authentication. If you have not set a username and password, Solid Errors will not require any authentication to view the dashboard. 211 | 212 | If you use Devise for authentication in your app, you can also restrict access to the dashboard by using their `authenticate` constraint in your routes file: 213 | 214 | ```ruby 215 | authenticate :user, -> (user) { user.admin? } do 216 | mount SolidErrors::Engine, at: "/solid_errors" 217 | end 218 | ``` 219 | 220 | #### Email notifications 221 | 222 | Solid Errors _can_ send email notifications whenever an error occurs, if your application has ActionMailer already properly setup to send emails. However, in order to activate this feature you must define the email address(es) to send the notifications to. Optionally, you can also define the email address to send the notifications from (useful if your email provider only allows emails to be sent from a predefined list of addresses) or simply turn off this feature altogether. You can also define a subject prefix for the email notifications to quickly identify the source of the error. 223 | 224 | There are two ways to configure email notifications. First, you can use environment variables: 225 | 226 | ```ruby 227 | ENV["SOLIDERRORS_SEND_EMAILS"] = true # defaults to false 228 | ENV["SOLIDERRORS_EMAIL_FROM"] = "errors@myapp.com" # defaults to "solid_errors@noreply.com" 229 | ENV["SOLIDERRORS_EMAIL_TO"] = "devs@myapp.com" # no default, must be set 230 | ENV["SOLIDERRORS_EMAIL_SUBJECT_PREFIX"] = "[Application name][Environment]" # no default, optional 231 | ``` 232 | 233 | Second, you can set the values via the configuration object: 234 | 235 | ```ruby 236 | # Set authentication credentials and optional subject prefix for Solid Errors 237 | config.solid_errors.send_emails = true 238 | config.solid_errors.email_from = "errors@myapp.com" 239 | config.solid_errors.email_to = "devs@myapp.com" 240 | config.solid_errors.email_subject_prefix = "[#{Rails.application.name}][#{Rails.env}]" 241 | ``` 242 | 243 | If you have set `send_emails` to `true` and have set an `email_to` address, Solid Errors will send an email notification whenever an error occurs. If you have not set `send_emails` to `true` or have not set an `email_to` address, Solid Errors will not send any email notifications. 244 | 245 | ### Examples 246 | 247 | There are only two screens in the dashboard. 248 | 249 | * the index view of all unresolved errors: 250 | 251 | ![image description](images/index-screenshot.png) 252 | 253 | * and the show view of a particular error: 254 | 255 | ![image description](images/show-screenshot.png) 256 | 257 | ### Usage with API-only Applications 258 | 259 | If your Rails application is an API-only application (generated with the `rails new --api` command), you will need to add the following middleware to your `config/application.rb` file in order to use the dashboard UI provided by Solid Errors: 260 | 261 | ```ruby 262 | # /config/application.rb 263 | config.middleware.use ActionDispatch::Cookies 264 | config.middleware.use ActionDispatch::Session::CookieStore 265 | config.middleware.use ActionDispatch::Flash 266 | ``` 267 | 268 | ### Overwriting the views 269 | 270 | You can find the views in [`app/views`](https://github.com/fractaledmind/solid_errors/tree/main/app/views). 271 | 272 | ```bash 273 | app/views/ 274 | ├── layouts 275 | │   └── solid_errors 276 | │   ├── _style.html 277 | │   └── application.html.erb 278 | └── solid_errors 279 | ├── error_mailer 280 | │   ├── error_occurred.html.erb 281 | │   └── error_occurred.text.erb 282 | ├── errors 283 | │   ├── _actions.html.erb 284 | │   ├── _error.html.erb 285 | │   ├── _row.html.erb 286 | │   ├── index.html.erb 287 | │   └── show.html.erb 288 | └── occurrences 289 | ├── _collection.html.erb 290 | └── _occurrence.html.erb 291 | ``` 292 | 293 | You can always take control of the views by creating your own views and/or partials at these paths in your application. For example, if you wanted to overwrite the application layout, you could create a file at `app/views/layouts/solid_errors/application.html.erb`. If you wanted to remove the footer and the automatically disappearing flash messages, as one concrete example, you could define that file as: 294 | 295 | ```erb 296 | 297 | 298 | 299 | Solid Errors 300 | <%= csrf_meta_tags %> 301 | <%= csp_meta_tag %> 302 | 303 | <%= render "layouts/solid_errors/style" %> 304 | 305 | 306 |
307 | <%= content_for?(:content) ? yield(:content) : yield %> 308 |
309 | 310 |
311 | <% if notice.present? %> 312 |

313 | <%= notice %> 314 |

315 | <% end %> 316 | 317 | <% if alert.present? %> 318 |

319 | <%= alert %> 320 |

321 | <% end %> 322 |
323 | 324 | 325 | ``` 326 | 327 | ## Sponsors 328 | 329 |

330 | Proudly sponsored by 331 |

332 |

333 | 334 | 335 | 336 |

337 | 338 | ## Development 339 | 340 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 341 | 342 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 343 | 344 | ## Contributing 345 | 346 | Bug reports and pull requests are welcome on GitHub at https://github.com/fractaledmind/solid_errors. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/fractaledmind/solid_errors/blob/main/CODE_OF_CONDUCT.md). 347 | 348 | ## License 349 | 350 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 351 | 352 | ## Code of Conduct 353 | 354 | Everyone interacting in the SolidErrors project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/fractaledmind/solid_errors/blob/main/CODE_OF_CONDUCT.md). 355 | 356 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/test_*.rb"] 10 | end 11 | 12 | require "standard/rake" 13 | 14 | task default: %i[test standard] 15 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Solid Errors Upgrade Guide 2 | 3 | Follow this guide to upgrade your Solid Errors implementation to the next version 4 | 5 | ## Solid Errors 0.4.0 6 | 7 | We've added a `fingerprint` column to the `solid_errors` table and changed the `exception_class`, `message`, `severity`, and `source` columns to be limitless `text` type columns. This allows the unique index on the table to be on the single `fingerprint` column, which is a SHA256 hash of the `exception_class`, `message`, `severity`, and `source` columns. This change resolves problems with the unique index being too large as well as problems with one of the data columns being truncated. But, it requires a migration to update the `solid_errors` table. 8 | 9 | Create a migration for whatever database stores your errors: 10 | ```bash 11 | rails generate migration UpgradeSolidErrors --database {name_of_errors_database} 12 | ``` 13 | 14 | Then, update the migration file with the following code: 15 | ```ruby 16 | class UpgradeSolidErrors < ActiveRecord::Migration[7.1] 17 | def up 18 | change_column :solid_errors, :exception_class, :text, null: false, limit: nil 19 | change_column :solid_errors, :message, :text, null: false, limit: nil 20 | change_column :solid_errors, :severity, :text, null: false, limit: nil 21 | change_column :solid_errors, :source, :text, null: true, limit: nil 22 | add_column :solid_errors, :fingerprint, :string, limit: 64 23 | add_index :solid_errors, :fingerprint, unique: true 24 | remove_index :solid_errors, [:exception_class, :message, :severity, :source], unique: true 25 | end 26 | 27 | def down 28 | change_column :solid_errors, :exception_class, :string, null: false, limit: 200 29 | change_column :solid_errors, :message, :string, null: false, limit: nil 30 | change_column :solid_errors, :severity, :string, null: false, limit: 25 31 | change_column :solid_errors, :source, :string, null: true, limit: nil 32 | remove_index :solid_errors, [:fingerprint], unique: true 33 | remove_column :solid_errors, :fingerprint, :string, limit: 64 34 | add_index :solid_errors, [:exception_class, :message, :severity, :source], unique: true 35 | end 36 | end 37 | ``` 38 | 39 | Then, run this migration: 40 | ```bash 41 | rails db:migrate:{name_of_errors_database} 42 | ``` 43 | 44 | Once the migration is complete, you will next need to fingerprint any existing errors in your database. This can be done using the following Ruby script, which can be put in a Rake task, a data migration, or simply done in the console: 45 | ```ruby 46 | SolidErrors::Error.where(fingerprint: nil).find_each do |error| 47 | error_attributes = error.attributes.slice('exception_class', 'message', 'severity', 'source') 48 | fingerprint = Digest::SHA256.hexdigest(error_attributes.values.join) 49 | error.update_attribute(:fingerprint, fingerprint) 50 | end 51 | ``` 52 | 53 | You will need to run this script _as soon as the schema migration_ is complete so that you can fingerprint all existing errors before new errors with pre-generated fingerprints are recorded. 54 | 55 | Once you have migrated all existing errors to include a fingerprint, the final step is to run one more schema migration to mark the `fingerprint` column as non-nullable. You can generate a migration for this with the following command: 56 | ```bash 57 | rails generate migration SolidErrorFingerprintNonNullable --database {name_of_errors_database} 58 | ``` 59 | 60 | Then, update the migration file with the following code: 61 | ```ruby 62 | class SolidErrorFingerprintNonNullable < ActiveRecord::Migration[7.1] 63 | def change 64 | change_column_null :solid_errors, :fingerprint, false 65 | end 66 | end 67 | ``` 68 | 69 | Once this migration has been successfully run in production, your upgrade to Solid Errors 0.4.0 is complete! 70 | -------------------------------------------------------------------------------- /app/controllers/solid_errors/application_controller.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | class ApplicationController < ActionController::Base 3 | protect_from_forgery with: :exception 4 | 5 | http_basic_authenticate_with name: SolidErrors.username, password: SolidErrors.password if SolidErrors.password 6 | 7 | # adapted from: https://github.com/ddnexus/pagy/blob/master/gem/lib/pagy.rb 8 | OverflowError = Class.new(StandardError) 9 | class Page 10 | attr_reader :count, :items, :pages, :first, :last, :prev, :next, :offset, :from, :to 11 | 12 | def initialize(collection, params) 13 | @count = collection.count 14 | @items = (params[:items] || 20).to_i 15 | @pages = [(@count.to_f / @items).ceil, 1].max 16 | @page = ((page = (params[:page] || 1).to_i) > @pages) ? @pages : page 17 | @first = (1 unless @page == 1) 18 | @last = (@pages unless @page == @pages) 19 | @prev = (@page - 1 unless @page == 1) 20 | @next = (@page == @pages) ? nil : @page + 1 21 | @offset = (@items * (@page - 1)) 22 | @from = [@offset + 1, @count].min 23 | @to = [@offset + @items, @count].min 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/controllers/solid_errors/errors_controller.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | class ErrorsController < ApplicationController 3 | around_action :force_english_locale! 4 | 5 | before_action :set_error, only: %i[show update destroy] 6 | 7 | helper_method :error_scope 8 | 9 | # GET /errors 10 | def index 11 | errors_table = Error.arel_table 12 | occurrences_table = Occurrence.arel_table 13 | query_scope = error_scope.resolved? ? Error.resolved : Error.unresolved 14 | @errors = query_scope 15 | .joins(:occurrences) 16 | .select(errors_table[Arel.star], 17 | occurrences_table[:created_at].maximum.as("recent_occurrence"), 18 | occurrences_table[:id].count.as("occurrences_count")) 19 | .group(errors_table[:id]) 20 | .order(recent_occurrence: :desc) 21 | end 22 | 23 | # GET /errors/1 24 | def show 25 | @page = Page.new(@error.occurrences, params) 26 | @occurrences = @error.occurrences.offset(@page.offset).limit(@page.items).order(created_at: :desc) 27 | end 28 | 29 | # PATCH/PUT /errors/1 30 | def update 31 | @error.update!(error_params) 32 | redirect_to errors_path, notice: "Error marked as resolved." 33 | end 34 | 35 | # DELETE /errors/1 36 | def destroy 37 | if @error.resolved? 38 | @error.destroy 39 | redirect_to errors_path(scope: :resolved), notice: "Error deleted." 40 | else 41 | redirect_to error_path(@error), alert: "You must resolve the error before deleting it." 42 | end 43 | end 44 | 45 | private 46 | 47 | # Only allow a list of trusted parameters through. 48 | def error_params 49 | params.require(:error).permit(:resolved_at) 50 | end 51 | 52 | def set_error 53 | @error = Error.find(params[:id]) 54 | end 55 | 56 | def force_english_locale!(&action) 57 | I18n.with_locale(:en, &action) 58 | end 59 | 60 | def error_scope 61 | ActiveSupport::StringInquirer.new(params[:scope] || "unresolved") 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/mailers/solid_errors/error_mailer.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | # adapted from: https://github.com/codergeek121/email_error_reporter/blob/main/lib/email_error_reporter/error_mailer.rb 3 | class ErrorMailer < ActionMailer::Base 4 | def error_occurred(occurrence) 5 | @occurrence = occurrence 6 | @error = occurrence.error 7 | subject = "#{@error.severity_emoji} #{@error.exception_class}" 8 | if SolidErrors.email_subject_prefix.present? 9 | subject = [SolidErrors.email_subject_prefix, subject].join(" ").squish! 10 | end 11 | mail( 12 | subject: subject, 13 | from: SolidErrors.email_from, 14 | to: SolidErrors.email_to 15 | ) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/models/solid_errors/backtrace.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | # adapted from: https://github.com/honeybadger-io/honeybadger-ruby/blob/master/lib/honeybadger/backtrace.rb 3 | class Backtrace 4 | # Holder for an Array of Backtrace::Line instances. 5 | attr_reader :lines, :application_lines 6 | 7 | def self.parse(ruby_backtrace, opts = {}) 8 | ruby_lines = ruby_backtrace.to_a 9 | 10 | lines = ruby_lines.collect do |unparsed_line| 11 | BacktraceLine.parse(unparsed_line.to_s, opts) 12 | end.compact 13 | 14 | new(lines) 15 | end 16 | 17 | def initialize(lines) 18 | self.lines = lines 19 | self.application_lines = lines.select(&:application?) 20 | end 21 | 22 | # Convert Backtrace to array. 23 | # 24 | # Returns array containing backtrace lines. 25 | def to_ary 26 | lines.take(1000).map { |l| {number: l.filtered_number, file: l.filtered_file, method: l.filtered_method, source: l.source} } 27 | end 28 | alias_method :to_a, :to_ary 29 | 30 | # JSON support. 31 | # 32 | # Returns JSON representation of backtrace. 33 | def as_json(options = {}) 34 | to_ary 35 | end 36 | 37 | def to_s 38 | lines.map(&:to_s).join("\n") 39 | end 40 | 41 | def inspect 42 | "" 43 | end 44 | 45 | private 46 | 47 | attr_writer :lines, :application_lines 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/models/solid_errors/backtrace_line.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | # adapted from: https://github.com/honeybadger-io/honeybadger-ruby/blob/master/lib/honeybadger/backtrace.rb 3 | class BacktraceLine 4 | # Backtrace line regexp (optionally allowing leading X: for windows support). 5 | INPUT_FORMAT = %r{^((?:[a-zA-Z]:)?[^:]+):(\d+)(?::in `([^']+)')?$} 6 | STRING_EMPTY = "".freeze 7 | GEM_ROOT = "[GEM_ROOT]".freeze 8 | PROJECT_ROOT = "[PROJECT_ROOT]".freeze 9 | PROJECT_ROOT_CACHE = {} 10 | GEM_ROOT_CACHE = {} 11 | RELATIVE_ROOT = Regexp.new('^\.\/').freeze 12 | RAILS_ROOT = ::Rails.root.to_s.dup.freeze 13 | ROOT_REGEXP = Regexp.new("^#{Regexp.escape(RAILS_ROOT)}").freeze 14 | BACKTRACE_FILTERS = [ 15 | lambda { |line| 16 | return line unless defined?(Gem) 17 | GEM_ROOT_CACHE[line] ||= Gem.path.reduce(line) do |line, path| 18 | line.sub(path, GEM_ROOT) 19 | end 20 | }, 21 | lambda { |line| 22 | c = (PROJECT_ROOT_CACHE[RAILS_ROOT] ||= {}) 23 | return c[line] if c.has_key?(line) 24 | c[line] ||= line.sub(ROOT_REGEXP, PROJECT_ROOT) 25 | }, 26 | lambda { |line| line.sub(RELATIVE_ROOT, STRING_EMPTY) } 27 | ].freeze 28 | 29 | attr_reader :file 30 | attr_reader :number 31 | attr_reader :method 32 | attr_reader :filtered_file, :filtered_number, :filtered_method, :unparsed_line 33 | 34 | # Parses a single line of a given backtrace 35 | # 36 | # @param [String] unparsed_line The raw line from +caller+ or some backtrace. 37 | # 38 | # @return The parsed backtrace line. 39 | def self.parse(unparsed_line, opts = {}) 40 | filtered_line = BACKTRACE_FILTERS.reduce(unparsed_line) do |line, proc| 41 | proc.call(line) 42 | end 43 | 44 | if filtered_line 45 | match = unparsed_line.match(INPUT_FORMAT) || [].freeze 46 | fmatch = filtered_line.match(INPUT_FORMAT) || [].freeze 47 | 48 | file, number, method = match[1], match[2], match[3] 49 | filtered_args = [fmatch[1], fmatch[2], fmatch[3]] 50 | new(unparsed_line, file, number, method, *filtered_args, opts.fetch(:source_radius, 2)) 51 | end 52 | end 53 | 54 | def initialize(unparsed_line, file, number, method, filtered_file = file, 55 | filtered_number = number, filtered_method = method, 56 | source_radius = 2) 57 | self.unparsed_line = unparsed_line 58 | self.filtered_file = filtered_file 59 | self.filtered_number = filtered_number 60 | self.filtered_method = filtered_method 61 | self.file = file 62 | self.number = number 63 | self.method = method 64 | self.source_radius = source_radius 65 | end 66 | 67 | # Reconstructs the line in a readable fashion. 68 | def to_s 69 | "#{filtered_file}:#{filtered_number}:in `#{filtered_method}'" 70 | end 71 | 72 | def ==(other) 73 | to_s == other.to_s 74 | end 75 | 76 | def inspect 77 | "" 78 | end 79 | 80 | # Determines if this line is part of the application trace or not. 81 | def application? 82 | (filtered_file =~ /^\[PROJECT_ROOT\]/i) && !(filtered_file =~ /^\[PROJECT_ROOT\]\/vendor/i) 83 | end 84 | 85 | def source 86 | @source ||= get_source(file, number, source_radius) 87 | end 88 | 89 | private 90 | 91 | attr_writer :file, :number, :method, :filtered_file, :filtered_number, :filtered_method, :unparsed_line 92 | 93 | attr_accessor :source_radius 94 | 95 | # Open source file and read line(s). 96 | # 97 | # Returns an array of line(s) from source file. 98 | def get_source(file, number, radius = 2) 99 | return {} unless file && File.exist?(file) 100 | 101 | before = after = radius 102 | start = (number.to_i - 1) - before 103 | start = 0 and before = 1 if start <= 0 104 | duration = before + 1 + after 105 | 106 | l = 0 107 | File.open(file) do |f| 108 | start.times { 109 | f.gets 110 | l += 1 111 | } 112 | return duration.times.map { (line = f.gets) ? [(l += 1), line] : nil }.compact.to_h 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /app/models/solid_errors/error.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | class Error < Record 3 | self.table_name = "solid_errors" 4 | 5 | SEVERITY_TO_EMOJI = { 6 | error: "🔥", 7 | warning: "⚠️", 8 | info: "ℹ️" 9 | } 10 | SEVERITY_TO_BADGE_CLASSES = { 11 | error: "bg-red-100 text-red-800", 12 | warning: "bg-yellow-100 text-yellow-800", 13 | info: "bg-blue-100 text-blue-800" 14 | } 15 | STATUS_TO_EMOJI = { 16 | resolved: "✅", 17 | unresolved: "⏳" 18 | } 19 | STATUS_TO_BADGE_CLASSES = { 20 | resolved: "bg-green-100 text-green-800", 21 | unresolved: "bg-violet-100 text-violet-800" 22 | } 23 | 24 | has_many :occurrences, class_name: "SolidErrors::Occurrence", dependent: :destroy 25 | 26 | validates :exception_class, presence: true 27 | validates :message, presence: true 28 | validates :severity, presence: true 29 | 30 | scope :resolved, -> { where.not(resolved_at: nil) } 31 | scope :unresolved, -> { where(resolved_at: nil) } 32 | 33 | def severity_emoji 34 | SEVERITY_TO_EMOJI[severity.to_sym] 35 | end 36 | 37 | def severity_badge_classes 38 | "px-2 inline-flex text-sm font-semibold rounded-md #{SEVERITY_TO_BADGE_CLASSES[severity.to_sym]}" 39 | end 40 | 41 | def status 42 | resolved? ? :resolved : :unresolved 43 | end 44 | 45 | def status_emoji 46 | STATUS_TO_EMOJI[status] 47 | end 48 | 49 | def status_badge_classes 50 | "px-2 inline-flex text-sm font-semibold rounded-md #{STATUS_TO_BADGE_CLASSES[status]}" 51 | end 52 | 53 | def resolved? 54 | resolved_at.present? 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/models/solid_errors/occurrence.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | class Occurrence < Record 3 | belongs_to :error, class_name: "SolidErrors::Error" 4 | 5 | after_create_commit :send_email, if: -> { SolidErrors.send_emails? && SolidErrors.email_to.present? } 6 | 7 | # The parsed exception backtrace. Lines in this backtrace that are from installed gems 8 | # have the base path for gem installs replaced by "[GEM_ROOT]", while those in the project 9 | # have "[PROJECT_ROOT]". 10 | # @return [Array<{:number, :file, :method => String}>] 11 | def parsed_backtrace 12 | return @parsed_backtrace if defined? @parsed_backtrace 13 | 14 | @parsed_backtrace = parse_backtrace(backtrace.split("\n")) 15 | end 16 | 17 | private 18 | 19 | def parse_backtrace(backtrace) 20 | Backtrace.parse(backtrace) 21 | end 22 | 23 | def send_email 24 | ErrorMailer.error_occurred(self).deliver_later 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/models/solid_errors/record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidErrors 4 | class Record < ActiveRecord::Base 5 | self.abstract_class = true 6 | 7 | connects_to(**SolidErrors.connects_to) if SolidErrors.connects_to 8 | end 9 | end 10 | 11 | ActiveSupport.run_load_hooks :solid_errors_record, SolidErrors::Record 12 | -------------------------------------------------------------------------------- /app/views/layouts/solid_errors/_style.html: -------------------------------------------------------------------------------- 1 | 1194 | -------------------------------------------------------------------------------- /app/views/layouts/solid_errors/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Solid Errors 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= render "layouts/solid_errors/style" %> 9 | 10 | 11 |
12 | <%= content_for?(:content) ? yield(:content) : yield %> 13 |
14 | 15 | 21 | 22 |
23 | <% if notice.present? %> 24 |

27 | <%= notice %> 28 |

29 | <% end %> 30 | 31 | <% if alert.present? %> 32 |

35 | <%= alert %> 36 |

37 | <% end %> 38 |
39 | 40 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/views/solid_errors/error_mailer/error_occurred.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | Solid Errors | <%= @error.severity_emoji %> <%= @error.exception_class %> 4 | <%= render "layouts/solid_errors/style" %> 5 | 6 | 7 | <%= render "solid_errors/errors/error", 8 | error: @error, 9 | show_actions: false %> 10 | 11 |
12 |
13 | 14 | <%= render "solid_errors/occurrences/occurrence", 15 | occurrence: @occurrence %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/views/solid_errors/error_mailer/error_occurred.text.erb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/app/views/solid_errors/error_mailer/error_occurred.text.erb -------------------------------------------------------------------------------- /app/views/solid_errors/errors/_actions.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= link_to errors_path(scope: error.resolved? ? :resolved : :unresolved), class: "inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-8" do %> 3 | 4 | 5 | 6 | Back to errors 7 | <% end %> 8 | <% if error.resolved? %> 9 | <%= render 'solid_errors/errors/delete_button', error: error %> 10 | <% else %> 11 | <%= render 'solid_errors/errors/resolve_button', error: error %> 12 | <% end %> 13 |
14 | -------------------------------------------------------------------------------- /app/views/solid_errors/errors/_delete_button.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (error:) -%> 2 | <%= button_to error_path(error), method: :delete, class: "inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-transparent text-red-500 border-red-500 hover:ring-red-200 hover:ring-8" do %> 3 | 4 | 5 | 6 | Delete , Error #<%= error.id %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /app/views/solid_errors/errors/_error.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (error:, show_actions: true) -%> 2 | 3 | <%= tag.section id: dom_id(error), class: "space-y-6" do %> 4 |
5 | <%= tag.h1 class: "font-bold flex items-center text-2xl gap-2" do %> 6 | <%= error.severity_emoji %> 7 | <%= error.exception_class %> 8 | from 9 | <%= error.source %> 10 | <% end %> 11 | 12 | 13 | #<%= error.id %> 14 | 15 |
16 | 17 |
<%= error.message %>
18 | 19 |
20 |
21 |
22 | <%= SolidErrors::Error.human_attribute_name(:severity) %> 23 |
24 |
25 | <%= error.severity_emoji %> 26 | 27 | <%= error.severity %> 28 | 29 |
30 |
31 |
32 |
33 | <%= SolidErrors::Error.human_attribute_name(:status) %> 34 |
35 |
36 | <%= error.status_emoji %> 37 | 38 | <%= error.status %> 39 | 40 |
41 |
42 |
43 |
44 | <%= SolidErrors::Error.human_attribute_name(:first_seen) %> 45 |
46 |
47 | <% first_seen_at = error.occurrences.minimum(:created_at) %> 48 | 49 | 50 | <%= time_tag first_seen_at, "#{time_ago_in_words(first_seen_at)} ago" %> 51 | 52 |
53 |
54 |
55 |
56 | <%= SolidErrors::Error.human_attribute_name(:last_seen) %> 57 |
58 |
59 | <% last_seen_at = error.occurrences.maximum(:created_at) %> 60 | 61 | 62 | <%= time_tag last_seen_at, "#{time_ago_in_words(last_seen_at)} ago" %> 63 | 64 |
65 |
66 |
67 |
68 | <%= SolidErrors::Error.human_attribute_name(:exception_class) %> 69 |
70 |
71 | <%= error.exception_class %> 72 |
73 |
74 |
75 |
76 | <%= SolidErrors::Error.human_attribute_name(:source) %> 77 |
78 |
79 | <%= error.source %> 80 |
81 |
82 |
83 |
84 | <%= SolidErrors::Error.human_attribute_name(:project_root) %> 85 |
86 |
87 | <%= SolidErrors::BacktraceLine::RAILS_ROOT %> 88 |
89 |
90 |
91 |
92 | <%= SolidErrors::Error.human_attribute_name(:gem_root) %> 93 |
94 |
95 |
    96 | <% Gem.path.each do |path| %> 97 |
  • <%= path %>
  • 98 | <% end %> 99 |
100 |
101 |
102 |
103 | 104 | <%= render "solid_errors/errors/actions", error: error if show_actions %> 105 | <% end %> 106 | -------------------------------------------------------------------------------- /app/views/solid_errors/errors/_resolve_button.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (error:) -%> 2 | <%= button_to error_path(error), method: :patch, class: "inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-transparent text-blue-500 border-blue-500 hover:ring-blue-200 hover:ring-8", params: { error: { resolved_at: Time.now } } do %> 3 | 4 | 5 | 6 | 7 | Resolve , Error #<%= error.id %> 8 | <% end %> 9 | -------------------------------------------------------------------------------- /app/views/solid_errors/errors/_row.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (error:) -%> 2 | 3 | 4 |
5 | <%= error.severity_emoji %> 6 | <%= link_to error_path(error), class: "text-blue-400 underline inline-flex items-baseline gap-1" do %> 7 | <%= error.exception_class %> 8 | <% end %> 9 | from 10 | <%= error.source %> 11 |
12 |
<%= error.message %>
13 | 14 | 15 | <%= error.occurrences_count %> 16 | 17 | 18 | <% last_seen_at = error.recent_occurrence.is_a?(String) ? DateTime.strptime(error.recent_occurrence, "%Y-%m-%d %H:%M:%S.%N") : error.recent_occurrence %> 19 | 20 | <%= time_tag last_seen_at, time_ago_in_words(last_seen_at, scope: 'datetime.distance_in_words.short') %> 21 | 22 | 23 | 24 | <% if error.resolved? %> 25 | <%= render 'solid_errors/errors/delete_button', error: error %> 26 | <% else %> 27 | <%= render 'solid_errors/errors/resolve_button', error: error %> 28 | <% end %> 29 | 30 | 31 | -------------------------------------------------------------------------------- /app/views/solid_errors/errors/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | Errors 4 |

5 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 | 35 | 36 | <%= render partial: "solid_errors/errors/row", collection: @errors, as: :error %> 37 | 38 |
ErrorCountLast 30 | Resolve 31 |
39 | -------------------------------------------------------------------------------- /app/views/solid_errors/errors/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= render "solid_errors/errors/error", 2 | error: @error, 3 | show_actions: true %> 4 | 5 |
6 |
7 | 8 | <%= render "solid_errors/occurrences/collection", 9 | occurrences: @occurrences, 10 | page: @page, 11 | titled: true %> 12 | -------------------------------------------------------------------------------- /app/views/solid_errors/occurrences/_collection.html.erb: -------------------------------------------------------------------------------- 1 | <% titled ||= false %> 2 | 3 |
4 | <% if titled %> 5 |
6 |

7 | Occurrences 8 |

9 |
10 |

11 | <% page_count = page.count %> 12 | <% if page_count.zero? %> 13 | No posts found 14 | <% elsif page.pages == 1 %> 15 | <%= page_count %> total 16 | <% else %> 17 | <%= page.from %>-<%= page.to %> of <%= page_count %> 18 | <% end %> 19 |

20 | 21 | 46 |
47 |
48 | <% end %> 49 | 50 |
51 | <% if occurrences.any? %> 52 | <%= render partial: "solid_errors/occurrences/occurrence", 53 | collection: occurrences, as: :occurrence %> 54 | <% else %> 55 |
56 | <%= bootstrap_svg "list-ul" %> 57 | No occurrences yet… 58 |
59 | <% end %> 60 |
61 |
62 | -------------------------------------------------------------------------------- /app/views/solid_errors/occurrences/_occurrence.html.erb: -------------------------------------------------------------------------------- 1 | <% seen_at = occurrence.created_at %> 2 | <% backtrace = occurrence.parsed_backtrace %> 3 | <% open ||= defined?(occurrence_counter) ? occurrence_counter.zero? : true %> 4 | 5 | <%= tag.section id: dom_id(occurrence), class: "" do %> 6 | <%= tag.details open: open do %> 7 | 8 | <%= time_tag seen_at, seen_at %> 9 | (<%= time_ago_in_words(seen_at) %> ago) 10 | 11 |
12 |
13 | <% occurrence.context&.each do |key, value| %> 14 |
15 |
16 | <%= SolidErrors::Occurrence.human_attribute_name(key) %> 17 |
18 |
19 | <%= value %> 20 |
21 |
22 | <% end %> 23 |
24 |
25 | <%= SolidErrors::Occurrence.human_attribute_name(:backtrace) %> 26 |
27 |
28 | <% backtrace.lines.each_with_index do |line, i| %> 29 | <%= tag.details open: line.application? || i.zero? do %> 30 | 31 | <% if line.filtered_file %> 32 | <%= File.dirname(line.filtered_file) %>/<%= File.basename(line.filtered_file) %>:<%= line.filtered_number %> 33 | in 34 | <%= line.filtered_method %> 35 | <% else %> 36 | <%= line.unparsed_line %> 37 | <% end %> 38 | 39 |
<% line.source.each do |n, code| %>
40 |                     
<%= n %><%= code %>
41 | <% end %>
42 | <% end %> 43 | <% end %> 44 |
45 |
46 |
47 |
48 | <% end %> 49 | <% end %> 50 | 51 |
52 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "solid_errors" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # en.stripe.individual.card_issuing.user_terms_acceptance.ip 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | # activerecord.errors.models.card.attributes.stripe_issuing_account.mismatched_accounts 32 | 33 | en: 34 | datetime: 35 | distance_in_words: 36 | short: 37 | about_x_hours: 38 | one: ~%{count}h 39 | other: ~%{count}h 40 | about_x_months: 41 | one: ~%{count}mo 42 | other: ~%{count}mo 43 | about_x_years: 44 | one: ~%{count}y 45 | other: ~%{count}y 46 | almost_x_years: 47 | one: ~%{count}y 48 | other: ~%{count}y 49 | half_a_minute: .5m 50 | less_than_x_seconds: 51 | one: <%{count}s 52 | other: <%{count}s 53 | less_than_x_minutes: 54 | one: <1m 55 | other: <%{count}m 56 | over_x_years: 57 | one: ">%{count}y" 58 | other: ">%{count}y" 59 | x_seconds: 60 | one: "%{count}s" 61 | other: "%{count}s" 62 | x_minutes: 63 | one: "%{count}m" 64 | other: "%{count}m" 65 | x_days: 66 | one: "%{count}d" 67 | other: "%{count}d" 68 | x_months: 69 | one: "%{count}mo" 70 | other: "%{count}mo" 71 | x_years: 72 | one: "%{count}y" 73 | other: "%{count}y" 74 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | SolidErrors::Engine.routes.draw do 2 | get "/", to: "errors#index", as: :root 3 | 4 | resources :errors, only: [:index, :show, :update, :destroy], path: "" 5 | end 6 | -------------------------------------------------------------------------------- /images/index-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/images/index-screenshot.png -------------------------------------------------------------------------------- /images/show-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/images/show-screenshot.png -------------------------------------------------------------------------------- /lib/generators/solid_errors/install/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Installs solid_errors as a Rails error subscriber 3 | 4 | Example: 5 | bin/rails generate solid_errors:install 6 | 7 | This will perform the following: 8 | Adds solid_errors db schema 9 | -------------------------------------------------------------------------------- /lib/generators/solid_errors/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidErrors 4 | # 5 | # Rails generator used for setting up SolidErrors in a Rails application. 6 | # Run it with +bin/rails g solid_errors:install+ in your console. 7 | # 8 | class InstallGenerator < Rails::Generators::Base 9 | source_root File.expand_path("templates", __dir__) 10 | 11 | def add_solid_errors_db_schema 12 | template "db/errors_schema.rb" 13 | end 14 | 15 | def configure_solid_errors 16 | insert_into_file Pathname(destination_root).join("config/environments/production.rb"), after: /^([ \t]*).*?(?=\nend)$/ do 17 | [ 18 | "", 19 | '\1# Configure Solid Errors', 20 | '\1config.solid_errors.connects_to = { database: { writing: :errors } }', 21 | '\1config.solid_errors.send_emails = true', 22 | '\1config.solid_errors.email_from = ""', 23 | '\1config.solid_errors.email_to = ""', 24 | '\1config.solid_errors.username = Rails.application.credentials.dig(:solid_errors, :username)', 25 | '\1config.solid_errors.password = Rails.application.credentials.dig(:solid_errors, :password)' 26 | ].join("\n") 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/generators/solid_errors/install/templates/db/errors_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema[7.1].define(version: 1) do 4 | create_table "solid_errors", force: :cascade do |t| 5 | t.text "exception_class", null: false 6 | t.text "message", null: false 7 | t.text "severity", null: false 8 | t.text "source" 9 | t.datetime "resolved_at" 10 | t.string "fingerprint", limit: 64, null: false 11 | t.datetime "created_at", null: false 12 | t.datetime "updated_at", null: false 13 | t.index ["fingerprint"], name: "index_solid_errors_on_fingerprint", unique: true 14 | t.index ["resolved_at"], name: "index_solid_errors_on_resolved_at" 15 | end 16 | 17 | create_table "solid_errors_occurrences", force: :cascade do |t| 18 | t.integer "error_id", null: false 19 | t.text "backtrace" 20 | t.json "context" 21 | t.datetime "created_at", null: false 22 | t.datetime "updated_at", null: false 23 | t.index ["error_id"], name: "index_solid_errors_occurrences_on_error_id" 24 | end 25 | 26 | add_foreign_key "solid_errors_occurrences", "solid_errors", column: "error_id" 27 | end 28 | -------------------------------------------------------------------------------- /lib/solid_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "solid_errors/version" 4 | require_relative "solid_errors/sanitizer" 5 | require_relative "solid_errors/subscriber" 6 | require_relative "solid_errors/engine" 7 | 8 | module SolidErrors 9 | mattr_accessor :connects_to 10 | mattr_writer :username 11 | mattr_writer :password 12 | mattr_writer :send_emails 13 | mattr_writer :email_from 14 | mattr_writer :email_to 15 | mattr_writer :email_subject_prefix 16 | 17 | class << self 18 | # use method instead of attr_accessor to ensure 19 | # this works if variable set after SolidErrors is loaded 20 | def username 21 | @username ||= ENV["SOLIDERRORS_USERNAME"] || @@username 22 | end 23 | 24 | # use method instead of attr_accessor to ensure 25 | # this works if variable set after SolidErrors is loaded 26 | def password 27 | @password ||= ENV["SOLIDERRORS_PASSWORD"] || @@password 28 | end 29 | 30 | def send_emails? 31 | @send_emails ||= ENV["SOLIDERRORS_SEND_EMAILS"] || @@send_emails || false 32 | end 33 | 34 | def email_from 35 | @email_from ||= ENV["SOLIDERRORS_EMAIL_FROM"] || @@email_from || "solid_errors@noreply.com" 36 | end 37 | 38 | def email_to 39 | @email_to ||= ENV["SOLIDERRORS_EMAIL_TO"] || @@email_to 40 | end 41 | 42 | def email_subject_prefix 43 | @email_subject_prefix ||= ENV["SOLIDERRORS_EMAIL_SUBJECT_PREFIX"] || @@email_subject_prefix 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/solid_errors/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidErrors 4 | class Engine < ::Rails::Engine 5 | isolate_namespace SolidErrors 6 | 7 | config.solid_errors = ActiveSupport::OrderedOptions.new 8 | 9 | initializer "solid_errors.config" do 10 | config.solid_errors.each do |name, value| 11 | SolidErrors.public_send(:"#{name}=", value) 12 | end 13 | end 14 | 15 | initializer "solid_errors.active_record.error_subscriber" do 16 | Rails.error.subscribe(SolidErrors::Subscriber.new) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/solid_errors/sanitizer.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | # adapted from: https://github.com/honeybadger-io/honeybadger-ruby/blob/master/lib/honeybadger/util/sanitizer.rb 3 | class Sanitizer 4 | BASIC_OBJECT = "#".freeze 5 | DEPTH = "[DEPTH]".freeze 6 | RAISED = "[RAISED]".freeze 7 | RECURSION = "[RECURSION]".freeze 8 | TRUNCATED = "[TRUNCATED]".freeze 9 | MAX_STRING_SIZE = 65536 10 | 11 | def self.sanitize(data) 12 | @sanitizer ||= new 13 | @sanitizer.sanitize(data) 14 | end 15 | 16 | def initialize(max_depth: 20) 17 | @max_depth = max_depth 18 | end 19 | 20 | def sanitize(data, depth = 0, stack = nil) 21 | return BASIC_OBJECT if basic_object?(data) 22 | 23 | if recursive?(data) 24 | return RECURSION if stack&.include?(data.object_id) 25 | 26 | stack = stack ? stack.dup : Set.new 27 | stack << data.object_id 28 | end 29 | 30 | case data 31 | when Hash 32 | return DEPTH if depth >= max_depth 33 | 34 | new_hash = {} 35 | data.each do |key, value| 36 | key = key.is_a?(Symbol) ? key : sanitize(key, depth + 1, stack) 37 | value = sanitize(value, depth + 1, stack) 38 | new_hash[key] = value 39 | end 40 | new_hash 41 | when Array, Set 42 | return DEPTH if depth >= max_depth 43 | 44 | data.to_a.map do |value| 45 | sanitize(value, depth + 1, stack) 46 | end 47 | when Numeric, TrueClass, FalseClass, NilClass 48 | data 49 | when String 50 | sanitize_string(data) 51 | else # all other objects 52 | klass = data.class 53 | 54 | begin 55 | data = String(data) 56 | rescue 57 | return RAISED 58 | end 59 | 60 | return "#<#{klass.name}>" if inspected?(data) 61 | 62 | sanitize_string(data) 63 | end 64 | end 65 | 66 | private 67 | 68 | attr_reader :max_depth 69 | 70 | def basic_object?(object) 71 | object.respond_to?(:to_s) 72 | false 73 | rescue 74 | # BasicObject doesn't respond to `#respond_to?`. 75 | true 76 | end 77 | 78 | def recursive?(data) 79 | data.is_a?(Hash) || data.is_a?(Array) || data.is_a?(Set) 80 | end 81 | 82 | def sanitize_string(string) 83 | string = string.gsub(/#<(.*?):0x.*?>/, '#<\1>') # remove object_id 84 | return string unless string.respond_to?(:size) && string.size > MAX_STRING_SIZE 85 | string[0...MAX_STRING_SIZE] + TRUNCATED 86 | end 87 | 88 | def inspected?(string) 89 | String(string) =~ /#<.*>/ 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/solid_errors/subscriber.rb: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | class Subscriber 3 | IGNORED_ERRORS = ["ActionController::RoutingError", 4 | "AbstractController::ActionNotFound", 5 | "ActionController::MethodNotAllowed", 6 | "ActionController::UnknownHttpMethod", 7 | "ActionController::NotImplemented", 8 | "ActionController::UnknownFormat", 9 | "ActionController::InvalidAuthenticityToken", 10 | "ActionController::InvalidCrossOriginRequest", 11 | "ActionDispatch::Http::Parameters::ParseError", 12 | "ActionController::BadRequest", 13 | "ActionController::ParameterMissing", 14 | "ActiveRecord::RecordNotFound", 15 | "ActionController::UnknownAction", 16 | "ActionDispatch::Http::MimeNegotiation::InvalidType", 17 | "Rack::QueryParser::ParameterTypeError", 18 | "Rack::QueryParser::InvalidParameterError", 19 | "CGI::Session::CookieStore::TamperedWithCookie", 20 | "Mongoid::Errors::DocumentNotFound", 21 | "Sinatra::NotFound", 22 | "Sidekiq::JobRetry::Skip"].map(&:freeze).freeze 23 | 24 | def report(error, handled:, severity:, context:, source: nil) 25 | return if ignore_by_class?(error.class.name) 26 | 27 | error_attributes = { 28 | exception_class: error.class.name, 29 | message: s(error.message), 30 | severity: severity, 31 | source: source 32 | } 33 | fingerprint = Digest::SHA256.hexdigest(error_attributes.values.join) 34 | if (record = SolidErrors::Error.find_by(fingerprint: fingerprint)) 35 | record.update!(resolved_at: nil, updated_at: Time.now) 36 | else 37 | record = SolidErrors::Error.create!(error_attributes.merge(fingerprint: fingerprint)) 38 | end 39 | 40 | SolidErrors::Occurrence.create( 41 | error_id: record.id, 42 | backtrace: error.backtrace.join("\n"), 43 | context: s(context) 44 | ) 45 | end 46 | 47 | def s(data) 48 | Sanitizer.sanitize(data) 49 | end 50 | 51 | def ignore_by_class?(error_class_name) 52 | IGNORED_ERRORS.any? do |ignored_class| 53 | ignored_class_name = ignored_class.respond_to?(:name) ? ignored_class.name : ignored_class 54 | 55 | ignored_class_name == error_class_name 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/solid_errors/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidErrors 4 | VERSION = "0.6.1" 5 | end 6 | -------------------------------------------------------------------------------- /sig/solid_errors.rbs: -------------------------------------------------------------------------------- 1 | module SolidErrors 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /solid_errors.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/solid_errors/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "solid_errors" 7 | spec.version = SolidErrors::VERSION 8 | spec.authors = ["Stephen Margheim"] 9 | spec.email = ["stephen.margheim@gmail.com"] 10 | 11 | spec.summary = "Database-backed Rails error subscriber" 12 | spec.homepage = "https://github.com/fractaledmind/solid_errors" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 2.6.0" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = "https://github.com/fractaledmind/solid_errors" 18 | 19 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 20 | Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 21 | end 22 | 23 | ">= 7.0".tap do |rails_version| 24 | spec.add_dependency "actionmailer", rails_version 25 | spec.add_dependency "actionpack", rails_version 26 | spec.add_dependency "actionview", rails_version 27 | spec.add_dependency "activerecord", rails_version 28 | spec.add_dependency "activesupport", rails_version 29 | spec.add_dependency "railties", rails_version 30 | end 31 | 32 | spec.add_development_dependency "sqlite3" 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module Dummy 22 | class Application < Rails::Application 23 | config.load_defaults Rails::VERSION::STRING.to_f 24 | 25 | # For compatibility with applications that use this config 26 | config.action_controller.include_all_helpers = false 27 | 28 | # Please, add to the `ignore` list any other `lib` subdirectories that do 29 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 30 | # Common ones are `templates`, `generators`, or `middleware`, for example. 31 | config.autoload_lib(ignore: %w[assets tasks]) 32 | 33 | # Configuration for the application, engines, and railties goes here. 34 | # 35 | # These settings can be overridden in specific environments using the files 36 | # in config/environments, which are processed later. 37 | # 38 | # config.time_zone = "Central Time (US & Canada)" 39 | # config.eager_load_paths << Rails.root.join("extras") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | primary: &primary 13 | <<: *default 14 | database: storage/<%= ENV.fetch("RAILS_ENV", "development") %>.sqlite3 15 | 16 | queue: &queue 17 | <<: *default 18 | migrations_paths: db/queue_migrate 19 | database: storage/queue.sqlite3 20 | 21 | errors: &errors 22 | <<: *default 23 | migrations_paths: db/errors_migrate 24 | database: storage/errors.sqlite3 25 | 26 | development: 27 | primary: 28 | <<: *primary 29 | database: storage/<%= `git branch --show-current`.chomp || 'development' %>.sqlite3 30 | queue: *queue 31 | errors: *errors 32 | 33 | # Warning: The database defined as "test" will be erased and 34 | # re-generated from your development database when you run "rake". 35 | # Do not set this db to the same as development or production. 36 | test: 37 | primary: 38 | <<: *primary 39 | database: db/test.sqlite3 40 | queue: 41 | <<: *queue 42 | database: db/queue.sqlite3 43 | errors: 44 | <<: *errors 45 | database: db/errors.sqlite3 46 | 47 | production: 48 | primary: *primary 49 | queue: *queue 50 | errors: *errors 51 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # While tests run files are not watched, reloading is not necessary. 12 | config.enable_reloading = false 13 | 14 | # Eager loading loads your entire application. When running a single test locally, 15 | # this is usually not necessary, and can slow down your test suite. However, it's 16 | # recommended that you enable it in continuous integration systems to ensure eager 17 | # loading is working properly before deploying your code. 18 | config.eager_load = ENV["CI"].present? 19 | 20 | # Configure public file server for tests with Cache-Control for performance. 21 | config.public_file_server.enabled = true 22 | config.public_file_server.headers = { 23 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 24 | } 25 | 26 | # Show full error reports and disable caching. 27 | config.consider_all_requests_local = true 28 | config.action_controller.perform_caching = false 29 | config.cache_store = :null_store 30 | 31 | # Render exception templates for rescuable exceptions and raise for other exceptions. 32 | config.action_dispatch.show_exceptions = :rescuable 33 | 34 | # Disable request forgery protection in test environment. 35 | config.action_controller.allow_forgery_protection = false 36 | 37 | # Store uploaded files on the local file system in a temporary directory. 38 | # config.active_storage.service = :test 39 | 40 | # config.action_mailer.perform_caching = false 41 | 42 | # Tell Action Mailer not to deliver emails to the real world. 43 | # The :test delivery method accumulates sent emails in the 44 | # ActionMailer::Base.deliveries array. 45 | # config.action_mailer.delivery_method = :test 46 | 47 | # Print deprecation notices to the stderr. 48 | config.active_support.deprecation = :stderr 49 | 50 | # Raise exceptions for disallowed deprecations. 51 | config.active_support.disallowed_deprecation = :raise 52 | 53 | # Tell Active Support which deprecation messages to disallow. 54 | config.active_support.disallowed_deprecation_warnings = [] 55 | 56 | # Raises error for missing translations. 57 | # config.i18n.raise_on_missing_translations = true 58 | 59 | # Annotate rendered view with file names. 60 | # config.action_view.annotate_rendered_view_with_filenames = true 61 | 62 | # Raise error when a before_action's only/except options reference missing actions 63 | config.action_controller.raise_on_missing_callback_actions = true 64 | end 65 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | 5 | # Puma can serve each request in a thread from an internal thread pool. 6 | # The `threads` method setting takes two numbers: a minimum and maximum. 7 | # Any libraries that use thread pools should be configured to match 8 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 9 | # and maximum; this matches the default thread size of Active Record. 10 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 11 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 12 | threads min_threads_count, max_threads_count 13 | 14 | # Specifies that the worker count should equal the number of processors in production. 15 | if ENV["RAILS_ENV"] == "production" 16 | require "concurrent-ruby" 17 | worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) 18 | workers worker_count if worker_count > 1 19 | end 20 | 21 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 22 | # terminating a worker in development environments. 23 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 24 | 25 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 26 | port ENV.fetch("PORT") { 3000 } 27 | 28 | # Specifies the `environment` that Puma will run in. 29 | environment ENV.fetch("RAILS_ENV") { "development" } 30 | 31 | # Specifies the `pidfile` that Puma will use. 32 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 33 | 34 | # Allow puma to be restarted by `bin/rails restart` command. 35 | plugin :tmp_restart 36 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html 3 | 4 | # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. 5 | # Can be used by load balancers and uptime monitors to verify that the app is live. 6 | get "up" => "rails/health#show", :as => :rails_health_check 7 | 8 | # Defines the root path route ("/") 9 | # root "posts#index" 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/db/errors.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/db/errors.sqlite3 -------------------------------------------------------------------------------- /test/dummy/db/queue.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/db/queue.sqlite3 -------------------------------------------------------------------------------- /test/dummy/db/test.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/db/test.sqlite3 -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/storage/.keep -------------------------------------------------------------------------------- /test/dummy/storage/test.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/storage/test.sqlite3 -------------------------------------------------------------------------------- /test/dummy/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/tmp/.keep -------------------------------------------------------------------------------- /test/dummy/tmp/local_secret.txt: -------------------------------------------------------------------------------- 1 | 2e674226836fd5b8d265fbc2088757c026b86086afb1ea213271ec09b432cce56122da2e7ba0ab5e70ab06f5b1b07fdf386b0cea6b28efce5afaa11d5aa1e76f -------------------------------------------------------------------------------- /test/dummy/tmp/pids/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/tmp/pids/.keep -------------------------------------------------------------------------------- /test/dummy/tmp/storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fractaledmind/solid_errors/b75251b35c28e53f4339cc01f2ed519367c7a6f0/test/dummy/tmp/storage/.keep -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | 5 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 6 | 7 | require_relative "../test/dummy/config/environment" 8 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 9 | require "rails/test_help" 10 | require "solid_errors" 11 | 12 | require "minitest/autorun" 13 | -------------------------------------------------------------------------------- /test/test_solid_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TestSolidErrors < Minitest::Test 6 | def test_that_it_has_a_version_number 7 | refute_nil ::SolidErrors::VERSION 8 | end 9 | end 10 | --------------------------------------------------------------------------------