Monday, January 25, 2021

SXA cache multilingual datasources

Sitecore SXA datasource caching

I bumped into an issue a while ago related to datasources and how they are handled in a Sitecore SXA environment. This is an overview of our setup:

  • using SXA (Sitecore Experience Accellerator) for Sitecore 9.2
  • a PageList component with a variant
  • an Item Query that refers to custom code (a query starting with "code:" - more info here)
  • custom code that return a list of items and is language dependent (meaning: the list of items is different per language)  

That last one is actually the trigger for my issue as I had used custom coded Item Queries before and they seemed to work fine. Even in a multilingual setup. But in this particular case the results per language were actually different items - not just the same items with translated content.

I noticed that there was some caching issue and in the end I contacted Sitecore Support and placed some information on Sitecore StackExchange: "Custom SXA PageList datasource is not working in multilingual environment".


ResolveRenderingDatasourceCache

With some research I found there was an issue with the ResolveRenderingDatasourceCache. That cache is not language dependent and so my results were wrong.

A first possible solution would be to disable that cache by patching the config setting XA.Foundation.LocalDatasources.ResolveRenderingDatasourceCache.Enabled to false. This works, but of course that turns off this cache completely.

I noticed that the resolveRenderingDatasource pipeline has 2 relevant functions:
  • Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.GetFromCache
  • Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.SetCache
This brings us to solution two, which would disable the cache only for datasources that start with "code:": we can override the process method in the SetCache class and add code like this:
if (args.DefaultDatasource.StartsWith("code")) { return; }
This works as well, we keep the cache for most of our datasources but it is still disabled for the coded ones. And so I thought of another final solution: let's check the generation of the cache key!

BuildCacheKey

Both methods (GetFromCache and SetCache) are using a BuildCacheKey method from an underlying ResolveRenderingDatasourceCacheBase class. This method seemed not to be used anywhere else so I decided to override that one and use it in both the GetFromCache and SetCache classes.
protected override string BuildCacheKey(ResolveRenderingDatasourceArgs args)
{
    var key = base.BuildCacheKey(args);
    if (!string.IsNullOrEmpty(key) && args.DefaultDatasource.StartsWith("code:", StringComparison.OrdinalIgnoreCase))
    {
        key += Sitecore.Context.Language.Name;
    }

    return key;
}
This solution makes the cache work in all situations. We keep the current implementation for most datasources (as those are not language dependent) and add the language to the key for those that might (the ones starting with "code:" and you might also add those starting with "query:" if needed).
 
Patch the processors as:
<sitecore>
  <pipelines>
    <resolveRenderingDatasource>
      <processor type="Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.GetFromCache, Sitecore.XA.Foundation.LocalDatasources" resolve="true">
        <patch:attribute name="type">Feature.Caching.Rendering.Datasources.GetFromCache, Feature.Caching</patch:attribute>
      </processor>
      <processor type="Sitecore.XA.Foundation.LocalDatasources.Pipelines.ResolveRenderingDatasource.SetCache, Sitecore.XA.Foundation.LocalDatasources" resolve="true">
        <patch:attribute name="type">Feature.Caching.Rendering.Datasources.SetCache, Feature.Caching</patch:attribute>
      </processor>
    </resolveRenderingDatasource>
  </pipelines>
</sitecore>

A change request has been made to adjust this in a future version of SXA - but in the meantime you can easily patch it yourself if needed. Keep the cache and enable different results in different languages!


 

Thursday, January 21, 2021

Sharing available renderings in multisite SXA

Sitecore SXA available renderings

We have a multisite setup in SXA to easily create (small) websites and have been sharing a lot of configuration, layout information and content between those. Check my latest SXA blog posts for more information.

We did already cover sharing placeholder settings. But if we can share those, we might as well share the available renderings configuration. This is a list of renderings that is defined per site to define the availability within the SXA Toolbox - and since SXA 1.8 we can also define the structure of the toolbox with these Available renderings. As explained in the documentation, you have to check the box "Group renderings in sections according to Available Renderings items in the site" to do so.


I believe it is a best practice to do this so that feature is always on in my projects.

But let's get back to sharing...  We have a shared site (and master site) setup, so it would be a good idea to define the available renderings in the shared site and not in every site (created from the master site). This idea was on my radar and I got triggered by Toby Gutierrez to really try this now.


Sharing available renderings

