lib/honeybadger/plugin.rb



require 'forwardable'

module Honeybadger
  # +Honeybadger::Plugin+ defines the API for registering plugins with
  # Honeybadger. Each plugin has requirements which must be satisfied before
  # executing the plugin's execution block(s). This allows us to detect
  # optional dependencies and load the plugin for each dependency only if it's
  # present in the application.
  #
  # See the plugins/ directory for examples of official plugins. If you're
  # interested in developing a plugin for Honeybadger, see the Integration
  # Guide: https://docs.honeybadger.io/ruby/gem-reference/integration.html
  #
  # @example
  #
  #   require 'honeybadger/plugin'
  #   require 'honeybadger/ruby'
  #
  #   module Honeybadger
  #     module Plugins
  #       # Register your plugin with an optional name. If the name (such as
  #       # "my_framework") is not provided, Honeybadger will try to infer the name
  #       # from the current file.
  #       Plugin.register 'my_framework' do
  #         requirement do
  #           # Check to see if the thing you're integrating with is loaded. Return true
  #           # if it is, or false if it isn't. An exception in this block is equivalent
  #           # to returning false. Multiple requirement blocks are supported.
  #           defined?(MyFramework)
  #         end
  #
  #         execution do
  #           # Write your integration. This code will be executed only if all requirement
  #           # blocks return true. An exception in this block will disable the plugin.
  #           # Multiple execution blocks are supported.
  #           MyFramework.on_exception do |exception|
  #             Honeybadger.notify(exception)
  #           end
  #         end
  #       end
  #     end
  #   end
  class Plugin
    # @api private
    CALLER_FILE = Regexp.new('\A(?:\w:)?([^:]+)(?=(:\d+))').freeze

    class << self
      # @api private
      @@instances = {}

      # @api private
      def instances
        @@instances
      end

      # Register a new plugin with Honeybadger. See {#requirement} and {#execution}.
      #
      # @example
      #
      #   Honeybadger::Plugin.register 'my_framework' do
      #     requirement { }
      #     execution { }
      #   end
      #
      # @param [String, Symbol] name The optional name of the plugin. Should use
      #   +snake_case+. The name is inferred from the current file name if omitted.
      #
      # @return nil
      def register(name = nil, &block)
        name ||= name_from_caller(caller) or
          raise(ArgumentError, 'Plugin name is required, but was nil.')
        instances[key = name.to_sym] and fail("Already registered: #{name}")
        instances[key] = new(name).tap { |d| d.instance_eval(&block) }
      end

      # @api private
      def load!(config)
        instances.each_pair do |name, plugin|
          if config.load_plugin?(name)
            plugin.load!(config)
          else
            config.logger.debug(sprintf('skip plugin name=%s reason=disabled', name))
          end
        end
      end

      # @api private
      def name_from_caller(caller)
        caller && caller[0].match(CALLER_FILE) or
          fail("Unable to determine name from caller: #{caller.inspect}")
        File.basename($1)[/[^\.]+/]
      end
    end

    # @api private
    class Execution
      extend Forwardable

      def initialize(config, &block)
        @config = config
        @block = block
      end

      def call
        instance_eval(&block)
      end

      private

      attr_reader :config, :block
      def_delegator :@config, :logger
    end

    # @api private
    def initialize(name)
      @name         = name
      @loaded       = false
      @requirements = []
      @executions   = []
    end

    # Define a requirement. All requirement blocks must return +true+ for the
    # plugin to be executed.
    #
    # @example
    #
    #   Honeybadger::Plugin.register 'my_framework' do
    #     requirement { defined?(MyFramework) }
    #
    #     # Honeybadger's configuration object is available inside
    #     # requirement blocks. It should generally not be used outside of
    #     # internal plugins. See +Config+.
    #     requirement { config[:'my_framework.enabled'] }
    #
    #     execution { }
    #   end
    #
    # @return nil
    def requirement(&block)
      @requirements << block
    end

    # Define an execution block. Execution blocks will be executed if all
    # requirement blocks return +true+.
    #
    # @example
    #
    #   Honeybadger::Plugin.register 'my_framework' do
    #     requirement { defined?(MyFramework) }
    #
    #     execution do
    #       MyFramework.on_exception {|err| Honeybadger.notify(err) }
    #     end
    #
    #     execution do
    #       # Honeybadger's configuration object is available inside
    #       # execution blocks. It should generally not be used outside of
    #       # internal plugins. See +Config+.
    #       MyFramework.use_middleware(MyMiddleware) if config[:'my_framework.use_middleware']
    #     end
    #   end
    #
    # @return nil
    def execution(&block)
      @executions << block
    end

    # @api private
    def ok?(config)
      @requirements.all? {|r| Execution.new(config, &r).call }
    rescue => e
      config.logger.error(sprintf("plugin error name=%s class=%s message=%s\n\t%s", name, e.class, e.message.dump, Array(e.backtrace).join("\n\t")))
      false
    end

    # @api private
    def load!(config)
      if @loaded
        config.logger.debug(sprintf('skip plugin name=%s reason=loaded', name))
        return false
      elsif ok?(config)
        config.logger.debug(sprintf('load plugin name=%s', name))
        @executions.each {|e| Execution.new(config, &e).call }
        @loaded = true
      else
        config.logger.debug(sprintf('skip plugin name=%s reason=requirement', name))
      end

      @loaded
    rescue => e
      config.logger.error(sprintf("plugin error name=%s class=%s message=%s\n\t%s", name, e.class, e.message.dump, Array(e.backtrace).join("\n\t")))
      @loaded = true
      false
    end

    # @api private
    def loaded?
      @loaded
    end

    # @private
    # Used for testing only; don't normally call this. :)
    def reset!
      @loaded = false
    end

    # @api private
    attr_reader :name, :requirements, :executions
  end
end