CrackedRuby logo

CrackedRuby

Backtrace Locations

Overview

Ruby's backtrace location system provides detailed information about the call stack, enabling developers to track program execution flow and debug issues effectively. The system centers around Thread::Backtrace::Location objects, which represent individual frames in the call stack, and methods like caller that generate arrays of these location objects.

The backtrace system captures three essential pieces of information for each stack frame: the file path, line number, and method or block context. Ruby generates this information dynamically during program execution, making it available through several built-in methods and automatically including it in exception objects.

def example_method
  puts caller[0].path     # File path
  puts caller[0].lineno   # Line number
  puts caller[0].label    # Method name
end

example_method
# => "/path/to/file.rb"
# => 6
# => "example_method"

The Thread::Backtrace::Location class serves as the foundation for backtrace functionality. Each location object contains immutable information about a specific point in the call stack. Ruby creates these objects on-demand when backtrace information is requested, either explicitly through methods like caller or implicitly during exception handling.

location = caller_locations[0]
puts location.class           # => Thread::Backtrace::Location
puts location.absolute_path   # => "/full/path/to/file.rb"
puts location.base_label      # => "block"
puts location.inspect         # => "file.rb:10:in `method_name'"

Ruby provides multiple methods for accessing backtrace information. The caller method returns an array of strings in the traditional format, while caller_locations returns an array of Thread::Backtrace::Location objects with richer information and better performance characteristics for programmatic processing.

Basic Usage

The most common entry point for backtrace functionality is the caller method, which returns the current call stack as an array of strings. Each string represents one frame in the stack, formatted as "filename:line:in `method_name'".

def level_three
  puts caller
end

def level_two
  level_three
end

def level_one
  level_two
end

level_one
# => ["file.rb:7:in `level_two'", "file.rb:11:in `level_one'", "file.rb:14:in `<main>'"]

The caller method accepts optional parameters to control which frames are returned. The first parameter specifies how many frames to skip from the top of the stack, while the second parameter limits the total number of frames returned.

def deep_method
  puts "Full stack:"
  puts caller
  
  puts "\nSkip 1 frame:"
  puts caller(1)
  
  puts "\nLimit to 2 frames:"
  puts caller(0, 2)
end

def intermediate_method
  deep_method
end

def calling_method
  intermediate_method
end

calling_method

For programmatic processing, caller_locations provides Thread::Backtrace::Location objects instead of strings. These objects offer individual access to path, line number, and label information without string parsing.

def analyze_stack
  locations = caller_locations
  
  locations.each_with_index do |location, index|
    puts "Frame #{index}:"
    puts "  File: #{location.path}"
    puts "  Line: #{location.lineno}"
    puts "  Method: #{location.label}"
    puts "  Base method: #{location.base_label}"
    puts
  end
end

def outer_method
  analyze_stack
end

outer_method

The Thread.current.backtrace method provides access to the current thread's backtrace without requiring method calls. This approach works identically to caller but makes the thread context explicit.

def show_thread_backtrace
  backtrace = Thread.current.backtrace
  puts "Current thread backtrace:"
  backtrace.first(3).each { |frame| puts "  #{frame}" }
end

show_thread_backtrace

Block contexts appear in backtraces with special formatting to distinguish them from method calls. Ruby identifies blocks, procs, and lambdas differently in the backtrace output.

def method_with_blocks
  [1, 2, 3].each do |num|
    puts caller[0]  # Shows block context
    
    proc_obj = proc { puts caller[0] }  # Shows proc context
    proc_obj.call
    
    lambda_obj = lambda { puts caller[0] }  # Shows lambda context
    lambda_obj.call
  end
end

method_with_blocks

Error Handling & Debugging

Exception objects automatically capture backtrace information when raised, providing immediate access to the call stack at the point where the error occurred. The Exception#backtrace method returns the same string format as caller, while Exception#backtrace_locations provides Thread::Backtrace::Location objects.

begin
  def problematic_method
    raise StandardError, "Something went wrong"
  end
  
  def calling_method
    problematic_method
  end
  
  calling_method
