Sitecore Xperiences - The things I've seen as a Sitecore Developer

(Bug-free) Custom Form IDs in Sitecore Forms

Some time ago I was challenged by one requirement need from the marketing team, related to the Sitecore Forms that we put together for them.

In order to do a better event tracking with Google Tag Manager, we had to give the power for Content Editor to customize the form ID. Instead of a hard to read string, composed by concatenations of the form and the session Guids, we should enable any kind of arbitrary string to be configured.

Speaking to my long time friend and Hackathon partner Joao Neto, who is great with Experience Forms, he put together this solution. What amazing friend hu? Joao’s solution does the trick as the marketing team requested, but introduced a 500 error that was affecting the Google ranking – and that, as we know, is also something that marketers love to hate.

So I was provided with the time necessary to investigate and fix this error and now share it with our community.

The error

The forms with custom IDs will trigger 500 errors when doing the field tracking – which happens when you fill a field and move to the next field by pressing TAB.

These errors are not visible to final users, they occur behind the scenes and can potentially affect your search ranking. You can see them happening by using tools like Fiddler or Developer Tools.

The errors are also tracked as Error Interactions of your current Contact:

Error 500 on Sitecore Forms

Why it breaks?

The issue happens because there are some native Javascript that uses the Form ID to extract the Session Id. Since we have modified the Form ID, then this code will fail, causing the issue.

The fix

In order to fix this issue, we will store the original form ID as another attribute in the form, then change the defective javascript to take from that attribute instead of the Form ID itself.

 

STEP 1 – Save the original ID

Looking back to Joao’s code, the class that we need to modify is “SetFormId“.

Modify the line 34 adding the following two lines, before assigning the customId to the form:

if (!args.Attributes.ContainsKey("originalId"))
   args.Attributes.Add("originalId",args.FormHtmlId);
// Setup custom form ID
args.FormHtmlId = customId;

 

STEP 2 – Change the JS file to pick from that attribute

The following native JS must be changed: /sitecore%20modules/Web/ExperienceForms/scripts/form.tracking.js

The method “getSessionId” must be modified to the following code:

function getSessionId(form) {
   var formId = form[0].id;
   // The fix starts here
   if ($jq(form[0]).attr("originalId")!==undefined)
      formId = $jq(form[0]).attr("originalId");
   // The fix ends here
   var targetId = formId.slice(0, -(formId.length - formId.lastIndexOf("_") - 1)) + "FormSessionId";
   var element = form.find("input[type='hidden'][id=\"" + targetId + "\"]");
   return element.val();
}

Posted in Experience Forms

Malformed querystring breaks Sitecore Experience Forms (Value cannot be null. Parameter name: key)

We noticed a bug in Sitecore Experience Forms 9.0.2: when you access a page that has a form passing wrong query string parameters, the system responds with a Runtime Error.

The Error…

For instance, the following url:
==> http://local.myclient.com/myform?good=1&bad

Because the query string parameter “bad” does not have a corresponding value, accessing this page will result in a error like this:

Server Error in ‘/’ Application.


Value cannot be null.
Parameter name: key

Description: An unhandled exception occurred.

Exception Details: System.ArgumentNullException: Value cannot be null.
Parameter name: key

Source Error:

An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.


Stack Trace:

