Sharing strings between multiple notification channels in Rails

Authored by jmstfv

Backstory

When I shipped the initial version of Hexadecimal, the only way to get notified when something went wrong with your websites, was via email. Naturally, all of the text pertaining to notifications was contained inside mailer views.

As I embarked on adding more channels, the challenge I faced was how to share notification text that were previously living inside mailers between all channels?

When I say notification text, I mean the contents of the message that you receive whenever your websites go down. Instead of a generic "your website is down, go figure" message, you receive a reason why your website is down, what might have caused it, and how to go about troubleshooting it. Ideally, when your websites implode, you want to spend as little time as possible on identifying the problem.

Name resolution for tryhexadecimal.com has failed.

The most common reason for this failure is the URL that doesn't have any DNS records pointing to it.

If you think that you have mistyped the URL, the best course of action is to delete this check and create a new one (you can't modify the URL after creation, by design).

Head over to [...] to view debug information that can assist you in troubleshooting this incident.
A notification you might receive when a name resolution for the endpoint fails

A bit of a context. Each notification is a single, standalone ActiveRecord model. These models share some similarities, but also differ in some ways. I looked into polymorphism and Single Table Inheritance (STI), but after a bit of consideration, tradeoffs seemed more costly than alleged benefits. Also, having a separate model for each notification type conformed to the Do The Simplest Thing That Could Possibly Work, which is my preferred way of doing things.

Every notification is associated with a single Event object. Notifications can be either local (i.e., you add them manually to each website) or global (i.e., they are automatically added to all websites).

Each notification has a dispatch method that receives data, processes it, and passes on the request to the specific background job that sends the notification. DispatchNotificationsJob background job is responsible for orchestrating the entire show: from gathering all channels for a given website, to dispatching notifications to them.

class DispatchNotificationsJob < ApplicationJob
  queue_as :notifications

  def perform(website, event, **kwargs)
    alert_channels = []

    alert_channels += website.email_alerts.to_a
    alert_channels += website.sms_alerts.to_a
    alert_channels += website.slack_alerts.to_a

    alert_channels.each do |channel|
      channel.dispatch(website, event, kwargs)
    end
  end
end

First attempt - i18n

My first instinct was to place those strings in a config/locales/en.yml file, and pull them out from there when necessary.

This approach quickly fell flat on its face because some mailer views contained logic (for example, iterating over missing keywords and listing them in an unordered list).

Some keywords that were supposed to appear in the response body weren't found. Missing keywords are:

* Duct tape and bubble gum
* correct horse battery staple
* His laptop's encrypted. Let's build a million dollar cluster to crack it.

Note that this check only runs if it is possible to reach your website and HTTP status code is 2xx. Make sure that you haven't made drastic changes to your website that rendered this check obsolete.

Head over to [...] to view debug information that can assist you in troubleshooting this incident.
A notification you might receive when the response body doesn't contain keywords of your choosing

I briefly entertained the idea of transforming messages into static strings but quickly decided against it, because that would have resulted in more generic messages, which in turn would have necessitated folks to log in to their dashboards every time a Bad Thing happened. Doubleplusungood.

Second attempt - a service object

Create an intermediary service object that will accept the data and return the appropriate message.

Whenever I need to obtain the message, I would create the object and pass all the data to it, and in return, would magically receive appropriate strings via public methods such as notification_title and notification_body. Think junk drawer.

Surprisingly, it worked. I wish it didn't. You should have seen that: the epitome of the duct tape and bubble gum. An unashamedly long case statement with squiggly heredocs all over the place. It was one of those Don't touch it places of the codebase where monsters roam.

As much as I abhorred that piece of code, it was working. I left it alone, hoping that someday I will rip that monster apart.

Third attempt - static strings in a database

One day, when I didn't know have anything productive to do with my time, I decided to tackle the technical debt once for all.

"What if I could shove those strings in a database", I thought. To make it happen, I will replace dynamic parts with placeholders (for example, website.url will become {website_url}), and insert necessary data whenever I need to. Those strings should be a part of the event object that this notification is associated with. Whenever I would have to dispatch a message, I would pull that string from the corresponding event object, and pass in the necessary data. For example:

website_url = website.url
event.body % { website_url }

Even though this proof of concept sort of worked, it never made it to production because of how messy the underlying code became. Since I can't access object methods within those static strings, I need to pass each of those variables separately. Mere object access turned into multiple variable declarations, named after those placeholders, that needed to get passed to that string.

As much as I wanted to get rid of that despicable service object, I certainly didn't want to trade one mess with another.

Final attempt - going back home

"It would be stellar to return all those nomadic strings to mailer views, just like in the olden days", I thought to myself. For some unbeknownst reasons, it didn't occur to me that this is indeed possible before I stumbled upon these two StackOverflow questions. If I could place those strings back into mailers, and somehow access them from outside of mailers, my life would certainly become a bit less miserable.

I bit the bullet and started making changes. I initialized the mailer class, passed in the necessary params, and dispatched the request to the appropriate mailer (notice public_send method: each event is tied to a single mailer). Then I would simply query the mailer object and get the necessary strings, such as the title and the body of the notification.

Guess what? It worked like a charm. This approach might not conform to the "Single Responsibility Principle", but in my book, practicality beats purity.

class SlackAlert < ApplicationRecord
  def dispatch(website, event, **kwargs)
    params = { website: website, event: event }.merge(kwargs)
    mailer = NotificationMailer.with(params).public_send(event.name)

    subject = mailer.subject
    body = mailer.body.encoded

    # Prepare the payload and schedule a background job
  end
end

I felt quite foolish for the rest of the day.

No link tracking, no hidden pixels, no promotional emails, or other nonsense. I will only send you one email when a new article is out. Unsubscribe anytime.

You can also subscribe to the Atom feed.