Module: Alplus::Stack

Defined in:
lib/alplus/stack.rb

Overview

Maps a Ruby backtrace to the wire frames[] shape ({file, function, lineno, in_app}) with in-app vs library detection.

Prefers Exception#backtrace_locations (structured, available since Ruby 2.0) over parsing #backtrace strings; falls back to string parsing only for backtraces that don't expose locations (e.g. a synthetic backtrace assigned by hand).

Constant Summary collapse

LIBRARY_MARKERS =

Path fragments that mark a frame as library code regardless of app_dirs: installed gems, the Ruby stdlib, and common version manager layouts. Checked before app_dirs, so a gem vendored inside the app directory is still treated as library code.

[
  "/gems/",
  "/lib/ruby/",
  "/vendor/bundle/",
  "/.rbenv/",
  "/.rvm/",
  "/.asdf/",
  "<internal:"
].freeze
LINE_PATTERN =
/\A(.+):(\d+):in [`'"](.+)['"]\z/.freeze
MAX_SOURCE_LINE_CHARS =

Longest source line kept in pre_context/context_line/ post_context (issue: source-context frames) -- a single absurdly long line (minified/generated code) is truncated rather than blowing up the envelope.

500
MAX_CACHED_SOURCE_FILES =

Per-process cache of a source file's lines. A deep in-app backtrace, or an error storm, otherwise re-reads the same files on the capturing thread on every event. Source does not change within a process, so each file is read at most once. Bounded (FIFO eviction) so it cannot grow without limit.

256

Class Method Summary collapse

Class Method Details

.build_frame(path, lineno, label, app_dirs, context_lines = 0) ⇒ Object



60
61
62
63
64
65
# File 'lib/alplus/stack.rb', line 60

def build_frame(path, lineno, label, app_dirs, context_lines = 0)
  in_app = in_app?(path, app_dirs)
  frame = { file: path, lineno: lineno, function: label, in_app: in_app }
  frame.merge!(source_context(path, lineno, context_lines)) if in_app && context_lines.to_i.positive?
  frame.compact
end

.cached_source_lines(path) ⇒ Object

Returns path's lines, reading from disk at most once per process. FIFO-evicts the oldest entry past the cap. Holds the mutex across the read so concurrent captures of the same file do not each read it.



100
101
102
103
104
105
106
107
108
109
# File 'lib/alplus/stack.rb', line 100

def cached_source_lines(path)
  SOURCE_CACHE_MUTEX.synchronize do
    return SOURCE_CACHE[path] if SOURCE_CACHE.key?(path)

    lines = File.readlines(path)
    SOURCE_CACHE[path] = lines
    SOURCE_CACHE.shift if SOURCE_CACHE.size > MAX_CACHED_SOURCE_FILES
    lines
  end
end

.cap_source_line(line) ⇒ Object



111
112
113
114
# File 'lib/alplus/stack.rb', line 111

def cap_source_line(line)
  line = line.chomp
  line.length > MAX_SOURCE_LINE_CHARS ? line[0, MAX_SOURCE_LINE_CHARS] : line
end

.frame_from_line(line, app_dirs, context_lines = 0) ⇒ Object



67
68
69
70
71
72
# File 'lib/alplus/stack.rb', line 67

def frame_from_line(line, app_dirs, context_lines = 0)
  match = LINE_PATTERN.match(line)
  return nil unless match

  build_frame(match[1], match[2].to_i, match[3], app_dirs, context_lines)
end

.frames_for(exception, app_dirs: [], context_lines: 0) ⇒ Object

context_lines: (default 0, i.e. disabled) is config.context_lines at call sites -- see Envelope.exception_item. Source context is only ever attached to in_app frames: library/gem frames have no value to a host app's developer and reading arbitrary gem source is wasted work.



51
52
53
54
55
56
57
58
# File 'lib/alplus/stack.rb', line 51

def frames_for(exception, app_dirs: [], context_lines: 0)
  locations = exception.respond_to?(:backtrace_locations) ? exception.backtrace_locations : nil
  if locations
    locations.map { |loc| build_frame(loc.path, loc.lineno, loc.label, app_dirs, context_lines) }
  else
    Array(exception.backtrace).filter_map { |line| frame_from_line(line, app_dirs, context_lines) }
  end
end

.in_app?(path, app_dirs) ⇒ Boolean

Returns:

  • (Boolean)


116
117
118
119
120
121
122
# File 'lib/alplus/stack.rb', line 116

def in_app?(path, app_dirs)
  return false if path.nil?
  return false if LIBRARY_MARKERS.any? { |marker| path.include?(marker) }
  return true if app_dirs.empty?

  app_dirs.any? { |dir| path.start_with?(dir.to_s) }
end

.source_context(path, lineno, context_lines) ⇒ Object

Reads context_lines lines before/after lineno (1-indexed, as backtraces report it) from path. Returns {} (attaching nothing) for a missing/unreadable file or an out-of-range line number -- never raises, matching every other fail-safe boundary in this SDK.



78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/alplus/stack.rb', line 78

def source_context(path, lineno, context_lines)
  return {} unless path && File.file?(path) && File.readable?(path)

  lines = cached_source_lines(path)
  index = lineno - 1
  return {} unless index >= 0 && index < lines.length

  start_index = [index - context_lines, 0].max
  end_index = [index + context_lines, lines.length - 1].min

  {
    pre_context: lines[start_index...index].map { |line| cap_source_line(line) },
    context_line: cap_source_line(lines[index]),
    post_context: lines[(index + 1)..end_index].map { |line| cap_source_line(line) }
  }
rescue StandardError
  {}
end