rescue StandardError => e
  puts "Error: #{e.message}"
  puts "Backtrace:"
  e.backtrace.each { |frame| puts "  #{frame}" }
  
  puts "\nDetailed analysis:"
  e.backtrace_locations.each do |location|
    puts "  #{location.label} at #{location.path}:#{location.lineno}"
  end
end

Custom exception handling often requires filtering or processing backtrace information to provide meaningful error reporting. The backtrace can be modified by assigning a new array to the exception's backtrace.

class CustomError < StandardError
  def initialize(message, filtered_backtrace = nil)
    super(message)
    if filtered_backtrace
      set_backtrace(filtered_backtrace)
    end
  end
  
  def self.raise_with_context(message)
    # Skip internal framework methods from backtrace
    filtered = caller.reject { |frame| frame.include?('internal') }
    raise new(message, filtered)
  end
end

def internal_method
  def user_method
    CustomError.raise_with_context("User-facing error")
  end
  user_method
end

begin
  internal_method
rescue CustomError => e
  puts e.message
  puts e.backtrace.first(3)
end

Backtrace information proves essential for debugging callback chains and metaprogramming scenarios where the call stack spans dynamically defined methods or eval contexts.

class CallbackChain
  attr_reader :callbacks
  
  def initialize
    @callbacks = []
  end
  
  def add_callback(&block)
    @callbacks << block
  end
  
  def execute_with_debugging
    @callbacks.each_with_index do |callback, index|
      begin
        callback.call
      rescue => e
        puts "Callback #{index} failed:"
        puts "  Defined at: #{callback.source_location.join(':')}"
        puts "  Error: #{e.message}"
        puts "  Stack trace:"
        e.backtrace.first(5).each { |frame| puts "    #{frame}" }
        raise
      end
    end
  end
end

chain = CallbackChain.new
chain.add_callback { puts "First callback" }
chain.add_callback { raise "Callback error" }
chain.add_callback { puts "Third callback" }

begin
  chain.execute_with_debugging
rescue => e
  puts "\nFinal error handling complete"
end

Thread-specific backtrace debugging requires accessing backtrace information from different threads. Each thread maintains its own call stack, accessible through thread-specific methods.

def debug_multiple_threads
  main_thread_backtrace = caller_locations
  
  threads = []
  
  3.times do |i|
    threads << Thread.new do
      def nested_thread_method(thread_id)
        puts "Thread #{thread_id} backtrace:"
        Thread.current.backtrace_locations.first(3).each do |location|
          puts "  #{location.label} at #{location.path}:#{location.lineno}"
        end
      end
      
      nested_thread_method(i)
      sleep(0.1)  # Ensure threads run
    end
  end
  
  threads.each(&:join)
  
  puts "\nMain thread context:"
  main_thread_backtrace.first(2).each do |location|
    puts "  #{location.label} at #{location.path}:#{location.lineno}"
  end
end

debug_multiple_threads

Production Patterns

Production applications require structured error reporting that captures sufficient context without overwhelming monitoring systems. Backtrace information integration with logging frameworks provides essential debugging capabilities for deployed applications.

require 'logger'
require 'json'

class ProductionErrorHandler
  def initialize(logger = Logger.new(STDOUT))
    @logger = logger
  end
  
  def handle_error(error, additional_context = {})
    error_data = {
      timestamp: Time.now.iso8601,
      error_class: error.class.name,
      message: error.message,
      backtrace: format_backtrace(error.backtrace_locations),
      context: additional_context,
      thread_id: Thread.current.object_id
    }
    
    @logger.error(JSON.generate(error_data))
  end
  
  private
  
  def format_backtrace(locations)
    locations.first(20).map do |location|
      {
        file: location.path,
        line: location.lineno,
        method: location.label,
        absolute_path: location.absolute_path
      }
    end
  end
end

# Usage in Rails controllers or other production contexts
error_handler = ProductionErrorHandler.new

