Log custom activities

You may encounter use cases where you need to track visitor actions not covered by out-of-box options, or where you need to track additional details. Xperience allows you to define custom activities, which can then be logged through both client-side and server-side code.

This quickstart guide will cover the process of logging a custom activity when a visitor downloads a file using JavaScript, and logging a custom activity when a user clicks a like button using C#.

Activity tracking series - prerequisites

This guide is part of a series on Activity tracking, and uses the Xperience by Kentico quickstart guides repository

If you want to follow along, you can start here.

Activity tracking functionality ties in with consents, which are covered in detail in the Data protection series.

Create custom activity types

The first step to logging custom activities is to create the activity types that represent them.

In the Xperience administration interface, navigate to the Activity types tab of the Contact management application and define activity types with the following values.

  • Activity 1:
    • Display name: File Download
    • Code name: filedownload
    • Description: The visitor downloaded a file.
    • Enabled: True
  • Activity 2:
    • Display name: Page like
    • Code name: pagelike
    • Description: The visitor clicked a like button on a page.
    • Enabled: True

Log file downloads

Write logging javascript

The client-side activity logging example from the documentation calls a function from the onclick attribute of a specific button.

This guide’s example uses a similar function, but stores it in a separate file rather than inline, and dynamically registers it to any links with the downloadattribute. Make sure to include this attribute on any downloadable file links that you want to track.

  1. Add a new file called FileDownloadActivityLogger.js to the ~/wwwroot/assets/js folder of the TrainingGuides.Web project.
  2. Create a function called handleClick that logs a custom activity as outlined in the documentation example.
    1. Assign the filedownload custom activity type.
    2. Set the value to the path of the current page.
    3. Assign a meaningful title that includes the alt attribute of the specific download link if it exists.
  3. In the window.onload event, iterate through all the links on the page, and assign the handleClick function to the click event of any links with the download attribute present.
FileDownloadActivityLogger.js


window.onload = function () {
    const links = document.getElementsByTagName("a");

    for (let i = 0; i < links.length; i++) {
        if (links[i].hasAttribute("download")) {
            links[i].addEventListener("click", handleClick);
        }
    }
}

function handleClick() {
    kxt('customactivity', {
        type: 'filedownload',
        value: window.location.pathname,
        title: 'File downloaded - ' + this.getAttribute("alt"),
        onerror: t => console.log(t)
    });
}

For the sake of readability, this example is not minified. In production scenarios, consider storing this file elsewhere and using an automated tool to render a minified version of this script to the wwwroot folder.

Create a view component

Following the same process as the previous guide, create a view component class to conditionally render a script reference to your new JavaScript file to the page.

  1. Add a folder called CustomActivityScripts in TrainingGuides.Web/Features/Activities/ViewComponents.

  2. Create a view component to conditionally render the custom activity script if the current contact has consented to tracking.

    CustomActivityScriptsViewComponent.cs
    
    
     using Microsoft.AspNetCore.Mvc;
     using TrainingGuides.Web.Features.Activities.ViewComponents.Shared;
     using TrainingGuides.Web.Features.DataProtection.Services;
    
     namespace TrainingGuides.Web.Features.Activities.ViewComponents.CustomActivityScripts;
    
     public class CustomActivityScriptsViewComponent : ViewComponent
     {
         private readonly ICookieConsentService cookieConsentService;
    
         public CustomActivityScriptsViewComponent(ICookieConsentService cookieConsentService)
         {
             this.cookieConsentService = cookieConsentService;
         }
    
         public IViewComponentResult Invoke()
         {
             var model = new ContactTrackingAllowedViewModel()
             {
                 ContactTrackingAllowed = cookieConsentService.CurrentContactCanBeTracked()
             };
    
             return View("~/Features/Activities/ViewComponents/CustomActivityScripts/CustomActivityScripts.cshtml", model);
         }
     }
    
     
  3. Add a view that renders a script tag for your FileDownloadActivityLogger.js file.

    CustomActivityScripts.cshtml
    
    
     @using TrainingGuides.Web.Features.Activities.ViewComponents.Shared
    
     @model ContactTrackingAllowedViewModel
    
     @if (Model.ContactTrackingAllowed)
     {
         @*Scripts for logging custom activities*@
         <script src="~/assets/js/FileDownloadActivityLogger.js"></script>
     }
    
     

Add the view component to the Layout view