I am going to try the same principle I used for the placeholder settings: if the current site has no configuration in place -meaning no children underneath the available renderings parent node- we check the shared site(s) and get the configuration there.

The toolbox

First of all, we need to figure out how the toolbox works. There is a handler (sxa-toolbox-feed.ashx) but that is not to much use for us here as it works with data that is retrieved elsewhere. I noticed however that in case the renderings grouping is turned on, a AvailableRenderingsOrderingService is called. This service will read the configuration and use this to structure the toolbox data that was passed along. As a side affect, it will also filter (again) on the available renderings.

This means that if you are using the grouping -as I do- you will have to override this. Let's do that:
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.DependencyInjection;
using Sitecore.XA.Foundation.Editing.Models;
using Sitecore.XA.Foundation.Multisite;
using Sitecore.XA.Foundation.Presentation;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;
using Sitecore.XA.Foundation.SitecoreExtensions.Repositories;

namespace Feature.Multisite.Editing
{
  public class AvailableRenderingsOrderingService : Sitecore.XA.Foundation.Editing.Service.AvailableRenderingsOrderingService
  {
    public AvailableRenderingsOrderingService(
      IPresentationContext presentationContext,
      IMultisiteContext multisiteContext,
      IContentRepository contentRepository) : base(presentationContext, multisiteContext, contentRepository)
    {
    }

    public override IList<AvailableRenderingEntry> GetOrderedRenderings(Item siteItem, IList<Item> renderings = null)
    {
      var orderDictionary = new Dictionary<ID, ValueTuple<int, string>>();
      var presentationItem = PresentationContext.GetPresentationItem(siteItem);
      var folder = presentationItem?.FirstChildInheritingFrom(Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.ID);
      if (folder != null)
      {
        if (!folder.Children.Any())
        {
          var shared = ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSitesWithoutCurrent(siteItem);
          foreach (var site in shared)
          {
            presentationItem = PresentationContext.GetPresentationItem(site);
            folder = presentationItem?.FirstChildInheritingFrom(Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.ID);
            if (folder != null && folder.Children.Any())
            {
              break;
            }
          }
        }

        if (folder != null)
        {
          var sections = (CheckboxField)folder.Fields[Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.Fields.GroupRenderingsInSections];
          if (sections != null && sections.Checked)
          {
            var array = folder.Children.Where(item => !string.IsNullOrEmpty(item[Sitecore.XA.Foundation.Presentation.Templates._RenderingsList.Fields.Renderings])).ToArray();
            for (var index1 = 0; index1 < array.Length; ++index1)
            {
              var fieldRenderings = (MultilistField)array[index1].Fields[Sitecore.XA.Foundation.Presentation.Templates._RenderingsList.Fields.Renderings];
              for (var index2 = 0; index2 < fieldRenderings.TargetIDs.Length; ++index2)
              {
                if (!orderDictionary.ContainsKey(fieldRenderings.TargetIDs[index2]))
                {
                  orderDictionary.Add(fieldRenderings.TargetIDs[index2], new ValueTuple<int, string>((index1 + 1) * 1000 + index2, fieldRenderings.InnerField.Item.DisplayName));
                }
              }
            }
          }
        }
      }

      return renderings?.Select(r => new AvailableRenderingEntry
      {
        RenderingItem = r,
        Order = orderDictionary.ContainsKey(r.ID) ? orderDictionary[r.ID].Item1 : 0,
        SectionName = orderDictionary.ContainsKey(r.ID) ? orderDictionary[r.ID].Item2 : r.Parent.DisplayName
      }).OrderBy(r => r.Order).ToList();
    }
  }
}  
This code is almost identical as the original one. The difference is in the GetOrderedRenderings function where we check the existance of a local configuration (folder.Children.Any) and try the shared sites (using the SharedSitesContext) if needed.

The service is registered in the config, so we patch that config to include our version:
<sitecore>
  <services>
    <register serviceType="Sitecore.XA.Foundation.Editing.Service.IAvailableRenderingsOrderingService, Sitecore.XA.Foundation.Editing"
              implementationType="Sitecore.XA.Foundation.Editing.Service.AvailableRenderingsOrderingService, Sitecore.XA.Foundation.Editing" lifetime="Singleton">
      <patch:attribute name="implementationType">Feature.Multisite.Editing.AvailableRenderingsOrderingService, Feature.Multisite</patch:attribute>
    </register>
  </services>
