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:
- Ignore the deprecation warning and hope that a test catches it when you upgrade to 6.1
- 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::Deprecation
s.
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)
[Note: 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::Deprecation
s, 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. 😄