Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved click collection with ActivityMap and event grouping support #1108

Merged
merged 18 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a6e7f6a
Better click collection with event grouping and activitymap support
liljenback Apr 16, 2024
cadeb61
More functional and unit tests
liljenback Apr 19, 2024
08395c9
Deprecating onBeforeLinkClickSend and replacing it with clickCollecti…
liljenback Apr 20, 2024
17887d1
Updating click-handler targetElement to clickedElement
liljenback Apr 23, 2024
c00ab65
PR feedback updates
liljenback May 1, 2024
cac8372
Merge branch 'main' into activitymap-full-support
liljenback May 1, 2024
e016398
Switch to using existing string utility
liljenback May 3, 2024
4ec8285
Deprecated validator -> renamed validator, new deprecated validator
jonsnyder May 4, 2024
3e650f1
Changing new callback from filterClickedElementProperties to filterCl…
liljenback May 7, 2024
9428ec5
Added checks to block clicks if ActivityMap interface is active
liljenback May 9, 2024
a534bde
Events are not bundled if old callback is defined
liljenback May 14, 2024
e8aeaa4
Not saving click data for internal links when sub-domain changes
liljenback May 15, 2024
c276b1f
Event grouping and session storage will be off by default for click c…
liljenback May 30, 2024
fc460a5
PR feedback updates
liljenback May 30, 2024
b423597
Switching monitor to use click properties object instead of XDM
liljenback Jun 14, 2024
818c88a
Fixed issue with invalid anchor elements
liljenback Jun 25, 2024
2e61d4d
Merge from main for prettier updates
liljenback Jun 26, 2024
d4b0b05
Trying to fix external link test for Safari and Firefox
liljenback Jun 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"sandbox:build": "rollup -c --environment SANDBOX && cd sandbox && npm run build",
"sandbox:build:custom": "SANDBOX=true npm run build:custom -- --exclude personalization && cd sandbox && npm run build && npm run start",
"dev": "concurrently --names build,sandbox \"rollup -c -w --environment SANDBOX\" \"cd sandbox && export REACT_APP_NONCE=321 && npm start\"",
"dev:standalone": "npm run clean && rollup -c -w --environment STANDALONE",
"build": "npm run format && npm run lint && npm run clean && rollup -c --environment BASE_CODE_MIN,STANDALONE,STANDALONE_MIN && echo \"Base Code:\" && cat distTest/baseCode.min.js",
"build:custom": "npm run clean && rollup -c --environment BASE_CODE_MIN,NPM_PACKAGE_LOCAL && node scripts/helpers/customBuild.js",
"prepare": "husky install && cd sandbox && npm install",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { noop } from "../../utils";

const createClickHandler = ({ eventManager, lifecycle, handleError }) => {
return clickEvent => {
// Ignore repropagated clicks from AppMeasurement
if (clickEvent.s_fe) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if you wanted to run AppMeasurement and Web SDK side by side to ensure they were collecting all the data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppMeasurement ignores the event that has been "tagged" with s_fe because it is the previously captured and cancelled event that was then re-fired. What happened was that Alloy ended up picking up the original event and this event as well causing two link click event getting sent. It might be that this design in AppMeasurement no longer is required (with the beacon API being available etc.). This addition makes it so that alloy is not affected by the "additional " event.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, depending on the browser prioritization or execution logic for the capturing click handlers this could potentially cause race conditions where we end up losing the event. Perhaps a better solution would be to introduce some sort of rate limiting instead.

return Promise.resolve();
}
// TODO: Consider safeguarding from the same object being clicked multiple times in rapid succession?
const clickedElement = clickEvent.target;
const event = eventManager.createEvent();
Expand All @@ -26,7 +30,6 @@ const createClickHandler = ({ eventManager, lifecycle, handleError }) => {
if (event.isEmpty()) {
return Promise.resolve();
}

return eventManager.sendEvent(event);
})
// eventManager.sendEvent() will return a promise resolved to an
Expand All @@ -45,6 +48,5 @@ export default ({ eventManager, lifecycle, handleError }) => {
lifecycle,
handleError
});

document.addEventListener("click", clickHandler, true);
};
21 changes: 19 additions & 2 deletions src/components/ActivityCollector/configValidators.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ export const downloadLinkQualifier = string()