begin
  def simulate_business_logic
    def process_user_data(user_id)
      def validate_permissions(user_id)
        raise ArgumentError, "Invalid user ID: #{user_id}" if user_id.nil?
      end
      validate_permissions(user_id)
    end
    process_user_data(nil)
  end
  
  simulate_business_logic
rescue => e
  error_handler.handle_error(e, { user_session: "abc123", request_id: "req_456" })
end

Web application error pages benefit from intelligent backtrace filtering that separates application code from framework and library code, helping developers focus on relevant information.

class WebErrorPresenter
  FRAMEWORK_PATTERNS = [
    /\/gems\/rails/,
    /\/gems\/rack/,
    /\/ruby\/\d+\.\d+\.\d+/,
    /\/bundler\/gems/
  ].freeze
  
  def self.format_for_web(error)
    locations = error.backtrace_locations
    
    {
      application_frames: filter_application_frames(locations),
      framework_frames: filter_framework_frames(locations),
      error_context: extract_error_context(locations.first)
    }
  end
  
  private
  
  def self.filter_application_frames(locations)
    locations.reject { |loc| framework_frame?(loc) }.first(10).map do |location|
      {
        file: File.basename(location.path),
        full_path: location.absolute_path,
        line: location.lineno,
        method: location.label,
        code_snippet: extract_code_snippet(location)
      }
    end
  end
  
  def self.filter_framework_frames(locations)
    locations.select { |loc| framework_frame?(loc) }.first(5).map do |location|
      {
        file: location.path,
        line: location.lineno,
        method: location.label
      }
    end
  end
  
  def self.framework_frame?(location)
    FRAMEWORK_PATTERNS.any? { |pattern| pattern.match?(location.absolute_path) }
  end
  
  def self.extract_code_snippet(location)
    return nil unless File.exist?(location.absolute_path)
    
    lines = File.readlines(location.absolute_path)
    start_line = [location.lineno - 3, 0].max
    end_line = [location.lineno + 2, lines.length - 1].min
    
    (start_line..end_line).map do |line_number|
      {
        number: line_number + 1,
        content: lines[line_number].chomp,
        current: line_number + 1 == location.lineno
      }
    end
  end
  
  def self.extract_error_context(location)
    return {} unless location
    
    {
      file: location.path,
      line: location.lineno,
      method: location.label,
      directory: File.dirname(location.absolute_path)
    }
  end
end

# Simulate a web application error
begin
  def controller_action
    def service_method
      def model_validation
        raise ValidationError, "Email format invalid"
      end
      model_validation
    end
    service_method
  end
  
  class ValidationError < StandardError; end
  
  controller_action
rescue ValidationError => e
  formatted_error = WebErrorPresenter.format_for_web(e)
  puts "Application frames:"
  formatted_error[:application_frames].each { |frame| puts "  #{frame[:file]}:#{frame[:line]} in #{frame[:method]}" }
  puts "\nFramework frames:"
  formatted_error[:framework_frames].each { |frame| puts "  #{frame[:file]}:#{frame[:line]}" }
end

Microservice architectures require correlation of backtrace information across service boundaries. Request tracing systems combine local backtraces with distributed trace identifiers.

