Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions api-docs/docs/browser-tracker/browser-tracker.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@ export type ActivityCallbackData = {
minYOffset: number;
maxXOffset: number;
maxYOffset: number;
activityMetrics?: ActivityMetrics;
};

// @public
export type ActivityMetrics = {
mouseDistance: number;
scrollDistance: number;
keyPresses: number;
clicks: number;
touches: number;
};

// @public
export interface ActivityTrackingConfiguration {
activityMetrics?: boolean;
heartbeatDelay: number;
minimumVisitLength: number;
}
Expand Down
11,753 changes: 7,755 additions & 3,998 deletions api-docs/package-lock.json

Large diffs are not rendered by default.

13 changes: 9 additions & 4 deletions api-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,22 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "3.4.0",
"@docusaurus/preset-classic": "3.4.0",
"@docusaurus/core": "3.10.0",
"@docusaurus/preset-classic": "3.10.0",
"@mdx-js/react": "^3.0.1",
"clsx": "^1.2.1",
"prism-react-renderer": "^2.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.4.0",
"@docusaurus/types": "3.4.0"
"@docusaurus/module-type-aliases": "3.10.0",
"@docusaurus/types": "3.10.0"
},
"overrides": {
"serialize-javascript": "^6.0.2 || ^7.0.3",
"lodash": "^4.17.21",
"webpackbar": "^7.0.0"
},
"browserslist": {
"production": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@snowplow/browser-plugin-optimizely-x",
"comment": "Fix optimizely personalization campaign attribution",
"type": "none"
}
],
"packageName": "@snowplow/browser-plugin-optimizely-x"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"comment": "Add opt-in activity metrics tracking with activity_metrics entity",
"type": "none",
"packageName": "@snowplow/browser-tracker-core"
}
],
"packageName": "@snowplow/browser-tracker-core"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"comment": "Add opt-in activity metrics tracking with activity_metrics entity",
"type": "none",
"packageName": "@snowplow/browser-tracker"
}
],
"packageName": "@snowplow/browser-tracker"
}
130 changes: 111 additions & 19 deletions libraries/browser-tracker-core/src/tracker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
PageViewEvent,
ActivityCallback,
ActivityCallbackData,
ActivityMetrics,
TrackerConfiguration,
BrowserTracker,
ActivityTrackingConfiguration,
Expand Down Expand Up @@ -65,7 +66,13 @@ import {
emptyIdCookie,
eventIndexFromIdCookie,
} from './id_cookie';
import { CLIENT_SESSION_SCHEMA, WEB_PAGE_SCHEMA, BROWSER_CONTEXT_SCHEMA, APPLICATION_CONTEXT_SCHEMA } from './schemata';
import {
CLIENT_SESSION_SCHEMA,
WEB_PAGE_SCHEMA,
BROWSER_CONTEXT_SCHEMA,
APPLICATION_CONTEXT_SCHEMA,
ACTIVITY_METRICS_SCHEMA,
} from './schemata';
import { getBrowserProperties } from '../helpers/browser_props';
import { asyncCookieStorage, syncCookieStorage } from './cookie_storage';

Expand All @@ -88,6 +95,8 @@ type ActivityConfig = {
configHeartBeatTimer: number;
/** The setInterval identifier */
activityInterval?: number;
/** Whether activity metrics tracking is enabled for this configuration */
activityMetrics?: boolean;
};

