Cross session personalisation with EPiServer

Posted by Jeroen Wijdeven on 2015-11-18

With the standard visitor group criteria cross session personalisation is not standard.
However when a user already has visited your website you know him or her from a previous visit and don’t want to discover who he or she
is the second time, you only like to know him better. In the real world the customer don’t need to introduce him self the second time.
We like to reach the same goal with the visitors on our website.

Session based

The standard visitor criteria for “Viewed pages” is session based. In this article we are going to make this a cross session
experience. We are going to create an experience where we base personlisation based on the last / previous visits.
This is farely easy to do in EpiServer by turing the standard criterion into a cross session equivalent.

Cross session criteria

Visitorgroups are based on criterion. To introduce cross session behaviour we created a XCriterionBase class. This class saves an “Anonymous”UserId into a cookie.
When the user turns back the next time, we know this user from previous visits, based on this UserId. Each cross session criteria
we are going to make relies on this base class XCriterionBase. Let’s explain the code step by step.

1. Handle the start of each session

Handle the start of each session and make sure you get an already existing cookie value, or to create a new one, when it doesn’t
already exist. When it doesn’t exist we write a new Guid to it, which will be used for this user, until it expire.
note: The GetCookie function can be used for saving other cookie related information as well.

public override void Subscribe(ICriterionEvents criterionEvents)
{
    criterionEvents.StartSession += Handle_StartSession;
}

private static void Handle_StartSession(object sender, CriterionEventArgs e)
{
    //Set the cookie but do not override the existing value.
    GetCookie(e.HttpContext, AnonymousUserCookieKey, Guid.NewGuid().ToString(), false);
}

protected static HttpCookie GetCookie(HttpContextBase httpContext, string cookieName, string cookieValue=null, bool overrideExistingValue=false)
{
    var cookie = httpContext.Request.Cookies[cookieName];

    if (cookie == null)
    {
        cookie = new HttpCookie(cookieName)
        {
            Value = cookieValue,
            Expires = CookieExpires
        };
        httpContext.Response.Cookies.Add(cookie);
        httpContext.Request.Cookies.Add(cookie);
    }
    else //update exsiting
    {
        if(overrideExistingValue) cookie.Value = cookieValue;
        cookie.Expires = CookieExpires;
        httpContext.Response.Cookies.Add(cookie);
        httpContext.Request.Cookies.Set(cookie);
    }

    return cookie;
}

2. Identify the user

By using the AnonymousUserId property you can get all user information any time you want.

protected Guid AnonymousUserId
{
    get
    {
        return Guid.Parse(GetCookie(new HttpContextWrapper(HttpContext.Current), AnonymousUserCookieKey, Guid.NewGuid().ToString(), false).Value);
    }
}

3. Unsubscribe

Don’t forget to unsubscribe the session start.

public override void Unsubscribe(ICriterionEvents criterionEvents)
{
    criterionEvents.StartSession -= Handle_StartSession;
}

Data repository.

Data for the “Anonymous”User is saved into a database using a repository. This allows you to save this stuff into a custom
“Experience” database or just use the database you already use for the EPiServer CMS, it’s up to you.

1. The repository

The repository is based on an interface. We can create custom implementations based on it.

public interface IXCriterionRepository
{
    XCriterionModel GetXCriterion(Guid userId, string key);
    void UpdateXCriterion(XCriterionModel criterion);
    void Clean(int expirationDays);
}

2. Register the implementation

Be sure you register the implementation of you repository at startup. This can be done with an attribute or by adding it to the
container at startup in a InitializationModule.

x.For<IXCriterionRepository>().Use<XCriterionRepository>();

3. Implement the repository

Because the DDS in EPiServer can be a little slow we use
Insight.Database for super fast database access / query execution.
More important is it also allows you to execute sql queries async with ease. We like to have this because
we are going to update the table during each request, but don’t want to wait for it, see the UpdateXCriterion function for an example.
The XCriterionModel.Data property contains JSON, so you can save basically anything you want.

public class XCriterionRepository : BaseRepository, IXCriterionRepository
{
    public XCriterionModel GetXCriterion(Guid userId, string key)
    {
        XCriterionModel result = null;
        var results = Connection.QuerySql<XCriterionModel>(
            "SELECT * FROM XCriterionModel WHERE AnonymousUserId = @AnonymousUserId AND SessionKey = @SessionKey",
            new { AnonymousUserId = userId, SessionKey = key });

        if (!results.Any())
        {
            var newObj = new XCriterionModel()
                {
                    AnonymousUserId = userId,
                    SessionKey = key,
                    Data = string.Empty,
                    Modified = DateTime.Now
                };

            try
            {

                Connection.ExecuteSql(
                    "INSERT INTO XCriterionModel SELECT AnonymousUserId, SessionKey, Data, Modified FROM @Criterion",
                    new {Criterion = new List<XCriterionModel> {newObj}});
                result = newObj;
            }
            catch (Exception ex)
            {
                Log.Error(ex);
                throw ex;
            }
        }
        else
        {
            result = results.FirstOrDefault();
        }

        return result;
    }

