lib/rorvswild/agent.rb



require "logger"
require "socket"
require "etc"

module RorVsWild
  class Agent
    def self.default_config
      {
        api_url: "https://www.rorvswild.com/api/v1",
        ignore_exceptions: default_ignored_exceptions,
        ignore_requests: [],
        ignore_plugins: [],
        ignore_jobs: [],
      }
    end

    def self.default_ignored_exceptions
      if defined?(Rails)
        ActionDispatch::ExceptionWrapper.rescue_responses.keys
      else
        []
      end
    end

    attr_reader :config, :locator, :client, :queue

    def initialize(config)
      @config = self.class.default_config.merge(config)
      load_features
      @client = Client.new(@config)
      @queue = config[:queue] || Queue.new(client)
      @locator = RorVsWild::Locator.new
      Host.load_config(config)
      Deployment.load_config(config)

      RorVsWild.logger.debug("Start RorVsWild #{RorVsWild::VERSION}")
      setup_plugins
      cleanup_data
    end

    def load_features
      features = config[:features] || []
      RorVsWild.logger.info("Server metrics are now monitored enabled by default") if features.include?("server_metrics")
    end

    def setup_plugins
      for name in RorVsWild::Plugin.constants
        next if config[:ignore_plugins] && config[:ignore_plugins].include?(name.to_s)
        if (plugin = RorVsWild::Plugin.const_get(name)).respond_to?(:setup)
          RorVsWild.logger.debug("Setup RorVsWild::Plugin::#{name}")
          plugin.setup
        end
      end
    end

    def measure_code(code)
      measure_block(code) { eval(code) }
    end

    def measure_block(name = nil, kind = "code".freeze, &block)
      current_data ? measure_section(name, kind: kind, &block) : measure_job(name, &block)
    end

    def measure_method(method)
      return if method.name.end_with?("_measured_by_rorvswild")
      if method.is_a?(Method)
        method_full_name = [method.receiver, method.name].join(".") # Method => class method
      else
        method_full_name = [method.owner, method.name].join("#") # UnboundMethod => instance method
      end
      method_alias = :"#{method.name}_measured_by_rorvswild"
      return if method.owner.method_defined?(method_alias)
      method.owner.alias_method(method_alias, method.name)
      method_file, method_line = method.source_location
      method_file = locator.relative_path(File.expand_path(method_file))
      method.owner.define_method(method.name) do |*args|
        section = Section.start
        section.file = method_file
        section.line = method_line
        section.commands << method_full_name
        result = send(method_alias, *args)
        Section.stop
        result
      end
    end

    def measure_section(name, kind: "code", &block)
      return block.call unless current_data
      begin
        RorVsWild::Section.start do |section|
          section.commands << name
          section.kind = kind
        end
        block.call
      ensure
        RorVsWild::Section.stop
      end
    end

    def measure_job(name, parameters: nil, &block)
      return measure_section(name, &block) if current_data # For recursive jobs
      return block.call if ignored_job?(name)
      initialize_data[:name] = name
      begin
        block.call
      rescue Exception => ex
        push_exception(ex, parameters: parameters, job: {name: name})
        raise
      ensure
        gc = Section.stop_gc_timing(current_data[:gc_section])
        current_data[:sections] << gc if gc.calls > 0
        current_data[:runtime] = RorVsWild.clock_milliseconds - current_data[:started_at]
        queue_job
      end
    end

    def start_request
      current_data || initialize_data
    end

    def stop_request
      return unless data = current_data
      gc = Section.stop_gc_timing(data[:gc_section])
      data[:sections] << gc if gc.calls > 0 && gc.total_ms > 0
      data[:runtime] = RorVsWild.clock_milliseconds - current_data[:started_at]
      queue_request
    end

    def catch_error(context = nil, &block)
      begin
        block.call
      rescue Exception => ex
        record_error(ex, context)
        ex
      end
    end

    def record_error(exception, context = nil)
      queue_error(exception_to_hash(exception, context)) if !ignored_exception?(exception)
    end

    def push_exception(exception, options = nil)
      return if ignored_exception?(exception)
      return unless current_data
      current_data[:error] = exception_to_hash(exception)
      current_data[:error].merge!(options) if options
      current_data[:error]
    end

    def merge_error_context(hash)
      self.error_context = error_context ? error_context.merge(hash) : hash
    end

    def error_context
      current_data[:error_context] if current_data
    end

    def error_context=(hash)
      current_data[:error_context] = hash if current_data
    end

    def send_server_timing=(boolean)
      current_data[:send_server_timing] = boolean if current_data
    end

    def current_data
      Thread.current[:rorvswild_data]
    end

    def add_section(section)
      return unless current_data[:sections]
      if sibling = current_data[:sections].find { |s| s.sibling?(section) }
        sibling.merge(section)
      else
        current_data[:sections] << section
      end
    end

    def ignored_request?(name)
      config[:ignore_requests].any? { |str_or_regex| str_or_regex === name }
    end

    def ignored_job?(name)
      config[:ignore_jobs].any? { |str_or_regex| str_or_regex === name }
    end

    def ignored_exception?(exception)
      return false unless config[:ignore_exceptions]
      config[:ignore_exceptions].any? { |str_or_regex| str_or_regex === exception.class.to_s }
    end

    #######################
    ### Private methods ###
    #######################

    private

    def initialize_data
      Thread.current[:rorvswild_data] = {
        started_at: RorVsWild.clock_milliseconds,
        gc_section: Section.start_gc_timing,
        environment: Host.to_h,
        section_stack: [],
        sections: [],
      }
    end

    def cleanup_data
      result = Thread.current[:rorvswild_data]
      Thread.current[:rorvswild_data] = nil
      result
    end

    def queue_request
      (data = cleanup_data) && data[:name] && queue.push_request(data)
      data
    end

    def queue_job
      queue.push_job(cleanup_data)
    end

    def queue_error(hash)
      queue.push_error(hash)
    end

    def exception_to_hash(exception, context = nil)
      file, line = locator.find_most_relevant_file_and_line_from_exception(exception)
      context = context ? error_context.merge(context) : error_context if error_context
      {
        line: line.to_i,
        file: locator.relative_path(file),
        message: exception.message[0,1_000_000],
        backtrace: exception.backtrace || ["No backtrace"],
        exception: exception.class.to_s,
        context: context,
        environment: Host.to_h,
      }
    end
  end
end