Implementing the Notification Center

Marketers use Iterable to launch a variety of powerful campaigns and workflows. There are a lot of complex moving parts and they would ideally be able to know the status/completion of certain actions and be warned or alerted about errors that occur.

For instance, they might want to know when a workflow webhook they have setup is returning error codes – and that could affect millions of users going through that workflow.

The goal of the Notification Center is to provide an actionable, dynamic feedback system that alerts Iterable users.

This post covers some of the key design and engineering decisions. The code has been simplified to illustrate the main points made in this blog post.

Design Decisions

We did some user story research and ended up deciding that:

  • Notifications should be dynamic and updatable. We would want 1 notification reporting on 5000 workflow webhook errors over 5000 separate workflow webhook notifications.
  • Notifications are at the Iterable Project level, but since there could be many users per Project, their read/unread statuses’ for each notification should be isolated. User 1 reading notification A, should not make that notification to be marked as read for User 2.
  • Notifications should be easy to create from an engineering perspective
  • Notifications should know how to update themselves (more on this in the fingerprinting section)

Version 1 – Naive Version

case class Notification {
  id: Long
  projectId: Long
  title: String
  description: String
}
case class UserNotificationStatus {
  notification_id: Long // references Notification.id
  userId: String // guaranteed to be unique
  status: UserNotificationStatus // Read or Unread
}

This is the naive Notification Center prototype. It works but is clearly deficient in many ways.

  • You can’t update (or add new information) into an existing Notification easily as there’s no easy way to parse what is already in there (since title and description are Strings).
  • Secondly, if it references Iterable data such as a List or Workflow, the name of that List or Workflow could change so the description might end up being something like “Your Old List Name list has been updated”.
  • Lastly, there’s no way of mapping multiple notifications to to the same event and just updating an occurrence count.

Version 2

notification Context

From the main Iterable app, we wanted to make it as easy as possible to notify the notification center service of anything new. So the interface looks something like this.

class IterableNotificationCenterService() {
  def notify(project: Project, notificationContext: NotificationContext): Try[Long]
}

We iterated on this and introduced a NotificationContext that is an interface that each type of notification will generate. Many of it’s functions take in the Notification parent with the metadata so we can dynamically generate things like the description based on all the data available to us.

case class Notification {
  id: Long
  projectId: Long
  title: String
  description: String
  notificationContext: NotificationContext
}
trait NotificationContext {
  def timestamp: DateTime
  def occurrenceCount: Int 
  def notificationCategory: NotificationCategory // Campaign related...etc.
  def notificationLevel: NotificationLevel // succcess, info, warning, error
  def notificationType: NotificationType
  def title(owner: Notification[_]): String
  def description(owner: Notification[_]): String
  def actionOpt(owner: Notification[_]): Option[Call] = None
  def update(oldContext: NotificationContext): NotificationContext = this
  def fingerprint(owner: Notification[_]): String
}

Fingerprinting and updating

One key is the introduction of fingerprinting our notifications so we know what “maps” to the same notification. In other words, we decide on the granularity of each notification. For instance, let’s say we have many workflow webhook errors going on, and each webook has a different URL but they are all part of the same workflow.

Now we can do something like:

case class WorkflowWebhookNotificationContext extends NotificationContext(
  workflowId: Long,
  webhookErrors: Map[WorkflowCampaignId, ...],
  workflowName: String
) {
  override def fingerprint(owern: Notification[_]): String = generateFingerprint(
    NotificationFingerprintKeys.ProjectId -> owner.projectId,
    NotificationFingerprintKeys.WorkflowId -> workflowId
  )
  
  override def update(oldContext: NotificationContext): WorkflowWebhookNotificationContext = {
    val oldWorkflowWebhookNotificationContext = oldContext.asInstanceOf[WorkflowWebhookNotificationContext]
    this.copy(
      workflowCampaignFailures = webhookErrors |+|
        oldWorkflowWebhookNotificationContext.webhookErrors
    )
  }
}

which would generate a notification per workflow, per project. As seen from the code above, it knows how to update itself so that when the fingerprints match, we know that we are updating an existing notification and not creating a new one, and the NotificationContext knows how to update itself.

Dynamic Rendering of description and title

Among other fields, the description and title are now functions which means we have separated the persistence of data the notification needs, from the transformation of that into a human readable string. The only downside is that we need to store things like the workflow name.

The other downside is that perhaps the user might delete some of the WorkflowCampaignIds we reference, so the resulting notification can be invalid in the sense in that it describes or references deleted / non-existent data.

Latest Version

But we still faced the issue of how we would be able to make sure that any references to Iterable data (which could change) was up to date. Referenced names and counts of Lists and such could change so we needed a way to dynamically retrieve that data and couldn’t store it just as Strings.

The solution was to introduce a rendering step. So upon getNotifications(), we would get the notifications from the database, then render them before returning them to the front-end.

Iterable Dependencies

We introduce an explicit dependencies field where we note what data we need to fetch from Iterable.

trait NotificationContext {
  ...
  def dependencies: IterableNotificationDependencies
}

We also add in a rendering service class that does the actual rendering. In short, it fetches the dependencies needed for any notification. If the dependencies cannot be fetched or is found to be deleted, we mark that notification as invalid and delete it. For instance, a notification may reference a campaign that was deleted, so that notification should be deleted. We want our notifications to not only be dynamic in being able to update itself, but accurately reflect the state of Iterable data as well.

trait NotificationRenderingService {
  def render(notification: NotificationWithContext[NotificationContext]): Future[Try[RenderedNotification]]
}

Accurate dynamic rendering

Now, the data we need to persist for capturing the statistics of a notification is completely separate from our decision in how to render that. Now in our WorkflowWebhookNotificationContext we can refer to the latest workflow name and other properties based on the fetched dependencies.

case class WorkflowWebhookNotificationContext extends NotificationContext(
  workflowId: Long,
  webhookErrors: Map[WorkflowCampaignId, ...]
) {
  ...
  override def dependencies: IterableNotificationDependencies = IterableNotificationDependencies(
    workflowCampaignIds = workflowCampaignFailures.keys.toSet,
    workflowWebhookIds = Set(workflowWebhookId)
  )
  
  override def description(
    owner: NotificationWithContext[_],
    fetchedIdDependencies: IterableNotificationDependenciesResolved
  ): String = {
    val workflowWebhook = fetchedIdDependencies.workflowWebhooks.head
    val workflowCampaigns = fetchedIdDependencies.workflowCampaigns

    val workflowWebhookText = anchorLink(
      routes.WorkflowsController.manageWebhooks().url,
      s"${workflowWebhook.name} [${workflowWebhook.id.get}]"
    )
    ...
    descriptionBuilder.toString
  } 
}

Other considerations

We knew that this design was going to lead to a high write, low read load on our Postgres database. Upon IterableNotificationCenterService.notify(), many of those calls may map to the same stored notification (same fingerprint) so we could have dirty reads or lost writes.

There are other optimizations we could make in the future, but at the time we decided it was best to have less captured notifications than to allow for invalid notifications (lost writes, where the data in the notification isn’t consistent) so we used a transaction level of repeatable reads for all update notification calls.

The product altogether

There are other features of the notification center as well (such as being able to change the notificationLevel from warning to errors based on custom thresholds…etc.) but at a high-level, we hope this shows how we went from generating static, string based notifications to dynamic, actionable notifications that help the user gain visibility and control as well as make it easy for engineers to add new notification classes or modify existing ones.