class DistributedTraceHandler
  def initialize(trace_id: nil, span_id: nil)
    @trace_id = trace_id || generate_trace_id
    @span_id = span_id || generate_span_id
    @local_spans = []
  end
  
  def trace_execution(operation_name)
    span_start = Time.now
    entry_backtrace = caller_locations
    
    begin
      result = yield
      record_successful_span(operation_name, span_start, entry_backtrace)
      result
    rescue => error
      record_error_span(operation_name, span_start, entry_backtrace, error)
      raise
    end
  end
  
  def create_child_context
    self.class.new(trace_id: @trace_id, span_id: generate_span_id)
  end
  
  def export_trace_data
    {
      trace_id: @trace_id,
      spans: @local_spans,
      service_name: 'ruby-service',
      timestamp: Time.now.iso8601
    }
  end
  
  private
  
  def record_successful_span(operation, start_time, backtrace)
    @local_spans << {
      span_id: @span_id,
      operation_name: operation,
      start_time: start_time.to_f,
      duration: Time.now.to_f - start_time.to_f,
      status: 'success',
      entry_point: format_entry_point(backtrace.first)
    }
  end
  
  def record_error_span(operation, start_time, backtrace, error)
    @local_spans << {
      span_id: @span_id,
      operation_name: operation,
      start_time: start_time.to_f,
      duration: Time.now.to_f - start_time.to_f,
      status: 'error',
      error_class: error.class.name,
      error_message: error.message,
      entry_point: format_entry_point(backtrace.first),
      error_backtrace: error.backtrace_locations.first(10).map do |loc|
        "#{loc.path}:#{loc.lineno}:in `#{loc.label}'"
      end
    }
  end
  
  def format_entry_point(location)
    return 'unknown' unless location
    "#{File.basename(location.path)}:#{location.lineno}:in `#{location.label}'"
  end
  
  def generate_trace_id
    SecureRandom.hex(16)
  end
  
  def generate_span_id
    SecureRandom.hex(8)
  end
end

# Usage in service methods
tracer = DistributedTraceHandler.new

begin
  tracer.trace_execution('user_authentication') do
    def authenticate_user(token)
      def validate_token(token)
        raise SecurityError, "Invalid token" unless token == "valid_token"
        { user_id: 123, role: 'admin' }
      end
      validate_token(token)
    end
    
    authenticate_user("invalid_token")
  end
rescue SecurityError => e
  puts "Authentication failed"
  trace_data = tracer.export_trace_data
  puts "Trace data: #{JSON.pretty_generate(trace_data)}"
end

Performance & Memory

Backtrace generation involves significant computational overhead, particularly in high-frequency execution paths. Ruby must traverse the call stack, resolve file paths, and create location objects, making backtrace operations expensive for performance-critical code.

require 'benchmark'

def performance_comparison
  def deep_call_stack(depth)
    return caller_locations if depth == 0
    deep_call_stack(depth - 1)
  end
  
  # Benchmark backtrace generation at different stack depths
  [5, 10, 20, 50].each do |depth|
    time = Benchmark.realtime do
      1000.times { deep_call_stack(depth) }
    end
    puts "Stack depth #{depth}: #{(time * 1000).round(2)}ms for 1000 calls"
  end
  
  # Compare different backtrace methods
  def method_for_testing
    Benchmark.bm(20) do |x|
      x.report("caller") { 10000.times { caller } }
      x.report("caller_locations") { 10000.times { caller_locations } }
      x.report("caller(0, 5)") { 10000.times { caller(0, 5) } }
      x.report("caller_locations(0, 5)") { 10000.times { caller_locations(0, 5) } }
    end
  end
  
  method_for_testing
end

performance_comparison

Memory usage becomes a concern when storing backtrace information or when generating backtraces frequently. Thread::Backtrace::Location objects consume less memory than string representations and provide better performance for repeated access.

require 'objspace'

def memory_usage_analysis
  def generate_backtraces(count)
    results = {}
    
    # String-based backtraces
    GC.start
    before_strings = ObjectSpace.memsize_of_all
    string_backtraces = count.times.map { caller }
    after_strings = ObjectSpace.memsize_of_all
    results[:string_memory] = after_strings - before_strings
    
    # Location-based backtraces  
    GC.start
    before_locations = ObjectSpace.memsize_of_all
    location_backtraces = count.times.map { caller_locations }
    after_locations = ObjectSpace.memsize_of_all
    results[:location_memory] = after_locations - before_locations
    
    results[:string_objects] = string_backtraces.flatten.size
    results[:location_objects] = location_backtraces.flatten.size
    
    results
  end
  
  [100, 500, 1000].each do |count|
    def nested_method_for_memory_test
      generate_backtraces(count)
    end
    
    memory_data = nested_method_for_memory_test
    puts "#{count} backtraces:"
    puts "  String memory: #{memory_data[:string_memory]} bytes"
    puts "  Location memory: #{memory_data[:location_memory]} bytes"
    puts "  String objects: #{memory_data[:string_objects]}"
    puts "  Location objects: #{memory_data[:location_objects]}"
    puts
  end
