Category Archives: Sitecore 9

Untangling the Sitecore Search LINQ to SolR queries

Problem

It can be very difficult to identify why you do not get the search results you expected from Sitecore Search, but there is a simple way to help untangle what is going on.

Solution

It is possible to see the query that Sitecore generates and sends to SolR and then use the query on the SolR instance to see what data is returned to Sitecore.

This is such a huge help when trying to understand why your queries do not work!

Step 1 – Find the Query that was sent to SolR from Sitecore

Sitecore logs all the queries it sends to SolR in the standard sitecore log folder, look for files named Search.log.xxx.yyy.txt .

Step 2 – Execute the query in your SolR instance

Go to your Solr instance, and use the core selector drop down to select the index your Sitecore Search query is being executed against.

Select Query, from the menu

Then paste the query from the sitecore log, and you can see the result that is returned to Sitecore.

This has helped me a lot, so I hope this helps others untangling their search results using Sitecore Search 🙂

 

 

 

Reduce Technical Debt Part 2 – Empty Try Catch

Here is a the second in the series on how to reduce Technical Debt, please read part one as it gives an insight into the scale and challenges we faced, and outlines what this blog post is trying to address.

As you are aware the first part introduced a few code examples to help remove redundant code, this blog will continue to focus on how to remove redundant code by introducing the EmptyTryCatchService class and the IgnoreEmptyTryCatch Custom attribute .

But before that I just briefly want to mention integrations, in my experience this is where a lot of redundant and or unnecessary code can hide.

Integrations

Therefore, an important concept to reduce technical debt, is to identify, separate and isolate dependencies on external systems, especially complex and or legacy systems.

I have already written a blog series about this, so if you missed please read it.

Integrations Platform

I believe in an ideal world, most integrations and especially complex and or legacy system specific code should be move out of the website solution to an integration platform!

Most issues, difficulties, problems and cost relating to code maintenance and technical debt for website is due to being responsible for stuff they should not be.

For example, the website is responsible for aggregation data from several systems to provide a unified view of their data, NO this is the job of an Integrations/aggregation platform

Empty Try Catch

So, let me start by stating – ignoring exceptions is a bad idea, because you are silently swallowing an error condition and then continuing execution.

Occasionally this may be the right thing to do, but often it’s a sign that a developer saw an exception, didn’t know what to do about it, and so used an empty catch to silence the problem.

It’s the programming equivalent of putting black tape over an engine warning light.

It’s best to handle exceptions as close as possible to the source, because the closer you are, the more context you have to achieve doing something useful with the exception.

Ignore Empty Try Catch – Custom attribute

In some rare cases the empty try catch can be valid, in which case you can use the custom attribute to mark the function and explain why it is OK, and check one last time is there not a TryParse version of the function and or code you are calling.

Performance

Slightly off topic, but still a type of technical debt, do not use exceptions for program flow!

Throwing exceptions is very expensive (must dump the registries, call stack, etc. and whilst doing this it blocks all threads) so it has a big impact on performance.

I have seen sites brought to their knees because of the number exceptions being thrown.

Redundant Code

In the solution we took over there were over 300 empty try-catch statements ☹

But how can it hide redundant code?

When an exception is thrown it can jump over lots of code, which is therefore never called.

Therefore, all the code after the exception is redundant.

Below is the classic Hello World program it works as expected, it prints out “Hello World”.

But there is a lot of technical debt, now this might look like a funny example, but I have seen a lot of similar examples in real world, usually with a lot more code in the try catch, and usually found most often around big complex integrations!

try catch redunant code

Solution – EmptyTryCatchService

For empty try catches I would not recommend you use Sitecore’s standard logging, as it can create enormous log files which is enough to kill your sitecore solution, if the empty try catch is called a lot.

For tracking down empty try catches, it is good to have a dedicated log file and a way to limit the amount of data written to the log file.

