├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── exception_page_spec.cr ├── frame_spec.cr ├── spec_helper.cr └── support │ ├── app_exception_page.cr │ ├── test_handler.cr │ └── test_server.cr └── src ├── exception_page.cr └── exception_page ├── exception_page.ecr ├── styles.cr └── version.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Exception Page CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | check_format: 11 | strategy: 12 | fail-fast: false 13 | runs-on: ubuntu-latest 14 | continue-on-error: false 15 | steps: 16 | - name: Download source 17 | uses: actions/checkout@v4 18 | - name: Install Crystal 19 | uses: crystal-lang/install-crystal@v1 20 | - name: Install shards 21 | run: shards install 22 | - name: Format 23 | run: crystal tool format --check 24 | - name: Lint 25 | run: ./bin/ameba 26 | specs: 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | os: [ubuntu-latest, windows-latest, macos-latest] 31 | crystal_version: [latest] 32 | runs-on: ${{ matrix.os }} 33 | continue-on-error: false 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: crystal-lang/install-crystal@v1 37 | with: 38 | crystal: ${{ matrix.crystal_version }} 39 | - name: Install dependencies 40 | run: shards install --skip-postinstall --skip-executables 41 | - name: Run tests 42 | run: crystal spec 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Paul Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Exception Page [![CI](https://github.com/crystal-loot/exception_page/actions/workflows/ci.yml/badge.svg)](https://github.com/crystal-loot/exception_page/actions/workflows/ci.yml) 2 | 3 | A library for displaying exceptional exception pages for easier debugging. 4 | 5 | ![screen shot 2018-06-29 at 2 39 18 pm](https://user-images.githubusercontent.com/22394/42109073-6e767d06-7baa-11e8-9ec9-0a2afce605be.png) 6 | 7 | ## Installation 8 | 9 | Add this to your application's `shard.yml`: 10 | 11 | ```yaml 12 | dependencies: 13 | exception_page: 14 | github: crystal-loot/exception_page 15 | ``` 16 | 17 | ## Usage 18 | 19 | Require the shard: 20 | 21 | ```crystal 22 | require "exception_page" 23 | ``` 24 | 25 | Create an exception page: 26 | 27 | ```crystal 28 | class MyApp::ExceptionPage < ExceptionPage 29 | def styles : Styles 30 | ExceptionPage::Styles.new( 31 | accent: "purple", # Choose the HTML color value. Can be hex 32 | ) 33 | end 34 | end 35 | ``` 36 | 37 | Render the HTML when an exception occurs: 38 | 39 | ```crystal 40 | class MyErrorHandler 41 | include HTTP::Handler 42 | 43 | def call_next(context) 44 | begin 45 | # Normally you'd call some code to handle the request 46 | # We're hard-coding an error here to show you how to use the lib. 47 | raise SomeError.new("Something went wrong") 48 | rescue e 49 | context.response.status_code = 500 50 | context.response.print MyApp::ExceptionPage.new context, e 51 | end 52 | end 53 | ``` 54 | 55 | ## Customizing the page 56 | 57 | ```crystal 58 | class MyApp::ExceptionPage < ExceptionPage 59 | def styles : Styles 60 | ExceptionPage::Styles.new( 61 | accent: "purple", # Required 62 | highlight: "gray", # Optional 63 | flash_highlight: "red", # Optional 64 | logo_uri: "base64_encoded_data_uri" # Optional. Defaults to Crystal logo. Generate a logo here: https://dopiaza.org/tools/datauri/index.php 65 | ) 66 | end 67 | 68 | # Optional. If provided, clicking the logo will open this page 69 | def project_url 70 | "https://myproject.com" 71 | end 72 | 73 | # Optional 74 | def stack_trace_heading_html 75 | <<-HTML 76 | Say hi 77 | HTML 78 | end 79 | 80 | # Optional 81 | def extra_javascript 82 | <<-JAVASCRIPT 83 | window.sayHi = function() { 84 | alert("Say Hi!"); 85 | } 86 | JAVASCRIPT 87 | end 88 | end 89 | ``` 90 | 91 | ## Development 92 | 93 | TODO: Write development instructions here 94 | 95 | ## Contributing 96 | 97 | 1. Fork it () 98 | 2. Create your feature branch (`git checkout -b my-new-feature`) 99 | 3. Commit your changes (`git commit -am 'Add some feature'`) 100 | 4. Push to the branch (`git push origin my-new-feature`) 101 | 5. Create a new Pull Request 102 | 103 | ## Contributors 104 | 105 | - [@paulcsmith](https://github.com/paulcsmith) Paul Smith 106 | - [@faustinoaq](https://github.com/faustinoaq) Faustino Aigular - Wrote the initial [Amber PR adding exception pages](https://github.com/amberframework/amber/pull/864) 107 | - [@Sija](https://github.com/paulcsmith) Sijawusz Pur Rahnama 108 | 109 | ## Special Thanks 110 | 111 | This exception page is heavily based on the [Phoenix error page](https://github.com/phoenixframework/phoenix/issues/1776) 112 | by [@rstacruz](https://github.com/rstacruz). Thanks to the Phoenix team and @rstacruz! 113 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: exception_page 2 | version: 0.5.0 3 | 4 | authors: 5 | - Paul Smith 6 | - Faustino Aguilar 7 | - Sijawusz Pur Rahnama 8 | 9 | dependencies: 10 | backtracer: 11 | github: Sija/backtracer.cr 12 | version: ~> 1.2.2 13 | 14 | development_dependencies: 15 | lucky_flow: 16 | github: luckyframework/lucky_flow 17 | version: ~> 0.10.0 18 | ameba: 19 | github: crystal-ameba/ameba 20 | version: ~> 1.5.0 21 | 22 | crystal: ">= 1.8.0" 23 | 24 | license: MIT 25 | -------------------------------------------------------------------------------- /spec/exception_page_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe ExceptionPage do 4 | it "allows debugging the exception page" do 5 | flow = ErrorDebuggingFlow.new 6 | 7 | flow.view_error_page 8 | flow.should_have_information_for_debugging 9 | flow.show_all_frames 10 | flow.should_be_able_to_view_other_frames 11 | end 12 | 13 | it "allows debugging the multiline exception page" do 14 | flow = MultilineErrorDebuggingFlow.new 15 | 16 | flow.view_error_page 17 | flow.should_have_additional_message_lines 18 | end 19 | 20 | it "allows instantiating one manually" do 21 | MyApp::ExceptionPage.new Exception.new("Oh noes"), "SEARCH", "/users", :im_a_teapot 22 | end 23 | end 24 | 25 | class ErrorDebuggingFlow < LuckyFlow 26 | def view_error_page 27 | visit "/" 28 | end 29 | 30 | def should_have_information_for_debugging 31 | current_page.should have_element("@exception-title", text: "Something went very wrong") 32 | current_page.should have_element("@code-frames", text: "test_server.cr") 33 | end 34 | 35 | def show_all_frames 36 | el("@show-all-frames").click 37 | end 38 | 39 | def should_be_able_to_view_other_frames 40 | el("@code-frame-file", "server.cr").click 41 | current_page.should have_element("@code-frame-summary", text: "->") 42 | end 43 | 44 | # A shim used for readibility in tests 45 | private def current_page 46 | self 47 | end 48 | end 49 | 50 | class MultilineErrorDebuggingFlow < LuckyFlow 51 | def view_error_page 52 | visit "/multiline-exception" 53 | end 54 | 55 | def should_have_additional_message_lines 56 | click("@see-raw-error-message") 57 | current_page.should have_element("@raw-error-message") 58 | end 59 | 60 | # A shim used for readibility in tests 61 | private def current_page 62 | self 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/frame_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | include ExceptionPage::Helpers 4 | 5 | describe "Frame parsing" do 6 | it "returns the correct label" do 7 | frame = frame_for("usr/crystal-lang/frame_spec.cr:6:7 in '->'") 8 | label_for_frame(frame).should eq("crystal") 9 | 10 | frame = frame_for("usr/crystal/frame_spec.cr:6:7 in '->'") 11 | label_for_frame(frame).should eq("crystal") 12 | 13 | frame = frame_for("lib/exception_page/spec/frame_spec.cr:6:7 in '->'") 14 | label_for_frame(frame).should eq("exception_page") 15 | 16 | frame = frame_for("lib/exception_page/frame_spec.cr:6:7 in '->'") 17 | label_for_frame(frame).should eq("exception_page") 18 | 19 | frame = frame_for("lib/frame_spec.cr:6:7 in '->'") 20 | label_for_frame(frame).should eq("app") 21 | 22 | frame = frame_for("src/frame_spec.cr:6:7 in '->'") 23 | label_for_frame(frame).should eq("app") 24 | end 25 | end 26 | 27 | private def frame_for(backtrace_line) 28 | Backtracer.parse(backtrace_line).frames.first 29 | end 30 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "http" 3 | require "lucky_flow" 4 | require "../src/exception_page" 5 | require "./support/*" 6 | 7 | include LuckyFlow::Expectations 8 | 9 | LuckyFlow.configure do |settings| 10 | settings.base_uri = "http://local.test" 11 | settings.stop_retrying_after = 40.milliseconds 12 | end 13 | 14 | LuckyFlow::Registry.register :webless do 15 | LuckyFlow::Webless::Driver.new(TestHandler.new) 16 | end 17 | 18 | LuckyFlow.default_driver = ENV.fetch("LUCKYFLOW_DRIVER", "webless") 19 | LuckyFlow::Spec.setup 20 | 21 | Habitat.raise_if_missing_settings! 22 | -------------------------------------------------------------------------------- /spec/support/app_exception_page.cr: -------------------------------------------------------------------------------- 1 | class MyApp::ExceptionPage < ExceptionPage 2 | def styles : Styles 3 | Styles.new(accent: "purple") 4 | end 5 | 6 | def stack_trace_heading_html 7 | <<-HTML 8 | Say hi 9 | HTML 10 | end 11 | 12 | def extra_javascript 13 | <<-JAVASCRIPT 14 | window.sayHi = function() { 15 | alert("Say Hi!"); 16 | } 17 | JAVASCRIPT 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/test_handler.cr: -------------------------------------------------------------------------------- 1 | class TestHandler 2 | include HTTP::Handler 3 | 4 | def call(context) 5 | case context.request.resource 6 | when "/favicon.ico" 7 | context.response.print "" 8 | when "/multiline-exception" 9 | begin 10 | raise CustomException.new("Something went very wrong\nBut wait, there's more!") 11 | rescue e : CustomException 12 | context.response.content_type = "text/html" 13 | context.response.print MyApp::ExceptionPage.new context, e 14 | end 15 | else 16 | begin 17 | raise CustomException.new("Something went very wrong") 18 | rescue e : CustomException 19 | context.response.content_type = "text/html" 20 | context.response.print MyApp::ExceptionPage.new context, e 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/support/test_server.cr: -------------------------------------------------------------------------------- 1 | class TestServer 2 | getter addr : Socket::IPAddress 3 | 4 | delegate :listen, :close, to: @server 5 | 6 | def initialize(port : Int32? = nil) 7 | @server = HTTP::Server.new([TestHandler.new]) 8 | if port 9 | @addr = @server.bind_tcp(port: port) 10 | else 11 | @addr = @server.bind_unused_port 12 | end 13 | end 14 | end 15 | 16 | class CustomException < Exception 17 | end 18 | -------------------------------------------------------------------------------- /src/exception_page.cr: -------------------------------------------------------------------------------- 1 | require "crystal/syntax_highlighter/html" 2 | require "http/cookie" 3 | require "ecr" 4 | require "backtracer" 5 | 6 | abstract class ExceptionPage 7 | module Helpers 8 | def label_for_frame(frame) : String 9 | frame.shard_name || begin 10 | case frame.path 11 | when nil 12 | "???" 13 | when .includes?("/crystal/"), .includes?("/crystal-lang/") 14 | "crystal" 15 | else 16 | "app" 17 | end 18 | end 19 | end 20 | 21 | def css_class_for_frame(frame) : String 22 | case label_for_frame(frame) 23 | when "app" then "app" 24 | when "???" then "unknown" 25 | else 26 | "all" 27 | end 28 | end 29 | end 30 | 31 | include Helpers 32 | 33 | def self.new(context : HTTP::Server::Context, exception : Exception) 34 | new( 35 | exception, 36 | context.request.method, 37 | context.request.path, 38 | context.response.status, 39 | nil, 40 | context.request.query_params, 41 | context.response.headers, 42 | context.response.cookies, 43 | exception.message, 44 | ) 45 | end 46 | 47 | @method : String 48 | @path : String 49 | @status : HTTP::Status 50 | @title : String 51 | @params : URI::Params 52 | @headers : HTTP::Headers 53 | @cookies : HTTP::Cookies? 54 | @message : String 55 | @url : String 56 | @frames : Array(Backtracer::Backtrace::Frame) 57 | 58 | def initialize( 59 | exception : Exception, 60 | @method : String, 61 | @path : String, 62 | @status : HTTP::Status, 63 | title : String? = nil, 64 | @params : URI::Params = URI::Params.new, 65 | @headers : HTTP::Headers = HTTP::Headers.new, 66 | @cookies : HTTP::Cookies = HTTP::Cookies.new, 67 | message : String? = nil, 68 | url : String? = nil, 69 | ) 70 | @title = title || "An Error Occurred: #{@status.description}" 71 | @message = message || "Something went wrong" 72 | @url = url || "#{@headers["host"]?}#{@path}" 73 | 74 | @frames = if exception.backtrace? 75 | Backtracer.parse(exception.backtrace, configuration: backtracer).frames 76 | else 77 | [] of Backtracer::Backtrace::Frame 78 | end 79 | end 80 | 81 | abstract def styles : Styles 82 | 83 | # Add an optional link to your project 84 | def project_url : String? 85 | nil 86 | end 87 | 88 | # Override this method to add extra HTML to the top of the stack trace heading 89 | def stack_trace_heading_html 90 | "" 91 | end 92 | 93 | # Override this method to add extra javascript to the page 94 | def extra_javascript 95 | "" 96 | end 97 | 98 | # Override this method to provide custom `Backtracer` configuration 99 | def backtracer : Backtracer::Configuration? 100 | end 101 | 102 | ECR.def_to_s "#{__DIR__}/exception_page/exception_page.ecr" 103 | end 104 | 105 | require "./exception_page/*" 106 | -------------------------------------------------------------------------------- /src/exception_page/exception_page.ecr: -------------------------------------------------------------------------------- 1 | 14 | <% monospace_font = "menlo, consolas, monospace" %> 15 | 16 | 17 | 18 | <% details = @message.lines %> 19 | <% headline = details.first? || "Error" %> 20 | 21 | <%= @title %> at <%= @method %> <%= @path %> - <%= headline %> 22 | 23 | 24 | 707 | 708 | 709 |
710 | <%- if project_url -%> 711 | 712 | <%- else %> 713 | 714 | <%- end %> 715 |
716 |
717 | <%= @title %> 718 | at <%= @method %> <%= @path %> 719 |
720 |

<%= HTML.escape(headline).gsub("'", '\'').gsub(""", '"') %>

721 | <%- if details.size > 1 %> 722 |
723 | 724 | See raw message 725 | 726 |
727 |                     <%- details.each do |detail| -%>
728 |                         <%= HTML.escape(detail).gsub("'", '\'').gsub(""", '"') %>
729 |                     <%- end -%>
730 |                 
731 |
732 | <%- end %> 733 |
734 | <% unless @frames.empty? %> 735 |
736 |
737 | <%= stack_trace_heading_html %> 738 | 739 | 740 | 741 | <% @frames.each_with_index do |frame, index| %> 742 |
743 | > 744 | 761 |
762 | <%- if context = frame.context -%> 763 | 766 |
767 |                           <%- context.to_h.each do |lineno, code| -%>
768 |                             <%- css_class = (lineno == frame.lineno) ? "-highlight" : nil -%>
769 |                             <%= lineno %><%= Crystal::SyntaxHighlighter::HTML.highlight!(code) %>
770 |                           <%- end -%>
771 |                         
772 |
773 | 774 | <%= label_for_frame(frame) %> 775 | <%= HTML.escape(frame.method) %> 776 | 777 |
<%= Crystal::SyntaxHighlighter::HTML.highlight!(context.line.to_s.strip) %>
778 |
779 | <%- else -%> 780 | 783 |
No code available.
784 | <%- end -%> 785 |
786 |
787 | <% end %> 788 |
789 |
790 | <% end %> 791 |
792 | 793 |
794 | <% if @params && !@params.empty? %> 795 |
796 | Params 797 | <% @params.each do |key, value| %> 798 |
799 |
<%= HTML.escape(key) %>
800 |
<%= HTML.escape(value.inspect) %>
801 |
802 | <% end %> 803 |
804 | <% end %> 805 | 806 |
807 | Request info 808 | 809 |
810 |
URI:
811 |
<%= HTML.escape(@url) %>
812 |
813 | 814 |
815 |
Query string:
816 |
<%= HTML.escape(@params.to_s) %>
817 |
818 |
819 | 820 |
821 | Headers 822 | <% @headers.each do |key, value| %> 823 |
824 |
<%= HTML.escape(key) %>
825 |
<%= HTML.escape(value.inspect) %>
826 |
827 | <% end %> 828 |
829 | 830 | <% if (cookies = @cookies) && !cookies.empty? %> 831 |
832 | Session 833 | <% cookies.each do |cookie| %> 834 |
835 |
<%= HTML.escape(cookie.name) %>
836 |
<%= HTML.escape(cookie.value.inspect) %>
837 |
838 | <% end %> 839 |
840 | <% end %> 841 |
842 | 843 | <% if custom_js = extra_javascript.presence %> 844 | 849 | <% end %> 850 | 851 | 852 | -------------------------------------------------------------------------------- /src/exception_page/styles.cr: -------------------------------------------------------------------------------- 1 | class ExceptionPage::Styles 2 | getter accent : String, 3 | highlight : String, 4 | flash_highlight : String, 5 | logo_uri : String? 6 | 7 | def initialize( 8 | @accent, 9 | @highlight = "#e5e5e5", 10 | @flash_highlight = "#ffdc93", 11 | @logo_uri = crystal_logo, 12 | ) 13 | end 14 | 15 | private def crystal_logo 16 | "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAyMS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iTGF5ZXJfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCAxOTMuMiAyMDYuNyIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTkzLjIgMjA2Ljc7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+DQoJLnN0MHtmaWxsOm5vbmU7fQ0KPC9zdHlsZT4NCjxnPg0KCTxwYXRoIGQ9Ik0xNjUuNCwxMjJsLTUwLDQ5LjljLTAuMiwwLjItMC41LDAuMy0wLjcsMC4ybC02OC4zLTE4LjNjLTAuMy0wLjEtMC41LTAuMy0wLjUtMC41TDI3LjUsODUuMWMtMC4xLTAuMywwLTAuNSwwLjItMC43DQoJCWw1MC00OS45YzAuMi0wLjIsMC41LTAuMywwLjctMC4ybDY4LjMsMTguM2MwLjMsMC4xLDAuNSwwLjMsMC41LDAuNWwxOC4zLDY4LjJDMTY1LjcsMTIxLjYsMTY1LjYsMTIxLjgsMTY1LjQsMTIyeiBNOTguNCw2Ny43DQoJCUwzMS4zLDg1LjZjLTAuMSwwLTAuMiwwLjItMC4xLDAuM2w0OS4xLDQ5YzAuMSwwLjEsMC4zLDAuMSwwLjMtMC4xbDE4LTY3Qzk4LjcsNjcuOCw5OC41LDY3LjYsOTguNCw2Ny43eiIvPg0KCTxnPg0KCQk8cmVjdCBjbGFzcz0ic3QwIiB3aWR0aD0iMTkzLjIiIGhlaWdodD0iMjA2LjciLz4NCgk8L2c+DQo8L2c+DQo8L3N2Zz4NCg==" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/exception_page/version.cr: -------------------------------------------------------------------------------- 1 | class ExceptionPage 2 | {% begin %} 3 | VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify }} 4 | {% end %} 5 | end 6 | --------------------------------------------------------------------------------