export default objectOf({
clickCollectionEnabled: boolean().default(true),
onBeforeLinkClickSend: callback(),
downloadLinkQualifier
clickCollection: objectOf({
internalLinkEnabled: boolean().default(true),
externalLinkEnabled: boolean().default(true),
downloadLinkEnabled: boolean().default(true),
// TODO: Consider moving downloadLinkQualifier here.
sessionStorageEnabled: boolean().default(false),
eventGroupingEnabled: boolean().default(false),
filterClickProperties: callback()
}).default({
internalLinkEnabled: true,
externalLinkEnabled: true,
downloadLinkEnabled: true,
sessionStorageEnabled: false,
eventGroupingEnabled: false
}),
downloadLinkQualifier,
onBeforeLinkClickSend: callback().deprecated(
'The field "onBeforeLinkClickSend" has been deprecated. Use "clickCollection.filterClickDetails" instead.'
)
});
33 changes: 33 additions & 0 deletions src/components/ActivityCollector/createClickActivityStorage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
Copyright 2024 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

import { CLICK_ACTIVITY_DATA } from "../../constants/sessionDataKeys";

export default ({ storage }) => {
return {
liljenback marked this conversation as resolved.
Show resolved Hide resolved
save: data => {
const jsonData = JSON.stringify(data);
storage.setItem(CLICK_ACTIVITY_DATA, jsonData);
},
load: () => {
let jsonData = null;
const data = storage.getItem(CLICK_ACTIVITY_DATA);
if (data) {
jsonData = JSON.parse(data);
}
return jsonData;
},
remove: () => {
storage.removeItem(CLICK_ACTIVITY_DATA);
}
};
};
225 changes: 225 additions & 0 deletions src/components/ActivityCollector/createClickedElementProperties.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
Copyright 2022 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

const buildXdmFromClickedElementProperties = props => {
return {
eventType: "web.webinteraction.linkClicks",
web: {
webInteraction: {
name: props.linkName,
region: props.linkRegion,
type: props.linkType,
URL: props.linkUrl,
linkClicks: {
value: 1
}
}
}
};
};

const buildDataFromClickedElementProperties = props => {
return {
__adobe: {
analytics: {
c: {
a: {
activitymap: {
page: props.pageName,
link: props.linkName,
region: props.linkRegion,
pageIDType: props.pageIDType
}
}
}
}
}
};
};

const populateClickedElementPropertiesFromOptions = (options, props) => {
const { xdm, data, clickedElement } = options;
props.clickedElement = clickedElement;
if (xdm && xdm.web && xdm.web.webInteraction) {
const { name, region, type, URL } = xdm.web.webInteraction;
props.linkName = name;
props.linkRegion = region;
props.linkType = type;
props.linkUrl = URL;
}
// DATA has priority over XDM
/* eslint no-underscore-dangle: 0 */
if (data && data.__adobe && data.__adobe.analytics) {
const { c } = data.__adobe.analytics;
if (c && c.a && c.a.activitymap) {
// Set the properties if they exists
const { page, link, region, pageIDType } = c.a.activitymap;
props.pageName = page || props.pageName;
props.linkName = link || props.linkName;
props.linkRegion = region || props.linkRegion;
if (pageIDType !== undefined) {
props.pageIDType = pageIDType;
}
}
}
};

