AEM Text component tracking – Adobe Client Datalayer

Problem Statement:

How to track Text component using ACDL? How to track the links inside a text component using ACDL? Can I add the custom ID to the links?

Requirement:

Track all the hyperlinks inside the text component using ACDL and add also provide a custom ID to track the links.

Introduction:

The goal of the Adobe Client Data Layer is to reduce the effort to instrument websites by providing a standardized method to expose and access any kind of data for any script.

The Adobe Client Data Layer is platform agnostic, but is fully integrated into the Core Components for use with AEM.

You can also learn more about the OOTB core text component here and also you can learn more about the Adobe Client data layer here. You can also learn about how to create tracking for the custom component here.

In the following example, we are going to use component delegation to delegate the OOTB text component and enable custom tracking on the text component, you can learn more about component delegation here.

Add a JSOUP and Lombok dependency to your project.

<dependency>
	<groupId>org.jsoup</groupId>
	<artifactId>jsoup</artifactId>
	<version>1.13.1</version>
	<scope>provided</scope>
</dependency>
<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<version>1.18.20</version>
	<scope>provided</scope>
</dependency>

Add a service called JSONConverter as shown below:

package com.adobe.aem.guides.wknd.core.service;

import com.fasterxml.jackson.databind.util.JSONPObject;

/**
 * Interface to deal with Json.
 */
public interface JSONConverter {

    /**
     * Convert Json Object to given object
     *
     * @param jsonpObject
     * @param clazz       type of class
     * @return @{@link Object}
     */
    @SuppressWarnings("rawtypes")
    Object convertToObject(JSONPObject jsonpObject, Class clazz);

    /**
     * Convert Json Object to given object
     *
     * @param jsonString
     * @param clazz      type of class
     * @return @{@link Object}
     */
    @SuppressWarnings("rawtypes")
    Object convertToObject(String jsonString, Class clazz);

    /**
     * Convert Json Object to given object
     *
     * @param object
     * @return @{@link String}
     */
    String convertToJsonString(Object object);

    /**
     * Convert Json Object to given object
     *
     * @param object
     * @return @{@link JSONPObject}
     */
    JSONPObject convertToJSONPObject(Object object);
}

Add a Service implementation JSONConverterImpl to convert object to JSON String using Object Mapper API

package com.adobe.aem.guides.wknd.core.service.impl;

import com.adobe.aem.guides.wknd.core.service.JSONConverter;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.util.JSONPObject;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;

@Component(service = JSONConverter.class)
public class JSONConverterImpl implements JSONConverter {

    private static final Logger LOG = LoggerFactory.getLogger(JSONConverterImpl.class);

    @SuppressWarnings("unchecked")
    @Override
    public Object convertToObject(JSONPObject jsonpObject, @SuppressWarnings("rawtypes") Class clazz) {
        ObjectMapper mapper = new ObjectMapper();
        try {
            return mapper.readValue(jsonpObject.toString(), clazz);
        } catch (IOException e) {
            LOG.debug("IOException while converting JSON to {} class {}", clazz.getName(), e.getMessage());
        }
        return null;
    }

    @SuppressWarnings("unchecked")
    @Override
    public Object convertToObject(String jsonString, @SuppressWarnings("rawtypes") Class clazz) {
        ObjectMapper mapper = new ObjectMapper();
        try {
            return mapper.readValue(jsonString, clazz);
        } catch (IOException e) {
            LOG.debug("IOException while converting JSON to {} class {}", clazz.getName(), e.getMessage());
        }
        return null;
    }

    @Override
    public String convertToJsonString(Object object) {
        ObjectMapper mapper = new ObjectMapper();
        try {
            return mapper.setSerializationInclusion(Include.NON_EMPTY).writerWithDefaultPrettyPrinter().writeValueAsString(object);
        } catch (IOException e) {
            LOG.debug("IOException while converting object {} to Json String {}", object.getClass().getName(),
                    e.getMessage());
        }
        return null;
    }

    @Override
    public JSONPObject convertToJSONPObject(Object object) {
        ObjectMapper mapper = new ObjectMapper();
        try {
            String jsonString = mapper.writeValueAsString(object);
            return mapper.readValue(jsonString, JSONPObject.class);
        } catch (IOException e) {
            LOG.debug("IOException while converting object {} to Json String {}", object.getClass().getName(),
                    e.getMessage());
        }
        return null;
    }
}

