AndyButland / UmbracoPersonalisationGroups

Package for personalisation of content with Umbraco.
MIT License
38 stars 18 forks source link

Umbraco Personalisation Groups

What it does

Umbraco Personalisation Groups is an Umbraco package intended to allow personalisation of content to different groups of site visitors.

It supports Umbraco versions 7 and 8.

For Version 9, pleasee see this source code repository: https://github.com/AndyButland/UmbracoPersonalisationGroupsCore.

It can be downloaded and installed from: https://our.umbraco.org/projects/website-utilities/personalisation-groups

It contains a few different pieces:

Using the package

Installation

Firstly install the package in the usual way.

Once installed you'll find a few additional components:

There's also a NuGet installer if you prefer to use that:

PM> Install-Package UmbracoPersonalisationGroups

However this will only install the dll, not the document types and data types. As such I'd reccommend if you do want to use NuGet for ease of updates, do the following:

Example usage

Editing a group definition

Editing a specific criteria

Templating

Personalising repeated content - showing and hiding items in a list

A typical example would be to personalise a list of repeated content to only show items that are appropriate for the current site visitor. Here's how you might do that:

@foreach (var post in Model.Content.Children.Where(x => x.ShowToVisitor()))
{
    <h2>@post.Name</h2>
}

Personalising page content

With a little more work you can also personalise an individual page. One way to do this would be to create sub-nodes of a page of a new type called e.g. "Page Variation". This document type should contain all the fields common to the parent page that you might want to personalise - e.g. title, body text, image - and an instance of the "Personalisation group picker". You could then implement some logic on the parent page template to pull back the first of the sub-nodes that match the current site visitor. If one is found, you can display the content from that sub-node rather than what's defined for the page. And if not, display the default content for the page. Something like:

@{
    var personalisedContent = Model.Content.Children.Where(x => x.ShowToVisitor()).FirstOrDefault();
    string title, bodyText;
    if (personalisedContent != null) 
    {
        title = personalisedContent.Name;
        bodyText = personalisedContent.GetPropertyValue<string>("bodyText");
    }
    else 
    {
        title = Model.Content.Name;
        bodyText = Model.Content.GetPropertyValue<string>("bodyText");  
    }
}

<h1>@title</h1>
<p>@bodyText</p>

Instead of using sub-nodes for the personalised information, this could just as well be items of nested content, given they also implement IPublishedContent.

Personalising repeated content - ranking of items in a list

In addition to simply showing and hiding content, it's possible to rank a list of items to display them in order of relevence to the site visitor. This can be achieved using the Score field for each created personsalisation group that can be set to a value between 1 and 100. These can either be set to all the same value, or more important groups can be given a higher score.

The following code will then determine which groups are associated with each item of content in the list, sum up the scores of those that match the site visitor and order with the highest score first:

@{
    var personalisedContent = Model.Content.Children.OrderByDescending(x => x.ScoreForVisitor());
}

Matching groups by name

If you want to simply check if the current user matches one or more groups by their name, there are some extensions on the UmbracoHelper to support this. The following all return a boolean value:

@Umbraco.MatchesGroup("Weekday Visitors")
@Umbraco.MatchesAllGroups(new string[] { "Weekday Visitors", "Country match" })
@Umbraco.MatchesAnyGroup(new string[] { "Weekday Visitors", "Country match" })

Cookie regulations

Personalisation Groups requires the setting of cookies in the user's browser for certain functionality. In particular the criteria for "pages viewed" and "number of visits" rely on the user's behaviour being tracked in a cookie value.

On many websites a user will be asked if they want to accept cookies and be provided the option to opt-out of unncessary ones.

In order to ensure that the package will cease writing tracking cookies, you can either set a cookie with a key of personalisationGroupsCookiesDeclined or a session variable with a key of PersonalisationGroups_CookiesDeclined. If either of those are set, no further cookies will be written. Any cookies already set won't be deleted (that's left to the developer to action if required when the visitor declines cookies), but they will no longer be updated or new ones created.

The keys for this cookie and session can be amended in configuration if required via the keys personalisationGroups.cookieKeyForTrackingCookiesDeclined and personalisationGroups.sessionKeyForTrackingCookiesDeclined respectively (see "Configuration" below).

Configuration

No configuration is required if you are happy to accept the default behaviour of the package. The following optional keys can be added to your web.config appSettings though if required to amend this.

How it works

Personalisation group criteria (IPersonalisationGroupCriteria)

Group criteria all implement an interface IPersonalisationGroupCriteria which provides a few properties to identify and describe the criteria as well as a single method - MatchesVisitor().

