Monday, February 23, 2015

Syncing xDB Experience Profiles with Dynamics CRM


Intro and Prerequisites

Like other Sitecore enthusiasts, I have been extremely impressed with the new technology and architecture behind xDB

As Nick Wesselman puts it: "The Experience Database provides data structures and APIs to allow you to track an individual across devices, provided you can identify him/her by some unique identifier such as an email address. This is key, otherwise you just end up having a ton of anonymous contacts in your database."

If you haven't read his blog, I highly recommend it. It got me on the right path with all things xDB / Mongo:

Reading through his "One Month with Sitecore 7.5, Part 5: Persisting Contact Data to xDB" post where he talks about Contact Facets will help you better understand what I am doing below.

Also make sure you check out Martina Welander's posts as well: She has tons of good stuff in there.

The Goal

As you can see, the code for identifying a user is straightforward:

1:  var tracker = Sitecore.Analytics.Tracker.Current;  
2:  if (tracker == null || !tracker.IsActive || tracker.Contact == null)  
3:  {  
4:     return;  
5:  }  
6:  tracker.Session.Identify("some unique identifier");  

This is nice and all, but it doesn't help with populating my Experience Profile (xFile).

Wouldn't it be awesome if I could identify the user, and then check my CRM system to see if it contained profile data about the user, and if so, populate the xFile using that information?

Wouldn't it be even more awesome if after I identified the user, I could make sure that my xFile always kept in sync with my CRM system?

Having extensive Dynamics CRM development experience, I decided to write up a POC to make this happen.

NOTE: What I am doing here is a one-way sync from Dynamics CRM to Sitecore. We could very easily take this one step further with a more advanced contact form, and update data in CRM from Sitecore.

Identify User and Flush-To-Mongo Control

My first task was to put together a user control that would allow me to identify the user and then flush the session data to Mongo so that I could review the outcome.

The basic control consisted of 1 text box and 2 buttons. The text box would be used to capture the user's email address. Clicking the first button would identify the user using the code shown above, while clicking the second button would flush the session data to Mongo.

Flushing the session data can be achieved by simply making a call to end the session: Session.Abandon()

Here is the code-behind for my control:

1:   public partial class IdentifyUser : System.Web.UI.UserControl  
2:    {  
3:      private void Page_Load(object sender, EventArgs e)  
4:      {  
5:        btnIdentifyUser.Click += btnIdentifyUser_Click;  
6:        btnFlushToMongo.Click += btnFlushToMongo_Click;  
7:        lblIdentiedUser.Text = Tracker.Current.Contact.Identifiers.Identifier;  
8:      }  
9:      private void btnIdentifyUser_Click(Object sender, EventArgs e)  
10:      {    
11:        Tracker.Current.Session.Identify(txtCurrentUser.Text);  
12:        string myScript = "alert('Done!');";  
13:        Page.ClientScript.RegisterStartupScript(GetType(), new Guid().ToString(), myScript, true);  
14:      }  
15:      private void btnFlushToMongo_Click(Object sender, EventArgs e)  
16:      {  
17:        Session.Abandon();  
18:        string myScript = "alert('Flushed!');";  
19:        Page.ClientScript.RegisterStartupScript(GetType(), new Guid().ToString(), myScript, true);  
20:      }  
21:    }  

Nothing elegant about this at all (especially because I used WebForms over MVC). I simply slapped this on the out-of-the-box Sitecore demo page and the result looked like this:

You will notice that I also had a label on line 7 where I would populate the contact's identifier so that I could make sure that the tracker is working as expected.

Working with Dynamics CRM

Creating a Proxy Class

When coding for Dynamics CRM, you need to use the "CrmSvcUtil" command-line code generation tool, available in the CRM SDK, in order to generate early-bound classes that represent the entity data model in your CRM system.

So in short; this tool located in the bin folder of the SDK is used to create a proxy class from the CRM Web Service of your instance.