Create TexModelImpl Sling model class which will be extending OOTB Text component and add delegate to override default getText() method.

Create a custom method called addLinkTracking and JSOUP API to read the text and get all the hyperlinks, once you have all the hyperlinks you can add custom tracking code by calling getLinkData method and this method should take care of custom ID tracking or generating default ID for all the links.

package com.adobe.aem.guides.wknd.core.models.impl;

import com.adobe.aem.guides.wknd.core.service.JSONConverter;
import com.adobe.cq.export.json.ComponentExporter;
import com.adobe.cq.export.json.ExporterConstants;
import com.adobe.cq.wcm.core.components.models.Text;
import com.adobe.cq.wcm.core.components.util.ComponentUtils;
import com.day.cq.wcm.api.Page;
import com.day.cq.wcm.api.components.ComponentContext;
import lombok.experimental.Delegate;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.*;
import org.apache.sling.models.annotations.injectorspecific.*;
import org.apache.sling.models.annotations.via.ResourceSuperType;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

@Model(adaptables = SlingHttpServletRequest.class, adapters = {Text.class, ComponentExporter.class}, resourceType = TextModelImpl.RESOURCE_TYPE, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
@Exporter(name = ExporterConstants.SLING_MODEL_EXPORTER_NAME, extensions = ExporterConstants.SLING_MODEL_EXTENSION)
public class TextModelImpl implements Text {

    public static final String RESOURCE_TYPE = "wknd/components/text";

    @SlingObject
    protected Resource resource;

    @ScriptVariable(injectionStrategy = InjectionStrategy.OPTIONAL)
    private ComponentContext componentContext;

    @ScriptVariable(injectionStrategy = InjectionStrategy.OPTIONAL)
    private Page currentPage;

    @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL)
    @Default(values=StringUtils.EMPTY)
    protected String id;

    @OSGiService
    private JSONConverter jsonConverter;

    @Self
    @Via(type = ResourceSuperType.class)
    @Delegate(excludes = DelegationExclusion.class)
    private Text text;

    @Override
    public String getText() {
        return addLinkTracking(text.getText());
    }

    private String addLinkTracking(String text) {
        if(StringUtils.isNotEmpty(text) && (ComponentUtils.isDataLayerEnabled(resource) || resource.getPath().contains("/content/experience-fragments"))) {
            Document doc = Jsoup.parse(text);
            Elements anchors = doc.select("a");
            AtomicInteger counter = new AtomicInteger(1);
            anchors.stream().forEach(anch -> {
                anch.attr("data-cmp-clickable", true);
                anch.attr("data-cmp-data-layer", getLinkData(anch, counter.getAndIncrement()));
            });
            return doc.body().html();
        }
        return text;
    }

    public String getLinkData(Element anchor, int count) {
        //Create a map of properties we want to expose
        Map<String, Object> textLinkProperties = new HashMap<>();
        textLinkProperties.put("@type", resource.getResourceType()+"/link");
        textLinkProperties.put("dc:title", anchor.text());
        textLinkProperties.put("xdm:linkURL", anchor.attr("href"));

        //Use AEM Core Component utils to get a unique identifier for the Byline component (in case multiple are on the page)                        
        String textLinkID;
        if(StringUtils.isEmpty(id)) {
            textLinkID = ComponentUtils.getId(resource, this.currentPage, this.componentContext) + ComponentUtils.ID_SEPARATOR + ComponentUtils.generateId("link", resource.getPath()+anchor.text());
        } else {
            textLinkID = ComponentUtils.getId(resource, this.currentPage, this.componentContext) + ComponentUtils.ID_SEPARATOR + "link-" + count;
        }
        // Return the bylineProperties as a JSON String with a key of the bylineResource's ID
        return String.format("{\"%s\":%s}",
                textLinkID,
                jsonConverter.convertToJsonString(textLinkProperties));
    }

    private interface DelegationExclusion {
        String getText();
    }
}

Check the default tracking for hyperlinks inside the text component

auto generated link tracking

Add a custom ID to the component as shown below:

tracking ID field

In the below screenshot, we can see a custom tracking ID added to the link and each link will be called has 1, 2, 3 …

custom tracking ID for each link
click event getting captured