</sitecore>

That's it. Now we can safely delete all children from the Available renderings node in our sites that use our shared site. The toolbox in those sites will use the configuration defined in the shared site.


But...

Note that this only works if the grouping is enabled. As I always do this, I'm not going to try to implement a solution if it's not. This also means that the toolbox handler might be getting too much renderings as a parameter, but the result is ok so that's fine for me. 

However, if you do require such a solution without the grouping you'll need to check the getPlaceholderRenderings pipeline and especially the GetModuleRenderings processor. This has a function called GetSiteRenderingSources and you'll have to add the same logic there as I used in the AvailableRenderingsOrderingService.


Conclusion

Yet another configuration that we can share across all the created sites...  this makes it easier to handle if we add/remove components that can be used on those sites - we only have to do this on one spot from now on.


Monday, January 11, 2021

Sitecore personalization with device detection

Sitecore device detection personalization rules

I had a request from a content editor to add a different (large) image to a page depending on the device - on smaller screens it had to be a completely different image that was better suited. So the normal approach of a responsive image didn't work here as the image source was different and not just the size.



A first test was made by using the grid options provided by SXA. This way the editor can add both images and select when they should appear by setting the visibility as none on the unwanted devices per image.

Although this works on first sight, there is something that made us not use this - browsers will load both images even if they are marked not to be displayed by css. As we are trying to find a solution to have better mobile experience, this is not what we want.


Sitecore personalization - "where device type is value"

Out of the box Sitecore has some personalization rules that work with the device detection. As mentioned in the documentation we can use this to set up personalization targeting users differently based on their device. Many rules are available and documented. The screen size rules might seem an option, but those are not very easy to configure to detect what we wanted. A better suited rule would be the check on device type: "where device type is value". 

But.. although it is still in the documentation and screenshots, we couldn't find that rule. With the Sitecore search we did find it - in a section with obsolete rules :(  There is another -very similar- rule now though: "where device type is one of". This does almost the same but you can give a list of device to match - which seems even better! 

When testing the rule we found something weird though.


Where device type is one of

I created a page with a simple component and two datasources, one with the desktop image and one with the mobile image. When we tested this it worked once, but most of the time it didn't. We were using the Chrome developer tools device simulator as we didn't have access to the test environment with a mobile phone. I started the investigation with a look in the code.   

I started by checking the DeviceTypeCondition in the CES assembly, but that is marked obsolete (just as the item in Sitecore) - we should check Sitecore.ContentTesting.Rules.Conditions.DeviceTypeCondition instead. 

This new version of the rule has some twists however that seemed weird. When I debugged this code I noted that the cast from ruleContext to DeviceRuleContext always failed and in the RuleDeviceInformationManager this leads to a fallback mechanism - the getFallbackUserAgent pipeline.
DeviceRuleContext deviceRuleContext1 = ruleContext as DeviceRuleContext;
if (deviceRuleContext1 != null)
{
    ...
    string deviceRuleContext2 = this.GetUserAgentFromDeviceRuleContext(deviceRuleContext1);
    ...
    return ...
}
GetFallbackUserAgentArgs args = new GetFallbackUserAgentArgs();
GetFallbackUserAgentPipeline.Run(args);
When I checked the pipeline I found one processor: GetUserAgentFromTracker. And then it made sense to me...
The rule is getting the user agent from the tracker and not from the request - so the first call in the session determines the device.


Problem?

Although it is very weird that a piece of code always seems to go into what is called a fallback, is this actually a problem? Sitecore support thinks not - I think it is. I added a proposal to the Sitecore product ideas portal where you can upvote it if you agree...

So why is this a problem for me? My editors want to test their setup before going live. The site has a preview target where this can be accomplished but that preview target is (obviously) not publicly available. To properly test some content an editor would have to connect their mobile devices with a VPN or such and that is not that simple within some companies. It is much easier to use simulator tools like the one in Google Chrome. To use those however, one would have to clear all the site cookies before switching devices. This is possible... but with all due respect to editors, I think it wouldn't take that long before we get a request for help because the device rule is not working...

And what happens when there is no tracker? With all privacy regulations in place these days one cannot assume we do have a tracker.

The fix