For more information about this, you can check out this link from Microsoft:

I named my generated class file "CrmModels.cs" and threw it into my project.

The xFileManager

I started putting together a manager class that I would use to facilitate finding a user / contact match in Dynamics CRM and then populate the xFile using the contact record's data.

I added a Dynamics "contact" entity property to my class called CrmContact. The plan was to populate this if I had a match in CRM based on email address, and then use it to hydrate my various Sitecore xFile contact facet objects.

Finding the contact in Dynamics CRM

The first method I added to my class was to make a call out to Dynamics to see if I had a contact match based on the identified user's email address. If I did find the user, it would set my CrmContact property to the match and return true.

1:  private bool FoundCrmContact(string emailAddress)  
2:      {  
3:        var connection = new CrmConnection("MTCCRMConnString");  
4:        var org = new OrganizationService(connection);  
5:        var crmDataContext = new CRMDataContext(org);  
6:        var contactMatch = crmDataContext.ContactSet.Where(t => t.EMailAddress1.Equals(emailAddress));  
7:        if (contactMatch.FirstOrDefault() != null)  
8:        {  
9:          CrmContact = contactMatch.FirstOrDefault();  
10:          return true;  
11:        }  
12:        return false;  
13:      }  

Syncing CRM contact data with the identified contact's facets

Next, I focused on writing a method that would populate the contact's various facets with the CRM data if a match was found.

Here is my method:

1:  public void SyncCrmXFile(Sitecore.Analytics.Tracking.Contact identifiedUser)  
2:      {  
3:        if (identifiedUser.Identifiers.IdentificationLevel == ContactIdentificationLevel.Known)  
4:        {  
5:          //Get contact unique indentifier, in our case the email address  
6:          var contactEmail = identifiedUser.Identifiers.Identifier;  
7:          if (FoundCrmContact(contactEmail))  
8:          {  
9:            #region Get Facets  
10:            var emailFacet = identifiedUser.GetFacet<IContactEmailAddresses>("Emails");  
11:            var addressFacet = identifiedUser.GetFacet<IContactAddresses>("Addresses");  
12:            var personalFacet = identifiedUser.GetFacet<IContactPersonalInfo>("Personal");  
13:            var phoneFacet = identifiedUser.GetFacet<IContactPhoneNumbers>("Phone Numbers");  
14:            IEmailAddress email = emailFacet.Entries.Contains("Work Email")   
15:              ? emailFacet.Entries["Work Email"]  
16:              : emailFacet.Entries.Create("Work Email");  
17:            IAddress address = addressFacet.Entries.Contains("Work Address")  
18:              ? addressFacet.Entries["Work Address"]  
19:              : addressFacet.Entries.Create("Work Address");  
20:            IPhoneNumber workPhone = phoneFacet.Entries.Contains("Work Phone")  
21:              ? phoneFacet.Entries["Work Phone"]  
22:              : phoneFacet.Entries.Create("Work Phone");  
23:            #endregion  
24:            #region Update Facets with CRM Contact Data  
25:            email.SmtpAddress = CrmContact.EMailAddress1;  
26:            emailFacet.Preferred = "Work Email";  
27:            address.StreetLine1 = CrmContact.Address1_Line1;  
28:            address.StreetLine2 = CrmContact.Address1_Line2;  
29:            address.PostalCode = CrmContact.Address1_PostalCode;  
30:            personalFacet.Title = CrmContact.Salutation;  
31:            personalFacet.FirstName = CrmContact.FirstName;  
32:            personalFacet.MiddleName = CrmContact.MiddleName;  
33:            personalFacet.Surname = CrmContact.LastName;  
34:            //personalFacet.Gender = CrmContact.GenderCode.Value;  
35:            personalFacet.JobTitle = CrmContact.JobTitle;  
36:            personalFacet.BirthDate = CrmContact.BirthDate;  
37:            workPhone.CountryCode = workPhone.Number = CrmContact.Telephone1;  
38:            #endregion  
39:            Sitecore.Diagnostics.Log.Info(string.Format("Successfully synced Dynamics CRM known user: {0} {1}", CrmContact.FirstName, CrmContact.LastName), this);  
40:          }  
41:        }  
42:      }  

