<![CDATA[danisaacs.net]]>https://blog.danisaacs.net/https://blog.danisaacs.net/favicon.pngdanisaacs.nethttps://blog.danisaacs.net/Ghost 4.36Fri, 25 Mar 2022 04:41:03 GMT60<![CDATA[Guess who's back? Alloy's back!]]>Alloy! On CMS 12!

If you've worked with Optimizely Content Cloud for awhile, two things are probably true:

  1. You still sometimes call it Episerver CMS (and if it's been a longer while, you write it as EPiServer CMS)
  2. You've seen the Alloy demo site
]]>
https://blog.danisaacs.net/guess-whos-back-alloys-back/622382367ea3e100011d57d9Sat, 05 Mar 2022 15:48:34 GMTAlloy! On CMS 12!Guess who's back? Alloy's back!

If you've worked with Optimizely Content Cloud for awhile, two things are probably true:

  1. You still sometimes call it Episerver CMS (and if it's been a longer while, you write it as EPiServer CMS)
  2. You've seen the Alloy demo site

Until recently with CMS 12, starting a new site meant either an empty site, or a Foundation site. However, last Friday, Optimizely quietly released a CMS12 version of the Alloy sample site.

Guess who's back? Alloy's back!

Want to get your own CMS12 Alloy site running locally? Follow the steps below!

Prereqs

.NET SDK 5+ SQL Server 2016 Express LocalDB (or later)

Setup

First, set up your development environment -- install the Episerver Templates and the Episerver CLI Tool.

Note: if it’s been awhile since you installed the CLI Tool, it might be worthwhile to make sure you’re on the latest version:

dotnet tool update EPiServer.Net.Cli --global --add-source https://nuget.optimizely.com/feed/packages.svc/

Install the Alloy templates: in your command-line of choice, run the following command to install the template package (you can look up the latest version number here):

dotnet new --install EPiServer.Templates::1.0.0

Create your project folder, and change to that directory:

mkdir projectname

cd projectname

Create a new CMS 12 site with the Alloy MVC templates included:

dotnet new epi-alloy-mvc --name projectName

Create a new, empty database:

dotnet-episerver create-cms-database ProjectName.csproj -S . -E

At this point, you have everything you need to run the site.

Build the site (technically, this step can be skipped, because the run command will trigger a build if needed, but I prefer to do the build separately -- easier to identify issues):

dotnet build

Finally, run the site:

dotnet run

If there are no errors in the console, the site should now be up, and can be accessed in your web browser at: https://localhost:5000/

Note: want to change the port, or run on http instead of https? Before running the site, update the “applicationUrl” property in the file projectName\Properties\launchSettings.json .

The first time you access the site, it should prompt you to create an admin user.

Guess who's back? Alloy's back!

Once the site loads, there's a login link in the bottom-right of the footer -- or just append the path "/episerver/cms" to the URL to get to the login page. That will bring you to the CMS. First time in CMS 12? You'll find it largely looks the same, but everything is quite a bit snappier. You'll love it.

Guess who's back? Alloy's back!

Troubleshooting

If the dotnet run command fails with an error about creating the database (“Cannot attach the file '…\projectName\App_Data\alloy_cms12.mdf' as database 'alloy_cms12'.”) – you may need to change the permissions on your AppData folder. (I just added "Modify" rights to Users -- right click the folder > Properties > Security > click Edit > select Users > check "Modify". Then apply the changes.)

Additional options

  • Not on Windows? It also supports Docker. (I haven't tried it yet, my laptop can't handle Docker.)
  • Want to set the database SA password? Use the "--sa-password" option.

More info on those options can be found via the help command, or by checking out the project readme:

PS F:\code\_TESTING> dotnet new epi-alloy-mvc --help
Optimizely Alloy MVC (C#)
Author: Episerver AB
Description: Example MVC application for testing and learning Optimizely CMS.
Options:
  --enable-docker  Enable Docker support
                   bool - Optional
                   Default: false

  --sa-password    The password the SA database account should have
                   string - Optional
                   Default: Qwerty12345!

Thanks for reading, and enjoy Alloy on CMS12!

Resources

]]>
<![CDATA[Running with Foundation]]>https://blog.danisaacs.net/running-with-foundation/620d9db7b71c9400018985a9Thu, 17 Feb 2022 02:51:25 GMT

