Showing posts with label Sitecore CDP. Show all posts
Showing posts with label Sitecore CDP. Show all posts

Friday, May 27, 2022

Vercel, Next.js middleware and personalization using Sitecore Personalize

When I started working with Vercel middleware I was convinced that I would be able to parse html before returning it to the user, however, to my big surprise you can't do html parsing in middleware which makes complete sense from the caching perspective.

I used Boxever library implementation from Sitecore.Demo.Edge Git repo and added a few things that I needed.

So if html can't be parse, how would one implement personalization? While searching for solution I ran into a YouTube video by Steve Sewel and a solution from https://www.builder.io. The solution that Steve described made perfect sense and seemed to be the only way to implement personalization in middleware. I used the same approach in my POC and here what I was able to do.

Use case

A user from Germany comes to the site and a component on a product listing page shows a greeting stating the user is visiting from Germany.

While the use case is very simple and you don't necessarily need Sitecore Personalize to implement that, it was the easiest scenario to implement to test the waters with middleware.

Solution

Under /pages folder I added _middleware.ts file which mean that the middleware function will be called for all pages. Here is the content of the file:

import { getPersonalizedPagePath } from 'lib/middleware-utils';
import { NextResponse, NextRequest } from 'next/server'

const excludededPrefixes = ['/favicon', '/api']

export async function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  const country = req.geo?.country || 'US';

  if(!excludededPrefixes.find((path) => url.pathname?.startsWith(path))) {  
    const browserId = req.cookies[`bid_${process.env.CDP_CLIENT_KEY}`] || process.env.CDP_BROWSER_ID;
    const friendlyId = 'user_geolocation';  

    if (!url?.pathname!.includes(';')) {        
      const rewriteUrl = await getPersonalizedPagePath(friendlyId, url?.pathname!, req.cookies, country, browserId);
      if (rewriteUrl && rewriteUrl.path) {
        url.pathname = rewriteUrl.path;          
        return NextResponse.rewrite(url).cookie(`bx_userAttributes_${friendlyId}`, rewriteUrl[friendlyId]);        
      }
    }

    return NextResponse.rewrite(url).cookie(`bx_userAttributes_${friendlyId}`, friendlyId);    
  }
  return NextResponse.rewrite(url);  
}

As you can see I am rewriting the url of the current page to include country and friendlyId parameters.

If a request is made to /products page, for the user from Germany the rewrite url will look like this:

/products;country=DE;user_geolocation=Germany

Country code is easily retrieved from NextRequest. The second parameter is a little bit more involved. I defined a Full Stack experience in Sitecore Personalize that is called user_geolocation. The experience returns Germany if the latest guest session come from Germany. I added the country code (retrieved from NextRequest) to the VIEW event, so now all guest VIEW events have a country code. I also have a Decision Model attached to my Full Stack experience which gives me flexibility with API response. I can return "Germany" or I can return something else that I can use to display personalized content. The only thing to be aware of is the length of the outcome. Since it is part of the url, it should be short. I can use this response either to hardcode personalization or retrieve personalized content from Sitecore. For the simplicity of this POC, I hardcoded the personalization message.

When the content for rewritten url is being request and for SSG Next.js application it is possible because of the Next.js ISR functionality. The fallback configuration is set to "blocking" as for a usual Sitecore JSS application with SSG and the revalidate for this POC is set to 15 seconds. In real live you would want it to be as high as possible.

When the new version of the pages is being generated getStaticProps function is being called in [[...path]].ts. I added a few lines to read the parameters that I set in middleware and remove them from the actual page path, so it can retrieve the proper page from Sitecore.

export const getStaticProps: GetStaticProps = async (context) => {
  if (context) {    
    let userAttributes:any = {};
    let path:string[] = [];
    if(context.params?.path) {
      (context.params?.path as string[]).forEach((part) => {
        if (part && part.includes(';')) {
          userAttributes = { ...getTargetingValues(part?.split(';').slice(1)) };
          path.push(part?.split(';')[0]);
        } else {
          path.push(part);
        }
      });
    }
    context.params!.userAttributes = userAttributes;
    context.params!.path = path;
  }

  const props = await sitecorePagePropsFactory.create(context);  

  return {
    props,
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 15 seconds
    revalidate: 15, // In seconds
    notFound: props.notFound, // Returns custom 404 page with a status code of 404 when true
  };
};