The script is ready to be added to the layout view. To make the script work, you need to add Xperience activity logging API to the page, along with a reference to the javascript file from the previous section.

  1. Navigate to ~Views/Shared/_Layout.cshtml in the TrainingGuides.Web project, and add a using directive for your new view component.

    _Layout.cshtml
    
    
     ...
     @using TrainingGuides.Web.Features.Activities.ViewComponents.CustomActivityScripts
     ...
    
     
  2. At the bottom of the body tag, use the tag helper to invoke the custom-activity-scripts view component.

    _Layout.cshtml
    
    
     ...
         <vc:custom-activity-scripts/>
     </body>
     ...
    
     

If you only expect to have download links in certain parts of your site, you can improve performance by using Razor sections to only include these scripts for views which you know will show downloads.

Test the code

The coding is done, so the functionality is ready to be tested.

  1. Run the TrainingGuides.Web project on your dev machine and visit the site in your browser.

  2. Check your browser cookies in your browser’s developer tools, and ensure that the CMSCookieLevel cookie is set to 1000 or above.

    If you’ve completed the consent-related quickstart guides, go to the /cookie-policy page and set your preferred cookie level to Marketing.

  3. Visit the /policy-downloads page and click to download the privacy policy PDF.

  4. Visit the /adminpath to log in to the administration interface.

  5. Navigate to the Activities tab of the Contact management application to see that the download activity has been logged.

Log page likes

The next custom activity scenario covered in this guide is a Page like widget demonstrating server-side logging with C# code. You will implement a controller action that logs a custom activity, and a widget that posts to the controller.

Define a request model

Create a strongly-typed class to represent the data that the widget will post to the controller, to make working with the data easier. In this case, you only need information to identify which page was liked, and to retrieve the correct item, so the Id of the web page content item, and the name of the content type should suffice.

  1. Add a new folder named PageLike under TrainingGuides.Web/Features/Activities/Widgets.
  2. Create a class named PageLikeRequestModel.cs with properties to identify the web page item and its content type.
PageLikeRequestModel.cs


namespace TrainingGuides.Web.Features.Activities.Widgets.PageLike;

public class PageLikeRequestModel
{
    public string WebPageItemID { get; set; }

    public string ContentTypeName { get; set; }
}

Expand the content item retriever service

This quickstart guide shows an approach to querying content item data that is valid for Xperience by Kentico 28.3.1 and older versions. We will update the guides to reflect improvements in content API released in 28.4.0.

In the meantime, you can read about the changes in the Xperience changelog or dive into retrieving content to see how the new mapping works.

You may have noticed that the content item query api requires a strongly typed mapping function in order to select the results of the query.

However, since you’re building a widget, you may not know the content type of the web page item that your widget is placed on.

In order to account for this, you need a way to retrieve a strongly typed mapping function based on a provided content type name, which is available from the Page property of the ComponentViewmodel supplied to widgets.

  1. Add an interface with no generic parameter to the existing TrainingGuides.Web/Features/Shared/Services/IContentItemRetrieverService.cs file.

    IContentItemRetrieverService.cs
    
    
     ...
     public interface IContentItemRetrieverService
     {
         public Task<IWebPageFieldsSource> RetrieveWebPageById(int webPageItemId, string contentTypeName);
     }
    
     
  2. Add a new class called ContentItemRetrieverService to ContentItemRetrieverService.cs with no

  3. Define a dictionary to hold the names of each of your content types, and a corresponding query result mapper function.

  4. Retrieve an IContentItemRetrieverService<IWebPageFieldsSource> object through constructor injection, along with an IWebPageQueryResultMapper object you can use to populate the dictionary.

  5. Create a method called RetrieveWebPageById, taking the Id of a web page and the name of a content type as parameters.

  6. Use the IContentItemRetrieverService<IWebPageFieldsSource> along with the appropriate function from the dictionary, to retrieve the content item based on the provided content type name.

    ContentItemRetrieverService.cs
    
    
     ...
     public class ContentItemRetrieverService : IContentItemRetrieverService
     {
         private readonly Dictionary<string, Func<IWebPageContentQueryDataContainer, IWebPageFieldsSource>> contentTypeDictionary;
    
         private readonly IWebPageQueryResultMapper webPageQueryResultMapper;
         private readonly IContentItemRetrieverService<IWebPageFieldsSource> contentItemRetrieverService;
    
         public ContentItemRetrieverService(IWebPageQueryResultMapper webPageQueryResultMapper, IContentItemRetrieverService<IWebPageFieldsSource> contentItemRetrieverService)
         {
             this.webPageQueryResultMapper = webPageQueryResultMapper;
             this.contentItemRetrieverService = contentItemRetrieverService;
    
             contentTypeDictionary = new Dictionary<string, Func<IWebPageContentQueryDataContainer, IWebPageFieldsSource>>
             {
                 { ArticlePage.CONTENT_TYPE_NAME, container => this.webPageQueryResultMapper.Map<ArticlePage>(container) },
                 { DownloadsPage.CONTENT_TYPE_NAME, container => this.webPageQueryResultMapper.Map<DownloadsPage>(container) },
                 { EmptyPage.CONTENT_TYPE_NAME, container => this.webPageQueryResultMapper.Map<EmptyPage>(container) },
                 { LandingPage.CONTENT_TYPE_NAME, container => this.webPageQueryResultMapper.Map<LandingPage>(container) }
             };
         }
    
         public async Task<IWebPageFieldsSource> RetrieveWebPageById(int webPageItemId, string contentTypeName) => await
              contentItemRetrieverService.RetrieveWebPageById(
                 webPageItemId,
                 contentTypeName,
                 contentTypeDictionary[contentTypeName],
                 1);
     }
    
     
  7. Register the service implementation as a singleton, in the TrainingGuides.Web/ServiceCollectionExtensions.cs file.

    Unlike the typed version, this version does not need to be constructed with a specific type that could vary whenever it is used, so it can safely be a singleton.

    ServiceCollectionExtensions.cs
    
    
     ...
     services.AddSingleton<IContentItemRetrieverService, ContentItemRetrieverService>();
     ...
    
     