EmptyTryCatchService class provides the following features:

  • Report interval – the interval between exceptions with the same owner, name and exception message are written to the log file.
  • Max Log limit – when the number exceptions with the same owner, name and exception message is exceed no more data is written to the log file.
  • Dedicated log file for each day
  • Disable all logging via configuration.

EmptyTryCatchService class is a simple class that, relies on the MaxUsageLog for most of its functionality (see the code below).

In addition to finding redundant code the EmptyTryCatchService will track down hidden errors and problems in your solution, which will result in a reduction of the technical debt.

You must be careful when reviewing the exceptions logged and deciding how best to deal with the exceptions. See part 3 in the series, to reduce technical debt.

public class EnsureIsObsoleteService
{
private readonly MaxUsageLog _maxUsageLog =
new MaxUsageLog(10000, "EnsureIsObsoleteService",1000);
public void EnsureIsObsolete(object owner, string message)
{
_maxUsageLog.Log(owner, message);
}
}
public class MaxUsageLog
{

public MaxUsageLog(int maxLogLimit,
string fileNamePrefix,
int reportCountInterval=1000000)
{
_maxLogLimit = maxLogLimit;
_fileNamePrefix = !string.IsNullOrEmpty(fileNamePrefix) ? fileNamePrefix : "MaxUsageLog";
_reportCountInterval = reportCountInterval;
}

public void Log(object owner, string message, Exception ex = null)
{
if (!IsEnabled())
return;

string type = string.Empty;
if (owner != null)
{
if (owner is Type typeObj)
{
type = typeObj.FullName;
}
else
{
type = owner.GetType().FullName;
}
}
string key = GenerateKey(type, message, ex);
if (!ShouldLog(owner, key))
return;
var count = Count(key);
WriteToFile(owner, type, message, ex, count);
}

private int Count(string key)
{
return Usage.ContainsKey(key) ? Usage[key] : 0;
}

private void WriteToFile(object owner, string type, string message, Exception exceptionToLog, int count)
{
try
{
StreamWriter log = File.Exists(FileName) ? File.AppendText(FileName) : File.CreateText(FileName);
try
{
log.AutoFlush = true;
log.WriteLine($"{DateTime.Now.ToUniversalTime()}: Type:'{type}' Message:'{message}' Count:{count}");
if (exceptionToLog != null)
{
log.WriteLine($"Exception:{exceptionToLog}");
}
log.Close();
}
finally
{
log.Close();
}
}
catch (Exception ex)
{
if (!Sitecore.Configuration.ConfigReader.ConfigutationIsSet)
return;
Sitecore.Diagnostics.Log.Error(
$"Failed writing log file {FileName}. The following text may be missing from the file: Type:{type} Message:{message}",
ex, owner);
}
}
private bool ShouldLog(object owner, string key)
{
if (!Usage.ContainsKey(key))
{
Usage.Add(key, 1);
return true;
}
var count = Usage[key] = Usage[key] + 1;

if (count % _reportCountInterval == 0)
{
WriteToFile(owner, "******** Report Count Interval ******", $"Key:'{key}'", null,count);
}

if (count > _maxLogLimit)
return false;
if (count == _maxLogLimit)
{
WriteToFile(owner, "******** Usage Max Exceeded ******", $"Key:'{key}' Max Limit:{_maxLogLimit}",null,count);
return false;
}
return true;
}
private string GenerateKey(string type, string message, Exception ex)
{
return ex != null ?
$"{_fileNamePrefix}_{type}_{message}_{ex.HResult}" :
$"{_fileNamePrefix}_{type}_{message}";
}

private string FileName
{
get
{
DateTime date = DateTime.Now;
string fileName = $@"\{_fileNamePrefix}.{date.Year}.{date.Month}.{date.Day}.log";

if (!Sitecore.Configuration.ConfigReader.ConfigutationIsSet)
return ConfigurationManager.AppSettings[Constants.Configuration.Key.LogFolderForApplications] + fileName;

return Sitecore.MainUtil.MapPath(Sitecore.Configuration.Settings.LogFolder) + fileName;
}
}

private bool IsEnabled()
{
if (!Sitecore.Configuration.ConfigReader.ConfigutationIsSet)
return StringToBool(ConfigurationManager.AppSettings[Constants.Configuration.Key.MaxUsageLogEnabled],false);

return Sitecore.Configuration.Settings.GetBoolSetting(Constants.Configuration.Key.MaxUsageLogEnabled, true);
}

private bool StringToBool(string value, bool defaultValue)
{
if (value == null)
return defaultValue;
bool result;
if (!bool.TryParse(value, out result))
return defaultValue;
return result;
}

private readonly int _maxLogLimit;
private readonly string _fileNamePrefix;
private readonly int _reportCountInterval;

// this is to ensure we can count how many times a message has been logged across all threads
private static readonly Dictionary<string, int> Usage = new Dictionary<string, int>();
}

