Working with deprecations

Deprecations are the normal part of lifecycle of any large application. A big amount of code makes changes of public API really difficult. Especially if we need to do it for all the code. When you meet this problem for the first time, the first reaction is to simply add some puts with info and you think it will be ok, but there are better options, especially if your project is on RoR.

Let’s try to make our deprecation warning messages more standard. So, let’s imagine that you have method which performs some calculations (of course it will be named calculate), and you want to deprecate it.  

class Calculator
  def calculate
    # a lot of very useful calculations
    :result
  end
end

The most simple way to do it is to use ActiveSupport::Deprecation. So, you simply need to add one line to your method body:

def calculate
  ActiveSupport::Deprecation.new.warn
  :result
end

  Let’s run our code:

DEPRECATION WARNING: You are using deprecated behavior which will be removed
from the next major or minor release. (called from <main> at logic.rb:9)

There are many options you can use to customize your deprecation messages. You can add some details, may be pass some callbacks, etc. But if you are going this way, you are changing method body and polluting git history. In this case if something change you will need to change your messages across whole application. Yes, you can introduce some singletons, add inheritance, move it to different classes, may be introduce a factory…

Wait a minute, I’m simply wanted to deprecate some methods.

Actually, I do want to use the trick from the new Ruby version (can’t remember exact number), which enforced method definitions to return their names, and allowed something like: deprecated def calculate to exist. And it’s really easy, we need to declare anonymous module which we will prepend for the class which contains methods we want to deprecate.

module Deprecatable
  def deprecate(method_name)
    mod = Module.new do
      define_method(method_name) do |*args, &block|
        ActiveSupport::Deprecation.new.warn
        super(*args, &block)
      end
    end

    self.prepend(mod)
  end
end
class Calculator
  extend Deprecatable

  deprecate def calculate
    :result
  end
end

We don’t change any business logic, just add some wrapper for it, and it looks really awesome (at least for me) but, actually, it doesn’t solve our problems (but it is still cool). From the other side, it is very similar to Java annotations, but who cares). Ok, we still have a problem.

Reading through Rails source code, you could find a module Deprecation::MethodWrapper, which is included in ActiveSupport::Deprecation. It allows to deprecate methods without making direct changes in our code. According to related docs we can use it in the next manner:

ActiveSupport::Deprecation.deprecate_methods(Calculator, :calculate)

And the result is:

DEPRECATION WARNING: calculate is deprecated and will be removed from Rails 5.2
(called from <main> at logic.rb:11)

Ok, it’s very nice, no code was changed, message is a little weird, but it can be easily changed to something more readable. Rails provides us such an ability:  

ActiveSupport::Deprecation.deprecate_methods(Calculator,
  calculate: 'please, do not use this method because of the reason',
  deprecator: ActiveSupport::Deprecation.new('next-release', 'Calculator'))

New result:

DEPRECATION WARNING: calculate is deprecated and will be removed from Calculator
next-release (please, do not use this method because of the reason) (called from
<main> at logic.rb:15)

Ok, we have a nice and clean solution, which is doing what we want, but… How often do you ignore such messages? I will fix it later, aha. It will be nice to track such deprecation in some place to fix it later. Hm, very similar to errors tracking systems. So, Honeybadger for example. As you can see above, Rails provides us an ability to pass custom deprecators. Further investigation reveals that Rails is actually calling only one method: deprecation_warning (if we are talking about methods deprecations). So, we can introduce our own deprecator which will notify Honeybadger.

 As I said before, we simply need to implement deprecation_warning method to introduce our deprecator.

class HoneybadgerDeprecator
  DEPRECATION = '⛔ Method `%<method_name>s` is deprecated. Please, refer to %<refer>s.'

  def initialize(debug: false, refer: 'your team lead')
    @debug = debug
    @refer = refer
  end

  def deprecation_warning(depricated_method_name, message = nil, caller_backtrace = nil)
    caller_backtrace ||= caller_locations(2) if @debug
    message ||= format(DEPRECATION,
                       method_name: depricated_method_name,
                       refer: @refer)
    Honeybadger.notify(ActiveSupport::DeprecationException.new(message),
                       message: message,
                       trace: caller_backtrace,
                       depricated_method_name: depricated_method_name)
  end
end

Also it will be nice to add some tests to check that code actually works  

RSpec.describe HoneybadgerDeprecator do
  let(:fred)     { Class.new { def call; end } }
  let(:message)  { /`call` is deprecated/ }

  before do
    ActiveSupport::Deprecation.deprecate_methods(fred, :call, deprecator: subject)
    Honeybadger.configure do |config|
      config.api_key = 'temp' # we need to set any API key
      config.backend = 'test' # special backend for Honeybadger testing
      config.logger  = NullLogger.new # we do not want to hit our console
    end
  end

  it 'sends notification to honeybadger' do
    expect do
      fred.new.call
      Honeybadger.flush
    end.to change(Honeybadger::Backend::Test.notifications[:notices], :size).by(1)

    expect(Honeybadger::Backend::Test.notifications[:notices].first.error_message)
      .to match(message)
  end