Create the logging controller

The controller is central to the functionality of the Page like widget. It is where you must log the custom activity and account for any errors.

  1. Add a new controller named PageLikeController.cs to the ~/Features/Activities/Widgets/PageLike folder.
  2. Acquire an ICustomActivityLogger, an IContentItemRetrieverService, and an ICookieConsentService through constructor injection.
  3. Define a controller action with the HttpPost attribute to register it to the route ~/pagelike for POST requests.
  4. Use the CurrentContactCanBeTracked method from the previous guide in this series to return a message if the visitor has not consented to tracking.
  5. Validate the WebPageItemID and ContentTypeName properties of the provided PageLikeRequestModel.
  6. Use your IContentItemRetrieverService to retrieve the web page item specified by the supplied Id.
  7. Use the page to construct a CustomActivityData object, including relevant information about the liked page in the ActivityTitle and ActivityValue, before logging the activity as described in the documentation.
  8. Include an activity identifier constant, which will be stored in the view component created in a future step.
PageLikeController.cs


using CMS.Activities;
using Microsoft.AspNetCore.Mvc;
using TrainingGuides.Web.Features.DataProtection.Services;
using TrainingGuides.Web.Features.Shared.Services;

namespace TrainingGuides.Web.Features.Activities.Widgets.PageLike;

public class PageLikeController : Controller
{
    private const string NO_TRACKING_MESSAGE = "<span>You have not consented to tracking, so we cannot save this page like.</span>";
    private const string BAD_PAGE_DATA_MESSAGE = "<span>Error in page like data. Please try again later.</span>";
    private const string THANK_YOU_MESSAGE = "<span>Thank you!</span>";

    private readonly ICustomActivityLogger customActivityLogger;
    private readonly IContentItemRetrieverService contentItemRetrieverService;
    private readonly ICookieConsentService cookieConsentService;

    public PageLikeController(
        ICustomActivityLogger customActivityLogger,
        IContentItemRetrieverService contentItemRetrieverService,
        ICookieConsentService cookieConsentService)
    {
        this.customActivityLogger = customActivityLogger;
        this.contentItemRetrieverService = contentItemRetrieverService;
        this.cookieConsentService = cookieConsentService;
    }