export default ({ properties, logger } = {}) => {
let props = properties || {};
const clickedElementProperties = {
get pageName() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We haven't yet dropped support for IE11, although I think it is planned for this year. I don't think IE11 supports property accessors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're using it in a few other locations such as the createTaskQueue.js.

return props.pageName;
},
set pageName(value) {
props.pageName = value;
},
get linkName() {
return props.linkName;
},
set linkName(value) {
props.linkName = value;
},
get linkRegion() {
return props.linkRegion;
},
set linkRegion(value) {
props.linkRegion = value;
},
get linkType() {
return props.linkType;
},
set linkType(value) {
props.linkType = value;
},
get linkUrl() {
return props.linkUrl;
},
set linkUrl(value) {
props.linkUrl = value;
},
get pageIDType() {
return props.pageIDType;
},
set pageIDType(value) {
props.pageIDType = value;
},
get clickedElement() {
return props.clickedElement;
},
set clickedElement(value) {
props.clickedElement = value;
},
get properties() {
return {
pageName: props.pageName,
linkName: props.linkName,
linkRegion: props.linkRegion,
linkType: props.linkType,
linkUrl: props.linkUrl,
pageIDType: props.pageIDType,
clickedElement: props.clickedElement
};
},
isValidLink() {
return (
!!props.linkUrl &&
!!props.linkType &&
!!props.linkName &&
!!props.linkRegion
);
},
isInternalLink() {
return this.isValidLink() && props.linkType === "other";
},
isValidActivityMapData() {
return (
!!props.pageName &&
!!props.linkName &&
!!props.linkRegion &&
props.pageIDType !== undefined
);
},
get xdm() {
if (props.filteredXdm) {
return props.filteredXdm;
}
return buildXdmFromClickedElementProperties(this);
},
get data() {
if (props.filteredData) {
return props.filteredData;
}
return buildDataFromClickedElementProperties(this);
},
applyPropertyFilter(filter) {
if (filter && filter(props) === false) {
if (logger) {
logger.info(
`Clicked element properties were rejected by filter function: ${JSON.stringify(
this.properties,
null,
2
)}`
);
}
props = {};
}
},
applyOptionsFilter(filter) {
const opts = this.options;
if (opts && opts.clickedElement && (opts.xdm || opts.data)) {
// Properties are rejected if filter is explicitly false.
if (filter && filter(opts) === false) {
if (logger) {
logger.info(
`Clicked element properties were rejected by filter function: ${JSON.stringify(
this.properties,
null,
2
)}`
);
}
this.options = undefined;
return;
}
this.options = opts;
// This is just to ensure that any fields outside clicked element properties
// set by the user filter persists.
props.filteredXdm = opts.xdm;
props.filteredData = opts.data;
}
},
get options() {
const opts = {};
if (this.isValidLink()) {
opts.xdm = this.xdm;
}
if (this.isValidActivityMapData()) {
opts.data = this.data;
}
if (this.clickedElement) {
opts.clickedElement = this.clickedElement;
}
if (!opts.xdm && !opts.data) {
return undefined;
}
return opts;
},
set options(value) {
props = {};
if (value) {
populateClickedElementPropertiesFromOptions(value, props);
}
}
};
return clickedElementProperties;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
Copyright 2022 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License.
*/

import createClickedElementProperties from "./createClickedElementProperties";

export default ({
window,
getLinkName,
getLinkRegion,
getAbsoluteUrlFromAnchorElement,
findClickableElement,
determineLinkType
}) => {
return ({ clickedElement, config, logger, clickActivityStorage }) => {
const {
onBeforeLinkClickSend: optionsFilter, // Deprecated
clickCollection
} = config;
const { filterClickDetails: propertyFilter } = clickCollection;
const elementProperties = createClickedElementProperties({ logger });
if (clickedElement) {
const clickableElement = findClickableElement(clickedElement);
if (clickableElement) {
elementProperties.clickedElement = clickedElement;
elementProperties.linkUrl = getAbsoluteUrlFromAnchorElement(
window,
clickableElement
);
elementProperties.linkType = determineLinkType(
window,
config,
elementProperties.linkUrl,
clickableElement
);
elementProperties.linkRegion = getLinkRegion(clickableElement);
elementProperties.linkName = getLinkName(clickableElement);
elementProperties.pageIDType = 0;
elementProperties.pageName = window.location.href;
// Check if we have a page-name stored from an earlier page view event
const storedLinkData = clickActivityStorage.load();
if (storedLinkData && storedLinkData.pageName) {
elementProperties.pageName = storedLinkData.pageName;
// Perhaps pageIDType should be established after customer filter is applied
// Like if pageName starts with "http" then pageIDType = 0
elementProperties.pageIDType = 1;
}
// If defined, run user provided filter function
if (propertyFilter) {
// clickCollection.filterClickDetails
elementProperties.applyPropertyFilter(propertyFilter);
} else if (optionsFilter) {
// onBeforeLinkClickSend
elementProperties.applyOptionsFilter(optionsFilter);
}
}
}
return elementProperties;
};
};
Loading