[ArgumentNullException: Value cannot be null.
Parameter name: key]
   System.ThrowHelper.ThrowArgumentNullException(ExceptionArgument argument) +49
   System.Collections.Generic.Dictionary`2.Insert(TKey key, TValue value, Boolean add) +14789605
   System.Linq.Enumerable.ToDictionary(IEnumerable`1 source, Func`2 keySelector, Func`2 elementSelector, IEqualityComparer`1 comparer) +283
   Sitecore.ExperienceForms.Mvc.Pipelines.RenderForm.SetFormParameters.Process(RenderFormEventArgs args) +639
   (Object , Object ) +14
   Sitecore.Pipelines.CorePipeline.Run(PipelineArgs args) +484
   Sitecore.Pipelines.DefaultCorePipelineManager.Run(String pipelineName, PipelineArgs args, String pipelineDomain, Boolean failIfNotExists) +236
   Sitecore.Pipelines.DefaultCorePipelineManager.Run(String pipelineName, PipelineArgs args, String pipelineDomain) +22
   Sitecore.Mvc.Pipelines.PipelineService.RunPipeline(String pipelineName, TArgs args) +195
   Sitecore.Mvc.Pipelines.PipelineService.RunPipeline(String pipelineName, TArgs args, Func`2 resultGetter) +161
   Sitecore.ExperienceForms.Mvc.Html.HtmlExtensions.BeginRenderRouteForm(HtmlHelper htmlHelper, FormViewModel model, Boolean isPost) +301
   ASP._Views_FormBuilder_Form_cshtml.Execute() in D:\Git\ADMI\src\Feature\ADMI.Jobs\Forms\code\obj\CodeGen\Views\FormBuilder\Form.cshtml:6
   System.Web.WebPages.WebPageBase.ExecutePageHierarchy() +252
   System.Web.Mvc.WebViewPage.ExecutePageHierarchy() +148
   System.Web.WebPages.WebPageBase.ExecutePageHierarchy(WebPageContext pageContext, TextWriter writer, WebPageRenderingBase startPage) +122
   System.Web.Mvc.ViewResultBase.ExecuteResult(ControllerContext context) +375
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultFilterRecursive(IList`1 filters, Int32 filterIndex, ResultExecutingContext preContext, ControllerContext controllerContext, ActionResult actionResult) +88
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultFilterRecursive(IList`1 filters, Int32 filterIndex, ResultExecutingContext preContext, ControllerContext controllerContext, ActionResult actionResult) +775
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultFilterRecursive(IList`1 filters, Int32 filterIndex, ResultExecutingContext preContext, ControllerContext controllerContext, ActionResult actionResult) +775
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultFilterRecursive(IList`1 filters, Int32 filterIndex, ResultExecutingContext preContext, ControllerContext controllerContext, ActionResult actionResult) +775
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultWithFilters(ControllerContext controllerContext, IList`1 filters, ActionResult actionResult) +81
   System.Web.Mvc.ControllerActionInvoker.InvokeAction(ControllerContext controllerContext, String actionName) +701

Version Information: Microsoft .NET Framework Version:4.0.30319; ASP.NET Version:4.7.3282.0


 

…The root cause…

Decompiling the “Sitecore.ExperienceForms.Mvc.dll” shows exactly where the issue happens: one of the processors at the <forms.renderForm>, more precisely the “Sitecore.ExperienceForms.Mvc.Pipelines.RenderForm.SetFormParameters” processor, is missing a simple null check.

 

…And the fix!

Of course, I have tried to extend the original class, to apply my fix in a more “surgical” way. However, due to a private property, we will need to re-implement the whole class.

The fix is very simple and will only require a class and a config patch.

STEP 1 – Create the new SetFormParameters processor

Here is how your processor class should look like. The code is fully commented to show exactly what has been modified and what is simply copied/pasted from the original class.

using System.Collections.Generic;
using System.Linq;
using System.Web.Routing;
using Sitecore.Diagnostics;
using Sitecore.ExperienceForms;
using Sitecore.ExperienceForms.Mvc.Pipelines.RenderForm;
using Sitecore.Mvc.Configuration;
using Sitecore.Mvc.Pipelines;

namespace MyProject.Pipelines.Forms
{
 /// <summary>
 /// Pipeline that implements a fix for Sitecore.ExperienceForms.Mvc.Pipelines.RenderForm.SetFormParameters
 /// The fix is basically a null check in a LINQ expression, but due to a private property
 /// we are forced to re-implement the whole class in order apply the fix
 /// </summary>
 public class SetFormParameters : MvcPipelineProcessor<RenderFormEventArgs>
 {
    /// <summary>
    /// This is the private property that we need to re-implement
    /// </summary>
    private readonly IQueryStringProvider _queryStringProvider;

    /// <summary>
    /// Constructor is identical to the original class
    /// </summary>
    /// <param name="queryStringProvider"></param>
    public SetFormParameters(IQueryStringProvider queryStringProvider)
    {
       Assert.ArgumentNotNull((object) queryStringProvider, nameof (queryStringProvider));
       this._queryStringProvider = queryStringProvider;
    }

    /// <summary>
    /// This is where the fix is applied - whole method is identical to original
    /// except for the LINQ expression in the end of the file
    /// </summary>
    /// <param name="args"></param>
    public override void Process(RenderFormEventArgs args)
    {
       Assert.ArgumentNotNull((object) args, nameof (args));
       args.FormHtmlId = args.HtmlHelper.AttributeEncode(args.HtmlHelper.ViewData.TemplateInfo.GetFullHtmlFieldId(args.ViewModel.ItemId));
       args.Attributes = new Dictionary<string, object>()
       {
          {
             "enctype",
             (object) "multipart/form-data"
          },{
             "id",
             (object) args.FormHtmlId
          },{
             "data-sc-fxb",
             (object) args.ViewModel.ItemId
          }
       };
       if (!string.IsNullOrEmpty(args.ViewModel.CssClass))
          args.Attributes.Add("class", args.ViewModel.CssClass);
       args.QueryString = new RouteValueDictionary(
          _queryStringProvider.QueryParameters.AllKeys
             .Where(p=>p!=null) // <--- The fix itself is here
             .ToDictionary(key => key,
                key => (object) _queryStringProvider.QueryParameters[key]));
                args.RouteName = MvcSettings.SitecoreRouteName;
    }
 }
}

STEP 2 – Register your processor to make it load instead of the original one

And of course, let’s register the processor using the following config patch:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
 <sitecore>
 
 <forms.renderForm>
 <processor type="MyProject.Pipelines.Forms.SetFormParameters, MyProject" resolve="true" 
 patch:instead="processor[@type='Sitecore.ExperienceForms.Mvc.Pipelines.RenderForm.SetFormParameters, Sitecore.ExperienceForms.Mvc']"/>
 </forms.renderForm>

 </pipelines>
 </sitecore>
</configuration>

 

Is this officially recognized as a bug?

Yes, it a known bug, registered with the public reference number 324457 (More information about public reference numbers can be found here: https://kb.sitecore.net/articles/853187)

This bug was resolved in Sitecore 9.3, as can be seen in the release notes:
https://dev.sitecore.net/Downloads/Sitecore%20Experience%20Platform/93/Sitecore%20Experience%20Platform%2093%20Initial%20Release/Release%20Notes

 

“​​​​If you use a query string that contains parameter without a value to open a page that contains a form, the form is not rendered​.”

Posted in Experience Forms, Sitecore 9

Custom rule condition to find Top-N Profile Key

Here we are for another simple yet useful tip from the Smart Bookshelf Demo. Although we are now improving it with some Artificial Intelligence, the first version has Sitecore Rules-based Personalization in the hearth of the recommendation mechanism.

Our desired behavior is to light-up LED strips from the top 3 Book Genres, such as seen below:

Face Detection picture

The books you see on screen are shown according to the top 3 genres for the current visitor.

It automatically scrolls, showing one genre each time. Here is how the component looks like:

Recommended Books

Our content tree is composed by Genre Pages, each of them with respective Profile Card assigned:

Mapping Genres and Profile Cards

So when your visitor visits a Genre Page, he or she will get points for that respective Profile Card, enabling Sitecore to register and track what are the preferred genres. The same thing also happens when a Book Page is visited (achieved by using the technique explained in my last post).

At this point we are already able to see preferred genres for our contacts, by opening any known contact at the Experience Profile:

Experience Profile shows preferred Genres

 

Rules Conditions

We will retrieve the top 3 genres by using Rules-based Personalization. What you see below is the personalization setting required to display the Poetry Genre if it’s “top 2″ for the current Visitor.

Custom Rule Condition

 

Custom Rule Condition: Matches Pattern Best X

Our custom condition is based on this OOTB condition:

/sitecore/system/Settings/Rules/Definitions/Elements/Visitor/Matches Pattern

However, it can only match match top 1, while our new condition accepts a number to test against.

 

Definition Item

  • Path: /sitecore/system/Settings/Rules/Definitions/Elements/Visitor/Matches Pattern Best X
  • Template:  /sitecore/templates/System/Rules/Condition
  • Text: where the current contact matches best [value,Text,,specific value] on profile key [ProfileKey,ProfileKey,,profile key]
  • Type: Your.Namespace.Rules.Conditions.ContactPatternMatchBestCondition, Your.AssemblyName
    • Make sure this points to your correct namespace

 

Source Code

Here is the source code to implement this condition. After setting up the Definition Item, as described at the previous topic, all you need to use it is to compile it.

using Sitecore.Abstractions;
using Sitecore.Analytics;
using Sitecore.Analytics.Tracking;
using Sitecore.Data;
using Sitecore.Framework.Conditions;
using Sitecore.Rules;
using Sitecore.Rules.Conditions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using Sitecore.Diagnostics;

namespace Your.Namespace.Rules.Conditions.ContactPatternMatchBestCondition
{
   public class ContactPatternMatchBestCondition<T> : WhenCondition<T> where T : RuleContext
   {
      // ID for the item /sitecore/system/Settings/Content Testing/Personalization Suggestions/No pattern match
      private readonly ID _noPatternMatchItemId = ID.Parse("9E05C456-5CD0-405B-9D45-31DDD92AB8E4");
      private string _profileKey;
      public ContactPatternMatchBestCondition() : this(DependencyResolver.Current.GetService<BaseLog>()) { }

      internal ContactPatternMatchBestCondition(BaseLog log) {
         Condition.Requires<BaseLog>(log, nameof(log)).IsNotNull<BaseLog>();
         this.Log = log;
      }

      private string _value;
      public string Value {
         get => _value ?? string.Empty;
         set {
            Assert.ArgumentNotNull(value, nameof(value));
            _value = value;
         }
      }

      public string ProfileKey {
         get => _profileKey;
         set => _profileKey = value ?? string.Empty;
      }

      internal BaseLog Log { get; }

      protected override bool Execute(T ruleContext) {
         Condition.Requires<T>(ruleContext, nameof(ruleContext)).IsNotNull<T>();
         Condition.Ensures<ITracker>(Tracker.Current, string.Format("{0}.{1}", (object)"Tracker", 
            (object)"Current")).IsNotNull<ITracker>();
         Condition.Ensures<Session>(Tracker.Current.Session, string.Format("{0}.{1}.{2}", (object)"Tracker", 
            (object)"Current", (object)"Session")).IsNotNull<Session>();
         Condition.Ensures<Contact>(Tracker.Current.Session.Contact, string.Format("{0}.{1}.{2}.{3}", 
            (object)"Tracker", (object)"Current", (object)"Session", (object)"Contact")).IsNotNull<Contact>();
         if (string.IsNullOrEmpty(this.ProfileKey))
            return false;
         IEnumerable<IBehaviorProfileContext> profiles = Tracker.Current.Session.Contact.BehaviorProfiles.Profiles;
         List<ID> idsList = this.GetIdsList(this.ProfileKey);
         if (!profiles.Any<IBehaviorProfileContext>() && idsList.Contains(this._noPatternMatchItemId))
            return true;
         if (!idsList.Any())
            return false;

         var profileKeyId = idsList.First();

         int best;
         if (!int.TryParse(Value, out best))
            return false;

         foreach (IBehaviorProfileContext profile in profiles) {
            var profileScores = GetProfileKeyValue(profile);
            if (profileScores.Count < best)
               return false;

            var index = best - 1;
            var specifiedBest = profileScores[index].Key;
            var specifiedValue = profileScores[index].Value;
            var isBest = specifiedBest == profileKeyId && specifiedValue>0;
            if (isBest)
               return true;
         }
         return false;
      }

      private List<KeyValuePair<ID,double>> GetProfileKeyValue(IBehaviorProfileContext profile) {
         if (profile == null)
            return new List<KeyValuePair<ID,double>>();

         // ISSUE: explicit non-virtual call
         var ret = profile.Scores.ToList();
         ret.Sort((pair1,pair2) => pair2.Value.CompareTo(pair1.Value));
         return ret;
      }

      private List<ID> GetIdsList(string idList) {
         string[] strArray = idList.Split(new char[1] { '|' }, StringSplitOptions.RemoveEmptyEntries);
         var idList1 = new List<ID>();
         foreach (string str in strArray) {
            if (!string.IsNullOrEmpty(str)) {
               ID id = ID.Parse(str, ID.Null);
               if (!ID.IsNullOrEmpty(id))
                  idList1.Add(id);
            }
         }
         return idList1;
      }
   }
}

Hope you enjoy it!

Posted in Analytics, Rules, xDB

Programmatically "Visit" a page (and all xDB-related)

Yesterday during my presentation at the SUG-Quebec (check video here), my host and good friend Jeff L’Heureux asked if I could share the multiple challenges involved in the Smart Bookshelf for Sitecore Demo. In response to that, I’m putting together a number of articles, modules and other resources, to share how each of these things works.

The present article shows how we can simulate a visit to a Sitecore page, along with all xDB-related events that happens when you actually do it. For instance, if your page has Profile Card(s) associated, then your contact should get points in that profile when the page is visited.

The Smart Bookshelf has the following situation:

Anthology Profile Cards

As you can see, Genres have corresponding Profile Cards, so when a Genre page is visited, the corresponding Profile Card is summed up to the contact’s profile. This is how we can track and define the user’s preferred Book Genres.

Now we want to apply the same Genre Profile Cards when the user visits a Book Page. Given that each book belongs to a single Genre, we don’t have to manually associate the Profile Card to individual books. Instead, we can use the following technique to simulate a visit to a Genre Page when a Book is visited.

 

Enough talking – show me the code!

Ozzy Rock

So simple and easy, that you’ll probably want to kill me for the long intro

 

Step 1 – Get the item you want to simulate a visit to

In my case, Books are direct children of Genres, so all I have to do is to get the parent item:

var bookItem = Sitecore.Context.Item;
var genreItem = bookItem.Parent;

Step 2 – Simulate the visit

Go ahead and call the following static method:

Sitecore.Analytics.Data.TrackingFieldProcessor.Process(genreItem);

Thats it?

Yes! You want it harder?

Minions saying NO!

Thought so ;)

Posted in Analytics, xDB

Index Searching - Find-Item using custom fields with Sitecore Powershell


UPDATE: Michael West likes this solution and confirmed that it will be part of the next release of SPE (6.0), becoming my first contribution to SPE! Here is the ticket where this implementation is being tracked.


If you are a big fan of Sitecore Powershell Extensions like me, you probably know the Find-Item command, which allows searching your search index in Powershell scripts. This command, however, has an important limitation: you cannot use custom fields to filter and sort from the index. Due to this limitation, extended usage of Find-Item with custom code is usually not possible.

Internally Find-Item uses the base class “Sitecore.ContentSearch.SearchTypes.SearchResultItem“, which does not include any custom fields. What we usually do with C# is to create a class that inherits from “SearchResultItem“, adding our custom fields, such as below:

public class CustomSearchResultItem : SearchResultItem
{
   [IndexField("_templates")]
   [DataMember]
   public virtual List<ID> TemplateIds { get; set; }
}

The Find-Item command, however, does not allow using this field. For instance, the following script:

$props = @{
 Index = "sitecore_master_index"
 Where = "Paths.Contains(@0) And TemplateIds.Contains(@1)"
 WhereValues = [ID]::Parse("{D5B8857B-DE30-4616-84F5-812A7129ACD5}"), [ID]::Parse("{50FFE147-4F48-428B-A417-4D96DD2048FF}")
}
Find-Item @props

Will give the following error:

Find-Item : No property or field 'TemplateIds' exists in type 'SearchResultItem'

Because, yeah… that property is only defined at our custom class, not at SearchResultItem. To circumvent this limitation I built this special command.

Find-CustomItem

This command extends the original Find-Item, allowing you to pass a type that will be used instead of the base SearchResultItem class:

$props = @{
 Index = "sitecore_master_index"
 Type = [PSWebsite.Foundation.Indexing.SearchTypes.BaseSearchResultItem]
 Where = "Paths.Contains(@0) And TemplateIds.Contains(@1)"
 WhereValues = [ID]::Parse("{D5B8857B-DE30-4616-84F5-812A7129ACD5}"), [ID]::Parse("{50FFE147-4F48-428B-A417-4D96DD2048FF}")
}
Find-CustomItem @props

Create the custom command

In order to use Find-CustomItem in your project, you should:

  1. Add NuGet references to Sitecore.ContextSearch and Sitecore.ContextSearch.Linq;
  2. Add a reference to Cognifide.Powershell.dll;
  3. Create a custom FindItemCommand class extending Cognifide.PowerShell.Commandlets.Data.Search.FindItemCommand;
    You can download the whole class here
  4. Register your new command with an include patch – make sure the content is according to the following
<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
   <sitecore>
      <powershell>
         <commandlets>
            <add Name="Find Item" type="PSWebsite.Foundation.Indexing.Powershell.Commandlets.FindItemCommand, PSWebsite.Foundation.Indexing" />
         </commandlets>
      </powershell>
   </sitecore>
</configuration>

Limitations

Not all parameters from Find-Item are supported by Find-CustomItem. If you use any of the unsupported parameters along with any custom properties, it will cast the query to the base SearchResultItem, and give the same error as the original Find-Item command.

Supported parameters:

  • Index
  • Where
  • WhereValues
  • OrderBy
  • First
  • Last
  • Skip

Unsupported parameters:

  • Criteria
  • Predicate
  • ScopeQuery

 

Posted in Powershell, SPE

Part 2 - XConnect Avatar Facet breaking Experience Profile (Follow Up)

If you read my last post – or if you just passing by got interested in the subject, here are a few follow-ups:

  1. When you create your custom Facet to store the bigger version of the image, make sure to decorate it with [DoNoIndex] as we don’t need the image to be indexed
    [FacetKey(DefaultFacetKey)]
    public class UserPicture : Facet
    {
        public const string DefaultFacetKey = "UserPicture";
    
        /// <summary>
        /// Base64 of the picture
        /// </summary>
        [DoNotIndex]
        public string Picture { get; set; }
    }
  2. xDB is not the best storage for images – Base64 encoded files are rawly 37% bigger than the original image. Although xDB can store files of any size, xConnect can eventually slow down when you have a high number of bigger files. Instead, you can save only the image reference on xDB, and store the file somewhere else.
  3. In the last article, when I say the Base64 was truncated on SQL Server, I was actually wrong.
    It is only truncated on Management Studio, but the full information is on xDB. You can even read and write it, but still, it breaks Experience Profile when you use the native “Avatar” facet.
Posted in XConnect

XConnect Avatar Facet breaking Experience Profile

During the last Sitecore Hackathon, I’ve been through a very strange and annoying issue with my Contacts, while developing our Face Login module.

This happens in Sitecore 9.1, but previous versions should also be affected.

When the issue starts, your Experience Profile gets “frozen” with the contacts that you already have. Any new contact will not appear.

Experience Profile broken

Your Indexer log also will start to show this error:

2019-03-07 11:44:31.739 -03:00 [Error] The attempt to recover from previous failure has not been successful. There will be another attempt. Attempts count: 35
Sitecore.Xdb.Collection.Failures.DataProviderException: *** [xdb_collection.GetContactsChanges], Line 27. Errno 50000: Sync token is no longer valid for [Contacts] table. ---> System.Data.SqlClient.SqlException: *** [xdb_collection.GetContactsChanges], Line 27. Errno 50000: Sync token is no longer valid for [Contacts] table.
   at System.Data.SqlClient.SqlCommand.<>c.<ExecuteDbDataReaderAsync>b__180_0(Task`1 result)
   at System.Threading.Tasks.ContinuationResultTaskFromResultTask`2.InnerInvoke()
   at System.Threading.Tasks.Task.Execute()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Sql.Common.Extensions.DbCommandExtensions.<>c__DisplayClass1_0.<<ExecuteReaderWithRetryAsync>b__0>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Sql.Common.Extensions.DbCommandExtensions.<ExecuteReaderWithRetryAsync>d__1.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Sql.Common.Extensions.SqlCommandExtensions.<ExecuteReaderWithRetryAsync>d__1.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Sql.Common.Extensions.SqlCommandExtensions.<ExecuteReaderWithRetryAsync>d__0.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Data.SqlServer.Managers.SqlDataRecordsManager`2.<>c__DisplayClass75_0.<<ReadChanges>b__0>d.MoveNext()
   --- End of inner exception stack trace ---
   at Sitecore.Xdb.Collection.Data.SqlServer.Managers.SqlDataRecordsManager`2.<>c__DisplayClass75_0.<<ReadChanges>b__0>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Data.SqlServer.Managers.SqlDataRecordsManager`2.<ReadChanges>d__75.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Data.SqlServer.Managers.SqlDataRecordsManager`2.<GetChanges>d__51.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Data.SqlServer.SqlDataProvider.<GetChanges>d__16.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.DataProviderCountersDecorator.<GetChanges>d__14.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.DataProviderCountersDecorator.<GetChanges>d__14.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Indexing.Indexer.<>c__DisplayClass9_0.<<GetChanges>b__0>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Indexing.IndexerExtensions.<IndexNextChangesSimple>d__0.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Indexing.SingleThreadedIndexer.<IndexNextChangesWithTiming>d__8.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
   at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw()
   at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
   at Sitecore.Xdb.Collection.Indexing.SingleThreadedIndexer.<RunInThread>d__6.MoveNext()

What’s going on?

Good question… Wanted to take a look at my Shard databases, more specifically at the ContactFacets database:

SELECT * FROM [xdb_collection].[ContactFacets]
WHERE FacetKey='Avatar'

This will list the Avatar facet of my contacts:

Avatar Facets results

Look closer to the FacetData field – this is where the serialized Avatar Facet is stored. Due to the size of the image I uploaded, this will be a very long string.

In my case, this string was truncated:

{"@odata.type":"#Bookshelf.Repository.XConnect.Facets.BookshelfPicture","Picture":"/9j/4AAQSkZJRgABAQEASABIAAD (... ... ... ) 8QP7K1LxJHrUJm

Resulting in a value that cannot be de-serialized, and thus causing all issues previously described.

How to fix it?

In order to fix this issue, you will need to get rid of your broken Avatars from Shard0 and Shard1 databases.

  • Run this SQL (make sure to point to your database names)
    DELETE FROM [Collection.Shard0].[xdb_collection].[ContactFacets] where FacetKey='Avatar'
    DELETE FROM [Collection.Shard1].[xdb_collection].[ContactFacets] where FacetKey='Avatar'

    This script is deleting all Avatars from all Contacts. You can add more filters to delete only the corrupted ones

  • Restart your Indexer Service
  • Request an XConnect rebuild:
    XConnectSearchIndexer -rr

After that, your Experience Profile will get back to normal.

How to avoid this issue to re-appear?

The Avatar Facet was not designed to store huge files – an Avatar usually has a size of 200×200. In order to avoid this issue in the future, this is what I’ve done:

  • Created a custom Facet to store the Base64 information of the big picture;
  • Limited the upload of files to 1.5Mb maximum;
  • When a picture is uploaded, two versions are stored by XConnect:
    • Picture Facet (Custom) – Stores the original size of the image
    • Avatar Facet (OOTB) – Stores a smaller version of the image, resized to the width of 200px

By doing this, my new contacts are appearing correctly on Experience Profile.

Posted in XConnect Tagged with:

Powershell script to find TDS files with length error

Powershell script to find TDS files with lenght error

A very well described problem with TDS is the File Name length error. This article from Hedgehog brings everything you need to know about the issue itself, and how to work it out. The solution is to use the “Alias” TDS feature to cut down the file path length.

For instance, if you have an item with a very long name such as “Subpage with Left Rail Without Footer”, you could choose an Alias as “sub3″ and save 33 characters. Of course, your original item name is preserved, as this only applies to the file system.

In short:

  • File path should not exceed 260 chars
  • Folder path should not exceed 248 chars

What causes and what to do about

  1. You have cloned the repository into a folder with a too large name
    What to do about: 

    1. Make sure you have something short like:
      C:\src\ABC
      instead of
      C:\Source Control\My Client Name Is Long\My Long Project Name
  2. TDS project name is too wide – due to the Helix standard TDS projects can have longer names (Eg: MyProject.Foundation.DependencyInjection.Master)
    What to do about:

    1. New projects: avoid long names (Eg: DI instead of DependencyInjection)
    2. Existent projects:
      1. Use the Powershell script to find long paths and apply Aliases
      2. Be proactive to apply Aliases to TDS long names
  3. The Sitecore Item Path added to TDS (including the item itself) is too long
    What to do about:

    1. Use the Powershell script to identify long paths, then apply a TDS Alias to each item that makes it longer
    2. EG: this path
      /sitecore/content/mywebsite/This page has a big name/But this page also has a big name/NotToBlame
      has 2 problematic items:

      1. “This page has a big name”
      2. “But this page also has a big name”
    3. Those 2 items must be added to TDS and have their “File System Alias” setup to something smaller

Powershell script

When a certain environment has this issue, make sure to follow the steps described earlier. However, to execute Step 3 you should first discover what items are problematic.

To quickly obtain such a list, use the following Powershell script:

# Script Setup
$pathToScan = “D:\src\ABC”;
$outputToCsv = $false;
$outputToPrompt = $true;
$maxLength = 225;
#########

cls;

if ($pathToScan.IndexOf(“\src”) -eq -1){
$pathToScan = “$pathToScan\src”;
}

# Get all TDS folders
$tdsFolders = Get-ChildItem -Path $pathToScan -File -Recurse | Where-Object {($_.FullName -like ‘*.Master*’) -or ($_.FullName -like ‘*.Core*’)} | Where-Object {($_.FullName -notlike ‘*\bin\*’)};
#$tdsFolders = Get-ChildItem -Path $pathToScan -Directory -Recurse;

# Add properties
foreach ($folder in $tdsFolders){
$folder | Add-Member PathLength $folder.FullName.Length;
}

# Sort
$tdsFoldersSorted = $tdsFolders | sort FullName | sort PathLength -Descending;

# Get Max Length to filter those that will not match
#$maxLength = Read-Host -Prompt ‘Max Length allowed';
$tdsFoldersToShow = $tdsFoldersSorted | Where-Object {$_.PathLength -gt $maxLength};

# Output Loop
$fileName = “$PSScriptRoot\beyond $maxLength.csv”;
$csvText = “”;
if ($outputToCsv){
$csvText = “$($csvText)Length,FullName`n”;
#Add-Content -Path $fileName -Value “Length,FullName”;
}
if ($outputToPrompt){
Write-Output(“Length,FullName”);
}