end

memory_usage_analysis

Caching strategies can mitigate performance impacts when backtrace information is accessed repeatedly. However, cached backtraces become stale when the call stack changes, requiring careful cache invalidation.

class BacktraceCache
  def initialize(cache_size: 100)
    @cache = {}
    @cache_order = []
    @max_size = cache_size
    @hits = 0
    @misses = 0
  end
  
  def get_cached_backtrace(skip_frames: 0, limit: nil)
    cache_key = generate_cache_key(skip_frames, limit)
    
    if @cache.key?(cache_key)
      @hits += 1
      refresh_cache_order(cache_key)
      return @cache[cache_key]
    end
    
    @misses += 1
    backtrace = caller_locations(skip_frames + 1, limit)  # +1 to skip this method
    store_in_cache(cache_key, backtrace)
    backtrace
  end
  
  def cache_stats
    total_requests = @hits + @misses
    hit_rate = total_requests > 0 ? (@hits.to_f / total_requests * 100).round(2) : 0
    
    {
      hits: @hits,
      misses: @misses,
      hit_rate: "#{hit_rate}%",
      cache_size: @cache.size
    }
  end
  
  def clear_cache
    @cache.clear
    @cache_order.clear
  end
  
  private
  
  def generate_cache_key(skip_frames, limit)
    # Include call stack context in cache key
    current_location = caller_locations(1, 3)  # Skip this method
    context = current_location.map { |loc| "#{loc.path}:#{loc.lineno}" }.join('|')
    "#{context}:#{skip_frames}:#{limit}"
  end
  
  def store_in_cache(key, value)
    if @cache.size >= @max_size
      oldest_key = @cache_order.shift
      @cache.delete(oldest_key)
    end
    
    @cache[key] = value
    @cache_order << key
  end
  
  def refresh_cache_order(key)
    @cache_order.delete(key)
    @cache_order << key
  end
end

# Test caching performance
cache = BacktraceCache.new(cache_size: 50)

def test_cached_performance(cache)
  def repeated_backtrace_access(cache)
    cache.get_cached_backtrace(skip_frames: 0, limit: 5)
  end
  
  # Warm up cache
  10.times { repeated_backtrace_access(cache) }
  
  time_without_cache = Benchmark.realtime do
    1000.times { caller_locations(0, 5) }
  end
  
  time_with_cache = Benchmark.realtime do
    1000.times { repeated_backtrace_access(cache) }
  end
  
  puts "Without cache: #{(time_without_cache * 1000).round(2)}ms"
  puts "With cache: #{(time_with_cache * 1000).round(2)}ms"
  puts "Cache stats: #{cache.cache_stats}"
  puts "Performance improvement: #{((time_without_cache - time_with_cache) / time_without_cache * 100).round(2)}%"
end

test_cached_performance(cache)

Production applications should implement backtrace sampling to reduce performance impact while maintaining debugging capabilities. Sampling strategies collect backtrace information for a percentage of operations rather than every execution.

class BacktraceSampler
  def initialize(sample_rate: 0.01, max_samples_per_minute: 60)
    @sample_rate = sample_rate
    @max_samples_per_minute = max_samples_per_minute
    @samples_this_minute = 0
    @last_minute_reset = Time.now.to_i / 60
    @total_calls = 0
    @sampled_calls = 0
  end
  
  def maybe_sample_backtrace(operation_name)
    @total_calls += 1
    reset_minute_counter_if_needed
    
    return nil if @samples_this_minute >= @max_samples_per_minute
    return nil unless should_sample?
    
    @samples_this_minute += 1
    @sampled_calls += 1
    
    {
      operation: operation_name,
      timestamp: Time.now.to_f,
      backtrace: caller_locations(1, 20),  # Skip this method
      thread_id: Thread.current.object_id
    }
  end
  
  def sampling_stats
    {
      total_calls: @total_calls,
      sampled_calls: @sampled_calls,
      actual_sample_rate: @total_calls > 0 ? (@sampled_calls.to_f / @total_calls) : 0,
      configured_sample_rate: @sample_rate,
      samples_this_minute: @samples_this_minute
    }
  end
  
  private
  
  def should_sample?
    rand < @sample_rate
  end
  
  def reset_minute_counter_if_needed
    current_minute = Time.now.to_i / 60
    if current_minute > @last_minute_reset
      @samples_this_minute = 0
      @last_minute_reset = current_minute
    end
  end