    public void UpdateXCriterion(XCriterionModel criterion)
    {
        Connection.ExecuteSqlAsync("UPDATE XCriterionModel SET Data = @Data, Modified = @Modified WHERE AnonymousUserId = @AnonymousUserId AND SessionKey = @SessionKey", criterion);
    }

    public void Clean(int expirationDays)
    {

        Connection.ExecuteSql(
            "DELETE FROM XCriterionModel WHERE Modified < DATEADD(DAY, DATEDIFF(DAY, 0, GETDATE()), @NumberOfDays)",
            new {NumberOfDays = 0 - expirationDays});
    }
}

4. Ensure the database

Make sure the database is setup correctly. The EnsureDatabase function is executed during initiaizing the repository and is required
by the base repository we are using.

public override void EnsureDatabase()
{
    Connection.ExecuteSql(
        "IF NOT exists(SELECT 1 FROM sys.Tables WHERE Name = N'XCriterionModel' AND Type = N'U')" +
        "BEGIN" +
        "   CREATE TYPE XCriterionModelTable AS TABLE (AnonymousUserId [uniqueidentifier], SessionKey [nvarchar](50), Data [nvarchar](max), Modified [date]); " +
        "   CREATE TABLE XCriterionModel (" +
        "    AnonymousUserId uniqueidentifier," +
        "    SessionKey nvarchar(50)," +
        "    Data nvarchar(max)," +
        "    Modified date" +
        "    primary key (AnonymousUserId, SessionKey)" +
        "  );" +
        "END");
}

The final implementation

Finally you can implement your custom visitor group criterion. This criterion is using the IXCriterionRepository to save the data to
where ever you want and relies on the XCriterionBase class to identify the user. In the example code I show you an example of the cross session
implementation we made for the ViewedPages criterion.

1. The sessionkey

Each implementation needs a unique sessionkey. Together with the AnonymousUserId this makes unique combination in the database.

protected override string SessionKey
{
    get
    {
        return "EPi:ViewedPagesX";
    }
}

2. Add viewed pages

Register the events to handle the start of each session. We initialize the VisitedPage event.
Each time a page is visited this event occurs and we add the viewedpage to a hashset in the session
and notice the repository to update the database with the new situation as well.

public override void Subscribe(ICriterionEvents criterionEvents)
{
    base.Subscribe(criterionEvents);
    criterionEvents.VisitedPage += VisitedPage;
}

private void VisitedPage(object sender, CriterionEventArgs e)
{
    var pageLink = e.GetPageLink();
    if (PageReference.IsNullOrEmpty(pageLink))
        return;

    AddViewedPage(e.HttpContext, pageLink);
}

private void AddViewedPage(HttpContextBase httpContext, PageReference pageLink)
{
    var hashSet = GetViewedPages(httpContext);
    if (hashSet == null)
    {
        hashSet = new HashSet<PageReference>();
        if (httpContext.Session != null)
            httpContext.Session[SessionKey] = hashSet;
    }

    if (!hashSet.Contains(pageLink))
        hashSet.Add(pageLink); //add to current set when not exists

    //always make sure you update the database.
    Repository.UpdateXCriterion(
        new XCriterionModel() {
            AnonymousUserId = AnonymousUserId,
            SessionKey = SessionKey,
            Data = JsonConvert.SerializeObject(hashSet), //save as json
            Modified = DateTime.Now
        });
}

3. Get previously viewed pages

To be sure we don’t need the database for every request we get all viewed pages from the repository during the first request
and put everything into the session. After this the implementation works more or less the same as you are used to with the standard
ViewedPages criterion, with the exception that we update the database after every request for cross session purpose (see previous paragraph).

private HashSet<PageReference> GetViewedPages(HttpContextBase httpContext)
{
    if (httpContext.Session == null)
        return null;

    var hashSet = httpContext.Session[SessionKey] as HashSet<PageReference>;
    if (hashSet == null)
    {
        var criterion = Repository.GetXCriterion(AnonymousUserId, SessionKey);

        if (criterion.Data.ValidateJSON())
        {
            hashSet = JsonConvert.DeserializeObject<HashSet<PageReference>>(criterion.Data);
            httpContext.Session[SessionKey] = hashSet;
        }
    }

    return hashSet;
}

4. Unsubscribe

And finally do’nt forget to unsubscribe.

public override void Unsubscribe(ICriterionEvents criterionEvents)
{
    base.Unsubscribe(criterionEvents);
    criterionEvents.VisitedPage -= VisitedPage;
}

5. Clean up entities

Because you have a cookie expiration users can get new AnonymousUserIds from time to time. It’s also likely you like to cleanup
database entities for several other reasons. Don’t forget to create an EpiServer job which can execute the repository’s cleanup
function from time to time. The function removes every entity not modified since a particular date. Ofcourse there are also many reasons
you want to keep the data for big data analysis, but that’s up to you.

Repository.Clean(90) //when you like to delete everything that's not modified for 90 days.

Finally

Again EpiServer has the right tools to extend the system in a way you like. This article hopefully helps you out by creating
your own cross session visitor group criterion. I also put together all code in one big gist. Feel free to
use it the way you want and create some beautiful customer experiences.


Comments: