PicNet Custom Software Development

.Net and Javascript Development

Split Testing (A/B Testing) in ASP.Net Mvc

Posted on clock May 11, 2010 10:21 by author guido

Working on our website heat maps product, Mouse Eye Tracking, has allowed us to really get into some of the more lean and super agile approaches to developing software.  Something that we have loved doing recently is Split Testing (or A/B Testing). Its really amazing how much time you can save when using techniques like this.

Basically for our heat map product we try every deployment out before investing huge amount of time into it.  For example.  We wanted to see if the features video could be made more prominent.  So what we did is created a page for this approach, published it and compared results.  We realised that this in fact was a waste of time and left the page exactly as it was.

There are plenty of products out there that allow you to do AB testing but most of those are CMSs which is useless when your site is in a server side language so what we use is a custom ASP.Net Mvc solution that works a real treat.  It's this solution that I hope to describe in this post.

SplitABController

The brains of the whole operation is a new controller (descendant of System.Web.Mvc.Controller) that allows you to provide multiple views for each ViewResult.  This is kind of hard to explain so why not just show some code:

 

    using System;

    using System.Web;

    using System.Web.Mvc;

 

    public abstract class SplitABController : Controller

    {

        private static readonly Results results = new Results();

        private const string B_TEST_SUFFIX = "_B";

        private const string SPLIT_TEST_VIEW_COOKIE_NAME = "SPLIT_TEST_VIEW_COOKIE";

        private static ViewFilesCache cache;

 

        public static Results GetSplitTestResults() { return results; }

 

        protected over ride void OnActionExecuting(ActionExecutingContext filterContext)

        {

            AddSplitTestResultsToResultsMap(filterContext);

            base.OnActionExecuting(filterContext);

        }

 

        private void AddSplitTestResultsToResultsMap(ActionExecutingContext filterContext)

        {

// If last request was not for a split test view then just return

            if (Request.Cookies[SPLIT_TEST_VIEW_COOKIE_NAME] == null) return;

 

// Add this controller/action to the results of the split test

            ResultRow rr = ResultRow.FromString(Request.Cookies[SPLIT_TEST_VIEW_COOKIE_NAME].Value);

            results.RemoveOneFromResults(rr);

            rr.ToControllerName = filterContext.ActionDescriptor.ControllerDescriptor.ControllerName;

            rr.ToAction = filterContext.ActionDescriptor.ActionName;

            results.AddOneToResults(rr);                        

        }                

 

/// <summary>

/// If the view has a _B counterpart then we mark this action as a 'Split Test' we

/// then record the results of this split view in the next request (OnActionExecuting).

/// 

/// This method also determines if we should supply the A or B view depending on the

/// 'ShouldRequestUseBView' algorithm.

/// </summary>

        protected override ViewResult View(string viewName, string masterName, object model)

        {            

            if (String.IsNullOrEmpty(viewName)) { viewName = (string) RouteData.Values["action"]; }

            bool isSplitTestingView = IsSplitTestingView(masterName, viewName);            

// If this view does not have a _B counterpart then we mark this request as non split test (by

// removing the 'SPLIT_TEST_VIEW_COOKIE_NAME' cookie) and just send control to base.View

            if (!isSplitTestingView)

            {

                Response.Cookies.Remove(SPLIT_TEST_VIEW_COOKIE_NAME);

                return base.View(viewName, masterName, model);

            }               

// Wether to use the A or B view depending on the 'ShouldRequestUseBView' algorithm

            bool useb = ShouldRequestUseBView();

// Lets create a results row and store it in the cookie (SPLIT_TEST_VIEW_COOKIE_NAME). This will let the 

// next request (OnActionExecuting) know that we just hit a split test view.

            ResultRow rr = new ResultRow {FromController = this, FromAction = viewName, UsedBView = useb};

            Response.Cookies.Add(new HttpCookie(SPLIT_TEST_VIEW_COOKIE_NAME, rr.ToString()));

            results.AddOneToResults(rr);            

 

// Display the appropriate view (A or B)

            return useb 

                ? base.View(viewName + B_TEST_SUFFIX, masterName, model)

                : base.View(viewName, masterName, model);            

        }        

 

/// <summary>

/// Returns wether the specified view has a _B counterpart. 

/// </summary>

        private bool IsSplitTestingView(string masterName, string viewName)

        {

            if (cache == null) { lock (GetType()) { if (cache == null) { cache = new ViewFilesCache(B_TEST_SUFFIX); } } }           

            return cache.HasSplitTestingAlternative(this, masterName, viewName);            

        }

 

/// <summary>

/// If odd IP then use 'B' view.  This will give a ~50% A / B split.

/// </summary>

        private bool ShouldRequestUseBView()

        {            

            return Int32.Parse(Request.UserHostAddress.Substring(Request.UserHostAddress.LastIndexOf('.') + 1)) % 2 == 1;

        }

    }

 

Description

So what is this code doing, basically it checks if a view has a '_B' counterpart, i.e.: If there is an Index.spark and an Index_B.spark.  If the view does have a _B counterpart then we mark the request as a split test and on the next request we save the results of that test.

To use this code simply extend this controller rather than the standard System.Web.Mvc.Controller.

Download

I have created a very simple test project (which uses Spark View Engine) that you can download here.  

Once you get the project set up simply navigation to Home.mvc/Index (which has a '_B' view also) and you can click around there for a while.  You can then navigation to Home.mvc/SplitTestResults to see a sample of how the results are stored.

Disclosure

This code was ripped quite aggressively from a much more comprehensive library and is intended only to illustrate the technique described here.  I highly suggest you do not use the code in production until you are happy with its stability.

Potential

I have been using this technique now for 2 months and have found it a fantastic way to measure true user acceptance of new features.  I also know that there is no other open source solution for asp.net mvc that allows you to do split testing efficiently so if you would like to work with me on getting this code production ready as an open source project let me know and I'll be more than happy to spend a bit more time making this code a bit more robust and creating a project for it,

Thanks

Guido Tapia

PicNet Pty Ltd


PicNet Mouse Eye Tracking Service Limited Release

Posted on clock January 27, 2010 06:43 by author guido

You may have noticed that I have been a bit quiet over the last few weeks, this is because we have been working very hard to release a new product and it gives me great pleasure to announce that today we have flicked on the switch.

The product is called the Mouse Eye Tracking service. It is basically a web analytics product that records your visitors mouse interactions with your web site. You then use our product to analyse these interations and get value from them. You can think of it as a more visual 'Google analytics' (however the products are very different and supplement each other nicely).

Features
Heat Maps
Check out where our visitors are viewing our home page:


Or where they are 'clicking'


User Trackings
You can also just replay the user interactions on your site. Either one at a time or all at once:


Page Navigation
You can also visualize how your visitors are navigating around on your site:


Technology
One of the reasons I'm so proud of this product is because of the number of technical hurdles that had to be overcome to get a successful solution. I will be blogging about these in detail over the next few weeks in the hope that someone can learn from our trials and tribulations. But some of the issues we had to address were:

  • Capacity of a high volume, highly data driven application
  • Speed of visualisation generation on javascript
  • Threading in javascript
  • Best tools for large development team in a javascript project
  • Large javascript application programming practices
  • Html 5
  • IE Issues (Which we have deferred for now)
  • Unit testing in javascript (and continous testing) + multiple browsers


Limited Release
As mentioned above, this is a limited release. So no media releases yet, we are taking it slowly just to make sure we have nailed down all the capacity issues. But this is too exciting not to at least blog about it so feel free to subscribe and give it a try.
Please send me any feedback you may have, either through comments below, email or just use the built in feedback submission form in the system.

Links

Thanks All

Guido Tapia

Manager - Custom Software Development

PicNet Pty Ltd


Rendering a Spark Partial View to a string

Posted on clock September 25, 2009 09:56 by author guido

I needed to do this recently so I searched the interweb and quickly found this great article by Brent Edwards:

http://blog.edwardsdigital.com/post/Rendering-a-Spark-partial-view-to-a-string-or-JSONP-with-ASPNET-MVC.aspx

Now don't get me wrong, this works and is explained very well but I don't see the point in re-creating the ViewEngine? So I cleaned this up and ended up with:

