lib/elastic_apm/stacktrace.rb



# frozen_string_literal: true

require 'elastic_apm/stacktrace/frame'
require 'elastic_apm/stacktrace/line_cache'

module ElasticAPM
  # @api private
  class Stacktrace
    JAVA_FORMAT = /^(.+)\.([^\.]+)\(([^\:]+)\:(\d+)\)$/
    RUBY_FORMAT = /^(.+?):(\d+)(?::in `(.+?)')?$/

    RUBY_VERS_REGEX = %r{ruby(/gems)?[-/](\d+\.)+\d}
    JRUBY_ORG_REGEX = %r{org/jruby}

    def initialize(backtrace)
      @backtrace = backtrace
    end

    attr_reader :frames

    def self.build(config, backtrace, type)
      return nil unless backtrace

      stack = new(backtrace)
      stack.build_frames(config, type)
      stack
    end

    def build_frames(config, type)
      @frames = @backtrace.map do |line|
        build_frame(config, line, type)
      end
    end

    def length
      frames.length
    end

    def to_a
      frames.map(&:to_h)
    end

    private

    def parse_line(line)
      ruby_match = line.match(RUBY_FORMAT)

      if ruby_match
        _, file, number, method = ruby_match.to_a
        file.sub!(/\.class$/, '.rb')
        module_name = nil
      else
        java_match = line.match(JAVA_FORMAT)
        _, module_name, method, file, number = java_match.to_a
      end

      [file, number, method, module_name]
    end

    def library_frame?(config, abs_path)
      return false unless abs_path

      if abs_path.start_with?(config.root_path)
        return true if abs_path.start_with?(config.root_path + '/vendor')
        return false
      end

      return true if abs_path.match(RUBY_VERS_REGEX)
      return true if abs_path.match(JRUBY_ORG_REGEX)

      false
    end

    class << self
      attr_accessor :frame_cache
    end

    # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
    def build_frame(config, line, type)
      # TODO: Eventually move this to agent 'context'
      self.class.frame_cache ||= Util::LruCache.new(2048) do |cache, keys|
        line, type = keys
        abs_path, lineno, function, _module_name = parse_line(line)

        frame = Frame.new
        frame.abs_path = abs_path
        frame.filename = strip_load_path(abs_path)
        frame.function = function
        frame.lineno = lineno.to_i
        frame.library_frame = library_frame?(config, abs_path)

        line_count =
          context_lines_for(config, type, library_frame: frame.library_frame)
        frame.build_context line_count

        cache[[line, type]] = frame
      end

      self.class.frame_cache[[line, type]]
    end
    # rubocop:enable Metrics/AbcSize, Metrics/MethodLength

    def strip_load_path(path)
      return nil if path.nil?

      prefix =
        $LOAD_PATH
        .map(&:to_s)
        .select { |s| path.start_with?(s) }
        .max_by(&:length)

      prefix ? path[prefix.chomp(File::SEPARATOR).length + 1..-1] : path
    end

    def context_lines_for(config, type, library_frame:)
      key = "source_lines_#{type}_#{library_frame ? 'library' : 'app'}_frames"
      config.send(key.to_sym)
    end
  end
end