If you're reading this, you've probably already heard of Foundation, our Optimizely  (formerly Episerver) Content Cloud + B2C Commerce Cloud reference site. Want to run a local instance for testing or evaluation purposes? Want to use it as a starter site for a build? No problem – it's freely available up in GitHub: https://github.com/episerver/Foundation. Below I'm going to walk through a few tips + tricks on getting it running, but first two important callouts:

  1. Use the latest & greatest! This article is focused on the CMS 12 / Commerce 14 version, running on .NET 5. If you want to use the earlier version, you can still follow the setup steps detailed in the readme on the (at the time of this writing) master branch. (But really, that'd be a mistake – .NET 5 is the way to go.)
  2. I'm focused here on the full version of Foundation – there's also a CMS-only version if that's more your bag. (I think most of the instructions below should apply to that one, but I make no promises!)

Getting ready

As noted above, the repository is at: https://github.com/episerver/Foundation – for .NET5 (at least at the moment) you'll want to be working with the main branch.

Running with Foundation

Take a look at the readme to see the system requirements. On Windows, make you'll want SQL Server, Node.JS, and .NET5 runtime. (And some other stuff for running under IIS, but I'll cover that below.) For Mac/Linux (we can run on Mac and Linux now! Exciting!), the setup requires Docker (for SQL Server). You could approach that in other ways, but it's not built in to the setup script.

Running locally

If you just want to run things locally (for example, for dev or for evaluation purposes), setup is a breeze. Just follow the instructions in the readme for your system type.

The only pro-tip I'd throw in here is to update your appsettings.json file with a valid Optimizely Search & Navigation (AKA Optimizely Find (AKA Episerver Find)) index before running the solution. Don't have an index? Sign up at https://find.episerver.com and generate a free dev index, and then add the index and service URL details:

Running with Foundation
changeme!

Then, execute the dotnet run... command, wait until new warnings stop being shown, and then pull up your browser and hit http://localhost:5000. (If it doesn't load or you get a "connection refused" message, try again in 30 seconds. The first time up can take a minute or two or three, as it does some initialization steps and content imports.)