You can also learn more about
AEM ACDL (Adobe Client Data Layer) tracking – Core Component

AEM Core Component Delegation

Problem Statement:

The article aims to explain the process of delegating OOTB components in AEM and customizing them by adding new parameters and methods.


Requirement:

This article is about delegating the Out Of The Box (OOTB) components of Adobe Experience Manager (AEM) using Lombok delegation. The author explains how to add new parameters and methods to the components using delegation. Specifically, the article covers how to delegate the OOTB image component, add custom logic to its existing methods, and introduce new methods such as getCredit() and getSizes().


Delegate OOTB image component and add custom logic on existing methods like getWidth(), getHeight(), and getSrcSet() and also add new methods getCredit() and getSizes()


Introduction:

Any field or no-argument method can be annotated with @Delegate to let Lombok generate delegate methods that forward the call to this field (or the result of invoking this method).

Lombok delegates all public methods of the field’s type (or method’s return type), as well as those of its supertypes except for all methods declared in java.lang.Object.

You can pass any number of classes into the @Delegate annotation’s types parameter. If you do that, then Lombok will delegate all public methods in those types (and their supertypes, except java.lang.Object) instead of looking at the field/method’s type.

All public non-Object methods that are part of the calculated type(s) are copied, whether or not you also wrote implementations for those methods. That would thus result in duplicate method errors. You can avoid these by using the @Delegate(excludes=SomeType.class) parameter to exclude all public methods in the excluded type(s), and their supertypes.

The below image shows how the Lombok delegation happens:

Lombok request handling and delegation
Lombok request delegation

Whenever we make a request to a class (for the Image Sling model) the Lombok delegation will copy all the public methods and exclude the methods which we want to override and provide the custom implementation to those methods.

Image Component Delegation

For our requirement, I am creating a class called ImageDelegate and implementing the Image component interface.

I will be making the call to the Image component using @self via ResourceSuperType and using @Delegate annotation I will be excluding some of the methods inside DelegationExclusion interface class

I will be adding custom code to the overridden methods and also I am able to introduce new methods getCredit(), getSizes()

package com.mysite.core.models.impl;

import com.adobe.cq.export.json.ExporterConstants;
import com.adobe.cq.wcm.core.components.models.Image;
import com.day.cq.dam.api.DamConstants;
import com.day.cq.dam.commons.util.DamUtil;
import com.day.cq.wcm.api.designer.Style;
import com.drew.lang.annotations.Nullable;
import lombok.experimental.Delegate;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Exporter;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.Via;
import org.apache.sling.models.annotations.injectorspecific.*;
import org.apache.sling.models.annotations.via.ResourceSuperType;
import org.apache.sling.servlets.post.SlingPostConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