We could create our own custom version of the rule - this works but we did bump into obsolete Sitecore code. It's an option - not sure whether it is a good one.

 Another option is to use the pipeline. Adding another processor before the tracker fallback will do the trick as well. For now I couldn't find any other usage except for the device rules so we might "fix" more than one...  

 Pipeline code

public class GetUserAgentFromContext : Sitecore.CES.DeviceDetection.Pipelines.GetFallbackUserAgent.GetFallbackUserAgentProcessor
{
    protected override string GetUserAgent()
    {
        return HttpContext.Current?.Request.UserAgent;
    }
}
<sitecore>
   <pipelines>
     <getFallbackUserAgent>
       <processor patch:before="*[1]" type="Feature....GetUserAgentFromContext, Feature..." />
     </getFallbackUserAgent>
   </pipelines>
</sitecore>
I've placed my processor first to keep the tracker as the fallback. If the context code found a useragent the other processors won't do anything anymore - if not, they will still do their job.




Monday, January 4, 2021

Sitecore media cache headers on pdf extension

MediaCache headers on PDF extension

MediaResponse.MaxAge

Based upon a recommendation from Google we set the MediaResponse.MaxAge setting to 365.00:00:00. This sets the Cache-Control header for media from the Sitecore MediaLibrary to "public, max-age=31536000", which is very good for images within the site. The header tells browsers that cached content younger than max-age seconds can be used without consulting the server- it should be used for content that doesn't change. 

But.. we also have other media types within the MediaLibrary - and today I am focusing on one particular type here being PDF documents. We wanted other cache settings for the pdf documents as we did run into a situation where a pdf file did change and only the internet-gods know where the old document was cached - it was not that easy to get rid off. So, I would like to be able to alter the cache settings for certain media types.


SXA and the MediaRequestHandler 

Note that I am using SXA. The Sitecore eXperience Accelerator makes our developer lives so much easier on various parts of a Sitecore development process, and again this is one of those occasions. If you have SXA, you have a Sitecore.XA.Foundation.MediaRequestHandler.MediaRequestHandler which overrides the default Sitecore.Resources.Media.MediaRequestHandler and this handler introduces 2 new pipelines. We could try to override this code again but it seems like a better option to check the pipelines. The first one is the  <mediaRequestHandler> pipeline and has some processors from SXA within. The second one is much more interesting for me here and is called <mediaRequestHeaders>. This pipeline is called just after setting the default media headers and can be used to alter them. By default it is empty (it doesn't even exist) - let's add a processor to it.

 MediaRequestHeaders pipeline

Adding a processor to the pipeline is rather easy:

<mediaRequestHeaders>
    <processor type="Feature.Caching.Pipelines.MediaRequestHeaderProcessor, Feature.Caching" />
</mediaRequestHeaders>
Now I just need to write some code for that processor:
public class MediaRequestHeaderProcessor
{
    public void Process(MediaRequestHeadersArgs args)
    {
        Assert.ArgumentNotNull(args, nameof(args));
        var media = args.Media;
        var cache = args.Context.Response.Cache;

        if (media.MimeType.StartsWith("application", StringComparison.OrdinalIgnoreCase))
        {
            cache.SetMaxAge(TimeSpan.FromMinutes(15));
            cache.SetCacheability(HttpCacheability.ServerAndPrivate);
            cache.AppendCacheExtension("no-cache");
        }
    }
}
The code provided here is an example of what it could be. You can do anything you want in here actually...  Our processor gets the media item and the Cache context from the provided arguments - we don't have to fetch any extra data here which is nice. We check the MimeType of the media item and act accordingly. Note again that we can implement any logic for any media item here, based on any type of information we can get from the media item (type, extension, …).

Using the cache context we can set all the header information we want. As an example in the code I am setting the max-age, the cacheability and I'm appending a custom extension. This will result in a cache header like "private, no-cache, max-age=900". 

Such a header makes the browser cache the result for 900 seconds, makes sure no intermediate (proxy) servers can cache it, and tells the browser not to trust a local version without a server agreement. The no-cache addition doesn't mean it can't be cached, it means a browser must check ("revalidate") with the server before using the cached resource. For our pdf documents, this sounds reasonable.


Conclusion

SXA made my life easier once again 😃

The extra mediaRequestHeaders pipeline made it very easy to include any business logic I want to alter cache headers that are being send with media requests.


Thanks to Sitecore StackExchange - as this answer from Richard helped me into the right direction.