<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><language>en</language><title>Blog posts by Paul Gruffydd</title> <link>https://world.optimizely.com/blogs/paul-gruffydd/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Developer meetup - Manchester, 23rd January</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2025/1/developer-meetup---manchester-23rd-january/</link>            <description>&lt;p&gt;Yes, it&#39;s that time of year again where tradition dictates that people reflect on the year gone by and brace themselves for the year ahead, and what better way to kick start your year than an evening of talks, food, drink and of course plenty of time to mingle with other members of our fantastic Optimizely community. Our first Optimizely developer meetup of 2025 is just 2 weeks away on the evening of Thursday the 23rd of January and we can&#39;t wait to welcome you. We&#39;ve got a fantastic schedule of talks lined up from some great speakers all in the luxurious surroundings of Manchester&#39;s Everyman cinema.&lt;/p&gt;
&lt;p&gt;We&#39;re just putting the finishing touches to the running order but the lineup includes OMVP (and UK dev meetup legend) Mark Everard of First Three Things talking about how you can get the most out of the new Visual Builder, and the ever entertaining and informative Simon Chapman of Optimizely giving an update on all that&#39;s new and upcoming in the world of Optimizely.&lt;/p&gt;
&lt;div style=&quot;background: #eee; border: solid 1px #aaa; padding: 1em;&quot;&gt;
&lt;h2 style=&quot;text-align: left;&quot;&gt;⚠️UPDATE⚠️&lt;/h2&gt;
&lt;p&gt;We have now finalised the running order and I&#39;m pleased to say we&#39;ve got 4 fantastic talks lined up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mark Everard (First Three Things) - Making the Most of Visual Builder&lt;/li&gt;
&lt;li&gt;Anil Patel (Knight Frank) - Azure Business Communication Services integration with Optimizely Forms&lt;/li&gt;
&lt;li&gt;Andy Blyth (26) - The fragment conundrum - Overcoming Fragment Limits in GraphQL&lt;/li&gt;
&lt;li&gt;Simon Chapman (Optimizely) - Optimizely Roadmap Update&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;As always, tickets are free (though spaces are limited so &lt;a href=&quot;https://www.optimizely.com/local-dev-meetups&quot;&gt;please register&lt;/a&gt;) and we&#39;ll be laying on food and drinks during the evening.&lt;/p&gt;
&lt;p&gt;Doors open from 17:15 and talks begin at 18:00. All the talks should be wrapped up by 20:30 but please do stick around afterwards in the bar area for a drink and a chat.&lt;/p&gt;
&lt;p&gt;This promises to be a fantastic event so &lt;a href=&quot;https://www.optimizely.com/local-dev-meetups&quot;&gt;register now&lt;/a&gt; to guarantee your place via the form on the Optimizely events page (select &quot;Manchester UK - Jan 23&quot;):&lt;br /&gt;&lt;a href=&quot;https://www.optimizely.com/local-dev-meetups&quot;&gt;https://www.optimizely.com/local-dev-meetups&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Venue details&lt;/h2&gt;
&lt;p&gt;Everyman Manchester St. John&#39;s&lt;br /&gt;ABC Buildings&lt;br /&gt;23 Quay Street&lt;br /&gt;Manchester&lt;br /&gt;M3 4AS&lt;/p&gt;
&lt;p&gt;&#127760;&amp;nbsp;&lt;a href=&quot;https://www.everymancinema.com/manchester-st-johns#venueDetails&quot;&gt;https://www.everymancinema.com/manchester-st-johns#venueDetails&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&#128506;️&amp;nbsp;&lt;a href=&quot;https://www.google.co.uk/maps/place/Everyman+Manchester+St.+John&#39;s/@53.4787078,-2.2545795,17z/data=!3m1!4b1!4m6!3m5!1s0x487bb10bc5ff2583:0xbb79bea47afc9370!8m2!3d53.4787046!4d-2.2523908!16s%2Fg%2F11fmy19n6l&quot;&gt;View on Google maps&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See you there!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2025/1/developer-meetup---manchester-23rd-january/</guid>            <pubDate>Thu, 09 Jan 2025 12:45:40 GMT</pubDate>           <category>Blog post</category></item><item> <title>Developer Meetup - Manchester, 1st March</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2023/2/developer-meetup---manchester-1st-march/</link>            <description>&lt;p&gt;This Wednesday (the 1st of March) sees the first UK developer meetup of 2023 and our first in Manchester for far too long. We&#39;ve got some brilliant talks lined up from some great speakers in a fantastic venue (&lt;a href=&quot;https://www.everymancinema.com/manchester-st-johns#venueDetails&quot;&gt;The Everyman Cinema, Manchester St. John&#39;s&lt;/a&gt;).&amp;nbsp;&lt;/p&gt;
&lt;p&gt;The lineup includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mark Stott, Lead Optimizely Developer at 26 talking about simplifying a new website build on CMS 12&lt;/li&gt;
&lt;li&gt;Mark Everard, Optimizely Consultant talking about Liquid Templating&lt;/li&gt;
&lt;li&gt;Paul Gruffydd, Technical Director at Kin + Carta talking about GraphQL and Optimizely Content Graph&lt;/li&gt;
&lt;li&gt;Simon Chapman, Lead Solution Architect at Optimizely talking about how to run successful Experimentation Projects with Optimizely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As always, tickets are free (though &lt;a href=&quot;https://www.optimizely.com/local-dev-meetups/&quot;&gt;please register&lt;/a&gt;) and we&#39;ll be laying on food and drinks during the evening.&lt;/p&gt;
&lt;p&gt;Doors open from 17:15 and talks begin at 18:00. All the talks should be wrapped up by 20:30 but feel free to stick around afterwards in the bar area for a drink and a chat.&lt;/p&gt;
&lt;p&gt;This promises to be a fantastic event so be sure to register your place via the form on the Optimizely events page (select &quot;Manchester - Mar 1&quot;):&lt;br /&gt;&lt;a href=&quot;https://www.optimizely.com/local-dev-meetups/&quot;&gt;https://www.optimizely.com/local-dev-meetups/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;Venue details&lt;/h2&gt;
&lt;p&gt;Everyman Manchester St. John&#39;s&lt;br /&gt;ABC Buildings&lt;br /&gt;23 Quay Street&lt;br /&gt;Manchester&lt;br /&gt;M3 4AS&lt;/p&gt;
&lt;p&gt;&#127760;&amp;nbsp;&lt;a href=&quot;https://www.everymancinema.com/manchester-st-johns#venueDetails&quot;&gt;https://www.everymancinema.com/manchester-st-johns#venueDetails&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&#128506;️ &lt;a href=&quot;https://www.google.co.uk/maps/place/Everyman+Manchester+St.+John&amp;#39;s/@53.4787078,-2.2545795,17z/data=!3m1!4b1!4m6!3m5!1s0x487bb10bc5ff2583:0xbb79bea47afc9370!8m2!3d53.4787046!4d-2.2523908!16s%2Fg%2F11fmy19n6l&quot;&gt;View on Google maps&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;See you there!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2023/2/developer-meetup---manchester-1st-march/</guid>            <pubDate>Mon, 27 Feb 2023 12:28:16 GMT</pubDate>           <category>Blog post</category></item><item> <title>Triggering Webhooks from custom events</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2021/6/triggering-webhooks-from-custom-events/</link>            <description>&lt;p&gt;In my &lt;a href=&quot;/link/352805796a5c42eead3c90af44333697.aspx&quot;&gt;previous post&lt;/a&gt; I introduced the &lt;a href=&quot;https://nuget.episerver.com/package/?id=KinAndCarta.Webhooks&quot;&gt;Kin and Carta Webhooks add-on&lt;/a&gt; and showed how it can be used to fire webhooks based on one of 5 out-of-the-box content events. But what if you want to trigger a webhook based on something else? While those 5 events cover the more common scenarios, there are plenty of situations which they don&amp;rsquo;t cover so in this post I&amp;rsquo;m going to show how you can register your own event types which can be used to set up webhooks in the add-on UI.&lt;/p&gt;
&lt;h2&gt;The basics&lt;/h2&gt;
&lt;p&gt;Setting up your own events is pretty straightforward and follows the same process regardless of what triggers your event. The basic steps are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create an initializable module with a module dependency of &quot;KinAndCarta.Connect.Webhooks.Setup.InitialiseWebhooks&quot;&lt;/li&gt;
&lt;li&gt;Create a new EventType for your event (in the &quot;KinAndCarta.Connect.Webhooks.Models&quot; namespace)&lt;/li&gt;
&lt;li&gt;Register your event type by calling the &quot;RegisterEventType&quot; method of IWebhookRepository&lt;/li&gt;
&lt;li&gt;Add a translation to give your event a friendly name&lt;/li&gt;
&lt;li&gt;When your event occurs, trigger the webhooks using the &quot;TriggerWebhooks&quot; extension method&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;While the steps above may tell you what you need to do to get up and running with custom events, I always find it easier to look at an example rather than a list and, in this instance, instead of coming out with some examples myself, I thought I&amp;rsquo;d take inspiration from the comments on my previous post.&lt;/p&gt;
&lt;h2&gt;Creating a &amp;ldquo;First Publish&amp;rdquo; event&lt;/h2&gt;
&lt;p&gt;In response to the previous article, &lt;a href=&quot;/link/5341f632537c4b0ab6b8fb651bd310f8.aspx?userId=55c98260-3981-4652-8354-94d3fec9c687&quot;&gt;Stein-Viggo Grenersen&lt;/a&gt; mentioned using the add-on to post to social media when new articles are published. That scenario is a great example of how the add-on can be used and it&amp;rsquo;s really simple to set up using integration platforms like IFTTT, Zapier and Azure Logic Apps but, using the OOTB content publish event, there&amp;rsquo;s a risk that you&amp;rsquo;d post to social media every time the content is published in any language, even if you&amp;rsquo;re just fixing a typo. That could mean that you&amp;rsquo;re posting the same article again and again. That may work in a demo scenario but, in the real world you probably just want to post it once, the first time you publish or, for multilingual scenarios, maybe once per language. So let&amp;rsquo;s take a look at how custom events can help.&lt;/p&gt;
&lt;p&gt;For this example, let&amp;rsquo;s create an EventType called &amp;ldquo;First publish in language&amp;rdquo; which will only fire the first time an article is published in a given language. An EventType in this context just requires a key (to identify the event) and a flag to indicate whether the event that has occurred impacts the descendants of the content item which triggered the event (for example, the Delete event sets ImpactsDescendants to true as all descendants will be deleted too). To trigger our event, we&amp;rsquo;ll need to hook in to the publishing and published events from IContentEvents. In the publishing event, we&amp;rsquo;ll check whether any published versions exist in the current language and pass that information through to the published event using the ContentEventArgs.Items Dictionary which is shared between events triggered by the same action. If no existing published versions exist, we then trigger our webhook from within the &amp;ldquo;published&amp;rdquo; event. To trigger the webhooks attached to a content item, we use the aptly named &quot;TriggerWebhooks&quot; extension method (from KinAndCarta.Connect.Webhooks.Extensions) and pass in our event object, the IWebhookRepository instance and any additional data we want to include about our event which, in this case, is null.&lt;/p&gt;
&lt;p&gt;The code looks like this&amp;hellip;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[InitializableModule]
[ModuleDependency(typeof(KinAndCarta.Connect.Webhooks.Setup.InitialiseWebhooks))]
public class FirstPublishWebhookEventsInitialisation : IInitializableModule
{
    private IWebhookRepository _webhookRepo;
    private IContentLoader _contentLoader;
    private IContentEvents _contentEvents;
    private EventType _customEvent;
    public void Initialize(InitializationEngine context)
    {
        //Get dependencies
        _contentEvents = context.Locate.Advanced.GetInstance&amp;lt;IContentEvents&amp;gt;();
        _webhookRepo = context.Locate.Advanced.GetInstance&amp;lt;IWebhookRepository&amp;gt;();
        _contentLoader = context.Locate.Advanced.GetInstance&amp;lt;IContentLoader&amp;gt;();

        //Create &amp;amp; register our event
        _customEvent = new EventType { ImpactsDescendants = false, Key = &quot;FIRST_PUBLISH_IN_LANGUAGE&quot; };
        _webhookRepo.RegisterEventType(_customEvent);

        //Setup Event handlers
        _contentEvents.PublishingContent += ContentPublishing;
        _contentEvents.PublishedContent += ContentPublished;
    }


    private void ContentPublishing(object sender, ContentEventArgs e)
    {
        var versionRepo = ServiceLocator.Current.GetInstance&amp;lt;IContentVersionRepository&amp;gt;();
        bool firstPublish;

        //Check whether a published version already exists
        //If content can be translated, check if a published version exists in the current language
        if (e.Content is ILocalizable localContent)
        {
            firstPublish = versionRepo.LoadPublished(e.ContentLink.ToReferenceWithoutVersion(), localContent.Language.Name) == null;
        }
        else
        {
            firstPublish = versionRepo.LoadPublished(e.ContentLink.ToReferenceWithoutVersion()) == null;
        }

        //Set a value for the subsequent publish event to check
        e.Items.Add(&quot;FirstPublish&quot;, firstPublish);
    }
    private void ContentPublished(object sender, ContentEventArgs e)
    {
        //Check for our FirstPublish valie
        if (e.Items[&quot;FirstPublish&quot;] as bool? ?? false)
        {
            //TriggerWebhooks extension uses KinAndCarta.Connect.Webhooks.Extensions namespace
            e.Content.TriggerWebhooks(_customEvent, _webhookRepo, null);
        }
    }

    public void Uninitialize(InitializationEngine context)
    {
        _contentEvents.PublishingContent -= ContentPublishing;
        _contentEvents.PublishedContent -= ContentPublished;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If we compile and run our code then look at a webhook in the Webhooks UI, here&amp;rsquo;s what we&amp;rsquo;ll see.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/5011e16d0c0e4da1abfa30f443227c7d.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s functional, but not the most user friendly so let&amp;rsquo;s set up a translation for our event name. Translations of the event names are handled like any other translation in the CMS as you can see in the XML snippet below.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot; ?&amp;gt;
&amp;lt;languages&amp;gt;
  &amp;lt;language name=&quot;English&quot; id=&quot;en&quot;&amp;gt;
    &amp;lt;Webhooks&amp;gt;
      &amp;lt;EventTypes&amp;gt;
        &amp;lt;PUBLISH&amp;gt;Content Published&amp;lt;/PUBLISH&amp;gt;
        &amp;lt;MOVING&amp;gt;Content Moving&amp;lt;/MOVING&amp;gt;
        &amp;lt;MOVE&amp;gt;Content Moved&amp;lt;/MOVE&amp;gt;
        &amp;lt;RECYCLE&amp;gt;Content Sent to Recycle Bin&amp;lt;/RECYCLE&amp;gt;
        &amp;lt;DELETE&amp;gt;Content Deleted from Recycle Bin&amp;lt;/DELETE&amp;gt;
        &amp;lt;FIRST_PUBLISH_IN_LANGUAGE&amp;gt;First publish in language&amp;lt;/FIRST_PUBLISH_IN_LANGUAGE&amp;gt;
      &amp;lt;/EventTypes&amp;gt;
    &amp;lt;/Webhooks&amp;gt;
  &amp;lt;/language&amp;gt;
&amp;lt;/languages&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reloading the screen, we can now see our friendly event name in the list of events and use it to fire a webhook the first time a content item is published in a given language.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/689323d236de4d088151f7c2ed2cbf1d.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Reporting on Scheduled Job execution&lt;/h2&gt;
&lt;p&gt;In another comment on the original article, &lt;a href=&quot;/link/5341f632537c4b0ab6b8fb651bd310f8.aspx?userId=717d97cf-148b-e611-9afb-0050568d2da8&quot;&gt;Jay Wilkinson&lt;/a&gt; suggested using webhooks to act on the outcome of a scheduled job execution, for example, sending an email if a job fails. As you&amp;rsquo;ve probably realised, we&amp;rsquo;re not restricted to just firing webhooks on content events. We can trigger webhooks from anywhere in our code, in response to any action, however, as seen in the example above, we need to trigger the webhook using a content item which will be passed in the payload of the webhook. In this particular example though, we&amp;rsquo;re not interested in the content so we&amp;rsquo;ll trigger the webhook using the global block folder, knowing it&amp;rsquo;s always there, easily referenced and contains minimal data of its own (it&#39;s not ILocalizable so won&#39;t contain language information unlike the content root node). We are, however, interested in information about the scheduled job execution and we can add that data to the webhook payload pretty easily using the &amp;ldquo;extraData&amp;rdquo; parameter when we call TriggerWebhooks. The extraData parameter is a Dictionary&amp;lt;string, object&amp;gt; so we can easily add pretty much any data we need.&lt;/p&gt;
&lt;p&gt;In this example we&amp;rsquo;re going to hook into the Executed event exposed by IScheduledJobEvents which fires when a scheduled job has finished executing. The basics are the same as the example above but this time we&amp;rsquo;re going to extract some information on the job which has executed and pass it into the webhook payload via the extraData parameter. Putting it all together, here&amp;rsquo;s how it looks.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[InitializableModule]
[ModuleDependency(typeof(KinAndCarta.Connect.Webhooks.Setup.InitialiseWebhooks))]
public class ScheduledJobCompletionWebhookInitialisation : IInitializableModule
{
    private IScheduledJobEvents _scheduledJobEvents;
    private IWebhookRepository _webhookRepo;
    private IContentLoader _contentLoader;
    private EventType _customEvent;

    public void Initialize(InitializationEngine context)
    {
        //Get dependencies
        _webhookRepo = context.Locate.Advanced.GetInstance&amp;lt;IWebhookRepository&amp;gt;();
        _contentLoader = context.Locate.Advanced.GetInstance&amp;lt;IContentLoader&amp;gt;();
        _scheduledJobEvents = context.Locate.Advanced.GetInstance&amp;lt;IScheduledJobEvents&amp;gt;();

        //Create &amp;amp; register our event
        _customEvent = new EventType { ImpactsDescendants = false, Key = &quot;SCHEDULED_JOB_EXECUTED&quot; };
        _webhookRepo.RegisterEventType(_customEvent);

        //Attach to scheduled job executed event
        _scheduledJobEvents.Executed += executedScheduledJob;
    }

    private void executedScheduledJob(object sender, ScheduledJobEventArgs e)
    {
        //Build extra info to pass in payload
        var jobInfo = new Dictionary&amp;lt;string, object&amp;gt;();
        jobInfo.Add(&quot;Name&quot;, e.Job.Name);
        jobInfo.Add(&quot;Success&quot;, !e.Job.HasLastExecutionFailed);
        jobInfo.Add(&quot;Message&quot;, e.Job.LastExecutionMessage);

        //Trigger webhook on asset root page
        _contentLoader.Get&amp;lt;IContent&amp;gt;(ContentReference.GlobalBlockFolder).TriggerWebhooks(_customEvent, _webhookRepo, jobInfo);
    }

    public void Uninitialize(InitializationEngine context)
    {
        //Clean up event
        _scheduledJobEvents.Executed -= executedScheduledJob;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As before, you&amp;rsquo;ll probably want to add a translation to give the event a friendly name.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Once you&amp;rsquo;ve compiled and run the solution you can set up a webhook which fires on scheduled job executions, just remember to select the root node or the global blocks folder when setting up the webhook as that&amp;rsquo;s the node we&amp;rsquo;re attaching our custom event to. The payload data will look something like this.&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
  &quot;ReferencedBy&quot;: [],
  &quot;Content&quot;: {
    &quot;ContentLink&quot;: {
      &quot;Id&quot;: 3,
      &quot;WorkId&quot;: 0,
      &quot;GuidValue&quot;: &quot;e56f85d0-e833-4e02-976a-2d11fe4d598c&quot;,
      &quot;ProviderName&quot;: null,
      &quot;Url&quot;: null,
      &quot;Expanded&quot;: null
    },
    &quot;Name&quot;: &quot;For All Sites&quot;,
    &quot;Language&quot;: null,
    &quot;ExistingLanguages&quot;: [],
    &quot;MasterLanguage&quot;: null,
    &quot;ContentType&quot;: [
      &quot;Folder&quot;,
      &quot;SysContentFolder&quot;
    ],
    &quot;ParentLink&quot;: {
      &quot;Id&quot;: 1,
      &quot;WorkId&quot;: 0,
      &quot;GuidValue&quot;: &quot;43f936c9-9b23-4ea3-97b2-61c538ad07c9&quot;,
      &quot;ProviderName&quot;: null,
      &quot;Url&quot;: null,
      &quot;Expanded&quot;: null
    },
    &quot;RouteSegment&quot;: &quot;SysGlobalAssets&quot;,
    &quot;Url&quot;: null,
    &quot;Changed&quot;: &quot;1999-01-01T00:00:00Z&quot;,
    &quot;Created&quot;: &quot;1999-01-01T00:00:00Z&quot;,
    &quot;StartPublish&quot;: null,
    &quot;StopPublish&quot;: null,
    &quot;Saved&quot;: &quot;1999-01-01T00:00:00Z&quot;,
    &quot;Status&quot;: null
  },
  &quot;ExtraData&quot;: {
    &quot;Name&quot;: &quot;Publish Delayed Content Versions&quot;,
    &quot;Success&quot;: true,
    &quot;Message&quot;: &quot;Nothing was published.&quot;
  },
  &quot;ContentInfo&quot;: {
    &quot;ContentId&quot;: 3,
    &quot;ContentGuid&quot;: &quot;e56f85d0-e833-4e02-976a-2d11fe4d598c&quot;,
    &quot;ContentLanguage&quot;: &quot;&quot;,
    &quot;Name&quot;: &quot;For All Sites&quot;,
    &quot;Url&quot;: null
  },
  &quot;EventType&quot;: &quot;SCHEDULED_JOB_EXECUTED&quot;,
  &quot;EventTime&quot;: &quot;2021-06-10T10:00:10.7754622Z&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can then easily set up a receiver in, for example, Azure Logic Apps to receive our webhook and use the data passed in the extraData parameter to perform actions such as sending a Slack message if a specific job fails.&lt;/p&gt;
&lt;p&gt;So there we have it - just a couple of simple examples to show how you can use Webhooks with custom events but hopefully it&amp;rsquo;s conveyed how simple and flexible it is to set up your own event types. I look forward to hearing how you put it to use in your own projects.&lt;/p&gt;
&lt;p&gt;As I mentioned in the previous post, the add-on is available in the Episerver NuGet feed under &amp;ldquo;&lt;a href=&quot;https://nuget.episerver.com/package/?id=KinAndCarta.Webhooks&quot;&gt;KinAndCarta.Webhooks&lt;/a&gt;&amp;rdquo; and requires no particular set-up other than installing the package. Full source code is also available on &lt;a href=&quot;https://github.com/PaulGruffyddAmaze/kinandcarta-epi-webhooks&quot;&gt;GitHub&lt;/a&gt; under an MIT license.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2021/6/triggering-webhooks-from-custom-events/</guid>            <pubDate>Thu, 10 Jun 2021 10:36:41 GMT</pubDate>           <category>Blog post</category></item><item> <title>Using content webhooks with Episerver</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2021/3/using-content-webhooks-with-episerver/</link>            <description>&lt;p&gt;The concept of webhooks isn&#39;t a new one but in recent years it has become a core component to many headless and SaaS implementations. Without direct access to the underlying data, solutions built in this way typically rely on HTTP APIs to access data and expose functionality, but relying on HTTP APIs alone can result in unnecessary overheads. Excessive data transfer and the network latency introduced can, if not carefully managed, make applications feel clunky and unresponsive. That&amp;rsquo;s where webhooks come in.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;At a high level, webhooks offer a basic pub/sub pattern implemented using standard HTTP requests. A URL is registered with the source system and when certain events occur in that system, an HTTP request is made to the URL containing information on the event which occurred. The data sent in the http request can then be processed by the receiving system, allowing for scenarios where data is refreshed in response to changes to that data rather than having to periodically check.&lt;/p&gt;
&lt;p&gt;While headless content delivery may be a key use case for web hooks, it&amp;rsquo;s certainly not the only use case. Webhooks can be of use in a whole range of scenarios, particularly when combined with low-code integration platforms such as &lt;a href=&quot;https://azure.microsoft.com/en-gb/services/logic-apps/&quot;&gt;Azure Logic Apps&lt;/a&gt;, &lt;a href=&quot;https://ifttt.com/&quot;&gt;IFTTT&lt;/a&gt; and &lt;a href=&quot;https://zapier.com/&quot;&gt;Zapier&lt;/a&gt;. These platforms provide ready built integrations to an extensive catalogue of services which you can put together like lego blocks enabling you to build complex integrations and data processing tasks without the need to write or deploy any code, and all of them can be triggered by webhooks.&lt;/p&gt;
&lt;p&gt;As great as this may sound, there&#39;s something missing. While Episerver / Optimizely does have some webhook functionality (most notably in &lt;a href=&quot;/link/f3d01f8934b946fabe24c583a651b18f.aspx&quot;&gt;Forms&lt;/a&gt; though also in &lt;a href=&quot;https://webhelp.episerver.com/latest/en/campaign/integration/webhooks.htm&quot;&gt;Campaign&lt;/a&gt;), there is no functionality to trigger webhooks from content events. I can understand why this isn&amp;rsquo;t provided out-of-the-box, after all, we can programmatically attach handlers to any Episerver events and perform whatever actions we need. But codebase updates and deployments introduce their own inefficiencies and, with decoupled architectures becoming more common, webhooks offer a simpler, more unified solution. Sounds like a job for an add-on...&lt;/p&gt;
&lt;h2&gt;Introducing Kin and Carta Webhooks&lt;/h2&gt;
&lt;p&gt;At February&amp;rsquo;s &lt;a href=&quot;/link/aaaf670824c54512a7b1a04adaee2182.aspx&quot;&gt;Developer Happy Hour&lt;/a&gt; I demonstrated an add-on I&amp;rsquo;ve been working on which adds webhook functionality to Episerver / Optimizely CMS solutions and I&amp;rsquo;m pleased to announce that it is now available via the &lt;a href=&quot;https://nuget.episerver.com/package/?id=KinAndCarta.Webhooks&quot;&gt;Episerver NuGet feed&lt;/a&gt;. After installing the add-on, an additional &amp;ldquo;webhooks&amp;rdquo; menu item is added to the CMS navigation for users with admin access which links to the webhook UI. The UI allows for webhooks to be created, tested, updated and deleted as well as allowing for an at-a-glance view of the last result for each webhook. Clicking on the last result pops up the full response received, allowing for easier debugging.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/538686c27f9349d8bb812ab0358b77f1.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Creating a webhook&lt;/h2&gt;
&lt;p&gt;To set up a new webhook, click the &amp;ldquo;New Webhook&amp;rdquo; button and enter a name to identify the webhook and a URL which will be called when the webhook is fired, then select a parent node which can be used to limit triggering the webhook to only the selected node or its children.&lt;/p&gt;
&lt;p&gt;You should also select one or more content events which will determine when the web hook is triggered. Out of the box, the add on supports 5 content events:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Publish &lt;/strong&gt;- Called once a content item has been published&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Moving &lt;/strong&gt;- Called before a content item has been moved&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Move &lt;/strong&gt;- Called after a content item has been moved&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Recycled &lt;/strong&gt;- Called after a content item&amp;nbsp; has been moved to the recycle bin&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deleted &lt;/strong&gt;- Called before a content item has been permanently deleted&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;N.B. Content save events have been intentionally ignored as they occur almost constantly while editing content however it is possible to register your own events if this is required. I&amp;rsquo;ll cover how to do that in a subsequent post.&lt;/p&gt;
&lt;p&gt;You have the option to select one or more content types so that the webhook will trigger only on events involving content of the selected types. Leaving this blank defaults to triggering on all content types (assuming they&amp;rsquo;re in the correct part of the tree). Should you require, you can also add up to 5 custom HTTP headers which could be used for sending authentication information, keys or any other additional information your receiver may require in the header.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/82878e5c98ed4c9ab0682303f414f32d.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Once all of the relevant information has been entered, click save to save the webhook and return to the webhook overview screen. You can now click on the &amp;ldquo;Test&amp;rdquo; button to trigger the webhook as though a content event had occurred. To do this, the add-on finds the first content item which matches the criteria set and triggers the webhook using that content item. The result of the test will appear in a popup.&lt;/p&gt;
&lt;h2&gt;Payload data&lt;/h2&gt;
&lt;p&gt;When one of the supported events is triggered by a content event, any webhooks targeted to that event, content location and content type will be retrieved and a POST request will be made to the defined URL. This request is made on a background thread to avoid impacting the default processing of the action which triggered the event. The body of the post request contains information about the event fired, the content that triggered it and any other content items impacted by the event in JSON format. The top level JSON properties are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Content &lt;/strong&gt;- a JSON representation of the content which triggered the event. This uses the content delivery API to serialise the content and so the structure of the serialised content data should match data retrieved from the API, including customisations such as flattening the structure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ContentInfo &lt;/strong&gt;- high level information about the content (Name, IDs, Language, URL)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ReferencedBy &lt;/strong&gt;- information about content items (Name, IDs, Language, URL) which reference the content which triggered the event. This can be useful when using webhooks to invalidate remote caches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EventType &lt;/strong&gt;- the name of the event which occurred&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;EventTime&lt;/strong&gt; - the date/time at which the event occurred&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is also a field called &lt;strong&gt;ExtraData&lt;/strong&gt;, though this exists for extension purposes and so will currently be null. More on this in a future post.&lt;/p&gt;
&lt;p&gt;An easy way to explore the structure of the payload is to set up a webhook pointed to a service like &lt;a href=&quot;https://webhook.site/&quot;&gt;webhook.site&lt;/a&gt;. To try this out, simply visit &lt;a href=&quot;https://webhook.site/&quot;&gt;https://webhook.site/&lt;/a&gt; and you will be assigned a temporary URL. You can then set up a webhook in Episerver using that URL and send a test. Any requests sent to that URL will appear as they are received and can be inspected to view the payload and header data.&lt;/p&gt;
&lt;h2&gt;Token Replacement&lt;/h2&gt;
&lt;p&gt;When creating a webhook, tokens can be used within the URL and custom header fields to allow information such as keys or environment-specific values to be pulled in. Tokens are in the format &lt;span style=&quot;font-family:&amp;#32;&amp;#39;courier&amp;#32;new&amp;#39;,&amp;#32;courier,&amp;#32;monospace;&quot;&gt;${&lt;em&gt;TokenName&lt;/em&gt;}&lt;/span&gt; where &lt;em&gt;TokenName&lt;/em&gt; is a key registered using the &lt;span style=&quot;font-family:&amp;#32;&amp;#39;courier&amp;#32;new&amp;#39;,&amp;#32;courier,&amp;#32;monospace;&quot;&gt;RegisterPlaceholder(string token, string value)&lt;/span&gt; method of the &lt;span style=&quot;font-family:&amp;#32;&amp;#39;courier&amp;#32;new&amp;#39;,&amp;#32;courier,&amp;#32;monospace;&quot;&gt;IWebhookRepository&lt;/span&gt;. The value associated with the token will replace the token placeholder. In the event that the token name doesn&#39;t match a key, the token placeholder will be left as-is.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/c75a41c263f7440290b45820e3a8dc5c.aspx&quot; style=&quot;display:&amp;#32;block;&amp;#32;margin-left:&amp;#32;auto;&amp;#32;margin-right:&amp;#32;auto;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Registering tokens and their values will typically be done in an IInitializableModule though, if you want to register all appsettings values from your web.config file, you can add the following app setting to your config: &lt;br /&gt;&lt;span style=&quot;font-family:&amp;#32;&amp;#39;courier&amp;#32;new&amp;#39;,&amp;#32;courier,&amp;#32;monospace;&quot;&gt;&amp;lt;add key=&quot;WH:AutoRegisterAppSettings&quot; value=&quot;true&quot; /&amp;gt;&lt;/span&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;N.B. Before using this config value, ensure you are aware of the implications. By registering all appsettings like this, it is possible to extract potentially sensitive data from configuration and send it to an arbitrary URL.&lt;/p&gt;
&lt;h2&gt;Further information&lt;/h2&gt;
&lt;p&gt;During the February Dev Happy Hour I demonstrated a few of the ways the add-on could be used, including a demo of how we can use webhooks with Azure Logic Apps to selectively invalidate a CDN cache when cached content is updated. You can find the recording on the &lt;a href=&quot;/link/aaaf670824c54512a7b1a04adaee2182.aspx&quot;&gt;EMEA Dev Happy Hour page&lt;/a&gt; (the webhooks bit starts at the 30 minute mark).&lt;/p&gt;
&lt;p&gt;The add-on is available in the Episerver NuGet feed under &amp;ldquo;&lt;a href=&quot;https://nuget.episerver.com/package/?id=KinAndCarta.Webhooks&quot;&gt;KinAndCarta.Webhooks&lt;/a&gt;&amp;rdquo; and requires no particular set-up other than installing the package. Full source code is also available on &lt;a href=&quot;https://github.com/PaulGruffyddAmaze/kinandcarta-epi-webhooks&quot;&gt;GitHub&lt;/a&gt; under an MIT license.&lt;/p&gt;
&lt;p&gt;I hope you find this add-on useful and do let me know in the comments if you&amp;rsquo;ve got any feedback, questions, comments or suggestions.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2021/3/using-content-webhooks-with-episerver/</guid>            <pubDate>Wed, 31 Mar 2021 09:30:07 GMT</pubDate>           <category>Blog post</category></item><item> <title>Using KQL to list popular content from Profile Store</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/12/using-kql-to-list-popular-content-from-profile-store/</link>            <description>&lt;p&gt;Shortly after Profile Store was released &lt;a href=&quot;/link/e89e6a79138d4664871c266a10d6c254.aspx&quot;&gt;I wrote a post&lt;/a&gt; about how we can use it to do more than just process customer-centric data for display in insight and, instead, look at more event-centric data like popular site content. While the solution did the job, there was plenty of room for improvement. As I said in the post, &amp;ldquo;In an ideal world, we could do the aggregation as part of our query but unfortunately this isn&amp;rsquo;t supported right now&amp;rdquo;. Well, following the recent announcements about KQL support, this should now be possible so I thought it would be a good time to revisit that post and see how we could implement this &amp;ldquo;popular content&amp;rdquo; functionality today.&lt;/p&gt;
&lt;h2&gt;Introducing KQL&lt;/h2&gt;
&lt;p&gt;For those not familiar with KQL, it stands for Kusto Query Language and you&amp;rsquo;re most likely to have seen (or even used) it when interrogating data in Application Insights in Azure. There&amp;rsquo;s a brief overview of using KQL with profile store in the &lt;a href=&quot;/link/8cc3950554304fd5b2b988e52ac1cf86.aspx&quot;&gt;Profile Store documentation&lt;/a&gt; and a bit more detail in this &lt;a href=&quot;https://dmytroduk.com/techblog/a-sneak-peek-of-segmenting-based-on-historical-data-in-episerver-insight/&quot;&gt;post from Dmytro Duk&lt;/a&gt;. I won&amp;rsquo;t cover-off the specifics of KQL here as there are other resources out there for that purpose but it&amp;rsquo;s worth noting that it&amp;rsquo;s a huge step up from the previous filter-based queries we could perform against Profile Store data. Where previously we could only filter and sort the results returned, KQL gives us the ability to project, aggregate, join and transform that data. You can even apply machine learning algorithms for clustering data if you&amp;rsquo;re that way inclined, but for our purposes, we&amp;rsquo;ll stick with the basics.&lt;/p&gt;
&lt;h2&gt;Tracking&lt;/h2&gt;
&lt;p&gt;For the reasons mentioned in the &lt;a href=&quot;/link/e89e6a79138d4664871c266a10d6c254.aspx&quot;&gt;original post&lt;/a&gt;, we&amp;rsquo;ll track our page views using the PageViewTrackingAttribute in EPiServer.Tracking.PageView which records epiPageView events in Profile Store which look like this:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
    &quot;TrackId&quot;: null,
    &quot;DeviceId&quot;: &quot;91c08036-5335-4762-9660-d17c5677fba9&quot;,
    &quot;EventType&quot;: &quot;epiPageView&quot;,
    &quot;EventTime&quot;: &quot;2019-11-29T13:57:13.9670478Z&quot;,
    &quot;Value&quot;: &quot;Viewed Start&quot;,
    &quot;Scope&quot;: &quot;463470c3-3eca-41d3-8b12-3f7f92f62d34&quot;,
    &quot;CountryCode&quot;: &quot;Localhost&quot;,
    &quot;PageUri&quot;: &quot;http://localhost:59422/&quot;,
    &quot;PageTitle&quot;: null,
    &quot;RemoteAddress&quot;: &quot;::1&quot;,
    &quot;Payload&quot;: {
        &quot;epi&quot;: {
            &quot;contentGuid&quot;: &quot;bd437cef-41bd-4ebc-8805-0c20fcf4edcf&quot;,
            &quot;language&quot;: &quot;en&quot;,
            &quot;siteId&quot;: &quot;463470c3-3eca-41d3-8b12-3f7f92f62d34&quot;,
            &quot;ancestors&quot;: [
                &quot;43f936c9-9b23-4ea3-97b2-61c538ad07c9&quot;
            ],
            &quot;recommendationClick&quot;: null
        }
    },
    &quot;User&quot;: {
        &quot;Name&quot;: &quot;admin&quot;,
        &quot;Email&quot;: &quot;email@address.blah&quot;,
        &quot;Info&quot;: {}
    },
    &quot;SessionId&quot;: &quot;a7751741-6936-4f89-81ac-e50dd980ab13&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can search on any of the properties shown in the JSON structure above but there&amp;rsquo;s a problem. In the original post we had the ability to filter by content type (e.g. return a list of popular content of type ArticlePage) but content type isn&amp;rsquo;t recorded in the epiPageView event so how did that work? Well, in the &lt;a href=&quot;/link/e89e6a79138d4664871c266a10d6c254.aspx&quot;&gt;original post&lt;/a&gt;, the data was processed in a scheduled job (as it would have been too slow and resource-intensive to do on-the-fly) and stored in the dynamic data store so, at that point we could augment the data with, for example, the page type. Our searching was then done against the DDS rather than Profile Store.&lt;/p&gt;
&lt;p&gt;In this instance I want to avoid using scheduled jobs and DDS queries if at all possible and try to condense everything we need into a KQL query, so we&amp;rsquo;re going to have to store the content type as part of that tracking request. We can do that by creating a &lt;a href=&quot;/link/7baebec643fa4b7582280e38976a1fe7.aspx&quot;&gt;tracking data interceptor&lt;/a&gt;&amp;nbsp;which allows us to modify the page tracking data before it&amp;rsquo;s sent to Profile Store. This involves creating a class which implements ITrackingDataInterceptor, registering it as an instance of ITrackingDataInterceptor and swapping out the standard payload for one which contains an additional field called typeId which will hold our content type. This is done in the intercept method like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ServiceConfiguration(ServiceType = typeof(ITrackingDataInterceptor), Lifecycle = ServiceInstanceScope.Singleton)]
public class ContentTypeTrackingInterceptor : ITrackingDataInterceptor
{
    private IContentLoader _contentLoader;
    private IContentTypeRepository _contentTypeRepository;

    public int SortOrder =&amp;gt; 100;

    public ContentTypeTrackingInterceptor(IContentLoader contentLoader, IContentTypeRepository contentTypeRepository)
    {
        _contentLoader = contentLoader;
        _contentTypeRepository = contentTypeRepository;
    }

    public void Intercept&amp;lt;TPayload&amp;gt;(TrackingData&amp;lt;TPayload&amp;gt; trackingData)
    {
        if (trackingData == null || trackingData.Payload == null)
        {
            return;
        }
        if (!(trackingData.Payload is EPiServer.Tracking.PageView.EpiPageViewWrapper payload))
        {
            return;
        }

        // Create replacement Epi payload object
        var pageView = new EpiPageViewWithType(payload.Epi);
        var page = _contentLoader.Get&amp;lt;IContent&amp;gt;(payload.Epi.ContentGuid);
        pageView.TypeId = page.ContentTypeID;
        payload.Epi = pageView;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If we run the site now and click around a bit, the recorded events should now have our Payload.epi.typeId field like this:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
    &quot;TrackId&quot;: null,
    &quot;DeviceId&quot;: &quot;91c08036-5335-4762-9660-d17c5677fba9&quot;,
    &quot;EventType&quot;: &quot;epiPageView&quot;,
    &quot;EventTime&quot;: &quot;2019-11-29T13:57:13.9670478Z&quot;,
    &quot;Value&quot;: &quot;Viewed Start&quot;,
    &quot;Scope&quot;: &quot;463470c3-3eca-41d3-8b12-3f7f92f62d34&quot;,
    &quot;CountryCode&quot;: &quot;Localhost&quot;,
    &quot;PageUri&quot;: &quot;http://localhost:59422/&quot;,
    &quot;PageTitle&quot;: null,
    &quot;RemoteAddress&quot;: &quot;::1&quot;,
    &quot;Payload&quot;: {
        &quot;epi&quot;: {
            &quot;typeId&quot;: 23,
            &quot;contentGuid&quot;: &quot;bd437cef-41bd-4ebc-8805-0c20fcf4edcf&quot;,
            &quot;language&quot;: &quot;en&quot;,
            &quot;siteId&quot;: &quot;463470c3-3eca-41d3-8b12-3f7f92f62d34&quot;,
            &quot;ancestors&quot;: [
                &quot;43f936c9-9b23-4ea3-97b2-61c538ad07c9&quot;
            ],
            &quot;recommendationClick&quot;: null
        }
    },
    &quot;User&quot;: {
        &quot;Name&quot;: &quot;admin&quot;,
        &quot;Email&quot;: &quot;email@address.blah&quot;,
        &quot;Info&quot;: {}
    },
    &quot;SessionId&quot;: &quot;a7751741-6936-4f89-81ac-e50dd980ab13&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;KQL query&lt;/h2&gt;
&lt;p&gt;So now we&amp;rsquo;ve got the data in the correct format, let&amp;rsquo;s look at how we can query that data to retrieve popular content. To run a KQL query against Profile Store, first we need to wrap our query in a query object containing the KQL query and the scope to run it against like this:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{ 
    &quot;Query&quot;: &quot;...&quot;, 
    &quot;Scope&quot;: &quot;...&quot; 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;N.B. Unless you&amp;rsquo;ve explicitly set it to something else, the value of &amp;lsquo;Scope&amp;rsquo; will be the GUID of the site you&amp;rsquo;re tracking which can be accessed as SiteDefinition.Current.Id.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This object needs to be &lt;strong&gt;POST&lt;/strong&gt;ed as the body of a request to:&lt;br /&gt;https://[your profilestore host name here]/api/v2.0/TrackEvents/preview&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ll also need to set the following headers:&lt;br /&gt;Authorization: epi-single [your token here]&lt;br /&gt;Content-Type: application/json-patch+json&lt;/p&gt;
&lt;p&gt;In our &lt;a href=&quot;/link/e89e6a79138d4664871c266a10d6c254.aspx&quot;&gt;original post&lt;/a&gt; we started by querying all epiPageView events in the last 24 hours which we could do quite easily with a query like this:&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;Events
| where EventTime between (ago(24h) .. now())
and EventType == &#39;epiPageView&#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If we use that query though, we&amp;rsquo;d still need to manually aggregate the data in a scheduled job as we did before, so we need to be more selective in what we return and we need to retrieve aggregated data rather than a raw listing of events. To do that we can add some additional predicates to the where clause then use KQL&amp;rsquo;s &lt;a href=&quot;https://docs.microsoft.com/en-us/azure/kusto/query/summarizeoperator&quot;&gt;&#39;summarize&#39; operator&lt;/a&gt;&amp;nbsp; alongside the &lt;a href=&quot;https://docs.microsoft.com/en-us/azure/kusto/query/topoperator&quot;&gt;&#39;top&#39; operator&lt;/a&gt; to return us the guids for the top &lt;em&gt;n&lt;/em&gt; content items of a given type, in a given language, under a given ancestor, ordered by number of views. Putting that together, we get a query like this:&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;Events
| where EventTime between (ago(24h) .. now())
    and EventType == &#39;epiPageView&#39;
    and Payload.epi.language == &#39;en&#39;
    and Payload.epi.ancestors contains(&#39;bd437cef-41bd-4ebc-8805-0c20fcf4edcf&#39;)
    and Payload.epi.typeId == 15
| summarize Count = count() by Payload.epi.contentGuid
| top 5 by Count desc&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there&amp;rsquo;s a caveat - data returned from a KQL query to Profile Store is returned in a fixed structure (the epiPageView JSON structure shown above) and when we call summarize, this cuts down the data returned to just include the count (if we assign it a name) and the values used to group the data. In our case we&amp;rsquo;re grouping the data by the page guid (Payload.epi.contentGuid) but, as that&amp;rsquo;s not a top-level variable name, we can&amp;rsquo;t retrieve it and get a 500 error. The workaround is to map the variable to one of the top-level property names. In my case I&amp;rsquo;ve chosen to use &amp;lsquo;Value&amp;rsquo; so our slightly tweaked KQL query looks like this:&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;Events
| where EventTime between (ago(24h) .. now())
    and EventType == &#39;epiPageView&#39;
    and Payload.epi.language == &#39;en&#39;
    and Payload.epi.ancestors contains(&#39;bd437cef-41bd-4ebc-8805-0c20fcf4edcf&#39;)
    and Payload.epi.typeId == 15
| summarize Count = count() by Value = tostring(Payload.epi.contentGuid)
| top 5 by Count desc&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which will return us a list of content GUIDs as shown below which can then be used to load in the content items associated with each GUID.&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
    &quot;items&quot;: [
        {
            &quot;Value&quot;: &quot;74964f63-98c9-4d05-8069-5f8221e0c6ad&quot;
        },
        {
            &quot;Value&quot;: &quot;59f81174-6502-4c18-9e71-19cd7a6f2980&quot;
        },
        {
            &quot;Value&quot;: &quot;6f3891ee-c7a1-4fc5-a12c-95222f05b537&quot;
        },
        {
            &quot;Value&quot;: &quot;5f280886-1e3a-4ae0-a283-6b7c180abc82&quot;
        },
        {
            &quot;Value&quot;: &quot;e99088f3-2394-49c0-8205-2e3a1aecc8f7&quot;
        }
    ],
    &quot;count&quot;: 5
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Putting it all together&lt;/h2&gt;
&lt;p&gt;All that remains is to wrap that all in some code to substitute in the appropriate values into the query, make the requests and process the response, giving us two very similar methods to the &amp;ldquo;GetPopularPages&amp;rdquo; methods in the &lt;a href=&quot;/link/e89e6a79138d4664871c266a10d6c254.aspx&quot;&gt;previous post&lt;/a&gt; but with one important difference. As we&amp;rsquo;re making the queries in real-time, we don&amp;rsquo;t have to have a fixed window for what&amp;rsquo;s classed as recent so, if articles attract thousands of views an hour but FAQs only attract a few per day, we could set the window for a recent view of an article to be 6 hours but an FAQ to be 7 days vastly improving the reusability of our block type.&lt;/p&gt;
&lt;p&gt;Putting it all together, our code looks like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ProfileStoreHelper
{
    //Settings
    private string _apiRootUrl = ConfigurationManager.AppSettings[&quot;episerver:profiles.ProfileApiBaseUrl&quot;];
    private string _appKey = ConfigurationManager.AppSettings[&quot;episerver:profiles.ProfileApiSubscriptionKey&quot;];
    private string _eventUrl = &quot;/api/v2.0/TrackEvents/preview&quot;;
    private string _scope = ConfigurationManager.AppSettings[&quot;episerver:profiles.Scope&quot;] ?? SiteDefinition.Current.Id.ToString();

    private IContentLoader _contentLoader;
    private IContentTypeRepository _contentTypeRepository;

    public ProfileStoreHelper(IContentTypeRepository contentTypeRepository = null, IContentLoader contentLoader = null)
    {
        _contentTypeRepository = contentTypeRepository ?? ServiceLocator.Current.GetInstance&amp;lt;IContentTypeRepository&amp;gt;();
        _contentLoader = contentLoader ?? ServiceLocator.Current.GetInstance&amp;lt;IContentLoader&amp;gt;();
    }

    /// &amp;lt;summary&amp;gt;
    /// Get pages of a given type
    /// &amp;lt;/summary&amp;gt;
    public IEnumerable&amp;lt;T&amp;gt; GetPopularPages&amp;lt;T&amp;gt;(ContentReference ancestor, string lang, int resultCount = 5, int recentHours = 24) where T : PageData
    {
        var contentTypeId = _contentTypeRepository.Load&amp;lt;T&amp;gt;().ID;
        var ancestorGuid = _contentLoader.Get&amp;lt;IContent&amp;gt;(ancestor).ContentGuid;
        var hits = GetRecentContentResponse(ancestorGuid, lang, resultCount, recentHours, contentTypeId);
        return hits?.Items?.Select(x =&amp;gt; _contentLoader.Get&amp;lt;T&amp;gt;(x.Value)) ?? Enumerable.Empty&amp;lt;T&amp;gt;();
    }

    /// &amp;lt;summary&amp;gt;
    /// Get all popular content regardless of type
    /// &amp;lt;/summary&amp;gt;
    public IEnumerable&amp;lt;IContent&amp;gt; GetPopularPages(ContentReference ancestor, string lang, int resultCount = 5, int recentHours = 24)
    {
        var ancestorGuid = _contentLoader.Get&amp;lt;IContent&amp;gt;(ancestor).ContentGuid;
        var hits = GetRecentContentResponse(ancestorGuid, lang, resultCount, recentHours);
        return hits?.Items?.Select(x =&amp;gt; _contentLoader.Get&amp;lt;IContent&amp;gt;(x.Value)) ?? Enumerable.Empty&amp;lt;IContent&amp;gt;();
    }

    /// &amp;lt;summary&amp;gt;
    /// Make request to profile store API
    /// &amp;lt;/summary&amp;gt;
    private RecentContentResponse GetRecentContentResponse(Guid ancestorGuid, string lang, int resultCount = 5, int recentHours = 24, int typeId = 0)
    {
        var requestBody = $&quot;{{\&quot;Query\&quot;: \&quot;{GenerateKQLQuery(ancestorGuid, lang, resultCount, recentHours, typeId)}\&quot;, \&quot;Scope\&quot;: \&quot;{_scope}\&quot; }}&quot;;
        var req = new RestRequest(_eventUrl, Method.POST);
        req.AddHeader(&quot;Authorization&quot;, $&quot;epi-single {_appKey}&quot;);
        req.AddParameter(&quot;application/json-patch+json&quot;, requestBody, ParameterType.RequestBody);
        req.RequestFormat = DataFormat.Json;
        req.AddBody(requestBody);
        var client = new RestClient(_apiRootUrl);
        var getEventResponse = client.Execute(req);
        return JsonConvert.DeserializeObject&amp;lt;RecentContentResponse&amp;gt;(getEventResponse.Content);
    }

    /// &amp;lt;summary&amp;gt;
    /// Construct KQL query
    /// &amp;lt;/summary&amp;gt;
    private string GenerateKQLQuery(Guid ancestorGuid, string lang, int resultCount = 5, int recentHours = 24, int typeId = 0)
    {
        var kqlQueryObj = @&quot;Events 
            | where EventTime between(ago({0}h) .. now()) 
                and EventType == &#39;epiPageView&#39; 
                and Payload.epi.language == &#39;{1}&#39; 
                and Payload.epi.ancestors contains(&#39;{2}&#39;) 
                {3}
            | summarize Count = count() by Value = tostring(Payload.epi.contentGuid)
            | top {4} by Count desc&quot;;
        //Only add type restriction if a type has been specified
        var typeQuery = typeId &amp;gt; 0 ? $&quot;and Payload.epi.typeId == {typeId}&quot; : string.Empty;
        return string.Format(kqlQueryObj, recentHours, lang, ancestorGuid.ToString(), typeQuery, resultCount);
    }
}

public class RecentContentResponse
{
    public int Count { get; set; }
    public RecentContentResponseItem[] Items { get; set; }
}

public class RecentContentResponseItem
{
    public Guid Value { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which we can then call from a block controller to give us something like this&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/d5f9093df4c34f4e84fbf891f2829229.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As you can see, by taking advantage of the recent API updates in Profile Store we&amp;rsquo;ve managed to remove our reliance on scheduled jobs and the DDS, and cut down the amount of code we need to get useful data while improving the relevance of the results we return.&lt;/p&gt;
&lt;p&gt;As with the previous post, I&amp;rsquo;ve added the code (including the block) to a &lt;a href=&quot;https://gist.github.com/PaulGruffyddAmaze/f39820d189228b74e151eaa6b961cdf4&quot;&gt;Gist on GitHub&lt;/a&gt; but do bear in mind that this has been created as a proof-of-concept rather than a battle-hardened, production-ready feature so use it with caution.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/12/using-kql-to-list-popular-content-from-profile-store/</guid>            <pubDate>Wed, 04 Dec 2019 12:31:57 GMT</pubDate>           <category>Blog post</category></item><item> <title>Templating emails from Episerver Forms</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/8/templating-emails-from-episerver-forms/</link>            <description>&lt;p&gt;This may sound obvious but maintaining brand consistency across channels is pretty important yet, all too often, transactional emails seem to fall through the cracks. Am I alone in feeling slightly disappointed when I fill in a form on a website only to receive an unbranded, generic, text-only &amp;ldquo;thanks for filling in our form&amp;rdquo; email which could have come from anyone?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/2d113500aa1d4e0eb637c17080f02b96.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;A fairly comprehensive solution to this problem (and several others) is to invest in marketing automation technology such as Episerver Campaign and an increasing number of companies are going down that route but there are still many situations where that kind of approach is overkill. In some circumstances it&amp;rsquo;s sufficient to just use Episerver Forms, particularly given that, should your requirements grow to include marketing automation, you can extend the functionality of Forms using one of the ready-built connectors.&lt;/p&gt;
&lt;p&gt;A big advantage of Episerver Forms over its predecessor (XForms &#128561;) is that Forms includes a mechanism out of the box to allow you to send different emails with editor controllable content to multiple email addresses (including the person filling in the form), functionality which we used to have to write ourselves in XForms. The downside of these emails is that you only get a WYSIWYG editor to manage the full content of the email which makes it difficult to produce fully branded emails and, as each email is created individually, this increases the burden on editors when it comes to managing essential shared text such as disclaimers which are required across all emails. If we could apply a template to those emails, wrapping the editable content, then we alleviate both of those issues.&lt;/p&gt;
&lt;p&gt;Within Forms, the processing of submitted form data is handled using a concept called actors and, as developers, we&amp;rsquo;re free to create as many actors as we like. Given that we already have an actor which does most of what we need it feels unnecessary to write a whole new actor from scratch, so I&amp;rsquo;d like to take the existing SendEmailAfterSubmissionActor as a base for my templated version and add in the functionality to allow the editor to choose from a list of available templates for the email.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s tackle the easy bit first &amp;ndash; managing the email templates. We could upload these as files in our solution the same way as we do for views but I can envisage many situations where we&amp;rsquo;d need to make minor tweaks to the templates which couldn&amp;rsquo;t really wait for a deployment cycle so I&amp;rsquo;m going to manage the email templates as content and, as they won&amp;rsquo;t be accessed directly via a URL, it makes sense to use a block like this.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ContentType(DisplayName = &quot;Email Template&quot;, GUID = &quot;03daf669-5884-47f2-840f-04056afc9536&quot;, Description = &quot;Content type for adding email templates&quot;)]
public class EmailTemplateBlock : BlockData
{
    [CultureSpecific]
    [Display(
        Name = &quot;Content&quot;,
        Description = &quot;The HTML/Text content of the email, use #CONTENT# to represent the editable section of the email&quot;,
        GroupName = SystemTabNames.Content,
        Order = 20)]
    [UIHint(UIHint.Textarea)]
    public virtual string Content { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The slight downside of this approach is that blocks are usually previewed in situ within a page so we need to give our EmailTemplateBlock its own preview controller to allow us to preview the email templates without the site header &amp;amp; footer wrapped around them.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[TemplateDescriptor(
    Inherited = true,
    TemplateTypeCategory = TemplateTypeCategories.MvcController, //Required as controllers for blocks are registered as MvcPartialController by default
    Tags = new[] { RenderingTags.Preview, RenderingTags.Edit },
    AvailableWithoutTag = false)]
[VisitorGroupImpersonation]
[RequireClientResources]
public class EmailTemplatePreviewController : ActionControllerBase, IRenderTemplate&amp;lt;EmailTemplateBlock&amp;gt;
{
    public EmailTemplatePreviewController()
    {
    }

    public ActionResult Index(EmailTemplateBlock currentContent)
    {
        // Display the template with some dummy content
        return Content((currentContent.Content ?? string.Empty).Replace(&quot;#CONTENT#&quot;, &quot;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;Lorem ipsum dolor sit amet&amp;lt;/strong&amp;gt;,&amp;lt;br/&amp;gt; consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.&amp;lt;/p&amp;gt;&quot;));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, we need to modify the UI for the SendEmailAfterSubmissionActor to allow the user to pick a template along with the rest of the details. This is done in a similar way to code-first content types in that we create a class and decorate its properties to say how we&amp;rsquo;d like them displayed. To avoid reinventing the wheel, I&amp;rsquo;m going to inherit from the model used by the default SendEmailAfterSubmissionActor and just add my additional &amp;ldquo;EmailTemplateId&amp;rdquo; field which uses a selection factory to show as a dropdown.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class TemplatableEmailTemplateActorModel : EmailTemplateActorModel
{
    [Display(
        Name = &quot;Template&quot;,
        Order = 9999)]
    [SelectOne(SelectionFactoryType = typeof(EmailTemplateSelectionFactory))]
    public virtual int EmailTemplateId { get; set; }
}

public class EmailTemplateSelectionFactory : ISelectionFactory
{
    public IEnumerable&amp;lt;ISelectItem&amp;gt; GetSelections(ExtendedMetadata metadata)
    {
        yield return new SelectItem { Text = &quot;None&quot;, Value = 0 };

        var contentModelUsage = ServiceLocator.Current.GetInstance&amp;lt;IContentModelUsage&amp;gt;();
        var contentTypeRepo = ServiceLocator.Current.GetInstance&amp;lt;IContentTypeRepository&amp;gt;();
        var templateType = contentTypeRepo.Load(&quot;EmailTemplateBlock&quot;);

        var content = contentModelUsage.ListContentOfContentType(templateType) //Get all instances of EmailTemplateBlock
            .GroupBy(x =&amp;gt; x.ContentLink.ID).Select(x =&amp;gt; x.First()) //Remove duplicates
            .OrderBy(x =&amp;gt; x.Name); //Order alphabetically by name

        foreach (var item in content)
        {
            yield return new SelectItem { Text = item.Name, Value = item.ContentLink.ID };
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we put it all together by creating our overridden version of the SendEmailAfterSubmissionActor, using our custom model as shown above. In our actor we override the &amp;ldquo;run&amp;rdquo; method and use it to modify the model to grab the selected template and wrap it around the email content before putting it back into the email body property of the model and letting the base SendEmailAfterSubmissionActor process the emails as it normally would.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class CustomSendEmailAfterSubmissionActor : SendEmailAfterSubmissionActor
{
    private Injected&amp;lt;IContentLoader&amp;gt; _contentLoader;
    private string _contentPlaceholder = &quot;#CONTENT#&quot;;

    public override Type PropertyType =&amp;gt; typeof(PropertyTemplatableEmailActor);

    public override object Run(object input)
    {
        var emailList = Model as IEnumerable&amp;lt;TemplatableEmailTemplateActorModel&amp;gt;;
        foreach (var email in emailList)
        {
            // Load the template in the correct language, allowing for fallbacks
            var languageLoaderOptions = new LoaderOptions();
            languageLoaderOptions.Add(new LanguageLoaderOption { FallbackBehaviour = LanguageBehaviour.FallbackWithMaster, Language = new System.Globalization.CultureInfo((input as EPiServer.Forms.Core.Models.FormIdentity).Language) });
            if (email.EmailTemplateId &amp;gt; 0 &amp;amp;&amp;amp; _contentLoader.Service.TryGet(new ContentReference(email.EmailTemplateId), languageLoaderOptions, out EmailTemplateBlock emailTemplateBlock) &amp;amp;&amp;amp; email.Body != null)
                {
                email.Body = new XhtmlString(emailTemplateBlock.Content.Replace(_contentPlaceholder, email.Body.ToHtmlString()));
            }
        }
        return base.Run(input);
    }
}

/// &amp;lt;summary&amp;gt;
/// Property definition for the Actor
/// &amp;lt;/summary&amp;gt;
[EditorHint(&quot;TemplatableEmailActorPropertyHint&quot;)]
[PropertyDefinitionTypePlugIn(DisplayName = &quot;TemplatableEmail&quot;)]
public class PropertyTemplatableEmailActor : PropertyGenericList&amp;lt;TemplatableEmailTemplateActorModel&amp;gt; { }


/// &amp;lt;summary&amp;gt;
/// Editor descriptor class, for using Dojo widget CollectionEditor to render.
/// Inherit from &amp;lt;see cref=&quot;CollectionEditorDescriptor&quot;/&amp;gt;, it will be rendered as a grid UI.
/// &amp;lt;/summary&amp;gt;
[EditorDescriptorRegistration(TargetType = typeof(IEnumerable&amp;lt;TemplatableEmailTemplateActorModel&amp;gt;), UIHint = &quot;TemplatableEmailActorPropertyHint&quot;)]
public class ConfigurableActorEditorDescriptor : CollectionEditorDescriptor&amp;lt;TemplatableEmailTemplateActorModel&amp;gt;
{
    public ConfigurableActorEditorDescriptor()
    {
        // N.B. This is a special ClientEditingClass just for the EmailTemplateActorModel.
        // Using the expected value of &quot;epi-forms/contentediting/editors/CollectionEditor&quot; will display incorrectly
        ClientEditingClass = &quot;epi-forms/contentediting/editors/EmailTemplateActorEditor&quot;;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point our custom actor will display in addition to the inbuilt one so we&amp;rsquo;ll want to hide the inbuilt one. We can do this using an EditorDescriptor to override metadata.ShowForEdit as shown below however, before you do this, make sure you&#39;ve cleared out any existing email responses which have been set up in the inbuilt actor as, once it&#39;s hidden, we won&#39;t be able to modify any existing emails.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[EditorDescriptorRegistration(
        TargetType = typeof(IEnumerable&amp;lt;EmailTemplateActorModel&amp;gt;),
        UIHint = &quot;EmailTemplateActorEditor&quot;,
        EditorDescriptorBehavior = EditorDescriptorBehavior.OverrideDefault)]
public class EmailTemplateActorEditorDescriptor : CollectionEditorDescriptor&amp;lt;EmailTemplateActorModel&amp;gt;
{
    public EmailTemplateActorEditorDescriptor()
    {
        //ClientEditingClass = &quot;epi-forms/contentediting/editors/EmailTemplateActorEditor&quot;;
    }
    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable&amp;lt;Attribute&amp;gt; attributes)
    {
        metadata.ShowForEdit = false;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And there we have it, templated emails sent through Episerver Forms.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/4c81c4beddc947a994b49f4ec4b2be51.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As always, I&amp;rsquo;ve created this as a proof-of-concept rather than battle-hardened, production-ready code so use with caution. The full source can be found on GitHub here:&lt;br /&gt;&lt;a href=&quot;https://gist.github.com/PaulGruffyddAmaze/6ce76f3c6ffb78f0b568c3a2f0998c06&quot;&gt;https://gist.github.com/PaulGruffyddAmaze/6ce76f3c6ffb78f0b568c3a2f0998c06&lt;/a&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/8/templating-emails-from-episerver-forms/</guid>            <pubDate>Thu, 08 Aug 2019 12:59:26 GMT</pubDate>           <category>Blog post</category></item><item> <title>Customising rendering to reduce nested blocks</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/6/customising-rendering-to-reduce-nesting-blocks/</link>            <description>&lt;p&gt;There have been a couple of posts about performance over the last few weeks from &lt;a href=&quot;https://stefanolsen.com/posts/8-things-to-avoid-to-make-an-episerver-site-go-faster/&quot;&gt;Stefan Holm Olsen&lt;/a&gt; then&amp;nbsp;&lt;a href=&quot;/link/2c38af9b5f7345efbe5ec0227c7fb607.aspx&quot;&gt;Khurram Khan&lt;/a&gt;, both of which suggest minimising the use of nested blocks. This is good advice not just for performance reasons but for improving the editing experience. As developers working in content management, it&amp;rsquo;s our role to make life easier for the editors who have to use what we implement on a daily basis so, if we can put in a little more work during development to save a lot of content management effort in the future I&amp;rsquo;d class that as a win.&lt;/p&gt;
&lt;p&gt;One of the suggested methods to avoid nesting is to use list properties but, while these are certainly useful and represent the right option in some circumstances, there are drawbacks to be aware of. From an editor&amp;rsquo;s perspective, using list properties can cause problems in that the items in list properties aren&amp;rsquo;t reusable in different lists, they&amp;rsquo;re not well suited to large amounts of fields or large amounts of text in a field, they can&amp;rsquo;t have personalisation applied and, perhaps most importantly, they are very difficult to manage if you need to translate them between different language branches. From a developer&amp;rsquo;s perspective, you also need to consider the fact that these property types aren&amp;rsquo;t yet officially supported (as you can see from the great big warning at the top of &lt;a href=&quot;/link/5639beae3dc9454cbe72a4a3106de560.aspx&quot;&gt;the documentation&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;One of the most common (and unnecessary) uses for nesting blocks I&amp;rsquo;ve seen in Episerver implementations is to create a &amp;ldquo;wrapper&amp;rdquo; block to group together &amp;ldquo;item&amp;rdquo; blocks. To give an example, you might be adding accordion functionality to a site and need to add markup like this:&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;div class=&quot;accordion&quot;&amp;gt;
    &amp;lt;div class=&quot;accordion__items&quot;&amp;gt;
        &amp;lt;div class=&quot;accordion__item&quot;&amp;gt;
            &amp;lt;h2 class=&quot;accordion__item-heading&quot;&amp;gt;Accordion 1&amp;lt;/h2&amp;gt;
            &amp;lt;div class=&quot;accordion__item-body&quot;&amp;gt;
                &amp;lt;p&amp;gt;Accordion content goes here&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
        &amp;lt;div class=&quot;accordion__item&quot;&amp;gt;
            &amp;lt;h2 class=&quot;accordion__item-heading&quot;&amp;gt;Accordion 2&amp;lt;/h2&amp;gt;
            &amp;lt;div class=&quot;accordion__item-body&quot;&amp;gt;
                &amp;lt;p&amp;gt;Accordion content goes here&amp;lt;/p&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One of my pet hates is to see this implemented by creating an accordion wrapper block like this.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ContentType(DisplayName = &quot;Accordion Wrapper&quot;, GUID = &quot;C11E7439-DD80-488B-8CEB-23FA6D23508D&quot;, Description = &quot;Wrapper for accordion items&quot;)]
public class AccordionWrapperBlock : SiteBlockData
{
    [Display(
        Name = &quot;Accordion Items&quot;,
        Description = &quot;The items in the accordion&quot;,
        GroupName = SystemTabNames.Content,
        Order = 10)]
    [AllowedTypes(typeof(AccordionItemBlock))]
    public virtual ContentArea AccordionItems { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this instance, the wrapper block exists only to render a wrapping element around the accordion items, adding to the workload of the editors without really adding value. Instead, we should identify groups of similar items while we&amp;rsquo;re rendering the content area and add in the wrappers programmatically, allowing the editors to concentrate on their content without having to worry about what it should be wrapped in.&lt;/p&gt;
&lt;p&gt;In order to address this scenario, first we need to be able to derive which items require which wrapping markup. To do this I&amp;rsquo;ve created a class which defines how a group should be rendered (ContentAreaWrapper) and an interface which my content types will implement which simply defines which ContentAreaWrapper we should use for that content.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;//Define the markup wrapping a group of blocks
public class ContentAreaWrapper
{
    public string StartHtml;
    public string EndHtml;
}

//Interface to allow content types to define how they should be grouped
interface IWrapable
{
    ContentAreaWrapper Wrapper { get; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To ensure consistency and to allow different content types to sit together in the same group, I&amp;rsquo;ve created a class which contains static instances of my ContentAreaWrapper definitions.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class ContentAreaWrappers
{
    //Used if no other wrapper is defined
    public static ContentAreaWrapper DefaultWrapper = new ContentAreaWrapper
    {
        StartHtml = &quot;&quot;,
        EndHtml = &quot;&quot;
    };

    //Wrapper for accordion blocks
    public static ContentAreaWrapper AccordionWrapper = new ContentAreaWrapper
    {
        StartHtml = &quot;&amp;lt;div class=\&quot;accordion\&quot;&amp;gt;&amp;lt;div class=\&quot;accordion__items\&quot;&amp;gt;&quot;,
        EndHtml = &quot;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&quot;
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rendering the content areas is, unsurprisingly, handled by an instance of &lt;a href=&quot;/link/55fe410f4f65482cab72fb59d47c7561.aspx?documentId=cms/11/C2943161&quot;&gt;ContentAreaRenderer&lt;/a&gt;. Sites based on Alloy will already have a &lt;a href=&quot;https://github.com/episerver/alloy-mvc-template/blob/master/src/Alloy.Mvc.Template/Business/Rendering/AlloyContentAreaRenderer.cs&quot;&gt;customised ContentAreaRenderer&lt;/a&gt; registered but, for sites which don&amp;rsquo;t, it&amp;rsquo;s just a matter of creating a class which inherits ContentAreaRenderer, overriding the necessary methods and using dependency injection to &lt;a href=&quot;https://github.com/episerver/alloy-mvc-template/blob/master/src/Alloy.Mvc.Template/Business/Initialization/DependencyResolverInitialization.cs&quot;&gt;swap out the default instance of ContentAreaRenderer for your custom version&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In order to wire-in the wrapping of elements, we need to override the RenderContentAreaItems method which is responsible for iterating through the content area items and sending them to be rendered. In this method we&amp;rsquo;ll loop through the items in the ContentArea, grouping them by their wrapper then sending them to be rendered in those groups, wrapping them in the appropriate HTML.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class WrappingContentAreaRenderer : ContentAreaRenderer
{
    //Override RenderContentAreaItems to group items together and render in batches
    protected override void RenderContentAreaItems(HtmlHelper htmlHelper, IEnumerable&amp;lt;ContentAreaItem&amp;gt; contentAreaItems)
    {
        if (contentAreaItems == null || !contentAreaItems.Any())
        {
            return;
        }

        var items = new List&amp;lt;ContentAreaItem&amp;gt;();
        ContentAreaWrapper wrapper = ContentAreaWrappers.DefaultWrapper;
        foreach (var item in contentAreaItems)
        {
            var content = item.GetContent();
            var itemWrapper = (content as IWrapable)?.Wrapper ?? ContentAreaWrappers.DefaultWrapper;
            if (itemWrapper != wrapper)
            {
                RenderItemGroup(htmlHelper, items, wrapper);
                wrapper = itemWrapper;
            }
            items.Add(item);
        }
        RenderItemGroup(htmlHelper, items, wrapper);
    }

    //Render grouped items wrapped in appropriate markup
    private void RenderItemGroup(HtmlHelper htmlHelper, List&amp;lt;ContentAreaItem&amp;gt; contentAreaItems, ContentAreaWrapper wrapper)
    {
        if (contentAreaItems.Any())
        {
            htmlHelper.Raw(wrapper.StartHtml);
            base.RenderContentAreaItems(htmlHelper, contentAreaItems);
            htmlHelper.Raw(wrapper.EndHtml);
            contentAreaItems.Clear();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we can create an AccordionBlock implementing IWrapable to define how we should render a group of accordion blocks without requiring a wrapper.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ContentType(DisplayName = &quot;Accordion Item&quot;, GUID = &quot;18d351e2-0046-4421-b405-e14f4ac56bd8&quot;, Description = &quot;&quot;)]
public class AccordionItemBlock : SiteBlockData, IWrapable
{
    #region Properties
    [CultureSpecific]
    [Display(
        Name = &quot;Heading&quot;,
        Description = &quot;The heading of the accordion item&quot;,
        GroupName = SystemTabNames.Content,
        Order = 10)]
    public virtual string Heading { get; set; }

    [CultureSpecific]
    [Display(
        Name = &quot;Content&quot;,
        Description = &quot;The content of the accordion item&quot;,
        GroupName = SystemTabNames.Content,
        Order = 20)]
    public virtual XhtmlString MainContent { get; set; }
    #endregion

    #region IWrapable
    public ContentAreaWrapper Wrapper =&amp;gt; ContentAreaWrappers.AccordionWrapper;
    #endregion
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Obviously this is a fairly simple example to demonstrate the technique but it&#39;s be pretty straightforward to extend it to handle more complex scenarios. It&#39;s also worth mentioning that, if you&#39;re currently adding in row blocks to wrap your inner blocks in a row container you should take a look at the &lt;a href=&quot;https://github.com/valdisiljuconoks/EPiBootstrapArea&quot;&gt;EPiBootstrapArea&lt;/a&gt; project from&amp;nbsp;Valdis Iljuconoks which uses a similar technique coupled with display options to dynamically wrap the appropriate number of items in row markup.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/6/customising-rendering-to-reduce-nesting-blocks/</guid>            <pubDate>Mon, 24 Jun 2019 13:55:13 GMT</pubDate>           <category>Blog post</category></item><item> <title>Optimising your site for a global audience</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/5/optimising-your-site-for-a-global-audience/</link>            <description>&lt;p&gt;I&amp;rsquo;d hope most people reading this understand the importance of web performance but, when considering how best to expand an existing site into new markets or to build a site spanning multiple regions, it&amp;rsquo;s easy to get caught up in the practicalities of localisation, etc. while neglecting the basics. High on the list of priorities for any site should be ensuring the site performs consistently well in &lt;em&gt;all&lt;/em&gt; of the countries and regions it serves (not just where the main stakeholders live &#128521;) which, as I mentioned in a &lt;a href=&quot;/link/9b0429b2998f491f917fe634a936be6f.aspx&quot;&gt;previous post&lt;/a&gt;, is not quite as straightforward as it seems, so I put together this post outlining some of the considerations and options available when building a site on a global scale.&lt;/p&gt;
&lt;h2&gt;Choose your location wisely&lt;/h2&gt;
&lt;p&gt;Probably the most commonly implemented of the techniques listed here (often combined with a CDN) is to simply choose a single central location to host your site. You take a look at where your traffic comes from and choose a location that best serves that traffic. This choice often comes down to what feels right though you can take a more scientific approach by estimating the percentage of traffic which would be best served by each of the regions in question, estimating the latency between the regions and calculating the impact of putting your site in each region. As an example, the traffic to a site breaks down to 50% EU west, 20% US East and 30% US West. Latency between the datacentres is as follows:&lt;/p&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;EU west&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US East&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US West&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;EU west&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;0&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;73ms&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;150ms&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US East&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;73ms&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;0&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;70ms&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US West&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;150ms&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;70ms&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;0&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If we multiply the latency by the percentage of traffic, we can get a relative measure of the best location to choose where the lowest number is the best&lt;/p&gt;
&lt;table&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;EU west&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US East&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US West&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;EU west&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;0 x 50 = 0&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;73ms x 20 = 1460&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;150ms x 30 = 4500&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;5960&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US East&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;73ms x 50 = 3650&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;0 x 20 = 0&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;70ms x 30 = 2100&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;5750&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;p&gt;&lt;strong&gt;US West&lt;/strong&gt;&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;150ms x 50 = 7500&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;70ms x 20 = 1400&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;0 x 30 = 0&lt;/p&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;p&gt;8900&lt;/p&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Use caching effectively&lt;/h2&gt;
&lt;p&gt;To make the most of your site you should always consider how best to cache your content. Though the term caching may refer to either server-side caching or client-side caching, in this instance I&amp;rsquo;m talking about client-side caching. It&amp;rsquo;s not that server-side caching isn&amp;rsquo;t important, far from it, it&amp;rsquo;s that server-side caching is as important for an intranet site serving 50 people sat within a 20 metre radius of the server as it is for a global site serving millions of users around the world. One of the main challenges we face when delivering a site globally is network latency. The closer we can get to the browser we&amp;rsquo;re serving files to, the better and you don&amp;rsquo;t get much closer than the user&amp;rsquo;s machine itself. The more we cache on the user&amp;rsquo;s machine, the less outbound requests we&amp;rsquo;re making and the lower the impact of the network latency.&lt;/p&gt;
&lt;p&gt;At its most basic, we can set cache headers on static assets, including assets like images uploaded to Episerver. To make the most of this cache we can set the expiry to a date in the future (say, 30 days) which means that, all being well, we shouldn&amp;rsquo;t need to call back to the server until the cache expires but there&amp;rsquo;s a catch. What if one of those cached files changes an hour after it&amp;rsquo;s been cached? In theory you&amp;rsquo;re stuck with it until the cache expires but in practice there&amp;rsquo;s a few things we can do. If we can encode a timestamp into the URL then, when the asset changes, the URL changes too meaning that the new URL won&amp;rsquo;t be cached and the file will be requested as though it were new. While this may sound complex to implement, the hard work&amp;rsquo;s already been done here:&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/bjuris/EPiServer.CdnSupport&quot;&gt;https://github.com/bjuris/EPiServer.CdnSupport&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Just add that module and a little bit of config and you need never worry about cache invalidation again.&lt;/p&gt;
&lt;p&gt;Beyond simply setting caching headers on static assets, you may also want to consider how best to cache your page content. This can be a problem if your pages are entirely dynamic or heavily personalised however if your content is primarily the same for all users but with one or two personalised areas such as a shopping cart count/value or logged in user&amp;rsquo;s name, it may be better to load a cacheable generic version of the page without those parts then load in the dynamic parts using JavaScript.&lt;/p&gt;
&lt;p&gt;Finally, if you need finer grained control over how you cache or load your content and assets, you may want to consider using service workers to manage a local cache or pre-fetching content and assets using a &amp;lt;link rel=&amp;rdquo;prefetch&amp;rdquo; /&amp;gt; tag.&lt;/p&gt;
&lt;h2&gt;Implement a CDN&lt;/h2&gt;
&lt;p&gt;If I could recommend just one of the techniques on this page it would be this one. Seriously, just do it. Even if you&amp;rsquo;re just serving a single region a content delivery network can dramatically improve a site&amp;rsquo;s performance (if you use it correctly). A CDN builds on the browser caching techniques described above by caching your content at edge locations closer to your end users. This means that you get a double-whammy performance boost in that there&amp;rsquo;s less latency between the user and the content and, just as importantly, your server isn&amp;rsquo;t having to handle requests for content cached in the CDN, freeing up resources to process requests which can&amp;rsquo;t be cached on the CDN.&lt;/p&gt;
&lt;p&gt;There was a time not so long ago when using a CDN was pretty pricey but nowadays you can implement a CDN for almost nothing on the likes of Azure, AWS CloudFront and CloudFlare, or for literally nothing on CloudFlare&amp;rsquo;s free plan (though with some limitations). As if that wasn&amp;rsquo;t enough of an incentive, these CDNs often offer additional services either bundled or for a small additional cost. As an example, the paid CloudFlare plans (as included in Episerver DXC service) go beyond the basic caching proxy functionality of a traditional CDN and add DDoS protection, Web Application Firewall and image optimisation (e.g. automatically serving images in webp for browsers which support it), making your site even faster and more resilient.&lt;/p&gt;
&lt;h2&gt;Separate sites&lt;/h2&gt;
&lt;p&gt;While this option is simple and effective, there are many drawbacks. This technique involves setting up entirely separate Episerver instances, each running a site to serve a different location. You may choose to run them all on a shared codebase to make overall management easier but you&amp;rsquo;ll get none of the benefits of running a single instance covering multiple locales. As an example, there would be no option for a language to fall back to another if they exist in different Episerver instances and similarly adding hreflang tags to link between languages becomes a manual process which is near impossible to keep on top of. This will also have a cost impact in terms of Episerver licensing and infrastructure costs.&lt;/p&gt;
&lt;p&gt;What you do get from this approach is a simple method for setting up sites with infrastructure close to your end users so, if your country sites are entirely managed by local teams and don&amp;rsquo;t necessarily have to share a common structure or interlink, this may work for you but I suspect that&amp;rsquo;s a pretty rare scenario these days.&lt;/p&gt;
&lt;h2&gt;Mirroring&lt;/h2&gt;
&lt;p&gt;A slight variation on the separate sites approach would be a technique I&amp;rsquo;m going to refer to as mirroring. Why? Well, back in the days when Episerver had to be installed through &amp;ldquo;Deployment Centre&amp;rdquo;, there was a feature called mirroring which allowed you to create content in one environment and transfer it to a different environment using a synchronisation service. Fast forward to 2018 and Episerver&amp;rsquo;s Mark Hall created a content synchronisation mechanism which gives you the benefits of mirroring while addressing some of the issues of the official (though deprecated) Episerver mirroring feature.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/link/2a56ce5b87de41129725fc66c1ce4e7b.aspx&quot;&gt;https://world.episerver.com/blogs/Mark-Hall/Dates/2018/6/simple-content-synchronization/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Though the solution here has been designed to allow a configuration without a CMS UI in the production environment, you could just as easily use it to create content in a single location and push it out to Episerver instances closer to your site users. Though this solves the shared/linked content issue from the separate sites approach, be aware that there will still be the same licensing implications.&lt;/p&gt;
&lt;h2&gt;Replication&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;re running in Azure and you&amp;rsquo;re willing to accept the limitation of having only a single writeable region and having all others read-only then you could consider taking advantage of Azure SQL&amp;rsquo;s geo replication capabilities. This technique allows read-only replicas of your database to be maintained in different azure regions which, when coupled with app service instances in those regions, allows for duplicate but synchronised infrastructure which can be scaled out across regions. Unfortunately there are a few complications with this approach but, at the risk of being accused of blatant self-promotion, I wrote a blog post explaining how to use the existing components of an Episerver site in Azure to work around these complications.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;/link/9b0429b2998f491f917fe634a936be6f.aspx&quot;&gt;https://world.episerver.com/blogs/paul-gruffydd/dates/2018/10/going-global--geographically-scaling-an-episerver-site-over-multiple-azure-regions/&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Decoupling&lt;/h2&gt;
&lt;p&gt;So far we&amp;rsquo;ve looked at techniques which can be applied to an existing site with minimal changes but what if you&amp;rsquo;ve got a blank canvas? One option would be to look at decoupling the CMS from the website so that the CMS sits in a single location and is used simply as a data source with the rendering and serving of the content happening independently in locations closer to your end users.&lt;/p&gt;
&lt;p&gt;With the advent of the Episerver Content API, this has opened up various options for us in terms of how we use content produced in the CMS with probably the most popular being to use single page app techniques to read data from the CMS and render it using libraries such as React, Vue or [insert name of JavaScript library-du-jour]. There are plenty of articles out there which explain the benefits of this approach so, in the interests of balance I feel I should point out that single page apps aren&amp;rsquo;t a silver bullet. Without effective planning and caching between the app and the Episerver instance, this approach has many of the same pitfalls as simply rendering the site using razor on the server in that the data still needs to be requested from the Episerver instance and that data will be subject to the same network latency we were trying to avoid. Done well though, you can get some great results.&lt;/p&gt;
&lt;p&gt;Taking a slightly more extreme approach, Allan Thraen&amp;rsquo;s post explains how you can publish static HTML out of an Episerver instance and, as I think we can all agree, nothing scales quite as well as static HTML. As with the replication option above, this technique results in a read-only site and, if you&amp;rsquo;ve got hundreds of thousands of pages of constantly changing content this may not be the right option for you however, for smaller, more static sites, this would be easy to scale out and cost next to nothing to host.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.codeart.dk/blog/2018/9/episerver-static-web-site-generator/&quot;&gt;https://www.codeart.dk/blog/2018/9/episerver-static-web-site-generator/&lt;/a&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2&gt;What next?&lt;/h2&gt;
&lt;p&gt;If you&#39;ve made it this far - well done. Hopefully you&#39;ve got an idea of some of the options currently available to you either individually or in combination but you&amp;rsquo;d be forgiven for thinking that this all seems like a lot of work and that there should be just a setting hidden away in Episerver admin that sorts this for you. Well, though it&amp;rsquo;s not going to happen overnight, there are some interesting developments in this area. You may have noticed that most, if not all of Episerver&amp;rsquo;s newer products and features have been built as independent hosted services and that approach is likely to extend beyond new features into the core of the CMS and Commerce.&lt;/p&gt;
&lt;p&gt;At last year&amp;rsquo;s Ascend events, there was talk of a plan to create an externally hosted order service for Episerver commerce to handle the ordering process separately from the installed Episerver site so you can still take orders even when your site is in read-only mode. Fast forward to February this year and that order service had become a reality which was demonstrated at the Partner close-up event in Stockholm. Alongside the order service there were rather more subtle mentions of a hosted content service and, though there are no details of this service beyond describing a suite of independent microservices based on .Net Core, it wouldn&amp;rsquo;t be unreasonable to assume that it would be built in such a way as to allow for geographical replication. By taking a distributed approach, it reduces the risk from a failure of a single region and paves the way for the next generation of distributed web applications making it a win-win-win for Episerver, partners and customers.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/5/optimising-your-site-for-a-global-audience/</guid>            <pubDate>Thu, 02 May 2019 12:41:51 GMT</pubDate>           <category>Blog post</category></item><item> <title>Future-proof your URLs with emojis</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/4/future-proof-your-urls-with-emojis/</link>            <description>&lt;p&gt;They say a picture paints a thousand words and, in our fast-paced world, who&amp;rsquo;s really got time to read a thousand words? Indeed, it would seem that even the 160 characters of a text message is a struggle for some these days leading to the creation of a whole new language - Emoji. But why should text messages have all the fun while web sites are stuck in the past?&lt;/p&gt;
&lt;p&gt;Imagine a world where a website&amp;rsquo;s cookie policy could have the URL /&#127850; rather than /cookies. This not only shrinks the URL to something memorable but also removes any language barriers as everyone can understand a picture regardless of the language they speak. In fact, even when languages should be the same there can be subtle differences which can cause problems. Imagine you ran an english language recipe site targeting the UK and US. You might have a URL like the following:&lt;br /&gt;/recipes/tasty-aubergine-with-a-splash-of wine/&lt;/p&gt;
&lt;p&gt;But in the US they don&#39;t use the word aubergine, they call it an eggplant so this would need to be rewritten to:&lt;br /&gt;/recipes/tasty-eggplant-with-a-splash-of-wine/&lt;/p&gt;
&lt;p&gt;If we were to write this in emoji we could have the following:&lt;br /&gt;/&#128105;&amp;zwj;&#127859;/&#128523;&#127814;&#128166;&#127870;/&lt;br /&gt;Which, as you can see, is fun, concise, easy to remember and, most importantly, couldn&amp;rsquo;t possibly be misinterpreted.&lt;/p&gt;
&lt;p&gt;Time to take a look at how we could do this in Episerver.&lt;/p&gt;
&lt;p&gt;If we try to add an emoji without making any modifications, we get this.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/4e4298c05cbf4725a31daeedb021b9a5.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This is due to the validation rules put in place by Episerver but, if the last couple of years have shown anything, it&amp;rsquo;s that us plucky Brits won&amp;rsquo;t just sit back and accept arbitrary rules put in place by unelected Europeans &#128521;. Time to take back control&amp;hellip;&lt;/p&gt;
&lt;p&gt;First we need to modify the rules for validating allowed characters in URL segments which we can do by overriding the UrlSegmentOptions in an IConfigurableModule.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class UrlInitialisation : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        //Enable emojis in URLs
        context.Services.RemoveAll&amp;lt;UrlSegmentOptions&amp;gt;();
        context.Services.AddSingleton&amp;lt;UrlSegmentOptions&amp;gt;(s =&amp;gt; new UrlSegmentOptions
        {
            Encode = true,
            ValidCharacters = @&quot;\p{L}0-9\-_~\.\$\u00a9\u00ae\u2000-\u3300\ud83c\ud000-\udfff\ud83d\ud000-\udfff\ud83e\ud000-\udfff&quot;
        });
    }


    public void Initialize(InitializationEngine context) {
        //TODO

    }

    public void Uninitialize(InitializationEngine context) {
        //TODO
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this stage we can now add emojis to our URLs but I want to take it a step further and get Episerver to generate emoji URL segments. To do this, I&amp;rsquo;ve downloaded a set of emojis from &lt;a href=&quot;https://github.com/amio/emoji.json/blob/master/emoji.json&quot;&gt;here&lt;/a&gt; which I&amp;rsquo;ve modified to remove unnecessary info, make the keywords more relevant and to tidy up some of the keywords we&amp;rsquo;ll use for our replacements. The new format for the emojis is as follows:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;[
  {
    &quot;Character&quot;: &quot;&#129488;&quot;,
    &quot;Keywords&quot;: &quot;monocle|stuffy|rees-mogg&quot;
  },
  {
    &quot;Character&quot;: &quot;&#128169;&quot;,
    &quot;Keywords&quot;: &quot;dung|turd|poo|poop|brexit&quot;
  }

]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, I load that into a collection of .net objects using json.net and build a dictionary mapping words to their emoji replacement. Then finally I&amp;rsquo;ve hooked in to the IUrlSegmentCreator&amp;rsquo;s Created event where I loop through the dictionary replacing any words I can.&lt;/p&gt;
&lt;p&gt;Putting it all together we get this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class UrlInitialisation : IConfigurableModule
{
    private Injected&amp;lt;IUrlSegmentCreator&amp;gt; _urlSegmentCreator;
    private Dictionary&amp;lt;string, string&amp;gt; _emojis = new Dictionary&amp;lt;string, string&amp;gt;();

    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        //Enable emojis in URLs
        context.Services.RemoveAll&amp;lt;UrlSegmentOptions&amp;gt;();
        context.Services.AddSingleton&amp;lt;UrlSegmentOptions&amp;gt;(s =&amp;gt; new UrlSegmentOptions
        {
            Encode = true,
            ValidCharacters = @&quot;\p{L}0-9\-_~\.\$\u00a9\u00ae\u2000-\u3300\ud83c\ud000-\udfff\ud83d\ud000-\udfff\ud83e\ud000-\udfff&quot;
        });
    }


    public void Initialize(InitializationEngine context) {
        SetupEmojis();
        _urlSegmentCreator.Service.Created += CreateUrlSegment;

    }

    public void Uninitialize(InitializationEngine context) {
        _urlSegmentCreator.Service.Created -= CreateUrlSegment;
    }

    private void CreateUrlSegment(object sender, UrlSegmentEventArgs e)
    {
        foreach (var keyword in _emojis.Keys.OrderByDescending(x =&amp;gt; x.Length))
        {
            //TODO: Remove stop words
            e.RoutingSegment.RouteSegment = e.RoutingSegment.RouteSegment.Replace(keyword, _emojis[keyword]);
        }
        //remove dashes
        e.RoutingSegment.RouteSegment = e.RoutingSegment.RouteSegment.Replace(&quot;-&quot;, &quot;&quot;);
    }

    private void SetupEmojis()
    {
        var emojis = JsonConvert.DeserializeObject&amp;lt;List&amp;lt;EmojiInfo&amp;gt;&amp;gt;(File.ReadAllText(HttpContext.Current.Server.MapPath(@&quot;/App_Data/emojis.json&quot;)));
        foreach (var emoji in emojis)
        {
            foreach (var keyword in emoji.KeywordList)
            {
                if (!_emojis.ContainsKey(keyword))
                {
                    _emojis.Add(keyword, emoji.Character);
                }
                var plural = keyword + &quot;s&quot;;
                if (!_emojis.ContainsKey(plural))
                {
                    _emojis.Add(plural, emoji.Character);
                }
            }
        }
    }
}

public class EmojiInfo
{
    private IEnumerable&amp;lt;string&amp;gt; _keywordList = null;
    public string Character { get; set; }
    public string Keywords { get; set; }

    public IEnumerable&amp;lt;string&amp;gt; KeywordList
    {
        get
        {
            return _keywordList ?? Keywords.Split(&#39;|&#39;).Select(x =&amp;gt; x.Replace(&quot; &quot;,&quot;-&quot;));
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So, why not join the &#128526; kids and add emojis to your URLs today or are you &#128337;&#128560;?&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/4/future-proof-your-urls-with-emojis/</guid>            <pubDate>Mon, 01 Apr 2019 10:45:48 GMT</pubDate>           <category>Blog post</category></item><item> <title>Feature switching based on language branch</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/3/language-based-feature-switching/</link>            <description>&lt;p&gt;Look after a multi-lingual site for long enough and there will come a point where you need to roll out some functionality one language or region at a time. At the recent developer meetup in London I coughed my way through presenting a technique for doing just that.&lt;/p&gt;
&lt;p&gt;The idea came from a client requirement where they have a multi-lingual site run by local teams in those markets. Their requirement involved a fairly extensive third party integration but, because of the nature of that integration it needed to be rolled out one market at a time. From the CMS side, we needed to add a number of fields to a number of our content types and ultimately provide a checkbox which determines whether the new integration is displayed on the site.&lt;/p&gt;
&lt;p&gt;In theory that&#39;s pretty straightforward but it&amp;rsquo;s not the best editor experience to have a bunch of fields which won&amp;rsquo;t actually do anything if you&amp;rsquo;re not in one of the chosen languages the functionality has been rolled out to. My first thought was to create a validator which could be applied to the feature-specific fields and check whether the feature has been enabled for that language. If the feature hasn&amp;rsquo;t been enabled and an editor starts typing into one of the fields, they will be shown an error message letting them know that the functionality hasn&amp;rsquo;t been rolled out to their region. This approach is better than nothing but still isn&amp;rsquo;t the best experience for the editors as we&amp;rsquo;re still, in effect, offering them a feature they can&amp;rsquo;t have. For the best editor experience, we should only show the feature-specific fields if that feature is enabled but, as all of our languages use the same content types, how can we set about doing that?&lt;/p&gt;
&lt;p&gt;My initial assumption was that, if we need to make a change to the way the Episerver editor displays a field, we&amp;rsquo;d need to do something with dojo and, quite frankly, every time it looks like I&amp;rsquo;ll need to write some dojo my heart sinks. In this instance however I found an alternative.&lt;/p&gt;
&lt;p&gt;When looking into the validator approach I noticed that, on a validation attribute (or, in fact, any attribute), you can implement the IMetadataAware interface which requires an additional method called OnMetadataCreated. What this method does is to allow us to modify the settings which tell the Episerver editor UI how to display a given field before the field is rendered and, perhaps most interestingly in this case, gives us the option to mark the field as read-only or to hide it from view altogether.&lt;/p&gt;
&lt;p&gt;So, now onto some code. In order to keep things simple, the code below is a kind of simplified version of what was produced, built on the alloy site which you should all be familiar with. In the code below, our integration is a twitter widget which requires us to set a heading and an account to pull the feed from.&lt;/p&gt;
&lt;p&gt;First up we need a mechanism to determine whether the integration is available in a given language and there are many ways to do this. If you&amp;rsquo;re after a more comprehensive feature switching library, check out &lt;a href=&quot;https://github.com/valdisiljuconoks/FeatureSwitch&quot;&gt;this project&lt;/a&gt; from &lt;a href=&quot;/link/5341f632537c4b0ab6b8fb651bd310f8.aspx?userid=611d36e4-f1d2-e011-837e-0018717a8c82&quot;&gt;Valdis&lt;/a&gt; but, for the sake of simplicity, in the example below I&amp;rsquo;ve just used a Boolean field in a tab called &amp;ldquo;features&amp;rdquo; on the start page of the site. The reason for the special tab is twofold. First, it allows us some scope for extending this functionality to other features in future &amp;ndash; we just need to add an additional Boolean field for each feature and they&amp;rsquo;re all grouped together. The other reason is so we can apply permissions to the tab to stop local editors from enabling features they shouldn&amp;rsquo;t have access to.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Display(Name = &quot;Activate Twitter Functionality&quot;, GroupName = Global.GroupNames.Features)]
[CultureSpecific]
public virtual bool TwitterFeedActive { get; set; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next we create an attribute implementing IMetaDataAware which accepts the name of a feature, checks to see whether the feature is enabled in the current language and, if it&amp;rsquo;s not, modifies the field&amp;rsquo;s configuration to hide it from the editor.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class FeatureSwitch : Attribute, IMetadataAware
{
    private string _featureName;
    private IContentLoader _contentLoader;

    public bool AllowEditing
    {
        get
        {
            if (!_contentLoader.TryGet(ContentReference.StartPage, out StartPage startPage))
            {
                return false;
            }
            //Check whether our feature switch property exists and has been checked on the start page
            return startPage.Property.Any(x =&amp;gt; x.Name.Equals($&quot;{_featureName}Active&quot;))
                &amp;amp;&amp;amp; ((startPage.Property[$&quot;{_featureName}Active&quot;]?.Value as bool?) ?? false);
        }
    }

    public FeatureSwitch(string featureName)
    {
        _featureName = featureName;
        _contentLoader = ServiceLocator.Current.GetInstance&amp;lt;IContentLoader&amp;gt;();
    }

    public void OnMetadataCreated(ModelMetadata metadata)
    {
        metadata.ShowForEdit = AllowEditing;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we add the validation attribute to all relevant fields, specifying the feature they relate to.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Display(
    Name = &quot;Twitter Feed Heading&quot;,
    GroupName = SystemTabNames.Content,
    Order = 5)]
[CultureSpecific]
[FeatureSwitch(&quot;TwitterFeed&quot;)]
public virtual string TwitterFeedHeading { get; set; }

[Display(
    Name = &quot;Twitter Account&quot;,
    GroupName = SystemTabNames.Content,
    Order = 6)]
[CultureSpecific]
[FeatureSwitch(&quot;TwitterFeed&quot;)]
public virtual string TwitterAccount { get; set; }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And there we have it, an alloy site with the fields to set up a twitter feed on the EN site but not on the SV site.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/1befb8aaac6547bfbadb868855a7bf32.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Taking this a step further, it may not just be individual properties we want to limit to a specific language, it may be content types as well. Continuing the twitter feed example from above, you may want to add a specific block type to represent the twitter feed rather than just properties on a specific page type so how can we extend this approach to do that as well?&lt;/p&gt;
&lt;p&gt;If we have individual teams managing content in each language then we could use permissions on the content types to limit who can create content of that type however this doesn&amp;rsquo;t allow for scenarios where a given individual works in multiple languages. It&amp;rsquo;s also a second place for us to make changes which feels unnecessary given that we&amp;rsquo;ve already got a feature switch defined on our start page.&lt;/p&gt;
&lt;p&gt;The better option here is to tie in to the ContentTypeAvailabilityService which is responsible for providing the listings of content types we can use when we click to create a new content item. By taking this approach we can make use of our existing FeatureSwitch attribute.&lt;/p&gt;
&lt;p&gt;We could create our own implementation of the abstract ContentTypeAvailabilityService class but that would involve writing our own version of the existing logic used to retrieve and filter the content types which seems like a lot of work just to filter the list of content types. We could also consider inheriting from the DefaultContentTypeAvailabilityService and just overriding the bits we need but that is found in an internal namespace and so is prone to breaking changes without warning so, instead I&amp;rsquo;m going to intercept whichever implementation of the ContentTypeAvailabilityService is being used and add the filtering on top.&lt;/p&gt;
&lt;p&gt;First, we need to create a class inheriting from ContentTypeAvailabilityService with a constructor accepting the currently registered instance. Next, in the ListAvailable methods, we take the list generated by the registered ContentTypeAvailabilityService instance and loop through the available content types, checking for the FeatureSwitch attribute. If we find a content type with that attribute, we check the AllowEditing property on the attribute and, if it&amp;rsquo;s false, we remove the type from the list.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class CustomContentTypeAvailabilityService : ContentTypeAvailabilityService
{
    private readonly ContentTypeAvailabilityService _defaultContentTypeAvailabilityService;
    public CustomContentTypeAvailabilityService(ContentTypeAvailabilityService defaultContentTypeAvailabilityService)
    {
        _defaultContentTypeAvailabilityService = defaultContentTypeAvailabilityService;
    }
    public override AvailableSetting GetSetting(string contentTypeName)
    {
        return _defaultContentTypeAvailabilityService.GetSetting(contentTypeName);
    }

    public override bool IsAllowed(string parentContentTypeName, string childContentTypeName)
    {
        return _defaultContentTypeAvailabilityService.IsAllowed(parentContentTypeName, childContentTypeName);
    }

    public override IList&amp;lt;ContentType&amp;gt; ListAvailable(string contentTypeName, IPrincipal user)
    {
        return FilterAvailableList(_defaultContentTypeAvailabilityService.ListAvailable(contentTypeName, user));
    }

    public override IList&amp;lt;ContentType&amp;gt; ListAvailable(IContent content, bool contentFolder, IPrincipal user)
    {
        return FilterAvailableList(_defaultContentTypeAvailabilityService.ListAvailable(content, contentFolder, user));
    }

    private IList&amp;lt;ContentType&amp;gt; FilterAvailableList(IList&amp;lt;ContentType&amp;gt; list)
    {
        for (var i = list.Count-1; i &amp;gt;= 0; i--)
        {
            var contentType = list[i];
            var attributes = contentType.ModelType.GetCustomAttributes(true) ?? new object[0];
            foreach (var attribute in attributes)
            {
                var featureAttribute = attribute as FeatureSwitch;
                if (featureAttribute != null &amp;amp;&amp;amp; !featureAttribute.AllowEditing)
                {
                    list.RemoveAt(i);
                }
            }
        }
        return list;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, we need to register our class to intercept the existing ContentTypeAvailabilityService implementation. We can do this in an initialisation module as follows.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ContentTypeAvailabilityInitialization : IConfigurableModule
{
    public void ConfigureContainer(ServiceConfigurationContext context)
    {
        context.Services.Intercept&amp;lt;ContentTypeAvailabilityService&amp;gt;(
            (locator, defaultContentTypeAvailabilityService) =&amp;gt; new CustomContentTypeAvailabilityService(defaultContentTypeAvailabilityService));
    }
    public void Initialize(InitializationEngine context) { }
    public void Uninitialize(InitializationEngine context) { }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then finally we can mark some content types in the same way as we marked our properties and verify that they only appear on languages where that feature is enabled.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;/// &amp;lt;summary&amp;gt;
/// Used to add a twitter feed
/// &amp;lt;/summary&amp;gt;
[SiteContentType(GUID = &quot;D8C3E587-AA5D-4456-8BC7-47CF971CC835&quot;)]
[SiteImageUrl]
[FeatureSwitch(&quot;TwitterFeed&quot;)]
public class TwitterFeedBlock : SiteBlockData
{
    [Display(
        Name = &quot;Twitter Feed Heading&quot;,
        GroupName = SystemTabNames.Content,
        Order = 5)]
    [CultureSpecific]
    public virtual string TwitterFeedHeading { get; set; }

    [Display(
        Name = &quot;Twitter Account&quot;,
        GroupName = SystemTabNames.Content,
        Order = 6)]
    [CultureSpecific]
    public virtual string TwitterAccount { get; set; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As a final note, this technique isn&amp;rsquo;t limited to just restricting by language, you could easily use the same approach to handle multi-site scenarios, differing editor permissions or even more outlandish requirements like &amp;ldquo;we should only be able to add blog articles on a Friday afternoon&amp;rdquo;. Whatever the requirements, I hope you find it useful.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2019/3/language-based-feature-switching/</guid>            <pubDate>Wed, 06 Mar 2019 13:51:13 GMT</pubDate>           <category>Blog post</category></item><item> <title>Limiting total items in a content area while supporting personalisation</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2018/10/limiting-the-number-of-items-in-a-content-area-while-supporting-personalisation/</link>            <description>&lt;p&gt;As anyone who&amp;rsquo;s spoken to me at any length about Episerver can confirm, I&amp;rsquo;m something of a fan of Episerver&amp;rsquo;s inbuilt visitor group personalisation capabilities. It&amp;rsquo;s easy to use and, done right, can add a load of value for next to no effort but all too often I see it forgotten about during builds. The most common occurrence of this that I see is the use of a ContentArea&amp;rsquo;s Items collection rather than FilteredItems but I was reminded of another by Aniket&amp;rsquo;s recent(ish) &lt;a href=&quot;/link/d4c7643088894d32a5694efe69f3d354.aspx&quot;&gt;blog post&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a technique I&amp;rsquo;ve seen both in various &lt;a href=&quot;/link/e7723a75c6a24c6a8c5b952386265817.aspx&quot;&gt;blog posts&lt;/a&gt; and out in the wild being used on sites but, while it does exactly as it claims (sets a maximum number of items you&amp;rsquo;re allowed to add to a content area), it&amp;rsquo;s probably not what you need. Why? Take a look&amp;hellip;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/35fbd3ea988c4686b64b9e9267cd3bab.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;In this example we&amp;rsquo;ve got a content area for a hero carousel which can display no more than 3 blocks. To enforce this, I&amp;rsquo;ve added the validation attribute described in the posts linked above to limit the maximum number of items in the content area to 3. I&amp;rsquo;ve then set up some blocks so that only 3 will display. All good?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/d2d52ecf213e4fb1abf6e50a86d7ec7a.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Well, not really. Even though only 3 items will display, the validation attribute will throw a validation error because it doesn&amp;rsquo;t consider personalisation. If we remove items until it stops throwing an error, in this instance we&amp;rsquo;d end up with only a single item being displayed on the site.&lt;/p&gt;
&lt;p&gt;To fix the issue we could use FilteredItems as I mentioned above and that would kind of work but then consider this scenario&amp;hellip;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/5229607dd3de472d85ce9221ebaf3a55.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;As the editor will always be logged in and the validation will run in the context of the editor, the first item would be filtered out and so, even though we could quite feasibly return 4 items from the FilteredItems collection for a user viewing the site, the validation will let that through.&lt;/p&gt;
&lt;p&gt;A better alternative would be to look at each of the content area items in the Items collection and check whether they&amp;rsquo;re part of a personalisation group. We can then total up the number of groups plus the number of items which aren&amp;rsquo;t in a group and that will give us the maximum number of items in the content area after they have been filtered.&lt;/p&gt;
&lt;p&gt;Putting it all together, we get this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;/// &amp;lt;summary&amp;gt;
/// Sets the maximum item count in a content area once personalisation is applied
/// &amp;lt;/summary&amp;gt;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class MaxItemsAttribute : ValidationAttribute
{
    private int _maxAllowed;

    public MaxItemsAttribute(int MaxItemsAllowed)
    {
        _maxAllowed = MaxItemsAllowed;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var contentArea = value as ContentArea;

        // Get all items or none if null
        var allItems = contentArea?.Items ?? Enumerable.Empty&amp;lt;ContentAreaItem&amp;gt;();

        // Count the unique personalisation group names, replacing empty ones (items which aren&#39;t personalised) with a unique name
        var i = 0;
        var maxNumberOfItemsShown = allItems.Select(x =&amp;gt; string.IsNullOrEmpty(x.ContentGroup) ? (i++).ToString() : x.ContentGroup).Distinct().Count();

        return (maxNumberOfItemsShown &amp;gt; _maxAllowed) ? new ValidationResult($&quot;The property \&quot;{validationContext.DisplayName}\&quot; is limited to {_maxAllowed} items&quot;) : null;
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which we can use like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Display(Name = &quot;Hero Carousel&quot;)]
[AllowedTypes(typeof(HeroItem))]
[MaxItems(3)]
public virtual ContentArea HeroCarousel { get; set; }
&lt;/code&gt;&lt;/pre&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2018/10/limiting-the-number-of-items-in-a-content-area-while-supporting-personalisation/</guid>            <pubDate>Tue, 30 Oct 2018 14:06:20 GMT</pubDate>           <category>Blog post</category></item><item> <title>Geographically scaling an Episerver site over multiple Azure regions</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2018/10/going-global--geographically-scaling-an-episerver-site-over-multiple-azure-regions/</link>            <description>&lt;p&gt;There are many reasons why you might want to run your site out of multiple regions &amp;ndash; performance, SEO, disaster recovery, legislative or even just preference. In our current era of dev-ops and cloud technology, it&amp;rsquo;s become almost trivial to simultaneously deploy a codebase to servers around the world so you&amp;rsquo;d be forgiven for thinking that deploying an Episerver site to several global locations would be just as simple but there&amp;rsquo;s a catch. Behind every modern Episerver site is a SQL server database and, for all its plus points (and there are many), geographical distribution isn&amp;rsquo;t really SQL server&amp;rsquo;s fort&amp;eacute;. This means that you&amp;rsquo;re basically stuck with your database being hosted in a single location and, because your web servers need the fastest access they can to the DB, that also means that your web servers are limited to that same location.&lt;/p&gt;
&lt;h2&gt;Azure SQL to the rescue?&lt;/h2&gt;
&lt;p&gt;Unwilling to accept that limitation, I thought I&amp;rsquo;d take a look at Azure SQL to see if I could find a workaround and, though it shares a common heritage with SQL server, Azure SQL does have a couple of features which look like they may be of use.&lt;/p&gt;
&lt;p&gt;First up, &amp;ldquo;SQL Data Sync&amp;rdquo;. This feature is mainly for managing hybrid infrastructure so it&amp;rsquo;s really targeted at DBAs used to setting up SQL server replication but what it gives us is multiple writeable clones of our database which are magically sync&amp;rsquo;d by a separate task. But that&amp;rsquo;s where the good news stops. If you take a &lt;a href=&quot;https://docs.microsoft.com/en-us/azure/sql-database/sql-database-get-started-sql-data-sync#faq-about-setup-and-configuration&quot;&gt;look at the FAQs&lt;/a&gt;, alarm bells should start to ring. First of all, you need to tell it what to sync down to a column level in your database tables which, aside from taking forever to set up, is likely to cause problems in the future should the schema change as a result of upgrades, etc. There&amp;rsquo;s also the issue of timings. The synchronisation task happens on a schedule with a frequency between 5 minutes and 30 days which means that, even if you run the sync as frequently as possible, it may be 5 minutes before a change is synchronised and even longer before it would be visible on the site due to the caching within the Episerver DataFactory. In summary, you could probably make this route work but you&amp;rsquo;d most likely spend the remainder of your life wishing you hadn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Our other option is to use geo-replication. While this feature was primarily built for disaster recovery scenarios, it is much closer to what we need in that the synchronisation is a continuous process performed as the data changes rather than on a schedule. It&amp;rsquo;s also worth mentioning at this point that geo-replication in Azure SQL is really easy to set up. You basically click &amp;ldquo;Geo-Replication&amp;rdquo; on your database in Azure Portal, click on the region you want to replicate your database to and choose the characteristics of the database then just sit back and wait while Azure handles the rest.&lt;/p&gt;
&lt;p&gt;But there&amp;rsquo;s a catch. Because this mechanism was built for DR/failover scenarios, we&amp;rsquo;re left with a fully functioning primary database but our replicas are read-only. That said, as long as you don&amp;rsquo;t need to write to the Episerver Database from your remote sites, you can simply switch your site into &lt;a href=&quot;/link/2c79c884305a412fac72f38755529efb.aspx&quot;&gt;read-only mode&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So far, so good but what happens when you publish content in the CMS? Well, as you&amp;rsquo;ve only got 1 writable database, all of your editing would need to happen in your primary region. When you hit publish, the status of the piece of content is updated in the database and the publish event is fired. The remote site picks up that event and clears the relevant record from its cache to be rebuilt on the next request which would be fine if the database synchronisation completes before the event is fired but it doesn&amp;rsquo;t. The synchronisation happens asynchronously so there&amp;rsquo;s a slight delay between the primary returning to say it&amp;rsquo;s updated and the replica being updated. This delay is typically less than a few seconds but is just enough to cause us problems. If someone requests the updated page on the remote site between the primary updating and the replica receiving the update, the cache on the remote site will be rebuilt with the stale data and our change won&amp;rsquo;t be visible until that cache is cleared again so what we ideally need to do is to hold off on clearing the remote cache until the data has been replicated.&lt;/p&gt;
&lt;h2&gt;Delayed events&lt;/h2&gt;
&lt;p&gt;Just to recap on the remote events process as it stands for sites on Azure &amp;ndash; an event is raised by one instance of the site which adds details of the event to a message sent to a topic within Azure Service Bus. All instances of the site are subscribed to the topic and so each one receives and acts on the message. If we want to add in a delay for some subscribers, our easiest route is to create a second topic which the remote instances can subscribe to and pass messages from one to the other after the delay has passed.&lt;/p&gt;
&lt;p&gt;Given that it there&amp;rsquo;s no way to know for sure exactly when the relevant data will have been synchronised, we&amp;rsquo;re going to have to make an assumption as to how long this sync will take and delay the remote events to the remote server by that amount. There are a few ways we could go about doing this (custom event provider, Azure functions, Logic app, etc.) but I like to keep things simple and reuse as much existing functionality as we can.&lt;/p&gt;
&lt;p&gt;As luck would have it, Azure Service Bus has some features which will help us with this, namely auto-forwarding, filter rules and message scheduling. By using these, we shouldn&amp;rsquo;t need any additional code or services to monitor, wait and forward messages as the service bus should be able to handle this all for us.&lt;br /&gt;To get this up and running, I&amp;rsquo;ve created an initialisation module which sets up the topics (if they don&amp;rsquo;t exist) and adds a subscriber which will pick up messages and schedule the delivery of each message into the new topic with a slight delay (10 seconds).&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class ServiceBusGeoInitialisation : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            var connStr = ConfigurationManager.ConnectionStrings[&quot;EPiServerAzureEvents&quot;].ConnectionString;
            var topic = ConfigurationManager.AppSettings[&quot;LocalTopicName&quot;] ?? &quot;mysiteevents&quot;;
            var geoTopic = ConfigurationManager.AppSettings[&quot;DelayedTopicName&quot;] ?? &quot;mysiteeventsdelayed&quot;;
            var nsMgr = Microsoft.ServiceBus.NamespaceManager.CreateFromConnectionString(connStr);

            //Create our local topic if it doesn&#39;t already exist
            if (!nsMgr.TopicExists(topic))
            {
                nsMgr.CreateTopic(topic);
            }

            //Create our new topic if it doesn&#39;t already exist
            if (!nsMgr.TopicExists(geoTopic))
            {
                nsMgr.CreateTopic(geoTopic);
            }

            //Create a new subscription to forward messages from our local topic to our new topic with a delay
            if (!nsMgr.SubscriptionExists(topic, &quot;GeoForwarder&quot;))
            {
                var subscription = new SubscriptionDescription(topic, &quot;GeoForwarder&quot;)
                {
                    ForwardTo = geoTopic
                };
                nsMgr.CreateSubscription(subscription);

                var subscriptionClient = SubscriptionClient.CreateFromConnectionString(connStr, topic, &quot;GeoForwarder&quot;);
                //Clear out default rule
                subscriptionClient.RemoveRule(RuleDescription.DefaultRuleName);

                //Set up a new rule to match all messages and schedule for 10 seconds time
                var rule = new RuleDescription
                {
                    Name = &quot;GeoDelayRule&quot;,
                    Filter = new SqlFilter(&quot;1 = 1&quot;),
                    Action = new SqlRuleAction(&quot;SET sys.TimeToLive = &#39;00:00:10&#39;; SET sys.ScheduledEnqueueTimeUtc = sys.ExpiresAtUtc; SET sys.TimeToLive = &#39;00:30:00&#39;&quot;)
                };
                subscriptionClient.AddRule(rule);
            }
        }

        public void Uninitialize(InitializationEngine context)
        {
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For those wondering what&amp;rsquo;s going on in the filter rule, it would appear that Azure service bus SQL syntax doesn&amp;rsquo;t support any kind of date manipulation functions so I&amp;rsquo;ve had to find a workaround. In this instance I&amp;rsquo;m setting the TimeToLive value on the message to 10 seconds which automatically sets the ExpiresAtUtc value. I can then take the value of ExpiresAtUtc and use it to set the ScheduledEnqueueTimeUtc field before resetting the ExpiresAtUtc value back to 30 minutes. Crazy to have to do it that way but it works.&lt;/p&gt;
&lt;p&gt;So there you have it. One initialisation module and a bit of config and we&amp;rsquo;ve got a working Episerver site running happily in multiple Azure regions. There are, of course, other options available which I&amp;rsquo;ll aim to cover off in a subsequent post but hopefully you&amp;rsquo;ve found this useful.&lt;/p&gt;
&lt;p&gt;As always, the code posted here is simply a proof-of-concept rather than tested, production ready code so use with caution.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2018/10/going-global--geographically-scaling-an-episerver-site-over-multiple-azure-regions/</guid>            <pubDate>Tue, 02 Oct 2018 12:20:31 GMT</pubDate>           <category>Blog post</category></item><item> <title>Listing popular content with Profile Store</title>            <link>https://world.optimizely.com/blogs/paul-gruffydd/dates/2018/6/listing-popular-content-with-profile-store/</link>            <description>&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;p&gt;Every so often, a certain requirement comes up. Sometimes it&amp;rsquo;s called &amp;ldquo;Most Popular&amp;rdquo;, sometimes it&amp;rsquo;s called &amp;ldquo;Trending&amp;rdquo;, I&amp;rsquo;ve even seen it called &amp;ldquo;Hot topics&amp;rdquo; but, regardless of the name, the requirement&amp;nbsp;is the same &amp;ndash; Show a listing of the top x most popular pieces of content. On paper, this may sound straightforward but&amp;nbsp;it hides&amp;nbsp;bit of a challenge in that, in order to show the most popular pages, you need to know what content is popular which generally requires keeping track of page views within a given timeframe.&lt;/p&gt;
&lt;p&gt;One approach to this would be to&amp;nbsp;take advantage of auto boosting within Find to apply a hit boost to a search.&amp;nbsp;This would be pretty straightforward (and pretty efficient) however the hits tracked to apply this boost only come from tracked clicks from the results of a find search.&amp;nbsp;This means that hits from navigation, social sharing, google searches, etc. won&#39;t be counted so you may miss out on a fair proportion of the hits for a given page.&lt;/p&gt;
&lt;p&gt;An alternative approach would be to pull data from an analytics package such as Google Analytics or, as I&#39;ve chosen in this instance, Episerver Profile Store. The process would be similar&amp;nbsp;whether we were to use&amp;nbsp;GA&amp;nbsp;or&amp;nbsp;Profile Store&amp;nbsp;but this is a good opportunity to&amp;nbsp;take a look at how we can put the Profile Store to good use beyond simply tracking data and looking at it in Insight.&lt;/p&gt;
&lt;h2&gt;Tracking&lt;/h2&gt;
&lt;p&gt;The first thing we need to do is to track our page views and, within profile store, there are many ways we could do this. I could go into detail here but I think that&amp;rsquo;s been covered fairly comprehensively by &lt;a href=&quot;https://www.david-tec.com/2018/01/working-with-episerver-insight-and-the-episerver-profile-store-api/&quot;&gt;David Knipe&lt;/a&gt;&amp;nbsp;and &lt;a href=&quot;https://blog.nicolaayan.com/2018/04/episerver-insight-profile-store-implementation/&quot;&gt;Nicola Ayan&lt;/a&gt;&amp;nbsp;though, having said that, I&amp;rsquo;m going to use a slightly different method and use the &lt;strong&gt;[PageViewTracking]&lt;/strong&gt; attribute from the &amp;ldquo;EPiServer.Tracking.PageView&amp;rdquo; library available through NuGet. Why? Well, a few reasons. Partly because it&amp;rsquo;s nice and easy (you just add &lt;strong&gt;[PageViewTracking]&lt;/strong&gt; to your controller action and it does the rest), partly because I couldn&amp;rsquo;t get the &lt;strong&gt;[Tracking()]&lt;/strong&gt; attribute from Episerver.Tracking.Cms to work, but mostly because we need to track things in a consistent way.&lt;/p&gt;
&lt;p&gt;At present, it&amp;rsquo;s only really Episerver Advance personalisation which requires you to track specific data in the Profile Store and it requires that data in a specific format. As more and more features come to rely on the data in the Profile Store, they too will need this data in a consistent format so, to me at least, it seems reasonable to expect that those features would require data in the same format as Advance to avoid having to raise multiple page view tracking events in slightly different formats on each page request.&lt;/p&gt;
&lt;h2&gt;Aggregating the data&lt;/h2&gt;
&lt;p&gt;Now we&amp;rsquo;ve got data flowing in to our profile store instance, we can look at how we can use that data to power our listing. Profile Store comes with a rest API for querying data both from individual profiles and from the events which were tracked. In this instance we&amp;rsquo;re going to use the latter to pull out all recent &amp;ldquo;epiPageView&amp;rdquo; events (as tracked by &amp;ldquo;EPiServer.Tracking.PageView&amp;rdquo;). First of all though, let&amp;rsquo;s take a look at the structure of the data we&amp;rsquo;re requesting.&lt;/p&gt;
&lt;p&gt;The basic structure of an event tracked by the attribute mentioned above is as follows, where the top level of this object is common to all events tracked within Profile Store but the contents of the Payload can be any arbitrary data we want to add:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;{
    &quot;TrackId&quot;: null,
    &quot;DeviceId&quot;: &quot;bf4b2611-63f5-4364-899a-7017f6d044b5&quot;,
    &quot;EventType&quot;: &quot;epiPageView&quot;,
    &quot;EventTime&quot;: &quot;2018-05-31T15:50:38.4951303Z&quot;,
    &quot;Value&quot;: &quot;Viewed Start&quot;,
    &quot;Scope&quot;: &quot;463470c3-3eca-41d3-8b12-3f7f92f62d34&quot;,
    &quot;CountryCode&quot;: &quot;Localhost&quot;,
    &quot;PageUri&quot;: &quot;http://localhost:59422/&quot;,
    &quot;PageTitle&quot;: null,
    &quot;RemoteAddress&quot;: &quot;127.0.0.1&quot;,
    &quot;Payload&quot;: {
        &quot;epi&quot;: {
            &quot;contentGuid&quot;: &quot;bd437cef-41bd-4ebc-8805-0c20fcf4edcf&quot;,
            &quot;language&quot;: &quot;en&quot;,
            &quot;siteId&quot;: &quot;463470c3-3eca-41d3-8b12-3f7f92f62d34&quot;,
            &quot;ancestors&quot;: [
                &quot;43f936c9-9b23-4ea3-97b2-61c538ad07c9&quot;
            ],
            &quot;recommendationClick&quot;: null
        }
    },
    &quot;User&quot;: {
        &quot;Name&quot;: null,
        &quot;Email&quot;: &quot;&quot;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When we query for this data we get back an array of these objects plus some totals. I&amp;rsquo;m going to use RestSharp for the Rest requests and Newtonsoft.Json to deserialise my data into a slightly cut-down object representation of the data (shown below):&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;{
    &quot;Total&quot;: 123,
    &quot;Count&quot;: 123,
    &quot;Items&quot;: [
        {
            &quot;Payload&quot;:{
                &quot;epi&quot;: {
                    &quot;contentGuid&quot;: &quot;00000000-0000-0000-0000-000000000000&quot;,
                    &quot;language&quot;: &quot;en&quot;
                }
            }
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In order to get back the list of popular pages, we need to query Profile Store for &amp;ldquo;epiPageView&amp;rdquo; hits within a given timeframe and total the results. The basic URL for this query looks something like this:&lt;br /&gt;/api/v1.0/trackevents/?$filter=EventType eq epiPageView and EventTime gt 2018-05-01T00:00:00Z&lt;/p&gt;
&lt;p&gt;In an ideal world, we could do the aggregation as part of our query but unfortunately this isn&amp;rsquo;t supported right now so we&amp;rsquo;re going to have to do it ourselves.&lt;/p&gt;
&lt;p&gt;Obviously sifting through thousands of hits is not something we want to do on the fly so&amp;nbsp;we&#39;ll use a scheduled job. This scheduled job pulls back the epiPageView events in pages of 1000, tallies up the totals and saves the result to the Dynamic Data Store in a suitable format for future consumption, adding in the PageTypeId to allow us to query by PageType later.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    [ScheduledPlugIn(DisplayName = &quot;Index Recent Page Hits&quot;, DefaultEnabled = false, IntervalLength = 1, IntervalType = EPiServer.DataAbstraction.ScheduledIntervalType.Hours)]
    public class IndexRecentHits : ScheduledJobBase
    {
        //Settings
        private readonly string _apiRootUrl = ConfigurationManager.AppSettings[&quot;episerver:profiles.ProfileApiBaseUrl&quot;];
        private readonly string _appKey = ConfigurationManager.AppSettings[&quot;episerver:profiles.ProfileApiSubscriptionKey&quot;];
        private readonly string _eventUrl = &quot;/api/v1.0/trackevents/&quot;;
        private readonly string _timeWindow = ConfigurationManager.AppSettings[&quot;RecentHours&quot;] ?? &quot;24&quot;;
        private readonly int _resultsPerPage = 1000;

        private Dictionary&amp;lt;string, int&amp;gt; _recentHits = new Dictionary&amp;lt;string, int&amp;gt;();
        private bool _stopSignaled;

        private static DynamicDataStoreFactory _dataStoreFactory;
        private static IContentLoader _contentLoader;

        public IndexRecentHits(DynamicDataStoreFactory dataStoreFactory, IContentLoader contentLoader)
        {
            _dataStoreFactory = dataStoreFactory;
            _contentLoader = contentLoader;
        }

        public IndexRecentHits()
        {
            IsStoppable = true;
        }

        /// &amp;lt;summary&amp;gt;
        /// Called when a user clicks on Stop for a manually started job, or when ASP.NET shuts down.
        /// &amp;lt;/summary&amp;gt;
        public override void Stop()
        {
            _stopSignaled = true;
        }

        /// &amp;lt;summary&amp;gt;
        /// Called when a scheduled job executes
        /// &amp;lt;/summary&amp;gt;
        /// &amp;lt;returns&amp;gt;A status message to be stored in the database log and visible from admin mode&amp;lt;/returns&amp;gt;
        public override string Execute()
        {
            //Call OnStatusChanged to periodically notify progress of job for manually started jobs
            OnStatusChanged(String.Format(&quot;Beginning processing of recent hits&quot;));

            var totalProcessed = 0;
            var errorCount = 0;

            //Get the recent hit counts
            if (!int.TryParse(_timeWindow, out int recentHours))
            {
                recentHours = 24;
            }
            var fromDate = DateTime.Now.AddHours(0 - recentHours).ToUniversalTime().ToString(&quot;o&quot;);

            // Set up the request
            var request = GetTrackingRequest($&quot;EventType eq epiPageView and EventTime gt {fromDate}&quot;, _resultsPerPage);

            // Gather the data from Profile Store
            ProcessEventResults(1, request);

            if (_stopSignaled)
            {
                return &quot;Execution was cancelled by user&quot;;
            }


            var store = _dataStoreFactory.CreateStore(typeof(RecentHit));
            store.DeleteAll();

            foreach (var hit in _recentHits)
            {
                if (_stopSignaled)
                {
                    return &quot;Execution was cancelled by user&quot;;
                }
                try
                {
                    var keyParts = hit.Key.Split(&#39;_&#39;);
                    var page = _contentLoader.Get&amp;lt;SitePageData&amp;gt;(new Guid(keyParts.FirstOrDefault() ?? Guid.Empty.ToString()));
                    var recentHit = new RecentHit
                    {
                        PageId = page.ContentLink.ID,
                        PageTypeId = page.ContentTypeID,
                        Parents = _contentLoader.GetAncestors(page.ContentLink).Select(x =&amp;gt; x.ContentLink.ID).ToArray(),
                        Language = keyParts.LastOrDefault() ?? &quot;en&quot;,
                        Hits = hit.Value
                    };
                    store.Save(recentHit);
                }
                catch (Exception)
                {
                    errorCount++;
                }
                totalProcessed++;
                if (totalProcessed.ToString().EndsWith(&quot;0&quot;))
                {
                    OnStatusChanged($&quot;Indexed {totalProcessed} of {_recentHits.Count} with {errorCount} errors&quot;);
                }
            }

            return $&quot;Reindexed {totalProcessed} pages with {errorCount} errors&quot;;
        }

        #region Private Methods
        /// &amp;lt;summary&amp;gt;
        /// Makes a request to ProfileStore and processes results
        /// &amp;lt;/summary&amp;gt;
        private void ProcessEventResults(int page, RestRequest request)
        {
            OnStatusChanged($&quot;Fetching hits page {page}&quot;);
            if (_stopSignaled)
            {
                return;
            }

            //Handle pagination
            request.AddOrUpdateParameter(&quot;$skip&quot;, (page - 1) * _resultsPerPage);

            // Execute the request to get the events matching the filter
            var eventResponseObject = GetTrackingResponse(request);
            foreach (var result in eventResponseObject.Items)
            {
                //Add/update the hit count per event
                var key = $&quot;{result.Payload.Epi.ContentGuid}_{result.Payload.Epi.Language}&quot;;
                if (_recentHits.ContainsKey(key))
                {
                    _recentHits[key]++;
                }
                else
                {
                    _recentHits.Add(key, 1);
                }
            }

            //Repeat until all pages of results have been processed
            if (eventResponseObject.Total &amp;gt; _resultsPerPage * page)
            {
                ProcessEventResults(page + 1, request);
            }

        }

        /// &amp;lt;summary&amp;gt;
        /// Builds the ProfileStore request
        /// &amp;lt;/summary&amp;gt;
        private RestRequest GetTrackingRequest(string filter, int resultsPerPage)
        {
            var req = new RestRequest(_eventUrl, Method.GET);
            req.AddHeader(&quot;Ocp-Apim-Subscription-Key&quot;, _appKey);

            req.AddParameter(&quot;$top&quot;, resultsPerPage);
            req.AddParameter(&quot;$filter&quot;, filter);
            return req;
        }

        /// &amp;lt;summary&amp;gt;
        /// Serialises the ProfileStore response into an object
        /// &amp;lt;/summary&amp;gt;
        private TrackingObjectResponse GetTrackingResponse(RestRequest request)
        {
            var client = new RestClient(_apiRootUrl);
            var getEventResponse = client.Execute(request);
            return JsonConvert.DeserializeObject&amp;lt;TrackingObjectResponse&amp;gt;(getEventResponse.Content);
        }
        #endregion

    }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Putting it all together&lt;/h2&gt;
&lt;p&gt;So, now we&amp;rsquo;ve collected the data and put it together into a handy list format, it&amp;rsquo;s time to put it to use and get the listing onto our site. To do this I&amp;rsquo;m going to create a couple of helper functions to get the data (one returning typed data, the other returning all data):&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public IEnumerable&amp;lt;T&amp;gt; GetPopularPages&amp;lt;T&amp;gt;(ContentReference ancestor, string language, int numberOfResults) where T : PageData
{
    var store = _dataStoreFactory.CreateStore(typeof(RecentHit));
    var contentTypeId = _contentTypeRepository.Load&amp;lt;T&amp;gt;().ID;
    var hits = store.Items&amp;lt;RecentHit&amp;gt;().Where(x =&amp;gt;     x.Parents.Contains(ancestor.ID) &amp;amp;&amp;amp; x.Language.Equals(language) &amp;amp;&amp;amp; x.PageTypeId.Equals(contentTypeId)).OrderByDescending(x =&amp;gt; x.Hits).Take(numberOfResults).ToList();
    var contentRefs = hits.Select(x =&amp;gt; new ContentReference(x.PageId));
    return _contentLoader.GetItems(contentRefs, new LoaderOptions() { LanguageLoaderOption.Specific(CultureInfo.GetCultureInfo(language)) }).OfType&amp;lt;T&amp;gt;();
}

public IEnumerable&amp;lt;IContent&amp;gt; GetPopularPages(ContentReference ancestor, string language, int numberOfResults)
{
    var store = _dataStoreFactory.CreateStore(typeof(RecentHit));
    var hits = store.Items&amp;lt;RecentHit&amp;gt;().Where(x =&amp;gt; x.Parents.Contains(ancestor.ID) &amp;amp;&amp;amp; x.Language.Equals(language)).OrderByDescending(x =&amp;gt; x.Hits).Take(numberOfResults);
    var contentRefs = hits.Select(x =&amp;gt; new ContentReference(x.PageId));
    return _contentLoader.GetItems(contentRefs, new LoaderOptions() { LanguageLoaderOption.Specific(CultureInfo.GetCultureInfo(language)) });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From here, it&amp;rsquo;s just a matter calling one of these functions from a block or page and rendering the result. In my case, I created a block and added it to the news and events page of the alloy site which gave me this&amp;hellip;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/bd2a93064aaa4fec8bd9da61455d02e1.aspx&quot; alt=&quot;Image AlloyNewsPopular.png&quot; /&gt;&lt;/p&gt;
&lt;p&gt;For those interested, I&amp;rsquo;ve added the code (including the block) to a &lt;a href=&quot;https://gist.github.com/PaulGruffyddAmaze/69cbe6ed1020d85b39898e57cc9f6094&quot;&gt;Gist on GitHub&lt;/a&gt;&amp;nbsp;but do bear in mind that this has been created as a proof-of-concept rather than a battle-hardened, production-ready feature so use it with caution.&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;</description>            <guid>https://world.optimizely.com/blogs/paul-gruffydd/dates/2018/6/listing-popular-content-with-profile-store/</guid>            <pubDate>Fri, 08 Jun 2018 10:43:29 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>