When working on a Rails upgrade, it can be really easy to overlook deprecation warnings because … they’re just warnings! In our normal day-to-day work, deprecations silently roll through our logs (if they’re being logged at all). Since we’re all used to ignoring them, teams dive in headfirst to their Rails upgrades without giving deprecations a second thought. But when teams do this, they’re missing a golden opportunity to ship a big part of their upgrade before ever running bundle update rails.

Deprecations help you upgrade your app before you upgrade your app

There’s nothing wrong with ignoring deprecation warnings in your day-to-day work (I do it too!). But when you start your next Rails upgrade, they should be one of the first things you look at. Typically, deprecation warnings indicate behavior that’s going to be removed from the next version of Rails. For example, on Rails 6.0 you might see this deprecation warning:

Accessing hashes returned from config_for by non-symbol keys is deprecated and will be removed in Rails 6.1. Use symbols for access instead.

Since this is just a warning, you can keep using non-symbol keys with config_for while you’re on Rails 6.0. However, once you upgrade to Rails 6.1 this is gonna blow up. So you have two options:

  1. Ignore the deprecation warning and hope that a test catches it when you upgrade to 6.1
  2. Fix the deprecation warning in production today so you have one less thing to think about when you’re ready to upgrade

One of those options sounds a lot more appealing to me. 😎

Lo and behold: the DeprecationSubscriber!

If you’re upgrading a big app, you might see tons of deprecations littering your logs. And it can be hard to keep track of how many there are or who’s fixing what. Let’s see if we can add some tooling to help us with both of these things.

First, Rails provides a few config options for deprecation handling. We’ll set our app to :notify deprecations in all environments.

module MyAwesomeApp < Rails::Application
  config.active_support.deprecation = :notify
end

Now when we hit a deprecation in our app, Rails will send a deprecation.rails event via ActiveSupport::Notifications.

Next, we’ll set up a subscriber to process those events. Lets call it DeprecationSubscriber.