/** The configurations for the two types of Activity Tracking */
Expand Down Expand Up @@ -264,6 +273,14 @@ export function Tracker(
maxXOffset: number,
minYOffset: number,
maxYOffset: number,
// Activity metrics tracking state
activityMetricsState = {
metrics: { mouseDistance: 0, scrollDistance: 0, keyPresses: 0, clicks: 0, touches: 0 } as ActivityMetrics,
lastMouseX: undefined as number | undefined,
lastMouseY: undefined as number | undefined,
lastScrollX: undefined as number | undefined,
lastScrollY: undefined as number | undefined,
},
// Domain hash value
domainHash: string,
// Domain unique user ID
Expand Down Expand Up @@ -514,17 +531,66 @@ export function Tracker(
* Process all "activity" events.
* For performance, this function must have low overhead.
*/
function activityHandler() {
function activityHandler(event?: Event) {
const now = new Date();
lastActivityTime = now.getTime();

if (isActivityMetricsEnabled() && event) {
switch (event.type) {
case 'mousemove': {
const me = event as MouseEvent;
if (activityMetricsState.lastMouseX !== undefined && activityMetricsState.lastMouseY !== undefined) {
const dx = me.clientX - activityMetricsState.lastMouseX;
const dy = me.clientY - activityMetricsState.lastMouseY;
activityMetricsState.metrics.mouseDistance += Math.sqrt(dx * dx + dy * dy);
}
activityMetricsState.lastMouseX = me.clientX;
activityMetricsState.lastMouseY = me.clientY;
break;
}
case 'click':
activityMetricsState.metrics.clicks++;
break;
case 'keydown':
activityMetricsState.metrics.keyPresses++;
break;
case 'touchstart':
activityMetricsState.metrics.touches++;
break;
// scroll handled in scrollHandler
}
}
}

/*
* Process all "scroll" events.
*/
function scrollHandler() {
updateMaxScrolls();
activityHandler();

const offsets = getPageOffsets();

const x = offsets[0];
if (x < minXOffset) {
minXOffset = x;
} else if (x > maxXOffset) {
maxXOffset = x;
}

const y = offsets[1];
if (y < minYOffset) {
minYOffset = y;
} else if (y > maxYOffset) {
maxYOffset = y;
}

if (isActivityMetricsEnabled()) {
if (activityMetricsState.lastScrollX !== undefined && activityMetricsState.lastScrollY !== undefined) {
activityMetricsState.metrics.scrollDistance += Math.abs(x - activityMetricsState.lastScrollX) + Math.abs(y - activityMetricsState.lastScrollY);
}
activityMetricsState.lastScrollX = x;
activityMetricsState.lastScrollY = y;
}
Comment on lines +587 to +593
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

scrollDistance accumulation logic is new here, but the added test suite doesn’t currently include a positive assertion that scrollDistance increases when scroll offsets change (only that it remains 0 in some cases). Adding a unit test that mutates scroll offsets and dispatches scroll would help prevent regressions in this metric.

Copilot uses AI. Check for mistakes.
}

/*
Expand Down Expand Up @@ -555,24 +621,35 @@ export function Tracker(
}

/*
* Check the max scroll levels, updating as necessary
* Whether activity metrics tracking is enabled for any active configuration
*/
function updateMaxScrolls() {
const offsets = getPageOffsets();
function isActivityMetricsEnabled(): boolean {
return !!(
activityTrackingConfig.configurations.pagePing?.activityMetrics ||
activityTrackingConfig.configurations.callback?.activityMetrics
);
}

const x = offsets[0];
if (x < minXOffset) {
minXOffset = x;
} else if (x > maxXOffset) {
maxXOffset = x;
}
/*
* Reset activity metrics accumulators and position trackers
*/
function resetActivityMetricsState() {
activityMetricsState.metrics = { mouseDistance: 0, scrollDistance: 0, keyPresses: 0, clicks: 0, touches: 0 };
activityMetricsState.lastMouseX = undefined;
activityMetricsState.lastMouseY = undefined;
activityMetricsState.lastScrollX = undefined;
activityMetricsState.lastScrollY = undefined;
}

const y = offsets[1];
if (y < minYOffset) {
minYOffset = y;
} else if (y > maxYOffset) {
maxYOffset = y;
}
/*
* Return a snapshot of current activity metrics
*/
function getActivityMetrics(): ActivityMetrics {
return {
...activityMetricsState.metrics,
mouseDistance: Math.round(activityMetricsState.metrics.mouseDistance),
scrollDistance: Math.round(activityMetricsState.metrics.scrollDistance),
};
}

/*
Expand Down Expand Up @@ -1098,6 +1175,7 @@ export function Tracker(

// Capture our initial scroll points
resetMaxScrolls();
resetActivityMetricsState();

// Add event handlers; cross-browser compatibility here varies significantly
// @see http://quirksmode.org/dom/events
Expand Down Expand Up @@ -1126,6 +1204,7 @@ export function Tracker(
if (activityTrackingConfig.enabled && (resetActivityTrackingOnPageView || installingActivityTracking)) {
// Periodic check for activity.
lastActivityTime = now.getTime();
resetActivityMetricsState();

let key: keyof ActivityConfigurations;
for (key in activityTrackingConfig.configurations) {
Expand All @@ -1147,8 +1226,16 @@ export function Tracker(
) {
const executePagePing = (cb: ActivityCallback, context: Array<SelfDescribingJson>) => {
refreshUrl();
cb({ context, pageViewId: getPageViewId(), minXOffset, minYOffset, maxXOffset, maxYOffset });
let activityMetrics: ActivityMetrics | undefined;
if (isActivityMetricsEnabled()) {
activityMetrics = getActivityMetrics();
context = context.concat([{ schema: ACTIVITY_METRICS_SCHEMA, data: activityMetrics }]);
}
cb({ context, pageViewId: getPageViewId(), minXOffset, minYOffset, maxXOffset, maxYOffset, activityMetrics });
resetMaxScrolls();
if (isActivityMetricsEnabled()) {
Comment on lines +1229 to +1236
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

executePagePing uses isActivityMetricsEnabled() (global OR across both activity-tracking configurations) to decide whether to attach/reset metrics. This means opting into activityMetrics for only the callback (or only page pings) will still attach the entity for the other configuration and can also reset the shared counters at the wrong time, producing incorrect/zeroed metrics when both activity tracking mechanisms are enabled. Consider gating attach/reset on the current config.activityMetrics and handling the “both enabled” case explicitly (e.g., separate accumulators per config or coordinated reset after both callbacks run).

Suggested change
let activityMetrics: ActivityMetrics | undefined;
if (isActivityMetricsEnabled()) {
activityMetrics = getActivityMetrics();
context = context.concat([{ schema: ACTIVITY_METRICS_SCHEMA, data: activityMetrics }]);
}
cb({ context, pageViewId: getPageViewId(), minXOffset, minYOffset, maxXOffset, maxYOffset, activityMetrics });
resetMaxScrolls();
if (isActivityMetricsEnabled()) {
const activityMetricsEnabledForConfig = !!config.activityMetrics;
let activityMetrics: ActivityMetrics | undefined;
if (activityMetricsEnabledForConfig) {
activityMetrics = getActivityMetrics();
context = context.concat([{ schema: ACTIVITY_METRICS_SCHEMA, data: activityMetrics }]);
}
cb({ context, pageViewId: getPageViewId(), minXOffset, minYOffset, maxXOffset, maxYOffset, activityMetrics });
resetMaxScrolls();
if (activityMetricsEnabledForConfig) {

Copilot uses AI. Check for mistakes.
resetActivityMetricsState();
}
};

const timeout = () => {
Expand Down Expand Up @@ -1192,6 +1279,7 @@ export function Tracker(
configMinimumVisitLength: minimumVisitLength * 1000,
configHeartBeatTimer: heartbeatDelay * 1000,
callback,
activityMetrics: configuration.activityMetrics,
};
}

Expand Down Expand Up @@ -1232,6 +1320,10 @@ export function Tracker(
}

activityTrackingConfig.configurations[actionKey] = undefined;

if (!activityTrackingConfig.configurations.pagePing && !activityTrackingConfig.configurations.callback) {
resetActivityMetricsState();
}
}

const apiMethods = {
Expand Down
1 change: 1 addition & 0 deletions libraries/browser-tracker-core/src/tracker/schemata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export const WEB_PAGE_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/web_page/jso
export const BROWSER_CONTEXT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/browser_context/jsonschema/2-0-0';
export const CLIENT_SESSION_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2';
export const APPLICATION_CONTEXT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application/jsonschema/1-0-0';
export const ACTIVITY_METRICS_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/activity_metrics/jsonschema/1-0-0';
21 changes: 21 additions & 0 deletions libraries/browser-tracker-core/src/tracker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,30 @@ export type ActivityCallbackData = {
maxXOffset: number;
/** The maximum Y scroll position for the current page view */
maxYOffset: number;
/** Activity metrics accumulated since the last ping (only when activityMetrics is enabled) */
activityMetrics?: ActivityMetrics;
};

/** The callback for enableActivityTrackingCallback */
export type ActivityCallback = (data: ActivityCallbackData) => void;

/**
* Quantitative activity metrics accumulated between page pings.
* Attached as a context entity when activityMetrics is enabled.
*/
export type ActivityMetrics = {
/** Cumulative Euclidean mouse distance in pixels from mousemove events */
mouseDistance: number;
/** Cumulative |deltaX|+|deltaY| scroll distance in pixels */
scrollDistance: number;
/** Count of keydown events */
keyPresses: number;
/** Count of click events */
clicks: number;
/** Count of touchstart events */
touches: number;
};

/**
* The base configuration for activity tracking
*/
Expand All @@ -240,6 +259,8 @@ export interface ActivityTrackingConfiguration {
minimumVisitLength: number;
/** The interval at which the callback will be fired */
heartbeatDelay: number;
/** Enable activity metrics to attach an activity_metrics entity to each page ping */
activityMetrics?: boolean;
}

/**
Expand Down
Loading
Loading