To stop the site, press Ctrl-C in your Powershell window (you may need to hit it a few times, if your laptop's like mine).

Graduating to IIS

Ready to step it up, and run under IIS? It's not all that different, but there are a few gotchas to be aware of.

Note: this is not intended to be an in-depth tutorial on all details of IIS setup. For a deeper dive into creating an IIS site for Optimizely CMS running on .NET 5, a more detailed write-up can be found here: https://www.jondjones.com/learn-optimizely/cms/how-to-install-optimizely-cms-12-and-configure-a-development-environment/

IIS server setup (one-time-only!)

That said: if this is your first time setting up a .NET 5 site in IIS, you must install the .NET 5 IIS module. Download and install the “Hosting Bundle” from: https://dotnet.microsoft.com/download/dotnet/5.0

Running with Foundation

Get the code

Download the code from the GitHub repository. (Note: several options to complete this step are presented below, depending on your familiarity with Git. Or you can skip past this step because you already know how to do it. It's your world, I'm just living in it.)

In all cases, I'm assuming the root of the repo is in the folder \foundation.

Option 1: download the main branch from the repository as a ZIP file
  1. Open the repo in your web browser:
  2. Change to the “main” branch
  3. Click the “Code” button, and select “Download ZIP”
  4. After the file has downloaded, right click and select “Properties”. Check the “Unblock” box and click OK.
  5. Unzip the file to a folder
Option 2: command line! Clone the repository and then checkout the main branch
  1. Clone the repo:
    git clone https://github.com/episerver/foundation.git
  2. Go into the “foundation” directory where you cloned the repo
  3. Check out the main branch:
    git checkout main
Option 3: more command line! Clone + checkout the main branch in one step
  1. Clone and checkout the main branch:
    git clone -b main https://github.com/episerver/foundation.git

Update appsettings.json

Remember earlier when I wrote about adding a Find (search) index to the appsettings.json? Do that. It might save some headaches (search is pretty integral to the Foundation site).

Have a license?

If you have a license (in the form of a License.config file), copy it to the site directory:
\foundation\src\Foundation\

Note: a license file isn’t needed for running under localhost. You might be able to sign up for a demo license at https://license.episerver.com/.

Run the setup

Return to the root folder and run the setup.cmd script as an administrator – this will create and configure the database, and some other initial setup. (Note, the “app name” property will just be used in the database names – it will not create an IIS site during this process.)

Running with Foundation

Publish the site

First, create a publish folder -- I created it in the root repository folder (\foundation) and called it “publish”, so it had the following path:

\foundation\publish


Now, open a command line (I like Powershell, myself).

  1. Switch to the site root directory: c:\whatever\your\path\is\foundation
  2. Publish to the directory you just created (this example assumes the path I used; adjust the path to your publish folder as needed):

dotnet publish -c Debug -o .\publish /p:EnvironmentName=Development


(Once you have your site up and running, subsequent publishes can be tricky – I'm going to include some notes at the bottom of this article around that, but for now this should be all you need.)

Set up the site in IIS

We're almost there! (Note to self (left publicly to remind me later): maybe this should be scripted and included as an optional step in the setup?) Until then – open up IIS.

  1. Create a new site (right click "Sites" and select "Add Website…")
  2. Site name: foundation-net5 [you can use anything you want here]
  3. Physical path: full path to the “publish” folder created in the earlier step
  4. Host name: the url you want to use to access the site.

    Fun tip: if you’re just interested in setting the site up in IIS but don’t need to access it externally --

    If you use the domain "mysite.localmachine.name" (substituting anything for "mysite"), you can set it up locally in IIS without needing to update your hosts file. For example, I used foundation-net5.localmachine.name. (For more info -- https://www.david-tec.com/2013/07/Never-edit-your-hosts-file-again-when-working-on-localhost/ -- thanks, David!)

When you created the site, it will have automatically created an app pool. You're going to need to update the Application Pool settings to avoid errors with OpenIDConnect:

Right click the Application Pool created when you created the site in the previous step (by default, it should have the same name as the site), and select “Advanced Settings”

Running with Foundation

Find the “Identity” setting, and click the dots to open the menu to change the setting

Running with Foundation

Change the setting to “LocalSystem”, and click OK

Running with Foundation

That's it!

You should be all set – open your browser, and try to access the site at the domain you added when setting up the site in IIS.

Troubleshooting

WindowsCryptographicException

Get a “WindowsCryptographicException” when you try to load the site? Double check that you updated the AppPool settings to use LocalSystem identity.

Get a syntax error while assets are built?

[webpack-cli] SyntaxError: Invalid regular expression: /(\p{Uppercase_Letter}+|\p{Lowercase_Letter}|\d)(\p{Uppercase_Letter}+)/: Invalid escape


Make sure you're on a recent version of Node.

Don't see anything?

Make sure your monitor is on.

More tips!

Publishing site changes while running under IIS

After the site is running under IIS, for subsequent publish commands you need to stop the site’s application pool before publishing. Otherwise, the files are locked by the app pool, and it will not deploy correctly.

To avoid this issue, you can manually add a file called “app_offline.htm” to the publish directory before publishing – this tells IIS to release the files.

To automate this process: create a Powershell script called "publish.ps1" and put it in the root /foundation folder. Add the following lines to the file:

$pathToApp = '.\publish' 
New-Item -Path $pathToApp -Name "app_offline.htm" -ItemType "file" 
dotnet publish -c Debug -o $pathToApp /p:EnvironmentName=Development 
Remove-Item -Path $pathToApp\app_offline.htm 

Adjust the path in the first line as needed, based on your publish folder location. Run this script from Powershell via the command “.\publish.ps1” whenever you want to publish the site, instead of just the standard dotnet publish command we used earlier.

Changing the port when running under localhost

Want to change the port the site runs on locally? Update the “applicationUrl” setting in the launchSettings.json file (\src\Foundation\Properties\launchSettings.json)


For example, to use port 8000, change that line to:

"applicationUrl": "https://localhost:8001;http://localhost:8000"

Conclusions

Hopefully this (much longer than I meant to) post will help you get the .NET 5 version of Foundation up and running locally. Have any questions? Feel free to reach out. Find an issue? Create an issue in the GitHub repo. Wish this article had more fun images? Me too friend, me too.

Running with Foundation
]]>
<![CDATA[Optimizely Data Platform (ODP) - Tracking and Usage Examples]]>https://blog.danisaacs.net/optimizely-data-platform-odp-tracking-and-usage-examples/61e0d9673c7ec60001079567Fri, 14 Jan 2022 02:30:44 GMT

The goal of this post is to provide some examples of working with Optimizely Data Platform (ODP) with Optimizely Content Cloud and Commerce Cloud. Note that, while focused on those platforms using our Foundation reference implementation, the overall strategies can be used for tracking on any solution.

Rather than recreate the wheel, for a great starting point please reference David Knipe's blog post "Adding Optimizely Data Platform to Optimizely Commerce Cloud". It provides a very solid base on which to build, and allows us to go straight into other tracking examples and how you might use the data in ODP itself. One key step from that blog post you must complete is to add the standard JavaScript tracking script for ODP to your site, to enable pageview and customer tracking. The script can be found by going to the ODP "Integrations" admin screen, and clicking into the "JavaScript Tag" integration -- refer to the ODP JavaScript tag documentation here for more details.

Now, let's get into some custom event tracking.

Optimizely Forms Events

ODP Prep

First you'll need to create a new field in ODP. Log in to ODP, then go to account settings by clicking the gear icon in the top-right of the page.

Optimizely Data Platform (ODP) - Tracking and Usage Examples

Then select "Create New Field" to create the new property -- I used the following values (the "field name" will be used in the tracking section below):

Optimizely Data Platform (ODP) - Tracking and Usage Examples

Tracking

The example tracking below for Optimizely Forms leverages the available Forms client-side events to track form impressions and form submissions. Alternatively, form submission tracking could be done server-side by modifying it to use the .NET events documented on that page.

// ODP Tracking for Optimizely Forms

if (typeof $$epiforms !== 'undefined') {
    $$epiforms(document).ready(function myfunction() {
        $$epiforms(".EPiServerForms").on("formsNavigationNextStep formsNavigationPrevStep formsSetupCompleted formsReset formsStartSubmitting formsSubmitted formsSubmittedError formsNavigateToStep formsStepValidating",
            function (event, param1, param2) {
                var eventType = event.type;
                var formName = event.workingFormInfo.Name;

                if (eventType == 'formsSetupCompleted') {
                    console.log('ODP: web_form impression: ' + formName);
                    zaius.event('web_form', { action: 'impression', form_name: formName });
                } else if (eventType == 'formsStepValidating') {
                    if (!event.isValid) {
                        console.log('ODP: web_form validation failed: ' + formName);
                        zaius.event('web_form', { action: 'submission_validation_failed', form_name: formName });
                    }
                } else if (eventType == 'formsSubmitted') {
                    console.log('ODP: web_form submission: ' + formName);
                    zaius.event('web_form', { action: 'submission', form_name: formName });
                } else {
                    // handle other form events here
                }
            });
    });
}

Once that script is included on your site, then viewing a page with an Optimizely Form will trigger a "Web Form: Impression" event, and submitting a form will trigger a "Web Form: Submission" event:

Optimizely Data Platform (ODP) - Tracking and Usage Examples

Usage in ODP

Once you're tracking those form events, you can start to put them to work for you. Some simple suggestions:

  • Build a report in ODP to track overall form conversion rates (impressions vs submissions)
  • Build a report in ODP to track form conversion rates for a specific form across multiple pages -- which page has more success?
  • Build a segment of visitors that saw the form, but didn't submit it -- convert that to a Facebook lookalike audience, and target them in an ad campaign

Let's walk through the steps for the first example -- a report to track form conversion rates:

Optimizely Data Platform (ODP) - Tracking and Usage Examples
  1. First, create a filter -- Event Type = "web_form" and Form Name is not empty
Optimizely Data Platform (ODP) - Tracking and Usage Examples

2. Create a new report. Apply your new filter by clicking "All Traffic" in the top-left, and selecting your filter. Click the checkbox to apply:

Optimizely Data Platform (ODP) - Tracking and Usage Examples

3. Add columns to your report -- in this example, I added the field "Form Name", and then the following rocket columns to get the count of impressions, submissions and the calculated conversion rate:

Optimizely Data Platform (ODP) - Tracking and Usage Examples

Bonus: want to track an individual form, and see its success rate across different pages? Follow the steps above, and then:

  1. Use the "Simple Filter" option to specify the form by name (don't forget to click "Apply" after adding the simple filter!)
Optimizely Data Platform (ODP) - Tracking and Usage Examples

2. Add the Page to the report columns.

Optimizely Data Platform (ODP) - Tracking and Usage Examples

Search Events

Note: the example tracking below is specific to the Foundation reference site, but the principles will apply and can easily be modified to fit any search implementation.

ODP Prep

First, create a new field for the search term -- the scripts below use the field name "search_term", type "text".

Tracking

I've included three types of tracking below: the initial search event, searches with no results, and search result click tracking.

Initial Search Event

// Enter for search
$(document).keypress(function(event){
    var key = event.which;
    if(key == 13)  // the enter key code
    {
        var searchValue = null;
        var searchInputs = $('.jsSearchText');
        if (searchInputs != null)
        {
            for (var i = 0; i < searchInputs.length; i++)
            {
                var input = searchInputs[i];
                if (input.value != null && input.value != '')
                {
                    searchValue = input.value;
                    console.log('ODP: search: ' + searchValue);
                    zaius.event('navigation', { action: 'search', search_term: searchValue });
                }
            }
        }
    }
});

"No Results" and Search Result Click Tracking

This script tracks searches that have no results, and also tracks search result clicks (breaking them up between clicks on content results versus product search results).

$(document).ready(function() {
	var searchValue = null;
	const params = new URLSearchParams(window.location.search);
	searchValue = params.get('search');
		 
	if (document.querySelector('.content-search-results') == null && document.querySelector('.product-tile-grid') == null) 
	{
		 console.log("ODP: Track no search results")
		 zaius.event('search', { action: 'no_results' , search_term: searchValue });
	}
	
	$('.content-search-results > a').click(function() {
		let clickedLink = $(this).attr("href");
		console.log("ODP: Track search result link click -- " + clickedLink + " (search term: " + searchValue + ")");
		zaius.event('search', { action: 'click' , search_term: searchValue, search_clicked_content: clickedLink });
	});

	$('.product-tile-grid').click(function() {
		let site = location.protocol + '//' + location.host;
		let clickedProd = $(this).children('.product-tile-grid__title').children('a').attr("href");
		let clickedProdLink  = site + clickedProd;
		console.log("ODP: Track search result product click -- " + clickedProdLink + " (search term: " + searchValue + ")");
		zaius.event('search', { action: 'click' , search_term: searchValue, search_clicked_product: clickedProdLink });
	});
});

Usage in ODP

Search event tracking can be used in a variety of ways. A few quick examples include:

  • Top searches
  • Top searches without results
  • Clickthrough rate for search results

Build reports in ODP to identify popular searches and searches without results to help drive content creation. Create Best Bets in Optimizely Search & Navigation to address searches without results. Build segments to target visitors searching for specific search terms.

Video Events

Just for fun, a rough implementation of tracking of YouTube video events. Identify users that loaded a video, and track their progress through it. Then use that data to identify popular (or unpopular) videos, or target visitors that stopped a certain video before completion, or visitors that are interested in certain types of videos.

ODP Prep

For this example I added three new fields to ODP:

  • video_id_yt (YouTube video ID) (type: Text)
  • video_play_percentage (type: Number)
  • video_title (type: Text)

Video Event Tracking

The following script will track YouTube video events, including video loads, video plays, video progress (10%, 25%, 50%, 75%, 90%, and completed), and pauses/restarts. It leverages the YouTube API for iframe embeds.

A couple caveats: the script as-is will only track if there is a single YouTube video block on the page, because it targets the video based on the id="youtube-block" attribute when the block is rendered.

var tag = document.createElement('script');
tag.id = 'youtube-iframe';
tag.src = 'https://www.youtube.com/iframe_api';
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

var player;
function onYouTubeIframeAPIReady() {
    player = new YT.Player('youtube-block', {
        events: {
            'onReady': onPlayerReady,
            'onStateChange': onPlayerStateChange
        }
    });
}

var sendEvents = true;

var videoDuration;
var videoId;
var videoTitle;
var timer;
var currentProgress = 0;
var previousProgress = 0;
var progressEventPoints = [10, 25, 50, 75, 90];


var startedPlay = false;
var pausedPlay = false;
var halfway = false;

function writeLoadVideoEvent() {
    console.log("ODP - video loaded - " + videoTitle);
    if (sendEvents) {
        zaius.event("video", {
            action: "loaded",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: 0,
            video_action: true
        });
    }
}

function writeStartVideoEvent() {
    console.log("ODP - video started");
    if (sendEvents) {
        zaius.event("video", {
            action: "started",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: 0,
            video_action: true
        });
    }
}

function writeHalfVideoEvent() {
    console.log("ODP - video 50% completed");
    if (sendEvents) {
        zaius.event("video", {
            action: "watched_half",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: 50,
            video_action: true
        });
    }
}

function writeEndVideoEvent() {
    console.log("ODP - video completed");
    if (sendEvents) {
        zaius.event("video", {
            action: "watched",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: 100,
            video_action: true
        });
    }
}

function writeVideoProgressEvent(percent) {
    console.log("ODP - video " + percent + "% completed");
    if (sendEvents) {
        zaius.event("video", {
            action: "video_progress",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: percent,
            video_action: true
        });
    }
}

function writePauseVideoEvent(percent) {
    console.log("ODP - video paused " + percent + "%");
    if (sendEvents) {
        zaius.event("video", {
            action: "paused",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: percent,
            video_action: true
        });
    }
}

function writeRestartVideoEvent(percent) {
    console.log("ODP - video restarted at " + percent + "%");
    if (sendEvents) {
        zaius.event("video", {
            action: "restarted",
            video_id_yt: videoId,
            video_title: videoTitle,
            video_play_percentage: percent,
            video_action: true
        });
    }
}

function onPlayerReady(event) {
    videoDuration = player.getDuration();
    videoId = player.getVideoData().video_id;
    videoTitle = player.getVideoData().title;
    writeLoadVideoEvent();
}

function play_progress_reached() {
    current_time = player.getCurrentTime();
    currentProgress = parseInt((current_time / videoDuration) * 100);
    if (player.getPlayerState() == YT.PlayerState.PLAYING) {
        if (startedPlay == false) {
            writeStartVideoEvent();
            startedPlay = true;
        } else if (pausedPlay == true) {
            writeRestartVideoEvent(currentProgress);
            pausedPlay = false;
        } else if (currentProgress > 0 && currentProgress % 5 == 0 && currentProgress > previousProgress) {
            if (currentProgress > previousProgress && progressEventPoints.includes(currentProgress)) {
                writeVideoProgressEvent(currentProgress);
                previousProgress = currentProgress;
            }
        }
    } else if (player.getPlayerState() == YT.PlayerState.PAUSED) {
        writePauseVideoEvent(currentProgress);
        pausedPlay = true;
        clearInterval(timer);
    } else if (player.getPlayerState() == YT.PlayerState.ENDED) {
        writeEndVideoEvent();
        clearInterval(timer);
    } else {
        clearInterval(timer);
    }
}

function play_progress_callback() {
    clearInterval(timer);
    current_time = player.getCurrentTime();
    currentProgress = parseInt((current_time / videoDuration) * 100);
    remaining_time = videoDuration - current_time;
    if (remaining_time > 0) {
        timer = setInterval(play_progress_reached, 500);
    }
}

function onPlayerStateChange(event) {
    if (event.data == YT.PlayerState.PLAYING) {
        console.log("Video playing");
    }
    clearInterval(timer);
    play_progress_callback();
}

Usage in ODP

Use the tracked events to identify which videos are getting watched (and watched to completion), versus videos that aren't getting any plays at all or are frequently stopped before the end.

Here's a sample report, as a starting point:

Optimizely Data Platform (ODP) - Tracking and Usage Examples

Conclusions

Optimizely Data Platform (ODP) provides your team with the flexibility to track any custom events for both known and unknown visitors, and use that information to build reports and create targeted campaigns to increase engagement with your visitors.

A few additional references for working with ODP:

]]>