end

Now, let’s try to apply it to our Calculator code:

ActiveSupport::Deprecation.deprecate_methods(Calculator,
  calculate: 'please, do not use this method because of the reason',
  deprecator: HoneybadgerDeprecator.new)

and it works! Now we can track all deprecations, analyze some statistics and so on. But usually Honeybadger is configured to work only in the production environment, which makes sense. So, we should always remember about developers who use it in the development environment as well. Seems like there are no better way to implement it, then simple logging. 

So, the final result is:

class HoneybadgerDeprecator
  DEPRECATION = '⛔ Method `%<method_name>s` is deprecated. Please, refer to %<refer>s.'
  LOG_TEXT = '%<message>s Called from: %<trace>s'

  def initialize(debug: false, refer: 'your team lead', logger: Logger.new(STDOUT))
    @debug = debug
    @refer = refer
    @logger = logger
  end

  def deprecation_warning(depricated_method_name, message = nil, caller_backtrace = nil)
    caller_backtrace ||= caller_locations(2) if @debug
    message ||= format(DEPRECATION,
                       method_name: depricated_method_name,
                       refer: @refer)
    if Rails.env.development?
      @logger.warn(format(LOG_TEXT,
                          message: message,
                          trace: caller_backtrace.to_a.join("\n")))
    else
      Honeybadger.notify(ActiveSupport::DeprecationException.new(message),
                         message: message,
                         trace: caller_backtrace,
                         depricated_method_name: depricated_method_name)
    end
  end
end

And don’t forget the tests:  

RSpec.describe HoneybadgerDeprecator do
  let(:fred)     { Class.new { def call; end } }
  let(:rails)    { double('Rails').as_null_object }
  let(:logger)   { instance_double(Logger) }
  let(:message)  { /`call` is deprecated/ }

  subject { HoneybadgerDeprecator.new(logger: logger) }

  before do
    stub_const('Rails', rails)
    ActiveSupport::Deprecation.deprecate_methods(fred, :call, deprecator: subject)
  end

  context 'in development mode' do
    before do
      allow(rails).to receive(:development?).and_return(true)
    end

    it 'notifies developer to console' do
      expect(logger).to receive(:warn).with(be =~ message)
      fred.new.call
    end
  end

  context 'in production mode' do
    before do
      allow(rails).to receive(:development?).and_return(false)
      Honeybadger.configure do |config|
        config.api_key = 'temp'
        config.backend = 'test'
        config.logger  = NullLogger.new
      end
    end

    it 'sends notification to honeybadger' do
      expect do
        fred.new.call
        Honeybadger.flush
      end.to change(Honeybadger::Backend::Test.notifications[:notices], :size).by(1)
      expect(Honeybadger::Backend::Test.notifications[:notices].first.error_message)
        .to match(message)
    end
  end
end

UPD: Another approach is to use Rails-provided DI instead of direct methods calling based on current Rails environment. There is a nice example how to use it in official Rails guide. New version of code could look something like:

class ApplicationDeprecator
  DEPRECATION = '⛔ Method `%<method_name>s` is deprecated. Please, refer to %<refer>s.'

  def initialize(debug: false, refer: 'your team lead')
    @debug = debug
    @refer = refer
  end

  def deprecation_warning(depricated_method_name, message = nil, caller_backtrace = nil)
    caller_backtrace ||= caller_locations(2) if @debug
    message ||= format(DEPRECATION,
                       method_name: depricated_method_name,
                       refer: @refer)
    reporter.notify(message, depricated_method_name, caller_backtrace)
  end

  private

  def reporter
    Rails.configuration.deprecation_reporter.tap do |instance|
      unless instance.respond_to?(:notify)
        raise ArgumentError, 'Deprecation reporter should respond to :notify'
      end
    end
  end
end

class HoneybadgerReporter
  def notify(message, depricated_method_name, caller_backtrace)
    Honeybadger.notify(ActiveSupport::DeprecationException.new(message),
                       message: message,
                       trace: caller_backtrace,
                       depricated_method_name: depricated_method_name)
  end
end

class LogReporter
  LOG_TEXT = '%<message>s Called from: %<trace>s'

  def initialize(logger: Logger.new(STDOUT))
    @logger = logger
  end

  def notify(message, depricated_method_name, caller_backtrace)
    @logger.warn(format(LOG_TEXT,
                        message: message,
                        trace: caller_backtrace.to_a.join("\n")))
  end
end

And now, to configure what exactly should happen:

# config/environments/production.rb
config.deprecation_reporter = HoneybadgerReporter.new
# config/environments/test.rb
# config/environments/development.rb
config.deprecation_reporter = LogReporter.new