    [HttpPost("/pagelike")]
    public async Task<IActionResult> PageLike(PageLikeRequestModel requestModel)
    {
        if (!cookieConsentService.CurrentContactCanBeTracked())
            return Content(NO_TRACKING_MESSAGE);

        if (!int.TryParse(requestModel.WebPageItemID, out int webPageItemID))
            return Content(BAD_PAGE_DATA_MESSAGE);

        if (string.IsNullOrEmpty(requestModel.ContentTypeName))
            return Content(BAD_PAGE_DATA_MESSAGE);

        var webPage = await contentItemRetrieverService.RetrieveWebPageById(
            webPageItemID,
            requestModel.ContentTypeName);

        if (webPage is null)
            return Content(BAD_PAGE_DATA_MESSAGE);

        string likedPageName = webPage.SystemFields.WebPageItemName;
        string likedPageTreePath = webPage.SystemFields.WebPageItemTreePath;
        string likedPageGuid = webPage.SystemFields.WebPageItemGUID.ToString();

        var pageLikeActicityData = new CustomActivityData()
        {
            ActivityTitle = $"Page like - {likedPageTreePath} ({likedPageName})",
            ActivityValue = likedPageGuid,
        };

        customActivityLogger.Log(PageLikeWidgetViewComponent.ACTIVITY_IDENTIFIER, pageLikeActicityData);
        return Content(THANK_YOU_MESSAGE);
    }
}

Add the widget view model

The Page like widget will have a relatively simple view model, considering its basic requirements.

  • The widget needs to post the WebPageItemID and ContentTypeName to the controller action from the previous step.
  • The widget should hide its button if the current visitor already liked the page in the past.

To meet both of these requirements, create a model with both identifying values and a boolean property to indicate whether or not the button should be displayed.

PageLikeWidgetViewModel.cs


namespace TrainingGuides.Web.Features.Activities.Widgets.PageLike;

public class PageLikeWidgetViewModel
{
    public bool ShowLikeButton { get; set; }
    public int WebPageItemID { get; set; }
    public string ContentTypeName { get; set; }
    public string BaseUrl { get; set; }
}

Define the view component

The widget’s view component needs to populate the view model. It must retrieve the ID and content type name of the current web page item, and also determine whether or not the current visitor has already liked the page, in order to pass that information along to the widget.

  1. Create a file called PageLikeWidgetViewComponent.cs in the TrainingGuides.Web/Features/Activities/Widgets/PageLike folder.

  2. Use the RegisterWidget assembly attribute to register the widget, using a newly defined constant that holds the widget identifier.

  3. Add another constant to hold the identifier of the page like activity

  4. Acquire IInfoProvider<ActivityInfo> and IContentItemRetrieverService objects with constructor injection.

  5. Define the InvokeAsync method, using IInfoProvider<ActivityInfo> to query for existing page-like activities of the current web page item by the current contact.

    Remember that in the controller, you stored the Guid of the current web page to the custom activity’s ActivityValue. You can use this field to look up likes of the current page.

  6. If no existing likes are found, set ShowLikeButton to true in a new PageLikeWidgetViewModel instance.

  7. Use the Page property of the provided ComponentViewModel parameter to populate the remaining properties of the PageLikeWidgetViewModel.

  8. Return the path to a view in the same folder, which will be added in the next section.

PageLikeWidgetViewComponent.cs


using CMS.Activities;
using CMS.ContactManagement;
using TrainingGuides.Web.Features.Activities.Widgets.PageLike;
using Kentico.PageBuilder.Web.Mvc;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using TrainingGuides.Web.Features.Shared.Services;

[assembly: RegisterWidget(
    identifier: PageLikeWidgetViewComponent.IDENTIFIER,
    viewComponentType: typeof(PageLikeWidgetViewComponent),
    name: "Page like button",
    Description = "Displays a page like button.",
    IconClass = "icon-check-circle")]

namespace TrainingGuides.Web.Features.Activities.Widgets.PageLike;

public class PageLikeWidgetViewComponent : ViewComponent
{
    private readonly IInfoProvider<ActivityInfo> activityInfoProvider;
    private readonly IContentItemRetrieverService contentItemRetrieverService;
    private readonly IHttpRequestService httpRequestService;

    public const string IDENTIFIER = "TrainingGuides.PageLikeWidget";
    public const string ACTIVITY_IDENTIFIER = "pagelike";

    public PageLikeWidgetViewComponent(IInfoProvider<ActivityInfo> activityInfoProvider,
        IContentItemRetrieverService contentItemRetrieverService,
        IHttpRequestService httpRequestService)
    {
        this.activityInfoProvider = activityInfoProvider;
        this.contentItemRetrieverService = contentItemRetrieverService;
        this.httpRequestService = httpRequestService;
    }

