Push Notification Analytics

Wed Jan 08, 2020


Any time I have to add push notifications to a project I get both excited and scared all at once. I’m excited because when I get them working it’s still one of the things that seems like witchcraft to me. However, they scare me because I know that their looming implementation is always fraught with more difficulty than first expected, even if you take that into account when making your estimates for the work!

This can also be described as Hofstadter’s Law: It always takes longer than you expect, even when you take into account Hofstadter’s Law.

Once we manage to link up with the necessary back end people to get push notifications up and running, we breath a sigh of relief. We did it. It was harder than we thought it was going to be, but we got there. However, just as we’re lured into this false sense of security, we’re hit with another hammer blow - analytics.

The problem

If you work in any modern company, which I’ll assume you do based on the fact that you’re reading an article about iOS development, you’ll more than likely have someone, or some team, in your office that craves analytics. They love it. It’s their bread and butter. And the thorn in your side. Usually when you come to the end of a feature, you receive one more task to add in analytics for everything that you’ve built. Most of the time it’s not so bad. A page view here, a click event there, and you’re done. However, with push notifications we run into slightly more difficulty.

The common points of data that any marketing or product team are interested around push notifications are:

  1. Number of notifications sent
  2. Number of notifications received
  3. Number of times the app was launched from tapping a notification
  4. Number tapped while the app is in the foreground

The first item here is fine. Hey, it mightn’t even be your job to track notification sent! Just get the back end team to do when they fire it off into the ether. The last two are also fine. We can handle those with our standard push notification functions that we get out of the box with iOS. But the real difficulty lies in tracking the number of notification received. Because our app mightn’t be running when we receive a push notification, there’s no guarantee that any of our code will execute when it’s received.

This is the problem that this article is going to try and solve. What this article won’t talk about is how to get push notifications working, for that I’d recommend the Ray Wenderlich push notifications tutorial.

Show me extensions!

Hitting File > New > Target in any iOS app presents a treasure trove of new possibilities. If you haven’t explored all the different types of app extensions that are available, I’d implore you do so. Apple’s own documentation on them should whet your appetite. We’re only going to talk about one type of extension today, and that’s the Notification Service Extension.

The Notification Service Extension is, in Apple’s own words, “launched on demand when a notification of the appropriate type is delivered to the user’s device”. This means that when a notification is received by the device that matches our bundle ID, our service extension will execute. This now gives us an opportunity to call whatever analytics monster we’re required to when receive a push notification.

Walkthrough

Within Xcode, let’s add a new target through File > New > Target and we’ll select Notification Service Extension. We have to give it a name, and then we’ll see a prompt to active a new scheme which we do. Now, we’re launched into a new file called NotificationService where we find two functions. The first is didReceive(_:withContentHandler:) where we’ll be doing most of our work, and the second is serviceExtensionTimeWillExpire() which we won’t use in this walkthrough, but its functionality will be explained in due course.

It’s about now that I should say this isn’t the advertised use of this app extension. Apple’s doc say that this is an opportunity for you to modify the content of the notification before it’s delivered to the user, but we’re not going to modify it at all, we’re just using this timing as a prompt to run some other code.

When we created the extension and got thrown into the NotificationService class we can see that there’s some code already generated for us in the didRecieve(_:withContentHandler:) function. It should look like this a the start:

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    // 1
    self.contentHandler = contentHandler 
    // 2
    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
    
    if let bestAttemptContent = bestAttemptContent {
        // Modify the notification content here…
        bestAttemptContent.title = “\(bestAttemptContent.title) [modified]
        
        contentHandler(bestAttemptContent)
    }
}
  1. The contentHandler that we assign to is actually a property of our class that is defined outside the function at the top of the file. This contentHandler is the closure that pass our notification back to the system once we’ve made any changes to it. This means that we have to call it before we exit this function.
  2. The bestAttemptContent is a property that acts as an exact copy of the notification that was initially received. As this extension only has a certain amount of time to execute, the bestAttemptContent is a fallback if we run out of time. We can see how this is used in the serviceExtensionTimeWillExpire() function.

 

override func serviceExtensionTimeWillExpire() {
    // Called just before the extension will be terminated by the system.
    // Use this as an opportunity to deliver your “best attempt” at modified content, otherwise the original push payload will be used.
    if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
        contentHandler(bestAttemptContent)
    }
}

The comments say it all. If we run out of time we pass our bestAttemptContent back to the system through the contentHandler closure.

Here’s where I’m going to simplify things by making one main assumption. Analytics events in most iOS apps are usually fire and forget. And that’s what we’ll be working with here. We’re just going to make a call to our analytics service to say that we’ve received the notification. This call will be synchronous, meaning that we won’t have to wait for anything to complete. With this assumption in mind, we can make some changes to our NotificationService class so that it looks like this:

class NotificationService: UNNotificationServiceExtension {

    override func didReceive(_ request: UNNotificationRequest,
                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        contentHandler(request.content)
    }    

}

You can see we’ve deleted the serviceExtensionTimeWillExpire(), in practice this probably isn’t the greatest idea, but bear with me, I’m trying to simplify things 😬. As well as that we’ve also slimmed down the didReceive(_:withContentHandler:) function. Now it really doesn’t do much. It’s just intercepting the notification and passing it on to the system without doing anything else. And this is where we add our analytics code. The function will simply update to something like:

override func didReceive(_ request: UNNotificationRequest,
                         withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    AnalyticsManager.trackEvent(.notificationRecieved)
    contentHandler(request.content)
}    

And now we’re tracking the receipt of notifications regardless of the running state of our app. So the analytics event will fire if we receive a notification when we’re running the app, when it’s in the background, and even if it’s been killed.

Wrap up & caveats

Ok so really it’s probably not best to delete the serviceExtensionTimeWillExpire() function. If we’re using a third party analytics tool, we don’t always know what’s going on under the hood and there could be something that slows execution time, so it’s a good idea to keep the function there to ensure that the notification gets presented to the user. The other caveat that people sometimes forget when working with extensions is that your analytics framework may not be visible to the Notification Service Extension, so you may need to do some work to ensure that any required analytics functions can be called from the extension.

Although this is a somewhat self stated misuse of this extension, it is a very handy one. It’s also very easy and quick to implement and should make you very popular with anyone that was looking for this valuable analytics data!


Spot something wrong? Let me know on Twitter


back · twitter · github · who is matt? · writing · projects · home