Optimizely Product Recommendation Troubleshooting
In today’s fast-paced digital landscape, personalization is everything. Customers expect relevant, tailored experiences whenever they interact with a brand — and meeting that expectation can make or break your success. That’s where Optimizely's Product Recommendation feature shines.
What is Optimizely Product Recommendation?
Optimizely’s Product Recommendation feature is a powerful, AI-driven tool designed to help brands offer hyper-personalized product suggestions to their customers. It’s built into the Optimizely Commerce platform and integrates seamlessly with both Commerce Cloud and Customized Commerce (formerly Episerver Commerce).
Instead of relying on static product lists, Optimizely uses machine learning algorithms and real-time customer behavior to dynamically surface the right products to the right audience at the right time.
Why Product Recommendations Matter?
- Boost Conversion Rates
Relevant product suggestions keep customers engaged and help them discover products they might not have found otherwise, leading to increased sales. - Enhance Customer Experience
When users feel like a site “understands” them, they’re more likely to stay, browse, and buy. - Increase Average Order Value
Smart cross-selling and upselling through recommendations can encourage customers to add more to their cart. - Reduce Bounce Rates
Presenting enticing alternatives or related items keeps visitors on your site longer.
Troubleshooting
During the implementation, we faced a few challenges, which I have outlined here.
Compatible Package
Before starting, ensure you verify the Commerce version you are using and identify the compatible versions of related packages.
For example, in my case, I was using EPiServer.Commerce 13.33.0, so I installed EPiServer.Personalization.Commerce and EPiServer.Tracking.Commerce version 3.2.37.
Configuration
If you are using a single catalog, follow the single-site configuration; otherwise, use the multi-site configuration approach. Since I was working with a multi-site, channel-based setup, I made a mistake during the configuration — I used Web instead of web, and similarly Mobile instead of mobile. Please note that these values are case-sensitive, so ensure you use the correct lower-case terms to avoid configuration issues.
Single Site
<add key="episerver:personalization.BaseApiUrl" value="https://mysite.uat.productrecs.optimizely.com" />
<add key="episerver:personalization.Site" value="MySite" />
<add key="episerver:personalization.ClientToken" value="MyClientToken" />
<add key="episerver:personalization.AdminToken" value="MyAdminToken" />
Multi-Site:
The multi-site configuration is scope-based. If you are using more than one catalog, you need to set up the configuration in a repeated form as shown below.
<add key="episerver:personalization.ScopeAliasMapping.MyScope" value="SiteId"/>
<add key="episerver:personalization.CatalogNameForFeed.MyScope" value="CatalogName"/>
<add key="episerver:personalization.BaseApiUrl.MyScope" value="https://sitename.uat.productrecs.episerver.net" />
<add key="episerver:personalization.Site.MyScope" value="sitename" />
<add key="episerver:personalization.AdminToken.MyScope" value="MyAdminToken" />
<add key="episerver:personalization.ClientToken.MyScope.web" value="MyWebToken" />
<add key="episerver:personalization.ClientToken.MyScope.mobile" value="MyMobileToken" />
Sync Specific Pricing (Group Price)
The 'Product Export Job' syncs only the 'All Customers' pricing group. To sync prices for a specific pricing group, use IEntryPriceService to fetch the price for that group.
e.g.
public class DefaultEntryPriceService : IEntryPriceService
{
private readonly IPromotionEngine _promotionEngine;
private readonly IMarketService _marketService;
private readonly IPriceService _priceService;
private readonly bool _calculateDiscountPrices;
private readonly IPricingService _pricingService;
public DefaultEntryPriceService(IPromotionEngine promotionEngine, IMarketService marketService, IPricingService pricingService)
{
_promotionEngine = promotionEngine;
_marketService = marketService;
_pricingService = pricingService;
_calculateDiscountPrices = !bool.TryParse(ConfigurationManager.AppSettings["episerver:personalization.CalculateDiscountPrices"], out _calculateDiscountPrices) || _calculateDiscountPrices;
}
public IEnumerable<EntryPrice> GetPrices(IEnumerable<EntryContentBase> entries, DateTime validOn, string scope)
{
CustomEntryPriceService entryPriceService = this;
List<CatalogKey> catalogKeys = entries
.Where(x => x is IPricing)
.Select(new Func<EntryContentBase, CatalogKey>(entryPriceService.GetCatalogKey))
.ToList();
Dictionary<string, ContentReference> codeContentLinkMap = entries.ToDictionary(c => c.Code, c => c.ContentLink);
IEnumerable<IMarket> source1 = entryPriceService._marketService.GetAllMarkets().Where(x => x.IsEnabled);
List<IPriceValue> source2 = new List<IPriceValue>();
foreach (IMarket market in source1)
{
foreach (CatalogKey key in catalogKeys)
{
//Read price for specific group from your service
var price = _pricingService.GetConsumerListPricing(key.CatalogEntryCode, market);
source2.Add(new PriceValue
{
CatalogKey = key,
MarketId = market.MarketId,
UnitPrice = new Money(price ?? decimal.Zero, market.DefaultCurrency)
});
}
}
foreach (var priceValue in source2.GroupBy(c => new
{
c.CatalogKey,
c.UnitPrice.Currency
}).Select(g => g.OrderBy(x => x.UnitPrice.Amount).First()).ToList())
{
ContentReference contentLink;
if (codeContentLinkMap.TryGetValue(priceValue.CatalogKey.CatalogEntryCode, out contentLink))
{
Money salePrice = priceValue.UnitPrice;
if (entryPriceService._calculateDiscountPrices)
{
var market = _marketService.GetMarket(priceValue.MarketId);
//Read Discounted Price
var discountPrice = _pricingService.GetConsumerDiscountPricing(
priceValue.CatalogKey.CatalogEntryCode,
market) ?? decimal.Zero;
salePrice = discountPrice == 0
? priceValue.UnitPrice
: new Money(discountPrice, market.DefaultCurrency);
}
yield return new EntryPrice(contentLink, priceValue.UnitPrice, salePrice);
}
}
}
private CatalogKey GetCatalogKey(EntryContentBase entryContent)
{
return new CatalogKey(entryContent.Code);
}
}
Target Specific Markets:
When targeting products for specific markets, use ICatalogItemFilter to filter the products from your catalog before syncing the feed into product recommendations.
In our case, for one of the sites, we are targeting more than 16 markets. However, we encountered an issue syncing the feed for the China market, as the Unicode product URLs were not compatible with the product recommendations feed (Optimizely need to fix the Unicode issue). As a result, we decided to launch this feature for specific markets only.
e.g.
public class DefaultCatalogItemFilter : ICatalogItemFilter
{
private readonly IPublishedStateAssessor _publishedStateAssessor;
private List<string> languages = new List<string>() { "en", "en-CA", "fr-CA" };
public DefaultCatalogItemFilter(IPublishedStateAssessor publishedStateAssessor)
{
_publishedStateAssessor = publishedStateAssessor;
}
public bool ShouldFilter(CatalogContentBase content, string scope)
{
if (_publishedStateAssessor.IsPublished(content, PublishedStateCondition.None) &&
languages.Any(x => string.Equals(x, content.Language.Name, StringComparison.InvariantCultureIgnoreCase)))
{
return false;
}
return !_publishedStateAssessor.IsPublished(content, PublishedStateCondition.None);
}
}
Sync Specific Attributes in the Product Feed
To target specific attributes for syncing into the feed for catalog entries, use IEntryAttributeService. This service helps you apply certain rules to control how products are displayed in the recommendation area.
In our case, we are using a single codebase for over 14 catalogs. However, the products and variants have some uncommon properties that cannot be included in the feed for other sites.
public class DefaultEntryAttributeService : IEntryAttributeService
{
private readonly CatalogFeedSettings _catalogFeedSettings;
private readonly IVariantAttributeService _variantAttributeService;
public DefaultEntryAttributeService(CatalogFeedSettings catalogFeedSettings, IVariantAttributeService variantAttributeService)
{
_catalogFeedSettings = catalogFeedSettings;
_variantAttributeService = variantAttributeService;
}
public bool CanBeRecommended(EntryContentBase content, decimal stock, string scope)
{
return stock > 0M;
}
public IDictionary<string, string> GetAttributes(EntryContentBase content, string scope)
{
List<string> userMetaFieldNames = GetUserMetaFields(content).ToList();
Dictionary<string, string> attributes = new Dictionary<string, string>();
if (!userMetaFieldNames.Any())
return attributes;
// Read attributes from your list that you would like to exclude from feed
_catalogFeedSettings.ExcludedAttributes = RecommendationHelper.ExcludeAttributes;
HashSet<string> excludedAttributes = new HashSet<string>(_catalogFeedSettings.ExcludedAttributes, StringComparer.OrdinalIgnoreCase);
foreach (PropertyData propertyData in content.Property.Where(x => IsValidContentProperty(x, userMetaFieldNames, excludedAttributes)))
{
attributes.Add(propertyData.Name, propertyData.Value.ToString());
}
if (content is MyVariant variant)
{
foreach (var attr in RecommendationHelper.IncludeAttributes) // Include specific attributes
{
if (attributes.Any(x => string.Equals(x.Key, attr, StringComparison.OrdinalIgnoreCase)))
continue;
// Read the value for attribute if exists in catalog entries
var value = _variantAttributeService.GetAttributeValueByName(attr, variant);
if (!string.IsNullOrWhiteSpace(value))
{
attributes.Add(attr, value);
}
}
}
return attributes;
}
public string GetDescription(EntryContentBase entryContent, string scope)
{
return entryContent[_catalogFeedSettings.DescriptionPropertyName]?.ToString();
}
public string GetTitle(EntryContentBase entryContent, string scope)
{
return !string.IsNullOrEmpty(entryContent.DisplayName) ? entryContent.DisplayName : entryContent.Name;
}
private bool IsValidContentProperty(
PropertyData property,
IEnumerable<string> userMetaFieldNames,
HashSet<string> excludedAttributes)
{
return userMetaFieldNames.Any(x => x.Equals(property.Name))
&& !property.Name.Equals("_ExcludedCatalogEntryMarkets")
&& !property.Name.Equals("DisplayName")
&& !property.Name.Equals("ContentAssetIdInternal")
&& !property.Name.StartsWith("Epi_")
&& property.Value != null
&& !excludedAttributes.Contains(property.Name);
}
public IEnumerable<string> GetUserMetaFields(EntryContentBase content)
{
var metaClass = Mediachase.MetaDataPlus.Configurator.MetaClass.Load(new MetaDataContext()
{
UseCurrentThreadCulture = false,
Language = content.Language.Name
}, content.MetaClassId);
return
metaClass != null
? metaClass.GetUserMetaFields().Select(x => x.Name).ToList()
: null ?? Enumerable.Empty<string>();
}
}
Product Page Tracking
Use the product code instead of the variant code to track the product.
If you're encountering any challenges with the native implementation, feel free to reach out for assistance.
Cheers!
Great Article, Sanjay.
Thank you for sharing.
Great Article, Sanjay.
Pleasure working with you on this project and look forward to working on more going forward