Saturday, August 4, 2012

Analytics are an aspect

On a recent project Jesse Kallhoff, a fellow nerd from The Nerdery, and I were tasked with integrating an existing analytics solution with our extensions to the client’s ASP.NET MVC web site. As it’s a commerce site, the customer’s tracking requirements are extensive.  Each page may have unique tracking requirements depending on a variety of conditions, both based on logged in state and query parameters.  Moreover, for browse and search pages, there is a tight coupling between the backend search technology and the query parameters supplied as part of the request we are tracking.

Much of the existing code incorporated a series of helpers within each action method, cluttering up the action method with code that wasn’t directly related to the view that was being rendered, but rather was related to the tracking parameters that were going to used by a script on the page to post back to the tracking solution provider.

I was initially tasked with the goal of investigating how we would incorporate analytics into the new pages that we were introducing. I wasn’t familiar with the product the customer had chosen so I dove into the existing code to see how it worked.  A familiar pattern appeared.
  1. Load up some default parameters into the analytics object based on the action being invoked.
  2. Set/change some parameters based on the search/browse/product context.
  3. Set some additional parameters based on the users logged in state.
  4. Dump some scripts on the page, including one script section that defined the parameter and their values that were to be posted back to the tracking provider’s web site via a script request.
Each action method in the solution repeated this pattern in one form or another, though complicated by some AJAX requests to local actions to retrieve the analytics values stored in local session state once the page was rendered.

I scoped out an an initial solution that would remove the analytics code out of the actions themselves and use action filters to provide, as much as possible, the values required.  This was relatively easy to do for items (1) and (3).  The filter defined a key that was used to access configuration data to find the default parameters for that action.

NOTE**: the code below is an approximation using the ideas we came up with.  Unfortunately I can’t share the actual code with you.

[Analytics(Key="Browse")]
public ActionResult Browse( string browseContext, ... )
{
...
}

In the OnActionExecuting method of the filter we use the key to extract the default values associated with the key.  We can also check the logged in state and set those parameters as well.

public void OnActionExecuting( ActionExecutingContext filterContext )
{
    if (!filterContext.Canceled && !filterContext.IsChildAction)
    {
        if (filterContext.Result is ViewResultBase) // we only care if we're delivering HTML
        {
            // retreive the base analytics object from the configuration
            var analytics = _analyticsConfiguration.Get(this.Key);
            if (analytics != null) // if we're doing analytics for this page
            {
                ... fill in user-related parameters from the current session/user
HttpContext.Items[“Analytics”] = analytics;
            }
        }
    }
}

The difficulty came with (2).  It seemed like we would still need to include some code in each action that would be able fill in the request specific context information in the analytics object.  To support this, we put the partially filled object into HttpContext.Items where the action could retrieve it as needed to add the parameter-related data.

To address (4), we used the OnActionExecuted method to put the analytics object in the ViewBag so that our master page could render the scripts on the page without using any further AJAX calls.

public void OnActionExecuted(ActionExecutedContext filterContext)
{
    if (!filterContext.Canceled && !filterContext.IsChildAction)
    {
        var result = filterContext.Result as ViewResultBase;
        if (result != null)
        {
            result.ViewBag.Analytics = HttpContext.Items["Analytics"];
        }
    }
}

And in the master view:

@if (ViewBag.Analytics != null)
{
    @Html.AnalyticsAPIScript()
    @Html.AnalyticsDataScript(ViewBag.Analytics)
    ... invoke analytics api ...
}

This is basically the architecture I had in place on my initial pass. At that point I wasn't familiar enough with the project to know how to address the browse/search/product details.  Before I could develop it further other aspects of the project rose in priority and I moved on to something else. Neither Jesse or I was particularly happy that we still had some analytics code that would be needed in each action, but it was difficult to see how we could avoid without duplicating code from the attribute in the action.

A few weeks later, Jesse took on the completion of the analytics task.  This is when one of those cool moments that I’ve come to regularly expect at The Nerdery happened. We were on site at the customer’s office and one evening I get a call from Jesse – he wants to brainstorm on how we were going to address a couple of different aspects of the project, including this one.  We spent a couple of hours working through the issues, bouncing ideas off each other back and forth. It was a pretty intense and enjoyable couple of hours, the kind of collaboration that I’ve come to expect from my awesome colleagues.

What we eventually hit on was using attribute inheritance in correspondence with our view model so that we would basically have a different analytics attribute for each base model type. This attribute would use the view model data available in OnActionExecuted to complete the population of the analytics data.

The convention is that you only apply the attribute to an action whose model is compatible with the model addressed by the attribute.  The attribute inherits the base behavior described above, but then uses its unique knowledge of the view model type to extract the rest of the data.  This allows us to put the code that would have been in the action into the attribute instead keeping our action code clean and localizing the analytics code within a few attributes.

Another approach, if the number of models you need to support is large, might use a factory within a common attribute to find and execute a model-specific strategy within OnActionExecuted. For us the number of models is low and inheritance was simpler and easier to comprehend.

There were a number of other problems specific to the customer’s domain that Jesse had to work through and our final solution differs in some respects from what I’ve described, but using attributes to attack analytics seems like a winning choice. Our code is cleaner and more focused, easier to understand, and much more extensible.