public static string GetPartialViewHtml(ViewDataDictionary viewData, string viewRalativePath) {
  SparkViewFactory f = (SparkViewFactory) ViewEngines.Engines.First(e => e is SparkViewFactory);           
  SparkView view = (SparkView) f.Engine.CreateInstance(f.CreateDescriptor(null, null, viewRalativePath, null, false));
  view.ViewData = viewData;      
  StringWriter writer = new StringWriter();  
  view.RenderView(writer);  
  return writer.ToString();

 

Done, now for anyone that has tried to do this with ASP.Net MVC View engine, I pity you.

Thanks

Guido Tapia

Manager - Custom Software Development

PicNet Pty Ltd


Sharing MVC Views Across Projects

Posted on clock August 12, 2009 04:25 by author guido

This is something I have been wanting to do for a while in the ASP.Net world however its not untill recently (with MVC) that this has been a 'clean' possibility. This article demonstrates how to do this.

Inspiration

At PicNet are always trying to deliver quality products at the cheapest possible cost to the user.  The way we do is is by having 'template' driven projects. This allows us to generate data access layers very quickly (see previous articles) and we also copy template web projects, windows projects, mobile projects that give us a good starting point.  We also levarage custom libraries heavily.  However we could never put any of the view code (aspx, ascx) in these libraries as it was just not clean.

Embedded Views

To store your shared views in a library project simply copy the view code (aspx, ascx, master) into your library project.  Please ensure that the view code compiles.  By this I mean that all references are still valid (without requiring Web.config namespaces).  Tools like Resharper will show wether the view is valid or not.  Then mark the view as an 'Embedded Resource'.  To do this just right click on the file name -> Properties and set the Buidl Action to 'Embedded Resource'.  You will then need to rebuild the project to embed the resource in the DLL.

 

The View Engine

Add the following View Engine to your library project.

 

/// <summary>

/// This class will read all embedded views in THIS dll and will dumpo them out to the

/// ~/tmp/Views directory.

/// </summary>

public class EmbeddedResourceViewEngine : WebFormViewEngine

{

    public EmbeddedResourceViewEngine() {            

        MasterLocationFormats = new[] {

            "~/Views/{1}/{0}.master",

            "~/Views/Shared/{0}.master",

            "~/tmp/Views/{0}.master"

        };

 

        ViewLocationFormats = new[] {

            "~/Views/{1}/{0}.aspx",

            "~/Views/{1}/{0}.ascx",

            "~/Views/Shared/{0}.aspx",

            "~/Views/Shared/{0}.ascx",

            "~/tmp/Views/{0}.aspx",

            "~/tmp/Views/{0}.ascx"

        };

        PartialViewLocationFormats = ViewLocationFormats;

 

        DumpOutViews();

    }

 

    private static void DumpOutViews()

    {

        IEnumerable<string> resources = typeof (EmbeddedResourceViewEngine).Assembly.GetManifestResourceNames().Where(name => name.EndsWith(".master") || name.EndsWith(".aspx") || name.EndsWith(".ascx"));

        foreach (string res in resources) { DumpOutView(res); }                

    }

 

    private static void DumpOutView(string res)

    {

        string rootPath = HttpContext.Current.Server.MapPath("~/tmp/Views/");

        if (!Directory.Exists(rootPath)) {

            Directory.CreateDirectory(rootPath);

        }

 

        Stream resStream = typeof (EmbeddedResourceViewEngine).Assembly.GetManifestResourceStream(res);

        int lastSeparatorIdx = res.LastIndexOf('.');

        string extension = res.Substring(lastSeparatorIdx + 1);

        res = res.Substring(0, lastSeparatorIdx);

        lastSeparatorIdx = res.LastIndexOf('.');

        string fileName = res.Substring(lastSeparatorIdx + 1);

 

        FileUtils.WriteFileContents(rootPath + fileName + "." + extension, resStream);

    }

}

 

 

This view engine simply gets the views that have been stored as embedded resources and dumps them out to the ~/tmp/Views directory.

 

Register the View Engine

In Global.asax.cs just add:

 

public static void RegisterCustomViewEngines(ViewEngineCollection viewEngines)  

{  

  viewEngines.Clear();  

  viewEngines.Add(new EmbeddedResourceViewEngine());  

}  

...

protected void Application_Start(object sender, EventArgs e)

{

  RegisterRoutes(RouteTable.Routes);

  RegisterCustomViewEngines(ViewEngines.Engines);

}

 

That's It

That's it all the embedded views now simply behave like shared views.

 

 

Guido Tapia

Manager - Custom Software Development

PicNet Pty Ltd