@Model(adaptables = SlingHttpServletRequest.class, adapters = {
  Image.class
}, resourceType = ImageDelegate.RESOURCE_TYPE, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
@Exporter(name = ExporterConstants.SLING_MODEL_EXPORTER_NAME, extensions = ExporterConstants.SLING_MODEL_EXTENSION)
public class ImageDelegate implements Image {
  private static final Logger LOGGER = LoggerFactory.getLogger(ImageDelegate.class);
  public static final String RESOURCE_TYPE = "mysite/components/content/image";
  public static final String CONTENT_DAM_PATH = "/content/dam";
  public static final String HTTPS = "https://";
  public static final String PN_DISPLAY_SIZES = "sizes";
  public static final String WIDTH = "{.width}";

  @SlingObject
  private ResourceResolver resourceResolver;

  @SlingObject
  protected Resource resource;

  @ValueMapValue(injectionStrategy = InjectionStrategy.OPTIONAL)
  @Nullable
  protected String credit;

  @ScriptVariable
  protected Style currentStyle;

  @RequestAttribute(injectionStrategy = InjectionStrategy.OPTIONAL)
  String host;

  @Self
  @Via(type = ResourceSuperType.class)
  @Delegate(excludes = DelegationExclusion.class)
  private Image image;

  @Override
  public String getSrc() {
    return prepareSuffix(image.getSrc());
  }

  @Override
  public String getWidth() {
    return DamUtil.resolveToAsset(resourceResolver.resolve(image.getFileReference())).getMetadataValueFromJcr(DamConstants.TIFF_IMAGEWIDTH);
  }

  @Override
  public String getHeight() {
    return DamUtil.resolveToAsset(resourceResolver.resolve(image.getFileReference())).getMetadataValueFromJcr(DamConstants.TIFF_IMAGELENGTH);
  }

  @Override
  public String getSrcset() {
    int[] widthsArray = image.getWidths();
    String srcUritemplate = image.getSrcUriTemplate();
    String[] srcsetArray = new String[widthsArray.length];
    if (widthsArray.length > 0 && srcUritemplate != null) {
      String srcUriTemplateDecoded = "";
      try {
        srcUriTemplateDecoded = HTTPS + host + prepareSuffix(URLDecoder.decode(srcUritemplate, StandardCharsets.UTF_8.name()));
      } catch (UnsupportedEncodingException e) {
        LOGGER.error("Character Decoding failed for {}", resource.getPath());
      }
      if (srcUriTemplateDecoded.contains(WIDTH)) {
        for (int i = 0; i < widthsArray.length; i++) {
          if (srcUriTemplateDecoded.contains("=" + WIDTH)) {
            srcsetArray[i] = srcUriTemplateDecoded.replace(WIDTH, String.format("%s", widthsArray[i])) + " " + widthsArray[i] + "w";
          } else {
            srcsetArray[i] = srcUriTemplateDecoded.replace(WIDTH, String.format(".%s", widthsArray[i])) + " " + widthsArray[i] + "w";
          }
        }
        return StringUtils.join(srcsetArray, ',');
      }
    }
    return null;
  }

  public String getCredit() {
    if (StringUtils.isEmpty(credit)) {
      return DamUtil.resolveToAsset(resourceResolver.resolve(image.getFileReference())).getMetadataValueFromJcr(DamConstants.DC_CREATOR);
    }
    return credit;
  }

  public String getSizes() {
    return currentStyle.get(PN_DISPLAY_SIZES, StringUtils.EMPTY);
  }

  private String prepareSuffix(String imageSrc) {
    if (StringUtils.isNotEmpty(imageSrc) && !StringUtils.containsIgnoreCase(imageSrc, CONTENT_DAM_PATH)) {
      int endIndex = imageSrc.lastIndexOf(SlingPostConstants.DEFAULT_CREATE_SUFFIX);
      String intermittenResult = imageSrc.substring(0, endIndex);
      endIndex = intermittenResult.lastIndexOf(SlingPostConstants.DEFAULT_CREATE_SUFFIX);
      return intermittenResult.substring(0, endIndex) + image.getFileReference();
    }
    return imageSrc;
  }

  private interface DelegationExclusion {
    String getSrc();
    String getSrcset();
    String getWidth();
    String getHeight();
  }
}

Sightly code:

Sightly call to the Image component will remain the same but I will be getting overridden method returns and new method returns as well

<div data-sly-use.image="com.adobe.cq.wcm.core.components.models.Image"
     data-sly-use.templates="core/wcm/components/commons/v1/templates.html"
     data-sly-test="${image.src}"
     data-cmp-dmimage="${image.dmImage}"
     data-asset-id="${image.uuid}"
     id="${image.id}"
     data-cmp-data-layer="${image.data.json}"
     class="cmp-image"
     itemscope itemtype="http://schema.org/ImageObject">
    <a data-sly-unwrap="${!image.imageLink.valid}"
       class="cmp-image__link"
       data-sly-attribute="${image.imageLink.htmlAttributes}"
       data-cmp-clickable="${image.data ? true : false}">
        <img srcset="${image.srcset}" src="${image.src}"
             loading="${image.lazyEnabled ? 'lazy' : 'eager'}"
             class="cmp-image__image cmp-image__image@tablet"
             itemprop="contentUrl"
             sizes="${image.sizes}"
             width="${image.width}" height="${image.height}"
             alt="${image.alt || true}" title="${image.displayPopupTitle && image.title}"/>
    </a>
    <span class="cmp-image__title" itemprop="caption" data-sly-test="${!image.displayPopupTitle && image.title}">${image.title}</span>
    <meta itemprop="caption" content="${image.title}" data-sly-test="${image.displayPopupTitle && image.title}">
</div>
<sly data-sly-call="${templates.placeholder @ isEmpty = !image.src, classAppend = 'cmp-image cq-dd-image'}"></sly>
sizes int the image
The image is from the wknd site