Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Collect backtrace #7

Merged
merged 1 commit into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/telebugs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
require_relative "telebugs/sender"
require_relative "telebugs/wrapped_error"
require_relative "telebugs/notice"
require_relative "telebugs/error_message"
require_relative "telebugs/backtrace"

module Telebugs
# The general error that this library uses when it wants to raise.
Expand Down
156 changes: 156 additions & 0 deletions lib/telebugs/backtrace.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# frozen_string_literal: true

module Telebugs
# Represents a cross-Ruby backtrace from exceptions (including JRuby Java
# exceptions). Provides information about stack frames (such as line number,
# file and method) in convenient for Telebugs format.
module Backtrace
module Patterns
# The pattern that matches standard Ruby stack frames, such as
# ./spec/notice_spec.rb:43:in `block (3 levels) in <top (required)>'
RUBY = %r{\A
(?<file>.+) # Matches './spec/notice_spec.rb'
:
(?<line>\d+) # Matches '43'
:in\s
`(?<function>.*)' # Matches "`block (3 levels) in <top (required)>'"
\z}x

# The pattern that matches JRuby Java stack frames, such as
# org.jruby.ast.NewlineNode.interpret(NewlineNode.java:105)
JAVA = %r{\A
(?<function>.+) # Matches 'org.jruby.ast.NewlineNode.interpret'
\(
(?<file>
(?:uri:classloader:/.+(?=:)) # Matches '/META-INF/jruby.home/protocol.rb'
|
(?:uri_3a_classloader_3a_.+(?=:)) # Matches 'uri_3a_classloader_3a_/gems/...'
|
[^:]+ # Matches 'NewlineNode.java'
)
:?
(?<line>\d+)? # Matches '105'
\)
\z}x

# The pattern that tries to assume what a generic stack frame might look
# like, when exception's backtrace is set manually.
GENERIC = %r{\A
(?:from\s)?
(?<file>.+) # Matches '/foo/bar/baz.ext'
:
(?<line>\d+)? # Matches '43' or nothing
(?:
in\s`(?<function>.+)' # Matches "in `func'"
|
:in\s(?<function>.+) # Matches ":in func"
)? # ... or nothing
\z}x

# The pattern that matches exceptions from PL/SQL such as
# ORA-06512: at "STORE.LI_LICENSES_PACK", line 1945
# @note This is raised by https://github.com/kubo/ruby-oci8
OCI = /\A
(?:
ORA-\d{5}
:\sat\s
(?:"(?<function>.+)",\s)?
line\s(?<line>\d+)
|
#{GENERIC}
)
\z/x

# The pattern that matches CoffeeScript backtraces usually coming from
# Rails & ExecJS
EXECJS = /\A
(?:
# Matches 'compile ((execjs):6692:19)'
(?<function>.+)\s\((?<file>.+):(?<line>\d+):\d+\)
|
# Matches 'bootstrap_node.js:467:3'
(?<file>.+):(?<line>\d+):\d+(?<function>)
|
# Matches the Ruby part of the backtrace
#{RUBY}
)
\z/x
end

def self.parse(error)
return [] if error.backtrace.nil? || error.backtrace.none?

parse_backtrace(error)
end

# Checks whether the given exception was generated by JRuby's VM.
def self.java_exception?(error)
if defined?(Java::JavaLang::Throwable) &&
error.is_a?(Java::JavaLang::Throwable)
return true
end

return false unless error.respond_to?(:backtrace)

(Patterns::JAVA =~ error.backtrace.first) != nil
end

class << self
private

def best_regexp_for(error)
if java_exception?(error)
Patterns::JAVA
elsif oci_exception?(error)
Patterns::OCI
elsif execjs_exception?(error)
Patterns::EXECJS
else
Patterns::RUBY
end
end

def oci_exception?(error)
defined?(OCIError) && error.is_a?(OCIError)
end

def execjs_exception?(error)
return false unless defined?(ExecJS::RuntimeError)
return true if error.is_a?(ExecJS::RuntimeError)
return true if error.cause&.is_a?(ExecJS::RuntimeError)

false
end

def stack_frame(regexp, stackframe)
if (match = match_frame(regexp, stackframe))
return {
file: match[:file],
line: (Integer(match[:line]) if match[:line]),
function: match[:function]
}
end

{file: nil, line: nil, function: stackframe}
end

def match_frame(regexp, stackframe)
match = regexp.match(stackframe)
return match if match

Patterns::GENERIC.match(stackframe)
end

def parse_backtrace(error)
regexp = best_regexp_for(error)

error.backtrace.map.with_index do |stackframe, i|
frame = stack_frame(regexp, stackframe)
next(frame) unless frame[:file]

frame
end
end
end
end
end
23 changes: 23 additions & 0 deletions lib/telebugs/error_message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module Telebugs
# Parses error messages to make them more consistent.
module ErrorMessage
# On Ruby 3.1+, the error highlighting gem can produce messages that can
# span over multiple lines. We don't want to display multiline error titles.
# Therefore, we want to strip out the higlighting part so that the errors
# look consistent.
RUBY_31_ERROR_HIGHLIGHTING_DIVIDER = "\n\n"

# The options for +String#encode+
ENCODING_OPTIONS = {invalid: :replace, undef: :replace}.freeze

def self.parse(error)
return unless (msg = error.message)

msg.encode(Encoding::UTF_8, **ENCODING_OPTIONS)
.split(RUBY_31_ERROR_HIGHLIGHTING_DIVIDER)
.first
end
end
end
20 changes: 2 additions & 18 deletions lib/telebugs/notice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@ class Notice
Encoding::UndefinedConversionError
].freeze

# On Ruby 3.1+, the error highlighting gem can produce messages that can
# span over multiple lines. We don't want to display multiline error titles.
# Therefore, we want to strip out the higlighting part so that the errors
# look consistent.
RUBY_31_ERROR_HIGHLIGHTING_DIVIDER = "\n\n"

# The options for +String#encode+
ENCODING_OPTIONS = {invalid: :replace, undef: :replace}.freeze

# The maxium size of the JSON payload in bytes
MAX_NOTICE_SIZE = 64000

Expand Down Expand Up @@ -54,19 +45,12 @@ def errors_as_json(error)
WrappedError.new(error).unwrap.map do |e|
{
type: e.class.name,
message: message(e)
message: ErrorMessage.parse(e),
backtrace: Backtrace.parse(e)
}
end
end

def message(error)
return unless (msg = error.message)

msg.encode(Encoding::UTF_8, **ENCODING_OPTIONS)
.split(RUBY_31_ERROR_HIGHLIGHTING_DIVIDER)
.first
end

def truncate
0
end
Expand Down
Loading