Module: Errsight::Backtrace

Defined in:
lib/errsight/backtrace.rb

Overview

Parses Ruby backtrace strings into structured frames and classifies each frame as in_app (customer code) vs framework/gem.

Sentry-ruby was the reference for the parser regex and in_app strategy (study + reimplement, not copy). Their ‘Sentry::Backtrace` handles the same edge cases — JIT frames, eval’d code, native extensions — so the patterns are similar by necessity. The implementation is ours; the bug surface is ours to maintain.

Constant Summary collapse

RUBY_FRAME =

Ruby 3.4+ formats methods with single quotes — “in ‘method’”. Earlier Ruby uses backticks — “in ‘method’”. The character class accepts either opener; the closer is always a single quote.

%r{
  \A
  (?<file>.+?)
  :(?<line>\d+)
  (?::in\s+
    ['`](?<method>.+)'
  )?
  \z
}x
MAX_FRAMES =

Cap on frames per event. A pathological infinite-recursion crash can produce 10k+ frames; without a cap the event blows past the 512 KB ingestion limit and gets rejected. Sentry caps at 50; we match.

50

Class Method Summary collapse

Class Method Details

.default_gem_pathsObject

Bundler.bundle_path can sit inside the project (vendor/bundle), so we include it AND Gem.path. Anything inside any of these is “not customer code.” Memoization is per-process; these paths don’t move at runtime.



97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/errsight/backtrace.rb', line 97

def default_gem_paths
  return @default_gem_paths if defined?(@default_gem_paths)
  paths = []
  paths.concat(Gem.path.map(&:to_s)) if defined?(::Gem)
  begin
    paths << ::Bundler.bundle_path.to_s if defined?(::Bundler) && ::Bundler.respond_to?(:bundle_path)
  rescue StandardError
    # Bundler.bundle_path can raise if Bundler isn't fully loaded
    # (some test harnesses, gem-from-source setups). Ignore.
  end
  @default_gem_paths = paths.compact.uniq
end

.default_project_rootObject



85
86
87
88
89
90
91
# File 'lib/errsight/backtrace.rb', line 85

def default_project_root
  if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
    ::Rails.root.to_s
  else
    Dir.pwd
  end
end

.in_app?(abs_path, project_root, gem_paths) ⇒ Boolean

An “in_app” frame is customer code — the kind of frame the issue UI should highlight, expand, and show source context for. Framework frames (Rails, gems) collapse into a “show 14 framework frames” group so the eye lands on what actually broke.

The order of checks matters: gem_paths first because Bundler can vendor gems inside the project tree (vendor/bundle), so a frame at /app/vendor/bundle/gems/foo/foo.rb starts with project_root but is not in_app.

Returns:

  • (Boolean)


66
67
68
69
70
71
72
73
74
# File 'lib/errsight/backtrace.rb', line 66

def in_app?(abs_path, project_root, gem_paths)
  return false if abs_path.nil? || abs_path.empty?
  # Internal frames (<internal:...>, <main>, (eval)) aren't files we
  # can show source context for — treat as not-in-app.
  return false if abs_path.start_with?("<", "(")
  return false if gem_paths.any? { |p| p && abs_path.start_with?(p) }
  return false if project_root.nil? || project_root.empty?
  abs_path.start_with?(project_root)
end

.parse(lines, project_root: nil, gem_paths: nil) ⇒ Object

Parses an Array<String> backtrace into Array<Hash> structured frames, most-recent-first (matching exception.backtrace’s natural order).



32
33
34
35
36
37
38
39
40
# File 'lib/errsight/backtrace.rb', line 32

def parse(lines, project_root: nil, gem_paths: nil)
  return [] unless lines.is_a?(Array)
  project_root ||= default_project_root
  gem_paths    ||= default_gem_paths

  lines.first(MAX_FRAMES).filter_map do |line|
    parse_line(line, project_root: project_root, gem_paths: gem_paths)
  end
end

.parse_line(line, project_root:, gem_paths:) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'lib/errsight/backtrace.rb', line 42

def parse_line(line, project_root:, gem_paths:)
  return nil unless line.is_a?(String)
  match = RUBY_FRAME.match(line)
  return nil unless match

  abs_path = match[:file]
  {
    filename: relative_filename(abs_path, project_root),
    abs_path: abs_path,
    lineno:   match[:line].to_i,
    function: match[:method],
    in_app:   in_app?(abs_path, project_root, gem_paths)
  }
end

.relative_filename(abs_path, project_root) ⇒ Object



76
77
78
79
80
81
82
83
# File 'lib/errsight/backtrace.rb', line 76

def relative_filename(abs_path, project_root)
  return abs_path if abs_path.nil?
  return abs_path if project_root.nil? || project_root.empty?
  return abs_path unless abs_path.start_with?(project_root)
  # Strip "<project_root>/" prefix; leaves "app/models/user.rb" etc.
  rest = abs_path[project_root.size..]
  rest.sub(%r{\A/}, "")
end

.reset_defaults!Object

Test/dev only: drop the memoized paths so a test that mutates Rails.root or Bundler can re-derive.



112
113
114
# File 'lib/errsight/backtrace.rb', line 112

def reset_defaults!
  remove_instance_variable(:@default_gem_paths) if defined?(@default_gem_paths)
end