Hope this was of help, Alan

 

resolve DI

Dependency Injection with Sitecore 9 Scheduling

With this post I hope to draw attention to the ability to use dependency injection with Sitecore’s Scheduling Agents, for more information about agents see Johns blog post.

Most solutions now use DI with MVC controllers which is great, but I have noticed it is not used with agents?

Which is a pity as it is very simple to do this in Sitecore 9.0, Just add resolve =”true”.

resolve DI

Then add any dependencies to your constructor as required.

private readonly ILogger _logger;
private readonly UpdateSeatAvailabilityService _updateSeatAvailabilityService;

public UpdateSeatAvailabilityAgent(
       [NotNull]ILogger logger, 
       [NotNull]UpdateSeatAvailabilityService updateSeatAvailabilityService)
        {
            Assert.ArgumentNotNull(logger, nameof(logger));
            Assert.ArgumentNotNull(updateSeatAvailabilityService, nameof(updateSeatAvailabilityService));
            _logger = logger;
            _updateSeatAvailabilityService = updateSeatAvailabilityService;
        }

Hope this helps, Alan

Salesforce Connector – Strange Error (AlreadyExists, contact) Syncing checkbox field from Salesforce to Sitecore

Problem

We had setup the pipeline batch to sync from sitecore to Sales-force, and the from Salesforce to Sitecore and it worked.

Then we had to add a single checkbox that should be mapped from Salesforce to Sitecore, the checkbox was already setup to be sync’d from sitecore to Salesforce.

After setting it up, we got the following error whenever we changed the checkbox  value in Salesforce.


ManagedPoolThread #3 13:40:16 INFO  [Data Exchange] Submitting batch to xConnect. (pipeline: Read Contacts from Salesforce Pipeline, pipeline step: Submit Remaining Operations in xConnect Batch, pipeline step identifier: 1bb0f7dd-1d1f-454a-879e-6655399a42bf, description: Batch from thread 0, operation count: 8)
ManagedPoolThread #3 13:40:16 ERROR [Data Exchange] Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
ManagedPoolThread #3 13:40:16 ERROR [Data Exchange] Exception while submitting batch to xConnect. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
ManagedPoolThread #3 13:40:16 ERROR [Data Exchange]    at Sitecore.DataExchange.Providers.XConnect.Processors.PipelineSteps.BaseSubmitXConnectBatchStepProcessor.SubmitBatch(String batchDescription, IXdbContext client, PipelineStep pipelineStep, PipelineContext pipelineContext, ILogger logger)
   at Sitecore.DataExchange.Providers.XConnect.Processors.PipelineSteps.SubmitXConnectBatchStepProcessor.ProcessPipelineStep(PipelineStep pipelineStep, PipelineContext pipelineContext, ILogger logger)
   at Sitecore.DataExchange.Processors.Pipelines.PipelineProcessor.ProcessPipelineStep(PipelineStep pipelineStep, PipelineContext pipelineContext, ILogger logger)
   at Sitecore.DataExchange.Processors.Pipelines.PipelineProcessor.ProcessPipeline(Pipeline pipeline, PipelineContext pipelineContext, ILogger logger)
   at Sitecore.DataExchange.Processors.PipelineBatches.BasePipelineBatchProcessor.ProcessPipelineBatch(PipelineBatch pipelineBatch, PipelineBatchContext pipelineBatchContext, ILogger logger)