end

# Usage in high-frequency operations
sampler = BacktraceSampler.new(sample_rate: 0.05, max_samples_per_minute: 100)

def simulate_high_frequency_operations(sampler)
  1000.times do |i|
    def business_operation(iteration, sampler)
      sample = sampler.maybe_sample_backtrace("business_operation")
      
      if sample
        puts "Sampled operation #{iteration}:"
        puts "  Location: #{sample[:backtrace].first.label}"
        puts "  Thread: #{sample[:thread_id]}"
      end
      
      # Simulate work
      sleep(0.001) if i % 100 == 0  # Occasional longer operation
    end
    
    business_operation(i, sampler)
  end
  
  puts "\nSampling results:"
  stats = sampler.sampling_stats
  stats.each { |key, value| puts "  #{key}: #{value}" }
end

simulate_high_frequency_operations(sampler)

Reference

Core Methods

Method Parameters Returns Description
caller(start=1, length=nil) start (Integer), length (Integer) Array<String> Returns call stack as formatted strings
caller_locations(start=1, length=nil) start (Integer), length (Integer) Array<Thread::Backtrace::Location> Returns call stack as location objects
Thread.current.backtrace None Array<String> Current thread's backtrace as strings
Thread.current.backtrace_locations None Array<Thread::Backtrace::Location> Current thread's backtrace as location objects

Thread::Backtrace::Location Methods

Method Parameters Returns Description
#path None String File path relative to working directory
#absolute_path None String Full absolute file path
#lineno None Integer Line number in file
#label None String Method name or block context
#base_label None String Method name without block annotations
#inspect None String Formatted string representation
#to_s None String Same as inspect

Exception Backtrace Methods

Method Parameters Returns Description
Exception#backtrace None Array<String> Exception's backtrace as strings
Exception#backtrace_locations None Array<Thread::Backtrace::Location> Exception's backtrace as location objects
Exception#set_backtrace(backtrace) backtrace (Array) Array Sets custom backtrace

Frame Format Patterns

Context Format Example
Method call file.rb:line:in 'method' app.rb:15:in 'process_data'
Block file.rb:line:in 'block in method' app.rb:8:in 'block in each'
Class method file.rb:line:in 'Class.method' user.rb:23:in 'User.find'
Module method file.rb:line:in 'Module.method' utils.rb:5:in 'Utils.format'
Top-level file.rb:line:in '<main>' script.rb:1:in '<main>'
eval context (eval):line:in 'method' (eval):1:in 'dynamic_method'

Performance Characteristics

Operation Relative Cost Notes
caller_locations 1.0× Baseline performance
caller 1.2-1.5× String formatting overhead
caller(0, 5) 0.3× Limited frame generation
Deep stack (50+ frames) 3-5× Linear with stack depth
Exception backtrace 1.1× Similar to caller_locations

Memory Usage Patterns

Type Memory per Frame Objects Created
String backtrace ~100-200 bytes String objects
Location objects ~80-120 bytes Location objects
Cached backtraces ~50% reduction Shared references
Exception backtraces Same as locations Automatic generation

Common Error Types

Error Cause Solution
SystemStackError Stack overflow from backtrace generation Limit backtrace depth
NoMemoryError Excessive backtrace storage Implement sampling
Encoding errors Non-UTF8 file paths Handle encoding conversion
File access errors Backtrace from inaccessible files Check file existence