Now in the Next.js component that will render the personalized content I added the following:

import { Field, withDatasourceCheck, useComponentProps, GetStaticComponentProps } from '@sitecore-jss/sitecore-jss-nextjs';
import { StyleguideComponentProps } from 'lib/component-props';

type PersonalizedNavigationProps = StyleguideComponentProps & {
  fields: {
    friendlyId: Field<string>;
  };
};

type PersonalizedNavigationData = { content: '', country: '' };

const PersonalizedNavigation = ({ rendering }: PersonalizedNavigationProps): JSX.Element => {
  const personalizedContent = rendering.uid ? useComponentProps<PersonalizedNavigationData>(rendering.uid) : undefined;
  let personalizedOutput = '';
  //console.log(personalizedContent);
  if (personalizedContent?.country) {    
    switch(personalizedContent?.country) {
      case 'US': {
        personalizedOutput = 'USA';
        break;
      }
      case 'DE': {
        personalizedOutput = 'Germany';
        break;
      }
      default: {
        //statements;
        break;
      }
    }
  }

  if (personalizedContent?.content) {    
    switch(personalizedContent?.content) {
      case 'US': {
        personalizedOutput = personalizedOutput + ' ::: USA';
        break;
      }
      case 'DE': {
        personalizedOutput = personalizedOutput + ' ::: Germany';
        break;
      }
      default: {
        //statements;
        break;
      }
    }
  }

  return (
    <div>
      <h2>
        Country:
        {personalizedOutput && (
          <> {personalizedOutput}</>
        )}      
      </h2>
    </div>
  );
};

export const getStaticProps: GetStaticComponentProps = async (rendering:any, context:any, params:any) => {
  if (params && rendering && context) {
    const friendlyId = rendering?.fields?.data?.contextItem?.friendlyId.value || 'user_geolocation' as string;
    const country = params?.params?.userAttributes ? params?.params?.userAttributes?.country : '';
    const output = params?.params?.userAttributes ? params?.params?.userAttributes[friendlyId] : '';
    //console.log(params);
    return { country: country || '', content: output || ''};
  }
  return {};
};

export default withDatasourceCheck()<PersonalizedNavigationProps>(PersonalizedNavigation);

And on the page after the code has been deployed to Vercel the following snippet will render:




Below you'll find the files references from the code above. I decided to separate them into a separate section to avoid the flow disruption in my explanation.

Referenced .ts files:

middleware-utils.ts

import { DynamicData, getDynamicData, PersonalizedPath } from "src/services/BoxeverApiService";

const getUrlSegments = (values:any) => {
    const attrs = Object.keys(values);
    return attrs
        .map((attr) => `${attr}=${encodeURIComponent(values[attr])}`)
        .sort();
};

const getTargetingCookies = (cookiesMap:any) => Object.keys(cookiesMap).filter((cookie) => cookie.startsWith("bx_userAttributes"));

export function getTargetingValues(path:any) {
    return path.sort().reduce((acc:any, segment:any) => {
        const [key, value] = segment.split("=");
        if (key) {
            return Object.assign(Object.assign({}, acc), { [key]: decodeURIComponent(value) });
        }
        return acc;
    }, {});
};

export function getPersonalizedRewrite(pathname: string, cookies: Record<string, string>, country: string, experiencePair: string) {
    const attributes = getTargetingCookies(cookies);
    const values = attributes.reduce((acc, cookie) => {
        const value:string = cookies[cookie];
        const key = cookie.split("bx_userAttributes_")[1];
        return Object.assign(Object.assign({}, acc), (typeof value === 'string' && { [key]: value }));
    }, {});
    if (Object.keys(values).length > 0 || country) {
        return `${pathname};${experiencePair};${getUrlSegments(Object.assign(values, { country })).join(";")}`;
    }
    return false;
}

export function getUserAttribute(name:string, path:string) {
    if (name && path) {
        const attr = path.split(';');
        let attrValue = '';
        attr.forEach((part) => {
            const pathAttr = part.split('=');
            if (pathAttr[0] === name) {
                attrValue = pathAttr[1];
            }
        });

        return attrValue;
    }

    return '';
}