Implementations of this interface must provide logic in this method for checking whether the current site visitor matches the definition provided using a JSON syntax supported by the criteria. So for example the DayOfWeekPersonalisationGroupCriteria expects a simple JSON array of day numbers - e.g. [1, 3, 5] - which is compared with the current day to determine a match.

PersonalisationGroupMatcher

PersonalisationGroupMatcher is a static class that when first instantiated will scan all loaded assemblies for implementations of the IPersonalisationGroupCriteria interface and store references to them. It's in this way the package will support extensions through the development of other criteria that may not be in the core package itself.

It then makes these criteria available to application logic that needs to create group definitions based on them and to check if a given definition matches the related criteria.

PersonalisationGroupDefinitionPropertyEditor

PersonalisationGroupDefinitionPropertyEditor defines an Umbraco property editor for the definition of the personalisation groups. It has a related angular view and controller, and also ensures the angular assets required for the specific criteria that are provided with the core package are loaded and available for use.

PersonalisationGroupDefinitionController

PersonalisationGroupDefinitionController is a server-side controller that provides logic and resources to the angular view and controller used for the property editor. It provides JSON end-points for the retrieval of the available criteria via HTTP requests. It also provides methods for the retrieval of the angular assets that are provided as embedded resources in the package dll (or any extension dlls). See http://www.nibble.be/?p=415 for more detail on how this technique is implemented.

Angular views and controllers

The primary view and controller for the property editor are editor.html and editor.controller.js respectively.

In addition to these, each criteria has it's own view and controller that provide a user friendly means of configuring the definitions, named definition.editor.html and definition.editor.controller.js which are loaded via a call to the Umbraco dialogService. All are provided as embedded resources.

Each criteria also has an angular service named definition.translator.js responsible for translating the JSON syntax into something more human readable. So again for example the DayOfWeekPersonalisationGroupCriteria will render "Sunday, Tuesday, Thursday" from [1, 3, 5].

PublishedContentExtensions / PublishedElementExtensions

PublishedContentExtensions defines the extension methods on IPublishedContent (PublishedElementExtensions and IPublishedElement are used when targetting Umbraco V8) named ShowToVisitor(bool showIfNoGroupsDefined = true) and ScoreForVisitor(bool showIfNoGroupsDefined = true). This implements the following logic:

There's also a related extension method on UmbracoHelper named ShowToVisitor(IEnumerable<int> groupIds, bool showIfNoGroupsDefined = true). Using this you can pass through a list of group Ids that may be drawn from another location than the current node.

Notes on particular criteria

Country and region

The country criteria uses the free GeoLite2 IP to country database made available by Maxmind.com. It will look for it in /App_Data/GeoLite2-Country.mmdb or at the path specified in the following appSetting:

<add key="personalisationGroups.geoLocationCountryDatabasePath" value="/my/custom/relative/path"/> 

Similarly the region criteria uses the city database available from the same link above. Similarly it will be read from the default location of /App_Data/GeoLite2-City.mmdb or at the path specified in the following appSetting:

<add key="personalisationGroups.geoLocationCityDatabasePath" value="/my/custom/relative/path"/> 

