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

Plugin: Element Tracking #1400

Open
wants to merge 30 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c290839
Initial element tracking plugin
jethron Oct 24, 2024
96636e3
Further work on element tracking plugin
jethron Nov 15, 2024
e9cc70e
ShadowRoot support
jethron Nov 15, 2024
90e99e8
Update demo page/docs
jethron Nov 15, 2024
94684b4
Updates & docs
jethron Nov 22, 2024
2d055d4
Unit tests
jethron Nov 22, 2024
d9ec02c
More doc updates
jethron Nov 26, 2024
0fe6142
Fix demo page
jethron Nov 27, 2024
c7adb3d
Reset observers after previous shutdown
jethron Nov 27, 2024
f74e965
Shuffle circular imports
jethron Nov 27, 2024
9eac717
Make individual observers optional
jethron Nov 27, 2024
177ce39
Validate shadow selectors
jethron Nov 27, 2024
58a6906
Small tidy
jethron Nov 27, 2024
38b0e41
More tests
jethron Nov 27, 2024
f690d31
Tests + fix for multiple trackers
jethron Nov 28, 2024
6b8f097
Update state handling
jethron Dec 10, 2024
14b8880
Add stats tracking
jethron Dec 10, 2024
52bd2ea
Schema changes
jethron Dec 10, 2024
0cd54c5
Add page ping support to demo page
jethron Dec 10, 2024
0ab3e8c
Integrate Element Tracking plugin with repo
jethron Dec 10, 2024
cbf6ef8
Test for element stats
jethron Dec 11, 2024
0f63181
Migrate demo page to README
jethron Dec 11, 2024
57eafad
Run rush change
jethron Dec 11, 2024
3f57ff8
Fix package.json version
jethron Dec 11, 2024
ba623c3
Make tests v4 compatible
jethron Dec 12, 2024
b8cc5df
Fix subtree create/destroy detection
jethron Dec 12, 2024
ef814ed
Update element_statistics for schema feedback
jethron Jan 7, 2025
9ee8525
element: keep track of originating page view ID
jethron Jan 7, 2025
6aade84
Use previously known size for obscure/destroy events
jethron Jan 7, 2025
a758174
Fix createdTs handling
jethron Jan 7, 2025
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
Prev Previous commit
Next Next commit
Update state handling
jethron committed Dec 11, 2024
commit 6b8f0978dd8bf446017b4ad47e79d325c3fbab7a
81 changes: 26 additions & 55 deletions plugins/browser-plugin-element-tracking/src/api.ts
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ import {
type ElementConfiguration,
} from './configuration';
import { buildContentTree, evaluateDataSelector, getElementDetails } from './data';
import { ElementStatus, elementsState, patchState } from './elementsState';
import { ElementStatus, aggregateStats, getState } from './elementsState';
import { ComponentsEntity, ElementDetailsEntity, Entity, Events, Event } from './schemata';
import { Frequency, type OneOrMany } from './types';
import { getMatchingElements, nodeIsElement, shouldTrackExpose } from './util';
@@ -190,13 +190,10 @@ export function startElementTracking(
const elements = getMatchingElements(config);

elements.forEach((element, i) => {
elementsState.set(
element,
patchState({
lastPosition: i,
})
);
elementsState.get(element)?.matches.add(config);
const state = getState(element);

state.lastPosition = i;
state.matches.add(config);

trackEvent(Events.ELEMENT_CREATE, config, element, { position: i + 1, matches: elements.length });

@@ -396,14 +393,10 @@ function trackEvent<T extends Events>(
*/
function handleCreate(nowTs: number, config: Configuration, node: Node | Element) {
if (nodeIsElement(node) && node.matches(config.selector)) {
elementsState.set(
node,
patchState({
state: ElementStatus.CREATED,
createdTs: nowTs,
})
);
elementsState.get(node)?.matches.add(config);
const state = getState(node);
state.state = ElementStatus.CREATED;
state.createdTs = nowTs;
state.matches.add(config);
trackEvent(Events.ELEMENT_CREATE, config, node);
if (config.expose.when !== Frequency.NEVER && intersectionObserver) intersectionObserver.observe(node);
}
@@ -428,16 +421,16 @@ function mutationCallback(mutations: MutationRecord[]): void {
if (record.type === 'attributes') {
if (nodeIsElement(record.target)) {
const element = record.target;
const prevState = elementsState.get(element);
const prevState = getState(element);

if (prevState) {
if (prevState.state !== ElementStatus.INITIAL) {
if (!element.matches(config.selector)) {
if (prevState.matches.has(config)) {
if (prevState.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, element);
trackEvent(Events.ELEMENT_DESTROY, config, element);
prevState.matches.delete(config);
if (intersectionObserver) intersectionObserver.unobserve(element);
elementsState.set(element, patchState({ state: ElementStatus.DESTROYED }, prevState));
prevState.state = ElementStatus.DESTROYED;
}
} else {
if (!prevState.matches.has(config)) {
@@ -452,11 +445,11 @@ function mutationCallback(mutations: MutationRecord[]): void {
record.addedNodes.forEach(createFn);
record.removedNodes.forEach((node) => {
if (nodeIsElement(node) && node.matches(config.selector)) {
const state = elementsState.get(node) ?? patchState({});
const state = getState(node);
if (state.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, node);
trackEvent(Events.ELEMENT_DESTROY, config, node);
if (intersectionObserver) intersectionObserver.unobserve(node);
elementsState.set(node, patchState({ state: ElementStatus.DESTROYED }, state));
state.state = ElementStatus.DESTROYED;
}
});
}
@@ -477,7 +470,7 @@ function mutationCallback(mutations: MutationRecord[]): void {
*/
function intersectionCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver): void {
entries.forEach((entry) => {
const state = elementsState.get(entry.target) ?? patchState({});
const state = getState(entry.target, { lastObservationTs: entry.time });
configurations.forEach((config) => {
if (entry.target.matches(config.selector)) {
const siblings = getMatchingElements(config);
@@ -486,33 +479,17 @@ function intersectionCallback(entries: IntersectionObserverEntry[], observer: In
const elapsedVisibleMs = [ElementStatus.PENDING, ElementStatus.EXPOSED].includes(state.state)
? state.elapsedVisibleMs + (entry.time - state.lastObservationTs)
: state.elapsedVisibleMs;
elementsState.set(
entry.target,
patchState(
{
state: ElementStatus.PENDING,
lastObservationTs: entry.time,
elapsedVisibleMs,
},
state
)
);
Object.assign(state, {
state: ElementStatus.PENDING,
lastObservationTs: entry.time,
elapsedVisibleMs,
});

// check configured criteria, if any
if (shouldTrackExpose(config, entry)) {
// check time criteria
if (config.expose.minTimeMillis <= state.elapsedVisibleMs) {
elementsState.set(
entry.target,
patchState(
{
state: ElementStatus.EXPOSED,
lastObservationTs: entry.time,
elapsedVisibleMs,
},
state
)
);
state.state = ElementStatus.EXPOSED;
trackEvent(Events.ELEMENT_EXPOSE, config, entry.target, {
boundingRect: entry.boundingClientRect,
position,
@@ -526,7 +503,7 @@ function intersectionCallback(entries: IntersectionObserverEntry[], observer: In
});
}
}
} else if (state.state !== ElementStatus.DESTROYED) {
} else {
if (state.state === ElementStatus.EXPOSED) {
trackEvent(Events.ELEMENT_OBSCURE, config, entry.target, {
boundingRect: entry.boundingClientRect,
@@ -535,16 +512,10 @@ function intersectionCallback(entries: IntersectionObserverEntry[], observer: In
});
}

elementsState.set(
entry.target,
patchState(
{
state: ElementStatus.OBSCURED,
lastObservationTs: entry.time,
},
state
)
);
Object.assign(state, {
state: state.state === ElementStatus.DESTROYED ? ElementStatus.DESTROYED : ElementStatus.OBSCURED,
lastObservationTs: entry.time,
});
}
}
});
41 changes: 24 additions & 17 deletions plugins/browser-plugin-element-tracking/src/elementsState.ts
Original file line number Diff line number Diff line change
@@ -42,25 +42,32 @@ type ElementState = {
/**
* Bank of per-element state that needs to be stored.
*/
export const elementsState =
const elementsState =
typeof WeakMap !== 'undefined' ? new WeakMap<Element, ElementState>() : new Map<Element, ElementState>();

/**
* Create new element state to be stored later in `elementState` with sane defaults for unspecified state.
* @param updates Custom state to include in the updated state.
* @param basis The previous version of the state we want to be updating, rather than blank defaults.
* @returns New updated state accounding for default, basis, and requested updates.
* Obtain element state from `elementState`, creating with sane defaults if element is unknown.
* @param target Element to obtain state for.
* @param initial Initial state to include in the state if created.
* @returns State for the target element.
*/
export function patchState(updates: Partial<ElementState>, basis?: ElementState): ElementState {
const nowTs = performance.now();
return {
state: ElementStatus.INITIAL,
matches: new Set(),
createdTs: nowTs + performance.timeOrigin,
lastPosition: -1,
lastObservationTs: nowTs,
elapsedVisibleMs: 0,
...basis,
...(updates || {}),
};
export function getState(target: Element, initial: Partial<ElementState> = {}): ElementState {
if (elementsState.has(target)) {
return elementsState.get(target)!;
} else {
const nowTs = performance.now();
const state: ElementState = {
state: ElementStatus.INITIAL,
matches: new Set(),
createdTs: nowTs + performance.timeOrigin,
lastPosition: -1,
lastObservationTs: nowTs,
elapsedVisibleMs: 0,
views: 0,
...initial,
};
elementsState.set(target, state);
return state;
}
}