export async function getPersonalizedPagePath(friendlyId:string, path:string, cookies:any, country:string, browserId?:string):Promise<PersonalizedPath> {
    let response:PersonalizedPath = {};
    const outputValue = cookies[`bx_userAttributes_${friendlyId}`];
    if (browserId) {
        await getDynamicData('en', friendlyId, browserId).then((content: DynamicData) => {
            const result = content && content.decisionValues ? content.decisionValues.filter((i) => { return i.decisionType === 'programmable'})[0].outputValue : ''
            if (result) {    
                response[friendlyId] = result;
                response.path = getPersonalizedRewrite(
                    path,
                    cookies,
                    country,
                    `${friendlyId}=${response[friendlyId]}`
                ) as string;

                return response;
            }      
            return response;
        });
    } else if (outputValue) {
        response[friendlyId] = outputValue;
        response.path = getPersonalizedRewrite(
            path,
            cookies,
            country,
            `${friendlyId}=${response[friendlyId]}`
        ) as string;

        return response;
    }
   
    return response;
}

BoxeverApiService.ts

import BoxeverServiceConfig from './BoxeverApiServiceConfig';

// ***** TYPES *****

type GuestRef = string;

interface GuestRefResponse {
  guestRef: GuestRef;
}


// ***** API *****

const CDP_CLIENT_KEY = process.env.CDP_CLIENT_KEY || '';
const CDP_API_TARGET_ENDPOINT = process.env.CDP_API_TARGET_ENDPOINT || '';
const CDP_API_ENDPOINT = process.env.CDP_API_ENDPOINT || '';
export const isCdpConfigured = !!CDP_CLIENT_KEY && !!CDP_API_TARGET_ENDPOINT;

// ****************************************************************************
// Delaying calls is required due to some race conditions where a guest
// identification call was executed before the Boxever library had finished
// initializing.
// ****************************************************************************

function callFlows(flowConfig: Record<string, unknown>) {
  return new Promise(function (resolve, reject) {
    try {
      boxeverGet('callFlows', flowConfig)
      .then((result) => { resolve(result) })
      .catch((error) => console.log(error));      
    } catch (err) {
      reject(err);
    }
  });
}

// ****************************************************************************
// Gets the current guest ref by calling a CDP flow.
// This is a workaround needed when an anonymous guest identify itself as a
// previously known guest, CDP is merging the 2 guests. The browser keeps the
// anonymous guest ref. From that moment, calls to the authenticated CDP APIs
// like get/set for data extensions are failing when using this old anonymous
// guest ref. These calls require the previously known guest ref which is
// returned by our flow.
// ****************************************************************************
export function getGuestRef(): Promise<GuestRefResponse> {
  return callFlows({
    friendlyId: 'get_guestref',
  }) as Promise<GuestRefResponse>;
}

export async function getBrowserId() {
  let browserId = '';
  const url = `${CDP_API_TARGET_ENDPOINT}/browser/create.json?client_key=${BoxeverServiceConfig.clientKey}?message={}`;
  const response = await fetch(url, { headers: {
    'Content-Type': 'application/json',
  } });
  response.json().then((response) => { browserId = response.ref || '' }).catch((error) => console.log(error));
  return browserId;
}

async function boxeverGet(action: string, payload?: Record<string, unknown>): Promise<unknown> {  
  let message = {...BoxeverServiceConfig, ...payload};
  const url = `${CDP_API_ENDPOINT}/${action}?message=${JSON.stringify(message)}`;
  const response = await fetch(url, { headers: {
    'Content-Type': 'application/json',
  } });
  return response.json().catch((error) => console.log(error));      ;
}

export interface DynamicData {
  decisionValues: [
    {
      decisionType: string,
      trackingUrl: string,
      decisionName: string,
      outputName: string,
      outputValue: string,
    }
  ],
  decisionOffers: [
    {
      ref: string,
      name: string,
      description: string,
      status: string,
      attributes: {
        link: string,
        name: string,
        imageUrl: string
      }
    }
  ]
}

export interface UserAttributes {
  urlPath:string,
  countr:string,
  browserId:string
}

export interface CreateResponse {
  status: string,
  version: string,
  client_key: string,
  ref: string,
  customer_ref: string
}

export interface PersonalizedPath {
  [key: string]: any
}

export function getDynamicData(
  language: string,
  friendlyId:string,
  browserId: string
): Promise<DynamicData> {
 
  return callFlows({
    friendlyId: friendlyId,
    language: language,
    browserId: browserId
  }) as Promise<DynamicData>;
}

BoxeverApiServiceConfig.ts