When it comes to selecting regions to match against, the list of regions available is provided by the package from a list provided by Maxmind. If you want to override this list, you can do so by taking a copy of this file, saving it to a relative path (likely in App_Data) and referencing it in configuration as follows. Doing this for example would allow you to override the region names from local language to English (we've found that in some cases, matches are more likely having done this).

<add key="personalisationGroups.geoLocationRegionListPath" value="/App_Data/regions.txt"/> 

If you are using a CDN, it's possible to use a feature that provides the user's geographical country location in a header such as that provided by Cloudflare. To use that method instead, add the following configuration:

<add key="personalisationGroups.countryCodeProvider" value="CdnHeader"/> 

By default the header CF-IPCountry is used. If another is required it can be configured with:

<add key="personalisationGroups.cdnCountryCodeHttpHeaderName" value="Some-Custom-Header"/> 

Pages viewed

In order to support personalising content to site visitors that have seen or not seen particular pages we need to track which pages they have viewed. This is implemented using a cookie named personalisationGroupsPagesViewed that will be written and amended on each page request. It has a default expiry of 90 days but you can amend this in configuration. The cookie expiry slides, so if the site is used again before it expires, the values stored remain.

If you don't want this cookie to be written, you can remove this criteria from the list available to select via configuration (see above). If you do that, the criteria can't be used and the page tracking behaviour will be switched off.

How to extend it

The idea moving forward is that not every criteria will necessarily be provided by the core package - it should be extensible by developers looking to implement something that might be quite specific to their application. This should be mostly straightforward. Due to the fact that the criteria that are made available come from a scan of all loaded assemblies, it should only be necessary to provide a dll with an implementation of IPersonalisationGroupCriteria and a unique Alias property, along with the definition editor angular view, controller and translation service - definition.editor.html, definition.editor.controller.js and definition.definition.translator.js respectively.

As well as the interface, there's a helper base class PersonalisationGroupCriteriaBase that you can inherit from that provides some useful methods for matching values and regular expressions. This isn't required though for the criteria to be recognised and used.

The C# files can sit anywhere of course. The client-side files should live in App_Plugins/UmbracoPersonalisationGroups/GetResourceForCriteria/<criteriaAlias.

As with other Umbraco packages, you'll also need to create a package.manifest file listing out the additional JavaScript files you need. It should live in App_Plugins/UmbracoPersonalisationGroups/ and look like this:

{
    javascript: [
        '~/App_Plugins/UmbracoPersonalisationGroups/GetResourceForCriteria/myAlias/definition.editor.controller.js',
        '~/App_Plugins/UmbracoPersonalisationGroups/GetResourceForCriteria/myAlias/definition.translator.js'
    ]    
}

Working with caching

Caching - at least at the page level - and personalisation don't really play nicely together. Such caching will normally be varied by the URL but with personalisation we are displaying different content to different users, so we don't want the cached version of a page customised to particular user being displayed to the next.

There are a couple of helper methods available within the package to help with this though.

Firstly there's an extension method associated with the Umbraco helper called GetPersonalisationGroupsHashForVisitor() that calculates a hash for the current visitor based on all the personalisation groups that apply to them. In other words, if you've created three groups, it will determine whether the user matches each of those three groups and create a string based on the result. It takes three parameters:

The last parameter is quite important - although not expensive, you likely don't want to calculate this value on every page request. However it equally shouldn't be cached for too long as visitor's status in each personalisation group may change as they use the website. For example a group targetting morning visitors would no longer match if the same visitor is still there in the afternoon.

With that method in available, it's possible to use it with output caching to ensure the cache varies by this set of matched personalisation groups, for example with a controller like this:

    public class TestPageController : RenderMvcController
    {
        [OutputCache(Duration = 600, VaryByParam = "*", VaryByCustom = "PersonalisationGroupsVisitorHash")]
        public override ActionResult Index(RenderModel model)
        {
            ...
        }

    }

And code in global.asax.cs as follows:

    public class Global : UmbracoApplication
    {
        private static readonly SessionStateSection SessionStateSection = (SessionStateSection)ConfigurationManager.GetSection("system.web/sessionState");

        public void Session_OnStart()
        {
            // Just set something to ensure a session is created
            Session[AppConstants.SessionKeys.PersonalisationGroupsEnsureSession] = 1;
        }

        public override string GetVaryByCustomString(HttpContext context, string custom)
        {
            if (custom.Equals("PersonalisationGroupsVisitorHash", StringComparison.OrdinalIgnoreCase))
            {
                var cookieName = SessionStateSection.CookieName;
                var sessionIdCookie = context.Request.Cookies[cookieName];
                if (sessionIdCookie != null)
                {
                    var umbracoHelper = new UmbracoHelper(UmbracoContext.Current);
                    var hash = umbracoHelper.GetPersonalisationGroupsHashForVisitor(1093,   // Would normally get the node Id from config
                        sessionIdCookie.Value, 
                        20);
                    return hash;
                }
            }

            return base.GetVaryByCustomString(context, custom);
        }
    }

Troubleshooting/known issues

Personalisation group data type not loading

If you run into a problem with the data type failing to load when running with debug="false", this is because it's necessary to whitelist the domains in use. See the forum post here along with links for discussion and resolution details. In summary though:

This has been resolved from version 0.1.11 for the criteria provided with the package, but there still looks to be a problem if you have created your own criteria using embedded resources as I've done so in the core package. And then, even the bundleDomains workaround doesn't help. So I believe it's necessary to avoid those and have the client-side files on disk as described in the section above.

Output cache being invalidated

In testing I've discovered that installing the package with default options will cause any output cache to be invalidated on every page request. Clearly personalisation with output caching is likely tricky anyway (as by defintion, the same cached page may need to be presented differently to different users), so unlikely to be something being used. If you do have a need for it though, it's necessary to disable any criteria that set cookies on each page request. It's this action that invalidates the cache.

To do this you can exclude such critieria with this configuration option:

<add key="personalisationGroups.excludeCriteria" value="numberOfVisits,pagesViewed"/>

If you needed to personalise by these criteria - number of pages viewed and/or number of visits - it would be necessary to implement an alternate criteria that uses a different storage mechanism (such as a custom table or hooked into an analytics engine).

Version history