diff --git a/README.md b/README.md index 247ca75..d13b9fa 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ from folks at [@getsentry](https://github.com/getsentry). - [x] Interfaces (Message, Exception, Stacktrace, User, HTTP, ...) - [x] Contexts (user, tags, extra, os, runtime) - [x] Breadcrumbs -- [x] Integrations ([Kemal](https://github.com/kemalcr/kemal), [Amber](https://github.com/amberframework/amber), [Lucky](https://github.com/luckyframework/lucky), [Sidekiq.cr](https://github.com/mperham/sidekiq.cr)) +- [x] Integrations ([Kemal](https://github.com/kemalcr/kemal), [Amber](https://github.com/amberframework/amber), [Lucky](https://github.com/luckyframework/lucky), [Sidekiq.cr](https://github.com/mperham/sidekiq.cr), [action-controller](https://github.com/spider-gazelle/action-controller)) - [x] Async support - [x] User Feedback - [x] Source code context for stack traces diff --git a/shard.yml b/shard.yml index ca0e437..fcfa20e 100644 --- a/shard.yml +++ b/shard.yml @@ -1,10 +1,13 @@ name: raven -version: 1.8.1 +version: 1.9.0 authors: - Sijawusz Pur Rahnama dependencies: + backtracer: + github: Sija/backtracer.cr + version: ~> 1.2.0 any_hash: github: Sija/any_hash.cr version: ~> 0.2.3 diff --git a/spec/raven/backtrace_line_spec.cr b/spec/raven/backtrace_line_spec.cr deleted file mode 100644 index 692f223..0000000 --- a/spec/raven/backtrace_line_spec.cr +++ /dev/null @@ -1,188 +0,0 @@ -require "../spec_helper" - -private def with_line(path = "#{__DIR__}/foo.cr", method = "foo_bar?") - line = "#{path}:1:7 in '#{method}'" - yield Raven::Backtrace::Line.parse(line) -end - -describe Raven::Backtrace::Line do - describe ".parse" do - it "fails to parse an empty string" do - expect_raises(ArgumentError) { Raven::Backtrace::Line.parse("") } - end - - context "when --no-debug flag is set" do - it "parses line with any value as method" do - backtrace_line = "__crystal_main" - line = Raven::Backtrace::Line.parse(backtrace_line) - - line.number.should be_nil - line.column.should be_nil - line.method.should eq(backtrace_line) - line.file.should be_nil - line.relative_path.should be_nil - line.under_src_path?.should be_false - line.shard_name.should be_nil - line.in_app?.should be_false - end - end - - context "with ~proc signature" do - it "parses absolute path outside of src/ dir" do - backtrace_line = "~proc2Proc(Fiber, (IO::FileDescriptor | Nil))@/usr/local/Cellar/crystal/0.27.2/src/fiber.cr:72" - line = Raven::Backtrace::Line.parse(backtrace_line) - - line.number.should eq(72) - line.column.should be_nil - line.method.should eq("~proc2Proc(Fiber, (IO::FileDescriptor | Nil))") - line.file.should eq("/usr/local/Cellar/crystal/0.27.2/src/fiber.cr") - line.relative_path.should be_nil - line.under_src_path?.should be_false - line.shard_name.should be_nil - line.in_app?.should be_false - end - - it "parses relative path inside of lib/ dir" do - backtrace_line = "~procProc(HTTP::Server::Context, String)@lib/kemal/src/kemal/route.cr:11" - line = Raven::Backtrace::Line.parse(backtrace_line) - - line.number.should eq(11) - line.column.should be_nil - line.method.should eq("~procProc(HTTP::Server::Context, String)") - line.file.should eq("lib/kemal/src/kemal/route.cr") - line.relative_path.should eq("lib/kemal/src/kemal/route.cr") - line.under_src_path?.should be_false - line.shard_name.should eq("kemal") - line.in_app?.should be_false - end - end - - it "parses absolute path outside of configuration.src_path" do - path = "/some/absolute/path/to/foo.cr" - with_line(path: path) do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq(path) - line.relative_path.should be_nil - line.under_src_path?.should be_false - line.shard_name.should be_nil - line.in_app?.should be_false - end - end - - context "with in_app? = false" do - it "parses absolute path outside of src/ dir" do - with_line do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq("#{__DIR__}/foo.cr") - line.relative_path.should eq("spec/raven/foo.cr") - line.under_src_path?.should be_true - line.shard_name.should be_nil - line.in_app?.should be_false - end - end - - it "parses relative path outside of src/ dir" do - path = "some/relative/path/to/foo.cr" - with_line(path: path) do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq(path) - line.relative_path.should eq(path) - line.under_src_path?.should be_false - line.shard_name.should be_nil - line.in_app?.should be_false - end - end - end - - context "with in_app? = true" do - it "parses absolute path inside of src/ dir" do - src_path = File.expand_path("../../src", __DIR__) - path = "#{src_path}/foo.cr" - with_line(path: path) do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq(path) - line.relative_path.should eq("src/foo.cr") - line.under_src_path?.should be_true - line.shard_name.should be_nil - line.in_app?.should be_true - end - end - - it "parses relative path inside of src/ dir" do - path = "src/foo.cr" - with_line(path: path) do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq(path) - line.relative_path.should eq(path) - line.under_src_path?.should be_false - line.shard_name.should be_nil - line.in_app?.should be_true - end - end - end - - context "with shard path" do - it "parses absolute path inside of lib/ dir" do - lib_path = File.expand_path("../../lib/bar", __DIR__) - path = "#{lib_path}/src/bar.cr" - with_line(path: path) do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq(path) - line.relative_path.should eq("lib/bar/src/bar.cr") - line.under_src_path?.should be_true - line.shard_name.should eq "bar" - line.in_app?.should be_false - end - end - - it "parses relative path inside of lib/ dir" do - path = "lib/bar/src/bar.cr" - with_line(path: path) do |line| - line.number.should eq(1) - line.column.should eq(7) - line.method.should eq("foo_bar?") - line.file.should eq(path) - line.relative_path.should eq(path) - line.under_src_path?.should be_false - line.shard_name.should eq "bar" - line.in_app?.should be_false - end - end - end - end - - it "#inspect" do - with_line do |line| - line.inspect.should match(/Backtrace::Line(.*)$/) - end - end - - it "#to_s" do - with_line do |line| - line.to_s.should eq "`foo_bar?` at #{__DIR__}/foo.cr:1:7" - end - end - - it "#==" do - with_line do |line| - with_line do |line2| - line.should eq(line2) - end - with_line(method: "other_method") do |line2| - line.should_not eq(line2) - end - end - end -end diff --git a/spec/raven/backtrace_spec.cr b/spec/raven/backtrace_spec.cr deleted file mode 100644 index 86bfa1a..0000000 --- a/spec/raven/backtrace_spec.cr +++ /dev/null @@ -1,24 +0,0 @@ -require "../spec_helper" - -describe Raven::Backtrace do - backtrace = Raven::Backtrace.parse(caller) - - it "#lines" do - backtrace.lines.should be_a(Array(Raven::Backtrace::Line)) - end - - it "#inspect" do - backtrace.inspect.should match(/#$/) - end - - {% unless flag?(:release) || !flag?(:debug) %} - it "#to_s" do - backtrace.to_s.should match(/backtrace_spec.cr:4/) - end - {% end %} - - it "#==" do - backtrace2 = Raven::Backtrace.new(backtrace.lines) - backtrace.should eq(backtrace2) - end -end diff --git a/spec/raven/event_spec.cr b/spec/raven/event_spec.cr index a8ca6e6..e663eb5 100644 --- a/spec/raven/event_spec.cr +++ b/spec/raven/event_spec.cr @@ -280,8 +280,8 @@ describe Raven::Event do frames[0][:lineno].should eq(1412) frames[0][:colno].should eq(1) frames[0][:function].should eq("other_function") - frames[0][:abs_path].should eq("some/relative/path") - frames[0][:filename].should eq(frames[0][:abs_path]) + frames[0][:abs_path].should eq("#{Dir.current}/some/relative/path") + frames[0][:filename].should eq("some/relative/path") frames[0][:package].should be_nil frames[0][:in_app].should be_false diff --git a/src/crash_handler.cr b/src/crash_handler.cr index 8fd80b4..5060e3a 100644 --- a/src/crash_handler.cr +++ b/src/crash_handler.cr @@ -25,7 +25,7 @@ module Raven # [0x105798128] main +40 # ``` CRYSTAL_CRASH_PATTERN = - /(?[^\n]+)\n(?\[#{Backtrace::Line::ADDR_FORMAT}\] .*)$/m + /(?[^\n]+)\n(?\[(?0x[a-f0-9]+)\] .*)$/im # Example: # @@ -51,12 +51,7 @@ module Raven # An `Array` of arguments passed to process. property args : Array(String)? - # FIXME: doesn't work yet due to usage of global Raven within `Backtrace::Line`. - # - # ``` - # getter raven : Instance { Instance.new } - # ``` - getter raven : Instance { Raven.instance } + getter raven : Instance { Instance.new } delegate :context, :configuration, :configure, :capture, to: raven diff --git a/src/raven.cr b/src/raven.cr index f680e81..f14f750 100644 --- a/src/raven.cr +++ b/src/raven.cr @@ -1,3 +1,4 @@ +require "backtracer" require "any_hash" require "./raven/ext/*" diff --git a/src/raven/backtrace.cr b/src/raven/backtrace.cr deleted file mode 100644 index 09ee601..0000000 --- a/src/raven/backtrace.cr +++ /dev/null @@ -1,44 +0,0 @@ -module Raven - class Backtrace - IGNORED_LINES_PATTERN = /_sigtramp|__crystal_(sigfault_handler|raise)|CallStack|caller:|raise<(.+?)>:NoReturn/ - - class_getter default_filters = [ - ->(line : String) { line unless line.match(IGNORED_LINES_PATTERN) }, - ] of String -> String? - - getter lines : Array(Line) - - def self.parse(backtrace : Array(String), **options) : Backtrace - filters = default_filters.dup - options[:filters]?.try { |f| filters.concat(f) } - - filtered_lines = backtrace.compact_map do |line| - filters.reduce(line) do |nested_line, proc| - proc.call(nested_line) || break - end - end - - lines = filtered_lines.map &->Line.parse(String) - new(lines) - end - - def self.parse(backtrace : String, **options) : Backtrace - parse(backtrace.lines, **options) - end - - def initialize(@lines) - end - - def_equals @lines - - def to_s(io : IO) : Nil - @lines.join(io, '\n') - end - - def inspect(io : IO) : Nil - io << "#' - end - end -end diff --git a/src/raven/backtrace_line.cr b/src/raven/backtrace_line.cr deleted file mode 100644 index 6eb1e3a..0000000 --- a/src/raven/backtrace_line.cr +++ /dev/null @@ -1,146 +0,0 @@ -module Raven - # Handles backtrace parsing line by line - struct Backtrace::Line - # :nodoc: - ADDR_FORMAT = /(?0x[a-f0-9]+)/i - - CALLSTACK_PATTERNS = { - # Examples: - # - # - `lib/foo/src/foo/bar.cr:50:7 in '*Foo::Bar#_baz:Foo::Bam'` - # - `lib/foo/src/foo/bar.cr:29:9 in '*Foo::Bar::bar_by_id:Foo::Bam'` - # - `/usr/local/Cellar/crystal-lang/0.24.1/src/fiber.cr:114:3 in '*Fiber#run:(IO::FileDescriptor | Nil)'` - CRYSTAL_METHOD: /^(?[^:]+)(?:\:(?\d+)(?:\:(?\d+))?)? in '\*?(?.*?)'(?: at #{ADDR_FORMAT})?$/, - - # Examples: - # - # - `~procProc(Nil)@/usr/local/Cellar/crystal-lang/0.24.1/src/http/server.cr:148 at 0x102cee376` - # - `~procProc(HTTP::Server::Context, String)@lib/kemal/src/kemal/route.cr:11 at 0x102ce57db` - # - `~procProc(HTTP::Server::Context, (File::PReader | HTTP::ChunkedContent | HTTP::Server::Response | HTTP::Server::Response::Output | HTTP::UnknownLengthContent | HTTP::WebSocket::Protocol::StreamIO | IO::ARGF | IO::Delimited | IO::FileDescriptor | IO::Hexdump | IO::Memory | IO::MultiWriter | IO::Sized | Int32 | OpenSSL::SSL::Socket | String::Builder | Zip::ChecksumReader | Zip::ChecksumWriter | Zlib::Deflate | Zlib::Inflate | Nil))@src/foo/bar/baz.cr:420` - CRYSTAL_PROC: /^(?~[^@]+)@(?[^:]+)(?:\:(?\d+))(?: at #{ADDR_FORMAT})?$/, - - # Examples: - # - # - `[0x1057a9fab] *CallStack::print_backtrace:Int32 +107` - # - `[0x105798aac] __crystal_sigfault_handler +60` - # - `[0x7fff9ca0652a] _sigtramp +26` - # - `[0x105cb35a1] GC_realloc +50` - # - `[0x1057870bb] __crystal_realloc +11` - # - `[0x1057d3ecc] *Pointer(UInt8)@Pointer(T)#realloc:Pointer(UInt8) +28` - # - `[0x105965e03] *Foo::Bar#bar!:Nil +195` - # - `[0x10579f5c1] *naughty_bar:Nil +17` - # - `[0x10579f5a9] *naughty_foo:Nil +9` - # - `[0x10578706c] __crystal_main +2940` - # - `[0x105798128] main +40` - CRYSTAL_CRASH: /^\[#{ADDR_FORMAT}\] \*?(?.*?) \+\d+(?: \((?\d+) times\))?$/, - - # Examples: - # - # - `HTTP::Server#handle_client:Nil` - # - `HTTP::Server::RequestProcessor#process:Nil` - # - `Kemal::WebSocketHandler@HTTP::Handler#call_next:(Bool | HTTP::Server::Context | IO+ | Int32 | Nil)` - # - `__crystal_main` - CRYSTAL_METHOD_NO_DEBUG: /^(?.+?)$/, - } - - # The file portion of the line (such as `app/models/user.cr`). - getter file : String? - - # The line number portion of the line. - getter number : Int32? - - # The column number portion of the line. - getter column : Int32? - - # The method of the line (such as index). - getter method : String? - - # Parses a single line of a given backtrace, where *unparsed_line* is - # the raw line from `caller` or some backtrace. - # - # Returns the parsed backtrace line on success or `nil` otherwise. - def self.parse?(unparsed_line : String) : Line? - return unless CALLSTACK_PATTERNS.values.any? &.match(unparsed_line) - - file = $~["file"]? - file = nil if file.try(&.blank?) - method = $~["method"]? - method = nil if method.try(&.blank?) - number = $~["line"]?.try(&.to_i?) - column = $~["col"]?.try(&.to_i?) - - new(file, number, column, method) - end - - # :ditto: - def self.parse(unparsed_line : String) : Line - parse?(unparsed_line) || \ - raise ArgumentError.new("Error parsing line: #{unparsed_line.inspect}") - end - - def initialize(@file, @number, @column, @method) - end - - def_equals_and_hash @file, @number, @column, @method - - # Reconstructs the line in a readable fashion - def to_s(io : IO) : Nil - io << '`' << @method << '`' if @method - if @file - io << " at " << @file - io << ':' << @number if @number - io << ':' << @column if @column - end - end - - def inspect(io : IO) : Nil - io << "Backtrace::Line(" - to_s(io) - io << ')' - end - - # FIXME: untangle it from global `Raven`. - protected delegate :configuration, to: Raven - - def under_src_path? : Bool - return false unless src_path = configuration.src_path - !!file.try(&.starts_with?(src_path)) - end - - def relative_path : String? - return unless path = file - return path unless path.starts_with?('/') - return unless under_src_path? - if prefix = configuration.src_path - path[prefix.chomp(File::SEPARATOR).size + 1..-1] - end - end - - def shard_name : String? - relative_path - .try(&.match(configuration.modules_path_pattern)) - .try(&.["name"]) - end - - def in_app? : Bool - !!(file =~ configuration.in_app_pattern) - end - - def context : {Array(String), String, Array(String)}? - context_lines = configuration.context_lines - - return unless context_lines && context_lines > 0 - return unless (lineno = @number) && lineno > 0 - return unless (filename = @file) && File.readable?(filename) - - lines = File.read_lines(filename) - lineidx = lineno - 1 - - if context_line = lines[lineidx]? - pre_context = lines[Math.max(0, lineidx - context_lines), context_lines] - post_context = lines[Math.min(lines.size, lineidx + 1), context_lines] - {pre_context, context_line, post_context} - end - end - end -end diff --git a/src/raven/configuration.cr b/src/raven/configuration.cr index e603452..1613747 100644 --- a/src/raven/configuration.cr +++ b/src/raven/configuration.cr @@ -26,21 +26,6 @@ module Raven # Array of default request methods for which data should be removed. DEFAULT_REQUEST_METHODS_FOR_DATA_SANITIZATION = %w(POST PUT PATCH) - # Used in `#in_app_pattern`. - property src_path : String? = {{ Process::INITIAL_PWD }} - - # Directories to be recognized as part of your app. e.g. if you - # have an `engines` dir at the root of your project, you may want - # to set this to something like `/(src|engines)/` - property app_dirs_pattern = /src/ - - # `Regex` pattern matched against `Backtrace::Line#file`. - property in_app_pattern : Regex { /^(#{src_path}\/)?(#{app_dirs_pattern})/ } - - # Path pattern matching directories to be recognized as your app modules. - # Defaults to standard Shards setup (`lib/shard-name/...`). - property modules_path_pattern = %r{^lib/(?[^/]+)} - # Provide a `Proc` object that responds to `call` to send # events asynchronously, or pass `true` to to use standard `spawn`. # @@ -63,8 +48,14 @@ module Raven } end - # Number of lines of code context to capture, or `nil` for none. - property context_lines : Int32? = 5 + property backtracer = Backtracer::Configuration.new + + delegate \ + :src_path, :src_path=, + :app_dirs_pattern, :app_dirs_pattern=, + :modules_path_pattern, :modules_path_pattern=, + :context_lines, :context_lines=, + to: backtracer # Defaults to `SENTRY_ENVIRONMENT` variable if set, # `"default"` otherwise. diff --git a/src/raven/event.cr b/src/raven/event.cr index b6d67b4..0917a86 100644 --- a/src/raven/event.cr +++ b/src/raven/event.cr @@ -143,7 +143,11 @@ module Raven iface.stacktrace = if e.backtrace? && !backtraces.includes?(e.backtrace.object_id) backtraces << e.backtrace.object_id - Interface::Stacktrace.new(backtrace: e.backtrace).tap do |stacktrace| + + backtrace = Backtracer.parse e.backtrace, + configuration: event.configuration.backtracer + + Interface::Stacktrace.new(backtrace: backtrace).tap do |stacktrace| event.culprit = stacktrace.culprit end end @@ -211,6 +215,9 @@ module Raven end def backtrace=(backtrace) + backtrace = Backtracer.parse backtrace, + configuration: configuration.backtracer + interface(:stacktrace, backtrace: backtrace).tap do |stacktrace| self.culprit ||= stacktrace.as(Interface::Stacktrace).culprit end diff --git a/src/raven/instance.cr b/src/raven/instance.cr index 77380b7..592fc4c 100644 --- a/src/raven/instance.cr +++ b/src/raven/instance.cr @@ -190,7 +190,7 @@ module Raven # NOTE: Useful in scenarios where you need to reconstruct the error # (usually along with a backtrace from external source), while # having no access to the actual Exception object. - def capture(klass : String, message : String, backtrace = nil, **options, &block) + def capture(klass : String, message : String, backtrace : String? = nil, **options, &block) formatted_message = "#{klass}: #{message}" capture(formatted_message, **options) do |event| ex = Interface::SingleException.new.tap do |iface| @@ -199,7 +199,10 @@ module Raven iface.value = message if backtrace - iface.stacktrace = Interface::Stacktrace.new(backtrace: backtrace).tap do |stacktrace| + parsed = Backtracer.parse backtrace, + configuration: configuration.backtracer + + iface.stacktrace = Interface::Stacktrace.new(backtrace: parsed).tap do |stacktrace| event.culprit = stacktrace.culprit end end diff --git a/src/raven/integrations/action-controller.cr b/src/raven/integrations/action-controller.cr new file mode 100644 index 0000000..706349f --- /dev/null +++ b/src/raven/integrations/action-controller.cr @@ -0,0 +1,24 @@ +require "action-controller" + +module Raven + # ``` + # require "raven" + # require "raven/integrations/action-controller" + # ``` + # + # It's recommended to enable `Configuration#async` when using ActionController. + # + # ``` + # Raven.configure do |config| + # # ... + # config.async = true + # end + # ``` + module ActionController + def self.build_request_url(req : HTTP::Request) + "#{::ActionController::Support.request_protocol(req)}://#{req.host_with_port}#{req.resource}" + end + end +end + +require "./action-controller/*" diff --git a/src/raven/integrations/action-controller/error_handler.cr b/src/raven/integrations/action-controller/error_handler.cr new file mode 100644 index 0000000..29b027e --- /dev/null +++ b/src/raven/integrations/action-controller/error_handler.cr @@ -0,0 +1,44 @@ +require "http" +require "../http/*" + +module Raven + module ActionController + # Exception handler capturing all unhandled `Exception`s. + # After capturing exception is re-raised. + # + # ``` + # server = HTTP::Server.new([ + # # ... + # ActionController::ErrorHandler.new(production: true), + # Raven::ActionController::ErrorHandler.new, + # # ... + # ]) + # ``` + class ErrorHandler + include HTTP::Handler + include Raven::HTTPHandler + + # See `::HTTP::Request` + CULPRIT_PATTERN_KEYS = %i(method path) + + def initialize( + @culprit_pattern = "%{method} %{path}", + @capture_data_for_methods = %w(POST PUT PATCH), + @default_logger = "action-controller" + ) + end + + def build_raven_culprit_context(context : HTTP::Server::Context) + context.request + end + + def build_raven_http_url(context : HTTP::Server::Context) + ActionController.build_request_url(context.request) + end + + def build_raven_http_data(context : HTTP::Server::Context) + ::ActionController::Base.extract_params(context).to_h + end + end + end +end diff --git a/src/raven/interfaces/stacktrace.cr b/src/raven/interfaces/stacktrace.cr index 7d16f41..3bfcf21 100644 --- a/src/raven/interfaces/stacktrace.cr +++ b/src/raven/interfaces/stacktrace.cr @@ -6,11 +6,11 @@ module Raven :stacktrace end - def backtrace=(backtrace) + def backtrace=(backtrace : Backtracer::Backtrace) @frames.clear - backtrace = Backtrace.parse(backtrace) - backtrace.lines.reverse_each do |line| - @frames << Frame.from_backtrace_line(line) + + backtrace.frames.reverse_each do |frame| + @frames << Frame.from_backtrace_frame(frame) end end @@ -31,18 +31,19 @@ module Raven property colno : Int32? property? in_app : Bool? - def self.from_backtrace_line(line) + def self.from_backtrace_frame(line) new.tap do |frame| - frame.abs_path = line.file + frame.abs_path = line.absolute_path || line.path frame.filename = line.relative_path frame.function = line.method frame.package = line.shard_name - frame.lineno = line.number + frame.lineno = line.lineno frame.colno = line.column frame.in_app = line.in_app? if context = line.context - frame.pre_context, frame.context_line, frame.post_context = context + frame.pre_context, frame.context_line, frame.post_context = + context.pre, context.line, context.post end end end diff --git a/src/raven/processors/http_headers.cr b/src/raven/processors/http_headers.cr index 21fff1e..e62f201 100644 --- a/src/raven/processors/http_headers.cr +++ b/src/raven/processors/http_headers.cr @@ -24,7 +24,7 @@ module Raven data = data.to_any_json if headers = data[:request, :headers]?.as?(Hash) - headers.keys.select(&.to_s.match(fields_pattern)).each do |key| + headers.keys.select!(&.to_s.matches?(fields_pattern)).each do |key| headers[key] = STRING_MASK end end