class DeprecationSubscriber < ActiveSupport::Subscriber
  class UnallowedDeprecation < StandardError
    def initialize(message)
      super("Unallowed deprecation found. Please fixt it.\n#{message}")
    end
  end
  
  attach_to :rails

  def deprecation(event)
    exception = UnallowedDeprecation.new(event.payload[:message])
    exception.set_backtrace(event.payload[:callstack].map(&:to_s)
    raise exception
  end
end

Since DeprecationSubscriber inherits from ActiveSupport::Subscriber, we can use attach_to and define a method called deprecation that will automagically receive the deprecation.rails events.

class DeprecationSubscriber < ActiveSupport::Subscriber
  # …
  attach_to :rails

  def deprecation(event)
    # …
  end
end

When DeprecationSubscriber gets a rails.deprecation event, it will raise it as an exception.

  def deprecation(event)
    exception = UnallowedDeprecation.new(event.payload[:message])
    exception.set_backtrace(event.payload[:callstack].map(&:to_s)
    raise exception
  end

Now when we do something like run our test suite, we’ll see errors whenever there’s a deprecation. If you’re still with me you might be asking yourself, “why are we writing all of this code when we could just configure the app to raise errors for deprecations?” If we did that, we’d be putting ourselves in a little bit of a corner because all those deprecations we’ve been ignoring would immediately become errors. And we don’t want to ship a bunch of errors to production. Especially when they’re really just warnings.

So let’s modify DeprecationSubscriber a bit.

class DeprecationSubscriber < ActiveSupport::Subscriber
  class UnallowedDeprecation < StandardError
    def initialize(message)
      super("Unallowed deprecation found. Please fixt it.\n#{message}")
    end
  end
  
  attach_to :rails

  ALLOWED_DEPRECATIONS = [
    "A message for some deprecation",
    "Another message for a different deprecation",
    
  ]

  def deprecation(event)
    return if ALLOWED_DEPRECATIONS.any? { |allowed| event.payload[:message].include?(allowed) }

    exception = UnallowedDeprecation.new(event.payload[:message])
    exception.set_backtrace(event.payload[:callstack].map(&:to_s)
    raise exception
  end
end

We’ve now added an ALLOWED_DEPRECATIONS array, and we’re using it as a guard clause in #deprecation. If DeprecationSubscriber comes across a deprecation that’s allowed, it’ll be ignored. Otherwise it’ll be raised as an error.

Now when we run our test suite, we’ll collect all of the deprecations that are causing test errors and add them to ALLOWED_DEPRECATIONS. For example, if we see that deprecation about accessing hashes from config_for with non-symbol keys, we’ll add it to the list.

  ALLOWED_DEPRECATIONS = [
    "Accessing hashes returned from config_for by non-symbol keys is deprecated and will be removed in Rails 6.1. Use symbols for access instead.",
    
  ]

And we’ll do this for all the deprecations we come across. We can now ship this to production because we won’t be turning every deprecation into an error. Err, actually we need to make one more change to #deprecation.

  def deprecation(event)
    return if ALLOWED_DEPRECATIONS.any? { |allowed| event.payload[:message].include?(allowed) }

    if Rails.env.development? || Rails.env.test?
      exception = UnallowedDeprecation.new(event.payload[:message])
      exception.set_backtrace(event.payload[:callstack].map(&:to_s)
      raise exception
    else
      Rails.logger.warn("Unallowed deprecation found\n#{event.payload[:message]}")
    end
  end

We’re adding all the deprecations we know about to ALLOWED_DEPRECATIONS, but it’s hard to be 100% sure we’ve added them all. Instead of having these unknown deprecations become errors in production, we’ll log them. After we ship the DeprecationSubscriber to production, we’ll review the logs to collect any deprecations we missed.

After we’ve added all the deprecations we know about to ALLOWED_DEPRECATIONS, we’re ready to ship the DeprecationSubscriber to production.

Burning down ALLOWED_DEPRECATIONS

We now have a documented list of all the deprecations in our app. We can divvy them up across our team, opening small PRs along the way to incrementally bring our app one step closer to running on the next version of Rails. And when deprecations slip through the cracks, they’ll be logged in production giving us one last fail-safe to make sure we’ve really fixed all those deprecations.

As we fix deprecations, we’ll remove them from ALLOWED_DEPRECATIONS. When this happens, developers who naturally ignore deprecations (🙋🏾‍♂️ like me) will see errors pop up while they’re working that tell them it’s time to write non-deprecated code. The computers will help everyone slowly adopt the new patterns, and we’ll be one step closer to shipping that Rails upgrade … all before even running bundle update rails! 🙌🏾

BONUS: Capture Ruby 2.7 positional and keyword argument deprecations

Ruby 3.0 introduces a pretty big change to how positional and keyword arguments work. To support apps migrating from Ruby 2, Ruby 2.7 emits warnings when executing code that won’t work in Ruby 3.0. You can then use these warnings to update your code that’s running on Ruby 2.7 so it’s ready for Ruby 3.0. Sound familiar?

When we’re upgrading Rails apps from Ruby 2.7 to Ruby 3.0, we can leverage the DeprecationSubscriber to collect all of our app’s Ruby 2.7 argument warnings. We can then burn down the list, shipping small changes on Ruby 2.7 that gets our app one step closer to working on Ruby 3.0.

First, we’ll make sure the Ruby warnings are being sent by updating our app’s config.

module MyAwesomeApp < Rails::Application
  Warning[:deprecated] = true
end

Then, we’ll write a small patch to Ruby’s Warning module that turns the Ruby 2.7 warnings into ActiveSupport::Deprecations.

module CaptureRubyWarnings
  RUBY_2_7_DEPRECATIONS = [
    "Using the last argument as keyword parameters is deprecated",
    "Passing the keyword argument as the last hash parameter is deprecated",
    "Splitting the last argument into positional and keyword parameters is deprecated",
  ]

  def warn(message)
    if RUBY_2_7_DEPRECATIONS.any? { |warning| message.include?(warning) }
      ActiveSupport::Deprecation.warn(message)
    else
      super
    end
  end
end

Warning.extend(CaptureRubyWarnings)

In addition to capturing keyword and positional argument warnings, you might want to capture warnings for other things that are changing in Ruby 3.0. For example, Ruby 3.0 removes URI.escape and URI.unescape.

Before turning the warnings into ActiveSupport::Deprecations, we’ll first make sure they’re the Ruby 2.7 warnings we care about. Otherwise, we’ll see a lot of noise from all the different Ruby warnings in our app (we should probably fix those warnings too but that’s a story for another day).

Now whenever we run code that won’t work in Ruby 3.0, the DeprecationSubscriber will capture it and raise it as an error. Then we’ll be back in our old workflow, collecting errors and fixing them one bit at a time. 😄

Ali Ibrahim

Person An icon of a human figure Status
Sleeper Agent
Hash An icon of a hash sign Code Name
Agent 0022
Location An icon of a map marker Location
Baltimore, MD