const POINT_OF_SALE = process.env.CDP_POINT_OF_SALE;
const CURRENCY = 'USD';
const CDP_CLIENT_KEY = process.env.CDP_CLIENT_KEY || '';

const BoxeverApiServiceConfig = {
  channel: 'WEB',
  clientKey: CDP_CLIENT_KEY,
  pointOfSale: POINT_OF_SALE,
  currencyCode: CURRENCY
};

export default BoxeverApiServiceConfig;



Friday, November 5, 2021

Sitecore CDP: Triggered Experience with decision model needs offers

 I was fortunate to be able to take Sitecore CDP training with Sarah O'Reilly (https://www.sitecore.com/products/customer-data-platform). There were a lot of interesting features in Sitecore CDP that we got to play with during this training. If you have a chance to take, do so, you won't regret it.

During day four of the training we ran into an issue when our triggered full stack experience was failing with "NO DECISION" message. The full stack experience used a decision model that simply returned a list of products in abandoned cart and had only one programmable that returned that list. Without a decision model the experience works fine and send the email, but if a decision model is added it fails.



After further investigation into the log for each failed execution it was clear that the experience is expecting the offers even though there were no offers to return. All we wanted to do is to generate a list of products in abandoned cart and send guest an email with the list of those products.

Clicking on the "View Log" brings back the details about a specific execution which allowed me to debug it further. As as you can see below the decision model returns results, and everything should be working, but it didn't. 


I figured why not to compare the executions without the decision models that succeeded with the ones with the model that are returning "NO DECISION". And I noticed that one particular property is null in the second case:



The next step was adding logging to my programmable. To do that I added "print()" statement to render the guest session details and made this variant of the decision model live. In the log property of my failed execution I started seeing values and one of those values was "status=ERROR, error=No offers returned, errorCategory=NO_DECISION".

I started suspecting that the experience expects to get some sort of offers from the decision model. So I added a decision model to the programmable that always returns an offer. It doesn't matter what the offer is as long as it is an offer. After I made that variant of the decision model live my triggered experience started working.

Sarah O'Reilly confirmed that while this limitation of Sitecore CDP was recently removed in interactive experience, it still exists in triggered one. Just something to be aware of.

Tuesday, October 12, 2021

What is Boxever and why it is worth looking into it?

 As you probably know Sitecore has made a few acquisitions in the last year that promise to alter the Sitecore platform significantly.  Customer Data Platform called Boxever was one of the first two companies that got acquired by Sitecore. The first glimpse at the platform created a lot of buzz in Sitecore community which is not surprising. The platform provides capabilities that Sitecore xDB doesn’t, and in my humble opinion, it will replace Sitecore xDB in the future.

Why is Boxever worth looking into?

Client-Side Execution

First and foremost, Boxever CDP tracking and personalization is happening on the front-end. It means that as a developer you need to include the JavaScript reference to the Boxever library, and you are ready to roll. The way you include the reference to the library It is very similar to Google Analytics, Adobe Analytics, and many other services. If you are thinking of implementing a Jamstack solution, Boxever is a perfect solution for analytics and personalization.

 Connections to any system over REST API

If you need to retrieve the list of products from a separate commerce system and display different collection of products based on the audience or other criteria, you can do that with Boxever. It provides the Data sources feature that allows you to create connections to various systems of your choosing.

Personalization

You can personalize areas of the page based on the user data and the rules that you define in Boxever admin. Under the hood, all Boxever JavaScript does is it injects the html that you defined in the admin into a specific area of your page. Based on the rules you define; it can inject different html. The only challenge is the delay. Since the personalization is running on the front end and there is a call made to Boxever service to validate the rules and provide corresponding html, it takes a little bit of time to render the Web Experience. Rendering personalized main navigation with Boxever might not be the best solution because of that, but if you alter areas of the page that are below the page fold, for example, it works perfectly.

 APIs

Boxever CDP comes with a number of API endpoints that allows you alter user data that gets tracked by the system, retrieve the user data in various ways, manipulate connections, etc. Overall, it is a pretty long list of APIs that you can use in various cases.

Learning Boxever

Sitecore provided a Boxever course on the Learning Portal that would give you a basic understanding of the platform. Don’t expect it to go into developer implementation details. There is a separate documentation website for that, which is pretty useful.

https://developer.boxever.com/reference/whats-new