Module: Restless::StackFrames

Defined in:
lib/restless/stack_frames.rb

Overview

CONTRACT.md FP-043, FP-044, FP-045. The Ruby dialect of stack parsing.

FP-044 makes frame parsing and the skip list explicitly per-language: only the OUTPUT shape is contract surface. This module is therefore the one place in the SDK that knows what a Ruby backtrace looks like, and the shared conformance vectors deliberately do not cover it (FP-046); see test/test_stack_frames.rb.

Constant Summary collapse

FRAME_RE =

Ruby <= 3.3: "path.rb:12:in `method'" Ruby >= 3.4: "path.rb:12:in 'Klass#method'"

/\A(.+):(\d+):in [`'](.+)'\z/.freeze
FRAME_NO_FN_RE =

Some frames carry no method label at all.

/\A(.+):(\d+)\z/.freeze
BLOCK_LEVELS_RE =

block (3 levels) in handler -> block in handler. The nesting count is closer to a line number than to an identity: it moves when somebody wraps the throw site in one more each, which FP-041 says must not split a group.

/\Ablock \(\d+ levels\) in /.freeze
RECEIVER_RE =

Ruby 3.4 changed backtrace method labels to carry the receiver: detonate became Exploder.detonate, and run became Exploder#run. Stripping it back off is not cosmetic. Without it the SAME crash in the SAME file fingerprints differently depending on which Ruby the service happens to run, so a routine runtime upgrade silently splits every existing 5xx group and orphans the Agent Recovery guidance attached to it. FP-041 forbids exactly that churn for line numbers; a runtime version is no different.

Stripping rather than keeping, because that reproduces the label Ruby 3.3 and earlier already emit, so no stored key moves.

/\A(?:[A-Z]\w*(?:::[A-Z]\w*)*)[.#]/.freeze
SDK_DIR =

FP-044. The Ruby equivalent of node_modules / node:internal / @restlessai/sdk.

Everything here is matched by FILE PATH, never by module or class name. A name check would also skip a customer's own Restless-flavoured code and, worse, would not skip this gem when it is vendored under a different constant. The SDK's own directory is resolved from __dir__, so it is correct however the gem was installed.

File.expand_path("..", __dir__).freeze
STDLIB_DIRS =
[
  RbConfig::CONFIG["rubylibdir"],
  RbConfig::CONFIG["rubyarchdir"],
  RbConfig::CONFIG["sitelibdir"],
  RbConfig::CONFIG["vendorlibdir"]
].compact.reject(&:empty?).map { |d| File.expand_path(d) }.freeze
GEM_DIRS =
begin
  dirs = []
  begin
    dirs.concat(Array(Gem.path)) if defined?(Gem)
    dirs << Gem.dir if defined?(Gem) && Gem.respond_to?(:dir)
  rescue StandardError
    # Gem may not be loaded at all; the "/gems/" fallback below covers it.
  end
  dirs.compact.uniq.map { |d| File.expand_path(d) }.freeze
end

Class Method Summary collapse

Class Method Details

.from_exception(error) ⇒ Object

Convenience for adapters: fingerprint-ready frame from an exception.



138
139
140
141
142
143
144
# File 'lib/restless/stack_frames.rb', line 138

def from_exception(error)
  return nil unless error.respond_to?(:backtrace)

  top_user_frame(error.backtrace)
rescue StandardError
  nil
end

.normalize_fn(name) ⇒ Object



109
110
111
112
113
# File 'lib/restless/stack_frames.rb', line 109

def normalize_fn(name)
  cleaned = name.sub(BLOCK_LEVELS_RE, "block in ")
    cleaned = strip_receiver(cleaned)
  cleaned.empty? ? "anonymous" : cleaned
end

.parse_frame(line) ⇒ Object



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

def parse_frame(line)
  if (m = FRAME_RE.match(line))
    return { file: m[1], fn: normalize_fn(m[3]) }
  end
  if (m = FRAME_NO_FN_RE.match(line))
    # FP-045.
    return { file: m[1], fn: "anonymous" }
  end

  nil
end

.skip_path?(path) ⇒ Boolean

Returns:

  • (Boolean)


125
126
127
128
129
130
131
132
133
134
135
# File 'lib/restless/stack_frames.rb', line 125

def skip_path?(path)
  return true if path.nil? || path.empty?
  # Ruby 3.x synthesises frames like "<internal:kernel>:90:in `tap'".
  return true if path.start_with?("<internal:")
  return true if path.start_with?(SDK_DIR)
  return true if path.include?("/gems/")
  return true if STDLIB_DIRS.any? { |dir| path.start_with?(dir) }
  return true if GEM_DIRS.any? { |dir| path.start_with?(dir) }

  false
end

.strip_receiver(name) ⇒ Object

Applied after the block normalization, and to the method portion of a block in X label as well as a bare one, since 3.4 qualifies both.



117
118
119
120
121
122
123
# File 'lib/restless/stack_frames.rb', line 117

def strip_receiver(name)
  if name.start_with?("block in ")
    "block in " + name.sub(/\Ablock in /, "").sub(RECEIVER_RE, "")
  else
    name.sub(RECEIVER_RE, "")
  end
end

.top_user_frame(backtrace) ⇒ Object

FP-043. The frame NEAREST THE THROW SITE that is not vendor, runtime or SDK code.

Ruby's Exception#backtrace is innermost-FIRST (index 0 is where the exception was raised), like a v8 Error.stack and unlike a Python traceback, so the walk goes FORWARDS. Implementing this positionally in the wrong direction returns the Rack entry point for every crash in the process, which collapses every 500 into one fingerprint group and defeats the strategy entirely. Verified empirically in test/test_stack_frames.rb, not assumed.



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/restless/stack_frames.rb', line 81

def top_user_frame(backtrace)
  return nil if backtrace.nil?

  Array(backtrace).each do |raw|
    frame = parse_frame(raw.to_s)
    next if frame.nil?
    next if skip_path?(frame[:file])

    return {
      file: Fingerprint.project_relative(frame[:file]),
      fn: frame[:fn]
    }
  end
  nil
end