    public async Task<ViewViewComponentResult> InvokeAsync(ComponentViewModel properties)
    {
        var currentContact = ContactManagementContext.GetCurrentContact(false);

        var webPage = await contentItemRetrieverService.RetrieveWebPageById(
            properties.Page.WebPageItemID,
            properties.Page.ContentTypeName);

        var likesOfThisPage = currentContact != null
            ? await activityInfoProvider.Get()
                .WhereEquals("ActivityContactID", currentContact.ContactID)
                .And().WhereEquals("ActivityType", ACTIVITY_IDENTIFIER)
                .And().WhereEquals("ActivityValue", webPage.SystemFields.WebPageItemGUID.ToString())
                .GetEnumerableTypedResultAsync()
            : new List<ActivityInfo>();

        bool showLikeButton = likesOfThisPage.Count() == 0;

        var model = new PageLikeWidgetViewModel()
        {
            ShowLikeButton = showLikeButton,
            WebPageItemID = properties.Page.WebPageItemID,
            ContentTypeName = properties.Page.ContentTypeName,
            BaseUrl = httpRequestService.GetBaseUrl()
        };

        return View("~/Features/Activities/Widgets/PageLike/PageLikeWidget.cshtml", model);
    }
}

Make the identifier available

In the future, you may need to limit which widgets are available in different page builder sections and zones. To make this easier, add the page like widget’s identifier to the ComponentIdentifiers class.

  1. Navigate to TrainingGuides.Web/ComponentIdentifiers.cs.
  2. Add a new static class called Widgets inside the ComponentIdentifiers class if it does not already exist.
  3. Add a constant string that is equal to that of the page like widget view component.
ComponentIdentifiers.cs


public static class ComponentIdentifiers
{
    ...
    public static class Widgets
    {
        ...
        public const string PAGE_LIKE = PageLikeWidgetViewComponent.IDENTIFIER;
        ...
    }
    ...
}

Add the widget view

The last piece you need to add is the view file referenced by the view component.

  1. If you didn’t follow along with the Data protection series before this guide, add a dependency on the AspNetCore.Unobtrusive.Ajax NuGet Package, and add the following line to the part of Program.cs where services are added to the application builder.

    Program.cs
    
    
     ...
     builder.Services.AddUnobtrusiveAjax();
     ...
    
     
  2. Add a view file called PageLikeWidget.cshtml to the TrainingGuides.Web/Features/Activities/Widgets/PageLike folder.

  3. Create an AJAX form that posts to the path of the controller action from earlier in this guide.

    1. Pass the Id of a div for the AJAX form to write its response.
    2. Use a hidden input to pass the web page item’s Id and content type name to the controller action when the widget submits the form.
PageLikeWidget.cshtml


@using TrainingGuides.Web.Features.Activities.Widgets.PageLike;

@model PageLikeWidgetViewModel

@{
    var messageId = "pageLikeMessage";
}

@if(Model.ShowLikeButton || Context.Kentico().Preview().Enabled)
{
    @using (Html.AjaxBeginForm("PageLike", "PageLike", new AjaxOptions
     {
         HttpMethod = "POST",
         InsertionMode = InsertionMode.Replace,
         UpdateTargetId = messageId
     }, new { action = $"{Model.BaseUrl}/pagelike" }))
    {
        <div class="container">
            <div class="cookie-preferences js-cookie-preferences">
                <input id="WebPageItemID" name="WebPageItemID" type="hidden" value="@Model.WebPageItemID" />
                <input id="ContentTypeName" name="ContentTypeName" type="hidden" value="@Model.ContentTypeName" />
                <button class="btn btn-secondary text-uppercase mt-4 cookie-preferences__button" type="submit" name="button">
                    Like this page
                </button>
                <div id="@messageId" class="cookie-preferences__message"></div>
            </div>
        </div>
    }
}

The coding is done, so you can test your new widget.

  1. Run the TrainingGuides.Web project locally.
  2. Log in to the administration interface, and open the Training guides pages channel.
  3. Choose Edit → Create new version the Cookie policy page, and add an instance of the Page like widget to the widget zone.
  4. Save and publish the page, then do the same for the Contact us page.
  5. Visit the /cookie-policy path on the live site.
  6. Make sure your cookie level is set to Marketing, and click the like button.
  7. Return to the administration interface, opening the Activities tab of the Contact management application to see that Xperience has logged the Page like activity.

What’s next?

The next guide in this series will cover the process of creating a page section that hides its contents from contacts who have not consented to tracking, preventing them from submitting data through any widgets in the section.