In summary, the code above does the following:

  1. Accepts a Sitecore.Analytics.Tracking.Contact object as a parameter
  2. Makes sure that we had already identified the contact
  3. Pulls out the identified contact's email address and looks for a match in CRM
  4. If a match exists in CRM, it uses the data to populate / sync the identified contact's facets
I went ahead and added a call to the method in my IdentifyUser button click event handler just after the line where I identify the contact:

1:  Tracker.Current.Session.Identify(txtCurrentUser.Text);  
2:  var xFile = new XFileManager();  
3:  xFile.SyncCrmXFile(Tracker.Current.Contact);  

Identify, Flush and Review

With all these pieces in place, I was ready to do some testing. In my example below, I am using Stefan Edberg as my identified contact.

Side note

If you don't know who Stefan Edberg is, he was an awesome tennis player; one of my idols when growing up. He is currently the tennis coach of Roger Federer.

Here he is as a contact in our Dynamics CRM system:

So, I went ahead and identified Stefan using his email address. Things were starting to look good:

Next, I wanted to flush the session data to Mongo, and review what was populated in our Experience Profile dashboard:

Nice! There he is. Clicking on his name and then the details tab revealed the rest of the data that was populated using the CRM contact data:

Keeping things in sync

What I wanted to do next was make sure that my xFile always contained the most up-to-date data from my CRM system.

The key was finding the sweet spot in the "Session End" pipeline before the actual contact data gets flushed to Mongo.

Looking through some of the analytics config files, and digging a little bit around the analytics assemblies, I found my spot before the Sitecore.Analytics.Pipelines.SessionEnd.InitializeTracker processor.

So, my processor was simply:

1:   public class SyncCrm  
2:    {  
3:      public void Process(PipelineArgs args)  
4:      {  
5:        Assert.ArgumentNotNull(args, "args");  
6:        if (!AnalyticsSettings.Enabled)  
7:        {  
8:          return;  
9:        }  
10:        RunCrmSync();  
11:      }  
12:      private void RunCrmSync()  
13:      {  
14:        var xFile = new XFileManager();  
15:        xFile.SyncCrmXFile(Tracker.Current.Session.Contact);  
16:      }  
17:    }  

My config file looked like this:

1:  <configuration xmlns:patch="">  
2:   <sitecore>  
3:    <pipelines>  
4:     <sessionEnd>  
5:      <processor type="Arke.Sandbox.Xrm.Pipelines.Analytics.SyncCrm, Arke.Sandbox.Xrm" patch:before="processor[@type='Sitecore.Analytics.Pipelines.SessionEnd.InitializeTracker, Sitecore.Analytics']" />  
6:     </sessionEnd>  
7:    </pipelines>  
8:   </sitecore>  
9:  </configuration>  

Mission Complete

With this processor in place, every time the session ends, and before the data gets flushed to Mongo, there would be a call to Dynamics CRM to pull in the latest data associated with the contact so that their Sitecore xFile would always stay up-to-date.


Unknown said...

Really good article thanks. Have you been running this in a production environment? Would you advise this approach with a medium to large number of contacts (i.e. CRM contains 600k - 700k contacts)?

Martin English said...


SI actually build a module around this concept and presented it at this years Sitecore User Group conference. The slide deck contains the "recipe" for the module, so you could build something similar to solve your problem.

I think you will be fine with that number of contacts. When you start looking at the 10s of millions, thats were you need to think about a different approach. We had on of our client's in this boat, and instead of making the calls directly over to CRM, we added a database that contained contact records that were synced with CRM, that we made our calls to.

Post a Comment