ManagedPoolThread #3 13:40:16 ERROR [Data Exchange] One or more errors occurred. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
ManagedPoolThread #3 13:40:16 ERROR [Data Exchange]    at System.Threading.Tasks.Task.ThrowIfExceptional(Boolean includeTaskCanceledExceptions)
   at System.Threading.Tasks.Task.Wait(Int32 millisecondsTimeout, CancellationToken cancellationToken)
   at Sitecore.DataExchange.Providers.XConnect.Processors.PipelineSteps.BaseSubmitXConnectBatchStepProcessor.SubmitBatch(String batchDescription, IXdbContext client, PipelineStep pipelineStep, PipelineContext pipelineContext, ILogger logger)
ManagedPoolThread #3 13:40:16 ERROR [Data Exchange] START AGGREGATE EXCEPTION (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, ID: 19, HResult: -2146233088)
 Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
One or more operations did not succeed (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
   at Sitecore.XConnect.XdbContext.<ExecuteBatchAsyncInternal>d__79.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.XConnect.XdbContext.<SubmitAsync>d__74.MoveNext()
START AGGREGATE EXCEPTION (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, ID: 20, HResult: -2146233088)
  Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
Operation #7, AlreadyExists, Contact {0b9e30fe-f081-0000-0000-056ff184830f}, NewsletterInformation (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
  Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
Operation #7, AlreadyExists, Contact {0b9e30fe-f081-0000-0000-056ff184830f}, NewsletterInformation (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
END AGGREGATE EXCEPTION (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, ID: 20, HResult: -2146233088)

 Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
One or more operations did not succeed (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
   at Sitecore.XConnect.XdbContext.<ExecuteBatchAsyncInternal>d__79.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.XConnect.XdbContext.<SubmitAsync>d__74.MoveNext()
START AGGREGATE EXCEPTION (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, ID: 21, HResult: -2146233088)
  Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
Operation #7, AlreadyExists, Contact {0b9e30fe-f081-0000-0000-056ff184830f}, NewsletterInformation (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
  Exception thrown when processing a pipeline batch. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
Operation #7, AlreadyExists, Contact {0b9e30fe-f081-0000-0000-056ff184830f}, NewsletterInformation (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect)
END AGGREGATE EXCEPTION (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, ID: 21, HResult: -2146233088)

END AGGREGATE EXCEPTION (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, ID: 19, HResult: -2146233088)

ManagedPoolThread #3 13:40:16 DEBUG [Data Exchange] Pipeline batch processor finished processing pipelines. (pipeline batch: xConnectToSalesforceAndSalesforceToxConnect, completed pipelines: 1)


Now you might of noticed that the issue is related to the contact already existing?

  • Operation #7, AlreadyExists, Contact {0b9e30fe-f081-0000-0000-056ff184830f})

Strange error message? Of course the contact already exists, as the contact is first created via the website and then sync’d to Salesforce?

Solution

The issue was related to the fact, that I had missed to add the “Newsletter Information” facet to the “Facets to Read” field on the “Resolve Contact Model by Salesforce Id from xConnect” item, see image below.

To be honest I believe that the error message was very misleading, hence this blog post. As there are 1000’s of items to associated with the Salesforce connector, it is very easy to miss a field selection and or other required item setup.

Credits

Big thanks to Paul Kravchenko, from Sitecore support, who worked very had to identify and fix this issue, which we managed after a few online meetings 🙂

Hope this helps others out there, Alan

Structured, Type Safe Settings in Sitecore

This feature seems to be overlooked, so I hope this blog post will draw more attention to this feature, and make it’s use more widespread.

In Sitecore, it is easy to map configuration settings to a C# class, whilst maintaining a structure that adheres to the helix principles, see the config below.

Then the mapped C# class can registered with IServiceCollection, so it can be injected into any class using dependency injection.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:environment="http://www.sitecore.net/xmlconfig/environment">
<sitecore environment:require="Local">
<feature>
<salesforce>
<clientSettings type="Example.Feature.Salesforce.Infrastructure.SalesforceClientSettings, FKCC.Feature.Salesforce" singleInstance="true">
<Username>example@blog.example.com</Username>
<Password>xxxxxxx</Password>
<Token>yyyyyyy</Token>
<CacheExpiry>60</CacheExpiry>
<OrganisationId>1111111</OrganisationId>
</clientSettings>
</salesforce>
</feature>
</sitecore>
</configuration>

Previously settings used to be a long flat list of settings, which if we were lucky were grouped use prefixes in the name attribute to indicate which feature they were used by.

<setting name="Feature.Salesforce.Authentication.Username" value="xxxx@example.com" />
<setting name="Feature.Salesforce.Authentication.Password" value="BestPasswordInTheWorld" />
<setting name="Feature.Salesforce.Authentication.SfToken" value="Its a SF token" />
<setting name="Feature.Salesforce.Authentication.CacheExpiry" value="60" />

Solution

It is now very simple to map structured configuration to a type safe C# class, and it involves 4 simple steps.

Step 1 – Define C# Class

Define the C# class that stores the data defined by the settings in the config file, for this example, we will define some authentication settings for a sales force client.

namespace Example.Feature.Salesforce.Infrastructure
{
public class SalesforceClientSettings
{
public string Password { get; protected set; }
public string Username { get; protected set; }
public string Token { get; protected set; }
public int CacheExpiry { get; protected set; }
public string OrganisationId { get; set; }
}
}

Step 2 – Define the settings

It is not required, but I would recommend following the Helix principles when defining the settings structure i.e.

[Layer]/[Feature Name]/[Settings Name]

The type attribute defines which class (i.e. the one defined in step 1) to map the settings element to.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:environment="http://www.sitecore.net/xmlconfig/environment">
<sitecore environment:require="Local">
<feature>
<salesforce>
<clientSettings type="Example.Feature.Salesforce.Infrastructure.SalesforceClientSettings, FKCC.Feature.Salesforce" singleInstance="true">
<Username>example@blog.example.com</Username>
<Password>xxxxxxx</Password>
<Token>zzzzzz</Token>
<CacheExpiry>60</CacheExpiry>
<OrganisationId>1111111</OrganisationId>
</clientSettings>
</salesforce>
</feature>
</sitecore>
</configuration>

Step 3 – Map the configuration to the C# class

Sitecore makes this so easy, using Factory.CreateObject method, which loads the configuration and maps it to the C# class.

(SalesforceClientSettings) Factory.CreateObject("feature/salesforce/clientSettings", true)

Note: Factory.CreateObject expects that configuration path, is relative to the sitecore configuration, not the complete path.

Step 4 Setup dependency injection.

Register the created class with the IServiceCollection, so we can access the class, where necessary using constructor injection.

namespace Example.Feature.Salesforce.Infrastructure
{
public class ServiceConfigurator : IServicesConfigurator
{
public void Configure(IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton(provider =>
(SalesforceClientSettings) Factory.CreateObject("feature/salesforce/clientSettings", true));

}
}
}

I hope this blog posts, helps you to structure your settings in a more maintainable and coherent structure, Alan

Sitecore SIF NewSignedCertificate – The time period is invalid

Problem – The time period is invalid. 0x80630705

The client certificate for xConnect expired on my developer machine for a solution I was developing. I thought no problem I will get SIF to generate new certificates for the website and xConnect.

Unfortunately when I ran SIF, i got the following error when it was running CreateSignedCert : NewSignedCertificate.


PS>TerminatingError(New-SelfSignedCertificate): "CertEnroll::CX509Enrollment::_CreateRequest: The time period is invalid. 0x80630705 (-2140993787 PEER_E_INVALID_TIME_PERIOD)"
>> TerminatingError(New-SelfSignedCertificate): "CertEnroll::CX509Enrollment::_CreateRequest: The time period is invalid. 0x80630705 (-2140993787 PEER_E_INVALID_TIME_PERIOD)"
Install-SitecoreConfiguration : CertEnroll::CX509Enrollment::_CreateRequest: The time period is invalid. 0x80630705 (-21
40993787 PEER_E_INVALID_TIME_PERIOD)
At D:\Projects\FK.Donki\Sitecore\setup\FKCC-Install-Local-Sc-XP0.ps1:42 char:1
+ Install-SitecoreConfiguration @certParams -Verbose
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Install-SitecoreConfiguration
Install-SitecoreConfiguration : CertEnroll::CX509Enrollment::_CreateRequest: The time period is invalid. 0x80630705 (-2
140993787 PEER_E_INVALID_TIME_PERIOD)
At D:\Projects\FK.Donki\Sitecore\setup\FKCC-Install-Local-Sc-XP0.ps1:42 char:1
+ Install-SitecoreConfiguration @certParams -Verbose
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [Write-Error], WriteErrorException
    + FullyQualifiedErrorId : Microsoft.PowerShell.Commands.WriteErrorException,Install-SitecoreConfiguration


 

Firstly I would like to say thanks to Richard Dzien, from Sitecore as he was super quick via slack to help and identify what the problem.

The issue is caused by the fact that the Trusted root certificates, which had expired. See the image below.

Solution

Part 1 – Delete from Trusted Roots Certificates (Computer Account)

The solution is to delete the certificates form the machine account, which you can do either via the MMC certificates snap in or use power shell. Then run SIF again.

Part 2 – Delete from Trusted Roots Certificates (My User Account)

There was also a copy of the root certificates, as you can see below in my personal Certificate store. which also need to be deleted.

Part 3 – Delete from disk

In addition there can be a copy in C:\Certificates, which also need to be deleted.

Once the certificates are deleted from all locations everything worked. SIF 2 – the root certificates will expire in 10 years so no problem there, once it is released.

I hope this helps, Alan

Bonus help – Certificate not found, when calling xConnect

If you get an error that the xConnect client certificate can not be found in your sitecore log file!

But the certificate is in the store and has not expired!

This could be because the root certificate has expired.

 

 

Sitecore Update xDB Contact

Problem

We had a solution where we had to process any contacts that changed their details in the past 24 hours, for example changed their name, birthday, custom preferences, etc.

I expected I could get a list of all updated contacts (i.e. contacts where one of their facets had changed), but unfortunately, adding or updating facets does not change the contact’s Last Updated property?

According to the Sitecore support & documentation, this is not a bug, but the desired behavior?

Solution

The Contacts Last Updated property is updated, only if an identifier is added or removed.

The following code is a nasty work around that adds/removes an identifier; to force an update of the Last Updated property.

  private void TempWorkAroundToGetSitecoreToUpdateLastmodified(Contact contact, XConnectClient client)
	  {
      //when a contact facet is updated the contacts last modified is not updated?
      // see documentation WTF https://doc.sitecore.net/developers/xp/xconnect/xconnect-client-api/contacts/update-contacts.html

      // the only way to set the last modified for a contact, is to add or remove an identifier, so we have to toggle that
	    var identifierToRemove = contact.Identifiers.FirstOrDefault(x => x.Source == Constants.Source.FakeSource);
	    if (identifierToRemove != null)
	    {
	      client.RemoveContactIdentifier(contact, identifierToRemove);
      }
	    else
	    {
	      client.AddContactIdentifier(contact, new ContactIdentifier(Constants.Source.FakeSource, contact.Id.HasValue ? contact.Id.ToString() : Guid.NewGuid().ToString(), ContactIdentifierType.Known));
	    }
	  }

Each time a contact was updated, we called this code, to ensure it is possible to get a list of all contacts that have changed, within a given time span.

Hope this helps and enjoy the summer, Alan