foreach ($folder in $tdsFoldersToShow){
if ($outputToCsv){
$csvText = “$($csvText)$($folder.PathLength),$($folder.FullName)`n”;
#Add-Content -Path $fileName -Value “$($folder.PathLength),$($folder.FullName)”;
}
if ($outputToPrompt){
Write-Output(“$($folder.PathLength),$($folder.FullName)”);
}
}

if ($outputToCsv){
Add-Content -Path $fileName -Value $csvText;
Write-Output(“File $fileName saved”);
}

Instructions

  1. Open the script in Powershell ISE as Administrator
  2. Setup script changing variables at the top:
    1. $pathToScan – should point to your git folder
    2. $outputToCsv – Set as $true if you want the script to create a CSV file with results, $false otherwise
    3. $outputToPrompt – Set as $true if you want the script to display results at prompt, $false otherwise
    4. $maxLength – Max length tolerated to a TDS item path – the script will list everything that goes beyond this vale
  3. Run the script
  4. Use the list obtained to apply instructions Aliases, making paths shorter
Posted in Powershell, TDS

Custom Reset Layout in Content and Experience Editor Modes

Custom-Reset-Layout-in-Content-and-Experience-Editor-Modes

Recently I had to customize the code that triggers when a Reset Layout is executed. If you ever had to attach any code to it, this post is for you!

You know, that sympathetic prompt:

Reset Layout Prompt

The first thing you need to know is that Content Editor and Experience Editor does that differently, so let’s go for them:

Round 1 – Content Editor

Content Editor uses a command for that (pagedesigner:reset), which is natively implemented by the class Sitecore.Shell.Applications.Layouts.PageDesigner.Commands.Reset. We first need to create a class that will inherit from the original, so we don’t lose the original behavior, and implement our custom logic on it.

Create a Command class like this:

using System.Web.Mvc;
using Sitecore.Shell.Applications.Dialogs.ResetLayout;
using Sitecore.Web.UI.Sheer;

namespace MyProject.Commands
{
    public class ResetLayoutCommand : Sitecore.Shell.Applications.Layouts.PageDesigner.Commands.Reset
    {
        protected override void Run(ClientPipelineArgs args)
        {
            // Runs the default behaviour
            base.Run(args);

            // Skips if the Reset Layout prompt is not submited or results is undefined
            if (!args.IsPostBack || string.IsNullOrEmpty(args.Result) || args.Result == "undefined")
                return;

            // Takes the item that has been reset
            var itemReset = DeserializeItems(args.Parameters["items"])[0];

            // And here is where your custom logic will be implemented
            // .....
        }
    }
}

Then we override the default command with our class in a config patch like this:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
    <sitecore>
        <commands>
            <command patch:instead="*[@name='pagedesigner:reset']" name="pagedesigner:reset" resolve="true" 
                type="MyProject.Commands.ResetLayoutCommand, MyProject"/>
        </commands>
    </sitecore>
</configuration>

And this is how we run the first leg. But it is not over yet, let’s go for…

Round 2 – Experience Editor

Experience Editor, on the other hand, uses a different approach for that. It has a request node named “ExperienceEditor.ResetLayout.Execute” under <sitecore.experienceeditor.speak.requests> that we need to replace. This request node is implemented by the native class Sitecore.ExperienceEditor.Speak.Ribbon.Requests.ResetLayout.Execute, which again we are going to inherit.

So our Request class will be like this:

using System;
using System.Web.Mvc;
using Sitecore.Diagnostics;
using Sitecore.ExperienceEditor.Speak.Attributes;
using Sitecore.ExperienceEditor.Speak.Server.Responses;
using Sitecore.Shell.Applications.Dialogs.ResetLayout;

namespace MyProject.ExperienceEditor
{
    public class ResetLayout : Sitecore.ExperienceEditor.Speak.Ribbon.Requests.ResetLayout.Execute
    {
        [HasItemPermissions(Id = "{BE98D7F0-7404-4F97-8E4C-8FEF4ACA5DA3}", Path = "/sitecore/content/Applications/WebEdit/Ribbons/WebEdit/Advanced/Layout/Reset")]
        public override PipelineProcessorResponseValue ProcessRequest()
        {
            // Instantiates the returning object and 
            var processorResponseValue = new PipelineProcessorResponseValue();

            try
            {
                // Executes the default RESET LAYOUT process
                processorResponseValue = base.ProcessRequest();

                // Takes the item that has been reset
                var itemReset = RequestContext.Item;

                // And here is where your custom logic will be implemented
                // ..... (OF COURSE, DON'T DUPLICATE YOUR LOGIC!)
            }
            catch (Exception e)
            {
                Log.Error(
                    $"[ResetLayout] Cannot execute post Layout Reset operations to item '{RequestContext.Item.Paths.Path}'", e, GetType());
            }
            return processorResponseValue;
        }
    }
}

And finally we patch it again with a config file like this:

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
    <sitecore>
        <sitecore.experienceeditor.speak.requests>
            <request patch:instead="*[@name='ExperienceEditor.ResetLayout.Execute']"
                name="ExperienceEditor.ResetLayout.Execute"
                type="MyProject.ExperienceEditor.ResetLayout, MyProject" />
        </sitecore.experienceeditor.speak.requests>
    </sitecore>
</configuration>

And that’s all… You now have your custom code being triggered by both Content Editor and Experience Editor!

Posted in Content Edition Experience, Development, Experience Editor

Sitecore 9 Update 1 - Bug saving Shared Layout in Experience Editor

Sitecore-9-Update-1-Bug-saving-Shared-Layout-in-Experience-Editor

A few weeks ago, while upgrading one of at Nish Tech’s early-stages projects into Sitecore 9 Update 1, a very strange behavior when you try to do a very simple task: edit a Shared Layout of a page in Experience Editor.

When you try to do that, the infamous screen “Layout not Set” shows right after saving.

Sitecore 9 Bug

Initially, I observed that when saving in a _Standard Values, but later I noticed it can also happen with ordinary items. If you want to see how to reproduce the error, check this video.

Interesting enough, when you look at your Layout and Final Layout fields in Raw mode, you notice that the Shared Layout has a value such as:

<r xmlns:p=”p” xmlns:s=”s” p:p=”1″><d id=”{FE5D7FDF-89C0-4D99-9AA3-B5FBD009C9F3}”><r uid=”{597C17AD-DEF3-4947-BE5B-4104A2143F17}” p:after=”*[1=2]” s:id=”{493B3A83-0FA7-4484-8FC9-4680991CF743}” s:ph=”/main/centercolumn/content” /></d></r>

Which seems to have a more appropriate syntax to be in the Final Layout field instead, as you can check if you match this against the original value:

<r xmlns:xsd=”http://www.w3.org/2001/XMLSchema”><d id=”{FE5D7FDF-89C0-4D99-9AA3-B5FBD009C9F3}” l=”{14030E9F-CE92-49C6-AD87-7D49B50E42EA}”><r ds=”” id=”{885B8314-7D8C-4CBB-8000-01421EA8F406}” par=”” ph=”main” uid=”{43222D12-08C9-453B-AE96-D406EBB95126}” /><r ds=”” id=”{CE4ADCFB-7990-4980-83FB-A00C1E3673DB}” par=”” ph=”/main/centercolumn” uid=”{CF044AD9-0332-407A-ABDE-587214A2C808}” /><r ds=”” id=”{493B3A83-0FA7-4484-8FC9-4680991CF743}” par=”” ph=”/main/centercolumn/content” uid=”{B343725A-3A93-446E-A9C8-3A2CBD3DB489}” /></d><d id=”{46D2F427-4CE5-4E1F-BA10-EF3636F43534}” l=”{14030E9F-CE92-49C6-AD87-7D49B50E42EA}”><r ds=”” id=”{493B3A83-0FA7-4484-8FC9-4680991CF743}” par=”” ph=”content” uid=”{A08C9132-DBD1-474F-A2CA-6CA26A4AA650}” /></d></r>

Sitecore Helpdesk heroes

Then I reported the issue to our amazing Sitecore Helpdesk, who after a couple weeks came out with a bugfix, that now I want to share with our Sitecore Community.

I would like to thank the whole gang of superheroes at Sitecore Helpdesk, for providing this good HotFix in a proper time and allowing me to share it, in special to Igor DenisenkoPavel Ivashchenko and Ielyzaveta Kalinchuk, the good guys who replied to my ticket. You are awesome, mates!

The cure: HotFix 203387

You can find the hotfix at this link.

  • Please be aware that the hotfix was built specifically for Sitecore XP 9.0 rev. 171219 (Update-1)! You should not install it on other Sitecore versions or in combination with other hotfixes unless explicitly instructed by Sitecore Support;
  • Note that you need to extract ZIP file contents to locate installation instructions and related files inside it.

If you have anything to add on this, please drop a comment and let me know!

Posted in Bug fixing, Experience Editor, Support Ticket, Uncategorized
Social Media Auto Publish Powered By : XYZScripts.com