From 1bb2089f088d49fcad99745244b1ae1f748cfb52 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 14:50:30 -0700 Subject: [PATCH 01/16] removed extend and halt from UserAction interface and class --- .../src/api/userActions/initialize.test.ts | 8 --- packages/core/src/api/userActions/types.ts | 4 -- .../src/api/userActions/userAction.test.ts | 17 ----- .../core/src/api/userActions/userAction.ts | 70 +------------------ 4 files changed, 1 insertion(+), 98 deletions(-) diff --git a/packages/core/src/api/userActions/initialize.test.ts b/packages/core/src/api/userActions/initialize.test.ts index d2e6890ee..553b4a656 100644 --- a/packages/core/src/api/userActions/initialize.test.ts +++ b/packages/core/src/api/userActions/initialize.test.ts @@ -72,14 +72,6 @@ describe('initializeUserActionsAPI', () => { expect(a2).not.toBeDefined(); }); - it('create an action while one is halted will result action not getting created', () => { - const a1 = api.startUserAction('A'); - expect(a1).toBeDefined(); - a1?.halt(); - const a2 = api.startUserAction('B'); - expect(a2).not.toBeDefined(); - }); - it('getActiveUserAction returns undefined if the action is ended', () => { const action = api.startUserAction('first'); action?.end(); diff --git a/packages/core/src/api/userActions/types.ts b/packages/core/src/api/userActions/types.ts index 7518ba720..2be99672a 100644 --- a/packages/core/src/api/userActions/types.ts +++ b/packages/core/src/api/userActions/types.ts @@ -9,16 +9,12 @@ export enum UserActionState { Ended, } -export type HaltPredicate = () => boolean; - export interface UserActionInterface { name: string; parentId: string; addItem(item: TransportItem): void; - extend(haltPredicate?: HaltPredicate): void; end(attributes?: Record): void; - halt(reason?: string): void; cancel(): void; getState(): UserActionState; } diff --git a/packages/core/src/api/userActions/userAction.test.ts b/packages/core/src/api/userActions/userAction.test.ts index 41decc0b5..b811ab7a9 100644 --- a/packages/core/src/api/userActions/userAction.test.ts +++ b/packages/core/src/api/userActions/userAction.test.ts @@ -51,23 +51,6 @@ describe('UserAction', () => { expect(transports.execute).not.toHaveBeenCalled(); }); - it('halt() is no-op if user action is not started', () => { - const ua = new UserAction({ name: 'foo', transports, trigger: 'foo' }); - ua.cancel(); - ua.halt(); - - expect(ua.getState()).toBe(UserActionState.Cancelled); - }); - - it('halt() will end() after halt timeoute time', () => { - const ua = new UserAction({ name: 'foo', transports, trigger: 'foo' }); - ua.extend(() => true); - jest.advanceTimersByTime(ua.cancelTimeout); - expect(ua.getState()).toBe(UserActionState.Halted); - jest.advanceTimersByTime(ua.haltTimeout); - expect(ua.getState()).toBe(UserActionState.Ended); - }); - it('end() will not fire if action is cancelled', () => { const ua = new UserAction({ name: 'foo', transports, trigger: 'foo' }); ua.cancel(); diff --git a/packages/core/src/api/userActions/userAction.ts b/packages/core/src/api/userActions/userAction.ts index d905f7990..c46d047f5 100644 --- a/packages/core/src/api/userActions/userAction.ts +++ b/packages/core/src/api/userActions/userAction.ts @@ -7,10 +7,7 @@ import { type MeasurementEvent } from '../measurements'; import { type APIEvent } from '../types'; import { userActionEventName, UserActionSeverity } from './const'; -import { type HaltPredicate, type UserActionInterface, UserActionState } from './types'; - -const defaultFollowUpActionTimeRange = 100; -const defaultHaltTimeout = 10 * 1000; +import { type UserActionInterface, UserActionState } from './types'; export default class UserAction extends Observable implements UserActionInterface { name: string; @@ -21,20 +18,14 @@ export default class UserAction extends Observable implements UserActionInterfac severity: UserActionSeverity; startTime?: number; trackUserActionsExcludeItem?: (item: TransportItem) => boolean; - cancelTimeout: number; - haltTimeout: number; private _state: UserActionState; - private _timeoutId?: number; private _itemBuffer: ItemBuffer; private _transports: Transports; - private _haltTimeoutId: any; - private _isValid: boolean; constructor({ name, parentId, - haltTimeout, trigger, transports, attributes, @@ -55,17 +46,13 @@ export default class UserAction extends Observable implements UserActionInterfac this.attributes = attributes; this.id = genShortID(); this.trigger = trigger; - this.cancelTimeout = defaultFollowUpActionTimeRange; - this.haltTimeout = haltTimeout ?? defaultHaltTimeout; this.parentId = parentId ?? this.id; this.trackUserActionsExcludeItem = trackUserActionsExcludeItem; this.severity = severity; this._itemBuffer = new ItemBuffer(); this._transports = transports; - this._haltTimeoutId = -1; this._state = UserActionState.Started; - this._isValid = false; this._start(); } @@ -73,49 +60,11 @@ export default class UserAction extends Observable implements UserActionInterfac this._itemBuffer.addItem(item); } - extend(haltPredicate?: HaltPredicate) { - if (!this._isValid) { - this._isValid = true; - } - this._setFollowupActionTimeout(haltPredicate); - } - - private _setFollowupActionTimeout(haltPredicate?: HaltPredicate) { - this._timeoutId = startTimeout( - this._timeoutId, - () => { - if (this._state === UserActionState.Started && haltPredicate?.()) { - this.halt(); - } else if (this._isValid) { - this.end(); - } else { - this.cancel(); - } - }, - defaultFollowUpActionTimeRange - ); - } - private _start(): void { this._state = UserActionState.Started; if (this._state === UserActionState.Started) { this.startTime = dateNow(); } - this._setFollowupActionTimeout(); - } - - halt() { - if (this._state !== UserActionState.Started) { - return; - } - this._state = UserActionState.Halted; - - // If the halt timeout fires, we end the user action as - // it is still a valid one. - this._haltTimeoutId = setTimeout(() => { - this.end(); - }, this.haltTimeout); - this.notify(this._state); } cancel() { @@ -133,10 +82,6 @@ export default class UserAction extends Observable implements UserActionInterfac return; } - // Make sure we don't end the user action twice - clearTimeout(this._haltTimeoutId); - clearTimeout(this._timeoutId); - const endTime = dateNow(); const duration = endTime - this.startTime!; this._state = UserActionState.Ended; @@ -203,16 +148,3 @@ function isExcludeFromUserAction( (item.type === TransportItemType.MEASUREMENT && (item.payload as MeasurementEvent).type === 'web-vitals') ); } - -function startTimeout(timeoutId: number | undefined, cb: () => void, delay: number) { - if (timeoutId) { - clearTimeout(timeoutId); - } - - //@ts-expect-error for some reason vscode is using the node types - timeoutId = setTimeout(() => { - cb(); - }, delay); - - return timeoutId; -} From 9d571678c3b0061d4b85a1c596b66339850bba1c Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 14:57:41 -0700 Subject: [PATCH 02/16] introduced UserActionController --- .../userActions/instrumentation.ts | 5 +- .../processUserActionEventHandler.test.ts | 12 +- .../processUserActionEventHandler.ts | 65 +-------- .../userActions/userActionController.ts | 137 ++++++++++++++++++ .../src/instrumentations/userActions/util.ts | 13 ++ 5 files changed, 163 insertions(+), 69 deletions(-) create mode 100644 packages/web-sdk/src/instrumentations/userActions/userActionController.ts diff --git a/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts b/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts index f643ab666..ed27cb5a6 100644 --- a/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts +++ b/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts @@ -1,6 +1,7 @@ import { BaseInstrumentation, faro, type Subscription, userActionsMessageBus, VERSION } from '@grafana/faro-core'; import { getUserEventHandler } from './processUserActionEventHandler'; +import { UserActionController } from './userActionController'; export class UserActionInstrumentation extends BaseInstrumentation { readonly name = '@grafana/faro-web-sdk:instrumentation-user-action'; @@ -9,7 +10,7 @@ export class UserActionInstrumentation extends BaseInstrumentation { private _userActionSub?: Subscription; initialize(): void { - const { processUserEvent, proceessUserActionStarted } = getUserEventHandler(faro); + const processUserEvent = getUserEventHandler(faro); window.addEventListener('pointerdown', processUserEvent); window.addEventListener('keydown', (ev: KeyboardEvent) => { if ([' ', 'Enter'].includes(ev.key)) { @@ -19,7 +20,7 @@ export class UserActionInstrumentation extends BaseInstrumentation { this._userActionSub = userActionsMessageBus.subscribe(({ type, userAction }) => { if (type === 'user_action_start') { - proceessUserActionStarted(userAction); + new UserActionController(userAction).attach(); } }); } diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts index 28659b592..b660d554a 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts @@ -116,7 +116,7 @@ describe('getUserEventHandler', () => { it('starts a new user action when none exists', () => { getCurrentSpy.mockReturnValue(undefined); - const { processUserEvent } = getUserEventHandler(faro as Faro); + const processUserEvent = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.dataset['fooBar'] = 'my-action'; @@ -129,7 +129,7 @@ describe('getUserEventHandler', () => { it('does not start a new action if one already exists', () => { getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); + const processUserEvent = getUserEventHandler(faro as Faro); const event = { type: 'keydown', target: document.createElement('div') } as unknown as KeyboardEvent; processUserEvent(event); @@ -140,7 +140,7 @@ describe('getUserEventHandler', () => { it('does not process an event if the current user action is in Started/Halted state', () => { fakeAction.getState.mockReturnValueOnce(UserActionState.Cancelled); getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); + const processUserEvent = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.setAttribute('data-faro-user-action-name', 'foo'); @@ -151,7 +151,7 @@ describe('getUserEventHandler', () => { it('allows processing if there are running requests', () => { getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); + const processUserEvent = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.dataset['fooBar'] = 'foo'; @@ -185,7 +185,7 @@ describe('getUserEventHandler', () => { it('does not allow processing if there are no running requests', () => { getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); + const processUserEvent = getUserEventHandler(faro as Faro); const event = { type: 'keydown', target: document.createElement('div') } as unknown as KeyboardEvent; processUserEvent(event); @@ -205,7 +205,7 @@ describe('getUserEventHandler', () => { it('does not allow processing if there are running requests but the request id is not pending', () => { getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); + const processUserEvent = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.dataset['fooBar'] = 'baz'; diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts index 7f7e44123..f1e129342 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts @@ -1,25 +1,17 @@ -import { Observable, UserActionState } from '@grafana/faro-core'; -import type { Faro, Subscription, UserActionInterface } from '@grafana/faro-core'; +import type { Faro, Subscription } from '@grafana/faro-core'; import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START, userActionDataAttributeParsed as userActionDataAttribute, } from './const'; -import { monitorDomMutations } from './domMutationMonitor'; -import { monitorHttpRequests } from './httpRequestMonitor'; -import { monitorPerformanceEntries } from './performanceEntriesMonitor'; -import type { HttpRequestEndMessage, HttpRequestMessagePayload, HttpRequestStartMessage } from './types'; +import type { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; import { convertDataAttributeName } from './util'; export function getUserEventHandler(faro: Faro) { const { api, config } = faro; - const httpMonitor = monitorHttpRequests(); - const domMutationsMonitor = monitorDomMutations(); - const performanceEntriesMonitor = monitorPerformanceEntries(); - - function processUserEvent(event: PointerEvent | KeyboardEvent) { + return function processUserEvent(event: PointerEvent | KeyboardEvent) { const userActionName = getUserActionNameFromElement( event.target as HTMLElement, config.trackUserActionsDataAttributeName ?? userActionDataAttribute @@ -30,57 +22,8 @@ export function getUserEventHandler(faro: Faro) { return; } - const userAction = api.startUserAction(userActionName, {}, { triggerName: event.type }); - if (userAction) { - proceessUserActionStarted(userAction); - } + api.startUserAction(userActionName, {}, { triggerName: event.type }); } - - function proceessUserActionStarted(userAction: UserActionInterface) { - const runningRequests = new Map(); - const allMonitorsSub = new Observable() - .merge(httpMonitor, domMutationsMonitor, performanceEntriesMonitor) - .takeWhile(() => [UserActionState.Started, UserActionState.Halted].includes(userAction.getState())) - .filter((msg) => { - // If the user action is in halt state, we only keep listening to ended http requests - if ( - userAction.getState() === UserActionState.Halted && - !(isRequestEndMessage(msg) && runningRequests.has(msg.request.requestId)) - ) { - return false; - } - - return true; - }) - .subscribe((msg) => { - if (isRequestStartMessage(msg)) { - // An action is on halt if it has pending items, like pending HTTP requests. - // In this case we start a separate timeout to wait for the requests to finish - // If in the halt state, we stop adding Faro signals to the action's buffer (see userActionLifecycleHandler.ts) - // But we are still subscribed to - runningRequests.set(msg.request.requestId, msg.request); - } - - if (isRequestEndMessage(msg)) { - runningRequests.delete(msg.request.requestId); - } - - if (!isRequestEndMessage(msg)) { - userAction.extend(() => runningRequests.size > 0); - } else if (userAction.getState() === UserActionState.Halted && runningRequests.size === 0) { - userAction.end(); - } - }); - - (userAction as unknown as Observable) - .filter((v: UserActionState) => [UserActionState.Ended, UserActionState.Cancelled].includes(v)) - .first() - .subscribe(() => { - unsubscribeAllMonitors(allMonitorsSub); - }); - } - - return { processUserEvent, proceessUserActionStarted }; } export function getUserActionNameFromElement(element: HTMLElement, dataAttributeName: string): string | undefined { diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts new file mode 100644 index 000000000..8b1d1e71e --- /dev/null +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -0,0 +1,137 @@ +// packages/web-sdk/src/instrumentations/userActions/userActionController.ts +import { Observable, UserActionState } from '@grafana/faro-core'; +import type { Subscription, UserActionInterface } from '@grafana/faro-core'; +import { startTimeout } from './util'; +import { monitorDomMutations } from './domMutationMonitor'; +import { monitorHttpRequests } from './httpRequestMonitor'; +import { monitorPerformanceEntries } from './performanceEntriesMonitor'; +import { isRequestEndMessage, isRequestStartMessage } from './processUserActionEventHandler'; +import type { HttpRequestMessagePayload } from './types'; + +const defaultFollowUpActionTimeRange = 100; +const defaultHaltTimeout = 10 * 1000; + +export class UserActionController { + private readonly http = monitorHttpRequests(); + private readonly dom = monitorDomMutations(); + private readonly perf = monitorPerformanceEntries(); + + private allMonitorsSub?: Subscription; + private stateSub?: Subscription; + private followUpTid?: number; + private haltTid?: number; + + private isValid = false; + private runningRequests = new Map(); + + constructor(private ua: UserActionInterface) {} + + attach(): void { + // Subscribe to monitors while action is active/halting + this.allMonitorsSub = new Observable() + .merge(this.http, this.dom, this.perf) + .takeWhile(() => [UserActionState.Started, UserActionState.Halted].includes(this.ua.getState())) + .filter((msg) => { + // If the user action is in halt state, we only keep listening to ended http requests + if ( + this.ua.getState() === UserActionState.Halted && + !(isRequestEndMessage(msg) && this.runningRequests.has(msg.request.requestId)) + ) { + return false; + } + + return true; + }) + .subscribe((msg) => { + if (isRequestStartMessage(msg)) { + // An action is on halt if it has pending items, like pending HTTP requests. + // In this case we start a separate timeout to wait for the requests to finish + // If in the halt state, we stop adding Faro signals to the action's buffer (see userActionLifecycleHandler.ts) + // But we are still subscribed to + this.runningRequests.set(msg.request.requestId, msg.request); + } + + if (isRequestEndMessage(msg)) { + this.runningRequests.delete(msg.request.requestId); + + if (this.ua.getState() === UserActionState.Halted && this.runningRequests.size === 0) { + this.ua.end(); + } + } else { + this.scheduleFollowUp(); + } + }); + + // When UA ends or cancels, cleanup timers/subscriptions + this.stateSub = (this.ua as unknown as Observable) + .filter((s: UserActionState) => [UserActionState.Ended, UserActionState.Cancelled].includes(s)) + .first() + .subscribe(() => this.cleanup()); + + // initial follow-up window in case nothing else happens + this.scheduleFollowUp(); + } + + private scheduleFollowUp() { + this.clearTimer(this.followUpTid); + this.followUpTid = setTimeout(() => { + // If action just started and there's pending work, go to halted + if (this.ua.getState() === UserActionState.Started && this.runningRequests.size > 0) { + this.haltAction(); + return; + } + + // If we saw any relevant activity in the window, finish as ended + if (this.isValid) { + this.endAction(); + return; + } + + // Otherwise, no signals => cancel + this.cancelAction(); + }, defaultFollowUpActionTimeRange) as any; + } + + private haltAction() { + if (this.ua.getState() !== UserActionState.Started) { + return; + } + this.startHaltTimeout(); + } + + private startHaltTimeout() { + this.clearTimer(this.haltTid); + this.haltTid = startTimeout(this.haltTid, () => { + // If still halted after timeout, end + if (this.ua.getState() === UserActionState.Halted) { + this.endAction(); + } + }, defaultHaltTimeout) as any; + } + + private endAction() { + this.ua.end(); + this.cleanup(); + } + + private cancelAction() { + this.ua.cancel(); + this.cleanup(); + } + + private cleanup() { + this.clearTimer(this.followUpTid); + this.clearTimer(this.haltTid); + this.allMonitorsSub?.unsubscribe(); + this.stateSub?.unsubscribe(); + this.allMonitorsSub = undefined; + this.stateSub = undefined; + this.runningRequests.clear(); + } + + private clearTimer(id?: number) { + if (id) { + clearTimeout(id); + } + } +} diff --git a/packages/web-sdk/src/instrumentations/userActions/util.ts b/packages/web-sdk/src/instrumentations/userActions/util.ts index 1ab58fa7c..9ab68fbe3 100644 --- a/packages/web-sdk/src/instrumentations/userActions/util.ts +++ b/packages/web-sdk/src/instrumentations/userActions/util.ts @@ -10,3 +10,16 @@ export function convertDataAttributeName(userActionDataAttribute: string) { const withUpperCase = withoutData?.replace(/-(.)/g, (_, char) => char.toUpperCase()); return withUpperCase?.replace(/-/g, ''); } + +export function startTimeout(timeoutId: number | undefined, cb: () => void, delay: number) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + //@ts-expect-error for some reason vscode is using the node types + timeoutId = setTimeout(() => { + cb(); + }, delay); + + return timeoutId; +} From 2f3cf871be1911b699b3e585eba0e73168616707 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 16:00:11 -0700 Subject: [PATCH 03/16] update processUserActionStarted to use UserActionController --- .../userActions/instrumentation.ts | 5 ++--- .../processUserActionEventHandler.test.ts | 12 ++++++------ .../userActions/processUserActionEventHandler.ts | 16 +++++++++++++--- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts b/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts index ed27cb5a6..25b15131d 100644 --- a/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts +++ b/packages/web-sdk/src/instrumentations/userActions/instrumentation.ts @@ -1,7 +1,6 @@ import { BaseInstrumentation, faro, type Subscription, userActionsMessageBus, VERSION } from '@grafana/faro-core'; import { getUserEventHandler } from './processUserActionEventHandler'; -import { UserActionController } from './userActionController'; export class UserActionInstrumentation extends BaseInstrumentation { readonly name = '@grafana/faro-web-sdk:instrumentation-user-action'; @@ -10,7 +9,7 @@ export class UserActionInstrumentation extends BaseInstrumentation { private _userActionSub?: Subscription; initialize(): void { - const processUserEvent = getUserEventHandler(faro); + const { processUserEvent, processUserActionStarted } = getUserEventHandler(faro); window.addEventListener('pointerdown', processUserEvent); window.addEventListener('keydown', (ev: KeyboardEvent) => { if ([' ', 'Enter'].includes(ev.key)) { @@ -20,7 +19,7 @@ export class UserActionInstrumentation extends BaseInstrumentation { this._userActionSub = userActionsMessageBus.subscribe(({ type, userAction }) => { if (type === 'user_action_start') { - new UserActionController(userAction).attach(); + processUserActionStarted(userAction); } }); } diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts index b660d554a..28659b592 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts @@ -116,7 +116,7 @@ describe('getUserEventHandler', () => { it('starts a new user action when none exists', () => { getCurrentSpy.mockReturnValue(undefined); - const processUserEvent = getUserEventHandler(faro as Faro); + const { processUserEvent } = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.dataset['fooBar'] = 'my-action'; @@ -129,7 +129,7 @@ describe('getUserEventHandler', () => { it('does not start a new action if one already exists', () => { getCurrentSpy.mockReturnValue(fakeAction); - const processUserEvent = getUserEventHandler(faro as Faro); + const { processUserEvent } = getUserEventHandler(faro as Faro); const event = { type: 'keydown', target: document.createElement('div') } as unknown as KeyboardEvent; processUserEvent(event); @@ -140,7 +140,7 @@ describe('getUserEventHandler', () => { it('does not process an event if the current user action is in Started/Halted state', () => { fakeAction.getState.mockReturnValueOnce(UserActionState.Cancelled); getCurrentSpy.mockReturnValue(fakeAction); - const processUserEvent = getUserEventHandler(faro as Faro); + const { processUserEvent } = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.setAttribute('data-faro-user-action-name', 'foo'); @@ -151,7 +151,7 @@ describe('getUserEventHandler', () => { it('allows processing if there are running requests', () => { getCurrentSpy.mockReturnValue(fakeAction); - const processUserEvent = getUserEventHandler(faro as Faro); + const { processUserEvent } = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.dataset['fooBar'] = 'foo'; @@ -185,7 +185,7 @@ describe('getUserEventHandler', () => { it('does not allow processing if there are no running requests', () => { getCurrentSpy.mockReturnValue(fakeAction); - const processUserEvent = getUserEventHandler(faro as Faro); + const { processUserEvent } = getUserEventHandler(faro as Faro); const event = { type: 'keydown', target: document.createElement('div') } as unknown as KeyboardEvent; processUserEvent(event); @@ -205,7 +205,7 @@ describe('getUserEventHandler', () => { it('does not allow processing if there are running requests but the request id is not pending', () => { getCurrentSpy.mockReturnValue(fakeAction); - const processUserEvent = getUserEventHandler(faro as Faro); + const { processUserEvent } = getUserEventHandler(faro as Faro); const element = document.createElement('div'); element.dataset['fooBar'] = 'baz'; diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts index f1e129342..6545b3842 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts @@ -1,4 +1,4 @@ -import type { Faro, Subscription } from '@grafana/faro-core'; +import type { Faro, Subscription, UserActionInterface } from '@grafana/faro-core'; import { MESSAGE_TYPE_HTTP_REQUEST_END, @@ -7,11 +7,12 @@ import { } from './const'; import type { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; import { convertDataAttributeName } from './util'; +import { UserActionController } from './userActionController'; export function getUserEventHandler(faro: Faro) { const { api, config } = faro; - return function processUserEvent(event: PointerEvent | KeyboardEvent) { + function processUserEvent(event: PointerEvent | KeyboardEvent) { const userActionName = getUserActionNameFromElement( event.target as HTMLElement, config.trackUserActionsDataAttributeName ?? userActionDataAttribute @@ -22,8 +23,17 @@ export function getUserEventHandler(faro: Faro) { return; } - api.startUserAction(userActionName, {}, { triggerName: event.type }); + const userAction = api.startUserAction(userActionName, {}, { triggerName: event.type }); + if (userAction) { + processUserActionStarted(userAction); + } + } + + function processUserActionStarted(userAction: UserActionInterface) { + new UserActionController(userAction).attach(); } + + return { processUserEvent, processUserActionStarted }; } export function getUserActionNameFromElement(element: HTMLElement, dataAttributeName: string): string | undefined { From afe561e01f9791c1a26980f89b061ee78e38028f Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 20:30:15 -0700 Subject: [PATCH 04/16] set user action as valid if an event is observed --- .../src/instrumentations/userActions/userActionController.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index 8b1d1e71e..5d342c6e6 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -58,6 +58,9 @@ export class UserActionController { this.ua.end(); } } else { + if (!this.isValid) { + this.isValid = true; + } this.scheduleFollowUp(); } }); From 320404233856cd55c0f76e8d5ac9dba8d9b8eac9 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 20:43:02 -0700 Subject: [PATCH 05/16] added halt state outside of UserAction class --- .../userActions/userActionController.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index 5d342c6e6..e61060ba0 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -22,6 +22,7 @@ export class UserActionController { private haltTid?: number; private isValid = false; + private isHalted = false; private runningRequests = new Map(); constructor(private ua: UserActionInterface) {} @@ -53,15 +54,15 @@ export class UserActionController { if (isRequestEndMessage(msg)) { this.runningRequests.delete(msg.request.requestId); + } - if (this.ua.getState() === UserActionState.Halted && this.runningRequests.size === 0) { - this.ua.end(); - } - } else { + if (!isRequestEndMessage(msg)) { if (!this.isValid) { this.isValid = true; } this.scheduleFollowUp(); + } else if (this.isHalted && this.runningRequests.size === 0) { + this.endAction(); } }); @@ -99,6 +100,7 @@ export class UserActionController { if (this.ua.getState() !== UserActionState.Started) { return; } + this.isHalted = true; this.startHaltTimeout(); } From 376f239f170bfa938be4d3b5f177e43a1843863d Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 20:44:19 -0700 Subject: [PATCH 06/16] added tests for UserActionInstrumentation class --- .../userActions/instrumentation.test.ts | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts diff --git a/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts b/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts new file mode 100644 index 000000000..e63b6c5a6 --- /dev/null +++ b/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts @@ -0,0 +1,117 @@ +import { initializeFaro } from '@grafana/faro-core'; +import { Observable } from '@grafana/faro-core'; +import { mockConfig } from '@grafana/faro-core/src/testUtils'; + +import { + MESSAGE_TYPE_DOM_MUTATION, + MESSAGE_TYPE_HTTP_REQUEST_END, + MESSAGE_TYPE_HTTP_REQUEST_START, +} from './const'; + +let http$: Observable; +let dom$: Observable; +let perf$: Observable; + +jest.useFakeTimers(); + +jest.mock('./domMutationMonitor', () => ({ + monitorDomMutations: () => dom$, +})); + +jest.mock('./httpRequestMonitor', () => ({ + monitorHttpRequests: () => http$, +})); + +jest.mock('./performanceEntriesMonitor', () => ({ + monitorPerformanceEntries: () => perf$, +})); + +describe('UserActionInstrumentation output', () => { + beforeEach(() => { + http$ = new Observable(); + dom$ = new Observable(); + perf$ = new Observable(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.clearAllTimers(); + }); + + it('calls cancel() when no activity is observed', () => { + const faro = initializeFaro(mockConfig()); + + const { UserActionInstrumentation } = require('./instrumentation'); + const inst = new UserActionInstrumentation(); + inst.initialize(); + + const ua = faro.api.startUserAction('ua-dom'); + const cancelSpy = jest.spyOn(ua!, 'cancel'); + + jest.advanceTimersByTime(200); + + expect(cancelSpy).toHaveBeenCalled(); + }); + + it('calls end() when activity is observed via DOM mutations', () => { + const faro = initializeFaro(mockConfig()); + + const { UserActionInstrumentation } = require('./instrumentation'); + const inst = new UserActionInstrumentation(); + inst.initialize(); + + const ua = faro.api.startUserAction('ua-dom'); + const endSpy = jest.spyOn(ua!, 'end'); + + dom$.notify({ type: MESSAGE_TYPE_DOM_MUTATION }); + + jest.advanceTimersByTime(200); + + expect(endSpy).toHaveBeenCalled(); + }); + + it('calls end() when activity is observed via performance entries', () => { + const faro = initializeFaro(mockConfig()); + + const { UserActionInstrumentation } = require('./instrumentation'); + const inst = new UserActionInstrumentation(); + inst.initialize(); + + const ua = faro.api.startUserAction('ua-perf'); + const endSpy = jest.spyOn(ua!, 'end'); + + perf$.notify({ type: 'performance-entry' }); + + jest.advanceTimersByTime(200); + + expect(endSpy).toHaveBeenCalled(); + }); + + it('calls end() when HTTP request is observed', () => { + const faro = initializeFaro(mockConfig()); + + const { UserActionInstrumentation } = require('./instrumentation'); + const inst = new UserActionInstrumentation(); + inst.initialize(); + + const ua = faro.api.startUserAction('ua-http'); + const endSpy = jest.spyOn(ua!, 'end'); + + const requestId = 'req-1'; + http$.notify({ + type: MESSAGE_TYPE_HTTP_REQUEST_START, + request: { requestId, url: '/x', method: 'GET', apiType: 'fetch' }, + }); + + // Allow follow-up window to elapse and transition into waiting-for-HTTP-completion + jest.advanceTimersByTime(150); + + http$.notify({ + type: MESSAGE_TYPE_HTTP_REQUEST_END, + request: { requestId, url: '/x', method: 'GET', apiType: 'fetch' }, + }); + + expect(endSpy).toHaveBeenCalled(); + }); +}); From 5601b1ff61a91c073cd7db25255f55434a3a3378 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 20:57:15 -0700 Subject: [PATCH 07/16] eslint --- .../src/instrumentations/userActions/instrumentation.test.ts | 3 +-- .../userActions/processUserActionEventHandler.ts | 2 +- .../src/instrumentations/userActions/userActionController.ts | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts b/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts index e63b6c5a6..c34dc26ec 100644 --- a/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts @@ -1,5 +1,4 @@ -import { initializeFaro } from '@grafana/faro-core'; -import { Observable } from '@grafana/faro-core'; +import { initializeFaro, Observable } from '@grafana/faro-core'; import { mockConfig } from '@grafana/faro-core/src/testUtils'; import { diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts index 6545b3842..fdcda7d26 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts @@ -6,8 +6,8 @@ import { userActionDataAttributeParsed as userActionDataAttribute, } from './const'; import type { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; -import { convertDataAttributeName } from './util'; import { UserActionController } from './userActionController'; +import { convertDataAttributeName } from './util'; export function getUserEventHandler(faro: Faro) { const { api, config } = faro; diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index e61060ba0..eb41126ee 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -1,12 +1,13 @@ // packages/web-sdk/src/instrumentations/userActions/userActionController.ts import { Observable, UserActionState } from '@grafana/faro-core'; import type { Subscription, UserActionInterface } from '@grafana/faro-core'; -import { startTimeout } from './util'; + import { monitorDomMutations } from './domMutationMonitor'; import { monitorHttpRequests } from './httpRequestMonitor'; import { monitorPerformanceEntries } from './performanceEntriesMonitor'; import { isRequestEndMessage, isRequestStartMessage } from './processUserActionEventHandler'; import type { HttpRequestMessagePayload } from './types'; +import { startTimeout } from './util'; const defaultFollowUpActionTimeRange = 100; const defaultHaltTimeout = 10 * 1000; From d48cbc063a322a796cee4c723443e27392964647 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Mon, 20 Oct 2025 21:02:00 -0700 Subject: [PATCH 08/16] prettier lint --- .../userActions/instrumentation.test.ts | 6 +----- .../userActions/userActionController.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts b/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts index c34dc26ec..1e2020ade 100644 --- a/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/instrumentation.test.ts @@ -1,11 +1,7 @@ import { initializeFaro, Observable } from '@grafana/faro-core'; import { mockConfig } from '@grafana/faro-core/src/testUtils'; -import { - MESSAGE_TYPE_DOM_MUTATION, - MESSAGE_TYPE_HTTP_REQUEST_END, - MESSAGE_TYPE_HTTP_REQUEST_START, -} from './const'; +import { MESSAGE_TYPE_DOM_MUTATION, MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from './const'; let http$: Observable; let dom$: Observable; diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index eb41126ee..17b25680c 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -107,12 +107,16 @@ export class UserActionController { private startHaltTimeout() { this.clearTimer(this.haltTid); - this.haltTid = startTimeout(this.haltTid, () => { - // If still halted after timeout, end - if (this.ua.getState() === UserActionState.Halted) { - this.endAction(); - } - }, defaultHaltTimeout) as any; + this.haltTid = startTimeout( + this.haltTid, + () => { + // If still halted after timeout, end + if (this.ua.getState() === UserActionState.Halted) { + this.endAction(); + } + }, + defaultHaltTimeout + ) as any; } private endAction() { From b3b2a1d7c31ce234040462503ddfe84ee9ef2289 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 09:32:01 -0700 Subject: [PATCH 09/16] removed circular dependency --- .../processUserActionEventHandler.test.ts | 14 -------------- .../userActions/processUserActionEventHandler.ts | 11 ----------- .../userActions/userActionController.ts | 3 +-- .../src/instrumentations/userActions/util.test.ts | 15 ++++++++++++++- .../src/instrumentations/userActions/util.ts | 11 +++++++++++ 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts index 28659b592..2c70b67a2 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts @@ -11,8 +11,6 @@ import { import { getUserActionNameFromElement, getUserEventHandler, - isRequestEndMessage, - isRequestStartMessage, unsubscribeAllMonitors, } from './processUserActionEventHandler'; @@ -58,18 +56,6 @@ describe('Utility functions', () => { expect(result).toBeUndefined(); }); - it('isRequestStartMessage type guard', () => { - const msg = { type: MESSAGE_TYPE_HTTP_REQUEST_START }; - expect(isRequestStartMessage(msg)).toBe(true); - expect(isRequestEndMessage(msg)).toBe(false); - }); - - it('isRequestEndMessage type guard', () => { - const msg = { type: MESSAGE_TYPE_HTTP_REQUEST_END }; - expect(isRequestEndMessage(msg)).toBe(true); - expect(isRequestStartMessage(msg)).toBe(false); - }); - it('unsubscribeAllMonitors calls unsubscribe on subscription', () => { const sub: Subscription = { unsubscribe: jest.fn() }; unsubscribeAllMonitors(sub); diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts index fdcda7d26..cf993902a 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts @@ -1,11 +1,8 @@ import type { Faro, Subscription, UserActionInterface } from '@grafana/faro-core'; import { - MESSAGE_TYPE_HTTP_REQUEST_END, - MESSAGE_TYPE_HTTP_REQUEST_START, userActionDataAttributeParsed as userActionDataAttribute, } from './const'; -import type { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; import { UserActionController } from './userActionController'; import { convertDataAttributeName } from './util'; @@ -53,11 +50,3 @@ export function unsubscribeAllMonitors(allMonitorsSub: Subscription | undefined) allMonitorsSub?.unsubscribe(); allMonitorsSub = undefined; } - -export function isRequestStartMessage(msg: any): msg is HttpRequestStartMessage { - return msg.type === MESSAGE_TYPE_HTTP_REQUEST_START; -} - -export function isRequestEndMessage(msg: any): msg is HttpRequestEndMessage { - return msg.type === MESSAGE_TYPE_HTTP_REQUEST_END; -} diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index 17b25680c..c124a657b 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -5,9 +5,8 @@ import type { Subscription, UserActionInterface } from '@grafana/faro-core'; import { monitorDomMutations } from './domMutationMonitor'; import { monitorHttpRequests } from './httpRequestMonitor'; import { monitorPerformanceEntries } from './performanceEntriesMonitor'; -import { isRequestEndMessage, isRequestStartMessage } from './processUserActionEventHandler'; import type { HttpRequestMessagePayload } from './types'; -import { startTimeout } from './util'; +import { isRequestEndMessage, isRequestStartMessage, startTimeout } from './util'; const defaultFollowUpActionTimeRange = 100; const defaultHaltTimeout = 10 * 1000; diff --git a/packages/web-sdk/src/instrumentations/userActions/util.test.ts b/packages/web-sdk/src/instrumentations/userActions/util.test.ts index 99bb413d1..287572a0e 100644 --- a/packages/web-sdk/src/instrumentations/userActions/util.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/util.test.ts @@ -1,7 +1,20 @@ -import { convertDataAttributeName } from './util'; +import { MESSAGE_TYPE_HTTP_REQUEST_START, MESSAGE_TYPE_HTTP_REQUEST_END } from './const'; +import { convertDataAttributeName, isRequestStartMessage, isRequestEndMessage } from './util'; describe('util', () => { it('converts data attribute to camelCase and remove the "data-" prefix', () => { expect(convertDataAttributeName('data-test-action-name')).toBe('testActionName'); }); + + it('isRequestStartMessage type guard', () => { + const msg = { type: MESSAGE_TYPE_HTTP_REQUEST_START }; + expect(isRequestStartMessage(msg)).toBe(true); + expect(isRequestEndMessage(msg)).toBe(false); + }); + + it('isRequestEndMessage type guard', () => { + const msg = { type: MESSAGE_TYPE_HTTP_REQUEST_END }; + expect(isRequestEndMessage(msg)).toBe(true); + expect(isRequestStartMessage(msg)).toBe(false); + }); }); diff --git a/packages/web-sdk/src/instrumentations/userActions/util.ts b/packages/web-sdk/src/instrumentations/userActions/util.ts index 9ab68fbe3..956454c4d 100644 --- a/packages/web-sdk/src/instrumentations/userActions/util.ts +++ b/packages/web-sdk/src/instrumentations/userActions/util.ts @@ -1,3 +1,6 @@ +import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from "./const"; +import { HttpRequestEndMessage, HttpRequestStartMessage } from "./types"; + /** * Parses the action attribute name by removing the 'data-' prefix and converting * the remaining string to camelCase. @@ -23,3 +26,11 @@ export function startTimeout(timeoutId: number | undefined, cb: () => void, dela return timeoutId; } + +export function isRequestStartMessage(msg: any): msg is HttpRequestStartMessage { + return msg.type === MESSAGE_TYPE_HTTP_REQUEST_START; +} + +export function isRequestEndMessage(msg: any): msg is HttpRequestEndMessage { + return msg.type === MESSAGE_TYPE_HTTP_REQUEST_END; +} From 4f9f90b21856b01d1344c01319484a50cc581dbd Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 09:55:35 -0700 Subject: [PATCH 10/16] moved tests --- .../processUserActionEventHandler.test.ts | 71 +---------- .../userActions/userActionController.test.ts | 110 ++++++++++++++++++ 2 files changed, 111 insertions(+), 70 deletions(-) create mode 100644 packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts index 2c70b67a2..c32926626 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts @@ -5,7 +5,6 @@ import type { Faro, Subscription } from '@grafana/faro-core'; import { userActionDataAttributeParsed as defaultDataAttribute, - MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START, } from './const'; import { @@ -13,6 +12,7 @@ import { getUserEventHandler, unsubscribeAllMonitors, } from './processUserActionEventHandler'; +// Test for UserActionController moved to userActionController.test.ts // Stub dummy Observable for monitors class DummyObservable { @@ -135,40 +135,6 @@ describe('getUserEventHandler', () => { expect(fakeAction.extend).not.toHaveBeenCalled(); }); - it('allows processing if there are running requests', () => { - getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); - - const element = document.createElement('div'); - element.dataset['fooBar'] = 'foo'; - const event = { type: 'keydown', target: element } as unknown as KeyboardEvent; - processUserEvent(event); - - httpObservable.notify({ - type: MESSAGE_TYPE_HTTP_REQUEST_START, - request: { - requestId: 'foo', - url: '/bar', - method: 'POST', - apiType: 'xhr', - }, - }); - - expect(fakeAction.extend).toHaveBeenCalled(); - fakeAction.getState.mockReturnValue(UserActionState.Halted); - - httpObservable.notify({ - type: MESSAGE_TYPE_HTTP_REQUEST_END, - request: { - requestId: 'foo', - url: '/bar', - method: 'POST', - apiType: 'xhr', - }, - }); - expect(fakeAction.extend).toHaveBeenCalled(); - }); - it('does not allow processing if there are no running requests', () => { getCurrentSpy.mockReturnValue(fakeAction); const { processUserEvent } = getUserEventHandler(faro as Faro); @@ -188,39 +154,4 @@ describe('getUserEventHandler', () => { }); expect(fakeAction.extend).not.toHaveBeenCalled(); }); - - it('does not allow processing if there are running requests but the request id is not pending', () => { - getCurrentSpy.mockReturnValue(fakeAction); - const { processUserEvent } = getUserEventHandler(faro as Faro); - - const element = document.createElement('div'); - element.dataset['fooBar'] = 'baz'; - const event = { type: 'keydown', target: element } as unknown as KeyboardEvent; - processUserEvent(event); - - httpObservable.notify({ - type: MESSAGE_TYPE_HTTP_REQUEST_START, - request: { - requestId: 'foo', // request id 1 - url: '/bar', - method: 'POST', - apiType: 'xhr', - }, - }); - - expect(fakeAction.extend).toHaveBeenCalled(); - (fakeAction.extend as jest.Mock).mockReset(); - fakeAction.getState.mockReturnValue(UserActionState.Halted); - - httpObservable.notify({ - type: MESSAGE_TYPE_HTTP_REQUEST_END, - request: { - requestId: 'bar', // request id 2 - url: '/bar', - method: 'POST', - apiType: 'xhr', - }, - }); - expect(fakeAction.extend).not.toHaveBeenCalled(); - }); }); diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts new file mode 100644 index 000000000..dd9149ea6 --- /dev/null +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts @@ -0,0 +1,110 @@ +import { jest } from '@jest/globals'; + +import { Observable, UserActionState } from '@grafana/faro-core'; + +import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from './const'; +import { UserActionController } from './userActionController'; + +let httpObservable = new Observable(); +jest.mock('./httpRequestMonitor', () => ({ monitorHttpRequests: () => httpObservable })); +jest.mock('./domMutationMonitor', () => ({ monitorDomMutations: () => new Observable() })); +jest.mock('./performanceEntriesMonitor', () => ({ monitorPerformanceEntries: () => new Observable() })); + +describe('UserActionController', () => { + let fakeAction: any; + + beforeEach(() => { + fakeAction = { + end: jest.fn(), + cancel: jest.fn(), + getState: jest.fn().mockReturnValue(UserActionState.Started), + // Provide Observable-like API used by controller for state cleanup + filter: jest.fn().mockReturnValue({ first: jest.fn().mockReturnValue({ subscribe: jest.fn() }) }), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + httpObservable = new Observable(); + }); + + it('allows processing if there are running requests', () => { + jest.useFakeTimers(); + + const controller = new UserActionController(fakeAction); + controller.attach(); + + httpObservable.notify({ + type: MESSAGE_TYPE_HTTP_REQUEST_START, + request: { + requestId: 'foo', + url: '/bar', + method: 'POST', + apiType: 'xhr', + }, + }); + + // Advance the follow-up timer so the controller transitions to halted state internally + jest.advanceTimersByTime(101); + + // While request is running, action should not be ended or cancelled + expect(fakeAction.end).not.toHaveBeenCalled(); + expect(fakeAction.cancel).not.toHaveBeenCalled(); + + // Finishing the pending request should end the action + httpObservable.notify({ + type: MESSAGE_TYPE_HTTP_REQUEST_END, + request: { + requestId: 'foo', + url: '/bar', + method: 'POST', + apiType: 'xhr', + }, + }); + + expect(fakeAction.end).toHaveBeenCalled(); + expect(fakeAction.cancel).not.toHaveBeenCalled(); + }); + + it('does not allow processing if there are running requests but the request id is not pending', () => { + jest.useFakeTimers(); + + const controller = new UserActionController(fakeAction); + controller.attach(); + + // Start a request with id 'foo' + httpObservable.notify({ + type: MESSAGE_TYPE_HTTP_REQUEST_START, + request: { + requestId: 'foo', + url: '/bar', + method: 'POST', + apiType: 'xhr', + }, + }); + + // Move past the follow-up window so the controller halts due to pending requests + jest.advanceTimersByTime(101); + + // Simulate UA being in halted state now + fakeAction.getState.mockReturnValue(UserActionState.Halted); + + // End a different request id; original 'foo' is still pending + httpObservable.notify({ + type: MESSAGE_TYPE_HTTP_REQUEST_END, + request: { + requestId: 'bar', + url: '/bar', + method: 'POST', + apiType: 'xhr', + }, + }); + + // Action should not end or cancel because the pending request hasn't finished + expect(fakeAction.end).not.toHaveBeenCalled(); + expect(fakeAction.cancel).not.toHaveBeenCalled(); + }); +}); + + From a005c78de1fd9b42a7cfa7d36472150682d6e961 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 09:58:02 -0700 Subject: [PATCH 11/16] lint --- .../userActions/processUserActionEventHandler.test.ts | 5 +---- .../userActions/processUserActionEventHandler.ts | 4 +--- .../userActions/userActionController.test.ts | 2 -- .../web-sdk/src/instrumentations/userActions/util.test.ts | 6 +++--- packages/web-sdk/src/instrumentations/userActions/util.ts | 4 ++-- 5 files changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts index c32926626..f448bcbda 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.test.ts @@ -3,10 +3,7 @@ import { jest } from '@jest/globals'; import { Observable, UserActionState } from '@grafana/faro-core'; import type { Faro, Subscription } from '@grafana/faro-core'; -import { - userActionDataAttributeParsed as defaultDataAttribute, - MESSAGE_TYPE_HTTP_REQUEST_START, -} from './const'; +import { userActionDataAttributeParsed as defaultDataAttribute, MESSAGE_TYPE_HTTP_REQUEST_START } from './const'; import { getUserActionNameFromElement, getUserEventHandler, diff --git a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts index cf993902a..b3c372764 100644 --- a/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts +++ b/packages/web-sdk/src/instrumentations/userActions/processUserActionEventHandler.ts @@ -1,8 +1,6 @@ import type { Faro, Subscription, UserActionInterface } from '@grafana/faro-core'; -import { - userActionDataAttributeParsed as userActionDataAttribute, -} from './const'; +import { userActionDataAttributeParsed as userActionDataAttribute } from './const'; import { UserActionController } from './userActionController'; import { convertDataAttributeName } from './util'; diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts index dd9149ea6..159bce3ab 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.test.ts @@ -106,5 +106,3 @@ describe('UserActionController', () => { expect(fakeAction.cancel).not.toHaveBeenCalled(); }); }); - - diff --git a/packages/web-sdk/src/instrumentations/userActions/util.test.ts b/packages/web-sdk/src/instrumentations/userActions/util.test.ts index 287572a0e..97552c9ca 100644 --- a/packages/web-sdk/src/instrumentations/userActions/util.test.ts +++ b/packages/web-sdk/src/instrumentations/userActions/util.test.ts @@ -1,5 +1,5 @@ -import { MESSAGE_TYPE_HTTP_REQUEST_START, MESSAGE_TYPE_HTTP_REQUEST_END } from './const'; -import { convertDataAttributeName, isRequestStartMessage, isRequestEndMessage } from './util'; +import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from './const'; +import { convertDataAttributeName, isRequestEndMessage, isRequestStartMessage } from './util'; describe('util', () => { it('converts data attribute to camelCase and remove the "data-" prefix', () => { @@ -11,7 +11,7 @@ describe('util', () => { expect(isRequestStartMessage(msg)).toBe(true); expect(isRequestEndMessage(msg)).toBe(false); }); - + it('isRequestEndMessage type guard', () => { const msg = { type: MESSAGE_TYPE_HTTP_REQUEST_END }; expect(isRequestEndMessage(msg)).toBe(true); diff --git a/packages/web-sdk/src/instrumentations/userActions/util.ts b/packages/web-sdk/src/instrumentations/userActions/util.ts index 956454c4d..879204e18 100644 --- a/packages/web-sdk/src/instrumentations/userActions/util.ts +++ b/packages/web-sdk/src/instrumentations/userActions/util.ts @@ -1,5 +1,5 @@ -import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from "./const"; -import { HttpRequestEndMessage, HttpRequestStartMessage } from "./types"; +import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from './const'; +import { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; /** * Parses the action attribute name by removing the 'data-' prefix and converting From cc804fd8bae580c92797434d1dbe9855e389d6d0 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 10:16:49 -0700 Subject: [PATCH 12/16] fix build --- packages/web-sdk/src/instrumentations/userActions/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/util.ts b/packages/web-sdk/src/instrumentations/userActions/util.ts index 879204e18..6e09292ef 100644 --- a/packages/web-sdk/src/instrumentations/userActions/util.ts +++ b/packages/web-sdk/src/instrumentations/userActions/util.ts @@ -1,5 +1,5 @@ import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from './const'; -import { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; +import type { HttpRequestEndMessage, HttpRequestStartMessage } from './types'; /** * Parses the action attribute name by removing the 'data-' prefix and converting From cc7d0a17223a0cde080de77c34b37d8552ef1f44 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 17:08:05 -0700 Subject: [PATCH 13/16] draft of reusable component for tracking related events --- packages/web-sdk/src/utils/eventsTracker.ts | 114 ++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/web-sdk/src/utils/eventsTracker.ts diff --git a/packages/web-sdk/src/utils/eventsTracker.ts b/packages/web-sdk/src/utils/eventsTracker.ts new file mode 100644 index 000000000..9bb719928 --- /dev/null +++ b/packages/web-sdk/src/utils/eventsTracker.ts @@ -0,0 +1,114 @@ +import { Observable } from '@grafana/faro-core'; + +import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from '../instrumentations/userActions/const'; +import type { + HttpRequestEndMessage, + HttpRequestMessagePayload, + HttpRequestStartMessage, +} from '../instrumentations/userActions/types'; + +export default class EventsTracker extends Observable { + eventsObservable: Observable; + + private _tracking: boolean = false; + private _timeoutId?: number; + private _currentEvents?: Array; + private _runningRequests?: Map; + private _startTime?: number; + private _lastEventTime?: number; + + constructor(eventsObservable: Observable) { + super(); + this.eventsObservable = eventsObservable; + this._initialize(); + } + + _initialize() { + this.eventsObservable + .filter(() => { + return this._tracking; + }) + .subscribe((event) => { + this._lastEventTime = Date.now(); + this._currentEvents?.push(event); + + if (isRequestStartMessage(event)) { + this._runningRequests?.set(event.request.requestId, event.request); + } + + if (isRequestEndMessage(event)) { + this._runningRequests?.delete(event.request.requestId); + } + + if (!this.hasRunningRequests()) { + this._waitForEvents(); + } + }); + } + + startTracking(triggerEvent: any) { + if (this._tracking) { + return; + } + + this._tracking = true; + this._startTime = Date.now(); + this._lastEventTime = Date.now(); + + this.notify({ + message: 'tracking-started', + trigger: triggerEvent, + }); + + this._currentEvents = []; + this._runningRequests = new Map(); + this._waitForEvents(); + } + + stopTracking() { + this._tracking = false; + + this.notify({ + message: 'tracking-ended', + events: this._currentEvents, + duration: this._lastEventTime ? this._lastEventTime - this._startTime! : 0, + }); + } + + _waitForEvents() { + this._timeoutId = startTimeout( + this._timeoutId, + () => { + if (!this.hasRunningRequests()) { + this.stopTracking(); + } + }, + 100 + ); + } + + hasRunningRequests() { + return this._runningRequests && this._runningRequests?.size > 0; + } +} + +export function isRequestStartMessage(msg: any): msg is HttpRequestStartMessage { + return msg.type === MESSAGE_TYPE_HTTP_REQUEST_START; +} + +export function isRequestEndMessage(msg: any): msg is HttpRequestEndMessage { + return msg.type === MESSAGE_TYPE_HTTP_REQUEST_END; +} + +function startTimeout(timeoutId: number | undefined, cb: () => void, delay: number) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + //@ts-expect-error for some reason vscode is using the node types + timeoutId = setTimeout(() => { + cb(); + }, delay); + + return timeoutId; +} From 531cfb9021022a94c694543426f17bfc030dee40 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 21:19:43 -0700 Subject: [PATCH 14/16] extracted tracking into a separate module --- .../userActions/userActionController.ts | 126 ++++-------------- packages/web-sdk/src/utils/eventsTracker.ts | 94 ++++++++----- 2 files changed, 87 insertions(+), 133 deletions(-) diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index c124a657b..401d6ea71 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -5,8 +5,8 @@ import type { Subscription, UserActionInterface } from '@grafana/faro-core'; import { monitorDomMutations } from './domMutationMonitor'; import { monitorHttpRequests } from './httpRequestMonitor'; import { monitorPerformanceEntries } from './performanceEntriesMonitor'; -import type { HttpRequestMessagePayload } from './types'; -import { isRequestEndMessage, isRequestStartMessage, startTimeout } from './util'; +import { isRequestEndMessage, isRequestStartMessage } from './util'; +import ActivityWindowTracker from '../../utils/eventsTracker'; const defaultFollowUpActionTimeRange = 100; const defaultHaltTimeout = 10 * 1000; @@ -16,55 +16,33 @@ export class UserActionController { private readonly dom = monitorDomMutations(); private readonly perf = monitorPerformanceEntries(); - private allMonitorsSub?: Subscription; private stateSub?: Subscription; - private followUpTid?: number; - private haltTid?: number; - - private isValid = false; - private isHalted = false; - private runningRequests = new Map(); + private tracker?: ActivityWindowTracker; + private trackerSub?: Subscription; constructor(private ua: UserActionInterface) {} attach(): void { - // Subscribe to monitors while action is active/halting - this.allMonitorsSub = new Observable() - .merge(this.http, this.dom, this.perf) - .takeWhile(() => [UserActionState.Started, UserActionState.Halted].includes(this.ua.getState())) - .filter((msg) => { - // If the user action is in halt state, we only keep listening to ended http requests - if ( - this.ua.getState() === UserActionState.Halted && - !(isRequestEndMessage(msg) && this.runningRequests.has(msg.request.requestId)) - ) { - return false; - } - - return true; - }) - .subscribe((msg) => { - if (isRequestStartMessage(msg)) { - // An action is on halt if it has pending items, like pending HTTP requests. - // In this case we start a separate timeout to wait for the requests to finish - // If in the halt state, we stop adding Faro signals to the action's buffer (see userActionLifecycleHandler.ts) - // But we are still subscribed to - this.runningRequests.set(msg.request.requestId, msg.request); - } - - if (isRequestEndMessage(msg)) { - this.runningRequests.delete(msg.request.requestId); - } - - if (!isRequestEndMessage(msg)) { - if (!this.isValid) { - this.isValid = true; - } - this.scheduleFollowUp(); - } else if (this.isHalted && this.runningRequests.size === 0) { + const merged = new Observable().merge(this.http, this.dom, this.perf); + + this.tracker = new ActivityWindowTracker(merged, { + followUpMs: defaultFollowUpActionTimeRange, + haltMs: defaultHaltTimeout, + isBlockingStart: (msg) => (isRequestStartMessage(msg) ? msg.request.requestId : undefined), + isBlockingEnd: (msg) => (isRequestEndMessage(msg) ? msg.request.requestId : undefined), + }); + + this.trackerSub = (this.tracker as unknown as Observable).subscribe((evt: any) => { + if (evt?.message === 'tracking-ended') { + const events: any[] = Array.isArray(evt.events) ? evt.events : []; + const isValid = events.some((e) => !isRequestEndMessage(e)); + if (isValid) { this.endAction(); + } else { + this.cancelAction(); } - }); + } + }); // When UA ends or cancels, cleanup timers/subscriptions this.stateSub = (this.ua as unknown as Observable) @@ -72,53 +50,11 @@ export class UserActionController { .first() .subscribe(() => this.cleanup()); - // initial follow-up window in case nothing else happens - this.scheduleFollowUp(); - } - - private scheduleFollowUp() { - this.clearTimer(this.followUpTid); - this.followUpTid = setTimeout(() => { - // If action just started and there's pending work, go to halted - if (this.ua.getState() === UserActionState.Started && this.runningRequests.size > 0) { - this.haltAction(); - return; - } - - // If we saw any relevant activity in the window, finish as ended - if (this.isValid) { - this.endAction(); - return; - } - - // Otherwise, no signals => cancel - this.cancelAction(); - }, defaultFollowUpActionTimeRange) as any; - } - - private haltAction() { - if (this.ua.getState() !== UserActionState.Started) { - return; - } - this.isHalted = true; - this.startHaltTimeout(); - } - - private startHaltTimeout() { - this.clearTimer(this.haltTid); - this.haltTid = startTimeout( - this.haltTid, - () => { - // If still halted after timeout, end - if (this.ua.getState() === UserActionState.Halted) { - this.endAction(); - } - }, - defaultHaltTimeout - ) as any; + this.tracker.startTracking({ action: this.ua }); } private endAction() { + // console.trace(); this.ua.end(); this.cleanup(); } @@ -129,18 +65,10 @@ export class UserActionController { } private cleanup() { - this.clearTimer(this.followUpTid); - this.clearTimer(this.haltTid); - this.allMonitorsSub?.unsubscribe(); + this.trackerSub?.unsubscribe(); this.stateSub?.unsubscribe(); - this.allMonitorsSub = undefined; + this.tracker = undefined; + this.trackerSub = undefined; this.stateSub = undefined; - this.runningRequests.clear(); - } - - private clearTimer(id?: number) { - if (id) { - clearTimeout(id); - } } } diff --git a/packages/web-sdk/src/utils/eventsTracker.ts b/packages/web-sdk/src/utils/eventsTracker.ts index 9bb719928..9fabc09c8 100644 --- a/packages/web-sdk/src/utils/eventsTracker.ts +++ b/packages/web-sdk/src/utils/eventsTracker.ts @@ -1,29 +1,39 @@ import { Observable } from '@grafana/faro-core'; -import { MESSAGE_TYPE_HTTP_REQUEST_END, MESSAGE_TYPE_HTTP_REQUEST_START } from '../instrumentations/userActions/const'; -import type { - HttpRequestEndMessage, - HttpRequestMessagePayload, - HttpRequestStartMessage, -} from '../instrumentations/userActions/types'; - -export default class EventsTracker extends Observable { +type BlockingKey = string; + +export interface ActivityWindowTrackerOptions { + followUpMs?: number; + haltMs?: number; + isBlockingStart?: (msg: TMsg) => BlockingKey | undefined; + isBlockingEnd?: (msg: TMsg) => BlockingKey | undefined; +} + +export default class ActivityWindowTracker extends Observable { eventsObservable: Observable; private _tracking: boolean = false; - private _timeoutId?: number; - private _currentEvents?: Array; - private _runningRequests?: Map; + private _followUpTid?: number; + private _haltTid?: number; + private _currentEvents?: Array; + private _runningBlocking?: Map; private _startTime?: number; private _lastEventTime?: number; + private _options: Required; - constructor(eventsObservable: Observable) { + constructor(eventsObservable: Observable, options?: ActivityWindowTrackerOptions) { super(); this.eventsObservable = eventsObservable; + this._options = { + followUpMs: options?.followUpMs ?? 100, + haltMs: options?.haltMs ?? 10 * 1000, + isBlockingStart: options?.isBlockingStart ?? (() => undefined), + isBlockingEnd: options?.isBlockingEnd ?? (() => undefined), + } as Required; this._initialize(); } - _initialize() { + private _initialize() { this.eventsObservable .filter(() => { return this._tracking; @@ -32,16 +42,20 @@ export default class EventsTracker extends Observable { this._lastEventTime = Date.now(); this._currentEvents?.push(event); - if (isRequestStartMessage(event)) { - this._runningRequests?.set(event.request.requestId, event.request); + const startKey = this._options.isBlockingStart(event as any); + if (startKey) { + this._runningBlocking?.set(startKey, true); } - if (isRequestEndMessage(event)) { - this._runningRequests?.delete(event.request.requestId); + const endKey = this._options.isBlockingEnd(event as any); + if (endKey) { + this._runningBlocking?.delete(endKey); } - if (!this.hasRunningRequests()) { - this._waitForEvents(); + if (!endKey) { + this._scheduleFollowUp(); + } else if (!this.hasBlockingWork()) { + this.stopTracking(); } }); } @@ -61,12 +75,14 @@ export default class EventsTracker extends Observable { }); this._currentEvents = []; - this._runningRequests = new Map(); - this._waitForEvents(); + this._runningBlocking = new Map(); + this._scheduleFollowUp(); } stopTracking() { this._tracking = false; + this._clearTimer(this._followUpTid); + this._clearTimer(this._haltTid); this.notify({ message: 'tracking-ended', @@ -75,29 +91,39 @@ export default class EventsTracker extends Observable { }); } - _waitForEvents() { - this._timeoutId = startTimeout( - this._timeoutId, + private _scheduleFollowUp() { + this._followUpTid = startTimeout( + this._followUpTid, () => { - if (!this.hasRunningRequests()) { + if (this.hasBlockingWork()) { + this._startHaltTimeout(); + } else { this.stopTracking(); } }, - 100 + this._options.followUpMs ); } - hasRunningRequests() { - return this._runningRequests && this._runningRequests?.size > 0; + private _startHaltTimeout() { + this._haltTid = startTimeout( + this._haltTid, + () => { + this.stopTracking(); + }, + this._options.haltMs + ); } -} -export function isRequestStartMessage(msg: any): msg is HttpRequestStartMessage { - return msg.type === MESSAGE_TYPE_HTTP_REQUEST_START; -} + private hasBlockingWork(): boolean { + return !!this._runningBlocking && this._runningBlocking.size > 0; + } -export function isRequestEndMessage(msg: any): msg is HttpRequestEndMessage { - return msg.type === MESSAGE_TYPE_HTTP_REQUEST_END; + private _clearTimer(id?: number) { + if (id) { + clearTimeout(id); + } + } } function startTimeout(timeoutId: number | undefined, cb: () => void, delay: number) { From 92ebdb9312f58cb4663ee490381bfa829018a909 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 21:21:44 -0700 Subject: [PATCH 15/16] renamed file --- .../src/utils/{eventsTracker.ts => activityWindowTracker.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/web-sdk/src/utils/{eventsTracker.ts => activityWindowTracker.ts} (100%) diff --git a/packages/web-sdk/src/utils/eventsTracker.ts b/packages/web-sdk/src/utils/activityWindowTracker.ts similarity index 100% rename from packages/web-sdk/src/utils/eventsTracker.ts rename to packages/web-sdk/src/utils/activityWindowTracker.ts From a449fd312953a000abc5bb8cc2b60fd49f847052 Mon Sep 17 00:00:00 2001 From: Martin Kuba Date: Tue, 21 Oct 2025 21:29:52 -0700 Subject: [PATCH 16/16] moved activity tracker file --- .../_internal}/activityWindowTracker.ts | 0 .../src/instrumentations/userActions/userActionController.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/web-sdk/src/{utils => instrumentations/_internal}/activityWindowTracker.ts (100%) diff --git a/packages/web-sdk/src/utils/activityWindowTracker.ts b/packages/web-sdk/src/instrumentations/_internal/activityWindowTracker.ts similarity index 100% rename from packages/web-sdk/src/utils/activityWindowTracker.ts rename to packages/web-sdk/src/instrumentations/_internal/activityWindowTracker.ts diff --git a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts index 401d6ea71..dbfe1d9a7 100644 --- a/packages/web-sdk/src/instrumentations/userActions/userActionController.ts +++ b/packages/web-sdk/src/instrumentations/userActions/userActionController.ts @@ -6,7 +6,7 @@ import { monitorDomMutations } from './domMutationMonitor'; import { monitorHttpRequests } from './httpRequestMonitor'; import { monitorPerformanceEntries } from './performanceEntriesMonitor'; import { isRequestEndMessage, isRequestStartMessage } from './util'; -import ActivityWindowTracker from '../../utils/eventsTracker'; +import ActivityWindowTracker from '../_internal/activityWindowTracker'; const defaultFollowUpActionTimeRange = 100; const defaultHaltTimeout = 10 * 1000;